改善代码可测性的若干技巧
概述
软件的工程性体现在质量与效率。单测是构成软件质量的第一道防线,而单测覆盖率是软件质量的重要指标之一。 编写容易测试的代码,可带来更佳的单测覆盖率,间接提升开发效率。
为什么程序员不大写单测呢? 主要有如下原因:
- 习惯于将细小的重要业务点重复性地混杂在应用中。 结果是:难以对那些重要的业务点编写单测。
- 习惯于编写“一泻千里”的大函数大方法。往往需要花费至少1.5倍的力气去编写一段测试代码,合起来就是2.5倍的开发量。基于工期紧迫,又有多少人愿意费力不讨好呢?
- 习惯于编写耦合外部状态的方法。这是面向对象方法论的一个直接结果,但是也可以通过一个小技巧来改善。
- 习惯于将外部依赖耦合到方法中。这样就需要花费力气去mock外部依赖以及一堆单调乏味的mock代码,同样会使单测难度增加和开发量大增。
针对上述情况,使用“代码语义化”、“分离独立逻辑”、“分离实例状态”、“表达与执行分离”、“参数对象”、“分离纯函数”、“面向接口编程”的技巧,用于编写更容易测试的代码。
技巧
代码语义化
在工程中,常常多处看到类似无语义的代码:
if (state.equals(5)) {
// code ....
}
这段代码有两个问题:(1) 无语义,易重复; (2) 容易引起 NPE。 state.equals(5) 是想表达什么业务语义呢? 在不同领域里,有不同的含义。比如用于订单状态,可用于表达已付款。那么,代码里就应该明确表达这一含义,新建一个类 OrderStateUtil 及 isOrderPaid() ,把这段代码放进去;此外,如果 state = null,会引起 NPE,因此保险的写法是 Integer.valueOf(5).equals(state) 。 这段代码可写作:
public class OrderStateUtil {
public static isOrderPaid() {
return Integer.valueOf(State.ISPAID).equals(state);
}
}
这些,就可以对这段代码进行测试,并且多处放心引用。 像这样的代码,可称之“业务点”。 业务系统中充满着大量这样的细小的业务点。将业务点抽离出来,一则可以大量复用,二则可以任意组合, 就能避免系统重构时需要改多处的问题了。
将单纯的业务点从方法中分离出来。
分离独立逻辑
独立逻辑是不依赖于任何外部服务依赖的业务逻辑或通用逻辑,符合“相同输入运行任意次总是得到相同输出”的函数模型。独立逻辑容易编写单测,然而很多开发者却习惯把大段的独立逻辑放在一个大的流程方法里导致单测难写。来看这段放在流程方法里的代码:
deliveryParam.setItemIds(param
.getItemIds()
.stream()
.map(
x -> {
if (orderItems.stream().anyMatch(orderItem -> x.equals(orderItem.getNewItemId()))) {
return orderItems
.stream()
.filter(orderItem -> x.equals(orderItem.getNewItemId()))
.map(orderItem -> orderItem.getId())
.collect(Collectors.toList()).get(0);
} else {
return x.intValue();
}
}
).collect(Collectors.toList())
);
这段代码本质上就是获取itemIds并设置参数对象,由于嵌入到方法中,导致难以单测,且增大所在方法的长度。此外,不必要地使用stream的双重循环,导致代码难以理解和维护。如果这段逻辑非常重要,将一段未测的逻辑放在每日调用百万次的接口里,那简直是存侥幸心理,犯兵家之忌。应当抽离出来,创建成一个纯函数:
private List<Integer> getItemIds(DeliveryParamV2 param, List<OrderItem> orderItems) {
Map<Long, Integer> itemIdMap = orderItems.stream().collect(
Collectors.toMap(OrderItem::getNewItemId, OrderItem::getId));
return StreamUtil.map(param.getItemIds(), itemId -> itemIdMap.getOrDefault(itemId, itemId.intValue()));
}
public class StreamUtil {
public static <T,R> List<R> map(List<T> dataList, Function<T,R> getData) {
if (dataList == null || dataList.isEmpty()) { return new ArrayList(); }
return dataList.stream().map(getData).collect(Collectors.toList());
}
}
getItemIds 是纯函数,容易编写单测,而原来的一段代码转化为一行调用 deliveryParam.setItemIds(getItemIds(param, orderItems)); 缩短了业务方法的长度。这里封装了一个更安全的 StreamUtil.map , 是为了防止NPE。
将独立逻辑和通用逻辑从方法流程中分离出来。
分离实例状态
在博文 “使用Java函数接口及lambda表达式隔离和模拟外部依赖更容易滴单测” 的隔离依赖配置实际上已经给出了一个例子。 开发人员习惯于将类的实例变量在类方法中直接引用,而这样做的后果就是破坏了方法的通用性和纯粹性。改进的方法其实很简单:编写一个纯函数,将实例变量或实例对象作为参数传入,然后编写一个“外壳函数”,调用这个函数实现功能。这样既能保证对于外部一致的访问接口,又能保证内部实现的通用性和纯粹性,且更容易单测。
分离外部服务调用
现在我们进入正题。 一环扣一环的外部服务调用,正是使单测编写变得困难的主要因素。 在 “使用Java函数接口及lambda表达式隔离和模拟外部依赖更容易滴单测” 一文已经初步探讨了如何使用函数接口及lambda表达式来隔离和模拟外部依赖,增强代码可测性。不过不彻底。 如果一个方法里含有多个外部服务调用怎么办? 如果方法A调用B,B调用C,C调用D,D依赖了外部服务,怎么让 A,B,C,D更加容易测试? 如何可配置化地调用外部服务,而让类的大部分方法保持函数纯粹性而容易单测,少部分方法则承担外部服务调用的职责?指导思想是: 通过函数接口隔离外部服务依赖,分离出真正可单测的部分 。真正可单测的部分往往是条件性、循环性的不含服务调用依赖的业务性逻辑,而顺序的含服务调用依赖的流程性逻辑,应当通过接口测试用例来验证。
表达与执行分离
表达通常是声明式的,无状态的;执行通常是命令式的,有状态且依赖外部环境的。 表达与执行分离,可将状态与依赖分离出来,从而对表达本身进行单测。来看一段代码:
public BizComponent getBizComponentInstance(BizContext BizContext, BizParam params) {
if (ACondition1) {
LogUtils.info(log, "AComponent for {}", params);
return (BizComponent) applicationContext.getBean("AComponent");
}
if(BCondition2){
LogUtils.info(log, "BComponent for {}", params);
return (BizComponent) applicationContext.getBean("BComponent");
}
if (ECondition) {
LogUtils.info(log, "EComponent for {}", params);
return (BizComponent) applicationContext.getBean("EComponent");
}
LogUtils.info(log, "normalComponent for {}", params);
return (BizComponent) applicationContext.getBean("normalComponent");
}
这段代码根据不同条件,获取对应的发货子组件。 可见,代码要完成两个子功能: (1) 根据不同条件判断需要何种组件; (2) 获取相应组件,并打印必要日志。 (1) 是表达,真正值得测试的部分, (2) 是执行,通过接口测试即可验证; 而代码将(1)与(2) 混杂到一起,从而使得编写整个单测难度变大了,因为要mock applicationContext,还需要注入外部变量 log 。 可以将(1) 抽离出来,只返回要发货组件标识,更容易单测,而(2) 则使用多种方式实现。如下代码所示:
public BizComponent getBizComponentInstanceBetter(BizContext bizContext, BizParam params) {
return getActualComponentInstance(getBizComponentID(bizContext, params).name(), params);
}
public ComponentEnum getBizComponentID(BizContext BizContext, BizParam params) {
if (ACondition1) {
return AComponent;
}
if(BCondition2){
return BComponent;
}
if (ECondition) {
return EComponent;
}
return NormalComponent;
}
public BizComponent getActualComponentInstance(String componentName, BizParam params) {
LogUtils.info(log, "component {} for {}", componentName, params);
return (BizComponent) applicationContext.getBean(componentName);
}
public enum BizComponentEnum {
NormalComponent, AComponent, BComponent, EComponent
}
虽然多出了两个方法,但是只有 getBizComponentID 方法是最核心的最需要单测的,并且是无状态不依赖外部环境的,很容易编写单测,只需要测试各种条件即可。这里定义了 BizComponentEnum ,是为了规范发货组件的名称仅限于指定的若干种,防止拼写错误。
识别业务逻辑中的表达与执行,将表达部分分离出来。
分离纯函数
看下面这段代码:
/**
* 根据指定rowkey列表及指定列族、列集合获取Hbase数据
* @param tableName hbase表名
* @param rowKeyList rowkey列表
* @param cfName 列族
* @param columns 列名
* @param allowNull 是否允许值为null,通常针对rowkey
* @return hbase 数据集
* @throws Exception 获取数据集失败时抛出异常
*/
public List<Result> getRows(String tableName, List<String> rowKeyList,
String cfName, List<String> columns,
boolean allowNull) throws Exception {
HTable table = getHtable(tableName);
final String cf = (cfName == null) ? "cf" : cfName;
List<Get> gets = rowKeyList.stream().map(
rowKey -> {
String rowKeyNotEmpty = (rowKey == null ? "null" : rowKey);
Get get = new Get(Bytes.toBytes(rowKeyNotEmpty));
if (columns != null && !columns.isEmpty()) {
for (String col: columns) {
get.addColumn(Bytes.toBytes(cf), Bytes.toBytes(col));
}
}
return get;
}
).collect(Collectors.toList());
Result[] results = table.get(gets);
logger.info("Got {} results from hbase table {}. cf: {}, columns: {}", results.length, tableName, cf, columns);
List<Result> rsList = new ArrayList<>();
for (int i = 0; i < rowKeyList.size(); i++) {
if (!allowNull && isResultEmpty(results[i])) {
logger.warn("cant't get record for rowkey:{}", rowKeyList.get(i));
continue;
}
rsList.add(results[i]);
}
logger.info("got {} rows from table {} with {} rowkeys", rsList.size(), tableName, rowKeyList.size());
return rsList;
}
这段代码有大部分代码惯有的毛病:多个逻辑混杂在一起;大量条件性的业务逻辑中间藏有一小段外部依赖的调用(HTable table = getHtable(tableName); Result[] results = table.get(gets); 访问 Hbase数据源),而这一小段外部依赖使得整个方法的单测编写变得麻烦了。 在 “使用Java函数接口及lambda表达式隔离和模拟外部依赖更容易滴单测” 一文中已经指出,只要使用一个 BiFunction 来模拟 Result[] results = table.get(gets); 这段调用,即可使得 getRows 整个方法变成纯函数。 不过,这个方法已经有好几个参数了,再增加一个参数会比较难看。可以应用参数对象模式,将多个紧密关联的原子参数聚合为一个参数对象。注意到 htableName,rowkeyList, cf, columns, allowNull 确实是从Hbase获取数据所需要的紧密关联的参数聚合,因此适合参数对象模式。重构后代码如下所示:
public List<Result> getRows(String tableName, List<String> rowKeyList,
String cfName, List<String> columns,
boolean allowNull) throws Exception {
return getRows(
new HbaseFetchParamObject(tableName, rowKeyList, cfName, columns, allowNull),
this::getFromHbase
);
}
private Result[] getFromHbase(String tableName, List<Get> gets) {
try {
HTable table = getHtable(tableName);
return table.get(gets);
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
throw new RuntimeException(ex);
}
}
public List<Result> getRows(HbaseFetchParamObject hbaseFetchParamObject,
BiFunction<String, List<Get>, Result[]> getFromHbaseFunc) throws Exception {
String tableName = hbaseFetchParamObject.getTableName();
String cfName = hbaseFetchParamObject.getCfName();
List<String> rowKeyList = hbaseFetchParamObject.getRowKeyList();
List<String> columns = hbaseFetchParamObject.getColumns();
boolean allowNull = hbaseFetchParamObject.isAllowNull();
String cf = (cfName == null) ? "cf" : cfName;
List<Get> gets = buildGets(rowKeyList, cf, columns);
Result[] results = getFromHbaseFunc.apply(tableName, gets);
logger.info("Got {} results from hbase table {}. cf: {}, columns: {}", results.length, tableName, cf, columns);
List<Result> rsList = buildResult(rowKeyList, results, allowNull);
logger.info("got {} rows from table {} with {} rowkeys", rsList.size(), tableName, rowKeyList.size());
return rsList;
}
private List<Get> buildGets(List<String> rowKeyList, String cf, List<String> columns) {
return StreamUtil.map(
rowKeyList,
rowKey -> {
String rowKeyNotEmpty = (rowKey == null ? "null" : rowKey);
Get get = new Get(Bytes.toBytes(rowKeyNotEmpty));
if (columns != null && !columns.isEmpty()) {
for (String col: columns) {
get.addColumn(Bytes.toBytes(cf), Bytes.toBytes(col));
}
}
return get;
});
}
private List<Result> buildResult(List<String> rowKeyList, Result[] results, boolean allowNull) {
List<Result> rsList = new ArrayList<>();
for (int i = 0; i < rowKeyList.size(); i++) {
if (!allowNull && isResultEmpty(results[i])) {
logger.warn("cant't get record for rowkey:{}", rowKeyList.get(i));
continue;
}
rsList.add(results[i]);
}
return rsList;
}
重构后的代码中,(tableName, rowKeyList, cfName, columns, allowNull) 这些原子性参数都聚合到参数对象 hbaseFetchParamObject 中,大幅减少了方法参数个数。现在,getRows(hbaseFetchParamObject, getFromHbaseFunc) 这个从Hbase获取数据的核心函数变成无依赖外部的纯函数了,可以更容易滴单测,而原来的方法则变成了一个接口不变的外壳供外部调用。 这说明了, 任何一个依赖外部服务的非纯函数,总可以分为一个不依赖外部服务的具备核心逻辑的纯函数和一个调用外部服务的壳函数。而单测正是针对这个具备核心逻辑的纯函数。
此外,将构建 gets 和 results 的逻辑分离出来,使得 getRows 流程更加清晰。现在 getRows(hbaseFetchParamObject, getFromHbaseFunc) , buildGets, buildResult 都是纯函数,对三者编写单测后,对从Hbase获取数据的基础函数的质量会更加自信了。
只要方法中的调用服务调用不多于2个(不包括调用方法中的服务依赖),都可以采用这种方法来解决单测的问题。
使用函数接口将外部依赖隔离。
代码模式
纵观业务系统里的代码,主要原子代码模式主要有五种:
- 构建参数
- 判断条件是否满足
- 组装数据
- 调用服务查询数据
- 调用服务执行操作
前三者是可单测的,后两者是不可测的。而代码常常将前三者和后两者混杂在一起,必须想办法将其分离开。
依赖于外部服务的代码模式主要有如下五种:
- 构建参数 - 判断条件满足后调用服务查询数据 - 判断逻辑或组装数据;
- 构建参数 - 判断条件满足后调用服务执行操作 - 判断逻辑或组装数据;
- 构建参数 - 判断条件满足后调用服务查询数据 - 判断逻辑或组装数据 - 判断条件满足后调用服务执行操作 - 判断逻辑或组装数据;
- 构建参数 - 判断条件满足后调用服务执行操作 - 判断逻辑或组装数据 - 判断条件满足后调用服务查询数据 - 判断逻辑或组装数据;
- 以上的任意可能的组合。
一般前四种都可以采用函数接口的方式来解耦外部依赖。
面向接口编程
面向接口编程有两层含义:类级别,面向接口编程; 方法级别,面向函数接口编程。
当要编写单测时,很容易编写接口的mock类或lambda表达式。 比如 A 对象依赖 B 对象里的 M 方法,而 M 方法会从数据库里读取数据。那么 A 就不要直接依赖 B 的实体类,而引用 B 的接口。 当对 A 编写单测时,只要注入 B 的 mock 实现即可。 同理,方法中含有 service 调用时,不要直接依赖 service 调用,而是依赖函数接口,在函数接口中传递 service 调用,如上面的做法。这样,编写单测时,只要传入 lambda 表达式返回mock数据即可。
假设有 m1, m2, m3 方法,m1调用m2, m2调用m3, m1, m2 都是纯函数, m3 会调用外部服务依赖。由于 m3 不纯以及调用关系,导致 m1, m2 也不纯。解耦的方法是面向函数接口编程。 m3 不依赖于外部服务,而是依赖函数接口。在 m3 的参数中提供一个函数接口,m1, m2 传入一个 lambda 表达式。如果 m1, m2 也有很多业务逻辑要测试,那么 m1, m2 也提供相同的函数接口传入服务依赖,直到某一层只是一层“壳函数”。 这样,含有业务逻辑的方法都可以方便地单测,而且更容易理解(函数接口表达了需要什么外部依赖), 而壳函数不需要单测。 当然,这需要对编程方式和习惯的一种改变,而目前大部分编程习惯就是直接在方法里调用service,看上去直观,却会导致方法耦合了外部依赖,难以单测。
小结
良好的编程习惯会带来可测性更佳的代码,对软件的质量和开发效率都有积极影响。代码语义化、分离通用逻辑、将实例状态放在参数中、参数对象、面向接口编程等都是一些小的技巧和做法,结合起来使用就能让代码表达更加容易理解和维护;而函数编程,则可以解耦外部服务依赖,分离出容易测试的具有核心业务逻辑的纯函数。
面向对象/函数式编程是非常强大的混合编程范式。面向对象提供了贴近现实的自然的表达方法,为应用系统提供一个优秀的外部视角; 而函数编程则着重于内部结构优化,可以让内部实现解耦得更加清晰。 两者是相辅相成的,而非对立的。