Go工程化如何在整潔架構中使用事務?
回顧先簡單回顧一下 《Go工程化(九) 項目重構實踐》 如果還沒看過之前這篇文章可以先看一下:
在我們之前的項目目錄分層中,我們主要分為了五個塊:
- cmd/appname 是我們服務的入口,只負責啟動和依賴注入(使用 Wire)
- domain 或者 model 是我們的實體定義 + 接口定義
- server 負責實現我們在 proto 中定義的接口,在這一層中我們只做數據轉換,不寫業務邏輯
- usecase 負責實現我們的業務邏輯
- repo 負責數據操作, 僅做數據操作,不實現業務邏輯
在之前的文章中僅僅提到了一個非常簡單的示例,但是我們實際業務流程往往沒有那么簡單,就一個非常常見的例子,我們現在需要創建一篇文章,文章上需要關聯分類或者是標簽信息,這里至少就分兩步:
- 創建文章
- 關聯文章和標簽
這兩個創建操作需要保證一致性,我們需要在數據庫中使用事務,這時候我們的事務在哪里承載呢?
在 repo 層承載事務
其中最簡單也最直接的辦法就是在 repo 的 CreateArticle 方法中我們就使用事務去同時創建文章以及標簽之間的關聯關系。
- 我們不是所有的業務場景都需要關聯創建,有的場景下我們只需要一個單純的方法又怎么辦呢?
- 這么寫還有一個問題,我們把業務邏輯下沉到了 repo 中,后面我們還有其它關聯也這么搞么?
針對第一個問題,最簡單的辦法就是我們提供一個 CreateArticleWithTags 方法表示同時創建這兩者,如果我們需要一個獨立的 CreateArticle 再寫一個就好了。
但是隨著需求越來越多,可能后面還有需要和角色關聯的,和商品關聯的等等。
難道我們就一種邏輯寫一個方法么。想想就可怕。
還是在參數中加上很多可選的 options,然后在一個方法中不斷判斷。那我們還拿 usecase 做什么直接寫一起不更好么?
在 usecase 層承載事務
ok,所以直接在 repo 層里面來實現看上去好像行不通,那我們就把視線往上移動,我們在 usecase 來解決這個問題。
事務的能力是在 repo 上提供的,所以我們需要在 repo 層提供一個事務接口,然后在 usecase 中進行調用,保證是事務執行的就行。
使用 repo 層提供的事務接口
- // domain/article.go
- // ArticleRepoTxFunc 事務方法
- type ArticleRepoTxFunc = func(ctx context.Context, repo IArticleRepo) error
- // IArticleRepo IArticleRepo
- type IArticleRepo interface {
- Tx(ctx context.Context, f ArticleRepoTxFunc) error
- GetArticle(ctx context.Context, id int) (*Article, error)
- CreateArticle(ctx context.Context, article *Article) error
- }
在 repo 中,我們可以像上面這樣定義,提供一個 Tx 方法,這個方法接受一個 ArticleRepoTxFunc 作為參數,這個函數中的 repo 是開啟了事務的 repo,通過這個 repo 調用的所有方法都是在事務中執行的。
- // repo/article.go
- func (r *article) Tx(ctx context.Context, f domain.ArticleRepoTxFunc) error {
- // 注意,這里的 r.db 是 *gorm.DB
- // 在 gorm 中提供了 Transaction 的工具方法用于執行事務,這里我們就不自己寫了
- return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
- // 我們使用事務的 tx 重新初始化一個 repo
- // 這個 repo 后續的執行的數據庫相關的操作就都是事務的了
- repo := NewArticleRepo(tx)
- return f(ctx, repo)
- })
- }
然后我們在 usecase 調用的時候就可以這樣。
- // usecase/article.go
- func (u *article) CreateArticle(ctx context.Context, article *domain.Article, tagIDs []uint) error {
- return u.repo.Tx(ctx, func(ctx context.Context, repo domain.IArticleRepo) error {
- err := repo.CreateArticle(ctx, article)
- if err != nil {
- return err
- }
- var ats []*domain.ArticleTag
- for _, tid := range tagIDs {
- ats = append(ats, &domain.ArticleTag{
- ArticleID: article.ID,
- TagID: tid,
- })
- }
- return repo.CreateArticleTags(ctx, ats)
- })
- }
這樣寫起來就整潔很多了,業務邏輯和我們最初的設計一樣,在 usecase 中實現了,repo 中我們也保持了簡單的原則。
這樣是不是就萬事大吉了呢?如果萬事大吉了這篇文章到這兒也就應該結束了,但是還沒有,說明我在實踐的過程中還碰到了問題。
問題很簡單,就是我們在 usecase 中不僅僅需要復用 repo 中的代碼,還有可能需要復用 usecase 中的代碼,不然我們就可能在 usecase 中出現很多相同的邏輯代碼片段,代碼的重復率就很高。
我們來看下面一個例子會不會發現有點什么不對。
- // usecase/article.go
- func (u *article) A(ctx contect, args args) error {
- err := u.CreateArticle(ctx, args.Article) // 包含事務
- if err != nil {
- return err
- }
- return u.UpdateXXX(ctx, args.XXX) // 這個方法中也使用了事務
- }
這個方法內其實是開啟了兩個事務,這兩個事務之間互不相關,不符合我們需求。
在 usecase 層提供事務方法
- // usecase/article.go
- type handler func(ctx context.Context, usecase domain.IArticleUsecase) error
- func (u *article) tx(ctx context.Context, f handler) error {
- return u.repo.Tx(ctx, func(ctx context.Context, repo domain.IArticleRepo) error {
- usecase := NewArticleUsecase(repo)
- return f(ctx, usecase)
- })
- }
我們在 usecase 中也創建了一個 tx 方法,和 repo 類似,在調用 tx 之后,handler 中的方法的需要都是用新的參數 usecase 這個新的 usecase 可以保證里面的 repo 調用都是事務的。
所以我們之前的 A 函數可以修改為這樣:
- // usecase/article.go
- func (u *article) A(ctx contect, args args) error {
- return u.tx(ctx, func(ctx context.Context, usecase domain.IArticleUsecase) error {
- err := usecase.CreateArticle(ctx, args.Article) // 包含事務
- if err != nil {
- return err
- }
- return usecase.UpdateXXX(ctx, args.XXX) // 這個方法中也使用了事務
- })
- }
這樣就沒有問題了么?我們 UpdateXXX 方法中也調用 u.tx 方法,這樣就會導致反復開啟事務,雖然在 gorm 的 Transaction 方法是支持嵌套事務的,但是我們還是不要濫用這個特性。
解決辦法很簡單,我們只需要在執行的時候判斷下就行了。
- // usecase/article.go
- type article struct {
- repo domain.IArticleRepo
- isTx bool // 用于標識是否開啟了事務
- }
然后我們在 tx 方法內:
- func (u *article) tx(ctx context.Context, f handler) error {
- // 如果已經開啟過事務了我們就直接復用就行了
- if u.isTx {
- return f(ctx, u)
- }
- return u.repo.Tx(ctx, func(ctx context.Context, repo domain.IArticleRepo) error {
- usecase := &article{
- repo: repo,
- isTx: true,
- }
- return f(ctx, usecase)
- })
- }
總結
文章到這里就到尾聲了,同樣的問題,我們現在這么寫就可以了么?
對于我當前所遇到的一些需求來說已經可以解決了,當然這個方案并不完美,比如說我們涉及到多個 repo 的時候,當前的方法就沒法直接用了,還得進行一些改造,雖然我們要有遠見但是也不要想的太多,進化是優于完美的。