Golang 简洁架构实战
作者:bearluo,騰訊 IEG 運營開發工程師
文中項目代碼位置:https://github.com/devYun/go-clean-architecture
由于 golang 不像 java 一樣有一個統一的編碼模式,所以我們和其他團隊一樣,采用了 Go 面向包的設計和架構分層這篇文章介紹的一些理論,然后再結合以往的項目經驗來進行分包:
├──?cmd/ │???└──?main.go?//啟動函數 ├──?etc │???└──?dev_conf.yaml??????????????//?配置文件 ├──?global │???└──?global.go?//全局變量引用,如數據庫、kafka等 ├──?internal/ │???????└──?service/ │???????????└──?xxx_service.go?//業務邏輯處理類 │???????????└──?xxx_service_test.go │???????└──?model/ │???????????└──?xxx_info.go//結構體 │???????└──?api/ │???????????└──?xxx_api.go//路由對應的接口實現 │???????└──?router/ │???????????└──?router.go//路由 │???????└──?pkg/ │???????????└──?datetool//時間工具類 │???????????└──?jsontool//json?工具類其實上面的這個劃分只是簡單的將功能分了一下包,在項目實踐的過程中還是有很多問題。比如:
對于功能實現我是通過 function 的參數傳遞還是通過結構體的變量傳遞?
使用一個數據庫的全局變量引用傳遞是否安全?是否存在過度耦合?
在代碼實現過程中幾乎全部都是依賴于實現,而不是依賴于接口,那么將 MySQL 切換為 MongDB 是不是要修改所有的實現?
所以現在在我們工作中隨著代碼越來越多,代碼中各種 init,function,struct,全局變量感覺也越來越亂。每個模塊不獨立,看似按邏輯分了模塊,但沒有明確的上下層關系,每個模塊里可能都存在配置讀取,外部服務調用,協議轉換等。久而久之服務不同包函數之間的調用慢慢演變成網狀結構,數據流的流向和邏輯的梳理變得越來越復雜,很難不看代碼調用的情況下搞清楚數據流向。
不過就像《重構》中所說:先讓代碼工作起來-如果代碼不能工作,就不能產生價值;然后再試圖將它變好-通過對代碼進行重構,讓我們自己和其他人更好地理解代碼,并能按照需求不斷地修改代碼。
所以我覺得是時候自我改變一下。
The Clean Architecture
在簡潔架構里面對我們的項目提出了幾點要求:
獨立于框架。該架構不依賴于某些功能豐富的軟件庫的存在。這允許你把這些框架作為工具來使用,而不是把你的系統塞進它們有限的約束中。
可測試。業務規則可以在沒有 UI、數據庫、Web 服務器或任何其他外部元素的情況下被測試。
獨立于用戶界面。UI 可以很容易地改變,而不用改變系統的其他部分。例如,一個 Web UI 可以被替換成一個控制臺 UI,而不改變業務規則。
獨立于數據庫。你可以把 Oracle 或 SQL Server 換成 Mongo、BigTable、CouchDB 或其他東西。你的業務規則不受數據庫的約束。
獨立于任何外部機構。事實上,你的業務規則根本不知道外部世界的任何情況。
上圖中同心圓代表各種不同領域的軟件。一般來說,越深入代表你的軟件層次越高。外圓是戰術實現機制,內圓的是戰略核心策略。對于我們的項目來說,代碼依賴應該由外向內,單向單層依賴,這種依賴包含代碼名稱,或類的函數,變量或任何其他命名軟件實體。
對于簡潔架構來說分為了四層:
Entities:實體
Usecase:表達應用業務規則,對應的是應用層,它封裝和實現系統的所有用例;
Interface Adapters:這一層的軟件基本都是一些適配器,主要用于將用例和實體中的數據轉換為外部系統如數據庫或 Web 使用的數據;
Framework & Driver:最外面一圈通常是由一些框架和工具組成,如數據庫 Database, Web 框架等;
那么對于我的項目來說,也分為了四層:
models
repo
service
api
models
封裝了各種實體類對象,與數據庫交互的、與 UI 交互的等等,任何的實體類都應該放在這里。如:
import?"time"type?Article?struct?{ID????????int64?????`json:"id"`Title?????string????`json:"title"`Content???string????`json:"content"`UpdatedAt?time.Time?`json:"updated_at"`CreatedAt?time.Time?`json:"created_at"` }repo
這里存放的是數據庫操作類,數據庫 CRUD 都在這里。需要注意的是,這里不包含任何的業務邏輯代碼,很多同學喜歡將業務邏輯也放到這里。
如果使用 ORM,那么這里放入的 ORM 操作相關的代碼;如果使用微服務,那么這里放的是其他服務請求的代碼;
service
這里是業務邏輯層,所有的業務過程處理代碼都應該放在這里。這一層會決定是請求 repo 層的什么代碼,是操作數據庫還是調用其他服務;所有的業務數據計算也應該放在這里;這里接受的入參應該是 controller 傳入的。
api
這里是接收外部請求的代碼,如:gin 對應的 handler、gRPC、其他 REST API 框架接入層等等。
面向接口編程
除了 models 層,層與層之間應該通過接口交互,而不是實現。如果要用 service 調用 repo 層,那么應該調用 repo 的接口。那么修改底層實現的時候我們上層的基類不需要變更,只需要更換一下底層實現即可。
例如我們想要將所有文章查詢出來,那么可以在 repo 提供這樣的接口:
package?repoimport?("context""my-clean-rchitecture/models""time" )//?IArticleRepo?represent?the?article's?repository?contract type?IArticleRepo?interface?{Fetch(ctx?context.Context,?createdDate?time.Time,?num?int)?(res?[]models.Article,?err?error) }這個接口的實現類就可以根據需求變更,比如說當我們想要 mysql 來作為存儲查詢,那么只需要提供一個這樣的基類:
type?mysqlArticleRepository?struct?{DB?*gorm.DB }//?NewMysqlArticleRepository?will?create?an?object?that?represent?the?article.Repository?interface func?NewMysqlArticleRepository(DB?*gorm.DB)?IArticleRepo?{return?&mysqlArticleRepository{DB} }func?(m?*mysqlArticleRepository)?Fetch(ctx?context.Context,?createdDate?time.Time,num?int)?(res?[]models.Article,?err?error)?{err?=?m.DB.WithContext(ctx).Model(&models.Article{}).Select("id,title,content,?updated_at,?created_at").Where("created_at?>??",?createdDate).Limit(num).Find(&res).Errorreturn }如果改天想要換成 MongoDB 來實現我們的存儲,那么只需要定義一個結構體實現 IArticleRepo 接口即可。
那么在 service 層實現的時候就可以按照我們的需求來將對應的 repo 實現注入即可,從而不需要改動 service 層的實現:
type?articleService?struct?{articleRepo?repo.IArticleRepo }//?NewArticleService?will?create?new?an?articleUsecase?object?representation?of?domain.ArticleUsecase?interface func?NewArticleService(a?repo.IArticleRepo)?IArticleService?{return?&articleService{articleRepo:?a,} }//?Fetch func?(a?*articleService)?Fetch(ctx?context.Context,?createdDate?time.Time,?num?int)?(res?[]models.Article,?err?error)?{if?num?==?0?{num?=?10}res,?err?=?a.articleRepo.Fetch(ctx,?createdDate,?num)if?err?!=?nil?{return?nil,?err}return }依賴注入 DI
依賴注入,英文名 dependency injection,簡稱 DI 。DI 以前在 java 工程里面經常遇到,但是在 go 里面很多人都說不需要,但是我覺得在大型軟件開發過程中還是有必要的,否則只能通過全局變量或者方法參數來進行傳遞。
至于具體什么是 DI,簡單來說就是被依賴的模塊,在創建模塊時,被注入到(即當作參數傳入)模塊的里面。想要更加深入的了解什么是 DI 這里再推薦一下 Dependency injection 和 Inversion of Control Containers and the Dependency Injection pattern 這兩篇文章。
如果不用 DI 主要有兩大不方便的地方,一個是底層類的修改需要修改上層類,在大型軟件開發過程中基類是很多的,一條鏈路改下來動輒要修改幾十個文件;另一方面就是就是層與層之間單元測試不太方便。
因為采用了依賴注入,在初始化的過程中就不可避免的會寫大量的 new,比如我們的項目中需要這樣:
package?mainimport?("my-clean-rchitecture/api""my-clean-rchitecture/api/handlers""my-clean-rchitecture/app""my-clean-rchitecture/repo""my-clean-rchitecture/service" )func?main()?{//?初始化dbdb?:=?app.InitDB()//初始化?reporepository?:=?repo.NewMysqlArticleRepository(db)//初始化servicearticleService?:=?service.NewArticleService(repository)//初始化apihandler?:=?handlers.NewArticleHandler(articleService)//初始化routerrouter?:=?api.NewRouter(handler)//初始化ginengine?:=?app.NewGinEngine()//初始化serverserver?:=?app.NewServer(engine,?router)//啟動server.Start() }那么對于這么一段代碼,我們有沒有辦法不用自己寫呢?這里我們就可以借助框架的力量來生成我們的注入代碼。
在 go 里面 DI 的工具相對來說沒有 java 這么方便,技術框架一般主要有:wire、dig、fx 等。由于 wire 是使用代碼生成來進行注入,性能會比較高,并且它是 google 推出的 DI 框架,所以我們這里使用 wire 進行注入。
wire 的要求很簡單,新建一個 wire.go 文件(文件名可以隨意),創建我們的初始化函數。比如,我們要創建并初始化一個 server 對象,我們就可以這樣:
//+build?wireinjectpackage?mainimport?("github.com/google/wire""my-clean-rchitecture/api""my-clean-rchitecture/api/handlers""my-clean-rchitecture/app""my-clean-rchitecture/repo""my-clean-rchitecture/service" )func?InitServer()?*app.Server?{wire.Build(app.InitDB,repo.NewMysqlArticleRepository,service.NewArticleService,handlers.NewArticleHandler,api.NewRouter,app.NewServer,app.NewGinEngine)return?&app.Server{} }需要注意的是,第一行的注解:+build wireinject,表示這是一個注入器。
在函數中,我們調用wire.Build()將創建 Server 所依賴的類型的構造器傳進去。寫完 wire.go 文件之后執行 wire 命令,就會自動生成一個 wire_gen.go 文件。
//?Code?generated?by?Wire.?DO?NOT?EDIT.//go:generate?go?run?github.com/google/wire/cmd/wire //+build?!wireinjectpackage?mainimport?("my-clean-rchitecture/api""my-clean-rchitecture/api/handlers""my-clean-rchitecture/app""my-clean-rchitecture/repo""my-clean-rchitecture/service" )//?Injectors?from?wire.go:func?InitServer()?*app.Server?{engine?:=?app.NewGinEngine()db?:=?app.InitDB()iArticleRepo?:=?repo.NewMysqlArticleRepository(db)iArticleService?:=?service.NewArticleService(iArticleRepo)articleHandler?:=?handlers.NewArticleHandler(iArticleService)router?:=?api.NewRouter(articleHandler)server?:=?app.NewServer(engine,?router)return?server }可以看到 wire 自動幫我們生成了 InitServer 方法,此方法中依次初始化了所有要初始化的基類。之后在我們的 main 函數中就只需調用這個 InitServer 即可。
func?main()?{server?:=?InitServer()server.Start() }測試
在上面我們定義好了每一層應該做什么,那么對于每一層我們應該都是可單獨測試的,即使另外一層不存在。
models 層:這一層就很簡單了,由于沒有依賴任何其他代碼,所以可以直接用 go 的單測框架直接測試即可;
repo 層:對于這一層來說,由于我們使用了 mysql 數據庫,那么我們需要 mock mysql,這樣即使不用連 mysql 也可以正常測試,我這里使用 github.com/DATA-DOG/go-sqlmock 這個庫來 mock 我們的數據庫;
service 層:因為 service 層依賴了 repo 層,因為它們之間是通過接口來關聯,所以我這里使用 github.com/golang/mock/gomock 來 mock repo 層;
api 層:這一層依賴 service 層,并且它們之間是通過接口來關聯,所以這里也可以使用 gomock 來 mock service 層。不過這里稍微麻煩了一點,因為我們接入層用的是 gin,所以還需要在單測的時候模擬發送請求;
由于我們是通過 github.com/golang/mock/gomock 來進行 mock ,所以需要執行一下代碼生成,生成的 mock 代碼我們放入到 mock 包中:
mockgen?-destination?.\mock\repo_mock.go?-source?.\repo\repo.go?-package?mockmockgen?-destination?.\mock\service_mock.go?-source?.\service\service.go?-package?mock上面這兩個命令會通過接口幫我自動生成 mock 函數。
repo 層測試
在項目中,由于我們用了 gorm 來作為我們的 orm 庫,所以我們需要使用 github.com/DATA-DOG/go-sqlmock 結合 gorm 來進行 mock:
func?getSqlMock()?(mock?sqlmock.Sqlmock,?gormDB?*gorm.DB)?{//創建sqlmockvar?err?errorvar?db?*sql.DBdb,?mock,?err?=?sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))if?err?!=?nil?{panic(err)}//結合gorm、sqlmockgormDB,?err?=?gorm.Open(mysql.New(mysql.Config{SkipInitializeWithVersion:?true,Conn:??????????????????????db,}),?&gorm.Config{})if?nil?!=?err?{log.Fatalf("Init?DB?with?sqlmock?failed,?err?%v",?err)}return }func?Test_mysqlArticleRepository_Fetch(t?*testing.T)?{createAt?:=?time.Now()updateAt?:=?time.Now()//id,title,content,?updated_at,?created_atvar?articles?=?[]models.Article{{1,?"test1",?"content",?updateAt,?createAt},{2,?"test2",?"content2",?updateAt,?createAt},}limit?:=?2mock,?db?:=?getSqlMock()mock.ExpectQuery("SELECT?id,title,content,?updated_at,?created_at?FROM?`articles`?WHERE?created_at?>???LIMIT?2").WithArgs(createAt).WillReturnRows(sqlmock.NewRows([]string{"id",?"title",?"content",?"updated_at",?"created_at"}).AddRow(articles[0].ID,?articles[0].Title,?articles[0].Content,?articles[0].UpdatedAt,?articles[0].CreatedAt).AddRow(articles[1].ID,?articles[1].Title,?articles[1].Content,?articles[1].UpdatedAt,?articles[1].CreatedAt))repository?:=?NewMysqlArticleRepository(db)result,?err?:=?repository.Fetch(context.TODO(),?createAt,?limit)assert.Nil(t,?err)assert.Equal(t,?articles,?result) }service 層測試
這里主要就是用我們 gomock 生成的代碼來 mock repo 層:
func?Test_articleService_Fetch(t?*testing.T)?{ctl?:=?gomock.NewController(t)defer?ctl.Finish()now?:=?time.Now()mockRepo?:=?mock.NewMockIArticleRepo(ctl)gomock.InOrder(mockRepo.EXPECT().Fetch(context.TODO(),?now,?10).Return(nil,?nil),)service?:=?NewArticleService(mockRepo)fetch,?_?:=?service.Fetch(context.TODO(),?now,?10)fmt.Println(fetch) }api 層測試
對于這一層,我們不僅要 mock service 層,還需要發送 httptest 來模擬請求發送:
func?TestArticleHandler_FetchArticle(t?*testing.T)?{ctl?:=?gomock.NewController(t)defer?ctl.Finish()createAt,?_?:=?time.Parse("2006-01-02",?"2021-12-26")mockService?:=?mock.NewMockIArticleService(ctl)gomock.InOrder(mockService.EXPECT().Fetch(gomock.Any(),?createAt,?10).Return(nil,?nil),)article?:=?NewArticleHandler(mockService)gin.SetMode(gin.TestMode)//?Setup?your?router,?just?like?you?did?in?your?main?function,?and//?register?your?routesr?:=?gin.Default()r.GET("/articles",?article.FetchArticle)req,?err?:=?http.NewRequest(http.MethodGet,?"/articles?num=10&create_date=2021-12-26",?nil)if?err?!=?nil?{t.Fatalf("Couldn't?create?request:?%v\n",?err)}w?:=?httptest.NewRecorder()//?Perform?the?requestr.ServeHTTP(w,?req)//?Check?to?see?if?the?response?was?what?you?expectedif?w.Code?!=?http.StatusOK?{t.Fatalf("Expected?to?get?status?%d?but?instead?got?%d\n",?http.StatusOK,?w.Code)} }總結
以上就是我對 golang 的項目中發現問題的一點點總結與思考,思考的先不管對不對,總歸是解決了我們當下的一些問題。不過,項目總歸是需要不斷重構完善的,所以下次有問題的時候下次再改唄。
對于我上面的總結和描述感覺有不對的地方,請隨時指出來一起討論。
項目代碼位置:https://github.com/devYun/go-clean-architecture
Reference
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
https://github.com/bxcodec/go-clean-arch
https://medium.com/hackernoon/golang-clean-archithecture-efd6d7c43047
https://farer.org/2021/04/21/go-dependency-injection-wire/
總結
以上是生活随笔為你收集整理的Golang 简洁架构实战的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 一篇详文带你入门 Redis
- 下一篇: Web内核微信小程序框架实践