【规范】代码编写规范

一、如何精准命名

命名过于宽泛

问题描述:

  • 命名过于宽泛,无法精准描述。这是很多代码在命名上存在的严重问题,也是代码难以理解的根源所在:data、info、flag、process、handle、build、maintain、manage、modify 等词语。这种情形不加前缀容易导致界定模糊。

修改意见:

  • 命名要能够描述出这段代码在做的事情。
  • 一个好的名字应该描述意图,而非细节。
    • 我们之所以要将一段代码封装起来,一个重要的原因就是,很多情况下,我们不想知道那么多的细节。如果把细节平铺开来,那本质上和直接阅读代码细节差别并不大。

用技术术语命名

技术易变,改变之后容易忽略更改命名。
问题描述:

  • 用技术术语命名,常用的比如:xxxList、xxxMap、xxxSet 等。

修改意见:

  • 使用实际含义命名。
    • 因为接口是相对稳定的,而实现是易变的。我们需要面对接口编程,而不是面向实现编程。
  • 在实际的代码中,技术名词的出现,往往就代表着它缺少了一个应有的模型。命名要和模型匹配,而不应该使用技术名词。
  • 在一个技术类的项目中,这些技术术语其实就是它的业务语言。但对于业务项目,这个说法则不适用。

总结

  • **用业务语言写代码。**这里业务语言,指的是描述的语言,应该更倾向于业务而不是技术维度。
  • **编写可维护的代码要使用业务语言。**如何判断是否使用的是业务语言呢?将语言给业务成员看,如果能看懂,则表示使用的业务语言已经符合标准了。
  • **建立团队词汇表。**使团队业务对于业务有共同理解,不至于出现一个变量含义多种命名方式。
  • 如果命名不确定,可以在业务层面进行更深的讨论
  • 好的命名,是体现业务含义的命名。

二、如何使用英语命名

对于英语:最低限度的要求是写出来的代码要像是在用英语表达。

违反英语语法规则的命名

问题描述:

  • 使用违反语法的命名方式。

修改意见:

  • 类名是一个名词,表示一个对象,而方法名则是一个动词,或者是动宾短语,表示一个动作。
  • 作为函数名,应该是一个动词的形式组合。

不准确的英语词汇

问题描述:

  • 比如同样的“审核”,有使用 review 也有使用 audit 的,那么应该根据业务场景,决定共同使用何种词汇,而不是两者共存。

解决办法:

  • 建立起一个业务词汇表,而不是根据自己的主观猜想。
  • 使用集体智慧,而不是个人智慧。

image.png

词汇拼写错误

问题描述:

  • 例如:sortFiled 、historiy 等等。

解决办法:

  • 现在的 IDE 都会给出相应的拼写错误提示(typo)。注意不要忽略这些提示内容。
  • 需要注意平时的编码行为,提高自己的英语水平。

总结

  • **制定代码规范。**比如,类名要用名词,函数名要用动词或动宾短语;在函数方法的命名上,更倾向于使用动宾结构。
  • **要建立团队的词汇表。**避免每个团队成员依照各自的命名习惯为相同的目标事物使用了不同的命名。
  • **要经常进行代码评审。**经常性的代码评审可以及时发现这些问题并注意到这些问题。

三、重复代码过多

同项目中复制代码

重复代码过多,发生改动需要全盘修改。真正应该做的是,先提取出函数,然后,在需要的地方调用这个函数。

原文中没有提到作用域的问题。在实际编写的时候,需要注意 Extract 出的函数的作用域,避免代码因为团队成员的变动而腐败。

重复结构

@Task
public void sendBook() {
    try {
        this.service.sendBook();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}
@Task
public void sendChapter() {
    try {
        this.service.sendChapter();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}

重复的代码结构,会造成大量的冗余,比如其中的 catch 操作。根据面向对象的设计来说,应该针对这一段代码进行接口设计。

private void executeTask(final Runnable runnable) {
    try {
        runnable.run();
    } catch (Throwable t) {
        this.notification.send(new SendFailure(t)));
        throw t;
    }
}

之后再使用的时候,就能直接通过实现接口的方式来达到目的。

@Task
public void sendBook() {
    executeTask(this.service::sendBook);
}
@Task
public void sendChapter() {
    executeTask(this.service::sendChapter);
}

经过改造之后,就很容易解决结构上的重复。

选择结构的冗余

if (user.isEditor()) {
    service.editChapter(chapterId, title, content, true);
} else {
    service.editChapter(chapterId, title, content, false);
}

这种结构的 if 语句,只想到 if 语句判断之后要做什么,而没有想到这个 if 语句判断的到底是什么。如果做到更加优雅的编写呢?

boolean approved = user.isEditor();
service.editChapter(chapterId, title, content, approved);

对于进一步的 Extract 操作,可以使用函数来表示:

private boolean isApproved(final User user) {
    return user.isEditor();
}

只要你看到 if 语句出现,而且 if 和 else 的代码块长得又比较像,就可以使用这样的原则进行抽取改造。

总结

写代码要想做到 DRY,一个关键点是能够发现重复。

DRY:Dont’ Repeat Yourself。

  • 复制粘贴的代码。如果需要复制粘贴,首先应该做的是提取一个新的函数出来, 把公共的部分先统一掉。
  • 结构重复的代码。
  • if 和 else 代码块中的语句高度类似。

记住:不要重复自己,不要复制粘贴
参考:无代码低代码如何实现(代码dry)-天道酬勤-花开半夏

四、长函数

长函数的产生

对于函数长度容忍度高,这是导致长函数产生的关键点。
一个好的程序员面对代码库时要有不同尺度的观察能力,看设计时,要能够高屋建瓴,看代码时,要能细致入微。
一般来说,主要有以下原因:

  • 以性能为由。在很多人看来,把函数写长是为了所谓性能。性能优化不应该是写代码的第一考量。
  • 平铺直叙。写代码平铺 直叙,把自己想到的一点点罗列出来。把多个业务处理流程放在一个函数里实现,或者把不同层面的细节放到一个函数里实现。
    • 针对这种情况,我们只需要使用最简单的提取函数的方法,就能进行函数的拆分。
    • 长函数往往还隐含着一个命名问题。长函数,意味着业务处理流程上也会比较繁琐,变量名冲突的概率也会更大;将函数拆分,就可以缩短为了避免冲突而起的长变量名。
    • 关注点越多越好,粒度越小越好。
  • 一次加一点。任何代码都经不起无意识的累积,每个人都没做错,但最终的结果很糟糕。
    • 我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进。

总结

为了避免代码中出现不利于维护和理解的长函数,我们需要遵循的原则是:把函数写短,越短越好

五、大类

大类有两种表现形式:类里面的函数特别多,类里面有特别多的字段和函数。

分模块的程序

一个人理解的东西是有限的,没有人能同时面对所有细节。
如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免。
我们需要避免大类带来的问题,就需要解决大类,即拆解大类成为小类。

大类的产生

  • **职责不单一。**如果一个类的职责不单一,那么在创建的时候,就会产生越来越多的函数和字段,最终导致大类的产生。
    • 解决关键:能够把不同的职责拆分开来。
  • 字段未分组。在类的属性实在过多的情况下,可以将类的属性进行分组处理,避免所有的属性平铺直叙排列在同一个类中。即把不同的信息放到不同的类里面。

总结

将大类拆解成小类,本质上在做的工作是一个设计工作。
支撑我们来做这种分析和设计的就是单一职责原则。
把类写小,越小越好。

六、长参数列表

一旦参数列表变得很长,我们就很难对这些内容进行把控。
长参数列表的问题是数量多,解决这个问题的关键就在于,减少参数的数量。

聚沙成塔

public void createBook(final String title,
                       final String introduction,
                       final URL coverUrl,
                       final BookType type,
                       final BookChannel channel,
                       final String protagonists,
                       final String tags,
                       final boolean completed) {
    // ...
}

在这样的代码中,每增加一个条件,就会往这里面的代码增加一个参数。一旦参数越来越多,就会导致长参数列表。
一个很好的解决办法就是,将参数列表封装成类/对象

public class NewBookParamters {
    private String title;
    private String introduction;
    private URL coverUrl;
    private BookType type;
    private BookChannel channel;
    private String protagonists;
    private String tags;
    private boolean completed;
}

这样的方式解决了传参的问题,但是在使用该参数的时候,是不是要逐一使用 get 或者其他方法来讲属性字段提取出来呢?
一个模型的封装应该是以行为为基础的。
那么根据这个情况,该模型配套的行为应该是创建行为。

public class NewBookParamters {
    private String title;
    private String introduction;
    private URL coverUrl;
    private BookType type;
    private BookChannel channel;
    private String protagonists;
    private String tags;
    private boolean completed;
    
    public Book newBook() {
        return Book.builder
            .title(title)
            .introduction(introduction)
            .coverUrl(coverUrl)
            .type(type)
            .channel(channel)
            .protagonists(protagonists)
            .tags(tags)
            .completed(completed)
            .build();
    }
}

如果需求扩展,需要增加创建作品所需的内容,那这个参数列表就是不变的,相对来说,它就是稳定的。
在使用上,通过构造的方式进行。

public void createBook(final NewBookParamters parameters) {
    // ...
    Book book = parameters.newBook();
    this.repository.save(book);
}

动静分离

把长参数列表封装成一个类,这能解决大部分的长参数列表,但并不等于所有的长参数列表都应该用这种方式解决,因为不是所有情况下,参数都属于一个类。

public void getChapters(final long bookId,
                        final HttpClient httpClient,
                        final ChapterProcessor processor) {
    HttpUriRequest request = createChapterRequest(bookId);
    HttpResponse response = httpClient.execute(request);
    List<Chapter> chapters = toChapters(response);
    processor.process(chapters);
}

在这种情况下,因为每次调用该函数的时候,bookId 的变化频率同 httpClient 和 processor 这两个参数的变化频率是不同的。一边是每次都变,另一边是不变的。
进行动静分离,就是将不会改变的对象转化为成员变量而不是通过参数传递。

public void getChapters(final long bookId) {
    HttpUriRequest request = createChapterRequest(bookId);
    HttpResponse response = this.httpClient.execute(request);
    List<Chapter> chapters = toChapters(response);
    this.processor.process(chapters);
}

长参数列表固然可以用一个类进行封装,但能够封装出这个类的前提条件是:这些参数属于一个类,有相同的变化原因。
所以对于不方便封装成一个类的对象,最好是使用动静分离的方式进行拆分。

布尔标记

public void editChapter(final long chapterId,
                        final String title,
                        final String content,
                        final boolean apporved) {
    // ...
}

代码之中的 approved 属于布尔标记。代码之中的逻辑可能根据该标记有不同的处理方式,通常会使用 if-else 的形式写在函数中。
**将标记参数代表的不同路径拆分出来。**一方面可以进行函数 Extract ,一方面可以减少参数长度。

// 普通的编辑,需要审核
public void editChapter(final long chapterId,
                        final String title,
                        final String content) {
    ...
}

// 直接审核通过的编辑
public void editChapterWithApproval(final long chapterId,
                                    final String title,
                                    final String content) {
    ...
}

这里的一个函数可以拆分成两个函数,一个函数负责“普通的编辑”,另一个负责“可以直接审核通过的编辑”。
在重构中,这种手法叫做移除标记参数(Remove Flag Argument)。

总结

应该尽量写“短小”的代码。

这是由人类理解复杂问题的能力决定的,只有短小的代码,我们才能有更好地把握,而要写出短小的代码,需要我们能够“分离关注点”。

变化频率相同,则封装成一个类。
变化频率不同:

  • 静态不变的,可以成为软件结构的一部分;
  • 多个变化频率的,可以封装成几个类。

减小参数列表,越小越好。

七、控制语句

嵌套代码

public void distributeEpubs(final long bookId) {
    List<Epub> epubs = this.getEpubsByBookId(bookId);
    for (Epub epub : epubs) {
        if (epub.isValid()) {
            boolean registered = this.registerIsbn(epub);
            if (registered) {
                this.sendEpub(epub);
            }
        }
    }
}

这种嵌套语句,产生的原因就是:平铺直叙写代码。
通过对代码的修改,可以变成这样的形式。

public void distributeEpubs(final long bookId) {
    List<Epub> epubs = this.getEpubsByBookId(bookId);
    for (Epub epub : epubs) {
        this.distributeEpub(epub)}
}
private void distributeEpub(final Epub epub) {
    if (epub.isValid()) {
        boolean registered = this.registerIsbn(epub);
        if (registered) {
            this.sendEpub(epub);
        }
    }
}

此种情况下,如果代码的嵌套层数比较多,可以采用 Extract 抽取方法的形式降低嵌套层数。
但是如果是 for 循环语句的层数比较多的时候,考虑到 Java 中这种情况下,通常是 List、Set 等形式的集合,可以采用 Stream 流的形式来降低代码的嵌套层数。

if-else 语句

通常来说,if 语句造成的缩进,很多时候都是在检查某个先决条件,只有条件通过时,才继续执行后续的代码。
这样的代码可以使用**卫语句(guard clause)**来解决,也就是设置单独的检查条件,不满足这个检查条件时,立刻从函数中返回。
这是一种典型的重构手法:以卫语句取代嵌套的条件表达式(Replace Nested Conditional with Guard Clauses)

private void distributeEpub(final Epub epub) {
    if (!epub.isValid()) {
        return;
    }
    boolean registered = this.registerIsbn(epub);
    if (!registered) {
        return;
    }
    this.sendEpub(epub);
}

在编程的时候,要注意函数至多有一层缩进,且不要使用 else 关键字。else 也是一种不好的编程习惯。
在软件开发中,有一个衡量代码复杂度常用的标准,叫做圈复杂度(Cyclomatic complexity,简称 CC),圈复杂度越高,代码越复杂,理解和维护的成本就越高。在圈 复杂度的判定中,循环和选择语句占有重要的地位。圈复杂度可以使用工具来检查。有很多可以检查圈复杂度的工具,比如在 Java 中使用 Checkstyle 就可以进行圈复杂度的检查,你可以限制最大的圈复杂度,当圈复杂度大于某个值的时候, 就会报错。

重复的 Switch

之所以会出现重复的 switch,通常都是缺少了一个模型。所以,应对这种坏味道,重构的手法是:以多态取代条件表达式(Relace Conditional with Polymorphism)

public double getBookPrice(final User user, final Book book) {
    double price = book.getPrice();
    switch (user.getLevel()) {
        case UserLevel.SILVER:
            return price * 0.9;
        case UserLevel.GOLD:
            return price * 0.8;
        case UserLevel.PLATINUM:
            return price * 0.75;
        default:
            return price;
    }
}

public double getEpubPrice(final User user, final Epub epub) {
    double price = epub.getPrice();
    switch (user.getLevel()) {
        case UserLevel.SILVER:
            return price * 0.95;
        case UserLevel.GOLD:
            return price * 0.85;
        case UserLevel.PLATINUM:
            return price * 0.8;
        default:
            return price;
    }
}

在进行重构的时候,应该使用多态来进行消除:

interface UserLevel {
    double getBookPrice(Book book);
    double getEpubPrice(Epub epub);
}
class RegularUserLevel implements UserLevel {
    public double getBookPrice(final Book book) {
        return book.getPrice();
    }
    public double getEpubPrice(final Epub epub) {
        return epub.getPrice();
    }
    class GoldUserLevel implements UserLevel {
        public double getBookPrice(final Book book) {
            return book.getPrice() * 0.8;
        }
        public double getEpubPrice(final Epub epub) {
            return epub.getPrice() * 0.85;
        }
    }
    class SilverUserLevel implements UserLevel {
        public double getBookPrice(final Book book) {
            return book.getPrice() * 0.9;
        }
        public double getEpubPrice(final Epub epub) {
            return epub.getPrice() * 0.85;
        }
    }
    class PlatinumUserLevel implements UserLevel {
        public double getBookPrice(final Book book) {
            return book.getPrice() * 0.75;
        }
        public double getEpubPrice(final Epub epub) {
            return epub.getPrice() * 0.8;
        }
    }
}

在调用的时候,只需要以下的步骤即可:

public double getBookPrice(final User user, final Book book) {
    UserLevel level = user.getUserLevel();
    return level.getBookPrice(book);
}
public double getEpubPrice(final User user, final Epub epub) {
    UserLevel level = user.getUserLevel();
    return level.getEpubPrice(epub);
}

总结

  • 嵌套的代码尽量使用卫语句来进行代替。
  • 避免使用 else 语句。
  • 重复的 switch 尽量通过多态的方式来进行消除。
  • 循环语句和选择语句,可能都是不好的选择。

八、缺乏封装

连续调用

String name = book.getAuthor().getName();

当必须得先了解一个类的细节,才能写出代码时,说明这个封装是失败的。

这样的状况,不仅容易发生 NPE 空指针错误,还容易让人找不到目标数据。

为了解决这种过长的消息链,我们需要通过隐藏委托关系来解决。

class Book {
    // ...
    public String getAuthorName() {
        return this.author.getName();
    }
    // ...
}
String name = book.getAuthorName();

要想提升代码水平,就要先从少暴露细节开始。这一点我们可以遵循迪米特法则(Law of Demeter)。

迪米特法则(Law of Demeter)又叫作最少知识原则(The Least Knowledge Principle),一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。英文简写为:LOD。
迪米特法则可以简单说成:talk only to your immediate friends。 对于OOD来说,又被解释为下面几种方式:一个软件实体应当尽可能少的与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

为了避免封装的方法太多,我们应该考虑的问题是类应该提供哪些行为,而非简简单单地把数据换一种形式呈现出来。
一个好的封装是需要基于行为的。

基本类型

public double getEpubPrice(final boolean highQuality, final int chapterSequenc
	// ...
}

这样的返回值,看似正常,但其实是存在问题的。如果说要对返回的 double 数值进行测试,需要在调用之后一直重复判断。
这种采用基本类型的设计缺少了一个模型。
为了解决问题,可以新建一个模型来解决这个问题。

class Price {
    private long price;
    public Price(final double price) {
        if (price <= 0) {
            throw new IllegalArgumentException("Price should be positive");
        }
        this.price = price;
    }
}

这样的返回值设计之后,就可以避免在很多地方出现重复的逻辑判断代码。
这种引入一个模型封装基本类型的重构手法,叫做以对象取代基本类型(Replace Primitive with Object)
使用基本类型和使用继承出现的问题是异曲同工的。在设计类的时候,组合是优于继承的

public Books extends List<Book> {
	// ...
}

也就是说,在创建类的时候,需要优先使用下面的情况:

public Books {
	private List<Book> books;
	// ...
}

在设计的时候不要只看到了模型的相同之处,却忽略了差异的地方。这种情况称为基本类型偏执(Primitive Obsession)。
封装之所以有难度,主要在于它是一个构建模型的过程。

总结

  • **过长的调用链。**使用迪米特法则来对待该类,在类中尽量使用隐藏委托细节来实现改造。
  • 基本类型偏执。使用构建模型来处理这种问题,以对象的形式来取代基本数据类型,减少重复代码的出现。

构建模型,封装散落的代码。

九、可变数据

Setter 方法

Setter 方法是一种缺乏封装的表现。setter 同 getter 一样,反映的都是对细节的暴露。这两种方法同时存在,意味着你不仅可以读数据,还能对数据进行修改操作。

public void approve(final long bookId) {
    // ...
    book.setReviewStatus(ReviewStatus.APPROVED);
    // ...
}

比可变的数据更可怕的是,不可控的变化。因为暴露了 Setter 方法,那么在被调用的时候,变化就不可控,就有可能发生各种的变化。
缺乏封装 + 不可控变化, setter 方法带来的影响是比较严重的。
修改的方法是:用一个函数替代 setter,也就是用行为封装了起来。

public void approve(final long bookId) {
    // ...
    book.approve();
    // ...
}

之后,在 Book 类中提供 approve 审核方法。

class Book {
    public void approve() {
        this.reviewStatus = ReviewStatus.APPROVED;
    }
}

作为这个类的使用者,并不需要知道这个类到底是怎么实现的。更重要的是变化变得可控了。虽然审核状态这个字段还是会修改,但所有的修改都要通过几个函数作为入口。有任何业务上的调整,都会发生在类的内部,只要保证接口行为不变,就不会影响到其它的代码。
另外,对于在初始化过程中,需要使用到 setter 方法。对于这种只在初始化中使用的情况,没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数。

Book book = new Book(bookId, title, introduction);

消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)
下面是 lombok.config 的配置,通过配置就可以禁用 @Setter 了。

lombok.setter.flagUsage = error
lombok.data.flagUsage = error

可变数据

反对使用 setter,一个重要的原因就是它暴露了数据。暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug 。
在程序编码的时候尽量减少可变数据(Mutable Data)的存在
解决可变数据,还有一个解决方案是编写不变类
一个更实用的做法是区分类的性质。我们最核心要识别的对象分成两种,实体和值对象。实体对象要限制数据变化,而值对象就要设计成不变类
连赋值本身就是不好的编程习惯。
另外一个容易被忽略的就是全局数据(Global Data)。全局数据一样可能被多处修改,容易造成数据安全问题。

总结

  • 移除设值函数(Remove Setting Method)。尽量避免编写 setter 方法。
  • **取消可变数据(Mutable Data)。**采用限制其变化和编写不变类的形式进行。

在编写代码的时候,注意限制可变的数据。

十、变量声明与赋值分离

变量初始化

EpubStatus status = null;
CreateEpubResponse response = createEpub(request);
if (response.getCode() == 201) {
    status = EpubStatus.CREATED;
} else {
    status = EpubStatus.TO_CREATE;
}

这段代码中有两个问题,一个是 ELSE 的使用,一个是变量的初始化。对于变量而言,变量的初始化最好一次性完成。
初始化过程中,真正的问题就是不清晰,变量初始化与业务处理混在在一起。
保证变量初始化一次性完成。

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 变量。
另一个常见的坏习惯就是在 try-catch 语句块中:

InputStream is = null;
try {
    is = new FileInputStream(...);
    ...
} catch (IOException e) {
    ...
} finally {
    if (is != null) {
        is.close();
    }
}

在 JDK 1.7 之后,可以采用 try-with-resource 的写法,代码可以更简洁。

try (InputStream is = new FileInputStream(...)) {
    // ...
}

集合初始化

在集合的初始化上,通常使用的是如下方式:

List<Permission> permissions = new ArrayList<>();
permissions.add(Permission.BOOK_READ);
permissions.add(Permission.BOOK_WRITE);
check.grantTo(Role.AUTHOR, permissions);

但实际上,出现这种写法的原因是在早期的 Java 版本中,没有提供很好的集合初始 化的方法。我们真正需要的是添加了元素的集合,而不是一个空集合。
为了改变这种初始化繁琐的过程,在 JDK 9 中提供了集合的初始化方法:

List<Permission> permissions = List.of(
    Permission.BOOK_READ,
    Permission.BOOK_WRITE
);
check.grantTo(Role.AUTHOR, permissions);

如果使用的是 JDK 1.9 以下的版本,可以使用 Guava(Google 提供的一个 Java 库)实现类似的效果。

List<Permission> permissions = ImmutableList.of(
    Permission.BOOK_READ,
    Permission.BOOK_WRITE
);
check.grantTo(Role.AUTHOR, permissions);

因为此 List 没有可变的需求,所以我们可以使用 ImmutableList 类来实现初始化的需求。
一次性完成初始化,更像是声明式的代码体现的意图,是更高层面的抽象,把意图和实现分开,能更加实现关注点的分离。
用声明式的标准来审视代码,可以看出很多代码的关注点糅合的地方。而我们要做的就是尽量分离关注点。
学习编程不仅仅是要学习实现功能,编程的风格也要与时俱进。

总结

  • **变量要一次性完成初始化,变量的声明和赋值尽量不分离。**把赋值的过程与业务处理混杂在一起,会造成逻辑分散。需要尽可能一次性完成变量初始化。
  • **集合的初始化一次性完成。**可以用声明式的方式进行集合的初始化,让初始化的过程一次性完成。

十一、依赖混乱

缺少防腐层

@PostMapping("/books")
public NewBookResponse createBook(final NewBookRequest request) {
    boolean result = this.service.createBook(request);
    // ...
}

按照通常的架构设计原则,service 层属于我们的核心业务,而 controller 层属于接口。二者相较而言,核心业务的重要程度更高一些,所以,service 的稳定程度也应该更高一些。同样的业务,我们可以用 REST 的方式对外提供,也可以用 RPC 的方式对外提供。
这样来看,那么这个其中的 request 参数放到哪里都会有问题,放到哪个层里都有问题。
这个问题出现的关键在于缺少了一个模型
主要就是因为这个参数只能扮演一个层中的模型,所以只要再引入一个模型就可以破解这个问题。

class NewBookParameter {
    // ...
}

class NewBookRequest {
    public NewBookParameters toNewBookRequest() {
        // ...
    }
}

@PostMapping("/books")
    public NewBookResponse createBook(final NewBookRequest request) {
    boolean result = this.service.createBook(request.toNewBookParameter());
    // ...
}

这样,在调用的时候,可以直接传入原始的参数。

class NewBookRequest {
    public NewBookParameters toNewBookRequest(long userId) {
        // ...
    }
}

@PostMapping("/books")
    public NewBookResponse createBook(final NewBookRequest request, final Authentication authentication) {

    long userId = getUserIdentity(authentication);
    boolean result = this.service.createBook(request.toNewBookParameter(userId));
    // ...
}

这种场景就是个典型的缺陷,缺少防腐层

业务中的具体实现

在很多的业务场景中,会出现很多的具体实现代码。

@Task
public void sendBook() {

    try {
        this.service.sendBook();
    } catch (Throwable t) {
        this.feishuSender.send(new SendFailure(t)));
        throw t;
    }
}

这段代码的作用就是在执行 sendBook 方法的时候,出现异常通过 feishu 发送消息。这是一种符合直觉的做法,但是却不符合设计原则,违反了依赖倒置原则。

高层模块不应依赖于低层模块,二者应依赖于抽象。
抽象不应依赖于细节,细节应依赖于抽象。

这种用具体的实现来调用方法,就是违反了依赖倒置的设计原则。
业务代码中任何与业务无关的东西都是潜在的不好的设计
在这里,feishu 肯定不是业务的一部分,它只是当前选择的一个具体实现。换言之,是否选择 feishu,与团队当前的状态是相关的,如果哪一天团队切换即时通信软件,这个实现就需要换掉。但是,团队是不可能切换业务的,一旦切换,那就是一个完全不同的系统了。
识别一个东西是业务的一部分,还是一个可以替换的实现,可以设想如果不用它,是否还有其它的选择。

如果存在其他的选择,那么最好就不使用具体的实现来进行调用。

interface FailureSender {
    void send(SendFailure failure);
}

class FeishuFailureSenderS implements FailureSender {
    // ...
}

在进行这样的改造之后,我们就可以在后面需要切换 IM 软件或者该部分的实现方式的时候,修改维护更加容易。

总结

依赖混乱是编码的时候很容易出现的问题。

  • 缺少防腐层,会让请求对象传导到业务代码中,造成了业务与外部接口的耦合,也就是业务依赖了一个外部通信协议。一般来说,业务的稳定性要比外部接口高,这种反向的依赖就会让业务一直无法稳定下来,继而在日后带来更多的问题。解决方案自然就是引入一个防腐层,将业务和接口隔离开来。
  • 业务代码中出现具体的实现类,实际上是违反了依赖倒置原则。因为违反了依赖倒置原则,业务代码也就不可避免地受到具体实现的影响,也就造成了业务代码的不稳定。识别一段代码是否属于业务,我们不妨问一下,看把它换成其它的东西,是否影响业务。解决这种问题就是引入一个模型,将业务与具体的实现隔离开来。

代码应该向着稳定的方向依赖

十二、代码不一致

命名不一致

enum DistributionChannel {
    WEBSITE,
    KINDLE_ONLY,
    ALL
}

可以看到分发渠道包括网站(WEBSITE)、只在 Kindle(KINDLE_ONLY),还是全渠道(ALL)。但是其中,网站也代表了只在网站发布。这就意味着网站和 Kindle 都表示的是在单独一个渠道发布,然而 Kindle 渠道的命名缺加上了 ONLY 的结尾。这就是命名的不一致。
出于一致性,类似含义的代码应该有一致的名字。与之相反,一旦出现了不一致的名字,通常都应该表示不同的含义。
修改的方案,统一命名规则即可。

enum DistributionChannel {
    WEBSITE,
    KINDLE,
    ALL
}

方案不一致

public String nowTimestamp() {
    DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    Date now = new Date();
    return format.format(now);
}

这段代码很简单,就是获取当前的时间戳。
之后,在同一个项目中,又出现了另外一种写法。

public String nowTimestamp() {
	LocalDateTime now = LocalDateTime.now();
    return now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

之所以会出现这样的问题,主要是因为一个项目中,应对同一个问题出现了多个解决方案。
出现方案不一致的原因主要有两种:

  • 一个原因就是时间。随着时间流逝,人们会意识到原有解决方案存在的各种问题,于是,有人就会提出新的解决方案。
  • 另一个原因是引入不一致。比如,在代码中引入做同一件事情类似的程序库。在 Java 中常用的程序库就有 GuavaApache 的 Commons Lang 。在编程的时候根据自己的熟悉程度选择其中之一来用,就会可能造成方案出现不一致。

这一点解决方案,通常是在团队中规定一致的解决方案。

代码不一致

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);
}

这段代码中,首先是获取审核通过的作品,这是一个业务动作,接下来的三行其实是在做一件事,也就是发送创建作品的请求。具体到代码上,这三行代码分别是创建请求的参数,根据参数创建请求,最后,再把请求发送出去。这三行代码合起来完成了一个发送创建作品请求这么一件事,而这件事才是一个完整的业务动作。
这个函数里的代码并不在一个层次上,有的是业务动作,有的是业务动作的细节。
针对这个问题,我们可以做出相关的修改。

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);
    }
}

总结

  • 命名中的不一致,类似含义的代码应该有类似的命名,不一致的命名表示不同的含义,需要给出一个有效的解释。
  • 方案中的不一致,一方面是由于代码长期演化造成的,另一方面是项目中存在完成同样功能的程序库。无论是哪种原因,都需要团队先统一约定,保证所有人按照同一种方式编写代码。
  • 代码中的不一致常常是把不同层次的代码写在了一起,最典型的就是把业务层面的代码和实现细节的代码混在一起。解决这种问题的方式,就是通过提取方法,把不同层次的代码放到不同的函数里,而这一切的前提还是是分离关注点,这个代码问题的背后还是设计问题。

保持代码在各个层面上的一致性

十三、代码风格落后

随着语言版本的升级,经常会出现一些新的语言特性。新的语言特性都是为了提高代码的表达性,减少犯错误的几率。

Optional

String name = book.getAuthor().getName();

这样的代码中,因为没有考虑到空指针的问题,所以是有问题的。其次,缺乏封装。

Author author = book.getAuthor();
String name = (author == null) ? null : author.getName();

正确的写法应该是这样的。但是,在 Java 8 中,提供了更先进的 Optional 操作类。 Optional 提供了一个对象容器,可以更方便地用来探测空指针。

class Book {
  public Optional<Author> getAuthor() {
  	return Optioanl.ofNullable(this.author);
  }
  // ...
}
Optional<Author> author = book.getAuthor();
String name = author.isPresent() ? author.get().getName() : null;

除此之外,还有别的写法。

Optional<Author> author = book.getAuthor();
String name = author.map(Author::getName).orElse(null);

所以,在之后的编码中,如果为了避免空指针忘记判断,可以在项目中做一个约定,所有可能为 null 的返回值,都要返回 Optional,以此减少 NPE 的几率

函数式编程

public ChapterParameters toParameters(final List<Chapter> chapters) {
  List<ChapterParameter> parameters = new ArrayList<>();
  for (Chapter chapter : chapters) {
    if (chapter.isApproved()) {
    	parameters.add(toChapterParameter(chapter));
    }
  }
  return new ChapterParameters(parameters);
}

这段代码,主要是向翻译引擎发送章节信息前准备参数的代码,这里首先筛选出审核通过的章节,然后,再把章节转换成与翻译引擎通信的格式,最后,再把所有得到的单个参数打包成一个完整的章节参数。
因为函数式编程的兴起,本身循环语句就应该尽量避免。不是我们不需要遍历集合,而是我们有了更好的遍历集合的方式。
针对上面这段代码,我们可以采用 stream 流的形式将这段代码进行改造。

public ChapterParameters toParameters(final List<Chapter> chapters) {
  List<ChapterParameter> parameters = chapters.stream()
		.filter(Chapter::isApproved)
    .map(this::toChapterParameter)
    .collect(Collectors.toList());
  return new ChapterParameters(parameters);
}

在这段代码中,我们用到了 Java 8 提供的一些基础设施,比如,Stream、lambda 和方法引用等。
lambda 都是为了写短小代码提供的便利,最好的 lambda 应该只有一行代码

总结

  • 声明式编程。引入 Optional 可以减少因为忽略而导致的 NPE 问题。
  • 写短小的函数,不要在 lambda 中写过多代码。知道最基本的 map、filter、reduce 等操作就可以实现集合的转换,避免一定程度的循环。
  • 不断学习“新”的代码风格,不断改善自己的代码

十四、代码评审

沟通反馈

代码评审,它的本质,就是沟通反馈的过程。
我们希望沟通要尽可能透明,尽可能及时。把这样的理解放到代码评审中,就是要尽可能多暴露问题,尽可能多做代码评审。

暴露问题

代码评审就是一个发现问题的过程。我们可以从以下几个方面来进行代码审视:

  • 实现方案。我们要注意正常情况一切顺利,异常情况却考虑不足的情况。
  • 算法正确性。实际工作中,要注意算法正确性以及算法的可行性。
  • 代码的不优雅。及时发现代码编写中不注意规约的一些问题。

及时评审

需要关注代码评审的频率。

  • 评审周期过长是有问题的,周期过长,累积的问题就会增多。这样会造成修改问题的成本过大。
  • 提升评审的频率,评审的周期就会缩短。这样会更快得到反馈,修改的代价会更小。
  • 极限的频率就是结对编程。如果能创造这种条件,结对的双方都能有一定程度的收获。

总结

代码评审暴露的问题越多越好,频率越高越好

十五、新需求

一个有生命力的代码不会保持静止,新的需求总会到来,所以写代码时需要时时刻刻保持嗅觉。

新接口

我们必须对新增接口保持谨慎
是否每一次的需求都需要增加新的接口,我们需要保持谨慎的态度。

  • 新增接口是否有必要,需要认真思考。接口是系统暴露出的能力,一旦一个接口提供出去,无法得知他人会以什么样的方式使用这个接口。
  • 减少的接口是否可行,同样值得调研。有时候系统会与其他系统提供互相调用,如果贸然减少目标接口,可能影响到其他的系统运行。

实体改动

在实现新的需求的时候,通常出现有需求就改动实体的情况。但我们在更改实体之前要考虑实体的适用范围,要了解实体现有的使用范围,不要因为这次的改动影响到现有的系统运行。
对于一个业务系统而言,实体是其中最核心的部分,对它的改动必须有谨慎的思考
随意修改实体,必然伴随着其它部分的调整,而经常变动的实体,就会让整个系统难以稳定下来。

总结

谨慎地对待接口和实体的变动。

  • 接口和实体,其实也是一个系统对外界产生影响的重要部分,一个是对客户端提供能力,一个是产生持久化信息。所以必须谨慎地思考它们的变动。
  • 对于接口,我们对外提供得越少越好,而对于实体,我们必须仔细分析它们扮演的角色。

十六、重构的代码

重构的代码

什么代码应该被重构?质量不好的代码需要被重构。
如何界定“质量不好”这个标准?这才是我们界定这个问题的关键。

  • 对于“霰弹式修改”,解决的办法是使用“搬移函数”和“搬移字段”,把所有需要修改的代码放进同一个模块;
  • 对于“发散式变化”,解决的办法是首先用“提炼函数”将不同用途的逻辑分开,然后用“搬移函数”将它们分别搬移到合适的模块;
  • 对于“过长的消息链”,你应该使用“隐藏委托关系”;
  • 对于“中间人”,对症的疗法则是“移除中间人”,甚至直接“内联函数”。

培养判断力

要想写出高质量的代码,就必须要培养自己对于质量不好的代码的嗅觉灵敏性。
我们必须培养出自己的判断力,学会判断一个类内有多少实例变量算是太大、一个函数内有多少行代码才算太长…

更多的一些代码问题,可以参考:ThoughtWorks文集(精选版)_敏捷_Thoughtworks

我们需要在受控环境下的刻意练习,然后通过工作中的自然积累提升判断力。
从一开始就以合理的方式编程,从而极度避免代码出现问题。这就是极限编程,极限编程是唯一合理且有效的软件开发方法。

总结

  • 需要重构的代码应该是质量不好的代码。
  • 需要在编码的时候培养自己对于代码的嗅觉。

总结

写好代码应该是程序员一辈子需要为之努力的事情。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值