大学毕业一年后,一个不是程序员的同学问:为什么程序员总是看着别人的代码不顺眼?当初的我,感觉这是对程序员赤裸裸的挑战,直接否认了:不是这样的吧?我就感觉某些人的代码挺好的。但是这个问题却一直记在心上。
工作了这么多年,一次测试提了一个Bug单,让我修改。这个模块我不太熟悉,但凭经验感觉不难,可是看到代码时,我的内心是:“唉呀妈呀,你个傻X,你写的这个是什么?”,但是我转念一想,对方这样写代表了他对这个功能的理解,只是我没有理解。忽然我好想找到了问题的答案。
程序员这个岗位和其他岗位有一个很大的差异:深度的思想合作。从业务理解,到方案设计,到实施时类的命名、变量名称、参数顺序。你的每一个决定,都会被合作者接触到,被以后的维护人员接触到,你的每一个决定都在和其他人的想法碰撞。所以能让人舒畅的理解意图,是多么的重要呀。难怪《Clean Code》这本书第一章,用了整整一章的篇幅来讲代码可读性的重要性。
这个就是我同学开始提出问题的答案:我认为程序员之间互相就看着不顺眼的主要原因是——没有能很好的使用程序语言表达意图。
我重新阅读代码,想找到问题原因
-
方法命名不好吗?看起来都挺对的。
-
变量命名不好吗?也不差。
-
函数太长吗?基本都在20行内,短得令人发指。
-
圈复杂度高吗?函数都不长,能复杂到哪里去呢?
看起来代码符合各种开发规范,过程质量很不错,但是结果还是不易读。
经过一段时间的思考,我得到这么几个点
关注业务
《Clean Code》中对函数的要求:短小;只做一件事。其实还有一个要求:每个函数一个抽象层级。我理解为:关注业务。
在我们一般的项目中,都有三级结构Controller、Service、Repository。每层负责不同的职责,Controller负责处理Http请求项关键的传输内容,Service负责处理业务逻辑,Repository负责处理持久化。当Service层接收到HttpRequest对象的时候,我们能明显的知道越级了。但是实际上我们的业务并不只有三级,每个函数都应该当做一级处理,每一级都有自己的业务。
你可能会说:作为一个程序员,Show me your code。OK,上代码。下面代码负责计算一个任务,检测任务是否有效,加载任务信息,计算Kpi数据,保存结果即可
public void calculate(CalculateTask task) {
if (!task.isValidate()) {
return;
}
List<TaskData> taskDatas = loadTaskData(task);
List<TaskKpiData> kpiData = calculateAllKpi(taskDatas);
// Save Result
String version = getVersion(task);
List<String> ids = getKpiIds(kpiData);
esObjectClient.deleteByVersion(version, ids);
esObjectClient.saveAll(kpiData);
updateLatest(kpiData, task);
}
这里的逻辑比较简单,代码前面写得也挺简单,后面的代码块通过注释也能看出来是负责保存计算结果的,但是calculate方法的业务应该是对整个Task的调度过程,此过程仅仅关心保存这个动作什么时候进行,不关心如何保存数据的细节,因此这样才更为合理
public void calculate(CalculateTask task) {
if (!task.isValidate()) {
return;
}
List<TaskData> taskDatas = loadTaskData(task);
List<TaskKpiData> kpiData = calculateAllKpi(taskDatas);
saveResult(kpiData, task);
}
这里将代码块提成一个方法,并不是因为代码行太多了,而是因为这个方法的功能是描述计算过程,不是细节。因此代码写成下面的样子也是不合理的
public void calculate(CalculateTask task) {
if (!task.isValidate()) {
return;
}
loadAndCalculateKpiAndSaveResult(task);
}
calculate的逻辑应该包含:校验、加载、计算、保存,四个过程。这四个过程需要在方法中直接有体现,不多也不能少。
我们再来一个例子🌰,这里的功能是从文件加载发票数据,按照发票号分组保存到数据库,并将发票对应范围也保存入库。明确一下需求点
-
从文件加载发票数据
-
按照发票号分组
-
分组后发票信息保存到数据库
-
发票对应范围保存入库
我们再来看看实现
void saveFileToDb() {
Stream<InvoiceInterfaceEntity> invoiceEntities = loadInvoiceFromFile();
saveDataToDb(getSortedData(invoiceEntities));
}
public void saveDataToDb(List<InvoiceInterfaceEntity> entityList) {
entityList.forEach(entity -> {
entityManager.persist(entity);
});
saveInvoiceRecorder(entityList);
}
private void saveInvoiceRecorder(List<InvoiceInterfaceEntity> interfaceEntities) {
Map<String, List<InvoiceInterfaceEntity>> invoiceToEntityMap = interfaceEntities.stream()
.collect(Collectors.groupingBy(InvoiceInterfaceEntity::getInvoiceId));
getInvoiceRecorderEntityStream(invoiceToEntityMap).forEach(entity -> {
entityManager.persist(entity);
});
}
private Stream<InvoiceRecorderEntity> getInvoiceRecorderEntityStream(Map<String, List<InvoiceInterfaceEntity>> invoiceToEntityMap) {
// 将invoiceToEntityMap中的每组发票信息转换为InvoiceRecorderEntity,InvoiceRecorderEntity是用于记录发票开始Id和结束Id的对象
return ...
}
private List<InvoiceInterfaceEntity> getSortedData(Stream<InvoiceInterfaceEntity> invoiceEntities) {
// 对发票数据按照发票号排序
}
可以看得出来,这里的命名可读、有意义,方法短小,但你看懂它在干什么了吗?问题出在哪里呢?
需求中四个需求点,分散在各地,这导致了可读性极差,这是维护人员的噩梦。下面是改进后结果
void saveFileToDb() {
loadInvoiceFromFile()
.collect(Collectors.groupingBy(InvoiceInterfaceEntity::getInvoiceId))
.values().stream()
.forEach(invoiceGroup -> {
InvoiceRecorderEntity record = saveToDb(invoiceGroup);
entityManager.persist(record);
});
}
private InvoiceRecorderEntity saveToDb(List<InvoiceInterfaceEntity> invoiceGroup) {
String invoiceId = invoiceGroup.get(0).getInvoiceId();
long minId = Long.MAX_VALUE;
long maxId = Long.MIN_VALUE;
for (InvoiceInterfaceEntity entity : invoiceGroup) {
entityManager.persist(entity);
minId = Math.min(minId, entity.getId());
maxId = Math.max(maxId, entity.getId());
}
return new InvoiceRecorderEntity(invoiceId, minId, maxId);
}
这里方法saveFileToDb中几乎一行对应一个需求,在一个方法内将整个功能的核心逻辑表述清楚了。
传递方法强相关参数
《Clean Code》里面说:最理想的参数数量是零。可能有人看了这个规则,就将代码就写下了下面的代码。
下面代码用于从Excel文件的smpfp页中读取价格信息,然后转换为ServicePackage信息
public Stream<XXXDto> getServicePackages() {
Map<String, Optional<XXXDto>> prices = parsePrices();
// ...
return null;
}
private Map<String, Optional<XXXDto>> parsePrices() {
return getSheet(() -> "smpfp")
.getObjects(XXXDto.class)
.stream()
.collect(groupingBy(this::generatePackageIdentity,
reducing((l, r) -> l))
);
}
private SheetAgency getSheet(Supplier<String> sheetNameSpplier) {
return this.excelReader.getSheet(sheetNameSpplier.get());
}
大家看这段代码,是否你也好奇:
-
parsePrices从哪里解析的价钱?
-
getSheet怎么需要这么奇怪的一个参数?
我想《Clean Code》中说的“最理想的参数数量是零”的意思是参数数量尽量少,但绝不是说为了减少参数数量不择手段。方法名称是parsePrices,意思是要从某个东西里面解析Price信息,缺少了这个“某个东西”语义就不完善了,所以这个“某个东西”一定要有体现,这里的Price也必须返回。而且需要什么就传递什么参数,不需要的内容也不要传递。这里的方法getSheet需要指定Sheet页的名字,所以只要名字就够了,怎么生成的名字不是重点,相当于传递了过多的信息给getSheet。
下面是修改后的结果
public Stream<XXXDto> getServicePackages() {
Map<String, Optional<XXXDto>> prices = parsePrices(excelReader);
// ...
return null;
}
private Map<String, Optional<XXXDto>> parsePrices(ExcelReader excelReader) {
return getSheet(“smpfp”, excelReader)
.getObjects(XXXDto.class)
.stream()
.collect(groupingBy(this::generatePackageIdentity,
reducing((l, r) -> l))
);
}
private SheetAgency getSheet(String sheetName, ExcelReader excelReader) {
return this.excelReader.getSheet(sheetName);
}
函数需要传递逻辑相关的重要参数,不多也不少,函数名称和参数能表达完整含义。
参数分贵贱
这个世界不是平等的,同为参数,但是参数分贵贱。
下面方法是一个检测数值是否在指定范围的方法,如果不在范围内则记录错误信息
private void checkValue(MessageRecorder messageRecorder,
Set<String> range,
String value,
ExcelRowWrapper<XXXData> row,
String title)
调整顺序后会更为友好
private void checkValue(
String value, // 需要检测的内容是核心对象
String title, // Title不是核心信息,但是它是核心信息的关键附属信息
Set<String> range, // 数据所属范围是次级重要信息
MessageRecorder messageRecorder, // 如果出错负责保存信息的对象,参与核心操作
ExcelRowWrapper<XXXData> row) { // 完善性信息放在最后
这个没有什么好说的,自己感受吧。
正向命名
在《重构》中关于代码的坏味道第一条是神秘名称,强调名称一定要准确。 但是命名准确就没有麻烦吗?请看下面的例子
案例一:
public boolean notValidated() {
return date == null || !date.isValidated();
}
这是检测一个Task是否无效的方法,命名很准确,能看出来无效时返回true。 使用的代码如下:
public void someFun() {
ToDoItem item = new ToDoItem();
if (item.notValidated()) {
showRedColor();
}
//...
if (!item.notValidated()) { // 这是什么鬼逻辑?
queryMoreDetail();
}
}
案例二:我要做一个查询数据库的工具,我希望提供一个灵活的表示条件的结构
public class StringEqualCriteria {
private String field; // 需要查询的数据库字段
private String value; // 字段需要匹配的值
}
// 查询所有完成的任务
queryTask(Arrays.asList(new StringEqualCriteria("status", "完成")));
有了这个StringEqualCriteria,想要查询任意条件的组合就方便多了。 不过需求总是在变的,有一天需要增加不等于的条件,于是StringEqualCriteria变成了这个样子
public static class StringEqualCriteria {
private String field; // 需要查询的数据库字段
private String value; // 字段需要匹配的值
private boolean isNot; // 表示条件是否取反
}
为了查询所有没有完成的任务就有了这样的代码
queryTask(Arrays.asList(new StringEqualCriteria("status", "完成", true)));
是不是感觉哪里怪怪的?你是否也感觉是下面这个样子的?
new StringEqualCriteria("status", "完成", true); // 感觉表示状态为完成的
new StringEqualCriteria("status", "完成", false); // 感觉表示状态没有完成的
这个代码在我所在项目里面已经出现了,因为是一个公共的工具,很多地方都在用,不能随便修改参数含义。虽然已经导致了几次Bug,但是我们还是不能奈何他。
可能有人会问:StringUtils似乎就提供了isNotEmpty方法呀?
我想说:无论工具是否提供isNotEmpty,但它一定提供了isEmpty。
惨痛的经验告诉我们:方法属性命名时可以出现反向命名,但是正向命名的方法或者属性不能丢掉。规则简单点就是——正向命名。
合理注释
好多书中都有对注释不好的评价
-
《重构》中写道
如果你需要注释来解释一块代码做了什么,试试提炼函数 如果函数已经提炼出来,但还是需要注释来解释其行为,试试用改变函数声明
-
《Clean Code》中写道
注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。注意,我用了“失败”一词。我是说真的。注释总是一种失败。
所以我们现在对注释深恶痛绝,禁止了所有注释。
但大家注意到没有,《重构》也为注释平反了,书上说
别担心,我们并不是说你不该写注释。从嗅觉上说,注释不但不是一种坏味道, 事实上它们还是一种香味呢。
《Clean Code》更是大方的给了注释整整一章,细细的讲解如何使用注释。 第四章第一句话:
什么也比不上放置良好的注释来得有用。
后面又特别强调:
注释并不像辛德勒的名单。
《Clean Code》4.3好注释中部分推荐的注释场景
-
法律信息
在公司规范要求是增加
- 对意图的解释
private static synchronized String getUniqueTail() {
// Reset the counter if it is greater than 99999 无用注释,这个注释只是对代码的翻译
// 和ECC约定编号从0-99999循环变化 这个是意图解释的注释
if (counter > 99999) {
counter = 0;
}
counter = counter + 1;
return Long.toString(System.currentTimeMillis()) + "_" + counter;
}
- 警告
// Simple DateFormat is not thread safe
// So we need to use this cache in single thread
// We must not add 'static' qualifler
private Map<String, DateFormat> formatMap = new HashMap<>();
public static <T> Long newVersion(ESDocumentWrapper<T> wrapper) {
// When we don't find doc in es, It does not mean the doc never appear,
// when the doc appear in es sometime, the doc version will can't start from 1
// So we return null when not find doc.It may be overwrite doc sometime.
return (wrapper == null) ? null : wrapper.getVersion() + 1;
}
- TODO注释
这个大家一般都会用
SearchRequestBuilder requestBuilder = client.prepareSearch(indexName)
.setSize(10000) // TODO: 应该变更为Scroll的方式读取。当前数据量在10000以内,可以使用这个临时方案
.setQuery(queryBuilder);
-
公共API的Java Doc
如果代码是公共库,千万记得添加注释
/**
* 按照Key分组
* 前一个Iterator必须是已经根据key排序好的
* @param keyGenerator key的获取方法
* @param <K> key的类型
* @return 按照key分组后的Entity迭代器
*/
public <K> EasyIterator<Map.Entry<K, List<T>>> groupBy(Function<T, K> keyGenerator) {
return new GroupSortedListIterator<>(this, keyGenerator, v -> v);
}
/**
* 实现某个数据类型和字符串之间的互相转换逻辑
* @param <T> converter负责处理的数据类型
* @param <S> 子类类型,用于返回自身的时候不用cast
*/
public interface IPrimitiveConverter<T, S extends IPrimitiveConverter<T, S>> {
/**
* 返回所有支持的数据类型。注册的时候会使用这个类型注册,如果类型重复后一个类型对应的Converter会被注册进去
* @return 所有支持的数据类型
*/
Class[] dataTypes();
注释不是万恶之源,必要的时候该出手时就出手。
无论什么注释,要有意义。没意义的注释一定要删除。
注释不能美化糟糕的代码。如果有办法通过代码表达信息,尽量使用代码。
代码没有表达清楚的时候,一定要添加注释,有注释的代码比表达不清楚的代码好。
公共代码要有良好的注释。
总结
-
一个函数应该将好把自己的功能描述清楚,不多也不少。
-
函数需要传递逻辑相关的重要参数,不多也不少,函数名称和参数能表达完整含义。
-
参数分贵贱。按照语义和相关度排列。
-
函数或者变量需要正向命名。
-
工具类需要有注释,最好还有demo。
参考
阿里巴巴规范:https://alibaba.github.io/Alibaba-Java-Coding-Guidelines/