接着上篇总结,这篇文章继续梳理剩下的几种坏味道。
8 缺乏封装:火车残骸和基本类型偏执
说起火车残骸(Train Wreck)可能会让人一愣,但如果提及迪米特法则(Law of Demeter),可能就明白了:
又被称作最少知识原则(Principle of Least Knowledge):
- 每个单元对于其他的单元只能拥有有限的知识:只是与当前单元紧密联系的单元;
- 每个单元只能和它的朋友交谈:不能和陌生单元交谈;
- 只和自己直接的朋友交谈。
这个原理的名称来源于希腊神话中的农业女神,孤独的Demeter。
下面的示例代码就像火车残骸一般,断得一节一节的:
System.out.println("getBean for: " + beanName);
String name = book.getAuthor().getName();
之所以说它们有坏味道,是因为我们必须了解 System/PrintStream 和 Book/Author 的细节才能编写代码,这无疑就增加了成本,而解决方案就是将它们封装起来:
class Book {
private Author author;
...
public String getAuthorName() {
return this.author.getName();
}
...
}
可能有的小伙伴一看这里,就脱口而出:这不就是 Getter/Setter 方法吗?我用 IDE 自带的快捷键就能生成。
对不起,不是。这里之所以要有一个 getAuthorName()/getName()
方法,是因为我们有这样一个行为:
一个好的封装是基于行为的。要摆拖初级程序员的水平,先从少暴露细节开始。
如果可以,请禁用相关插件中的 Getter/Setter/Data
方法,以 lombok
为例:
lombok.setter.flagUsage = error
lombok.getter.flagUsage = error
lombok.data.flagUsage = error
我们要考虑的是,这个类要提供哪些行为,而不是一上来就让 Getter/Setter
布满屏幕,我们要的是业务动作,而非简简单单的数据展示。
但需要指出的是,之前说过的函数式编程,好像跟火车残骸很像,但它们不是。函数式编程中的表达是声明式的,而不是命令式,二者不能混淆。
再说说基本类型偏执,对象健身操中说:封装所有的原生类型和字符串。也就是说,尽量不要使用基本类型,而是把它们封装成类,以表现出业务逻辑:
class Book {
...
private double price;
...
}
这里用一个 double
属性表示书籍的价格,有什么问题?
- 如何校验价格不能为负数?
- 如何显示元、角、分等各种格式?
- 如何显示小数后两位?
- ……
也许有人会说,我把这些方法都写在 Book
类里面不就完了?这并非好方案,因为没有体现出封装:
class Book {
...
private Price price;
...
}
class Price {
private double price;
public Price(final double price) {
if (price <= 0) {
throw new IllegalArgumentException("Price should be positive");
}
this.price = price;
}
public double getDisplayPrice() {
BigDecimal decimal = new BigDecimal(this.price);
return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
}
}
有了 Price
类,业务逻辑就体现出来了,这也是大家经常说的充血模型。还有一种类似的问题,就是集合:
class UserBO {
...
private List<Book> books;
...
}
这里假设用户业务模型中有一个字段表示该用户拥有的书籍列表,其实这也是缺乏封装的,更好的方案是:
class UserBO {
...
private Books books;
...
}
class Books {
...
private List<Book> books;
...
}
把 Books
单独拎出来,里面是什么逻辑,完全由 Books
决定,哪天把 List<Book>
调整为 Set<Book>
,变动也不会扩散,只需要改 Books
类就好了。
郑晔老师说,封装之所以有难度,就在于这是一个构建模型的过程,而模型构建,是一种设计工作,需要有意识地训练,把散落在各处的代码集结到一起。
9 可变的数据
如果问 Java 程序中,哪种类型的代码会让数据发生不可控的变化,Setter
方法可能会排到 No.1。上文已经提到,要将这类操作禁用掉。
class User {
private String id;
private String name;
...
public void setId(String id){
this.id = id;
}
public void setName(String name){
this.name = name;
}
}
封装就是为了应对数据的变化,但比数据变化更可怕的是:不可控的变化。这些屏幕满天飞的 Setter
方法就会导致这样的结果,你不知道谁在什么时候就调用一个 setName
方法,然后把你的类实例给搞崩掉。
但我就是要改名字呢?我们应该按照业务动作封装一个函数,如果是重命名,就是 rename
,如果是重置名称,就是 resetName
:
class User {
...
private String name;
...
public void rename(String name){
this.name = name;
}
public void resetName() {
this.name = ...;
}
}
既然数据可变的风险这么大,最好的方法就是让它不能变,也就是不可变类,比如 Java 中的 String/LocalDateTime 等类:
public final class String implements ...{
...
public String substring(int beginIndex) {
return substring(beginIndex, length());
}
public String substring(int beginIndex, int endIndex) {
int length = length();
checkBoundsBeginEnd(beginIndex, endIndex, length);
if (beginIndex == 0 && endIndex == length) {
return this;
}
int subLen = endIndex - beginIndex;
return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
: StringUTF16.newString(value, beginIndex, subLen);
}
...
}
final class StringLatin1 {
...
public static String newString(byte[] val, int index, int len) {
if (len == 0) {
return "";
}
return new String(Arrays.copyOfRange(val, index, index + len), LATIN1);
}
...
}
public final class LocalDateTime implements ...{
...
public LocalDateTime plusDays(long days) {
LocalDate newDate = date.plusDays(days);
return with(newDate, time);
}
private LocalDateTime with(LocalDate newDate, LocalTime newTime) {
if (date == newDate && time == newTime) {
return this;
}
return new LocalDateTime(newDate, newTime);
}
...
}
这其实是来自函数式编程中的概念,任何改动,都会生成一个新副本,而不修改原有的对象。既然原有对象不再被改变,那自然是安全的。不可变类的设计主要有三点:
- 所有的字段只在构造函数中初始化;
- 所有的方法都是纯函数;
- 如果需要有改变,返回一个新的对象,而不是修改已有字段。
什么是纯函数,维基百科上的定义是这样的:
在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:
- 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关;
- 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
为此,尽可能编写不可变类是我们追求的目标。在 DDD 中有实体和值对象的概念,对于实体,应该尽可能限制数据变化,对于值对象则要设计成不可变类。
10 变量声明与赋值分离
相信很多人看到这个小标题会大吃一惊,这么简单的东西也有问题?确实有点挑战认知:
int total = 0;
char *p = NULL;
char arr[];
...
total = ...;
p = ...;
arr = ...;
上面一段 C 语言代码的样例,有什么问题?大家不都是这么写的吗?
这种写法其实已经过时了,之所以要将变量的声明与赋值分开,只是因为当时的计算机才刚刚开始发展,为了减小编译器的编写难度才这样做的。很多 Java 程序员从 C 转过来的,仍然这么写,其实已经落伍了:
变量应该尽量在一次性完成初始化,即变量声明与赋值不分离。
EpubStatus status = null;
CreateEpubResponse response = createEpub(request);
if (response.getCode() == 201) {
status = EpubStatus.CREATED;
} else {
status = EpubStatus.TO_CREATE;
}
可以看到,上面的 status 字段第一次赋值其实是个假赋值,没有任何作用,而后面的一大坨逻辑,就是为了拿到值,这其实是很混乱的:即把赋值过程与业务逻辑处理混在了一起。从声明到赋值的距离太远,注定需要我们花费额外的精力去理解,而更简洁的方案并不难:
final CreateEpubResponse response = createEpub(request);
final EpubStatus status = toEpubStatus(response);
private EpubStatus toEpubStatus(final CreateEpubResponse response) {
if (response.getCode() == 201) {
return EpubStatus.CREATED;
}
return EpubStatus.TO_CREATE;
}
除了将变量与赋值一次搞定之外,我们还在每个变量的前面加上了 final 修饰符,这么做就是为了防止它们被二次修改,也就是不可变变量。
在能够使用 final 的地方尽量使用 final,限制变量的赋值,这其中包括普通变量声明,参数声明,类字段声明,以及类和方法的声明。
另一个经常出现问题的地方就是集合的声明与赋值,比如:
List<Permission> permissions = new ArrayList<>();
permissions.add(Permission.BOOK_READ);
permissions.add(Permission.BOOK_WRITE);
private static Map<Locale, String> CODE_MAPPING = new HashMap<>();
...
static {
CODE_MAPPING.put(LOCALE.ENGLISH, "EN");
CODE_MAPPING.put(LOCALE.CHINESE, "CH");
}
这些其实都是坏味道,尤其是下面的 Map 变量定义,引入一个 static 静态代码段。有人可能抱怨说,Java 8 没有好的方式,这其实是借口,我们完全可以用 Google Guava 库 来实现:
List<Permission> permissions = ImmutableList.of(
Permission.BOOK_READ,
Permission.BOOK_WRITE
);
private static Map<Locale, String> CODE_MAPPING = ImmutableMap.of(
LOCALE.ENGLISH, "EN",
LOCALE.CHINESE, "CH"
);
而 Java 9 以后,JDK 已经内置了更简洁的方式,而且返回的实例也是不可变的:
List<Permission> permissions = List.of(
Permission.BOOK_READ,
Permission.BOOK_WRITE
);
private static Map<Locale, String> CODE_MAPPING = Map.of(
LOCALE.ENGLISH, "EN",
LOCALE.CHINESE, "CH"
);
两种方式的差别除了可读性/可维护性外,还有一点:声明一个集合,然后往里面添加元素,这其实是命令式编程(怎么做);而一次性完成声明与赋值,是声明式编程(做什么),即我要一个包含两个元素的集合。
用声明式的标准来看代码,是一个发现代码坏味道的重要参考。
另外提一下,Java 7 中引入的 try-with-resource
,它让我们不用把变量的声明与赋值拆开:
try (InputStream is = new FileInputStream(...)) {
...
}
11 依赖混乱
程序中的依赖是怎么来的,仔细想一想,其实源于我们对系统的拆分,子系统,子模块,包,类等等。拆的越细,依赖的东西可能就越多,由此可能会带来一些混乱。
Spring 中的 MVC 框架,我们经常这样写:
@PostMapping("/books")
public NewBookResponse createBook(final NewBookRequest request) {
boolean result = this.service.createBook(request);
...
}
有什么问题?service 层直接依赖 controller 层的参数,是不可取的,因为 service 层里面更多的是核心业务逻辑处理,相对来说要稳定一些。也就是说,service 层不应跟 rest 接口参数绑定,如果哪天要新增 rpc 接口,service 层就得维护两套逻辑。
再回到 controller 层的入参,一般来说,我们要对其进行入参校验和参数转换,这些工作放在 service 层并不合适。
之所以让人有些纠结,是因为这里缺少了一个模型,也被称为防腐层(Anti-corruption layer, ACL):
class NewBookParameter {
...
}
class NewBookRequest {
public NewBookParameters toNewBookParameter() {
...
}
}
@PostMapping("/books")
public NewBookResponse createBook(final NewBookRequest request) {
NewBookParameters newBookParameters = request.toNewBookParameter();
boolean result = this.service.createBook(newBookParameters);
...
}
**注意:**这里 NewBookRequest(高层)依赖 NewBookParameters(低层)是可以的,但反过来不行。
还有一种依赖混乱的场景:业务代码中混杂着与业务无关的东西,比如:
@Task
public void sendBook() {
try {
this.service.sendBook();
} catch (Throwable t) {
this.feishuSender.send(new SendFailure(t)));
throw t;
}
}
使用飞书来发送任务失败的消息,有什么问题?问题就出在这个飞书上,它是一种具体实现,与业务无关,它很可能会被其他实现替代,比如微信、短信、钉钉等等。为此,我们需要引入一个新模型,抽象出业务含义,即失败消息发送者:
interface FailureSender {
void send(SendFailure failure);
}
class FeishuFailureSenderS implements FailureSender {
...
}
@Task
public void sendBook() {
try {
this.service.sendBook();
} catch (Throwable t) {
this.failureSender.send(new SendFailure(t)));
throw t;
}
}
了解 SOLID 原则的小伙伴,可能已经知道,这里违背了依赖倒置原则:
高层模块不应依赖于低层模块,二者应依赖于抽象。
High-level modules should not depend on low-level modules. Both should depend on abstractions.
抽象不应依赖于细节,细节应依赖于抽象。
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
识别一个东西是业务的一部分,还是一个可以替换的实现,可以问问自己,如果不用它,是否还有其它的选择?如果有,那就应该构建一个模型,将其抽象出来。
12 不一致的代码
一致性,是指团队内应该对代码编写有统一的标准,有些是行业标准,有些是团队自身制定的标准。比如业务服务层,大家的命名可能都是 xxxService,如果出现不同的名字,说明有特殊的含义。
不一致主要有下面三种:
(1)命名中的不一致
enum DistributionChannel {
WEBSITE,
KINDLE_ONLY,
ALL
}
示例代码中的书籍分发渠道,第二个 KINDLE_ONLY 表示只有 Kindle 渠道,那第一个 WEBSITE 呢?表示只有网站。问题就出在这,如果都表示一个独立渠道,要么都加 ONLY,要么都不加:
enum DistributionChannel {
WEBSITE,
KINDLE,
ALL
}
// 或
enum DistributionChannel {
WEBSITE_ONLY,
KINDLE_ONLY,
ALL
}
从代码简洁度来说,不加 ONLY 显然更好。
(2)方案中的不一致
一个经常出现的案例是,工程中引入了很多类似的类库,比如处理字符串为空判断之类的工具包,像 Google Guava,Apache Commons Lang 等都有支持,但每个开发人员习惯不同,混着用的情况可能就发生了。针对这类场景,我们应该在团队内约定,使用哪一个类库。一般来说,Google Guava 库更现代,优先推荐。
还有一个案例是 Java 中的日期时间相关类,在 Java 8 之前的操作可能是:
public String nowTimestamp() {
DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date now = new Date();
return format.format(now);
}
但在 Java 8 及之后的项目中,我们就不能再这样写了,应该使用新版本的 LocalDateTime:
public String nowTimestamp() {
LocalDateTime now = LocalDateTime.now()
return now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
如果在新版本中还使用老版本的 API,这也是一种不一致,我们要与时俱进。
(3)代码中的不一致
最后一种不一致,主要体现在代码的层次上,在一个函数中有些是业务动作,有些是业务动作的细节,比如下面这个代码示例:
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)
}
这段代码中的第 2 行是业务动作,而后面 3 行是业务动作的细节(构造参数并调用函数),所以它们并不在一个层次上,为此我们需要将其抽取出来:
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 里面都是业务动作细节,它们在层次上是一致的。在此基础上,我们还可以进一步重构,比如 createRemoteBook 是一个通用操作,与当前类无关,那么就能将其提取到一个独立的类中。
可以看到,当有了不同层次的视角,分离关注点,代码在我们眼中就不只是一个个函数,而是业务逻辑,表现的是一个个不同的行为。
13 落后的代码风格
其实在前面的梳理中已经提及了一部分,比如 for 循环等,这里仍然以 Java 为例,再做一些补充。既然 JDK 在不断推新,作为开发者也要与时俱进,不断抛弃之前已经过气的写法,让代码更简洁,更安全。
(1)Optional
NullPointerException 是 Java 程序员最熟悉的一个异常,这背后体现的是对容错处理的忽视。这不能完全怪程序员,如果可以,编程语言层面应该要有方案来避免它。Java 8 引入 Optional 就是为了弥补这个错误,如果可以,请在所有可能会返回 null 地方,使用 Optional 来替代。
而从另一方面说,对 null 判断无疑会使代码量膨胀,并且不美观:
Object a = ...;
if(a == null){
return;
}
Object b = a.xxx();
if(b == null){
return;
}
b.xxx();
...
笔者曾单独对这一块进行了总结,有兴趣可以参阅这里:Optional——让你远离空指针的利器。
(2)函数式编程
函数编程的一个核心点是,大部分操作都可以归结为列表转换。其主要动作有下面几种:
- filter:过滤
接受一个条件判断(返回值为 boolean 类型)的函数参数,然后返回符合条件的流(截图来自《Java实战(第2版)》,下同):
- map:映射
接受一个函数作为参数,这个函数会被应用到流中的每个元素上,并将其映射(或者说转换)成一个新的元素:
- reduce:规约
通过一个函数,把流中的元素汇总或计算,比如之前示例中使用 collect 函数来将流中的所有元素组合成一个 List,另外还有计算流中的最大值,最小值,求和等。
如果想将 Java 8 及以后版本中的流运用的如火纯青,《Java实战(第2版)》这本书是一个不错的选择,下面是书中列出的一部分流操作的 API,供参考:
小结
到此,笔者便将 13 种常见的代码问题梳理完毕,知道并不等于做到,每当在工作中意识到问题时,都应该停下来想一想,这么做是不是一种坏味道。
写出好代码,本质上还是要对软件设计有深刻理解,封装、多态、函数式编程、依赖倒置原则等等,一方面是道,一方面是术,二者相辅相成。
最后,不断革新,及时换血,把过气的知识和方法抛弃掉,哪怕它们已经成为日常开发中一部分。郑晔老师说,作为一个精进的程序员,我们要不断地学习“新”的代码风格,改善自己的代码质量,不让自己停留在上一个时代。