本文已授权 InfoQ 转载,其它网站如有转载,请注明出处。

什么是干净架构

Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中提出了一种适用于复杂业务系统的软件架构方式。在干净架构出现之前,已经有一些其它架构,包括 Hexagonal ArchitectureOnion ArchitectureScreaming ArchitectureDCIBCE。这些架构本质都是类似的,它们都采用分层的方式来达到一个共同的目标,分离关注。干净架构将这些架构的核心理念提取了出来,形成了一种更加通用和灵活的架构。

干净架构的设计理念如下图所示:

CleanArchitecture

采用干净架构的系统,可以达成以下目标:

  1. 框架无关性。干净架构不依赖于具体的框架和库,而仅把它们当作工具,因此不会受限于任何具体的框架和库。
  2. 可测试性。业务规则可以在没有 UI、数据库、Web 服务器等外部依赖的情况下进行测试。
  3. UI 无关性。UI 改变可以在不改动系统其它部分的情况下完成,比如把 Web UI 替换成控制台 UI。
  4. 数据库无关性。可以很容易地切换数据库类型,比如从关系型数据库 MySQL 切换到文档型数据库 MongoDB,因为业务规则并没有绑定到某种特定的数据库类型。
  5. 外部代理无关性。业务规则对外部世界一无所知,因此外部代理的变动不会影响到业务代码。

可以看到干净架构是围绕业务规则来设计的,核心就是保证业务代码的稳定性。

向内依赖原则(Inward Dependency Rule)

干净架构最核心的原则就是代码依赖关系只能从外向内,而不能反之。干净架构的每一圈层代表软件系统的不同部分,越往里抽象程度越高。外层为机制,内层为策略。这里说的依赖关系,具体指的是内层代码不能引用外层代码的命名软件实体,包括类、方法、函数和数据类型等。

实体(Entities)

实体用于封装企业范围的业务规则。实体可以是拥有方法的对象,也可以是数据结构和函数的集合。如果没有企业,只是单个应用,那么实体就是应用里的业务对象。这些对象封装了最通用和高层的业务规则,极少会受到外部变化的影响。任何操作层面的改动都不会影响到这一层。

用例(Use Cases)

用例是特定于应用的业务逻辑,一般用来完成用户的某个操作。用例协调数据流向或者流出实体层,并且在此过程中通过执行实体的业务规则来达成用例的目标。用例层的改动不会影响到内部的实体层,同时也不会受外层的改动影响,比如数据库、UI 和框架的变动。只有而且应当应用的操作发生变化的时候,用例层的代码才随之修改。

接口适配器(Interface Adapters)

接口适配器层的主要作用是转换数据,数据从最适合内部用例层和实体层的结构转换成适合外层(比如数据持久化框架)的结构。反之,来自于外部服务的数据也会在这层转换为内层需要的结构。

框架和驱动(Frameworks and Drivers)

最外层由各种框架和工具组成,比如 Web 框架、数据库访问工具等。通常在这层不需要写太多代码,大多是一些用来跟内层通信的胶水代码。这一层包含了所有实现细节,把实现细节锁定在这一层能够减少它们的改动对整个系统造成的伤害。

关于层数

干净架构并没有定死图中的四层,可以按需增加或减少层数。前提是保证向内依赖原则,并且抽象的层级越往内越高。

跨层访问

依赖反转原则

向内依赖原则限定内层代码不能依赖外层代码,但如果内层代码确实需要调用外层代码代码怎么办?这个时候可以采用 依赖反转原则(Dependency Inversion Principle)。内层代码将其所依赖的外层代码定义为接口(Interface),外层代码实现该接口。这样依赖就反转了过来,变成了外层代码依赖内层代码。

传递数据

跨层传递的数据结构通常应比较简单。可以是语言提供的基本数据类型,简单的数据传输对象,函数参数,哈希表等。重要的是保证数据结构的隔离性和简单性,不要违反向内依赖原则。

采用干净架构来组织 Web 应用代码

下面要讲的 Web 服务是 围观 APP 的后端 API 服务。该服务提供 HTTP 接口给移动客户端,业务领域是 2C 领域,复杂程度不如 2B 业务。但同样有框架无关性、可测试性、UI 无关性、数据库无关性、外部代理无关性这些要求,因此也可以使用干净架构,同时按照自身特点做一些改动。

该 Web 服务使用了对高并发场景支持良好的 Go 语言来开发,为了不从零开始构造轮子,使用了 Iris 这个 Web 框架。不过得益于干净架构,使用什么语言和框架并不重要,切换它们并不会影响到核心业务逻辑代码,因此对代码结构影响不大。

具体的代码目录结构如下:

.
├── commands # CLI 命令行应用,包括启动 Web 应用的命令
├── config.yml # 配置文件
├── dependencies # 外部依赖实现
├── entities # 实体
├── interfaces # 外部依赖接口
├── main.go # 主程序入口
├── services # 业务逻辑实现
├── utils # 业务无关的小工具
└── web # Web 应用

目录结构大致与干净架构对齐,其中 entities 目录对应实体层,services 目录对应用例层,web 目录和 commands 目录对应接口适配器层,分别面向 Web 和控制台,dependencies 目录对应框架和驱动层。

用图形来描述如下:

依赖反转

Dependencies 层虽处于最外层,但它不依赖于任何内层,不过需要实现 interfaces 层的接口。所有对应用外系统的访问都属于 dependencies 层,包括数据库、缓存等内部服务,以及短信发送、邮件发送等外部服务。通过 interfaces 层使得应用不会跟外部系统产生紧耦合,只要接口保持不变,可任意替换应用所依赖的服务。Services 层需要调用外层 dependencies 的服务,但又不能直接调用,因为这会违反向内依赖原则。通过使用依赖反转,将这些依赖抽象成为 interfaces 层,就避免了出现这种情况。

可测试性

整个应用代码里,最重要的部分就是业务逻辑相关的代码,因此需要重点关注这部分的代码的可测试性。由于 services 层所有的外部依赖都通过依赖反转转换成了对 interfaces 层的依赖,因此可以在测试的时候注入实现了指定 interfaces 的模拟对象来替换外部服务,这样业务代码就可以在脱离外部服务的情况下进行单元测试。当然最终还是需要跟实际的外部服务一起进行系统测试。

跨层数据传递

干净架构原文里说不要跨层传递实体,但这样的话在强类型语言(比如 Go)里面需要在每层定义许多额外的数据类型,并且还要在各层之间进行数据类型转换。这会增加很多额外且繁琐的代码,因此在我们的实践中并没有遵循这一规定,允许跨层传递实体。由于实体位于最内层,其它所有层都可以依赖,所以并没有违反向内依赖原则。

代码示例

下面以几乎每个应用都有的用户注册和登录功能为例,来演示上述架构如何落地为代码。代码来自于笔者开发的 围观 APP 的后端 API 服务,该服务采用 Go 语言开发,使用了 Iris 这个 Web 框架。为了减少代码篇幅,只保留了结构体定义和方法签名,去掉了方法的具体实现代码。

相关代码从内层到外层依次为:

entities/user.go

package entities

...

func init() {
	rand.Seed(time.Now().UnixNano())
}

type User struct {
	ID             int    `json:"id"`
	Username       string `json:"username"`
	password       string
	Avatar         string    `json:"avatar"`
	Mobile         string    `json:"mobile"`
	Email          string    `json:"email"`
	Grade          int       `json:"grade"`
	ExpireAt       util.Time `json:"expireAt"`
	InvitationCode string    `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(password string, encrypt bool) (err error) {
	...
}

func (e *User) CheckPassword(password string) bool {
	...
}

interfaces/repository.go

package interfaces

...

type IUserRepo interface {
	Save(user entities.User) (id int, err error)
	ByID(id int) (user entities.User, err error)
	ByUsername(username string) (user entities.User, err error)
	ByIDs(ids []int) (es []entities.User, err error)
}

...

services/account.go

package services

...

type Account struct {
	userRepo interfaces.IUserRepo
}

func NewAccount(
	userRepo interfaces.IUserRepo,
) *Account {
	return &Account{
		userRepo: userRepo,
	}
}

func (s *Account) SaveUser(u entities.User) (user entities.User, err error) {
	...
}

func (s *Account) UserByID(id int) (user entities.User, err error) {
	...
}

func (s *Account) UserByUsername(username string) (user entities.User, err error) {
	...
}

func (s *Account) UserByIDs(ids []int) (es []entities.User, err error) {
	...
}

web/controller/account.go

package controller

...

type Account struct {
	Base
	AccountService *services.Account
}

func NewAccount(
	accountService *services.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() {
	...
}

dependencies/repository/user.go

package repository

...

type user struct {
	ID             int
	Username       string
	Password       string
	Avatar         string
	Mobile         sql.NullString
	Email          sql.NullString
	Grade          int
	ExpireAt       mysql.NullTime `db:"expire_at"`
	InvitationCode string         `db:"invitation_code"`
	CreatedAt      util.Time      `db:"created_at"`
	UpdatedAt      util.Time      `db:"updated_at"`
}

func fromUserEntity(e entities.User) (d user) {
	...
}

func (d *user) toUserEntity() (e entities.User) {
	...
}

type User struct {
	*sqlx.DB
	table string
}

func NewUser(db *sqlx.DB) *User {
	return &User{db, "user"}
}

func (r *User) Save(e entities.User) (id int, err error) {
	...
}

func (r *User) ByID(id int) (e entities.User, err error) {
	...
}

func (r *User) ByUsername(username string) (e entities.User, err error) {
	...
}

func (r *User) ByIDs(ids []int) (es []entities.User, err error) {
	...
}

注意,上述代码里的各个 service 结构体里的成员都是用的 interface 类型,这样就允许在创建服务对象的时候注入任意实现了指定 interface 的对象,包括模拟对象,以便进行单元测试。

本文使用了 Go 这种强类型语言来阐述干净架构的实现。虽然干净架构对 Java、Go 这样的支持接口类型的语言更合适,但也可以用于 Python 这样的弱类型语言,只不过没那么强制,约定的东西更多些。如果想学习干净架构在 Python Web 服务开发中的实际运用,可以参考此项目 Sanic in Practice

参考资料

  1. The Clean Architecture
  2. Iris
  3. Sanic in Practice