作者:bearluo,騰訊 IEG 運(yùn)營(yíng)開(kāi)發(fā)工程師
由于 golang 不像 JAVA 一樣有一個(gè)統(tǒng)一的編碼模式,所以我們和其他團(tuán)隊(duì)一樣,采用了 Go 面向包的設(shè)計(jì)和架構(gòu)分層這篇文章介紹的一些理論,然后再結(jié)合以往的項(xiàng)目經(jīng)驗(yàn)來(lái)進(jìn)行分包:
├── cmd/
│ └── main.go //啟動(dòng)函數(shù)
├── etc
│ └── dev_conf.yaml // 配置文件
├── global
│ └── global.go //全局變量引用,如數(shù)據(jù)庫(kù)、kafka等
├── internal/
│ └── service/
│ └── xxx_service.go //業(yè)務(wù)邏輯處理類
│ └── xxx_service_test.go
│ └── model/
│ └── xxx_info.go//結(jié)構(gòu)體
│ └── api/
│ └── xxx_api.go//路由對(duì)應(yīng)的接口實(shí)現(xiàn)
│ └── router/
│ └── router.go//路由
│ └── pkg/
│ └── datetool//時(shí)間工具類
│ └── jsontool//json 工具類
其實(shí)上面的這個(gè)劃分只是簡(jiǎn)單的將功能分了一下包,在項(xiàng)目實(shí)踐的過(guò)程中還是有很多問(wèn)題。比如:
- 對(duì)于功能實(shí)現(xiàn)我是通過(guò) function 的參數(shù)傳遞還是通過(guò)結(jié)構(gòu)體的變量傳遞?
- 使用一個(gè)數(shù)據(jù)庫(kù)的全局變量引用傳遞是否安全?是否存在過(guò)度耦合?
- 在代碼實(shí)現(xiàn)過(guò)程中幾乎全部都是依賴于實(shí)現(xiàn),而不是依賴于接口,那么將 MySQL 切換為 MongDB 是不是要修改所有的實(shí)現(xiàn)?
所以現(xiàn)在在我們工作中隨著代碼越來(lái)越多,代碼中各種 init,function,struct,全局變量感覺(jué)也越來(lái)越亂。每個(gè)模塊不獨(dú)立,看似按邏輯分了模塊,但沒(méi)有明確的上下層關(guān)系,每個(gè)模塊里可能都存在配置讀取,外部服務(wù)調(diào)用,協(xié)議轉(zhuǎn)換等。久而久之服務(wù)不同包函數(shù)之間的調(diào)用慢慢演變成網(wǎng)狀結(jié)構(gòu),數(shù)據(jù)流的流向和邏輯的梳理變得越來(lái)越復(fù)雜,很難不看代碼調(diào)用的情況下搞清楚數(shù)據(jù)流向。
不過(guò)就像《重構(gòu)》中所說(shuō):先讓代碼工作起來(lái)-如果代碼不能工作,就不能產(chǎn)生價(jià)值;然后再試圖將它變好-通過(guò)對(duì)代碼進(jìn)行重構(gòu),讓我們自己和其他人更好地理解代碼,并能按照需求不斷地修改代碼。
所以我覺(jué)得是時(shí)候自我改變一下。
The Clean Architecture
在簡(jiǎn)潔架構(gòu)里面對(duì)我們的項(xiàng)目提出了幾點(diǎn)要求:
- 獨(dú)立于框架。該架構(gòu)不依賴于某些功能豐富的軟件庫(kù)的存在。這允許你把這些框架作為工具來(lái)使用,而不是把你的系統(tǒng)塞進(jìn)它們有限的約束中。
- 可測(cè)試。業(yè)務(wù)規(guī)則可以在沒(méi)有 UI、數(shù)據(jù)庫(kù)、Web 服務(wù)器或任何其他外部元素的情況下被測(cè)試。
- 獨(dú)立于用戶界面。UI 可以很容易地改變,而不用改變系統(tǒng)的其他部分。例如,一個(gè) Web UI 可以被替換成一個(gè)控制臺(tái) UI,而不改變業(yè)務(wù)規(guī)則。
- 獨(dú)立于數(shù)據(jù)庫(kù)。你可以把 Oracle 或 SQL Server 換成 Mongo、BigTable、CouchDB 或其他東西。你的業(yè)務(wù)規(guī)則不受數(shù)據(jù)庫(kù)的約束。
- 獨(dú)立于任何外部機(jī)構(gòu)。事實(shí)上,你的業(yè)務(wù)規(guī)則根本不知道外部世界的任何情況。
上圖中同心圓代表各種不同領(lǐng)域的軟件。一般來(lái)說(shuō),越深入代表你的軟件層次越高。外圓是戰(zhàn)術(shù)實(shí)現(xiàn)機(jī)制,內(nèi)圓的是戰(zhàn)略核心策略。對(duì)于我們的項(xiàng)目來(lái)說(shuō),代碼依賴應(yīng)該由外向內(nèi),單向單層依賴,這種依賴包含代碼名稱,或類的函數(shù),變量或任何其他命名軟件實(shí)體。
對(duì)于簡(jiǎn)潔架構(gòu)來(lái)說(shuō)分為了四層:
- Entities:實(shí)體
- Usecase:表達(dá)應(yīng)用業(yè)務(wù)規(guī)則,對(duì)應(yīng)的是應(yīng)用層,它封裝和實(shí)現(xiàn)系統(tǒng)的所有用例;
- Interface Adapters:這一層的軟件基本都是一些適配器,主要用于將用例和實(shí)體中的數(shù)據(jù)轉(zhuǎn)換為外部系統(tǒng)如數(shù)據(jù)庫(kù)或 Web 使用的數(shù)據(jù);
- Framework & Driver:最外面一圈通常是由一些框架和工具組成,如數(shù)據(jù)庫(kù) Database, Web 框架等;
那么對(duì)于我的項(xiàng)目來(lái)說(shuō),也分為了四層:
- models
- repo
- service
- api
代碼分層
models
封裝了各種實(shí)體類對(duì)象,與數(shù)據(jù)庫(kù)交互的、與 UI 交互的等等,任何的實(shí)體類都應(yīng)該放在這里。如:
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
這里存放的是數(shù)據(jù)庫(kù)操作類,數(shù)據(jù)庫(kù) CRUD 都在這里。需要注意的是,這里不包含任何的業(yè)務(wù)邏輯代碼,很多同學(xué)喜歡將業(yè)務(wù)邏輯也放到這里。
如果使用 ORM,那么這里放入的 ORM 操作相關(guān)的代碼;如果使用微服務(wù),那么這里放的是其他服務(wù)請(qǐng)求的代碼;
service
這里是業(yè)務(wù)邏輯層,所有的業(yè)務(wù)過(guò)程處理代碼都應(yīng)該放在這里。這一層會(huì)決定是請(qǐng)求 repo 層的什么代碼,是操作數(shù)據(jù)庫(kù)還是調(diào)用其他服務(wù);所有的業(yè)務(wù)數(shù)據(jù)計(jì)算也應(yīng)該放在這里;這里接受的入?yún)?yīng)該是 controller 傳入的。
api
這里是接收外部請(qǐng)求的代碼,如:gin 對(duì)應(yīng)的 handler、gRPC、其他 REST API 框架接入層等等。
面向接口編程
除了 models 層,層與層之間應(yīng)該通過(guò)接口交互,而不是實(shí)現(xiàn)。如果要用 service 調(diào)用 repo 層,那么應(yīng)該調(diào)用 repo 的接口。那么修改底層實(shí)現(xiàn)的時(shí)候我們上層的基類不需要變更,只需要更換一下底層實(shí)現(xiàn)即可。
例如我們想要將所有文章查詢出來(lái),那么可以在 repo 提供這樣的接口:
package repo
import (
"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)
}
這個(gè)接口的實(shí)現(xiàn)類就可以根據(jù)需求變更,比如說(shuō)當(dāng)我們想要 mysql 來(lái)作為存儲(chǔ)查詢,那么只需要提供一個(gè)這樣的基類:
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).Error
return
}
如果改天想要換成 MongoDB 來(lái)實(shí)現(xiàn)我們的存儲(chǔ),那么只需要定義一個(gè)結(jié)構(gòu)體實(shí)現(xiàn) IArticleRepo 接口即可。
那么在 service 層實(shí)現(xiàn)的時(shí)候就可以按照我們的需求來(lái)將對(duì)應(yīng)的 repo 實(shí)現(xiàn)注入即可,從而不需要改動(dòng) service 層的實(shí)現(xiàn):
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,簡(jiǎn)稱 DI 。DI 以前在 java 工程里面經(jīng)常遇到,但是在 go 里面很多人都說(shuō)不需要,但是我覺(jué)得在大型軟件開(kāi)發(fā)過(guò)程中還是有必要的,否則只能通過(guò)全局變量或者方法參數(shù)來(lái)進(jìn)行傳遞。
至于具體什么是 DI,簡(jiǎn)單來(lái)說(shuō)就是被依賴的模塊,在創(chuàng)建模塊時(shí),被注入到(即當(dāng)作參數(shù)傳入)模塊的里面。想要更加深入的了解什么是 DI 這里再推薦一下 Dependency injection 和 Inversion of Control Containers and the Dependency Injection pattern 這兩篇文章。
如果不用 DI 主要有兩大不方便的地方,一個(gè)是底層類的修改需要修改上層類,在大型軟件開(kāi)發(fā)過(guò)程中基類是很多的,一條鏈路改下來(lái)動(dòng)輒要修改幾十個(gè)文件;另一方面就是就是層與層之間單元測(cè)試不太方便。
因?yàn)椴捎昧艘蕾囎⑷耄诔跏蓟倪^(guò)程中就不可避免的會(huì)寫(xiě)大量的 new,比如我們的項(xiàng)目中需要這樣:
package main
import (
"my-clean-rchitecture/api"
"my-clean-rchitecture/api/handlers"
"my-clean-rchitecture/App"
"my-clean-rchitecture/repo"
"my-clean-rchitecture/service"
)
func main() {
// 初始化db
db := app.InitDB()
//初始化 repo
repository := repo.NewMysqlArticleRepository(db)
//初始化service
articleService := service.NewArticleService(repository)
//初始化api
handler := handlers.NewArticleHandler(articleService)
//初始化router
router := api.NewRouter(handler)
//初始化gin
engine := app.NewGinEngine()
//初始化server
server := app.NewServer(engine, router)
//啟動(dòng)
server.Start()
}
那么對(duì)于這么一段代碼,我們有沒(méi)有辦法不用自己寫(xiě)呢?這里我們就可以借助框架的力量來(lái)生成我們的注入代碼。
在 go 里面 DI 的工具相對(duì)來(lái)說(shuō)沒(méi)有 java 這么方便,技術(shù)框架一般主要有:wire、dig、fx 等。由于 wire 是使用代碼生成來(lái)進(jìn)行注入,性能會(huì)比較高,并且它是 google 推出的 DI 框架,所以我們這里使用 wire 進(jìn)行注入。
wire 的要求很簡(jiǎn)單,新建一個(gè) wire.go 文件(文件名可以隨意),創(chuàng)建我們的初始化函數(shù)。比如,我們要?jiǎng)?chuàng)建并初始化一個(gè) server 對(duì)象,我們就可以這樣:
//+build wireinject
package main
import (
"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,表示這是一個(gè)注入器。
在函數(shù)中,我們調(diào)用wire.Build()將創(chuàng)建 Server 所依賴的類型的構(gòu)造器傳進(jìn)去。寫(xiě)完 wire.go 文件之后執(zhí)行 wire 命令,就會(huì)自動(dòng)生成一個(gè) wire_gen.go 文件。
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//+build !wireinject
package main
import (
"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 自動(dòng)幫我們生成了 InitServer 方法,此方法中依次初始化了所有要初始化的基類。之后在我們的 main 函數(shù)中就只需調(diào)用這個(gè) InitServer 即可。
func main() {
server := InitServer()
server.Start()
}
測(cè)試
在上面我們定義好了每一層應(yīng)該做什么,那么對(duì)于每一層我們應(yīng)該都是可單獨(dú)測(cè)試的,即使另外一層不存在。
- models 層:這一層就很簡(jiǎn)單了,由于沒(méi)有依賴任何其他代碼,所以可以直接用 go 的單測(cè)框架直接測(cè)試即可;
- repo 層:對(duì)于這一層來(lái)說(shuō),由于我們使用了 mysql 數(shù)據(jù)庫(kù),那么我們需要 mock mysql,這樣即使不用連 mysql 也可以正常測(cè)試,我這里使用 github.com/DATA-DOG/go-sqlmock 這個(gè)庫(kù)來(lái) mock 我們的數(shù)據(jù)庫(kù);
- service 層:因?yàn)?service 層依賴了 repo 層,因?yàn)樗鼈冎g是通過(guò)接口來(lái)關(guān)聯(lián),所以我這里使用 github.com/golang/mock/gomock 來(lái) mock repo 層;
- api 層:這一層依賴 service 層,并且它們之間是通過(guò)接口來(lái)關(guān)聯(lián),所以這里也可以使用 gomock 來(lái) mock service 層。不過(guò)這里稍微麻煩了一點(diǎn),因?yàn)槲覀兘尤雽佑玫氖?gin,所以還需要在單測(cè)的時(shí)候模擬發(fā)送請(qǐng)求;
由于我們是通過(guò)
github.com/golang/mock/gomock 來(lái)進(jìn)行 mock ,所以需要執(zhí)行一下代碼生成,生成的 mock 代碼我們放入到 mock 包中:
mockgen -destination .mockrepo_mock.go -source .reporepo.go -package mock
mockgen -destination .mockservice_mock.go -source .serviceservice.go -package mock
上面這兩個(gè)命令會(huì)通過(guò)接口幫我自動(dòng)生成 mock 函數(shù)。
repo 層測(cè)試
在項(xiàng)目中,由于我們用了 gorm 來(lái)作為我們的 orm 庫(kù),所以我們需要使用
github.com/DATA-DOG/go-sqlmock 結(jié)合 gorm 來(lái)進(jìn)行 mock:
func getSqlMock() (mock sqlmock.Sqlmock, gormDB *gorm.DB) {
//創(chuàng)建sqlmock
var err error
var db *sql.DB
db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
panic(err)
}
//結(jié)合gorm、sqlmock
gormDB, 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_at
var articles = []models.Article{
{1, "test1", "content", updateAt, createAt},
{2, "test2", "content2", updateAt, createAt},
}
limit := 2
mock, 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 層測(cè)試
這里主要就是用我們 gomock 生成的代碼來(lái) 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 層測(cè)試
對(duì)于這一層,我們不僅要 mock service 層,還需要發(fā)送 httptest 來(lái)模擬請(qǐng)求發(fā)送:
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 routes
r := 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: %vn", err)
}
w := httptest.NewRecorder()
// Perform the request
r.ServeHTTP(w, req)
// Check to see if the response was what you expected
if w.Code != http.StatusOK {
t.Fatalf("Expected to get status %d but instead got %dn", http.StatusOK, w.Code)
}
}
總結(jié)
以上就是我對(duì) golang 的項(xiàng)目中發(fā)現(xiàn)問(wèn)題的一點(diǎn)點(diǎn)總結(jié)與思考,思考的先不管對(duì)不對(duì),總歸是解決了我們當(dāng)下的一些問(wèn)題。不過(guò),項(xiàng)目總歸是需要不斷重構(gòu)完善的,所以下次有問(wèn)題的時(shí)候下次再改唄。
對(duì)于我上面的總結(jié)和描述感覺(jué)有不對(duì)的地方,請(qǐng)隨時(shí)指出來(lái)一起討論。
項(xiàng)目代碼位置:
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/






