Contents

[架构]常见的架构设计思想

这片文章想讲一下软件架构的基础,一些基本的原则和思想。我理解的架构是指导我们设计和实现软件的方法论;这意味着我们对复杂度、可扩展性、稳定性(分别对应迭代上手成本、迭代效率、事故损失)这三个方面有非常高的要求,尤其是复杂度,对修改代码的影响范围、成本、难易程度都有显著的影响,是架构要解决的核心问题。
另外,我们也不额外考虑性能,因为业务中的性能瓶颈距离我们讨论的问题往往还有几个数量级,往往是异步、流程优化、io等待等方面更加能有作为,采取什么样的设计方法能起到的作用微乎其微。

设计原则

关于原则,我想说一句话,原则是开发迭代中总结出来的经验,而不是开发迭代的规范。判断是不是原则适用的场景(而不是无脑用原则)往往更加有效,也更加困难。类似“没有银弹”。

KISS: Keep it simple, stupid,不要引入不必要的复杂
DRY: Don‘t repeat youself,不要重复代码
迪米特法则: The Least Knowledge Principle,使用者不知道实现细节
SOLID原则:

  • S 单一职责原则:对象应该仅具有一种单一功能
  • O 开闭原则:软件的目标应该是可扩展,不可修改
  • L 里氏替换原则:子类在任何代码中替换父类,行为一致
  • I 接口隔离原则:多个特定接口要好于一个宽泛用途的接口
  • D 依赖反转原则:抽象不应该依赖于具体,具体应当依赖于抽象(DDD就是这样,实际需求 ->抽象领域 -> 实现细节)

上面这些法则,几乎都在说:
代码简单可复用,架构高内聚低耦合

过程而非对象

面向对象(OOP)还是面向过程(函数式FP)?
这里的面向过程是对过程做结构化抽象,并不是平摊代码,这种明显的靶子行为不在我们讨论范围内。

我们先看看面向对象的核心特征(Java为例):封装、继承、多态

  • 封装:和自己数据有关的操作封装成成员方法,并控制可见性
  • 继承:得到父类的属性和方法
  • 多态:不同类型调用同一个成员方法会产生不同的结果

我们看看非面向对象的做法(Go为例):

  • 封装:和自己数据有关的操作封装成接收器,并控制可见性(非常类似);和自己数据无关的操作,直接做成普通函数
  • 继承:得到别人的属性和方法都用组合的方式,避免构造函数、重载、层次设计
  • 多态:类型之间没有继承关系,不同类型实现同一个接口的方法。调用的时候按自己的类型去做出对应行为

封装:函数和数据的耦合

编程的宗派,王垠

对象作为数据访问的方式,是有一定好处的。然而“面向对象”(多了“面向”两个字),就是把这种本来良好的思想东拉西扯,牵强附会,发挥过了头。很多面向对象语言号称“所有东西都是对象”(Everything is an Object),把所有函数都放进所谓对象里面,叫做“方法”(method),把普通的函数叫做“静态方法”(static method)。实际上呢,只有极少需要抽象的时候(为访问对象数据提供间接的封装),你需要使用内嵌于对象之内,跟数据紧密结合的“方法”。其他的时候,你其实只是想表达数据之间的变换操作,这些完全可以用普通的函数表达,而且这样做更加简单和直接。
这种把所有函数放进方法的做法是本末倒置的,因为函数并不属于对象。绝大部分函数是独立于对象的,它们不能被叫做“方法”。强制把所有函数放进它们本来不属于的对象里面,把它们全都作为“方法”,导致了面向对象代码逻辑过度复杂。很简单的想法,非得绕好多道弯子才能表达清楚。很多时候这就像把自己的头塞进屁股里面。

过度封装会导致几个结局:

  • 类的职责逐渐变得模糊,越来越大,远远超出了设计时的职责
  • 对象的成员变量太多,且有没有赋值、赋值产生的依赖很难管理
  • 对象的成员函数太多,类有哪些功能、哪些函数很难辨别

我们实践的时候,会有entity作为数据的封装,和一系列操作entity自身的成员方法。但如果涉及别的对象呢?我们有converter,helper等方法包作为补充

继承vs组合

不要仅仅为了代码复用而继承。当你使用组合来实现代码复用的时候,是不会产生继承关系的。过度使用继承的时候,如果修改了父类,会损坏所有的子类。这是因为子类和父类存在紧耦合。
不要仅仅为了多态而继承。如果你的类之间没有继承关系,并且你想要实现多态,那么你可以通过接口+组合的方式来实现,这样不仅可以实现代码重用,同时也可以实现运行时的灵活性。

Rust程序设计语言

子类不应总是共享其父类的所有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性。

考虑到实际的工程往往都是多层次的组合和复用,继承能带来的收益非常小,但是后期的维护成本却是很难评估的。所以我建议在任何时候都不要使用继承。

我们讨论完面向对象的核心特质:封装、继承、多态,发现这些行为的滥用都需要格外地小心。这也是为什么工业界越来越多的go和rust,仅仅从这一章节讨论的结果来说,这些更年轻的语言汲取了面向对象(数据封装、成员方法)和函数式(接口、一等公民)的优点,取消了他们诸多迷惑的特性,这是在任何语言都可以借鉴的行为。

“干净”的代码,贼差的性能

数据和方法

数据是肉,方法是血。按数据里有多少量的方法,我们划分为下面几种模型。我们按保存用户信息这样一个简单的操作来举例

失血模型

1
2
3
4
// dal 代码
func saveUser(user){...}
// service 代码
saveUser(user)

失血模型中,领域对象相当于一个数据实体,里面只有get和set方法。没有dao,service直接操作数据库。

  • service:操作数据库、做业务逻辑
  • model/do:数据实体和一些get/set方法
    失血模型的优点是链路简单易于理解。但是仅仅适用于非常简单的业务,比如一些定时任务或脚本。service和model缺乏中间层的做法,导致一些内聚的过程只能通过函数来简单封装。

贫血模型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// service 代码
userManager.Save(user)
// model 代码
struct UserManager {
	
}
func (UserManager *) Save(user UserDTO){
	userDO := converter.UserDTO2UserDO(user);
	dal.SaveUser(userDO);
	...
}
func (UserManager *) Query(para QueryPara){...}
// dal 代码
func SaveUser(user UserDO){...}

贫血模型中,领域对象包含了自己的原子领域逻辑,而组合逻辑在Service层。

  • service :跨领域的组合服务
  • model/manager:领域原子服务
  • dao/do:数据实体和一些get/set方法

Note:如果没有model层,就是传统的MVC架构,其实是个失血模型。
贫血模型的优点是系统层次清楚、单向依赖,模块复用性强且灵活,适用于比较复杂的系统。中间层的model也可以相互依赖,根据领域模型的方法完成抽象并确定依赖关系;也可以按照原子化能力沉淀的方式抽象manager

充血模型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// service 代码
userModel.save()
// model 代码
struct UserModel{
	name string
	id   int64
}

func (UserManager *u) Save(user UserDTO){
	userDO := u.convert2DO(user);
	dal.SaveUser(userDO);
	...
}

func (UserManager *u) convert2DO(user UserDTO) UserDO {...}

充血模型中,绝大多业务逻辑都应该被放在领域对象里面,包括持久化逻辑,而Service层是很薄的一层,仅仅封装事务和少量逻辑,不和DAO层打交道。

  • service :组合服务 也叫事务服务
  • model:包含get/set方法、数据持久化、原子服务的逻辑

充血模型的层次较深,对象包含的方法很多(且很难被其他模块复用)。好处是完全面向对象,对象和领域对齐。

实现vs抽象

这里说的实现和抽象不是依赖反转里的,实现指的是数据库、数据接口、接口定义这些东西,抽象指的是业务逻辑
我们在写任何CURD的东西的时候,总有两种思路需要去做:

  1. 我要自上而下的拆解需求,最终设计成数据库表、接口参数
  2. 我要自下而上的设计代码分层结构,写的时候,在接口参数的基础上做计算和外部数据获取,写入数据库;读的时候把数据库对象层层丰富,最终满足业务要求
    当我们思考实现的时候,我们会思考