Web開發框架推導

IvanEye發表於2018-03-06

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

假設我們的「特定環境 」如下:

  • 技術層面
    • 使用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

相關文章