在软件开发中,随着项目规模的扩大和业务逻辑的复杂化,重构代码变得越来越重要。本文将介绍如何在既有代码基础上,通过依赖倒置(DIP)和控制反转(IoC),实现新增加的代码可以循环引用到服务层的代码。然后,我们将探讨接口隔离、设计小而清晰的接口和包,以及共同依赖原则等内容。
包引用时的循环依赖问题
在开发服务端代码的时候,我们通常会采用单体分层设计,通常会将大量的领域代码集中在Service层,因为是同一个包,所以互相之间的引用是完全开放的。但是随着项目逐渐变得越来越复杂,无论是否采用微服务和领域驱动,我们总会需要根据领域进行拆分和重构,简化代码的复杂度。
不同于Java,共同依赖原则体现在逻辑上,而Golang的设计理念,是以包为维度组织代码的,引入依赖的的层级就是包而不是单个文件,如果代码出现了包之间物理上的循环引用,在编译阶段就会直接报错。
循环依赖和共同依赖原则
我觉得Golang的这种设计,体现的是面向对象设计中的共同依赖原则,设计的包,应该职责简单清晰,有明确的责任或者功能,因此,依赖了一个包就意味着会依赖包下的所有文件。
基于这个原则,我们常常可以推出两个编程原则。
第一个是,在选择是否依赖某个由其他人或者团队维护的包的时候,要分析这个包中的代码,是否会有其他不可预期的副作用,例如golang的init,或者包内某个文件,依赖的第三方包不够整洁;如果弊大于利,不如自己重写。
第二个是,我们自己在决定某段代码、函数或者类应该放在什么路径(包)的时候,要仔细的设计,是否自己跟当前的包足够内聚,语义匹配,新代码依赖的包或者库,有没有引入破坏当前所在包承诺的代码逻辑或者副作用。
一次代码重构经历
在一次重构代码经历中,我们计划开始渐进式地逐步将大型单体项目拆分成一些微服务,并且对单体代码库的代码进行优化,逐步的将可单独治理的基础设施,根据不同的领域,从大的BFF单体服务中抽离出去。
刚接触的时候,代码是很常见的简单分层架构,分层三层,接口(控制器)层、Service(复用的业务逻辑)层、数据持久化层。接口层的话常规上是一次性的复用可能性低的代码,处理网络请求、验证和响应数据的拼装;数据持久层一般是比较薄的一层代码,是对数据库访问的映射,将业务逻辑的语义翻译成数据引擎的语法,可能会有一些缓存和实体对象的转换。
最复杂的通常会是Service层的代码,在项目的探索阶段往往领域不清晰,资源有限,最简单的方法,是把复用的业务逻辑代码放在同一个包下面,以文件名和Struct为维度,作为相关领域函数的命名空间。
要抽离代码,首先找到一个相对独立,能够内聚的领域,我们首先要把这个领域对应的核心代码,抽离到一个单独的包下面,然后需要聚合数据或者处理某个大的业务逻辑的时候,通过领域包提供的方法,对领域的业务逻辑进行调用。
我们碰到的第一个问题是,因为驱动领域边界的最主要力量,是产品和业务,为了服务治理拆分的服务,往往互相之间不可避免会出现互相依赖,尤其是在BFF的代码库中。
Service层的代码会依赖特定领域的包,而特定领域的代码,也有很多逻辑,需要依赖目前依然在Service的代码。在Golang语言层面,当然不会允许循环调用。
突破循环调用的抽象
我们想到的是依赖倒置原则,要求我们在写代码的时候,高层不依赖于底层、底层也不依赖高层,他们都依赖于抽象,由使用者自己定义或者选择符合自己要求的接口,然后由服务提供方实现接口。
新实现的单独领域代码,可以遵循很多Golang类库的最佳实践,定义一个主包,主包只定义抽象接口,然后定义一个工厂方法和单例。
// ./domain/article
type SaveAndPublishArticleParam struct {
User *types.User
Draft *ArticleDraft
Action string
}
type ArticleDomain interface {
GetArticleById(ctx context.Context, id string) (*ArticleEntity, error)
SaveAndPublishArticle(ctx context.Context, id string, param *SaveAndPublishArticleParam) (*ArticleEntity, error)
}
type UserDomain interface {
GetUserById(ctx context.Context, id string) (*types.User, error)
}
// ./domain/article/factory
func CreateArticleDomain(userRepo article.UserDomain) (article.ArticleDomain, error) {
ad = *logic.SiteArticle{
User: userRepo,
}
return ad, nil
}
其中的UserDomian,就是article这个领域定义的自己要完成业务逻辑,需要用户领域能提供的能力。UserDomain接口的方法,本来就是Service层已经实现的,所以只需要将service层的user对象直接传进来,就可以实现访问。
最终达到的目标是,service层依赖的是article包定义的抽象接口,而article包接口的实现类,依赖的也是article包定义的抽象,无论article的实现类还是service层都依赖的是抽象,而不是依赖具体实现。
定义好抽象和实现类之后,还差一步,才能实现运行时的有效调用,这个时候就是控制反转登场的时候,我们需要在一个不会被service层和article领域依赖的包中,初始化Service层的对象和article领域的实现类,通过调用各自提供的绑定方法,完成抽象依赖到运行时实现对象的绑定。完成了这一步,在静态代码层面,就解除了循环依赖。
// ./server
func StartServer(ctx context.Context) error {
ur := service.NewUserService(ctx, ....)
...
ad, _ := articlefactory.CreateArticleDomain(ur)
ur.BindArticleDomain(ad)
...
runHttpServer(ctx, ...)
}
通过示例,我们看到了,只需要通过很简单的代码依赖注入和控制反转的方法,就可以为代码带来很大的灵活性,使代码结构变得更加有弹性。
我们编程的时候,也要透过现象看到本质,在我们重构的代码中,运行时的控制流中,我们关注的应该是函数的循环调用,只有在函数之间互现嵌套调用,才真正有可能发生死循环之类的问题。
而静态代码层面,通过限制包层面的循环引用,更大限度的避免了不可预期的死循环发生,无论静态层面是否有显式循环依赖,运行时都有可能产生循环调用。
回顾依赖倒置和控制反转
依赖倒置原则包含两条主要规则:
1. 高层模块不应该依赖于低层模块。二者都应该依赖于抽象。
2. 抽象不应该依赖于具体实现。具体实现应该依赖于抽象。
换句话说,依赖倒置原则的核心思想是通过依赖抽象(如接口或抽象类)来实现模块之间的依赖,而不是直接依赖具体实现。
通过依赖抽象,模块之间的依赖关系变得更加松散。如果具体实现发生变化,只需要修改依赖于抽象的部分,而不需要修改依赖于具体实现的模块。由于高层模块依赖于抽象,可以轻松地替换或扩展具体实现,而不影响高层模块的功能。依赖抽象使得高层模块更容易进行单元测试,因为可以方便地替换具体实现为模拟对象(Mock)。
控制反转是一个广义的设计原则,它指的是将对象的创建和管理控制权从应用程序代码中移交给一个外部容器或框架。
通过这种方式,对象不再自行控制其依赖对象的创建和管理,而是由IoC容器来完成。这种方式反转了传统的控制流,因此称为“控制反转”。
依赖对象由IoC容器注入,减少了类与类之间的直接依赖,降低了代码耦合度。可以更容易地替换依赖对象,实现模块化开发和系统的灵活扩展。
用依赖倒置和控制反转,突破Golang循环调用限制之后的思考https://mp.weixin.qq.com/s/ZfXabzh0I8hBcKmpQMu2JQ