设计原则
# 设计原则
来源:极客时间《设计模式之美》 (opens new window)专栏
经典的设计原则包括,SOLID、KISS、YAGNI、DRY、LOD 等。
# 1. 单一职责原则(SRP)
# 1.1 如何理解单一职责原则
单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是这样的:A class or module should have a single reponsibility。翻译成中文:一个类或者模块只负责完成一个职责(或者功能)。
这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。有两种理解方式。一种理解是:把模块看作比类更加抽象的概念,类也可以看作模块。另一种理解是:把模块看作比类更加粗粒度的代码块,模块中包含多个 类,多个类组成一个模块。
比如,一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。
# 1.2 如何判断一个类是否足够单一
在真实的软件开发中,对于一个类是否职责单一的判定,是很难拿捏的。
比如:在一个社交产品中,下面的 UserInfo 类来记录用户的信息。此时 UserInfo 类的设计是否满足单一职责原则呢?
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 detailAddress; // 详细地址
// ... 省略其他属性和方法...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
对于这个问题,有两种不同的观点。一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。
实际上,要从中做出选择,不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。
再进一步延伸一下。如果做这个社交产品的公司发展得越来越好,公司内部又开发出了跟多其他产品(可以理解为其他 App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候,我们就需要继续对 UserInfo 进行拆分, 将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。
综上所述,评价一个类的职责是否足够单一并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中, 也没必要过于未雨绸缪过度设计。所以,可以先写一个粗粒度的类,满足业务需 求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以 将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,就需要考虑对类进行拆分;宽泛的标准为不超过 200 行,属性行函数分别不超过 10 个。
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,就需要考虑对类进行拆分;
- 私有方法过多,就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
# 1.3 类的职责是否设计得越单一越好
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
# 2. 开闭原则(OCP)
# 2.1 如何理解“对扩展开发,修改关闭”
开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是: software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。翻译成中文就是:软件实体(模块、类、方法等) 应该“对扩展开放、对修改关闭”。
以一段 API 接口监控告警的代码为例:
public class Alert {
private AlertRule alertRule;
private Notification notification;
public Alert(AlertRule alertRule, Notification notification) {
this.alertRule = alertRule;
this.notification = notification;
}
public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
上面代码的业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预 先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接 口的相关负责人或者团队。
现在,如果需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,也要触发告警发送通知。主要的改动有两 处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时 接口请求数:第二处是在 check() 函数中添加新的告警逻辑。具体的代码改动如下所示:
public class Alert {
private AlertRule alertRule;
private Notification notification;
public Alert(AlertRule alertRule, Notification notification) {
this.alertRule = alertRule;
this.notification = notification;
}
// 改动1:添加一个 timeoutCount 参数
public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
// 改动2:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这样的代码修改实际上存在挺多问题的。一方面对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了 check() 函数,相应的单元测试都需要修改。
如果遵循开闭原则,也就是“对扩展开放、对修改关闭”。重构的内容主要包含两部分:
- 第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;
- 第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。
具体的代码实现如下所示:
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();
public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}
public void check(ApiStateInfo apiStateInfo) {
for (handler : alertHandlers) {
handler.check(apiStateInfo);
}
}
}
public class ApiStateInfo {
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}
public abstract class AlertHandler {
private AlertRule alertRule;
private Notification notification;
public AlertHandler(AlertRule alertRule, Notification notification) {
this.alertRule = alertRule;
this.notification = notification;
}
public abstract void check(ApiStateInfo apiStateInfo);
}
public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds
if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi())
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
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
重构后的 Alert 需要通过 ApplicationContext 使用。ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*. 省略参数.*/); // 省略构造参数
notification = new Notification(/*. 省略参数.*/); // 省略构造参数
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
}
public Alert getAlert() {
return this.alert;
}
// 单例模式
public static final ApplicationContext instance = new ApplicationContext();
public ApplicationContext() {
this.initializeBeans();
}
public static ApplicationContext getInstance() {
return this.instance;
}
}
public class Demo {
public static void main(String[] args) {
ApiStateInfo apiStateInfo = new ApiStateInfo();
// 省略 apiStateInfo 属性复制
ApplicationContext.getInstance().getAlert().check(apiStateInfo);
}
}
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
现在,基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,主要的改动有下面四处。
- 第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。
- 第二处改动是:添加新的 TimeoutAlertHander 类。
- 第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。
- 第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
改动之后的代码如下所示:
public class Alert {
// 代码未改动...
}
public class ApiStateInfo {
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler {
// 代码未改动...
}
public class TpsAlertHandler extends AlertHandler {
// 代码未改动...
}
public class ErrorAlertHandler extends AlertHandler {
// 代码未改动...
}
// 改动二:添加新的 handler
public class TimeoutAlertHandler extends AlertHandler {
// 省略代码...
}
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
// 改动三:注册 handler
alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
}
//... 省略其他未改动代码... }
public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ... 省略 apiStatInfo 的 set 字段代码
// 改动四:设置 tiemoutCount 值
apiStatInfo.setTimeoutCount(289);
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
}
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
重构之后的代码更加灵活和易扩展。如果要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。
# 2.2 修改代码就意味着违背开闭原则吗?
上面例子中的改动一、改动三、改动四是否违背开闭原则?回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,就可以说,这是一个合格的代码改动。
要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
# 2.3 如何做到“对扩展开放、修改关闭”
偏向顶层的指导思想:为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
比如代码中通过 Kafka 来发送异步消息。对于这样一个功能的开发,要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当要替换新的消息队列的时候,比如将 Kafka 替换成 RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列 实现。具体代码如下所示:
// 这一部分体现了抽象意识
public interface MessageQueue {
//...
}
public class KafkaMessageQueue implements MessageQueue {
//...
}
public class RocketMQMessageQueue implements MessageQueue {
//...
}
public interface MessageFromatter {
//...
}
public class JsonMessageFromatter implements MessageFromatter {
//...
}
public class ProtoBufMessageFromatter implements MessageFromatter {
//...
}
public class Demo {
private MessageQueue msgQueue; // 基于接口而非实现编程
public Demo(MessageQueue msgQueue) { // 依赖注入
this.msgQueue = msgQueue;
}
// msgFormatter:多态、依赖注入
public void sendNotification(Notification notification, MessageFormatter msg) {
//...
}
}
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
# 2.4 如何在项目中灵活应用开闭原则
如果开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,需要了解“它们会被如何使用?今后打算添加哪些功能?使用者未来会有哪些更多的功能需求等问题。
最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
有些情况下,代码的扩展性会跟可读性相冲突。在某些场景下,代码的扩展性很重要,就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那就适当地牺牲一些代码的可扩展性。
例如 Alert 告警的例子,如果告警规则很多很复杂,相应的代码行数很多,可读性,可维护性差等问题,就可以考虑使用重构后的符合开闭原则的代码;否则只需要未重构前的代码即可。
# 3. 里式替换原则(LSP)
# 3.1 如何理解“里式替换原则”
里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
例子:父类 Transporter 使 用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继 承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息。
public class Transporter {
private HttpClient httpClient;
public Transporter(HttpClient httpClient) {
this.httpClient = httpClient;
}
public Response sendRequest(Request request) {
// ...use httpClient to send request
}
}
public class SecurityTransporter extends Transporter {
private String appId;
private String appToken;
public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
super(httpClient);
this.appId = appId;
this.appToken = appToken;
}
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}
public class Demo {
public void demoFunction(Transporter transporter) {
Reuqest request = new Request();
//...省略设置request中数据值的代码...
Response response = transporter.sendRequest(request);
//...省略其他逻辑...
}
}
// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略参数*/););
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
在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。
里式替换原则与面向对象多态的区别:
多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
# 3.2 哪些代码违背里式替换原则?
子类违背父类声明要实现的功能
父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。
在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
子类违背父类注释中所罗列的任何特殊声明
父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
# 4. 接口隔离原则(ISP)
# 4.1 如何理解“接口隔离原则”
接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为 ISP。Robert Martin 在 SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
可以把“接口”理解为下面三种东西:
- 一组 API 接口集合
- 单个 API 接口或函数
- OOP 中的接口概念
# 4.2 一组 API 接口集合
例如微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。 UserService j中有 register、login、getUserInfoById、getUserInfoByCellPhone。现在后台管理系统要实现删除用户的功能,此时最先想到的是在 UserService 接口中添加接口 deleteUserById、deleteUserByCellPhone。
但删除用户是一个非常慎重的操作,只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。当然,最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,还可以从代码设计的层面,尽量避免接口被误用。参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。
# 4.3 单个 API 接口或函数
可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。例如:
public class Statistic {
private Long max;
private Long min;
private Long avarage;
private Long sum;
}
public Statistic count(Collection<Long> dataSet) {
Statistic statistic = new Statistic();
// ... 省略计算逻辑
return statistic;
}
2
3
4
5
6
7
8
9
10
11
在上面的代码中,count() 函数的功能不够单一,包含很多不同的统计功能,比如,求最大 值、最小值、平均值等等。按照接口隔离原则,应该把 count() 函数拆成几个更小粒 度的函数,每个函数负责一个独立的统计功能。
在项目中,对每个统计需求 Statistics 定义的那几个统计信息都有涉及,那 count() 函数的设计就是合理的。相反,如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分,比如,有的只需要用到 max、min、average 这三类统计信息,有的只需要用到 average、sum。而 count() 函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景 下,count() 函数的设计就有点不合理了,应该将其拆分成粒度 更细的多个统计函数。
# 4.4 OOP 中的接口概念
可以把“接口”理解为 OOP 中的接口概念,比如 Java 中的 interface。假设项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。代码如下:
public class RedisConfig {
private ConfigSource configSource; //配置中心(比如zookeeper)
private String address;
private int timeout;
private int maxTotal;
//省略其他配置: maxWaitMillis,maxIdle,minIdle...
public RedisConfig(ConfigSource configSource) {
this.configSource = configSource;
}
public String getAddress() {
return this.address;
}
//...省略其他get()、init()方法...
public void update() {
//从configSource加载配置到address/timeout/maxTotal...
}
}
public class KafkaConfig { //...省略... }
public class MysqlConfig { //...省略... }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
假如现在有个新功能,希望 Redis、Kafka 的配置信息支持热更新。因为某些原因并不希望对 MySQL 的配置信息进行热更新。根据需求设计实现了一个 ScheduledUpdater 类,以固定时间频率(periodInSeconds)来调用 RedisConfig、KafkaConfig 的 update() 方法更新配置信息。具体的代码实现如下所示:
public interface Updater {
void update();
}
public class RedisConfig implemets Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig { //...省略其他属性和方法... }
public class ScheduledUpdater {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();;
private long initialDelayInSeconds;
private long periodInSeconds;
private Updater updater;
public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
this.updater = updater;
this.initialDelayInSeconds = initialDelayInSeconds;
this.periodInSeconds = periodInSeconds;
}
public void run() {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
updater.update();
}
}, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
}
}
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);
kafkaConfigUpdater.run();
}
}
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
现在又有了一个新的监控功能需求。通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的,希望通过 web 页面查看配置信息。可以在项目中开发一个内嵌的 SimpleHttpServer,输出项目的配置信息到一个固定的 HTTP 地址,比如:http://127.0.0.1:2389/config 。只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。不过,出于某些原因只想暴露 MySQL 和 Redis 的配置信息,不想暴露 Kafka 的配置信息。
改造后的代码如下:
public interface Updater {
void update();
}
public interface Viewer {
String outputInPlainText();
Map<String, String> output();
}
public class RedisConfig implemets Updater, Viewer {
//...省略其他属性和方法...
@Override
public void update() { //... }
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}
public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig implements Viewer {
//...省略其他属性和方法...
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}
public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Viewer>> 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<Viewer>());
}
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();
}
}
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
上面设计了两个功能非常单一的接口:Updater 和 Viewer。ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。同理,SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则。
设计思路更加灵活、易扩展、易复用。因为 Updater、Viewer 职责更加单一,单一就意味了通用、复用性好。比如,我们现在又有一个新的需求,开发一个 Metrics 性能统计模块,并且希望将 Metrics 也通过 SimpleHttpServer 显示在网页上,以方便查看。这个时候,尽管 Metrics 跟 RedisConfig 等没有任何关系,但我们仍然可以让 Metrics 类实现非常通用的 Viewer 接口,复用 SimpleHttpServer 的代码实现。具体的代码如下所示:
public class ApiMetrics implements Viewer {//...}
public class DbMetrics implements Viewer {//...}
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 final ApiMetrics apiMetrics = new ApiMetrics();
public static final DbMetrics dbMetrics = new DbMetrics();
public static void main(String[] args) {
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mySqlConfig);
simpleHttpServer.addViewer("/metrics", apiMetrics);
simpleHttpServer.addViewer("/metrics", dbMetrics);
simpleHttpServer.run();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 4.5 接口隔离原则和单一原则的区别
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
# 5. 依赖反转原则(DIP)
# 5.1 控制反转(IOC)
英文 Inversion Of Controller,缩写 IOC。控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。
public class UserServiceTest {
public static boolean doTest() {
// ...
}
public static void main(String[] args) {//这部分逻辑可以放到框架中
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
在上面的代码中,所有的流程都由程序员来控制。如果抽象出一个下面这样一个框架:
public abstract class TestCase {
public void run() {
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
public abstract boolean doTest();
}
public class JunitApplication {
private static final List<TestCase> testCases = new ArrayList<>();
public static void register(TestCase testCase) {
testCases.add(testCase);
}
public static final void main(String[] args) {
for (TestCase case: testCases) {
case.run();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
把这个简化版本的测试框架引入到工程中之后,只需要在框架预留的扩展点,也就是 TestCase 类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了。 具体的代码如下所示:
public class UserServiceTest extends TestCase {
@Override
public boolean doTest() {
// ...
}
}
// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用register()
JunitApplication.register(new UserServiceTest();
2
3
4
5
6
7
8
9
这是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。
# 5.2 依赖注入(DI)
英文 Dependency Injection,缩写 DI。
用一句话来概括:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。
例如:
// 非依赖注入实现方式
public class Notification {
private MessageSender messageSender;
public Notification() {
this.messageSender = new MessageSender(); //此处有点像hardcode
}
public void sendMessage(String cellphone, String message) {
//...省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
public class MessageSender {
public void send(String cellphone, String message) {
//....
}
}
// 使用Notification
Notification notification = new Notification();
// 依赖注入的实现方式
public class Notification {
private MessageSender messageSender;
// 通过构造函数将messageSender传递进来
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
//...省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
//使用Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);
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
继续优化上面的代码,将 MessageSender 定义成接口,基于接口而非实现编程。如下:
public class Notification {
private MessageSender messageSender;
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
this.messageSender.send(cellphone, message);
}
}
public interface MessageSender {
void send(String cellphone, String message);
}
// 短信发送类
public class SmsSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}
// 站内信发送类
public class InboxSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}
//使用Notification
MessageSender messageSender = new SmsSender();
Notification notification = new Notification(messageSender);
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
# 5.3 依赖注入框架(DI Framework)
在采用依赖注入实现的 Notification 类中,虽然不需要用类似 hard code 的方式,在类内部通过 new 来创建 MessageSender 对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要自己来实现。具体代码如下所示:
public class Demo {
public static final void main(String args[]) {
MessageSender sender = new SmsSender(); //创建对象
Notification notification = new Notification(sender);//依赖注入
notification.sendMessage("13918942177", "短信验证码:2346");
}
}
2
3
4
5
6
7
而通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
现成的依赖注入框架有很多,比如 Google Guice、Java Spring、Pico Container、Butterfly Container 等。
# 5.4 依赖反转原则
英文 Dependency Inversion Principle,缩写 DIP,中文名依赖反转原则或依赖倒置原则。
描述:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
以 Tomcat 这个 Servlet 这个容器作为例子:Tomcat 是运行 Java Web 应用程序的容器。编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
# 6. KISS、YAGNI原则
# 6.1 如何理解 KISS 原则
KISS 原则的英文描述有好几个版本,比如下面这几个:
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
# 6.2 行数越少不代表越简单
例如下面三段代码实现同样一个功能:实现同样一个功能:检查输入的字符串 ipAddress 是否是合法的 IP 地址。一个合法的 IP 地址由四个数字组成,并且通过“.”来进行分割。每组数字的取值范围是 0~255。第一组数字比较特殊,不允许为 0。对比这三段代码。
// 第一种实现方式: 使用正则表达式
public boolean isValidIpAddressV1(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}
// 第二种实现方式: 使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}
// 第三种实现方式: 不使用任何工具类
public boolean isValidIpAddressV3(String ipAddress) {
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for (int i = 0; i < length; ++i) {
char c = ipChars[i];
if (c == '.') {
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (isFirstUnit && ipUnitIntValue == 0) return false;
if (isFirstUnit) isFirstUnit = false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if (c < '0' || c > '9') {
return false;
}
if (ipUnitIntValue == -1) ipUnitIntValue = 0;
ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (unitsCount != 3) return false;
return true;
}
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
第一种实现方式使用了正则表达式;第二种实现方式使用了 StringUtils 类、Integer 类提供的一些现成的工具函数,来处理 IP 地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理 IP 地址中的字 符,来判断是否合法。容易看出第二种方式代码逻辑更加清晰,更好理解,更加“简单”,更符合 KISS 原则。
# 6.3 如何满足 KISS 原则要求
- 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等
- 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。
- 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件 语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
# 6.4 如何理解 YAGNI 原则
YAGNI 英文为 You Ain‘ gonna need it,直译就是:你不会需要它。它的意思就是:不要去设计当前用不到的功能,不要编写当前不用的代码。该原则的核心思想就是:不要过度设计。但不代表不需要考虑代码的扩展性问题。
在项目中引入目前未使用到的常用的 library 包,也违背了 YAGNI 原则。
KISS 原则讲的是“如何做”的问题(尽量保存简单),而 YAGNI 将的是“要不要做”的问题(不需要就不做)。
# 7. DRY 原则
# 7.1 如何理解 DRY
英文 Don't Repeat Yourself。三种典型的代码重复情况:实现逻辑重复、功能语义重复、代码执行重复。
实现逻辑重复:登录功能中校验 username 和 password 的两个方法 isValidUserName() 和 isValidPaddword() 中由于校验规则相同,所以逻辑也是相同的,看起来就是 copy-paste 一下。但并不违背 DRY 原则,它们虽然逻辑实现开起来相似,但从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管代码的实现逻辑是相同的,但语义不同,可以判定它并不违反 DRY 原则。对于包含重复代码的问题,可以通过抽象成更细粒度函数的方式来解决。
功能语义重复
例如有两个函数:isValidIp() 和 checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来 判定 IP 地址是否合法的。尽管两段代码的实现逻辑不重复,但语义重 复,也就是功能重复,可以认为它违反了 DRY 原则。
代码执行重复
例如在写登录逻辑代码时,通常的流程是校验参数、判断用户是否存在、查询用户信息。有时在不同方法中会对参数进行重复校验,而判断用户是否存在与查询用户信息的代码也可以认为是重复的,只需要查询用户信息判断查询结果是否为 null 即可判断用户是否存在。
# 7.2 代码复用性(Code Reusability)
减少代码耦合
对于高度耦合的代码在改动时往往牵一发而动全身。所以,高度耦合的代码会影响到代码的复用性,要尽量减少代码耦合。
满足单一职责原则
越细粒度的代码,代码的通用性会越好,越容易被复用。
模块化
这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。
业务与非业务逻辑隔离
越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。
通用代码下沉
在代码分层之后,为了避免交叉调用导致调用关系混乱,只允许上层代码调用下层代码及同层代码之间的调用。所以通用的代码尽量下沉到更下层。
继承、封装、抽象、多态
利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。
利用多态,可以动态地替换一段代码的部分逻辑,让这段代码可复用。
抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。
应用模板等设计模式
有一个著名的原则,叫作“Rule of Three”。意思是说第一次遇到可以不考虑复用性,第二次遇到复用场景时,再将其重构使其复用。其中的 three 实际是 two 的意思。
# 8. 迪米特法则(LOD)
迪米特法则可用于实现“高内聚、松耦合”
# 8.1 高内聚与松耦合
所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。 相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。
所谓松耦合是说,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。
“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。不过,这两者并非完全独立不相干。高内聚有助于松耦合,松耦合又需要高内聚的支持。
很多设计原则都以实现代码的“高内聚、松耦合”为目的,比如单一职责原则、基于接口而非实现编程等。
例如图中左边部分的代码结构是“高内聚、松耦合”;右边则是“低内聚、高耦合”
# 8.2 迪米特法则(LOD)
英文 Law of Demeter,缩写 LOD。也叫做最小知识原则,The Least Knowledge Principle。可翻译为:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。
不该有直接依赖关系的类之间,不要有依赖
搜索引擎爬取网页的功能为例:NetworkTransporter 类负责底层网络通信,根据请求获取数据;HtmlDownloader 类用来通过 URL 获取网页;Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。
public class NetworkTransporter { // 省略属性和其他方法... public Byte[] send(HtmlRequest htmlRequest) { //... } } public class HtmlDownloader { private NetworkTransporter transporter;//通过构造函数或IOC注入 public Html downloadHtml(String url) { Byte[] rawHtml = transporter.send(new HtmlRequest(url)); return new Html(rawHtml); } } public class Document { private Html html; private String url; public Document(String url) { this.url = url; HtmlDownloader downloader = new HtmlDownloader(); this.html = downloader.downloadHtml(url); } //... }
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这段代码虽然“能用”,能实现我们想要的功能,但是它不够“好用”,有比较多的设计缺陷。
首先 NetworkTransporter 类作为一个底层网络通信类,它的功能应尽可能通用,而不只是服务于下载 HTML,违背迪米特法则,依赖了不该有直接依赖关系的 HtmlRequest 类。应该将 HtmlRequest 替换为 address 和 content 交给 NetworkTransporter。
Document 类主要有三点问题。第一,构造函数中的 downloader.downloadHtml() 逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。第二,HtmlDownloader 对象在构造函数中通过 new 来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。第三,从业务含义上来讲,Document 网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则。
重构后的代码如下:
public class NetworkTransporter { // 省略属性和其他方法... public Byte[] send(String address, Byte[] data) { //... } } public class HtmlDownloader { private NetworkTransporter transporter;//通过构造函数或IOC注入 public Html downloadHtml(String url) { HtmlRequest htmlRequest = new HtmlRequest(url); Byte[] rawHtml = transporter.send(htmlRequest.getAddress(), htmlRequest.getContent().getBytes()); return new Html(rawHtml); } } public class Document { private Html html; private String url; public Document(String url, Html html) { this.html = html; this.url = url; } //... } // 通过一个工厂方法来创建Document public class DocumentFactory { private HtmlDownloader downloader; public DocumentFactory(HtmlDownloader downloader) { this.downloader = downloader; } public Document createDocument(String url) { Html html = downloader.downloadHtml(url); return new Document(url, html); } }
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有依赖关系的类之间,尽量只依赖必要的接口
例子:序列化与反序列化
public class Serialization { public String serialize(Object object) { String serializedResult = ...; //... return serializedResult; } public Object deserialize(String str) { Object deserializedResult = ...; //... return deserializedResult; } }
1
2
3
4
5
6
7
8
9
10
11
12
13实际中某些类只用到了序列化,某些只用到反序列化,如果为了迎合迪米特法则,就应该将该类拆分为两个类。但拆分后的类却违背了高内聚的设计思想,修改时需要修改两个不同的类。
如果既不想违背高内聚的设计思想,也不想违背迪米特法则。可以通过引入两个接口就能轻松解决这个问题:
public interface Serializable { String serialize(Object object); } public interface Deserializable { Object deserialize(String text); } public class Serialization implements Serializable, Deserializable { @Override public String serialize(Object object) { String serializedResult = ...; ... return serializedResult; } @Override public Object deserialize(String str) { Object deserializedResult = ...; ... return deserializedResult; } } public class DemoClass_1 { private Serializable serializer; public Demo(Serializable serializer) { this.serializer = serializer; } //... } public class DemoClass_2 { private Deserializable deserializer; public Demo(Deserializable deserializer) { this.deserializer = deserializer; } //... }
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尽管还是要往 DemoClass_1 的构造函数中,传入包含序列化和反序列化的 Serialization 实现类,但是,依赖的 Serializable 接口只包含序列化操作,DemoClass_1 无法使用 Serialization 类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。
上面的的代码实现思路,也体现了“基于接口而非实现编程”的设计原则,结合迪米特法则,可以总结出一条新的设计原则,那就是“基于最小接口而非最大实现编程”。
# 9. 实战一:积分兑换系统
# 9.1 需求分析和设计
积分是一种常见的营销手段,很多产品都会通过它来促进消费、增加用户粘性,比如淘宝积分、信用卡积分、商场消费积分等等。根据产品经理给的产品设计文档(PRD)、线框图来设计,这种想法有点狭隘。技术人员应该更多地参与到产品设计中,通过借鉴已有产品的功能,来设计自己的系统。
# 9.1.1 需求分析
笼统地来讲,积分系统包括两大功能,一个是赚取积分,一个是消费积分。
赚取积分:
赚取积分功能包括积分赚取渠道,如下订单、每日签到、评论等;还包括积分兑换规矩,比如订单金额与积分的兑换比例,每日签到赠送多少积分等。
消费积分:
消费积分包括积分消费渠道,如抵扣订单金额、兑换优惠券、积分换购、参与积分活动等;还包括积分兑换规则,比如多少积分可以兑换抵扣多少订单金额,一张优惠券可以多少积分来兑换等。
除了这些功能需求,可能还会有积分的过期时间等需求,可以通过**线框图、用户用例(user case)**或者叫做用户故事(user story)来细化业务流程。
用户用例有点儿类似单元测试用例。它侧重情景化,其实就是模拟用户如何使用产品,描述用户在一个特定的应用场景里的一个完整的业务操作流程。所以,它包含更多的细节,且更加容易被人理解。比如,有关积分有效期的用户用例可以进行如下的设计:
- 用户在获取积分的时候,会告知积分的有效期;
- 用户在使用积分的时候,会优先使用快过期的积分;
- 用户在查询积分明细的时候,会显示积分的有效期和状态(是否过期);
- 用户在查询总可用积分的时候,会排除掉过期的积分。
积分系统的需求如下所示:
积分赚取和兑换规则
积分的消费渠道包括:抵扣订单金额、兑换优惠券、积分换购、参与活动扣积分等。可以根据不同的消费渠道,设置不同的积分兑换规则。比如,积分换算成消费抵扣金额的比例是 10%,也就是 10 积分可以抵扣 1 块钱;100 积分可以兑换 15 块钱的优惠券等。
积分及其明细查询
查询用户的总积分,以及赚取积分和消费积分的历史记录。
# 9.1.2 系统设计
面向对象设计聚焦在代码层面(主要是针对类),那系统设计就是聚焦在架构层面(主要是针对模块),两者有很多相似之处。可以借鉴面向对象设计的过程来做系统设计。
合理地将功能划分到不同的模块
类比面向对象设计,系统设计实际上就是将合适的功能放到合适的模块中。合理地划分模块也可以做到模块层面的高内聚、低耦合,架构整洁清晰。
对于前面罗列的所有功能点,有下面三种模块划分方法:
第一种划分方式是:积分赚取渠道及兑换规则、消费渠道及兑换规则的管理和维护(增删改查),不划分到积分系统中,而是放到更上层的营销系统中。这样积分系统就会变得非常简单,只需要负责增加积分、减少积分、查询积分、查询积分明细等这几个工作。
比如,用户通过下订单赚取积分。订单系统通过异步发送消息或者同步调用接口的方式,告知营销系统订单交易成功。营销系统根据拿到的订单信息,查询订单对应的积分兑换规则(兑换比例、有效期等),计算得到订单可兑换的积分数量,然后调用积分系统的接口给用户增加积分。
第二种划分方式是:积分赚取渠道及兑换规则、消费渠道及兑换规则的管理和维护,分散在各个相关业务系统中,比如订单系统、评论系统、签到系统、换购商城、优惠券系统等。用户下订单成功之后,订单系统根据商品对应的积分兑换比例,计算所能兑换的积分数量,然后直接调用积分系统给用户增加积分。
第三种划分方式是:所有的功能都划分到积分系统中,包括积分赚取渠道及兑换规则、消费渠道及兑换规则的管理和维护。用户下订单成功之后,订单系统直接告知积分系统订单交易成功,积分系统根据订单信息查询积分兑换规则,给用户增加积分。
怎么判断哪种模块划分合理呢?可以反过来通过看它是否符合高内聚、低耦合特性来判断。如果一个功能的修改或添加,经常要跨团队、跨项目、跨系统才能完成,那说明模块划分的不够合理,职责不够清晰,耦合过于严重。
除此之外,为了避免业务知识的耦合,让下层系统更加通用,一般不希望下层系统(也就是被调用的系统)包含太多上层系统(也就是调用系统)的业务信息,但是,可以接受上层系统包含下层系统的业务信息。比如,订单系统、优惠券系统、换购商城等作为调用积分系统的上层系统,可以包含一些积分相关的业务信息。但是,反过来,积分系统中最好不要包含太多跟订单、优惠券、换购等相关的信息。
所以,综合考虑更倾向于第一种和第二种模块划分方式。但是,不管选择这两种中的哪一种,积分系统所负责的工作是一样的,只包含积分的增、减、查询,以及积分明细的记录和查询。
设计模块与模块之间的交互关系
设计模块与模块之间的交互关系就是确定有哪些系统跟积分系统之间有交互以及如何进行交互。
比较常见的系统之间的交互方式有两种,一种是同步接口调用,另一种是利用消息中间件异步调用。第一种方式简单直接,第二种方式的解耦效果更好。
比如,用户下订单成功之后,订单系统推送一条消息到消息中间件,营销系统订阅订单成功消息,触发执行相应的积分兑换逻辑。这样订单系统就跟营销系统完全解耦,订单系统不需要知道任何跟积分相关的逻辑,而营销系统也不需要直接跟订单系统交互。
除此之外,上下层系统之间的调用倾向于通过同步接口,同层之间的调用倾向于异步消息调用。比如,营销系统和积分系统是上下层关系,它们之间就比较推荐使用同步接口调用。
同层之间一方面会有互相调用的情况,另一方面从业务上来讲有平行关系,如果设计成调用,可能存在互相调用,调用关系交叉复杂,使用消息中间件,实际上就能将网状调用关系,解耦为星状调用关系,调用关系更加简洁清晰。而且,使用消息中间件,更能体现同层之间的平行关系。
设计模块的接口、数据库、业务模型
见下面 9.2 内容。
# 9.2 遵从设计原则的积分系统
# 9.2.1 数据库设计
只需要一张记录积分流水明细的表就可以了,表中记录积分的赚取和消费流水。用户积分的各种统计数据,比如总积分、总可用积分等,都可以通过这张表来计算得到。
# 9.2.2 接口设计
接口设计要符合单一职责原则,粒度越小通用性就越好。但是,接口粒度太小也会带来一些问题。比如,一个功能的实现要调用多个小接口,一方面如果接口调用走网络(特别是公网),多次远程接口调用会影响性能;另一方面,本该在一个接口中完成的原子操作,现在分拆成多个小接口来完成,就可能会涉及分布式事务的数据一致性问题(一个接口执行成功了,但另一个接口执行失败了)。所以,为了兼顾易用性和性能,可以借鉴 facade(外观)设计模式,在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用。
# 9.2.3 业务模型设计
对于积分系统来说,因为业务相对比较简单,所以基于贫血模型的传统开发模式就足够了。
从开发的角度来说,我们可以把积分系统作为一个独立的项目,来独立开发,也可以跟其他业务代码(比如营销系统)放到同一个项目中进行开发。从运维的角度来说,可以将它跟其他业务一块部署,也可以作为一个微服务独立部署。具体选择哪种开发和部署方式,可以参考公司当前的技术架构来决定。
实际上,积分系统业务比较简单,代码量也不多,更倾向于将它跟营销系统放到一个项目中开发部署。只要做好代码的模块化和解耦,让积分相关的业务代码跟其他业务代码之间边界清晰,没有太多耦合,后期如果需要将它拆分成独立的项目来开发部署,那也并不困难。
省略代码实现。
使用到的设计原则、思想和模式:
# 10. MVC 三层开发疑问解答
# 10.1 为什么要分 MVC 三层开发
分成起到代码复用的作用
同一个 Repository 可能会被多个 Service 来调用,同一个 Service 可能会被多个 Controller 调用。如果没有 Service 层,每个 Controller 都要重复实现这部分逻辑,显然会违反 DRY 原则。
分成起到隔离变化的作用
分层体现了一种抽象和封装的设计思想。比如,Repository 层封装了对数据库访问的操作,提供了抽象的数据访问接口。基于接口而非实现编程的设计思想,Service 层使用 Repository 层提供的接口,并不关心其底层依赖的是哪种具体的数据库。当需要替换数据库的时候,比如从 MySQL 到 Oracle,从 Oracle 到 Redis,只需要改动 Repository 层的代码,Service 层的代码完全不需要修改。
除此之外,Controller、Service、Repository 三层代码的稳定程度不同、引起变化的原因不同,所以分成三层来组织代码,能有效地隔离变化。比如,Repository 层基于数据库表,而数据库表改动的可能性很小,所以 Repository 层的代码最稳定,而 Controller 层提供适配给外部使用的接口,代码经常会变动。分层之后,Controller 层中代码的频繁改动并不会影响到稳定的 Repository 层。
分区起到隔离关注点的作用
Repository 层只关注数据的读写。Service 层只关注业务逻辑,不关注数据的来源。Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关心业务逻辑。三层之间的关注点不同,分层之后,职责分明,更加符合单一职责原则,代码的内聚性更好。
关于参数校验,如果是参数合法性校验就在 Controller 层进行,如果是业务相关的校验则可以放在 Service 层校验。
分层能提高代码可测试性
分层之后,Repsitory 层的代码通过依赖注入的方式供 Service 层使用,当要测试包含核心业务逻辑的 Service 层代码的时候,可以用 mock 的数据源替代真实的数据库,注入到 Service 层代码中。
分层能应对系统的复杂性
当一个类或一个函数的代码过多之后,可读性、可维护性就会变差,就要想办法拆分。拆分有垂直和水平两个方向。水平方向基于业务来做拆分,就是模块化;垂直方向基于流程来做拆分,就是分层。
不管是分层、模块化,还是 OOP、DDD,以及各种设计模式、原则和思想,都是为了应对复杂系统,应对系统的复杂性。对于简单系统来说,其实是发挥不了作用的,就是俗话说的“杀鸡焉用牛刀”。
# 10.2 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 都继承这个父类,各自只定义特有的字段。因为这里的继承层次很浅,也不复杂,所以使用继承并不会影响代码的可读性和可维护性。后期如果因为业务的需要,有些字段需要从父类移动到子类,或者从子类提取到父类,代码改起来也并不复杂。
代码重复问题解决了,那不同分层之间的数据对象该如何互相转化呢?
整个开发的过程会涉及“Entity 到 BO”和“BO 到 VO”这两种转化。Java 中提供了多种数据对象转化工具,比如 BeanUtils、Dozer 等,可以大大简化繁琐的对象转化工作。
VO、BO、Entity 都是基于贫血模型的,且每个字段都有 set 方法。是否违背 OOP 的封装特性?
Entity 和 VO 的生命周期是有限的,都仅限在本层范围内。而对应的 Repository 层和 Controller 层也都不包含太多业务逻辑,所以也不会有太多代码随意修改数据,即便设计成贫血、定义每个字段的 set 方法,相对来说也是安全的。
但 Service 层包含比较多的业务逻辑代码,所以 BO 就存在被任意修改的风险了。但是,设计的问题本身就没有最优解,只有权衡。为了使用方便只能做一些妥协,放弃 BO 的封装特性,由程序员自己来负责这些数据对象的不被错误使用。
# 11. 实战二:实现一个支持各种统计规则的性能计数器
# 11.1 项目背景
设计开发一个通用的框架,应用到各种业务系统中,能够获取接口调用的各种统计信息,比如,响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON 格式、网页格式、自定义显示格式等)输出到各种终端(Console 命令行、HTTP 网页、Email、日志文件、自定义输出终端等),支持实时计算、查看数据的统计信息,
# 11.2 需求分析和设计
性能计数器作为一个跟业务无关的功能,完全可以把它开发成一个独立的框架或者类库,集成到很多业务系统中。而作为可被复用的框架,除了功能性需求之外,非功能性需求也非常重要。
# 11.2.1 功能性需求
拆解需求:
- 接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。
- 统计信息的类型:max、min、avg、percentile、count、tps 等。
- 统计信息显示格式:Json、Html、自定义显示格式。
- 统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端。
借助设计产品时常用到的线框图,把最终数据的显示样式画出来,会更加一目了然:
从线框图中还能挖掘出了下面几个隐藏的需求:
- 统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。
- 统计时间区间:框架需要支持自定义统计时间区间,比如统计最近 10 分钟的某接口的 tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等。
- 统计时间间隔:对于主动触发统计,还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件。
# 11.2.2 非功能性需求
对于这样一个通用的框架的开发还需要考虑很多非功能性的需求:
易用性
易用性听起来更像是一个评判产品的标准。在开发这样一个技术框架的时候,也要有产品意识。框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活等等,都是应该花心思去思考和设计的。有的时候,文档写得好坏甚至都有可能决定一个框架是否受欢迎。
性能
对于需要集成到业务系统的框架来说,不希望框架本身的代码执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,希望框架本身对内存的消耗不能太大。
扩展性
这里说的扩展性跟之前讲到的代码的扩展性有点类似,都是指在不修改或尽量少修改代码的情况下添加新的功能。但是这两者也有区别。之前讲到的扩展是从框架代码开发者的角度来说的。这里所说的扩展是从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件。
例如 Feign (个 HTTP 客户端框架),可以在不修改框架源码的情况下,用如下方式来扩展自己的编解码方式、日志、拦截器等。
Feign feign = Feign.builder() .logger(new CustomizedLogger()) .encoder(new FormEncoder(new JacksonEncoder())) .decoder(new JacksonDecoder()) .errorDecoder(new ResponseErrorDecoder()) .requestInterceptor(new RequestHeadersInterceptor()).build(); public class RequestHeadersInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { template.header("appId", "..."); template.header("version", "..."); template.header("timestamp", "..."); template.header("token", "..."); template.header("idempotent-token", "..."); template.header("sequence-id", "..."); } public class CustomizedLogger extends feign.Logger { //... } public class ResponseErrorDecoder implements ErrorDecoder { @Override public Exception decode(String methodKey, Response response) { //... } }
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容错性
对于性能计数器框架来说,不能因为框架本身的异常导致接口请求出错。所以要对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。
通用性
为了提高框架的复用性,能够灵活应用到各种场景中。框架在设计的时候,要尽可能通用。要多去思考一下,除了接口统计这样一个需求,还可以适用到其他哪些场景中,比如是否还可以处理其他事件的统计信息,比如 SQL 请求时间的统计信息、业务统计信息(比如支付成功率)等。
# 11.3 框架设计
对于稍微复杂系统的开发,可以借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型。
对于性能计数器这个框架的开发来说,可以先聚焦于一个非常具体、简单的应用场景,比如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并且将统计结果以 JSON 的格式输出到命令行中。现在这个需求简单、具体、明确,设计实现起来难度降低了很多。
最小原型的代码实现如下所示。其中,recordResponseTime() 和 recordTimestamp() 两个函数分别用来记录接口请求的响应时间和访问时间。startRepeatedReport() 函数以指定的频率统计数据并输出结果:
public class Metrics {
// Map的key是接口名称,value对应接口请求的响应时间或时间戳;
private Map<String, List<Double>> responseTimes = new HashMap<>();
private Map<String, List<Double>> timestamps = new HashMap<>();
private ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
public void recordResponseTime(String apiName, double responseTime) {
responseTimes.putIfAbsent(apiName, new ArrayList<>());
responseTimes.get(apiName).add(responseTime);
}
public void recordTimestamp(String apiName, double timestamp) {
timestamps.putIfAbsent(apiName, new ArrayList<>());
timestamps.get(apiName).add(timestamp);
}
public void startRepeatedReport(long period, TimeUnit unit){
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Gson gson = new Gson();
Map<String, Map<String, Double>> stats = new HashMap<>();
for (Map.Entry<String, List<Double>> entry : responseTimes.entrySet()) {
String apiName = entry.getKey();
List<Double> apiRespTimes = entry.getValue();
stats.putIfAbsent(apiName, new HashMap<>());
stats.get(apiName).put("max", max(apiRespTimes));
stats.get(apiName).put("avg", avg(apiRespTimes));
}
for (Map.Entry<String, List<Double>> entry : timestamps.entrySet()) {
String apiName = entry.getKey();
List<Double> apiTimestamps = entry.getValue();
stats.putIfAbsent(apiName, new HashMap<>());
stats.get(apiName).put("count", (double)apiTimestamps.size());
}
System.out.println(gson.toJson(stats));
}
}, 0, period, unit);
}
private double max(List<Double> dataset) {//省略代码实现}
private double avg(List<Double> dataset) {//省略代码实现}
}
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
用它来统计注册、登录接口的响应时间和访问次数。具体的代码如下所示:
//应用场景:统计下面两个接口(注册和登录)的响应时间和访问次数
public class UserController {
private Metrics metrics = new Metrics();
public UserController() {
metrics.startRepeatedReport(60, TimeUnit.SECONDS);
}
public void register(UserVo user) {
long startTimestamp = System.currentTimeMillis();
metrics.recordTimestamp("regsiter", startTimestamp);
//...
long respTime = System.currentTimeMillis() - startTimestamp;
metrics.recordResponseTime("register", respTime);
}
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();
metrics.recordTimestamp("login", startTimestamp);
//...
long respTime = System.currentTimeMillis() - startTimestamp;
metrics.recordResponseTime("login", respTime);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
最小原型的代码实现虽然简陋,但它却帮我们将思路理顺了很多,下面是针对性能计数器框架画的一个粗略的系统设计图:
把整个框架分为四个模块:数据采集、存储、聚合统计、显示。每个模块负责的工作简单罗列如下:
- 数据采集:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间。数据采集过程要高度容错,不能影响到接口本身的可用性。除此之外,因为这部分功能是暴露给框架的使用者的,所以在设计数据采集 API 的时候也要尽量考虑其易用性。
- 存储:负责将采集的原始数据保存下来,以便后面做聚合统计。数据的存储方式有多种,比如:Redis、MySQL、HBase、日志、文件、内存等。数据存储比较耗时,为了尽量地减少对接口性能(比如响应时间)的影响,采集和存储的过程异步完成。
- 聚合统计:负责将原始数据聚合为统计数据,比如:max、min、avg、pencentile、count、tps 等。为了支持更多的聚合统计规则,代码希望尽可能灵活、可扩展。
- 显示:负责将统计数据以某种格式显示到终端,比如:输出到命令行、邮件、网页、自定义显示终端等。
# 11.4 面向对象设计与实现
不一定非要全部实现需求分析中的所有功能,并一步到位做到完美的程度。即便有能力将所有需求都实现,可能也要花费很大的设计精力和开发时间,迟迟没有产出,leader 会因此产生很强的不可控感。对于现在的互联网项目来说,小步快跑、逐步迭代是一种更好的开发模式。所以应该分多个版本逐步完善这个框架。第一个版本可以先实现一些基本功能,对于更高级、更复杂的功能,以及非功能性需求不做过高的要求,在后续的 v2.0、v3.0……版本中继续迭代优化。
针对这个框架的开发,在 v1.0 版本中,暂时只实现下面这些功能:
- 数据采集:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间。
- 存储:负责将采集的原始数据保存下来,以便之后做聚合统计。数据的存储方式有很多种,暂时只支持 Redis 这一种存储方式,并且,采集与存储两个过程同步执行。
- 聚合统计:负责将原始数据聚合为统计数据,包括响应时间的最大值、最小值、平均值、99.9 百分位值、99 百分位值,以及接口请求的次数和 tps。
- 显示:负责将统计数据以某种格式显示到终端,暂时只支持主动推送给命令行和邮件。命令行间隔 n 秒统计显示上 m 秒的数据(比如,间隔 60s 统计上 60s 的数据)。邮件每日统计上日的数据。
实际上,学会结合具体的需求,做合理的预判、假设、取舍,规划版本的迭代设计开发,也是一个资深工程师必须要具备的能力。
上面的代码以最小原型的实现,所有的代码都耦合在一个类中,这显然是不合理的。现在按照面向对象设计的几个步骤,来重新划分、设计类:
划分职责识别出有哪些类
根据需求描述大致识别出下面几个接口或类:
- MetricsCollector 类负责提供 API,来采集接口请求的原始数据。可以为 MetricsCollector 抽象出一个接口,但这并不是必须的,因为暂时只能想到一个 MetricsCollector 的实现方式。
- MetricsStorage 接口负责原始数据存储,RedisMetricsStorage 类实现 MetricsStorage 接口。这样做是为了今后灵活地扩展新的存储方法,比如用 HBase 来存储。
- Aggregator 类负责根据原始数据计算统计数据。
- ConsoleReporter 类、EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件。至于 ConsoleReporter 和 EmailReporter 是否可以抽象出可复用的抽象类,或者抽象出一个公共的接口,暂时还不能确定。
定义类与类之间的关系
大致地识别出几个核心的类之后,可以现在 IDE 中创建好这几个类,然后开始试着定义这些类的属性和方法。在设计类、类与类之间交互的时候,应该不断运用设计原则的思想来审视设计是否合理,比如是否满足单一职责原则、开闭原则、依赖注入、KISS 原则、DRY 原则、迪米特法则,是否基于接口而非实现编程思想,代码是否高内聚、低耦合,是否可以抽象出可复用的代码等。
MetricsCollector 通过引入 RequestInfo 类来封装原始数据信息,用一个采集函数代替了之前的两个函数:
public class MetricsCollector { private MetricsStorage metricsStorage;//基于接口而非实现编程 //依赖注入 public MetricsCollector(MetricsStorage metricsStorage) { this.metricsStorage = metricsStorage; } //用一个函数代替了最小原型中的两个函数 public void recordRequest(RequestInfo requestInfo) { if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) { return; } metricsStorage.saveRequestInfo(requestInfo); } } public class RequestInfo { private String apiName; private double responseTime; private long timestamp; //...省略constructor/getter/setter方法... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23MetricsStorage 类和 RedisMetricsStorage 类的属性和方法也比较明确。
注意,一次性取太长时间区间的数据,可能会导致拉取太多的数据到内存中,有可能会撑爆内存。
public interface MetricsStorage { void saveRequestInfo(RequestInfo requestInfo); List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis); Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long endTimeInMillis); } public class RedisMetricsStorage implements MetricsStorage { //...省略属性和构造函数等... @Override public void saveRequestInfo(RequestInfo requestInfo) { //... } @Override public List<RequestInfo> getRequestInfos(String apiName, long startTimestamp, long endTimestamp) { //... } @Override public Map<String, List<RequestInfo>> getRequestInfos(long startTimestamp, long endTimestamp) { //... } }
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把统计显示所要完成的功能逻辑细分一下的话,主要包含下面 4 点:
- 根据给定的时间区间,从数据库中拉取数据;
- 根据原始数据,计算得到统计数据;
- 将统计数据显示到终端(命令行或邮件);
- 定时触发以上 3 个过程的执行。
暂时选择把第 1、3、4 逻辑放到 ConsoleReporter 或 EmailReporter 类中,把第 2 个逻辑放到 Aggregator 类中。其中,Aggregator 类负责的逻辑比较简单,把它设计成只包含静态方法的工具类。具体的代码实现如下所示:
public class Aggregator { public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) { double maxRespTime = Double.MIN_VALUE; double minRespTime = Double.MAX_VALUE; double avgRespTime = -1; double p999RespTime = -1; double p99RespTime = -1; double sumRespTime = 0; long count = 0; for (RequestInfo requestInfo : requestInfos) { ++count; double respTime = requestInfo.getResponseTime(); if (maxRespTime < respTime) { maxRespTime = respTime; } if (minRespTime > respTime) { minRespTime = respTime; } sumRespTime += respTime; } if (count != 0) { avgRespTime = sumRespTime / count; } long tps = (long)(count / durationInMillis * 1000); Collections.sort(requestInfos, new Comparator<RequestInfo>() { @Override public int compare(RequestInfo o1, RequestInfo o2) { double diff = o1.getResponseTime() - o2.getResponseTime(); if (diff < 0.0) { return -1; } else if (diff > 0.0) { return 1; } else { return 0; } } }); int idx999 = (int)(count * 0.999); int idx99 = (int)(count * 0.99); if (count != 0) { p999RespTime = requestInfos.get(idx999).getResponseTime(); p99RespTime = requestInfos.get(idx99).getResponseTime(); } RequestStat requestStat = new RequestStat(); requestStat.setMaxResponseTime(maxRespTime); requestStat.setMinResponseTime(minRespTime); requestStat.setAvgResponseTime(avgRespTime); requestStat.setP999ResponseTime(p999RespTime); requestStat.setP99ResponseTime(p99RespTime); requestStat.setCount(count); requestStat.setTps(tps); return requestStat; } } public class RequestStat { private double maxResponseTime; private double minResponseTime; private double avgResponseTime; private double p999ResponseTime; private double p99ResponseTime; private long count; private long tps; //...省略getter/setter方法... }
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
65ConsoleReporter 类定时根据给定的时间区间,从数据库中取出数据,借助 Aggregator 类完成统计工作,并将统计结果输出到命令行。具体的代码实现如下所示:
public class ConsoleReporter { private MetricsStorage metricsStorage; private ScheduledExecutorService executor; public ConsoleReporter(MetricsStorage metricsStorage) { this.metricsStorage = metricsStorage; this.executor = Executors.newSingleThreadScheduledExecutor(); } // 第4个代码逻辑:定时触发第1、2、3代码逻辑的执行; public void startRepeatedReport(long periodInSeconds, long durationInSeconds) { executor.scheduleAtFixedRate(new Runnable() { @Override public void run() { // 第1个代码逻辑:根据给定的时间区间,从数据库中拉取数据; long durationInMillis = durationInSeconds * 1000; long endTimeInMillis = System.currentTimeMillis(); long startTimeInMillis = endTimeInMillis - durationInMillis; Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis); Map<String, RequestStat> stats = new HashMap<>(); for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) { String apiName = entry.getKey(); List<RequestInfo> requestInfosPerApi = entry.getValue(); // 第2个代码逻辑:根据原始数据,计算得到统计数据; RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis); stats.put(apiName, requestStat); } // 第3个代码逻辑:将统计数据显示到终端(命令行或邮件); System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]"); Gson gson = new Gson(); System.out.println(gson.toJson(stats)); } }, 0, periodInSeconds, TimeUnit.SECONDS); } } public class EmailReporter { private static final Long DAY_HOURS_IN_SECONDS = 86400L; private MetricsStorage metricsStorage; private EmailSender emailSender; private List<String> toAddresses = new ArrayList<>(); public EmailReporter(MetricsStorage metricsStorage) { this(metricsStorage, new EmailSender(/*省略参数*/)); } public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) { this.metricsStorage = metricsStorage; this.emailSender = emailSender; } public void addToAddress(String address) { toAddresses.add(address); } public void startDailyReport() { Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DATE, 1); calendar.set(Calendar.HOUR_OF_DAY, 0); calendar.set(Calendar.MINUTE, 0); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); Date firstTime = calendar.getTime(); Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { long durationInMillis = DAY_HOURS_IN_SECONDS * 1000; long endTimeInMillis = System.currentTimeMillis(); long startTimeInMillis = endTimeInMillis - durationInMillis; Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis); Map<String, RequestStat> stats = new HashMap<>(); for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) { String apiName = entry.getKey(); List<RequestInfo> requestInfosPerApi = entry.getValue(); RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis); stats.put(apiName, requestStat); } // TODO: 格式化为html格式,并且发送邮件 } }, firstTime, DAY_HOURS_IN_SECONDS * 1000); } }
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
84
85
86将类组装起来并提供执行入口
有两个执行入口:一个是 MetricsCollector 类,提供了一组 API 来采集原始数据;另一个是 ConsoleReporter 类和 EmailReporter 类,用来触发统计显示。
public class Demo { public static void main(String[] args) { MetricsStorage storage = new RedisMetricsStorage(); ConsoleReporter consoleReporter = new ConsoleReporter(storage); consoleReporter.startRepeatedReport(60, 60); EmailReporter emailReporter = new EmailReporter(storage); emailReporter.addToAddress("wangzheng@xzg.com"); emailReporter.startDailyReport(); MetricsCollector collector = new MetricsCollector(storage); collector.recordRequest(new RequestInfo("register", 123, 10234)); collector.recordRequest(new RequestInfo("register", 223, 11234)); collector.recordRequest(new RequestInfo("register", 323, 12334)); collector.recordRequest(new RequestInfo("login", 23, 12434)); collector.recordRequest(new RequestInfo("login", 1223, 14234)); try { Thread.sleep(100000); } catch (InterruptedException e) { e.printStackTrace(); } } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 11.5 Review 设计与实现
Review 上面代码实现是否符合这些设计原则和思想。
MetricCollector
MetricsCollector 负责采集和存储数据,职责相对来说还算比较单一。它基于接口而非实现编程,通过依赖注入的方式来传递 MetricsStorage 对象,可以在不需要修改代码的情况下,灵活地替换不同的存储方式,满足开闭原则。
MetricStorage、RedisMetricStorage
当需要实现新的存储方式的时候,只需要实现 MetricsStorage 接口即可,满足开闭原则。
Aggravate
Aggregator 类是一个工具类,当需要扩展新的统计功能的时候,需要修改 aggregate() 函数代码,并且一旦越来越多的统计功能添加进来之后,这个函数的代码量会持续增加,可读性、可维护性就变差了。这个类的设计可能存在职责不够单一、不易扩展等问题,需要在之后的版本中,对其结构做优化。
ConsoleReporter、EmailReporter
ConsoleReporter 和 EmailReporter 中存在代码重复问题。在这两个类中,从数据库中取数据、做统计的逻辑都是相同的,可以抽取出来复用,否则就违反了 DRY 原则。而且整个类负责的事情比较多,职责不是太单一。特别是显示部分的代码,可能会比较复杂(比如 Email 的展示方式),最好是将显示部分的代码逻辑拆分成独立的类。除此之外,因为代码中涉及线程操作,并且调用了 Aggregator 的静态函数,所以代码的可测试性不好。
# 11.6 第一次重构:版本2
针对性能计数器,主要业务逻辑在 Aggregator、ConsoleReporter、EmailReporter 这三个类中,上面已经分析出这三个类存在的问题,所以重点重构这三个类。
Aggravate、ConsoleReporter、EmailReporter 三个类主要负责统计和显示功能。主要包含:
- 根据时间区间,从数据库中拉取数据
- 根据原始数据,计算得到统计数据
- 显示统计数据(终端或邮件)
- 定时触发上面三个过程的执行
组装前三部分逻辑的上帝类是必须要有的,可以将上帝类做的很轻量级,把核心逻辑都剥离出去,形成独立的类,上帝类只负责组装类和串联执行流程。这样代码结构更加清晰,底层核心逻辑更容易被复用。
第 1 个逻辑:根据给定时间区间,从数据库中拉取数据。这部分逻辑已经被封装在 MetricsStorage 类中了,所以这部分不需要处理。
第 2 个逻辑:根据原始数据,计算得到统计数据。可以将这部分逻辑移动到 Aggregator 类中。这样 Aggregator 类就不仅仅是只包含统计方法的工具类了。代码如下所示:
public class Aggregator { public Map<String, RequestStat> aggregate( Map<String, List<RequestInfo>> requestInfos, long durationInMillis) { Map<String, RequestStat> requestStats = new HashMap<>(); for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) { String apiName = entry.getKey(); List<RequestInfo> requestInfosPerApi = entry.getValue(); RequestStat requestStat = doAggregate(requestInfosPerApi, durationInMillis); requestStats.put(apiName, requestStat); } return requestStats; } private RequestStat doAggregate(List<RequestInfo> requestInfos, long durationInMillis) { List<Double> respTimes = new ArrayList<>(); for (RequestInfo requestInfo : requestInfos) { double respTime = requestInfo.getResponseTime(); respTimes.add(respTime); } RequestStat requestStat = new RequestStat(); requestStat.setMaxResponseTime(max(respTimes)); requestStat.setMinResponseTime(min(respTimes)); requestStat.setAvgResponseTime(avg(respTimes)); requestStat.setP999ResponseTime(percentile999(respTimes)); requestStat.setP99ResponseTime(percentile99(respTimes)); requestStat.setCount(respTimes.size()); requestStat.setTps((long) tps(respTimes.size(), durationInMillis/1000)); return requestStat; } // 以下的函数的代码实现均省略... private double max(List<Double> dataset) {} private double min(List<Double> dataset) {} private double avg(List<Double> dataset) {} private double tps(int count, double duration) {} private double percentile999(List<Double> dataset) {} private double percentile99(List<Double> dataset) {} private double percentile(List<Double> dataset, double ratio) {} }
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第 3 个逻辑:将统计数据显示到终端。将这部分逻辑剥离出来,设计成两个类:ConsoleViewer 类和 EmailViewer 类,分别负责将统计结果显示到命令行和邮件中。代码实现如下所示:
public interface StatViewer { void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills); } public class ConsoleViewer implements StatViewer { public void output( Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) { System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMills + "]"); Gson gson = new Gson(); System.out.println(gson.toJson(requestStats)); } } public class EmailViewer implements StatViewer { private EmailSender emailSender; private List<String> toAddresses = new ArrayList<>(); public EmailViewer() { this.emailSender = new EmailSender(/*省略参数*/); } public EmailViewer(EmailSender emailSender) { this.emailSender = emailSender; } public void addToAddress(String address) { toAddresses.add(address); } public void output( Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) { // format the requestStats to HTML style. // send it to email toAddresses. } }
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第 4 个逻辑:组装类并定时触发执行统计显示。在将核心逻辑剥离出来之后,这个类的代码变得更加简洁、清晰,只负责组装各个类(MetricsStorage、Aggegrator、StatViewer)来完成整个工作流程。代码如下所示:
public class ConsoleReporter { private MetricsStorage metricsStorage; private Aggregator aggregator; private StatViewer viewer; private ScheduledExecutorService executor; public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) { this.metricsStorage = metricsStorage; this.aggregator = aggregator; this.viewer = viewer; this.executor = Executors.newSingleThreadScheduledExecutor(); } public void startRepeatedReport(long periodInSeconds, long durationInSeconds) { executor.scheduleAtFixedRate(new Runnable() { @Override public void run() { long durationInMillis = durationInSeconds * 1000; long endTimeInMillis = System.currentTimeMillis(); long startTimeInMillis = endTimeInMillis - durationInMillis; Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis); Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis); viewer.output(requestStats, startTimeInMillis, endTimeInMillis); } }, 0L, periodInSeconds, TimeUnit.SECONDS); } } public class EmailReporter { private static final Long DAY_HOURS_IN_SECONDS = 86400L; private MetricsStorage metricsStorage; private Aggregator aggregator; private StatViewer viewer; public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) { this.metricsStorage = metricsStorage; this.aggregator = aggregator; this.viewer = viewer; } public void startDailyReport() { Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DATE, 1); calendar.set(Calendar.HOUR_OF_DAY, 0); calendar.set(Calendar.MINUTE, 0); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); Date firstTime = calendar.getTime(); Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run() { long durationInMillis = DAY_HOURS_IN_SECONDS * 1000; long endTimeInMillis = System.currentTimeMillis(); long startTimeInMillis = endTimeInMillis - durationInMillis; Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis); Map<String, RequestStat> stats = aggregator.aggregate(requestInfos, durationInMillis); viewer.output(stats, startTimeInMillis, endTimeInMillis); } }, firstTime, DAY_HOURS_IN_SECONDS * 1000); } }
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
重构好后,框架的使用方式:
public class PerfCounterTest {
public static void main(String[] args) {
MetricsStorage storage = new RedisMetricsStorage();
Aggregator aggregator = new Aggregator();
// 定时触发统计并将结果显示到终端
ConsoleViewer consoleViewer = new ConsoleViewer();
ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
consoleReporter.startRepeatedReport(60, 60);
// 定时触发统计并将结果输出到邮件
EmailViewer emailViewer = new EmailViewer();
emailViewer.addToAddress("wangzheng@xzg.com");
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
emailReporter.startDailyReport();
// 收集接口访问数据
MetricsCollector collector = new MetricsCollector(storage);
collector.recordRequest(new RequestInfo("register", 123, 10234));
collector.recordRequest(new RequestInfo("register", 223, 11234));
collector.recordRequest(new RequestInfo("register", 323, 12334));
collector.recordRequest(new RequestInfo("login", 23, 12434));
collector.recordRequest(new RequestInfo("login", 1223, 14234));
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
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
Review 重构后的代码
重构之后,MetricsStorage 负责存储,Aggregator 负责统计,StatViewer(ConsoleViewer、EmailViewer)负责显示,三个类各司其职。ConsoleReporter 和 EmailReporter 负责组装这三个类,将获取原始数据、聚合统计、显示统计结果到终端这三个阶段的工作串联起来,定时触发执行。
除此之外,MetricsStorage、Aggregator、StatViewer 三个类的设计也符合迪米特法则。它们只与跟自己有直接相关的数据进行交互。MetricsStorage 输出的是 RequestInfo 相关数据。Aggregator 类输入的是 RequestInfo 数据,输出的是 RequestStat 数据。StatViewer 输入的是 RequestStat 数据。
从每个类的设计来看:
Aggregator 类从一个只包含一个静态函数的工具类,变成了一个普通的聚合统计类。现在可以通过依赖注入的方式,将其组装进 ConsoleReporter 和 EmailReporter 类中,这样就容易编写单元测试。
Aggregator 类在重构前,所有的逻辑都集中在 aggregate() 函数内,代码行数较多,代码的可读性和可维护性较差。在重构之后,将每个统计逻辑拆分成独立的函数,aggregate() 函数变得比较单薄,可读性提高了。尽管要添加新的统计功能,还是要修改 aggregate() 函数,但现在的 aggregate() 函数代码行数很少,结构非常清晰,修改起来更加容易,可维护性提高。
目前来看,Aggregator 的设计还算合理。但是,如果随着更多的统计功能的加入,Aggregator 类的代码会越来越多。这时可以将统计函数剥离出来,设计成独立的类,以解决 Aggregator 类的无限膨胀问题。不过,暂时来说没有必要这么做,毕竟将每个统计函数独立成类,会增加类的个数,也会影响到代码的可读性和可维护性。
ConsoleReporter 和 EmailReporter 经过重构之后,代码的重复问题变小了,但仍然没有完全解决。尽管这两个类不再调用 Aggregator 的静态方法,但因为涉及多线程和时间相关的计算,代码的测试性仍然不够好。
# 11.7 第二次重构:版本3
# 11.7.1 代码重构
对于 ConsoleReporter 和 EmailReporter 两个类的代码重复问题,可以将两个两类的相同代码提取到父类中。如下:
public abstract class ScheduledReporter {
protected MetricsStorage metricsStorage;
protected Aggregator aggregator;
protected StatViewer viewer;
public ScheduledReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
long durationInMillis = endTimeInMillis - startTimeInMillis;
Map<String, List<RequestInfo>> requestInfos =
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
EmailReporter 代码如下所示:
public class EmailReporter extends ScheduledReporter {
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
private MetricsStorage metricsStorage;
private Aggregator aggregator;
private StatViewer viewer;
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
this.metricsStorage = metricsStorage;
this.aggregator = aggregator;
this.viewer = viewer;
}
public void startDailyReport() {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date firstTime = calendar.getTime();
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
long endTimeInMillis = System.currentTimeMillis();
long startTimeInMillis = endTimeInMillis - durationInMillis;
doStatAndReport(startTimeInMillis, endTimeInMillis);
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
}
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
前面提到,之所以 EmailReporter 可测试性不好,一方面是因为用到了线程(定时器也相当于多线程),另一方面是因为涉及时间的计算逻辑。而现在的 EmailReporter 逻辑相对简单,较复杂和容易出 bug 的地方是在计算 firstTime,可以将获取 firstTime 的代码单独封装成一个函数,而该函数强依赖当前的系统时间。实际上,这个问题挺普遍的。一般的解决方法是,将强依赖的部分通过参数传递进来,这有点类似我们之前讲的依赖注入。
public class EmailReporter extends ScheduledReporter {
// 省略其他代码...
public void startDailyReport() {
// new Date()可以获取当前时间
Date firstTime = trimTimeFieldsToZeroOfNextDay(new Date());
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// 省略其他代码...
}
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
}
protected Date trimTimeFieldsToZeroOfNextDay(Date date) {
Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
calendar.setTime(date); // 重新设置时间
calendar.add(Calendar.DATE, 1);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTime();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
最后,EmailReporter 类中 startDailyReport() 还是涉及多线程,不容易测试。而经过多次代码重构之后,startDailyReport() 函数里面已经没有多少代码逻辑了,所以,完全没必要对它写单元测试
# 11.7.2 功能性需求完善
最初的功能需求描述:
希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile),接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON 格式、网页格式、自定义显示格式等)输出到各种终端(Console 命令行、HTTP 网页、Email、日志文件、自定义输出终端等),以方便查看。
经过整理拆解之后的需求列表:
- 接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。
- 统计信息的类型:max、min、avg、percentile、count、tps 等。
- 统计信息显示格式:JSON、HTML、自定义显示格式。
- 统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端。
经过挖掘,还得到一些隐藏的需求:
- 统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。
- 统计时间区间:框架需要支持自定义统计时间区间,比如统计最近 10 分钟的某接口的 tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等。
- 统计时间间隔:对于主动触发统计,还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件。
剩余没有实现的小功能:
- 被动触发统计的方式,也就是需求中提到的通过网页展示统计信息。可以复用框架现在的代码,编写一些展示页面和提供获取统计数据的接口即可。
- 对于自定义显示终端,比如显示数据到自己开发的监控平台,这就有点类似通过网页来显示数据,不过更加简单些,只需要提供一些获取统计数据的接口,监控平台通过这些接口拉取数据来显示即可。
- 自定义显示格式。在框架现在的代码实现中,显示格式和显示终端(比如 Console、Email)是紧密耦合在一起的,比如,Console 只能通过 JSON 格式来显示统计数据,Email 只能通过某种固定的 HTML 格式显示数据,这样的设计还不够灵活。可以将显示格式设计成独立的类,将显示终端和显示格式的代码分离,让显示终端支持配置不同的显示格式。
# 11.7.3 非功能性需求完善
非功能性需求包括:易用性、性能、扩展性、容错性、通用性。
易用性
由 PerfCounterTest 类可见,当前框架用起来还是稍微有些复杂的,需要组装各种类。可以额外地提供一些封装了默认依赖的构造函数,让使用者自主选择使用哪种构造函数来构造对象。
public class MetricsCollector { private MetricsStorage metricsStorage; // 兼顾代码的易用性,新增一个封装了默认依赖的构造函数 public MetricsCollectorB() { this(new RedisMetricsStorage()); } // 兼顾灵活性和代码的可测试性,这个构造函数继续保留 public MetricsCollectorB(MetricsStorage metricsStorage) { this.metricsStorage = metricsStorage; } // 省略其他代码... } public class ConsoleReporter extends ScheduledReporter { private ScheduledExecutorService executor; // 兼顾代码的易用性,新增一个封装了默认依赖的构造函数 public ConsoleReporter() { this(new RedisMetricsStorage(), new Aggregator(), new ConsoleViewer()); } // 兼顾灵活性和代码的可测试性,这个构造函数继续保留 public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) { super(metricsStorage, aggregator, viewer); this.executor = Executors.newSingleThreadScheduledExecutor(); } // 省略其他代码... } public class EmailReporter extends ScheduledReporter { private static final Long DAY_HOURS_IN_SECONDS = 86400L; // 兼顾代码的易用性,新增一个封装了默认依赖的构造函数 public EmailReporter(List<String> emailToAddresses) { this(new RedisMetricsStorage(), new Aggregator(), new EmailViewer(emailToAddresses)); } // 兼顾灵活性和代码的可测试性,这个构造函数继续保留 public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) { super(metricsStorage, aggregator, 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性能
对于性能这一点,落实到具体的代码层面,需要解决两个问题,一个是采集和存储要异步来执行,因为存储基于外部存储(比如 Redis),会比较慢,异步存储可以降低对接口响应时间的影响。另一个是当需要聚合统计的数据量比较大的时候,一次性加载太多的数据到内存,有可能会导致内存吃紧,甚至内存溢出,这样整个系统都会瘫痪掉。
解决第一个问题:在 MetricsCollector 中引入 Google Guava EventBus 来解决。EventBus 可 看作一个“生产者 - 消费者”模型或者“发布 - 订阅”模型,采集的数据先放入内存共享队列中,另一个线程读取共享队列中的数据,写入到外部存储(比如 Redis)中。
public class MetricsCollector { private static final int DEFAULT_STORAGE_THREAD_POOL_SIZE = 20; private MetricsStorage metricsStorage; private EventBus eventBus; public MetricsCollector(MetricsStorage metricsStorage) { this(metricsStorage, DEFAULT_STORAGE_THREAD_POOL_SIZE); } public MetricsCollector(MetricsStorage metricsStorage, int threadNumToSaveData) { this.metricsStorage = metricsStorage; this.eventBus = new AsyncEventBus(Executors.newFixedThreadPool(threadNumToSaveData)); this.eventBus.register(new EventListener()); } public void recordRequest(RequestInfo requestInfo) { if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) { return; } eventBus.post(requestInfo); } public class EventListener { @Subscribe public void saveRequestInfo(RequestInfo requestInfo) { metricsStorage.saveRequestInfo(requestInfo); } } }
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解决第二个问题:当统计的时间间隔较大的时候,需要统计的数据量就会比较大。可以将其划分为一些小的时间区间(比如 10 分钟作为一个统计单元),针对每个小的时间区间分别进行统计,然后将统计得到的结果再进行聚合,得到最终整个时间区间的统计结果。不过,这个思路只适合响应时间的 max、min、avg,及其接口请求 count、tps 的统计,对于响应时间的 percentile 的统计并不适用。对于 percentile 具体的解决思路是这样子的:分批从 Redis 中读取数据,然后存储到文件中,再根据响应时间从小到大利用外部排序算法来进行排序(具体的实现方式可以看一下《数据结构与算法之美》专栏)。排序完成之后,再从文件中读取第 count*percentile(count 表示总的数据个数,percentile 就是百分比,99 百分位就是 0.99)个数据,就是对应的 percentile 响应时间。除了 percentile 之外的统计信息的计算代码如下所示。
public class ScheduleReporter { private static final long MAX_STAT_DURATION_IN_MILLIS = 10 * 60 * 1000; // 10minutes protected MetricsStorage metricsStorage; protected Aggregator aggregator; protected StatViewer viewer; public ScheduleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) { this.metricsStorage = metricsStorage; this.aggregator = aggregator; this.viewer = viewer; } protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) { Map<String, RequestStat> stats = doStat(startTimeInMillis, endTimeInMillis); viewer.output(stats, startTimeInMillis, endTimeInMillis); } private Map<String, RequestStat> doStat(long startTimeInMillis, long endTimeInMillis) { Map<String, List<RequestStat>> segmentStats = new HashMap<>(); long segmentStartTimeMillis = startTimeInMillis; while (segmentStartTimeMillis < endTimeInMillis) { long segmentEndTimeMillis = segmentStartTimeMillis + MAX_STAT_DURATION_IN_MILLIS; if (segmentEndTimeMillis > endTimeInMillis) { segmentEndTimeMillis = endTimeInMillis; } Map<String, List<RequestInfo>> requestInfos = metricsStorage.getRequestInfos(segmentStartTimeMillis, segmentEndTimeMillis); if (requestInfos == null || requestInfos.isEmpty()) { continue; } Map<String, RequestStat> segmentStat = aggregator.aggregate( requestInfos, segmentEndTimeMillis - segmentStartTimeMillis); addStat(segmentStats, segmentStat); segmentStartTimeMillis += MAX_STAT_DURATION_IN_MILLIS; } long durationInMillis = endTimeInMillis - startTimeInMillis; Map<String, RequestStat> aggregatedStats = aggregateStats(segmentStats, durationInMillis); return aggregatedStats; } private void addStat(Map<String, List<RequestStat>> segmentStats, Map<String, RequestStat> segmentStat) { for (Map.Entry<String, RequestStat> entry : segmentStat.entrySet()) { String apiName = entry.getKey(); RequestStat stat = entry.getValue(); List<RequestStat> statList = segmentStats.putIfAbsent(apiName, new ArrayList<>()); statList.add(stat); } } private Map<String, RequestStat> aggregateStats(Map<String, List<RequestStat>> segmentStats, long durationInMillis) { Map<String, RequestStat> aggregatedStats = new HashMap<>(); for (Map.Entry<String, List<RequestStat>> entry : segmentStats.entrySet()) { String apiName = entry.getKey(); List<RequestStat> apiStats = entry.getValue(); double maxRespTime = Double.MIN_VALUE; double minRespTime = Double.MAX_VALUE; long count = 0; double sumRespTime = 0; for (RequestStat stat : apiStats) { if (stat.getMaxResponseTime() > maxRespTime) maxRespTime = stat.getMaxResponseTime(); if (stat.getMinResponseTime() < minRespTime) minRespTime = stat.getMinResponseTime(); count += stat.getCount(); sumRespTime += (stat.getCount() * stat.getAvgResponseTime()); } RequestStat aggregatedStat = new RequestStat(); aggregatedStat.setMaxResponseTime(maxRespTime); aggregatedStat.setMinResponseTime(minRespTime); aggregatedStat.setAvgResponseTime(sumRespTime / count); aggregatedStat.setCount(count); aggregatedStat.setTps(count / durationInMillis * 1000); aggregatedStats.put(apiName, aggregatedStat); } return aggregatedStats; } }
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扩展性
当前框架在兼顾易用性的同时,也可以灵活地替换各种类对象,比如 MetricsStorage、StatViewer。举个例子来说,如果我们要让框架基于 HBase 来存储原始数据而非 Redis,那我们只需要设计一个实现 MetricsStorage 接口的 HBaseMetricsStorage 类,传递给 MetricsCollector 和 ConsoleReporter、EmailReporter 类即可。
容错性
对于这个框架来说,不能因为框架本身的异常导致接口请求出错。
现在的框架设计与实现中,采集和存储是异步执行,即便 Redis 挂掉或者写入超时,也不会影响到接口的正常响应。除此之外,Redis 异常,可能会影响到数据统计显示(也就是 ConsoleReporter、EmailReporter 负责的工作),但并不会影响到接口的正常响应。
通用性
思考一下,除了接口统计这样一个需求,这个框架还可以适用到其他哪些场景中。比如是否还可以处理其他事件的统计信息,比如 SQL 请求时间的统计、业务统计(比如支付成功率)等。