Go 語言 大型 Web 項目分層

2023-03-22 15:04 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-07-layout-of-web-project.html


5.7 layout 常見大型 Web 項目分層

流行的 Web 框架大多數(shù)是 MVC 框架,MVC 這個概念最早由 Trygve Reenskaug 在 1978 年提出,為了能夠?qū)?GUI 類型的應用進行方便擴展,將程序劃分為:

  1. 控制器(Controller)- 負責轉(zhuǎn)發(fā)請求,對請求進行處理。
  2. 視圖(View) - 界面設計人員進行圖形界面設計。
  3. 模型(Model) - 程序員編寫程序應有的功能(實現(xiàn)算法等等)、數(shù)據(jù)庫專家進行數(shù)據(jù)管理和數(shù)據(jù)庫設計(可以實現(xiàn)具體的功能)。

隨著時代的發(fā)展,前端也變成了越來越復雜的工程,為了更好地工程化,現(xiàn)在更為流行的一般是前后分離的架構(gòu)??梢哉J為前后分離是把 V 層從 MVC 中抽離單獨成為項目。這樣一個后端項目一般就只剩下 M 和 C 層了。前后端之間通過 ajax 來交互,有時候要解決跨域的問題,但也已經(jīng)有了較為成熟的方案。圖 5-13 是一個前后分離的系統(tǒng)的簡易交互圖。


圖 5-13 前后分離交互圖

圖里的 Vue 和 React 是現(xiàn)在前端界比較流行的兩個框架,因為我們的重點不在這里,所以前端項目內(nèi)的組織我們就不強調(diào)了。事實上,即使是簡單的項目,業(yè)界也并沒有完全遵守 MVC 框架提出者對于 M 和 C 所定義的分工。有很多公司的項目會在 Controller 層塞入大量的邏輯,在 Model 層就只管理數(shù)據(jù)的存儲。這往往來源于對于 model 層字面含義的某種擅自引申理解。認為字面意思,這一層就是處理某種建模,而模型是什么?就是數(shù)據(jù)唄!

這種理解顯然是有問題的,業(yè)務流程也算是一種 “模型”,是對真實世界用戶行為或者既有流程的一種建模,并非只有按格式組織的數(shù)據(jù)才能叫模型。不過按照 MVC 的創(chuàng)始人的想法,我們?nèi)绻押蛿?shù)據(jù)打交道的代碼還有業(yè)務流程全部塞進 MVC 里的 M 層的話,這個 M 層又會顯得有些過于臃腫。對于復雜的項目,一個 C 和一個 M 層顯然是不夠用的,現(xiàn)在比較流行的純后端 API 模塊一般采用下述劃分方法:

  1. Controller,與上述類似,服務入口,負責處理路由,參數(shù)校驗,請求轉(zhuǎn)發(fā)。
  2. Logic/Service,邏輯(服務)層,一般是業(yè)務邏輯的入口,可以認為從這里開始,所有的請求參數(shù)一定是合法的。業(yè)務邏輯和業(yè)務流程也都在這一層中。常見的設計中會將該層稱為 Business Rules。
  3. DAO/Repository,這一層主要負責和數(shù)據(jù)、存儲打交道。將下層存儲以更簡單的函數(shù)、接口形式暴露給 Logic 層來使用。負責數(shù)據(jù)的持久化工作。

每一層都會做好自己的工作,然后用請求當前的上下文構(gòu)造下一層工作所需要的結(jié)構(gòu)體或其它類型參數(shù),然后調(diào)用下一層的函數(shù)。在工作完成之后,再把處理結(jié)果一層層地傳出到入口,如 圖 5-14 所示。


圖 5-14 請求處理流程

劃分為 CLD 三層之后,在 C 層之前我們可能還需要同時支持多種協(xié)議。本章前面講到的 thrift、gRPC 和 http 并不是一定只選擇其中一種,有時我們需要支持其中的兩種,比如同一個接口,我們既需要效率較高的 thrift,也需要方便 debug 的 http 入口。即除了 CLD 之外,還需要一個單獨的 protocol 層,負責處理各種交互協(xié)議的細節(jié)。這樣請求的流程會變成 圖 5-15 所示。


圖 5-15 多協(xié)議示意圖

這樣我們 Controller 中的入口函數(shù)就變成了下面這樣:

func CreateOrder(ctx context.Context, req *CreateOrderStruct) (
    *CreateOrderRespStruct, error,
) {
    // ...
}

CreateOrder 有兩個參數(shù),ctx 用來傳入 trace_id 一類的需要串聯(lián)請求的全局參數(shù),req 里存儲了我們創(chuàng)建訂單所需要的所有輸入信息。返回結(jié)果是一個響應結(jié)構(gòu)體和錯誤??梢哉J為,我們的代碼運行到 Controller 層之后,就沒有任何與 “協(xié)議” 相關的代碼了。在這里你找不到 http.Request,也找不到 http.ResponseWriter,也找不到任何與 thrift 或者 gRPC 相關的字眼。

在協(xié)議 (Protocol) 層,處理 http 協(xié)議的大概代碼如下:

// defined in protocol layer
type CreateOrderRequest struct {
    OrderID int64 `json:"order_id"`
    // ...
}

// defined in controller
type CreateOrderParams struct {
    OrderID int64
}

func HTTPCreateOrderHandler(wr http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
    var params CreateOrderParams
    ctx := context.TODO()
    // bind data to req
    bind(r, &req)
    // map protocol binded to protocol-independent
    map(req, params)
    logicResp,err := controller.CreateOrder(ctx, &params)
    if err != nil {}
    // ...
}

理論上我們可以用同一個請求結(jié)構(gòu)體組合上不同的 tag,來達到一個結(jié)構(gòu)體來給不同的協(xié)議復用的目的。不過遺憾的是在 thrift 中,請求結(jié)構(gòu)體也是通過 IDL 生成的,其內(nèi)容在自動生成的 ttypes.go 文件中,我們還是需要在 thrift 的入口將這個自動生成的結(jié)構(gòu)體映射到我們 logic 入口所需要的結(jié)構(gòu)體上。gRPC 也是類似。這部分代碼還是需要的。

聰明的讀者可能已經(jīng)可以看出來了,協(xié)議細節(jié)處理這一層有大量重復勞動,每一個接口在協(xié)議這一層的處理,無非是把數(shù)據(jù)從協(xié)議特定的結(jié)構(gòu)體 (例如 http.Request,thrift 的被包裝過了) 讀出來,再綁定到我們協(xié)議無關的結(jié)構(gòu)體上,再把這個結(jié)構(gòu)體映射到 Controller 入口的結(jié)構(gòu)體上,這些代碼長得都差不多。差不多的代碼都遵循著某種模式,那么我們可以對這些模式進行簡單的抽象,用代碼生成的方式,把繁復的協(xié)議處理代碼從工作內(nèi)容中抽離出去。

先來看看 HTTP 對應的結(jié)構(gòu)體、thrift 對應的結(jié)構(gòu)體和我們協(xié)議無關的結(jié)構(gòu)體分別長什么樣子:

// http 請求結(jié)構(gòu)體
type CreateOrder struct {
    OrderID   int64  `json:"order_id" validate:"required"`
    UserID    int64  `json:"user_id" validate:"required"`
    ProductID int    `json:"prod_id" validate:"required"`
    Addr      string `json:"addr" validate:"required"`
}

// thrift 請求結(jié)構(gòu)體
type FeatureSetParams struct {
    DriverID  int64  `thrift:"driverID,1,required"`
    OrderID   int64  `thrift:"OrderID,2,required"`
    UserID    int64  `thrift:"UserID,3,required"`
    ProductID int    `thrift:"ProductID,4,required"`
    Addr      string `thrift:"Addr,5,required"`
}

// controller input struct
type CreateOrderParams struct {
    OrderID int64
    UserID int64
    ProductID int
    Addr string
}

我們需要通過一個源結(jié)構(gòu)體來生成我們需要的 HTTP 和 thrift 入口代碼。再觀察一下上面定義的三種結(jié)構(gòu)體,我們只要能用一個結(jié)構(gòu)體生成 thrift 的 IDL,以及 HTTP 服務的 “IDL(只要能包含 json 或 form 相關 tag 的結(jié)構(gòu)體定義信息)” 就可以了。這個初始的結(jié)構(gòu)體我們可以把結(jié)構(gòu)體上的 HTTP 的 tag 和 thrift 的 tag 揉在一起:

type FeatureSetParams struct {
    DriverID  int64  `thrift:"driverID,1,required" json:"driver_id"`
    OrderID   int64  `thrift:"OrderID,2,required" json:"order_id"`
    UserID    int64  `thrift:"UserID,3,required" json:"user_id"`
    ProductID int    `thrift:"ProductID,4,required" json:"prod_id"`
    Addr      string `thrift:"Addr,5,required" json:"addr"`
}

然后通過代碼生成把 thrift 的 IDL 和 HTTP 的請求結(jié)構(gòu)體都生成出來,如 圖 5-16 所示


圖 5-16 通過 Go 代碼定義結(jié)構(gòu)體生成項目入口

至于用什么手段來生成,你可以通過 Go 語言內(nèi)置的 Parser 讀取文本文件中的 Go 源代碼,然后根據(jù) AST 來生成目標代碼,也可以簡單地把這個源結(jié)構(gòu)體和 Generator 的代碼放在一起編譯,讓結(jié)構(gòu)體作為 Generator 的輸入?yún)?shù)(這樣會更簡單一些),都是可以的。

當然這種思路并不是唯一選擇,我們還可以通過解析 thrift 的 IDL,生成一套 HTTP 接口的結(jié)構(gòu)體。如果你選擇這么做,那整個流程就變成了 圖 5-17 所示。


圖 5-17 也可以從 thrift 生成其它部分

看起來比之前的圖順暢一點,不過如果你選擇了這么做,你需要自行對 thrift 的 IDL 進行解析,也就是相當于可能要手寫一個 thrift 的 IDL 的 Parser,雖然現(xiàn)在有 Antlr 或者 peg 能幫你簡化這些 Parser 的書寫工作,但在 “解析” 的這一步我們不希望引入太多的工作量,所以量力而行即可。

既然工作流已經(jīng)成型,我們可以琢磨一下怎么讓整個流程對用戶更加友好。

比如在前面的生成環(huán)境引入 Web 頁面,只要讓用戶點點鼠標就能生成 SDK,這些就靠讀者自己去探索了。

雖然我們成功地使自己的項目在入口支持了多種交互協(xié)議,但是還有一些問題沒有解決。本節(jié)中所敘述的分層沒有將中間件作為項目的分層考慮進去。如果我們考慮中間件的話,請求的流程是什么樣的?見 圖 5-18 所示。


圖 5-18 加入中間件后的控制流

之前我們學習的中間件是和 HTTP 協(xié)議強相關的,遺憾的是在 thrift 中看起來沒有和 HTTP 中對等的解決這些非功能性邏輯代碼重復問題的中間件。所以我們在圖上寫 thrift stuff。這些 stuff 可能需要你手寫去實現(xiàn),然后每次增加一個新的 thrift 接口,就需要去寫一遍這些非功能性代碼。

這也是很多企業(yè)項目所面臨的真實問題,遺憾的是開源界并沒有這樣方便的多協(xié)議中間件解決方案。當然了,前面我們也說過,很多時候我們給自己保留的 HTTP 接口只是用來做調(diào)試,并不會暴露給外人用。這種情況下,這些非功能性的代碼只要在 thrift 的代碼中完成即可。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號