不一致的代码会造成认知上的负担,在一个系统中,做类似的事情,却有不同的做法,或者起到类似作用的事物,却有不同的名字,让人困惑。
大部分程序员对于一致性本身的重要性是有认知的。但通常来说,大家理解的一致性都表现在比较大的方面,比如,数据库访问是叫 DAO还是叫 Mapper,Repository?在一个团队内,这是有统一标准的,但编码的层面上,要求往往就不是那么细致了。所以,我们才会看到在代码细节上呈现出了各种不一致。我们还是从一段具体的代码来分析问题。
命名中的不一致
有一次,我在代码评审中看到了这样一段代码:
enum DistributionChannel {
?WEBSITE
?KINDLE_ONLY
?AL
}
使用标记作品的分发渠道,从这段代码的内容上,我们可以看到,目前的分发渠道包括:
- 网站(WEBSITE)
- 只在Kindle(KINDLE_ONLY)
- 全渠道(ALL)
面对这段代码,我有些疑惑,于是我提了一个问题:
- WEBSITE 和 KINDLE_ONLY 分别表示的是什么?
WEBSITE 表示作品只会在我们自己的网站发布,KINDLE_ONLY 表示这部作品只会在 Kindle 的电子书商店里上架。 - 二者是不是都表示只在单独一个渠道发布?
是啊! - 既然二者都有只在一个平台上架发布的含义,为什么不都叫 XXX 或者 XXX_ONLY?
呃,你说得有道理。
问题原因就是这里 WEBSITE 和 KINDLE_ONLY 两个名字不一致。
表示类似含义的代码应该有一致的名字,比如,很多团队里都会把业务写到服务层,各种服务的命名也通常都是 XXXService。 一旦出现不一致名字,通常都表示不同含义。比如,对于那些非业务入口的业务组件,它们的名字就会不一样,会更符合其具体业务行为,像BookSender ,它表示将作品发送到翻译引擎。
一般枚举值表示的含义应该都有一致的业务含义,一旦出现不同,我就需要确定不同的点到底在哪里,这就是我提问的缘由。
显然,这段代码的作者给这两个枚举值命名时,只是分别考虑了它应该起什么名字,却忽略了这个枚举值在整体中扮演的角色。
理解这一点,改动是很容易,后来,代码被统一成了一个形式:
enum DistributionChannel {
?WEBSITE
?KINDLE
?AL
}
方案中的不一致
public String nowTimestamp() {
DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date now = new Date();
return format.format(now);
}
当一个系统向另外一个系统发送请求时,需要带一个时间戳过去,这里就是把这个时间戳按照一定格式转成了字符串类型,主要就是传输用,便于另外的系统进行识别,也方便在开发过程中进行调试。
这段代码本身的实现是没有问题的。它甚至考虑到了 SimpleDateFormat 这个类本身存在的多线程问题,所以,它每次去创建了一个新的 SimpleDateFormat 对象。
那我为什么还说它是有问题的呢?因为这种写法是 Java 8 之前的写法,而我们用的 Java 版本是 Java 8 之后的。
在很长的一段时间里,Java 的日期时间解决方案一直是一个备受争议的设计,它的问题很多,有的是概念容易让人混淆(比如:Date 和 Calendar 什么情况下该用哪个),有的是接口设计的不直观(比如:Date 的 setMonth 参数是从 0 到 11),有的是实现容易造成问题(比如:前面提到的 SimpleDateFormat 需要考虑多线程并发的问题,需要每次构建一个新的对象出来)。
这种乱象存在了很长时间,有很多人都在尝试解决这个问题(比如 Joda Time)。从 Java 8开始,Java 官方的 SDK 借鉴了各种程序库,引入了全新的日期时间解决方案。这套解决方案与原有的解决方案是完全独立的,也就是说,使用这套全新的解决方案完全可以应对我们的所有工作。
我们现在的这个项目是一个全新的项目,我们使用的版本是 Java 11,这就意味着我们完全可以使用这套从 Java 8 引入的日期时间解决方案。所以,我们在项目里的约定就是所有的日期时间类型就是使用这套新的解决方案。
现在你可能已经知道我说的问题在哪里了,在这个项目里,我们的要求是使用新的日期时间解决方案,而这里的 SimpleDateFormat 和 Date 是旧解决方案的一部分。所以,虽然这段代码本身的实现是没有问题的,然而,放在项目整体中,这却是一个坏味道,因为它没有和其它的部分保持一致。
后来使用了新的解决方案:
public String nowTimestamp() {
?LocalDateTime now = LocalDateTime.now()
return now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
之所以会这样,因为一个项目中,应对同一个问题出现了多个解决方案,如果没有统一约定,项目成员会根据自己写代码时的感觉随机选择方案,导致方案不一致。
为什么一个项目中会出现多个解决方案?
- 时间
时间消逝,技术发展,人们会主动意识到原方案的问题,就会提出新方案,像这里 Java 日期时间的解决方案,就是 JDK 本身随时间演化造成的。有的项目时间比较长,也会出现类似问题。 - 因为自己的原因引入
比如,在代码中引入做同一件事情类似的程序库。比如判断字符串是否为空或空串,就有 Guava 和 Apache Commons Lang,都能做同样事情,所以,程序员也会根据自己的熟悉程度选择其中之一来用,造成代码不一致。
这两个程序库是很多程序库的基础,经常因为引入了其它程序库,相应的依赖就出现在我们的代码中。所以,我们必须约定,哪种做法是我们在项目中的标准做法,以防出现各自为战的现象。比如,在我的团队中,我们就选择 Guava 作为基础库,因为相对来说,它的风格更现代,所以,团队就约定类似的操作都以 Guava 为准。
代码中的不一致
public void createBook(final List<BookId> bookIds) throws IOException {
?List<Book> books = bookService.getApprovedBook(bookIds)
?CreateBookParameter parameter = toCreateBookParameter(books)
?HttpPost post = createBookHttpRequest(parameter)
?httpClient.execute(post)
}
在翻译引擎中创建作品的代码:
- 首先,根据要处理的作品 ID,获取其中已审核通过的作品
- 然后,发送一个 HTTP 请求在翻译引擎中创建出这个作品
有什么问题? 这段代码的不一致,这些代码不是一个层次的代码!
首先是获取审核通过的作品,这是一个业务动作,接下来的三行其实是在做一件事,也就是发送创建作品的请求,这三行代码:
三行代码合起来完成了一个发送创建作品请求这么一件事,而这件事才是一个完整的业务动作。
所以,这个函数里的代码并不在一个层次上,有的是业务动作,有的是业务动作的细节。理解到这,把这些业务细节的代码提取到一个函数:
public void createBook(final List<BookId> bookIds) throws IOException {
?List<Book> books = bookService.getApprovedBook(bookIds)
?createRemoteBook(books)
}
private void createRemoteBook(List<Book> books) throws IOException {
?CreateBookParameter parameter = toCreateBookParameter(books)
?HttpPost post = createBookHttpRequest(parameter)
?httpClient.execute(post)
}
结果上看,原来的函数(createBook)里都是业务动作,而提取出来的函数(createRemoteBook)则都是业务动作的细节,各自语句都在一个层次。
分清代码处于不同层次,基本功还是分离关注点!
一旦分解出不同关注点,还可进一步调整代码的结构。 像前面拆分出来的这个方法,我们已经知道它的作用是发出一个请求去创建作品,本质上并不属于这个业务类的一部分。 所以,还可通过引入一个新模型,将这个部分调整出去:
public void createBook(final List<BookId> bookIds) throws IOException {
List<Book> books = this.bookService.getApprovedBook(bookIds);
this.translationEngine.createBook(books);
}
class TranslationEngine {
public void createBook(List<Book> books) throws IOException {
?CreateBookParameter parameter = toCreateBookParameter(books)
?HttpPost post = createBookHttpRequest(parameter)
?httpClient.execute(post)
?
?..
}
一说到分层,大多数人想到的只是模型的分层,很少有人会想到在函数的语句中也要分层。各种层次的代码混在一起,许多问题也就随之而来了,最典型莫过长函数。
我们在做的依然是模型分层,只不过,这次的出发点是函数的语句。“分离关注点,越小越好”的意义所在。观察代码的粒度足够小,很多问题自然就会暴露出来。
程序员开始写测试时,有一个典型的问题:如何测试一个私有方法。有人建议用一些特殊能力(比如反射)去测试。我给这个问题的答案是,不要测私有方法。 之所以想测试私有方法,就是分离关注点没有做好,把不同层次的代码混在一起。前面这段代码,如果要测试前面那个 createRemoteBook 方法还是有一定难度的,但调整之后,引入了 TranslationEngine 这个类,这个方法就变成了一个公开方法,就可以按照一个公开方法去测试了,所有问题迎刃而解。
很多程序员纠结的技术问题,其实是一个软件设计问题,不要通过奇技淫巧去解决一个本来不应该被解决的问题。
总结
对于一个团队来说,一致是非常重要的,是降低集体认知成本的重要方式。我们分别见识了:
类似含义的代码应该有类似的命名,不一致的命名表示不同含义,需要给出一个有效解释。
方案中的不一致:
- 由于代码长期演化造成的
- 项目中存在完成同样功能的程序库
无论是哪种原因,都需要团队先统一约定,保证所有人按照同一种方式编写代码。
代码中的不一致常常是把不同层次的代码写在了一起,最典型的就是把业务层面的代码和实现细节的代码混在一起。解决这种问题的方式,就是通过提取方法,把不同层次的代码放到不同的函数里,而这一切的前提还是是分离关注点,这个代码问题的背后还是设计问题。
保持代码在各个层面上的一致性。
|