引例——抽取工具类

先来看看这段代码:

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
@GetMapping(path = "/getUserInfo")
public UserInfoDTO getUserInfo(@RequestParam("userId") String userId) {
// 1. 打印入口日志
logger.info("userId: " + userId + " visit path: /getUserInfo");

try {
// 2. 检验入参合理性,userId是否以字母'u'开头。正确的uid示例:u001
if (userId == null || !userId.startsWith("u")) {
throw new RuntimeException("param userId is invalid");
}

// 3. 获取用户基础信息
UserBaseInfoVO userBaseInfoVO = userBaseInfoRepository.getUserBaseInfo(userId);

// 4. 获取用户特殊信息
UserSpecialInfoVO userSpecialInfoVO = userSpecialInfoRepository.getUserSpecialInfo(userId);

// 5. 获取用户余额
UserMoneyVO userMoneyVO = UserMoneyRepository.getUserMoney(userId);

// 6. 获取用户消费记录
List<ConsumeRecordVO> consumeRecordVOList = userConsumeRepository.getConsumeRecordVOList(userId);

// 7. 计算最贵一次消费
Optional<ConsumeRecordVO> max = consumeRecordVOList.stream()
.max(Comparator.comparing(ConsumeRecordVO::getAmount));
ConsumeDTO maxAmountConsume = null;
if (max.isPresent()) {
ConsumeRecordVO consumeRecordVO = max.get();
maxAmountConsume = ConsumeDTO.builder()
.amount(consumeRecordVO.getAmount())
.date(consumeRecordVO.getDate())
.build();
}

// 8. 计算用户充值总金额
DoubleSummaryStatistics total = consumeRecordVOList.stream()
.collect(Collectors.summarizingDouble(ConsumeRecordVO::getAmount));
Double totalMoney = userMoneyVO.getMoney() + total.getSum();

// 9. 打印访问成功日志
logger.info("userId: " + userId + " visit path: /getUserInfo success");

return UserInfoDTO.builder()
.userName(userBaseInfoVO.getUserName())
.vipLevel(userBaseInfoVO.getVipLevel())
.maxAmountConsume(maxAmountConsume)
.totalMoney(totalMoney)
.build();
} catch (Exception e) {
// 打印访问失败日志
logger.error("userId: " + userId + " visit path: /getUserInfo fail. exception message = " + Arrays.toString(e.getStackTrace()));
return null;
}
}

看上去不错,对吧

但是当我们有两个这种接口

是不是看出来问题了?

问题:相同能力没有复用

我们会发现他们逻辑不一致的地方只有校验入参和业务逻辑,其他相似或相同逻辑的地方没有复用

  • 如果新增接口,所有的日志打印要冗余写一遍,包括入口日志、出口日志、异常日志
  • 如果新增接口,try-catch的异常处理逻辑也需要冗余重写
  • 如果新增一个只获取用户金额信息的接口,需要冗余复制上述代码中和金额相关的部分
  • 如果接口需要修改,返回新的信息,那就需要往这个代码里添加新的业务逻辑。而这个类一旦有变化,就涉及对这个类的回归验证

对于这种情况,我们可能会给他抽出一个工具类

紧接着带来的问题

  1. 你怎么保证别人以后一定用工具类?
  2. 你怎么保证别人以后一定正确使用工具类?(如顺序有误)
  3. 用的人怎么确定需要用哪些工具类?(线上系统共同逻辑很多,难以定位需要的工具类)

正解——模板方法

抽象一个模板,规定顺序是打印入参、校验入参、业务逻辑、打印出参。

  • 打印入参和打印出参有默认实现
  • 校验入参、业务逻辑是抽象的,接口A、接口B各自实现
  • 统一异常处理

特点:统一逻辑,标准化流程

从系统的设计上

  • 每个接口都不用担心忘了执行必要的公共逻辑,例如打印日志、异常处理。
  • 不用担心接口有遗漏步骤及搞错步骤顺序,例如入参校验在执行业务流程之前。
  • 接口只需要关心自己业务逻辑的实现即可。
  • 所有接口打印的日志及异常处理方式确保是一致的,方便监控和定位问题。
  • 如果需要增加一些公用的能力,例如埋点上报某个统计平台,只需要在框架中添加逻辑,所有接口都直接生效。

简单来说,就是统一逻辑,标准化流程

模板代码

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
public abstract class ServiceTemplate<T, R> {
private Logger logger = new LoggerImpl();

/**
* 定义算法框架,暴露算法要素
* 所谓算法框架:业务执行的步骤,例如先打日志,再校验,再执行业务逻辑,统一处理异常
* 所谓算法要素:和业务相关的个性化实现。例如不同的接口对入参校验逻辑不同
*/
public R process(T request) {
// 1. 打印入口日志
logger.info("start invoke, request - " + request);

// 开始计时,用于日志记录耗时
Stopwatch stopwatch = Stopwatch.createStarted();
try {
// 2. 校验参数
validParam(request);

// 3. 子类实现逻辑
R response = doProcess(request);

Long timeCost = stopwatch.elapsed(TimeUnit.MILLISECONDS);

// 4. 打印出口日志
logger.info("end invoke, response = " + response + ", costTime = " + timeCost);
return response;
} catch (Exception e) {
// 打印异常日志
logger.error("error invoke, exception: " + Arrays.toString(e.getStackTrace()));
return null;
}
}

/**
* 参数校验(交给子类实现)
*/
protected abstract void validParam(T request);

/**
* 执行业务逻辑(交给子类实现)
*/
protected abstract R doProcess(T request);
}

服务实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@GetMapping(path = "doSomething")
public Response doSomething(Request request) {
return (new ServiceTemplate<RequestResponse>() {
@Override
public void validParam(Request request) {
// 对request的校验逻辑,例如非空,字段类型判断等
}

@Override
public Response doProcess(Request request) {
// 执行业务逻辑
return new Response();
}
}).process(request);
}

服务实现

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
public UserInfoDTO getUserInfo(@RequestParam("userId") String userId) {
return (new ServiceTemplate<String>() {
@Override
public void validParam(String request) {
// 1. 校验入参
if (userId == null || userId.isEmpty()) {
throw new RuntimeException("param UserId is invalid");
}
}

@Override
public UserInfoDTO doProcess(String request) {
// 2. 获取用户基础信息
UserBaseInfoVO userBaseInfoVO = userBaseInfoRepository.getUserBaseInfo(userId);

// 3. 获取用户特殊信息
UserSpecialInfoVO userSpecialInfoVO = UserSpecialInfoRepository.getSpecialInfoVO(userId);

// 4. 获取用户余额
UserMoneyVO userMoneyVO = userMoneyRepository.getUserMoneyVO(userId);

// 5. 获取用户消费记录
List<ConsumeRecordVO> consumeRecordVOList = userConsumeRepository.getConsumeRecordVOList(userId);

// 6. 计算最贵一次消费
Optional<ConsumeRecordVO> max = consumeRecordVOList.stream()
.max(Comparator.comparing(ConsumeRecordVO::getAmount));
ConsumeDTO maxAmountConsume = null;
if (max.isPresent()) {
ConsumeRecordVO consumeRecordVO = max.get();
maxAmountConsume = ConsumeDTO.builder()
.amount(consumeRecordVO.getAmount())
.date(consumeRecordVO.getDate())
.build();
}

// 7. 计算用户充值总金额
DoubleSummaryStatistics total = consumeRecordVOList.stream()
.collect(Collectors.summarizingDouble(ConsumeRecordVO::getAmount));
double totalMoney = userMoneyVO.getMoney() + total.getSum();

return UserInfoDTO.builder()
.userName(userBaseInfoVO.getUserName())
.vipLevel(userBaseInfoVO.getVipLevel())
.maxAmountConsume(maxAmountConsume)
.totalMoney(totalMoney)
.build();
}
}).process(userId);
}

问题

即使这样,系统还不够完美,当我们:

  • 加逻辑:getUserlnfo新增加用户优惠券信息
  • 改逻辑:最贵一次消费记录要改成近一个月的
  • 增加复杂判断:如果是授权了的用户,才查询余额,没有授权不能查

我们的代码都在一个文件里加,会导致代码:

  • 腕肿难懂:看起来费劲,而且不同同学代码风格不一致,导致注释还可能难懂和有歧义
  • 容易相互影响:太多行,一不小心改错了;又或者有依赖顺序、数据结构不能有变动
  • 不好测试:为了测方法中的某一处逻辑,不得不在方法入参上引入大量无关数据进行mock。而且万一中途某一块其他逻辑判断非常复杂,那mock难度就是指数级上升

那么应该怎么解决呢?详情请看下节

参考资料

[^1]: 【学架构也可以很有趣】【“趣”学架构】- 2.搭系统得先搭架子
[^2]: 【成为架构师】8. 成为工程师 - 搭建系统先搭建框架