回顾简单回顾一下《Go工程化(九) 项目重构实践》如果你还没有看过之前的文章,可以先看一下:在我们之前的项目目录层次结构中,我们主要分为五块:cmd/appname是我们服务的入口,只负责启动和依赖注入(使用Wire)domainormodel就是我们的实体定义+接口定义server负责实现我们在proto中定义的接口,在这一层我们只做数据转换,不写业务逻辑usecase负责实现我们的业务逻辑repo负责数据操作,只做数据操作,不实现业务逻辑在上一篇文章中只提到了一个很简单的例子,但是我们实际的业务流程往往并没有那么简单。举个很常见的例子,我们现在需要创建一篇文章,文章需要关联分类或者标签信息。这里至少有两个步骤:创建一篇文章并关联文章和标签。这两个创建操作需要保证一致性,我们需要在数据库中使用事务。此时我们的交易托管在哪里?在repo层承载事务最简单直接的方法是使用事务来创建文章和标签之间的关系。并非我们所有的业务场景都需要创建关联。如果我们在某些场景下只需要一个简单的方法怎么办?这样写还有一个问题。我们把业务逻辑下沉到repo里面,后面我们还有其他的关联,你们也是这样吗?对于第一个问题,最简单的方法是提供一个CreateArticleWithTags方法来创建两者。如果我们需要一个独立的CreateArticle,再写一个就可以了。但是随着需求的增加,可能会有一些跟角色相关的,跟产品相关的等等。我们写一个有逻辑的方法吗?光是想想就觉得可怕。或者在参数中加入很多可选的选项,然后在一个方法中继续判断。那我们用usecase做什么呢?直接写在一起不是更好吗?在usecase层承载事务是可以的,直接在repo层实现好像不行,那我们把目光往上移,在usecase中解决这个问题。事务能力是在repo上提供的,所以我们需要在repo层提供一个事务接口,然后在usecase中调用它来保证事务的执行。使用repo层提供的事务接口//domain/article.go//ArticleRepoTxFunc事务方法typeArticleRepoTxFunc=func(ctxcontext.Context,repoIArticleRepo)error//IArticleRepoIArticleRepotypeIArticleRepointerface{Tx(ctxcontext.Context,fArticleRepoTxFunc)errorGetArticle(ctxintContext)。(*Article,error)CreateArticle(ctxcontext.Context,article*Article)error}在repo中,我们可以如上定义,提供一个Tx方法,这个方法接受一个ArticleRepoTxFunc作为参数,打开这个函数中的repo一个事务性的repo,通过这个repo调用的所有方法都在一个事务中执行。//repo/article.gofunc(r*article)Tx(ctxcontext.Context,fdomain.ArticleRepoTxFunc)error{//注意,这里的r.db是*gorm.DB//gorm中提供了事务工具和方法用于执行transactions,这里我们不写自己的returnr.db.WithContext(ctx).Transaction(func(tx*gorm.DB)error{//我们用交易的tx重新初始化一个repo//后续执行这个repo数据库相关的操作都是事务性的repo:=NewArticleRepo(tx)returnf(ctx,repo)})}那么我们调用usecase的时候就可以这样//usecase/article.gofunc(u*article)CreateArticle(ctxcontext.Context,article*domain.Article,tagIDs[]uint)error{returnu.repo.Tx(ctx,func(ctxcontext.Context,repodomain.IArticleRepo)错误{err:=repo.CreateArticle(ctx,article)iferr!=nil{returnerr}varats[]*domain.ArticleTagfor_,tid:=rangetagIDs{ats=append(ats,&domain.ArticleTag{ArticleID:article.ID,TagID:tid,})}returnrepo.CreateArticleTags(ctx,ats)})}这样写就整洁多了,业务逻辑和我们原来的设计一样,用usecase实现,repo也保持简洁的原则.这是否意味着一切都会好起来的?如果一切顺利,这篇文章到这里应该就结束了,但是并没有,说明我在实践的过程中遇到了问题。问题很简单,就是我们不仅需要在usecase中复用repo中的代码,还可能需要复用usecase中的代码,否则我们在usecase中可能会有很多相同逻辑的代码片段,并且代码的重复率非常高。让我们看看下面的例子是否有问题。//usecase/article.gofunc(u*article)A(ctxcontect,argsargs)error{err:=u.CreateArticle(ctx,args.Article)//包含事务iferr!=nil{returnerr}returnu.UpdateXXX(ctx,args.XXX)//这个方法也是用到了事务}这个方法中,其实是开启了两个事务。这两笔交易互不相关,不符合我们的需求。在usecase层提供事务方法//usecase/article.gotypehandlerfunc(ctxcontext.Context,usecasedomain.IArticleUsecase)errorfunc(u*article)tx(ctxcontext.Context,fhandler)error{returnu.repo.Tx(ctx,func(ctxcontext.context,repodomain.IArticleRepo)error{usecase:=NewArticleUsecase(repo)returnf(ctx,usecase)})}我们在usecase中也创建了一个tx方法,类似repo,调用tx后,handler中的方法需要全部使用新参数用例。这个新的用例可以确保里面所有的repo调用都是事务性的。所以我们之前的A函数可以修改如下::=usecase.CreateArticle(ctx,args.Article)//包含事务iferr!=nil{returnerr}returnusecase.UpdateXXX(ctx,args.XXX)//这个方法也用到了事务})}所以没有问题么?我们还在UpdateXXX方法中调用了u.tx方法,会导致交易重复开启。虽然gorm中的Transaction方法支持嵌套事务,但我们不应该滥用这个特性。解决方法很简单,我们只需要在执行的时候进行判断即可。//usecase/article.gotypearticlestruct{repodomain.IArticleRepoisTxbool//用来标识交易是否开启}那么我们在tx方法中:func(u*article)tx(ctxcontext.Context,fhandler)error{//如果开启交易后,我们可以直接重用ifu.isTx{returnf(ctx,u)}returnu.repo.Tx(ctx,func(ctxcontext.Context,repodomain.IArticleRepo)error{usecase:=&article{repo:repo,isTx:true,}returnf(ctx,usecase)})}总结文章到此结束,同样的问题,现在可以这样写吗?可以解决我目前遇到的一些需求,当然这个方案并不完美。比如我们涉及到多个repos的时候,不能直接使用当前的方法,需要做一些修改。我们虽然要有眼光,但也不要想太多。进化胜于完美。
