11 月 19 - 20 日 Apache Pulsar 社区年度盛会来啦,立即报名! 了解详情
写点什么

干净架构在 Web 服务开发中的实践

  • 2019-01-10
  • 本文字数:0 字

    阅读完需:约 1 分钟

干净架构在 Web 服务开发中的实践

干净架构(The Clean Architecture)是 Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中,提出的一种适用于复杂业务系统的软件架构方式。干净架构的理念非常精炼,其中最核心的就是向内依赖原则。由于其并没有规定实施细节,因此各种采用不同语言、框架和库的软件系统都可以采用这种架构方式。这带来了很大的灵活性,但同时也增加了开发人员的实践难度。本文以一个 Go 语言开发的 Web 后端服务(围观 App 后端服务)为例,来阐述干净架构的一些实践细节,期望对大家理解干净架构有所帮助。


什么是干净架构

在干净架构出现之前,已经有一些其它架构,包括 Hexagonal ArchitectureOnion ArchitectureScreaming ArchitectureDCIBCE。这些架构本质都是类似的,它们都采用分层的方式来达到一个共同的目标,分离关注。干净架构将这些架构的核心理念提取了出来,形成了一种更加通用和灵活的架构。


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



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


  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 服务提供 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 目录对应框架和驱动层。


用图形来描述如下:



上图跟干净架构的圈层图有几点不同:


  1. Dependency 层虽然处于最外层,但它并不依赖于内层,所以跟内层之间有空白间隙。

  2. Dependency 层需要实现 Interface 层定义的接口。


依赖反转


Service 层需要调用外层 Dependency 的接口,比如从数据库读取和保存数据、支付、发送短信等,但又不能直接依赖外层接口,因为这会违反向内依赖原则。不过可以按照依赖反转原则,将这些依赖抽象成为 Interface 层,对应 interface 目录。Service 层和 Dependency 层都依赖于 Interface 层,这样就避免了内层 Service 依赖外层 Dependency。


Interface 层能够避免业务代码依赖于具体技术,比如使用什么类型的数据库、使用 ORM 还是 Raw SQL、使用哪种支付方式、使用哪家短信发送服务等。只要外部依赖接口保持不变,就可以任意替换外部依赖的实现。Dependency 层的代码不多,大多是使用第三方 SDK 来完成某个功能,但最容易发生变化。通过 Interface 层能够将这种变化的影响范围缩到最小。


可测试性


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


跨层数据传递


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


代码示例

下面以几乎每个应用都有的用户注册和登录功能为例,来演示上述架构如何落地为代码。代码来自于“围观”这款社交 APP 的后端服务。为了减少代码篇幅,只保留了结构体定义和方法签名,去掉了方法的具体实现代码。


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


entity/user.go


package entity
...
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 { ...}
GoCopy
复制代码


interface/repository/user.go


package repository
...
type IUser interface { Save(user entity.User) (id int, err error) ByID(id int) (user entity.User, err error) ByUsername(username string) (user entity.User, err error) ByIDs(ids []int) (es []entity.User, err error)}
GoCopy
复制代码


service/account.go


package service
...
type Account struct { userRepo repository.IUser}
func NewAccount( userRepo repository.IUser,) *Account { return &Account{ userRepo: userRepo, }}
func (s *Account) SaveUser(u entity.User) (user entity.User, err error) { ...}
func (s *Account) UserByID(id int) (user entity.User, err error) { ...}
func (s *Account) UserByUsername(username string) (user entity.User, err error) { ...}
func (s *Account) UserByIDs(ids []int) (es []entity.User, err error) { ...}
GoCopy
复制代码


web/controller/account.go


package controller
...
type Account struct { Base AccountService *service.Account}
func NewAccount( 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


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 entity.User) (d user) { ...}
func (d *user) toUserEntity() (e entity.User) { ...}
type User struct { *sqlx.DB table string}
func NewUser(db *sqlx.DB) *User { return &User{db, "user"}}
func (r *User) Save(e entity.User) (id int, err error) { ...}
func (r *User) ByID(id int) (e entity.User, err error) { ...}
func (r *User) ByUsername(username string) (e entity.User, err error) { ...}
func (r *User) ByIDs(ids []int) (es []entity.User, err error) { ...}
GoCopy
复制代码


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


更多资料


The Clean Architecture


Iris Web Framework


本文所提出的 Web 服务架构来自于个人对干净架构的理解和实践,这里抛砖引玉,欢迎大家一起讨论和指正错误。


原文地址: https://blog.jaggerwang.net/clean-architecture-in-web-service/


2019-01-10 15:359742

评论 4 条评论

发布
用户头像
本文内容已有更新,感兴趣的可查看原文。https://blog.jaggerwang.net/clean-architecture-in-practice/
2019-11-21 18:46
回复
没有更多了
发现更多内容

使用注解 @requires 给 SAP CAP CDS 模型添加权限控制

Jerry Wang

云原生 CAP Cloud SAP 10月月更

请求投放个性化广告时,如何征得用户同意?

HMS Core

广告

长安链源码分析之交易过程分析(6)

1024,我们干了点儿大事 | StarRocks 2.4 新版本特性介绍

StarRocks

数据库

React源码分析5-commit

goClient1992

React

SpringCloud-04 Feign学习笔记

游坦之

10月月更

一次 Redis 事务使用不当引发的生产事故

悟空聊架构

redis 事务 悟空聊架构 10月月更 @Transactional

长安链源码分析之交易过程分析(5)

React源码分析6-hooks源码

goClient1992

React

java培训哪家比较靠谱

小谷哥

从输入URL到渲染的过程中到底发生了什么?

loveX001

JavaScript

关于JavaScript的本地存储方案

CoderBin

JavaScript 前端 LocalStorage 本地存储 10月月更

长安链源码分析之交易过程分析(7)

盘它!基于CANN的辅助驾驶AI实战案例,轻松搞定车辆检测和车距计算!

华为云开发者联盟

人工智能 华为云 辅助驾驶 企业号十月 PK 榜

大数据培训学习就业难吗

小谷哥

web前端开发培训女生学习怎么样

小谷哥

百度搜索业务交付无人值守实践与探索

百度Geek说

Pytho 企业号十月 PK 榜 智能测试

百度前端高频react面试题总结

beifeng1996

React

web技术分享| 虚拟 tree

anyRTC开发者

Vue 前端 Web tree antDesign vue

倒计时第1天!2022 XDR网络安全运营新理念峰会即将开幕

未来智安XDR SEC

网络安全

杨帆:拆解研发流程,做好探索型项目的过程管理丨声网开发者创业讲堂 • 第 5 期

声网

技术管理 人工智能’

最短的桥

掘金安东尼

算法 10月月更

vue面试之Composition-API响应式包装对象原理

bb_xiaxia1998

Vue

一道React面试题把我整懵了

beifeng1996

React

微信小程序wx.getLocation审核不通过的解决方法

源字节1号

前端开发 小程序开发

一天梳理完React所有面试考察知识点

beifeng1996

React

SpringCloud-05 Hystrix学习笔记

游坦之

10月月更

腾讯前端常考vue面试题整理

bb_xiaxia1998

Vue

前端培训机构包就业靠谱吗?

小谷哥

快递单信息抽取【二】基于ERNIE1.0至ErnieGram + CRF预训练模型

汀丶

nlp 算法、

干净架构在 Web 服务开发中的实践_架构_jaggerwang_InfoQ精选文章