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