本文欲回答這樣一個問題:在 「特定環境 」下,如何規劃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,在裡面添加注解,實作資料驗證、主鍵加解密、字典自動查詢等邏輯。
- 缺點就是表結構調整後,需要手動修改需要調整的檔案。一是繁瑣,二是沒有編譯期校驗,如果手誤寫錯了,直到運作期才可能發現
一種優化方案是,第一次使用自動生成,後續手動修改。
但是結合前面的限制:
此方法并不适用。 此方法隻對于改動不太頻繁的項目還算适用,但是如果表結構改動較頻繁,後續的每次修改還是要手動修改,非常的麻煩(無法适應頻繁的變更,快速疊代)。且隻能第一次使用自動生成這個規定并沒法強制實施,你沒法保證誰不會誤操作了自動生成(考慮開發人員資曆較淺),導緻手寫的代碼被覆寫了!
結合以上限制,為了盡量避免錯誤,優先選擇自動生成!再來嘗試解決其短闆,即生成的三個檔案無法進行修改。是否有可行方案呢?
我們先考慮幾個問題:
- Controller需要對頁面傳過來的參數做哪些操作?
- 頁面傳來的參數和Model是一個什麼關系?
- 從Controller傳回給頁面的資料又和Model是什麼關系?
- Controller對傳回給頁面的資料又要做哪些操作?
為友善起見,我們把入參稱為Param,傳回結果稱為Result。我們先回答第一個和第四個問題!
-
Controller需要對Param做哪些操作?
- 把從頁面傳遞過來的flat資料transform為對象(這是面向對象語言的一種典型做法,我目前更偏向函數式做法,另開一篇讨論)
- 對資料做校驗:類型對不對、格式對不對、是否為空等等等等
- 解密:有些字段資料可能是加過密的,比如主鍵,在transform的過程中需要對這些字段進行解密處理
-
Controller需要對Result做哪些操作?
- 加密:對需要加密的字段進行加密操作,比如主鍵
- 字典轉換:有些字段是code碼,頁面需要code碼對應的值,友善人類閱讀。這裡需要根據這些code碼從字典中擷取對應的值(你可以在資料庫查詢的時候,直接關聯字典表查詢,但是這樣會帶來兩個麻煩,一個是model中需要包含字典value字段,就沒法自動生成了。第二個就是,一般字典會放在記憶體中,關聯表查詢相對記憶體取資料,性能上會有劣勢)
- 字典清單:和字典轉換類似,有些頁面需要字典清單資料,需要擷取這些資料到前台供使用者選擇
這些操作都可以友善的處理:
- SpringMVC已經提供了資料綁定功能,将資料綁定到對象上
- JSR303基于注解進行校驗
- 加解密、字典都可以通過自定義注解處理(擴充Jackson的注解處理即可。Jackson的注解隻在方法上生效,本以為是個問題,卻助我構思了一個方案:一個結合了自動生成的友善性和手寫的靈活性的方案!!!!)
這些都是規約!
針對第二個和第三個問題,我們先看Param、Result和Model之間的關系:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIn5GcuUjYjVjZ0U2MiZ2YhhTZhFTZ2YDO3gDN4EGZkJ2NzATOfdWbp9CXt92Yu4GZjlGbh5SZslmZxl3Lc9CX6MHc0RHaiojIsJye.png)
從上圖可以看出,除了第一種情況(且這種情況很少),其它四種情況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 | |
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