Web開發框架推導

本文欲回答這樣一個問題:在 「特定環境 」下,如何規劃Web開發框架,使其能知足 「指望 」?java

假設咱們的「特定環境 」以下:web

  • 技術層面spring

    • 使用Java語言進行開發
    • 經過Maven構建
    • 基於SpringBoot
    • 使用IntellijIDEA做爲IDE
    • 使用Mybatis做爲持久層框架
    • 先後端分離
  • 非技術層面數據庫

    • 新項目,變化較頻繁
    • 快速迭代
    • 開發人員資歷較淺
    • 人員流動性較大

咱們的 「指望 」是:後端

  • 快速上手:鑑於人員流動性較大、開發人員的資歷較淺和項目的快速迭代需求,指望開發框架易於開發人員開發。易於入門,易於部署。
  • 符合行業規約:儘可能不定義私有規範,使用行業標準,進一步下降學習難度
  • 快速開發:儘量複用代碼,儘量自動化生成模板代碼
  • 獨立性:應用能獨立運行,不過多的依賴其它應用或中間件。邊界清晰,有利於理解、開發、測試和部署。反例:就是沒有規劃的RPC調用。
  • 易於測試:能方便的進行單元/集成測試,不影響真實數據
  • 易於部署:能方便的進行部署,便於快速的擴容
  • 異常可追蹤:對異常,可快速定位到具體是哪一個應用,哪一個類,哪行代碼的問題

本文從一個空框架開始,逐步加入上面的約束,最終推導出符合指望的Web框架!
本文提供的是一種思路!若有紕漏、或不一樣意見,歡迎討論指正!緩存

從「空框架」開始

咱們從一個「空框架」開始咱們的框架推導!所謂「空框架」是一個沒有任何約束的接收HTTP的可運行代碼,好比對任何請求都只返回Hello World的servlet!
這裏咱們基於Maven和SpringBoot快速搭建一個「空框架」!服務器

代碼結構以下(Maven構建約束): 網絡

intellijweb2
    src/main
        java
            com.ivaneye.intellijweb2
                TestController
        resources
            application.properties
            logback-spring.xml

 
代碼以下:數據結構

package com.ivaneye.intellijweb2;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
@EnableAutoConfiguration
public class TestController {
 
    @RequestMapping("/")
    @ResponseBody
    public String home() {
        return "Hello World!";
    }
 
    public static void main(String[] args) throws Exception {
        SpringApplication.run(Main.class, args);
    }
}

 
啓動後,當訪問http://localhost:8080時,頁面上將顯示Hello world!字樣!架構

咱們徹底能夠基於這個「空框架」進行開發,可是這個「空框架」離咱們的指望還很遠。咱們來一步步的改造!

分層架構

分層架構能夠說是Web項目的默認架構風格,能夠說是行業標準!因此咱們首先引入分層架構這個約束!

分層架構有其優點和劣勢:

  • 優點:經過將組件對系統的知識限制在單一層內,爲整個系統的複雜性設置了邊界,而且提升了底層獨立性。使用層來封裝遺留的服務,使新的服務免受遺留客戶端的影響;經過將不經常使用的功能轉移到一個共享的中間組件中,從而簡化組件的實現。中間組件還可以經過支持跨多個網絡和處理器的負載均衡,來改善系統的可伸縮性。
  • 劣勢:增長了數據處理的開銷和延遲,所以下降了用戶可覺察的性能。能夠經過在中間層使用共享緩存來彌補這一缺點。

Web裏最經常使用的切分方式就是MVC模式!咱們對咱們的「空框架」引入MVC模式!
那咱們這裏是切分包?仍是切分模塊呢?考慮到最小影響原則,這裏先切分包。若是有後續約束,再作進一步調整。
 
 引入MVC模式後的代碼結構:

intellijweb2
    src/main
        java
            com.ivaneye.intellijweb2
                controller
                    TestController
                model
                respository
                service
                Main
        resources
            application.properties
            logback-spring.xml

 
引入MVC模式後的代碼:

package com.ivaneye.intellijweb2;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
 
@EnableAutoConfiguration
@ComponentScan({"com.ivaneye.intellijweb2"})
public class Main {
 
    public static void main(String[] args) throws Exception {
        SpringApplication.run(Main.class, args);
    }
}
 
 
package com.ivaneye.intellijweb2.controller;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
 
@Controller
public class TestController {
 
    @RequestMapping("/")
    @ResponseBody
    public String home() {
        return "Hello World!";
    }
}

 
這裏暫時切分了Controller,Service,Model,Respository四個包,職責以下:

  • Controller:接收前臺的請求,驗證數據,組裝須要的數據,委託Service執行具體業務邏輯,並將結果組裝返回給前臺
  • Service:處理核心業務邏輯,包含事務
  • Model:數據模型,與數據庫表的對應類
  • Respository:數據操做類包,操做Model中的類,進行基本的CRUD操做

 
分層後的框架邏輯清晰,且切分方式符合行業規約,更易於上手。

先後端分離

考慮到目前Web開發流行先後端分離,爲了適應潮流,引入先後端分離的約束。

爲了適應先後端分離,後端不負責頁面的渲染,只接收和返回JSON數據。SpringBoot對此有直接的支持,直接將@Controller改成@RestController便可!
 
相關代碼:

package com.ivaneye.intellijweb2.controller;
 
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class TestController {
 
    @RequestMapping("/")
    public String home() {
        return "Hello World!";
    }
}

 
整個URL符合RESTful,即符合行業規約!至於REST相關內容另行討論。

實際上完整的RESTful應用不僅是URL符合RESTful,須要符合四個核心的約束:

  • 資源的識別(identification of resources)
  • 經過表述操做資源(manipulation of resources through representations)
  • 自描述的消息(self-descriptive messages)
  • 超媒體做爲應用狀態引擎(hypermedia as the engine of application state)

絕大部分聲稱符合RESTful的應用都不是百分百符合這四個約束,特別是超媒體做爲應用狀態引擎(hypermedia as the engine of application state)這個約束。
 

基於註解的數據處理

肯定了以JSON的方式進行參數的傳遞後,就須要肯定如何來處理參數和返回結果?這涉及到幾個問題:

  • Controller如何接收參數?
  • Controller如何返回結果?
  • Controller如何將數據傳遞給Respository進行持久化處理?
  • Respository又如何將數據從數據庫中查出來返回給Controller?

這裏選擇了Mybatis做爲持久化框架,咱們先從Mybatis的角度來回答上面的幾個問題!

首先Mybatis做爲框架,會生成幾個文件:Model.java,Mapper.java和Mapper.xml!(這裏不作過多解釋!對Mybatis不熟悉的朋友請自行google!)這幾個文件能夠自動生成,也能夠手寫!

不管是自動生成仍是手寫都有其優缺點:

  • 先說自動生成的優缺點

    • 優勢就是在修改表結構之後,直接一條命令就能夠自動生成新文件。
    • 缺點就是這三個文件不能修改,若是修改了就不能再次自動生成了,不然會被覆蓋。
  • 手動編寫的優缺點

    • 優勢是徹底自主控制,可複用Model,在裏面添加註解,實現數據驗證、主鍵加解密、字典自動查詢等邏輯。
    • 缺點就是表結構調整後,須要手動修改須要調整的文件。一是繁瑣,二是沒有編譯期校驗,若是手誤寫錯了,直到運行期纔可能發現

一種優化方案是,第一次使用自動生成,後續手動修改。

可是結合前面的約束:

  • 新項目,變化較頻繁
  • 快速迭代
  • 開發人員資歷較淺

此方法並不適用。 此方法只對於改動不太頻繁的項目還算適用,可是若是表結構改動較頻繁,後續的每次修改仍是要手動修改,很是的麻煩(沒法適應頻繁的變動,快速迭代)。且只能第一次使用自動生成這個規定並無法強制實施,你無法保證誰不會誤操做了自動生成(考慮開發人員資歷較淺),致使手寫的代碼被覆蓋了!

結合以上約束,爲了儘可能避免錯誤,優先選擇自動生成!再來嘗試解決其短板,即生成的三個文件沒法進行修改。是否有可行方案呢?

咱們先考慮幾個問題:

  1. Controller須要對頁面傳過來的參數作哪些操做
  2. 頁面傳來的參數和Model是一個什麼關係
  3. 從Controller返回給頁面的數據又和Model是什麼關係
  4. Controller對返回給頁面的數據又要作哪些操做

爲方便起見,咱們把入參稱爲Param,返回結果稱爲Result。咱們先回答第一個和第四個問題!

  • Controller須要對Param作哪些操做

    • 把從頁面傳遞過來的flat數據transform爲對象(這是面嚮對象語言的一種典型作法,我目前更偏向函數式作法,另開一篇討論)
    • 對數據作校驗:類型對不對、格式對不對、是否爲空等等等等
    • 解密:有些字段數據多是加過密的,好比主鍵,在transform的過程當中須要對這些字段進行解密處理
  • Controller須要對Result作哪些操做

    • 加密:對須要加密的字段進行加密操做,好比主鍵
    • 字典轉換:有些字段是code碼,頁面須要code碼對應的值,方便人類閱讀。這裏須要根據這些code碼從字典中獲取對應的值(你能夠在數據庫查詢的時候,直接關聯字典表查詢,可是這樣會帶來兩個麻煩,一個是model中須要包含字典value字段,就無法自動生成了。第二個就是,通常字典會放在內存中,關聯表查詢相對內存取數據,性能上會有劣勢)
    • 字典列表:和字典轉換相似,有些頁面須要字典列表數據,須要獲取這些數據到前臺供用戶選擇

這些操做均可以方便的處理:

  • SpringMVC已經提供了數據綁定功能,將數據綁定到對象上
  • JSR303基於註解進行校驗
  • 加解密、字典均可以經過自定義註解處理(擴展Jackson的註解處理便可。Jackson的註解只在方法上生效,本覺得是個問題,卻助我構思了一個方案:一個結合了自動生成的方便性和手寫的靈活性的方案!!!!)

這些都是規約!

針對第二個和第三個問題,咱們先看Param、Result和Model之間的關係:
image1.png | center | 800x488
從上圖能夠看出,除了第一種狀況(且這種狀況不多),其它四種狀況Param和Model實際是一個包含的關係。既然是一種包含的狀況,那這種包含關係,在Java裏咱們可使用繼承來實現。也就是說可使Param extends Model,以這樣的方式來複用Model的內容!
咱們來看以這種方式來實現Param和Result,如何來解決上面的問題!

  • 首先,由於Param和Result都繼承了Model,因此Model是不須要作任何改動的,就能夠無限次的自動生成
  • 其次,數據驗證、加解密的註解是能夠添加到方法上的。咱們對須要這些註解的字段,在Param/Result裏覆蓋Model裏的get/set方法,在其上添加註解,就可使用基於註解的數據驗證和加解密
  • 假設數據字段有了修改,從新生成後,因爲有@Override註解,在編譯期就能夠定位到須要修改的get/set方法,結合IDE能夠快速修復
  • 若是是新增字段,則直接從新生成Mybatis的三個文件便可,原有代碼不受任何影響

 
儘可能以擴展規約的方式來處理問題,在不增長理解難度的狀況下提升易用性和開發效率!

數據返回

在RESTful約束中,推薦使用HTTP的標準響應來處理返回數據。SpringMVC中也提供了標準響應的支持。

ResponseEntity.ok("body");
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("");

可是因爲HTTP的標準狀態碼太少了,見下表:

代碼 消息 描述
100 Continue 只有請求的一部分已經被服務器接收,但只要它沒有被拒絕,客戶端應繼續該請求。
101 Switching Protocols 服務器切換協議。
200 OK 請求成功。
201 Created 該請求是完整的,並建立一個新的資源。
202 Accepted 該請求被接受處理,可是該處理是不完整的。
203 Non-authoritative Information  
204 No Content  
205 Reset Content  
206 Partial Content  
300 Multiple Choices 連接列表。用戶能夠選擇一個連接,進入到該位置。最多五個地址
301 Moved Permanently 所請求的頁面已經轉移到一個新的 URL。
302 Found 所請求的頁面已經臨時轉移到一個新的 URL。
303 See Other 所請求的頁面能夠在另外一個不一樣的 URL 下被找到。
304 Not Modified  
305 Use Proxy  
306 Unused 在之前的版本中使用該代碼。如今已再也不使用它,但代碼仍被保留。
307 Temporary Redirect 所請求的頁面已經臨時轉移到一個新的 URL。
400 Bad Request 服務器不理解請求。
401 Unauthorized 所請求的頁面須要用戶名和密碼。
402 Payment Required 你還不能使用該代碼。
403 Forbidden 禁止訪問所請求的頁面。
404 Not Found 服務器沒法找到所請求的頁面。
405 Method Not Allowed 在請求中指定的方法是不容許的。
406 Not Acceptable 服務器只生成一個不被客戶端接受的響應。
407 Proxy Authentication Required 在請求送達以前,您必須使用代理服務器的驗證。
408 Request Timeout 請求須要的時間比服務器可以等待的時間長,超時。
409 Conflict 請求由於衝突沒法完成。
410 Gone 所請求的頁面再也不可用。
411 Length Required "Content-Length" 未定義。服務器沒法處理客戶端發送的不帶 Content-Length 的請求信息。
412 Precondition Failed 請求中給出的先決條件被服務器評估爲 false。
413 Request Entity Too Large 服務器不接受該請求,由於請求實體過大。
414 Request-url Too Long 服務器不接受該請求,由於 URL 太長。當你轉換一個 「post」 請求爲一個帶有長的查詢信息的 「get」 請求時發生。
415 Unsupported Media Type 服務器不接受該請求,由於媒體類型不被支持。
417 Expectation Failed  
500 Internal Server Error 未完成的請求。服務器遇到了一個意外的狀況。
501 Not Implemented 未完成的請求。服務器不支持所需的功能。
502 Bad Gateway 未完成的請求。服務器從上游服務器收到無效響應。
503 Service Unavailable 未完成的請求。服務器暫時超載或死機。
504 Gateway Timeout 網關超時。
505 HTTP Version Not Supported 服務器不支持「HTTP協議」版本。

這些標準的狀態碼沒法詳細的表示一個項目中的全部狀況。且目前SpringMVC不支持自定義狀態碼。就是相似這樣的代碼:

ResponseEntity.status(10001).body("");

雖然不報錯,可是沒法正常響應,後臺會報相似「非標準狀態碼」的錯誤!
因此我自定義了一個對象Result,用來完成相似ResponseEntity的工做。Result的結構以下:

public class Result {
    private int code;//200爲正常,其它爲相關業務報錯
    private String msg;//對應的錯誤信息,200爲ok
    private Object body;//返回的業務對象
}

提供相似:

Result.ok("body")
Result.error(e);
Result.error(CommonConstants.SERVER_ERROR, e.getMessage());

這樣的構造方法,方便使用。
 

異常處理

異常處理在上面數據返回裏涉及了一點(就是Result的構造以及業務的各類場景處理)。這裏詳細說明。
約束中須要能方便的追蹤異常!
Java裏提供了CheckedException和UnCheckedException,而對於咱們實際使用來講,仍是須要區分業務場景。

  • 異常是業務異常仍是非業務異常?

    • 這裏的業務異常指的是:因爲不符合業務需求而致使的異常,好比:用戶沒登陸,必要字段沒填寫致使校驗失敗,訂單的數量超出了庫存。
    • 非業務異常則指的是:和業務場景不相關的異常。例如:數據庫鏈接失敗了,網絡鏈接失敗。

表現到代碼上,對於業務異常咱們能夠定義BusinessException來表示,全部繼承了BusinessException的異常,都是業務異常,而其它異常就是非業務異常。

  • 更進一步,業務異常也能夠分爲:

    • 通用業務異常,例如:用戶沒有登陸,必要字段沒填寫致使校驗失敗;
    • 和特定業務異常,例如:訂單的數量超出庫存了。

這兩種異常,咱們能夠經過異常碼來區分,例如:100開頭的爲通用業務異常,300開頭的爲訂單異常,400開頭的爲產品異常,依此類推。
同時異常的Code和Msg與Result對應,方便構建Result.error(e);直接返回。
再進一步,目前的應用都是分佈式的,甚至是微服務架構!咱們是否能夠經過異常能快速的定位到是哪一個應用的哪一個模塊裏的哪一個代碼出問題了呢?
一種可行方案仍是經過異常碼來處理:以三位數字爲間隔,來區分應用+模塊+代碼,例如:001002301,能夠理解爲異常是001機器上的,002應用,拋出的301(訂單相關)異常。

獨立性

當系統變得愈來愈大後,不免不會出現系統內不一樣應用之間的相互調用;若是是微服務的話,那麼服務間的相互調用是很常見的。若是處理不當,會使得各應用之間相互依賴,沒法獨立的運行。致使開發、測試、部署都很麻煩。
爲了不這樣的問題出現,結合以下兩個約束:

  • 符合行業規約
  • 獨立性

故使用RESTful方式,做爲應用間通訊的方式。這也是微服務推薦的通訊方式!
應用間調用會出現Model的依賴,故這裏將Model從包提高爲模塊。方便後續若是有其它應用要依賴時,可直接依賴Model模塊,而不是整個應用。

調整後代碼結構以下:

intellijweb2
    intellijweb2-web
        src/main
            java
                com.ivaneye.intellijweb2
                    controller
                        TestController
                    respository
                    service
                    Main
            resources
                application.properties
                logback-spring.xml
    intellijweb2-model
        src/main
                java
                    com.ivaneye.intellijweb2
                        model
                        param
                        result

將model包移動到了intellijweb2-model模塊中,同時新增了param和result包!

測試

SpringBoot自己提供了較爲完善的測試功能。包括單元測試、Mocker、Spy等。
基於以下幾個考慮:

  • 易於測試:我接觸的不少開發人員是不喜歡寫測試的。若是測試代碼不易編寫,那就更不肯意寫了。
  • 不影響環境:我指望的是在發佈時是包含測試的,測試不經過即不能發佈。也就是說在部署時測試,會使用正式環境的庫表數據,因此在測試時不能影響到這些數據。
  • 小範圍測試:以最少的代碼,覆蓋最核心的代碼邏輯

故決定只對Service測試,緣由以下:

  • 在上面的分層架構裏描述了各層的職責,能夠看出,核心業務都在Service層,Controller和Model都沒有業務邏輯,只是一些標準化代碼,不必測試
  • SpringBoot對Controller的測試是在不一樣的線程內,不支持事務,若是在正式環境測試的話,會影響正式庫數據

部署

SpringBoot能夠直接打包爲jar包,直接運行啓動。這很方便,可是若是想快速的橫向擴容,配置文件就是一個問題。由於不一樣機器上的配置並非徹底相同的。
有兩個方案能夠解決:

  • Docker
  • 配置服務器

從便利性考慮,仍是選擇配置服務器。
配置文件中均是開發環境配置,方便開發人員直接開發、測試。
在正式環境中,應用啓動時會從配置服務器獲取對應的配置,覆蓋本地測試進行部署。

代碼生成OR封裝

在結束以前,先問個問題?你是喜歡代碼生成、仍是封裝?

  • 代碼生成就相似Mybatis這樣生成了對應的文件,邏輯透明。你能夠去改
  • 封裝就相似Hibernate,你寫個對象,而後對對象操做就好了,底層數據庫操做由Hibernate來處理

我我的更偏向代碼生成,理由是:

  • 簡單:易於使用,易於上手
  • 行業標準:生成的代碼是行業標準代碼,只要熟悉Mybatis,Spring就能夠直接上手(而Mybatis和Spring目前是互聯網標配)。若是公司內部進行一些封裝,那麼新手須要先理解這些封裝,增長了學習成本。

基於上面的緣由,再考慮到其實咱們的框架都是符合規約的(RESTful,JSR303,覆寫,Jackson),故對於標準CRUD,咱們能夠一鍵生成!

一鍵生成

其實到上面一節,整個框架應該已經符合預期了!可是爲了獲得超預期的效果,咱們來更進一步!

咱們先看目前的開發流程:

  • 設計數據表
  • 生成Model,Mapper
  • 編寫Param,Result
  • 編寫Respository
  • 編寫Service
  • 編寫Controller
  • 編寫測試
  • 執行測試
  • 提交代碼

對於一個典型的CRUD操做,這裏有多少重複代碼呢?
篇幅有限,舉個簡單的例子:如今須要編寫Order和User的新增邏輯,Controller的代碼是什麼樣的?

Controller:

package ${package.Controller};

import ...

@Api(tags = "${table.controllerName}")
@RestController
@RequestMapping("$!{cfg.basePath}")
public class ${table.controllerName} extends ${superControllerClass}{

    @Autowired
    private ${table.serviceImplName} ${instanceName}Service;

    private Logger logger = LoggerFactory.getLogger(${table.controllerName}.class);

    @ApiOperation(value = "建立${entity}")
    @RequestMapping(value = "/$!{cfg.version}/${table.entityPath}", method = RequestMethod.POST)
    public Result create(@RequestBody @Validated(Create.class) ${entity}Param param, BindingResult bindingResult) {
        try {
            //驗證失敗
            if (bindingResult.hasErrors()) {
                throw new ValidException(bindingResult.getFieldError().getDefaultMessage());
            }
            Long recId = ${instanceName}Service.create(param);
            return Result.ok(recId);
        } catch (BusinessException e) {
            logger.error("create ${entity} Error!", e);
            return Result.error(e);
        } catch (Exception e) {
            logger.error("create ${entity} Error!", e);
            return Result.error(CommonConstants.SERVER_ERROR, e.getMessage());
        }
    }
}

如上的模板是否能符合OrderController和UserController?再日後看Service,Param,Result等是否均可以用相似的模板來統一處理?
因此,咱們徹底能夠對相應的代碼進行自動生成,儘量的下降模板代碼的手動編寫。對於標準的CRUD邏輯,咱們能夠作到以下的開發流程:

  • 設計數據表
  • 生成CRUD,包括測試(咱們測試的是Service,想一想測試代碼和Controller代碼有多少區別?)
  • 執行測試
  • 提交代碼

對於不可重複生成的文件,咱們能夠設置"存在即不覆蓋",在最大限度的提升開發效率的前提下,下降誤操做。

總結

如上便是我基於約束所作的Web推導!目前的主要問題仍是在Model層面:

  • 數據表映射爲Model是不是合理的?
  • 基於Model的操做是否合適?
  • 基於上面Param、Result和Model的關係圖來看,實際上Param、Result和Model大部分狀況下都不是契合的!把這些Param、Result限制在Model上是否合適?數據結構是否清晰?

目前我的以爲基於data的transform、filter、map操做更適合web開發(我會另開一篇討論這個)!或者你有什麼好的方案,歡迎指教?


公衆號:ivaneye

相關文章
相關標籤/搜索