干净架构(The Clean Architecture)是 Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中,提出的一种适用于复杂业务系统的软件架构方式。干净架构的理念非常精炼,其中最核心的就是向内依赖原则。由于其并没有规定实施细节,因此各种采用不同语言、框架和库的软件系统都可以采用这种架构方式。这带来了很大的灵活性,但同时也增加了开发人员的实践难度。本文以一个 Go 语言开发的 Web 后端服务(汤博乐交友 应用后端服务)为例,来阐述干净架构的一些实践细节,期望对大家理解干净架构有所帮助。
在干净架构出现之前,已经有一些其它架构,包括 Hexagonal Architecture 、 Onion Architecture 、 Screaming Architecture 、 DCI 和 BCE 。这些架构本质都是类似的,它们都采用分层的方式来达到一个共同的目标,分离关注。干净架构将这些架构的核心理念提取了出来,形成了一种更加通用和灵活的架构。
干净架构的设计理念如下图所示:
采用干净架构的系统,可以达成以下目标:
可以看到干净架构是围绕业务规则来设计的,核心就是保证业务代码的稳定性。
干净架构最核心的原则就是代码依赖关系只能从外向内,而不能反之。干净架构的每一圈层代表软件系统的不同部分,越往里抽象程度越高。外层为机制,内层为策略。这里说的依赖关系,具体指的是内层代码不能引用外层代码的命名软件实体,包括类、方法、函数和数据类型等。
实体用于封装企业范围的业务规则。实体可以是拥有方法的对象,也可以是数据结构和函数的集合。如果没有企业,只是单个应用,那么实体就是应用里的业务对象。这些对象封装了最通用和高层的业务规则,极少会受到外部变化的影响。任何操作层面的改动都不会影响到这一层。
用例是特定于应用的业务逻辑,一般用来完成用户的某个操作。用例协调数据流向或者流出实体层,并且在此过程中通过执行实体的业务规则来达成用例的目标。用例层的改动不会影响到内部的实体层,同时也不会受外层的改动影响,比如数据库、UI 和框架的变动。只有而且应当应用的操作发生变化的时候,用例层的代码才随之修改。
接口适配器层的主要作用是转换数据,数据从最适合内部用例层和实体层的结构转换成适合外层(比如数据持久化框架)的结构。反之,来自于外部服务的数据也会在这层转换为内层需要的结构。
最外层由各种框架和工具组成,比如 Web 框架、数据库访问工具等。通常在这层不需要写太多代码,大多是一些用来跟内层通信的胶水代码。这一层包含了所有实现细节,把实现细节锁定在这一层能够减少它们的改动对整个系统造成的伤害。
干净架构并没有定死图中的四层,可以按需增加或减少层数。前提是保证向内依赖原则,并且抽象的层级越往内越高。
向内依赖原则限定内层代码不能依赖外层代码,但如果内层代码确实需要调用外层代码代码怎么办?这个时候可以采用 依赖反转原则(Dependency Inversion Principle)。内层代码将其所依赖的外层代码定义为接口(Interface),外层代码实现该接口。这样依赖就反转了过来,变成了外层代码依赖内层代码。
跨层传递的数据结构通常应比较简单。可以是语言提供的基本数据类型,简单的数据传输对象,函数参数,哈希表等。重要的是保证数据结构的隔离性和简单性,不要违反向内依赖原则。
我们要开发的 Web 服务提供 HTTP 接口给移动客户端,业务领域是 2C 领域,复杂程度不如 2B 业务。但同样有框架无关性、可测试性、UI 无关性、数据库无关性、外部代理无关性这些要求,因此也可以使用干净架构,同时按照自身特点做一些改动。
该 Web 服务使用了对高并发场景支持良好的 Go 语言来开发,为了不从零开始构造轮子,使用了 Iris 这个 Web 框架。不过得益于干净架构,使用什么语言和框架并不重要,切换它们并不会影响到核心业务逻辑代码,因此对代码结构影响不大。
具体的代码目录结构如下:
复制代码
. ├── cmd # 控制台应用 ├── config.yml # 配置文件 ├── dependency # 外部依赖实现 │ ├── cache │ ├── pay │ ├── repository │ ├──sms │ └── util ├── entity # 实体 ├──interface# 外部依赖接口 │ ├── cache │ ├── pay │ ├── repository │ ├──sms │ └── util ├── main.go # Main 程序 ├──service# 业务逻辑 ├── util # 项目内用到的一些工具类和函数 └── web # Web 应用 ├── app.go ├── controller ├── factory # 对象工厂,用来构造 Web 应用里需要的各种对象,主要是业务对象 ├── middleware ├── model └── view
目录结构大致与干净架构对齐,其中 entity 目录对应实体层,service 目录对应用例层,web 目录和 cmd 目录对应接口适配器层,分别面向 Web 和控制台,dependency 目录对应框架和驱动层。
用图形来描述如下:
上图跟干净架构的圈层图有几点不同:
Service 层需要调用外层 Dependency 的接口,比如从数据库读取和保存数据、支付、发送短信等,但又不能直接依赖外层接口,因为这会违反向内依赖原则。不过可以按照依赖反转原则,将这些依赖抽象成为 Interface 层,对应 interface 目录。Service 层和 Dependency 层都依赖于 Interface 层,这样就避免了内层 Service 依赖外层 Dependency。
Interface 层能够避免业务代码依赖于具体技术,比如使用什么类型的数据库、使用 ORM 还是 Raw SQL、使用哪种支付方式、使用哪家短信发送服务等。只要外部依赖接口保持不变,就可以任意替换外部依赖的实现。Dependency 层的代码不多,大多是使用第三方 SDK 来完成某个功能,但最容易发生变化。通过 Interface 层能够将这种变化的影响范围缩到最小。
整个应用代码里,最重要的部分就是业务逻辑相关的代码,因此需要重点关注这部分的代码的可测试性。由于 Service 层所有的外部依赖都通过依赖反转转换成了对 Interface 层的依赖,因此可以在测试的时候注入实现了指定 Interface 的模拟对象来替换外部服务,这样业务代码就可以在脱离外部服务的情况下进行单元测试。当然最终还是需要跟实际的外部服务一起进行系统测试。
干净架构原文里说不要跨层传递实体,但这样的话在强类型语言(比如 Go)里面需要在每层定义许多额外的数据类型,并且还要在各层之间进行数据类型转换。这会增加很多额外且繁琐的代码,因此在我们的实践中并没有遵循这一规定,允许跨层传递实体。由于实体位于最内层,其它所有层都可以依赖,所以并没有违反向内依赖原则。
下面以几乎每个应用都有的用户注册和登录功能为例,来演示上述架构如何落地为代码。代码来自于 汤博乐交友 这款社交 APP 的后端服务。为了减少代码篇幅,只保留了结构体定义和方法签名,去掉了方法的具体实现代码。
相关代码从内层到外层依次为:
entity/user.go
复制代码
packageentity ... funcinit(){ rand.Seed(time.Now().UnixNano()) } typeUserstruct{ IDint`json:"id"` Usernamestring`json:"username"` passwordstring Avatarstring`json:"avatar"` Mobilestring`json:"mobile"` Emailstring`json:"email"` Gradeint`json:"grade"` ExpireAt util.Time`json:"expireAt"` InvitationCodestring`json:"invitationCode"` CreatedAt util.Time`json:"createdAt"` UpdatedAt util.Time`json:"updatedAt"` } func(e *User)RandUsername(){ ... } func(e *User)Password()string{ ... } func(e *User)SetPassword(passwordstring, encryptbool)(err error){ ... } func(e *User)CheckPassword(passwordstring)bool{ ... } GoCopy
interface/repository/user.go
复制代码
packagerepository ... typeIUserinterface{ Save(user entity.User) (idint, err error) ByID(idint) (user entity.User, err error) ByUsername(usernamestring) (user entity.User, err error) ByIDs(ids []int) (es []entity.User, err error) } GoCopy
service/account.go
复制代码
packageservice ... typeAccountstruct{ userRepo repository.IUser } funcNewAccount( userRepo repository.IUser, )*Account{ return&Account{ userRepo: userRepo, } } func(s *Account)SaveUser(u entity.User)(user entity.User, err error){ ... } func(s *Account)UserByID(idint)(user entity.User, err error){ ... } func(s *Account)UserByUsername(usernamestring)(user entity.User, err error){ ... } func(s *Account)UserByIDs(ids []int)(es []entity.User, err error){ ... } GoCopy
web/controller/account.go
复制代码
packagecontroller ... typeAccountstruct{ Base AccountService *service.Account } funcNewAccount( accountService *service.Account, )*Account{ return&Account{ AccountService: accountService, } } func(c *Account)PostRegister(){ ... } func(c *Account)PostLogin(){ ... } func(c *Account)GetLogout(){ ... } func(c *Account)GetInfo(){ ... } func(c *Account)PostEdit(){ ... } GoCopy
dependency/repository/user.go
复制代码
packagerepository ... typeuserstruct{ IDint Usernamestring Passwordstring Avatarstring Mobile sql.NullString Email sql.NullString Gradeint ExpireAt mysql.NullTime`db:"expire_at"` InvitationCodestring`db:"invitation_code"` CreatedAt util.Time`db:"created_at"` UpdatedAt util.Time`db:"updated_at"` } funcfromUserEntity(e entity.User)(d user){ ... } func(d *user)toUserEntity()(e entity.User){ ... } typeUserstruct{ *sqlx.DB tablestring } funcNewUser(db *sqlx.DB)*User{ return&User{db,"user"} } func(r *User)Save(e entity.User)(idint, err error){ ... } func(r *User)ByID(idint)(e entity.User, err error){ ... } func(r *User)ByUsername(usernamestring)(e entity.User, err error){ ... } func(r *User)ByIDs(ids []int)(es []entity.User, err error){ ... } GoCopy
注意,上述代码里的各个结构体里的成员都是用的 Interface 类型,这样就允许在创建结构体对象的时候注入任意实现了指定 Interface 的对象,包括模拟外部服务的对象,以便后续进行单元测试。
The Clean Architecture
本文所提出的 Web 服务架构来自于个人对干净架构的理解和实践,这里抛砖引玉,欢迎大家一起讨论和指正错误。