设计原则

SOLID 原则

单⼀职责(Single Responsibility Principle)

单⼀职责(SRP):Single Responsibility Principle,⼀个类只负责完成⼀个职责或者功能。不要设计⼤⽽全的类,要设计粒度⼩、功能单⼀的类。单⼀职责原则是为了实现代码⾼内聚、低耦合,提⾼代码的复⽤性、可读性、可维护性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ...省略其他属性和⽅法...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class UserInfo:
def __init__(self):
self.__userId = None
self.__username = None;
self.__email = None;
self.__telephone = None;
self.__createTime = None;
self.__lastLoginTime = None;
self.__avatarUrl = None;
self.__provinceOfAddress = None; // 省
self.__cityOfAddress = None; // 市
self.__regionOfAddress = None; // 区
self.__detailedAddress = None; // 详细地址
// ...省略其他属性和⽅法...
1

对于这个问题,有两种不同的观点。⼀种观点是,UserInfo 类包含的都是跟⽤户相关的信息,所有的属性和⽅法都⾪属于⽤户这样⼀个业务模型,满⾜单⼀职责原则;另⼀种观点是,地址信息在 UserInfo 类中,所占的⽐重⽐较⾼,可以继续拆分成独⽴的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类
的职责更加单⼀。

哪种观点更对呢?实际上,要从中做出选择,我们不能脱离具体的应⽤场景。如果在这个社交产品中,⽤户的地址信息跟其他信息⼀样,只是单纯地⽤来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得⽐较好,之后⼜在产品中添加了电商的模块,⽤户的地址信息还会⽤在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独⽴成⽤户物流信息(或者叫地址信息、收货信息等)。

从刚刚这个例⼦,我们可以总结出,不同的应⽤场景、不同阶段的需求背景下,对同⼀个类的职责是否单⼀的判定,可能都是不⼀样的。在某种应⽤场景或者当下的需求背景下,⼀个类的设计可能已经满⾜单⼀职责原则了,但如果换个应⽤场景或着在未来的某个需求背景下,可能就不满⾜了,需要继续拆分成粒度更细的类。

综上所述,评价⼀个类的职责是否⾜够单⼀,我们并没有⼀个⾮常明确的、可以量化的标准,可以说,这是件⾮常主观、仁者⻅仁智者⻅智的事情。实际上,在真正的软件开发中,我们也没必要过于未⾬绸缪,过度设计。所以,我们可以先写⼀个粗粒度的类,满⾜业务需求。随着业务的发展,如果粗粒度的类越来越庞⼤,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成⼏个更细粒度的类。这就是所谓的持续重构(后⾯的章节中我们会讲到)。

听到这⾥,你可能会说,这个原则如此含糊不清、模棱两可,到底该如何拿捏才好啊?我这⾥还有⼀些⼩技巧,能够很好地帮你,从侧⾯上判定⼀个类的职责是否够单⼀。⽽且,我个⼈觉得,下⾯这⼏条判断原则,⽐起很主观地去思考类是否职责单⼀,要更有指导意义、更具有可执⾏性:

  • 类中的代码⾏数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进⾏拆分;
  • 类依赖的其他类过多,或者依赖类的其他类过多,不符合⾼内聚、低耦合的设计思想,我们就需要考虑对类进⾏拆分;
  • 私有⽅法过多,我们就要考虑能否将私有⽅法独⽴到新的类中,设置为 public ⽅法,供更多的类使⽤,从⽽提⾼代码的复⽤性;
  • ⽐较难给类起⼀个合适名字,很难⽤⼀个业务名词概括,或者只能⽤⼀些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
  • 类中⼤量的⽅法都是集中操作类中的某⼏个属性,⽐如,在 UserInfo 例⼦中,如果⼀半的⽅法都是在操作address 信息,那就可以考虑将这⼏个属性和对应的⽅法拆分出来。

类的职责是否设计得越单⼀越好?

单⼀职责原则通过避免设计⼤⽽全的类,避免将不相关的功能耦合在⼀起,来提⾼类的内聚性。同时,类职责单⼀,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的⾼内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

开闭原则(Open Closed Principle)

开闭原则(OCP):Open Closed Principle,对扩展开放,对修改关闭。添加⼀个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、⽅法、属性等),⽽⾮修改已有代码(修改模块、类、⽅法、属性等)的⽅式来完成。关于定义,我们有两点要注意。第⼀点是,开闭原则并不是说完全杜绝修改,⽽是以最⼩的修改代码的代价来完成新功能的开发。第⼆点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能⼜被认定为“扩展”。

⽽且,我们要认识到,添加⼀个新功能,不可能任何模块、类、⽅法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做⼀些初始化操作,才能构建成可运⾏的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核⼼、最复杂的那部分逻辑代码满⾜开闭原则。
前⾯我们提到,写出⽀持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?如果你开发的是⼀个业务导向的系统,⽐如⾦融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有⾜够的了解,能够知道当下以及未来可能要⽀持的业务需求。如果你开发的是跟业务⽆关的、通⽤的、偏底层的系统,⽐如,框架、组件、类库,你需要了解“它们会被如何使⽤?今后你打算添加哪些功能?使⽤者未来会有哪些更多的功能需求?”等问题。

不过,有⼀句话说得好,“唯⼀不变的只有变化本身”。即便我们对业务、对系统有⾜够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地⽅都预留扩展点,这样做的成本也是不可接受的。我们没必要为⼀些遥远的、不⼀定发⽣的需求去提前买单,做过度设计。

最合理的做法是,对于⼀些⽐较确定的、短期内可能就会扩展,或者需求改动对代码结构影响⽐较⼤的情况,或者实现成本不⾼的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于⼀些不确定未来是否要⽀持的需求,或者实现起来⽐较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的⽅式来⽀持扩展的需求。

⽽且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。⽐如,我们之前举的 Alert 告警的例⼦。为了更好地⽀持扩展性,我们对代码进⾏了重构,重构之后的代码要⽐之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲⼀些代码的可读性;在另⼀些场景下,代码的可读性更加重要,那我们就适当地牺牲⼀些代码的可扩展性。

  1. 如何理解“对扩展开放、对修改关闭”?

    添加⼀个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、⽅法、属性等),⽽⾮修改已有代码(修改模块、类、⽅法、属性等)的⽅式来完成。关于定义,我们有两点要注意。第⼀点是,开闭原则并不是说完全杜绝修改,⽽是以最⼩的修改代码的代价来完成新功能的开发。第⼆点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能⼜被认定为“扩展”。

  2. 如何做到“对扩展开放、修改关闭”?

    我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考⼀下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最⼩代码改动的情况下,将新的代码灵活地插⼊到扩展点上。

很多设计原则、设计思想、设计模式,都是以提⾼代码的扩展性为最终⽬的的。特别是 23 种经典设计模式,⼤部分都是为了解决代码的扩展性问题⽽总结出来的,都是以开闭原则为指导原则的。最常⽤来提⾼代码扩展性的⽅法有:多态、依赖注⼊、基于接⼝⽽⾮实现编程,以及⼤部分的设计模式(⽐如,装饰、策略、模板、职责链、状态)。

⾥式替换(Liskov Substitution Principle)

⾥式替换(LSP):⼦类对象(object of subtype/derived class)能够替换程序(program)中⽗类对象(object of base/parent class)出现的任何地⽅,并且保证原来程序的逻辑⾏为(behavior)不变及正确性不被破坏。举例: 是拿⽗类的单元测试去验证⼦类的代码。如果某些单元测试运⾏失败,就有可能说明,⼦类的设计实现没有完全地遵守⽗类的约定,⼦类有可能违背了⾥式替换原则。

⾥式替换原则是⽤来指导,继承关系中⼦类该如何设计的⼀个原则。理解⾥式替换原则,最核⼼的就是理解“design by contract,按照协议来设计”这⼏个字。⽗类定义了函数的“约定”(或者叫协议),那⼦类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这⾥的约定包括:函数声明要实现的功能;对输⼊、输出、异常的约定;甚⾄包括注释中所罗列的任何特殊说明。

理解这个原则,我们还要弄明⽩⾥式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和⾥式替换有点类似,但它们关注的⻆度是不⼀样的。多态是⾯向对象编程的⼀⼤特性,也是⾯向对象编程语⾔的⼀种语法。它是⼀种代码实现的思路。⽽⾥式替换是⼀种设计原则,⽤来指导继承关系中⼦类该如何设计,⼦类的设计要保证在替换⽗类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。

接⼝隔离原则(Interface Segregation Principle)

接⼝隔离原则(ISP):调⽤⽅不应该被强迫依赖它不需要的接⼝。举例: Config 接⼝拆分为 Updater 和 Viewer 两个接⼝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public interface Updater {
void update();
}

public interface Viewer {
String outputInPlainText();
Map output();
}

public class RedisConfig implemets Updater, Viewer {
//...省略其他属性和⽅法...
@Override
public void update() { //... }
@Override
public String outputInPlainText() { //... }
@Override
public Map output() { //...}
}

public class KafkaConfig implements Updater {
//...省略其他属性和⽅法...
@Override
public void update() { //... }
}

public class MysqlConfig implements Viewer {
//...省略其他属性和⽅法...
@Override
public String outputInPlainText() { //... }
@Override
public Map output() { //...}
}

public class SimpleHttpServer {
private String host;
private int port;
private Map> viewers = new HashMap<>();

public SimpleHttpServer(String host, int port) {//...}

public void addViewers(String urlDirectory, Viewer viewer) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList());
}
this.viewers.get(urlDirectory).add(viewer);
}

public void run() { //... }
}

public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);

public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater =
new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();

ScheduledUpdater kafkaConfigUpdater =
new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();

SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mysqlConfig);
simpleHttpServer.run();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class Updater(object):
def __init__(self):
pass
def update(self):
pass

class Viewer(object):
def __init__(self):
pass
def outputInPlainText(self)->str:
pass
def output(self)->dict:
pass

class RedisConfig(Updater,Viewer):
def __init__(self,config):
pass

#...省略其他属性和⽅法...

def update(self):
#...
pass
def outputInPlainText(self):
#...
pass
def output(self):
#...
pass
class KafkaConfig(Updater):
def __init__(self,config):
pass

#...省略其他属性和⽅法...
def update(self):
#...
pass

class MySqlConfig(Viewer):
def __init__(self,config):
pass

#...省略其他属性和⽅法...
def outputInPlainText(self):
#...
pass
def output(self):
#...
pass

class SimpleHttpServer(object):
def __init__(self,host:str,port:int):
self.__host = host
self.__port = port
self.__viewers = {}
#...

def addViewer(self,urlDirectory, viewer):
if urlDirectory not in viewers.keys():
viewers[urlDirectory] = []
self.__viewers[urlDirectory] = viewer

def run(self):
# ...
pass

if __name__ == '__main__':
configSource = ZookeeperConfigSource();
redisConfig = RedisConfig(configSource);
kafkaConfig = KakfaConfig(configSource);
mysqlConfig = MySqlConfig(configSource);


redisConfigUpdater = ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();

kafkaConfigUpdater = ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();

simpleHttpServer = SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mysqlConfig);
simpleHttpServer.run();
1

依赖反转原则(Dependency Inversion Principle)

依赖反转原则(DIP): Dependency Inversion Principle ⾼层模块(high-level modules)不要依赖低层模块(low-level)。⾼层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。举例
Tomcat和Java WebApp,两者都依赖同⼀个“抽象”,也就是 Servlet 规范。

  1. 控制反转
    控制反转(IOC): Inversion Of Control,框架提供了⼀个可扩展的代码⻣架,⽤来组装对象、管理整个执⾏流程。程序员利⽤框架进⾏开发的时候,只需要往预留的扩展点上,添加跟⾃⼰业务相关的代码,就可以利⽤框架来驱动整个程序流程的执⾏。

    实际上,控制反转是⼀个⽐较笼统的设计思想,并不是⼀种具体的实现⽅法,⼀般⽤来指导框架层⾯的设计。这⾥所说的“控制”指的是对程序执⾏流程的控制,⽽“反转”指的是在没有使⽤框架之前,程序员⾃⼰控制整个程序的执⾏。在使⽤框架之后,整个程序的执⾏流程通过框架来控制。流程的控制权从程序员“反转”给了框架。

  2. 依赖注⼊
    依赖注⼊(DI): Dependency Injection 依赖注⼊的⽅式来将依赖的类对象传递进来,这样就提⾼了代码的扩展性,我们可以灵活地替换依赖的类

    依赖注⼊和控制反转恰恰相反,它是⼀种具体的编码技巧。我们不通过 new 的⽅式在类内部创建依赖类的对象,⽽是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等⽅式传递(或注⼊)给类来使⽤。

  3. 依赖注⼊框架
    我们通过依赖注⼊框架提供的扩展点,简单配置⼀下所有需要的类及其类与类之间依赖关系,就可以实现由框架来⾃动创建对象、管理对象的⽣命周期、依赖注⼊等原本需要程序员来做的事情。

  4. 依赖反转原则
    依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要⽤来指导框架层⾯的设计。⾼层模块不依赖低层模块,它们共同依赖同⼀个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。

KISS原则

KISS 原则是保持代码可读和可维护的重要⼿段。KISS 原则中的“简单”并不是以代码⾏数来考量的。代码⾏数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。⽽且,本身就复杂的问题,⽤复杂的⽅法解决,并不违背 KISS 原则。除此之外,同样的代码,在某个业务场景下满⾜ KISS 原则,换⼀个应⽤场景可能就不满⾜了。

对于如何写出满⾜ KISS 原则的代码,我还总结了下⾯⼏条指导原则:

  1. 不要使⽤同事可能不懂的技术来实现代码。⽐如前⾯例⼦中的正则表达式,还有⼀些编程语⾔中过于⾼级的语法等。
  2. 不要重复造轮⼦,要善于使⽤已经有的⼯具类库。经验证明,⾃⼰去实现这些类库,出 bug 的概率会更⾼,维护的成本也⽐较⾼。
  3. 不要过度优化。不要过度使⽤⼀些奇技淫巧(⽐如,位运算代替算术运算、复杂的条件语句代替 if-else、使⽤⼀些过于底层的函数等)来优化代码,牺牲代码的可读性。

YAGNI原则

YAGNI 原则的英⽂全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万⾦油了。当⽤在软件开发中的时候,它的意思是:不要去设计当前⽤不到的功能;不要去编写当前⽤不到的代码。实际上,这条原则的核⼼思想就是:不要做过度设计

再⽐如,我们不要在项⽬中提前引⼊不需要依赖的开发包。对于 Java 程序员来说,我们经常使⽤ Maven 或者Gradle 来管理依赖的类库(library)。我发现,有些同事为了避免开发中 library 包缺失⽽频繁地修改 Maven 或者 Gradle 配置⽂件,提前往项⽬⾥引⼊⼤量常⽤的 library 包。实际上,这样的做法也是违背 YAGNI 原则的。

DRY 原则(Don’t Repeat Yourself)

我们今天讲了三种代码重复的情况:实现逻辑重复、功能语义重复、代码执⾏重复。实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则。实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。除此之外,代码执⾏重复也算是违反 DRY 原则。

代码复⽤性

减少代码耦合

对于⾼度耦合的代码,当我们希望复⽤其中的⼀个功能,想把这个功能的代码抽取出来成为⼀个独⽴的模块、类或者函数的时候,往往会发现牵⼀发⽽动全身。移动⼀点代码,就要牵连到很多其他相关的代码。所以,⾼度耦合的代码会影响到代码的复⽤性,我们要尽量减少代码耦合。

满⾜单⼀职责原则

我们前⾯讲过,如果职责不够单⼀,模块、类设计得⼤⽽全,那依赖它的代码或者它依赖的代码就会⽐较多,进⽽增加了代码的耦合。根据上⼀点,也就会影响到代码的复⽤性。相反,越细粒度的代码,代码的通⽤性会越好,越容易被复⽤。

模块化

这⾥的“模块”,不单单指⼀组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独⽴的代码,封装成模块。独⽴的模块就像⼀块⼀块的积⽊,更加容易复⽤,可以直接拿来搭建更加复杂的系统。

业务与⾮业务逻辑分离

越是跟业务⽆关的代码越是容易复⽤,越是针对特定业务的代码越难复⽤。所以,为了复⽤跟业务⽆关的代码,我们将业务和⾮业务逻辑代码分离,抽取成⼀些通⽤的框架、类库、组件等。

通⽤代码下沉

从分层的⻆度来看,越底层的代码越通⽤、会被越多的模块调⽤,越应该设计得⾜够可复⽤。⼀般情况下,在代码分层之后,为了避免交叉调⽤导致调⽤关系混乱,我们只允许上层代码调⽤下层代码及同层代码之间的调⽤,杜绝下层代码调⽤上层代码。所以,通⽤的代码我们尽量下沉到更下层。

继承、多态、抽象、封装

在讲⾯向对象特性的时候,我们讲到,利⽤继承,可以将公共的代码抽取到⽗类,⼦类复⽤⽗类的属性和⽅法。利⽤多态,我们可以动态地替换⼀段代码的部分逻辑,让这段代码可复⽤。除此之外,抽象和封装,从更加⼴义的层⾯、⽽⾮侠义的⾯向对象特性的层⾯来理解的话,越抽象、越不依赖具体的实现,越容易复⽤。代码封装成模块,隐藏可变的细节、暴露不变的接⼝,就越容易复⽤

应⽤模板等设计模式

⼀些设计模式,也能提⾼代码的复⽤性。⽐如,模板模式利⽤了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复⽤。关于应⽤设计模式提⾼代码复⽤性这⼀部分,我们留在后⾯慢慢来讲解。

迪⽶特法则/最⼩知识原则

迪⽶特法则的英⽂翻译是:Law of Demeter,缩写是 LOD。也叫作最⼩知识原则,英⽂翻译为:The Least Knowledge Principle。

不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接⼝(也就是定义中的“有限知识”)。

如何理解“⾼内聚、松耦合”?

“⾼内聚、松耦合”是⼀个⾮常重要的设计思想,能够有效提⾼代码的可读性和可维护性,缩⼩功能改动导致的代码改动范围。“⾼内聚”⽤来指导类本身的设计,“松耦合”⽤来指导类与类之间依赖关系的设计。

所谓⾼内聚,就是指相近的功能应该放到同⼀个类中,不相近的功能不要放到同⼀类中。相近的功能往往会被同时修改,放到同⼀个类中,修改会⽐较集中。所谓松耦合指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,⼀个类的代码改动也不会或者很少导致依赖类的代码改动。

如何理解“迪⽶特法则”?

不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接⼝。迪⽶特法则是希望减少类之间的耦合,让类越独⽴越好。每个类都应该少了解系统的其他部分。⼀旦发⽣变化,需要了解这⼀变化的类就会⽐较少。

为什么要分 MVC 三层开发?

分层能起到代码复⽤的作⽤

同⼀个 Repository 可能会被多个 Service 来调⽤,同⼀个 Service 可能会被多个 Controller 调⽤。⽐如,UserService 中的 getUserById() 接⼝封装了通过 ID 获取⽤户信息的逻辑,这部分逻辑可能会被 UserController和 AdminController 等多个 Controller 使⽤。如果没有 Service 层,每个 Controller 都要重复实现这部分逻辑,显然会违反 DRY 原则。

分层能起到隔离变化的作⽤

分层体现了⼀种抽象和封装的设计思想。⽐如,Repository 层封装了对数据库访问的操作,提供了抽象的数据访问接⼝。基于接⼝⽽⾮实现编程的设计思想,Service 层使⽤ Repository 层提供的接⼝,并不关⼼其底层依赖的是哪种具体的数据库。当我们需要替换数据库的时候,⽐如从 MySQL 到 Oracle,从 Oracle 到 Redis,只需要改动Repository 层的代码,Service 层的代码完全不需要修改。
除此之外,Controller、Service、Repository 三层代码的稳定程度不同、引起变化的原因不同,所以分成三层来组织代码,能有效地隔离变化。⽐如,Repository 层基于数据库表,⽽数据库表改动的可能性很⼩,所以Repository 层的代码最稳定,⽽ Controller 层提供适配给外部使⽤的接⼝,代码经常会变动。分层之后,
Controller 层中代码的频繁改动并不会影响到稳定的 Repository 层。

分层能起到隔离关注点的作⽤

Repository 层只关注数据的读写。Service 层只关注业务逻辑,不关注数据的来源。Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关⼼业务逻辑。三层之间的关注点不同,分层之后,职责分明,更加符合单⼀职责原则,代码的内聚性更好。

分层能提⾼代码的可测试性

后⾯讲单元测试的时候,我们会讲到,单元测试不依赖不可控的外部组件,⽐如数据库。分层之后,Repsitory 层的代码通过依赖注⼊的⽅式供 Service 层使⽤,当要测试包含核⼼业务逻辑的 Service 层代码的时候,我们可以⽤ mock 的数据源替代真实的数据库,注⼊到 Service 层代码中。代码的可测试性和单元测试我们后⾯会讲到,这⾥你稍微了解即可。

分层能应对系统的复杂性

所有的代码都放到⼀个类中,那这个类的代码就会因为需求的迭代⽽⽆限膨胀。我们知道,当⼀个类或⼀个函数的代码过多之后,可读性、可维护性就会变差。那我们就要想办法拆分。拆分有垂直和⽔平两个⽅向。⽔平⽅向基于业务来做拆分,就是模块化;垂直⽅向基于流程来做拆分,就是这⾥说的分层。

还是那句话,不管是分层、模块化,还是 OOP、DDD,以及各种设计模式、原则和思想,都是为了应对复杂系统,应对系统的复杂性。对于简单系统来说,其实是发挥不了作⽤的,就是俗话说的“杀鸡焉⽤⽜⼑”。

BO、VO、Entity 存在的意义是什么?

在前⾯的章节中,我们提到,针对 Controller、Service、Repository 三层,每层都会定义相应的数据对象,它们分别是 VO(View Object)、BO(Business Object)、Entity,例如 UserVo、UserBo、UserEntity。在实际的开发中,VO、BO、Entity 可能存在⼤量的重复字段,甚⾄三者包含的字段完全⼀样。在开发的过程中,我们经常需要重复定义三个⼏乎⼀样的类,显然是⼀种重复劳动。

相对于每层定义各⾃的数据对象来说,是不是定义⼀个公共的数据对象更好些呢?
实际上,我更加推荐每层都定义各⾃的数据对象这种设计思路,主要有以下 3 个⽅⾯的原因。

  • VO、BO、Entity 并⾮完全⼀样。⽐如,我们可以在 UserEntity、UserBo 中定义 Password 字段,但显然不能在 UserVo 中定义 Password 字段,否则就会将⽤户的密码暴露出去。
  • VO、BO、Entity 三个类虽然代码重复,但功能语义不重复,从职责上讲是不⼀样的。所以,也并不能算违背DRY 原则。在前⾯讲到 DRY 原则的时候,针对这种情况,如果合并为同⼀个类,那也会存在后期因为需求的变化⽽需要再拆分的问题。
  • 为了尽量减少每层之间的耦合,把职责边界划分明确,每层都会维护⾃⼰的数据对象,层与层之间通过接⼝交互。数据从下⼀层传递到上⼀层的时候,将下⼀层的数据对象转化成上⼀层的数据对象,再继续处理。虽然这样的设计稍微有些繁琐,每层都需要定义各⾃的数据对象,需要做数据对象之间的转化,但是分层清晰。对于⾮常⼤的项⽬来说,结构清晰是第⼀位的!

既然 VO、BO、Entity 不能合并,那如何解决代码重复的问题呢?

从设计的⻆度来说,VO、BO、Entity 的设计思路并不违反 DRY 原则,为了分层清晰、减少耦合,多维护⼏个类的成本也并不是不能接受的。但是,如果你真的有代码洁癖,对于代码重复的问题,我们也有⼀些办法来解决。

我们前⾯讲到,继承可以解决代码重复问题。我们可以将公共的字段定义在⽗类中,让 VO、BO、Entity 都继承这个⽗类,各⾃只定义特有的字段。因为这⾥的继承层次很浅,也不复杂,所以使⽤继承并不会影响代码的可读性和可维护性。后期如果因为业务的需要,有些字段需要从⽗类移动到⼦类,或者从⼦类提取到⽗类,代码改起来也并不复杂。

前⾯在讲“多⽤组合,少⽤继承”设计思想的时候,我们提到,组合也可以解决代码重复的问题,所以,这⾥我们还可以将公共的字段抽取到公共的类中,VO、BO、Entity 通过组合关系来复⽤这个类的代码。

代码重复问题解决了,那不同分层之间的数据对象该如何互相转化呢?

当下⼀层的数据通过接⼝调⽤传递到上⼀层之后,我们需要将它转化成上⼀层对应的数据对象类型。⽐如,Service 层从 Repository 层获取的 Entity 之后,将其转化成 BO,再继续业务逻辑的处理。所以,整个开发的过程会涉及“Entity 到 BO”和“BO 到 VO”这两种转化。

VO、BO、Entity 都是基于贫⾎模型的,⽽且为了兼容框架或开发库(⽐如 MyBatis、Dozer、BeanUtils),我们还需要定义每个字段的 set ⽅法。这些都违背 OOP 的封装特性,会导致数据被随意修改。那到底该怎么办好呢?

前⾯我们也提到过,Entity 和 VO 的⽣命周期是有限的,都仅限在本层范围内。⽽对应的 Repository 层和Controller 层也都不包含太多业务逻辑,所以也不会有太多代码随意修改数据,即便设计成贫⾎、定义每个字段的set ⽅法,相对来说也是安全的。

不过,Service 层包含⽐较多的业务逻辑代码,所以 BO 就存在被任意修改的⻛险了。但是,设计的问题本身就没有最优解,只有权衡。为了使⽤⽅便,我们只能做⼀些妥协,放弃 BO 的封装特性,由程序员⾃⼰来负责这些数据对象的不被错误使⽤。