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 工具類

其實上面的這個劃分只是簡單的將功能分了一下包,在項目實踐的過程中還是有很多問題。比如:

所以現在在我們工作中隨着代碼越來越多,代碼中各種 init,function,struct,全局變量感覺也越來越亂。每個模塊不獨立,看似按邏輯分了模塊,但沒有明確的上下層關係,每個模塊裏可能都存在配置讀取,外部服務調用,協議轉換等。久而久之服務不同包函數之間的調用慢慢演變成網狀結構,數據流的流向和邏輯的梳理變得越來越複雜,很難不看代碼調用的情況下搞清楚數據流向。

不過就像《重構》中所說:先讓代碼工作起來 - 如果代碼不能工作,就不能產生價值;然後再試圖將它變好 - 通過對代碼進行重構,讓我們自己和其他人更好地理解代碼,並能按照需求不斷地修改代碼。

所以我覺得是時候自我改變一下。

The Clean Architecture

在簡潔架構裏面對我們的項目提出了幾點要求:

  1. 獨立於框架。該架構不依賴於某些功能豐富的軟件庫的存在。這允許你把這些框架作爲工具來使用,而不是把你的系統塞進它們有限的約束中。

  2. 可測試。業務規則可以在沒有 UI、數據庫、Web 服務器或任何其他外部元素的情況下被測試。

  3. 獨立於用戶界面。UI 可以很容易地改變,而不用改變系統的其他部分。例如,一個 Web UI 可以被替換成一個控制檯 UI,而不改變業務規則。

  4. 獨立於數據庫。你可以把 Oracle 或 SQL Server 換成 Mongo、BigTable、CouchDB 或其他東西。你的業務規則不受數據庫的約束。

  5. 獨立於任何外部機構。事實上,你的業務規則根本不知道外部世界的任何情況。

上圖中同心圓代表各種不同領域的軟件。一般來說,越深入代表你的軟件層次越高。外圓是戰術實現機制,內圓的是戰略核心策略。對於我們的項目來說,代碼依賴應該由外向內,單向單層依賴,這種依賴包含代碼名稱,或類的函數,變量或任何其他命名軟件實體。

對於簡潔架構來說分爲了四層:

那麼對於我的項目來說,也分爲了四層:

代碼分層

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 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)
}

這個接口的實現類就可以根據需求變更,比如說當我們想要 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).Error
 return
}

如果改天想要換成 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 injectionInversion of Control Containers and the Dependency Injection pattern 這兩篇文章。

如果不用 DI 主要有兩大不方便的地方,一個是底層類的修改需要修改上層類,在大型軟件開發過程中基類是很多的,一條鏈路改下來動輒要修改幾十個文件;另一方面就是就是層與層之間單元測試不太方便。

因爲採用了依賴注入,在初始化的過程中就不可避免的會寫大量的 new,比如我們的項目中需要這樣:

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)
 //啓動
 server.Start()
}

那麼對於這麼一段代碼,我們有沒有辦法不用自己寫呢?這裏我們就可以藉助框架的力量來生成我們的注入代碼。

在 go 裏面 DI 的工具相對來說沒有 java 這麼方便,技術框架一般主要有:wire、dig、fx 等。由於 wire 是使用代碼生成來進行注入,性能會比較高,並且它是 google 推出的 DI 框架,所以我們這裏使用 wire 進行注入。

wire 的要求很簡單,新建一個 wire.go 文件(文件名可以隨意),創建我們的初始化函數。比如,我們要創建並初始化一個 server 對象,我們就可以這樣:

//+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,表示這是一個注入器。

在函數中,我們調用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 !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 自動幫我們生成了 InitServer 方法,此方法中依次初始化了所有要初始化的基類。之後在我們的 main 函數中就只需調用這個 InitServer 即可。

func main() {
 server := InitServer()
 server.Start()
}

測試

在上面我們定義好了每一層應該做什麼,那麼對於每一層我們應該都是可單獨測試的,即使另外一層不存在。

由於我們是通過 github.com/golang/mock/gomock 來進行 mock ,所以需要執行一下代碼生成,生成的 mock 代碼我們放入到 mock 包中:

mockgen -destination .\mock\repo_mock.go -source .\repo\repo.go -package mock

mockgen -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) {
 //創建sqlmock
 var err error
 var db *sql.DB
 db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
 if err != nil {
  panic(err)
 }
 //結合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 層測試

這裏主要就是用我們 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 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: %v\n", 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 %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/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Xzlt_WcdcfLWhofafY3c6g