代码审查者应该关注哪些方面?
代码审查时应该关注以下方面:
- 设计:设计是否合理?
- 功能:是否满足prd需求?是否满足用户流程交互合理性?
- 复杂度:代码能更简单吗?将来其他开发人员能轻松理解并使用此代码吗?
- 测试:代码是否具有正确且设计良好的单元测试?
- 命名:开发人员是否为变量、类、方法、包等选择了明确的名称?
- 注释:注释是否清晰有用?
- 风格:代码是否遵守了代码规范?
- 文档:开发人员是否同时更新了相关文档?
设计
审查中最重要的是 CL 的整体设计。CL 中各种代码的交互是否有意义?现在是添加此功能的好时机吗?
单一职责原则 (Single Responsibility Principle)
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
- 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性,那就可以考虑将这几个属性和对应的方法拆分出来。
违背SRP原则的案例
1.CreditSchemaAbilityImpl中函数、依赖过多,充斥着类转换逻辑、访问facade逻辑、工厂逻辑、私有方法过多、大量私有方法最终只是得到一个字段值等。
2.User类中大量方法操作的都是UserAddress成员变量对象,而这些方法散落在User类而不是委托UserAddress类。
开闭原则 (Open-Closed Principle)
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
违背OCP原则的案例
public class Alert {
private AlertRule rule;
private Notification notification;
public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
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, "...");
}
}
}
上面这段代码非常简单,业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?主要的改动有两处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;第二处是在 check() 函数中添加新的告警逻辑。具体的代码改动如下所示:
public class Alert {
// ...省略AlertRule/Notification属性和构造函数...
// 改动一:添加参数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, "...");
}
// 改动二:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
这样的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了 check() 函数,相应的单元测试都需要修改(关于单元测试的内容我们在重构那部分会详细介绍)。
里氏替换原则 (Liskov Substitution Principle)
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
判断子类的设计实现是否违背里式替换原则,一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。
违背LSP原则的案例
子类违背父类声明要实现的功能父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
子类违背父类对输入、输出、异常的约定在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
子类违背父类注释中所罗列的任何特殊说明父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
依赖倒转原则 (Dependence Inversion Principle)
- 高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
违背DIP原则的案例
1.在sop流程中提供通用功能,拓展点却依赖了具体实现环节中的类,关心了具体实现。
2.在某个类中依赖了某个接口具体实现类,而不是依赖接口。
3.没有按照DI方式分层,单测困难。
4.没有通过spring ioc容器控制反转,直接在类中new成员变量,单测困难 , 依赖细节。
接口隔离原则 (Interface Segregation Principle)
客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
违背ISP原则的案例
1.为了程序分层方便api将对数据库的逻辑DELETE方法也暴露对外了,造成风险。
2.由于接口设计不佳,授信领域间接被强迫依赖了支用领域接口。
3.一个接口方法功能太多,只能兼容一些入参白跑了很多多余的逻辑。
迪米特法则(Law Of Demeter)
每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)。
不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。
违背LOD原则的案例
1.文件上传接口FileUploadInterface依赖了OSS的Bucket对象,而不是定义抽象的文件上传参数,文件上传不一定要用OSS实现的。
2.还款实体为了得到某个字段,依赖了征信实体,只因为征信实体里面有这个字段,获取方便,但是征信不属于还款领域概念。
功能
背景了解
Code Reviewer 要知道改动的背景,本次改动代码是为了做什么?不能做什么?会影响什么?
逻辑分析
Code Reviewer 根据需求背景,对代码逻辑进行分析,是否满足需求,是否有业务逻辑漏洞。
边界检查
Code Reviewer 检查改动影响的功能模块,是否有边界问题,如果是refactor change,应当检查兼容性,如果是feature change,需要检查领域边界是否合理。
异常监控
Code Reviewer 检查日志处理、异常处理、监控埋点方式等代码,是否存在缺陷、丢失。
非业务功能性检查
Code Reviewer 检查多线程、并发、代码安全、性能等角度,是否存在漏洞,是否可以优化。
复杂度
圈复杂度
Code Reviewer 检查代码圈复杂度情况(if while分支过多、方法分支太多),圈复杂度在1-10为合理范围。
改进方法
- 简化、合并条件表达式
- 将条件判定提炼出独立函数
- 将大函数拆成小函数
- 以明确函数取代参数
- 替换算法、策略模式
方法长度
代码长短可以直接说明它复不复杂、阅读成本高不高,长度度量往往是复杂度分析里最简单直接的手段。此处,有效代码长度 = 代码行 - 空白行 - 注释行。当前,集团95%的方法的有效代码行数在42以内。
方法参数长度
方法参数过多会降低容错性、模糊表达意图,参数个数应当在3个以内,可以通过静态创建、参数合并为Model等方式降低长度。
最高结构控制层数
花括号 { } 的最大嵌套层数。深度嵌套的代码总是像俄罗斯套娃一样晦涩难懂,可读性和可维护性大打折扣。当前,集团95%的方法的最大嵌套层数在3以内。
设计复杂度
是否有over设计的情况,简单的两个分支,拓展性低的case,做成了策略模式之类的。
测试
case覆盖率
Code Reviewer 检查单测case覆盖情况,是否覆盖了主要业务流程,严格来说每个逻辑分支都需要覆盖。
可测性
Code Reviewer 检查代码中是否有环境、外部、静态类等依赖,如有,会影响单测隔离性,单测应该可以通过打桩隔绝任何依赖。
规范
单测代码也需要规范,清晰易读,重复性低为准。
命名、注释、风格
代码规范
文档
文档更新
Code Reviewer 提醒 developer 在二分库、应用等升级之后更新文档。
如何撰写 Code Review 评论
总结
- 保持友善。
- 解释你的推理。
- 在给出明确的指示与只指出问题并让开发人员自己决定间做好平衡。
- 鼓励开发人员简化代码或添加代码注释,而不仅仅是向你解释复杂性。
糟糕的示例:“为什么这里你使用了线程,显然并发并没有带来什么好处?”
好的示例:“这里的并发模型增加了系统的复杂性,但没有任何实际的性能优势,因为没有性能优势,最好是将这些代码作为单线程处理而不是使用多线程。”
引用
- <设计模式之美> 王争
Google