转载

你真的懂 Builder 设计模式吗?论如何实现真正安全的 Builder 模式

作者 | 夏梓耀

你真的懂 Builder 设计模式吗?论如何实现真正安全的 Builder 模式

杏仁后端工程师,励志成为计算机艺术家

Introduction

本文主要探讨如何基于类型系统的一些 Trick 去提升代码的正确性。我们以变种 Builder 模式的缺点作为出发点(Type-Unsafe Builder Pattern),提出改进版本(Type-Safe Builder Pattern),优化正确性;然后再针对改进版本的缺点(Boilerplate Code),提出解决方案(JSR269 API),提升实用性;最后我们发散一下问题,并寻找解决方案。

Type-Unsafe Builder Pattern

变种 Builder 模式

我们在本文中提到的 Builder 模式,并非 GOF 中的 Builder 模式(敲黑板),而是其变种(没那么复杂)版本,其目的用于简化(不可变)对象的构造(比如 Google 的 Protobuf 协议,在生成为 Java 代码后,都会提供一个 Builder 类去构造相关 Message)

注:本文后面提到的 Builder 模式均指这种变种模式

我们定义一个数据类型 User (它有很多字段,但为了简化代码,这里只列出两个):

public class User {
    private String name;
    private String password;

    public void setName(String name) {
        this.name = name;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

然后通过无参构造函数和 setter 方法去构造这个对象:

User user = new User();
user.setName("milo");
user.setPassword("123456");

这种方式的缺点已经不用多说了(大家应该都明白,或者正深受其害中),因为对象的构造过程是非连续的,也就是说对象可处于一个构造不完全的状态,我们很容易写出将对象传入各个方法,每个方法去赋值对象的某一部分这样的代码,这其实引入了一个状态空间,如果状态空间是强可控的,那还好(但依然提高了维护成本,你需要牢牢掌握住对象的构造过程,什么字段在何处被赋值);如果不可控,那么就很难保证这个对象是否被正确的构造,可能在某个方法中覆盖了某字段,也可能遗漏了某字段导致 NPE。

现代 Java 编程更倾向于 Immutable Objects:

public class User {
    private final String name;
    private final String password;
    
    public User(String name, String password) {
        this.name = name;
        this.password = password;
    }
}

这就杜绝了对象的构造不完全状态,它会鼓励我们写出可维护性更高的代码,我们不再将一个构造不完全的对象丢入各种赋值方法中,而是会写出各种方法返回需要的值,然后传入统一的构造方法中,构造过程不再分散,状态空间被消除。

不过光靠一个构造函数是不够的,尤其是字段数很多的时候,这时我们很难保证,我们传对了值,当然现代 IDE 有这方面的优化,我们可以看到当前值被赋给了哪个参数,但是依然需要记住参数顺序,所以变种 Builder 模式产生了:

public class UserBuilder {

private String name;

private String password;

public UserBuilder name (String name) {

this . name = name;

return this ;

}

public UserBuilder password (String password) {

this . password = password;

return this ;

}

public User build () {

return new User(name, password);

}

public static UserBuilder user () {

return new UserBuilder();

}

}

在构建对象时,我们可以通过链式调用完成对象的构造:

User user = UserBuilder.user()
                .name("milo")
                .password("123456")
                .build();

注:一般我们会将 UserBuilder 作为 User 的内部类,并提供 builder 静态方法返回 UserBuilder 的构造

这种方式构造对象,解决了构造函数调用的尴尬,我们不用 care 参数传递的顺序,先 name 还是 password 都行,其次,还简化了默认值问题,通过构造函数传参,我们需要重载多个构造函数,或者将参数声明为 Optional (声明为 Optional 会加大调用端传参的负担),而使用 Builder 你只要不调用对应字段的方法即可。

这种变种 Builder 模式非常的常见,用起来也很优雅,但问题就是要写的样板代码(Boilerplate Code)太多,为此 Lombok 提供了 @Builder 注解,使我们不需要写任何额外代码,就可以优雅的构造对象

缺陷:缺少编译期保证

UserBuilder 相比构造函数的缺点在于缺少编译期保障,在最近的一个项目中,我看到有两个 Merge Request 都是专门解决因使用 Builder 时遗漏了某字段(该类型的字段比较多)的设置,而该字段并没有默认值,所以出 NPE 了,即例如如下代码:

User user = UserBuilder.user()
                .name("milo")
                .build();
...
user.getPassword().equals(password) // NPE

那么如何让我们放心的构造对象并且不会出现 NPE 呢?

试试 @NonNull

Lombok 提供了 @NonNull 注解,声明在字段上就会进行空值检查,这种方式有两个缺点:

  1. 强制空值检查:生成了额外的代码,每次赋值都会进行判断

  2. 运行时检查:代码在运行中才会进行检查,如果你某个 test case 没覆盖到,那么这个代码依然会上生产环境

那么有什么办法可以在编译期就保证构造正确呢?有,而且不止一种。


Type-Unsafe Builder Pattern

Phantom Type

Phantom Type 翻译为:幽灵类型,顾名思义,就是幽灵般的类型,这种类型往往在运行时可以消失,因为在运行时没有任何作用,它们最大的特点就是没有任何实例(Java 的 Void 就是幽灵类型的例子),我们可以通过合理应用幽灵类型来提高代码的正确性,比如我有一个队伍类型:Team,还有一个比赛类型:Game:

public class Team {
    private List<Member> members;
    
    public boolean isReady() {...}
    public boolean isPlaying() {...}
    ...
}
public class Game {
    public Team start(Team team) {
        if (team.isReady()) {...}
    }
    public Team end(Team team) {
        if (team.isPlaying()) {...}
    }
    ...
}

Game 的 start 方法会检查 Team 是否已经准备好(人数是否满足),end 方法会判断队伍是否在比赛中(我们不能对还没开始的队伍进行结算),这里其实引入了一个状态机,需要我们在状态转换时进行状态检查(额外状态维护代码引入可能会影响原本的逻辑),更重要的是这个检查是运行时的,我们很容易将没有 ready 的 team 传入 start 方法,这时我们可以通过定义幽灵类型来做静态的检查,将状态检查逻辑放在编译期:

public class Team<S> {
    private List<Member> members;
    ...
    // phantom types
    static abstract class READY {}
    static abstract class STARTED {}
    static abstract class END {}
}

public class Game {
    public Team<Team.STARTED> start(Team<Team.READY> team) {...}
    public Team<Team.END> end(Team<Team.STARTED> team) {...}
    ...
}

我们额外定义了三个幽灵类型:READY、STARTED、END 用于表示状态,将 Team 扩展为范型类,携带额外的状态类型,我们看 Game 的方法改动,一眼就可以看出这个简单的状态机:

READY -start-> STARTED -end-> END

注:幽灵类型不一定非要定义成内部类,也可以是外部,不一定是抽象类,也可以是接口类型

幽灵类型可以帮助我们写出 非法状态不可表 的代码,因为不合法状态的对象连编译都过不去,这里就不再详细展开了,下面我们就使用基于幽灵类型的 Builder 模式:

Phantom Type Builder Pattern

废话不多说直接上代码:

public class User {
    private final String name;
    private final String password;

    private User(String name, String password) {
        this.name = name;
        this.password = password;
    }

    public static User build(Builder<TRUE, TRUE> builder) {
        return new User(builder.name, builder.password);
    }

    public static Builder<FALSE, FALSE> builder() {
        return new Builder<FALSE, FALSE>();
    }

    public static class Builder<HNAME, HPASSWORD> {
        private String name;
        private String password;

        private Builder() {
        }

        private Builder(String name, String password) {
            this.name = name;
            this.password = password;
        }

        public Builder<TRUE, HPASSWORD> name(String name) {
            this.name = name;
            return new Builder<TRUE, HPASSWORD>(name, this.password);
        }

        public Builder<HNAME, TRUE> password(String password) {
            this.password = password;
            return new Builder<HNAME, TRUE>(this.name, password);
        }
    }
    // phantom types
    static abstract class TRUE {}
    static abstract class FALSE {}
}

可以看到我们将 Builder 类,扩展成了: Builder<HNAME, HPASSWORD> 多出来的类型参数用于表示某字段是否被赋值,默认构造 Builder 类时(builder 方法)返回: Builder<FALSE, FALSE>   表示未初始化,每当调用字段赋值方法时,对应的类型参数都会被设为 TRUE;我们将 build 方法移入了 User 类中,其接受的参数为: Builder<TRUE, TRUE> 即只有全部被设值的情况下才可以调用 build 执行构建,所以如下代码不会通过编译:

User user = User.build(User.builder().name("milo")); // compile error
User user = User.build(User.builder().password("123456")); // compile error
User user = User.build(User.builder().name("milo").password("123456")); // compile success

默认值处理

那么这种方式如何处理默认值呢? 其实很简单,就是将有默认值的字段对应的范型参数去掉即可,如:

public static class Builder<HNAME> {
    private String name;
    private String password = "abc123_";
    ...
    public Builder<TRUE> name(String name) {
        this.name = name;
        return new Builder<TRUE>(name, this.password);
    }

    public Builder<HNAME> password(String password) {
        this.password = password;
        return new Builder<HNAME>(this.name, password);
    }
}

此时 password 调不调用都能通过编译

这种模式有一个缺点,我想你已经看到了,就是别扭的 build 方法调用,它不再是链式的,因为我们要保证一个事情:只有  Builder<TRUE, TRUE> 类型的对象可以执行 build,目前 Java 的类型系统还做不到链式调用,但是具有强大类型系统的 Scala 可以(定义一个 implicit class 即可),实不相瞒这种方式就是从 Scala 那边来的

我们牺牲一点点优雅,换来正确性更高的程序,这是一件值得的事情,不过既能保证编译期检查,又能保持优雅的方式还是有的:

Staged Builder Pattern

阶段式构造,指为每一个字段的赋值都定义专门的类和方法,我们直接看代码,先定义阶段类型:

public interface UserBuilders {
    interface Name {
        Password name(String name);
    }

    interface Password {
        Build password(String password);
    }

    interface Build {
        User build();
    }
}

然后定义 UserBuilder 实现类:

public class UserBuilder implements
        UserBuilders.Name,
        UserBuilders.Password,
        UserBuilders.Build {
    private String name;
    private String password;

    @Override
    public UserBuilders.Password name(String name) {
        this.name = name;
        return this;
    }

    @Override
    public UserBuilders.Build password(String password) {
        this.password = password;
        return this;
    }

    @Override
    public User build() {
        return new User(this.name, this.password);
    }
}

再在 User 中定义静态方法 builder:

public static UserBuilders.Name builder() {
    return new UserBuilder();
}

此时你发现,你只能写出下面的构造代码:

User user = User.builder()
                .name("milo")
                .password("123456")
                .build();

name 方法后面一定跟着 password 方法,password 方法后面一定是 build 方法,你只能按照这个顺序去构造对象

阶段式构造,会引入很多的阶段类型,每个类型表示某个阶段的构造,只有一个设置方法;而且仅用到 Java 类型系统的核心特性,在 Java 4 等始祖版本中都能实现

硬要说个缺点出来的话,就是必须按照固定顺序去写,这可能一开始会不太适应,而幽灵类型是顺序无关的

默认值处理

依然很优雅,只需给  UserBuilders.Build 接口添加方法,且将不是必须的阶段类型删除,前一阶段返回类型跳过该阶段:

public interface UserBuilders {
    interface Name {
        Build name(String name);
    }

    interface Build {
        Build password(String password)
        User build();
    }
}

此时,你会发现 password 不再必须

阶段式构造,本质上等价于构造函数的柯理化,每个阶段类型都等价于一个 Function,只是给每个 Function 类型加了一个更有意义的名字:

public class User {
    ...
    public static Function<String, Function<String, Supplier<User>>> builder() {
        return name -> password -> () -> new User(name, password);
    }
}
...
User.builder().apply("milo").apply("123456").get();
User.builder().name("milo").password("123456").build();

注: 默认值处理仅需提供重载构造函数的柯理化版本即可

缺陷: 样板代码(Boilerplate Code)

一种技术可以解决问题,但是又很难(cost 过大)用于实践,那么这种技术就缺少工程完备性

上面的两种 Builder 模式都可以解决问题,但都缺少工程完备性,究其原因就是要写很多的样板代码,你可能会问,在没有 Lombok 提供 @Builder 注解下,这种模式不是照样用吗?注意这里的复杂性被乘以2了,你不仅要写本来的 Builder 代码,你还需要关注类型层面的计算,因为例子中 User 只有两个字段,如果有 5 个,我们就要写这样的代码: Builder<HNAME, HPASSWORD, TRUE, HEMAIL, HPHONE>每增加一个字段,都要扩展一下类型,阶段式构造也是一样的

生活的秘密在于……用一个烦恼代替另一个烦恼 —— 查尔斯.M.舒尔茨

好在这个烦恼是可以被解决的,学习 Lombok 就行了

JSR 269(Pluggable Annotation Processing API)

我们完全可以像 Lombok 那样定义一个自己的 @Builder 注解,去消除样板代码,篇幅和主题关系我不会详细展开讲解具体编写过程,参考中有很多资料可以学习,这里我们仅说说原理:

JSR 269 是什么? 看这里: https://docs.oracle.com/javase/6/docs/technotes/guides/apt/index.html,2006年发布,为 JPA 2.0 提供元模型生成支持

Pluggable Annotation Processing 顾名思义就是提供注解处理的,不过是在编译期,而非我们平常使用的 Spring 那样在运行期处理,使用它仅需要实现一个抽象类: javax.annotation.processing.AbstractProcessor 覆盖其 init 和 process 方法,剩下的就是针对 AST (抽象语法树) 的操作了,没错这和 macro 非常像,本质上就是一种编译期元编程(我亲切的将其称为: 面向 AST 编程),或者说就是编译期插件

为了让编译器找到处理器,我们可以在打包时通过 META-INF/services/javax.annotation.processing.Processor   文件注册自己,否则就要通过 javac 参数   -processor   来显式指定

Round

使用注解处理器时,有个比较重要的概念,就是 round,javac 的编译过程大致为:

1. 初始化插入式注解处理器(执行 AbstractProcessor 的 init 方法) ->
2. 词法分析,语法分析,输入符号表,产生 JCTree ->
3. 注解处理(执行 AbstractProcessor 的 process 方法 ) ->
4. 数据流处理 ->
5. 解语法糖 ->
6. 字节码生成

在执行完 3 注解处理后,会判断语法树有没有变动,没有就执行 4,有就要回到 2 再进行解析,直到所有插入式注解处理器都不再更改语法树为止,每次循环称为 round

注:1~3 为编译器前端,4~6 为编译器后端   

面向 AST 编程

为什么叫面向 AST 编程呢?因为你的输入是 AST,操作的是各种 AST 节点,输出的还是 AST

构造语法树是一件很枯燥的事,因为不是以写代码的形式去产生代码,而是手写语法树,你需要有一个 code 到 tree 的对应机制,Lombok 就是直接通过修改 JCTree 去完成其功能的,还有一种方式就是使用 JavaPoet 库,该库提供了更友好的 API 去产生 .java 文件的(最终还是要调用 Filer(JSR 269)的接口输出文件),值得注意的是,JavaPoet 只产生新代码,不会修改原有语法树,而 JCTree (即:使用 ${java.home}/../lib/tools.jar 的接口) 可以让你修改它,这不是一种好的实践(但确实有用)

注:我们的 vlogging 类库就是使用的 JCTree 的接口,除了 JavaPoet 外还有JavaParser 等库也可用于代码生成

产生新代码的好处就是: 本质上是一种代码生成,我们可以看到源文件,而修改 JCTree 是看不到代码的,因为它只是修改了内存中的语法树,而不会去修改对应源文件

不过我还是选择了修改 JCTree 的方式去解决 Phantom Type Builder Pattern 的样板代码问题,不是真香,而是 JavaPoet 以前玩过了(就换个没接触过的),而且用 JavaPoet 吐代码需要将内部类提出来

注:这里是 Phantom Type Builder Pattern 的源码:https://github.com/MiloXia/xbuilder,有兴趣可以看看

就目前来说,不管哪种方式去操作 AST 都不是很方便,调试异常麻烦,全靠 sout 了,写过一次不会再想写第二次

Jilt

为什么不去实现更优雅的阶段式构造,而去实现基于幽灵类型的 Builder 模式呢?因为已经有人实现啦,就是这个库: https://github.com/skinny85/jilt, 一个 star 数只有 20 (包括我点的)的小众库,强烈建议将 Lombok 的 @Builder 换成 Jilt的 @Builder(style= BuilderStyle.TYPE_SAFE)

注:本文真的不是一则广告

Null-check

最后,我们来看看我们到底解决了个什么问题,表面上是优化了 Builder 模式的正确性,让我们构造对象有更好的编译期保证,实际上我们只是避免了 NPE,我们提供了一种针对 Builder 模式的编译期 @NonNull

注:虽然本文核心是解决 null-check 问题,但是前面提到的那些类型系统的 Trick 都是普适的,你可以发挥你的想象力,用于解决别的问题上

前面我们都已经写编译器插件了,那么为何不直接通过扩展编译器去实现编译期去做空值检查呢?

当然可以,且已实现,这是一个我早就想介绍的工具了: https://checkerframework.org/

(不要因为那充满学术风的主页,而掉头就走)

Checker Framework 是一个静态分析工作,它增强了 Java 的类型系统,以编译器插件的形式,提供了额外的 type-check

The Checker Framework supports adding pluggable type systems to the Java language in a backward-compatible way. Java’s built-in type-checker finds and prevents many errors — but it doesn’t find and prevent *enough* errors. The Checker Framework lets you run an additional type-checker as a plug-in to the javac compiler. 
 
Programmers can write the type qualifiers in their programs and use the plug-in to detect or prevent errors. The Checker Framework is useful both to programmers who wish to write error-free code, and to type system designers who wish to evaluate and deploy their type systems.

我们可能不会用它去扩展 Java 类型系统,但完全可以用它现有的功能(各种 check)去优化代码正确性,最终写出 bug free 的代码(当然这只是个愿景)

这里是官方推荐的一个 step-by-step 的教程案例:https://github.com/glts/safer-spring-petclinic/wiki

主要讲的就是如何使用 Checker Framework 的 null-checker,需要注意的是:它默认就是检查任何地方的字段是否有可能为空,所以如果允许为空的,你需要添加 @Nullable 注解;另一个坑是,它还会检查依赖库的代码,所以需要配置一下将第三方库排除在检查外

总结

一句话:我们应该尽可能将低级错误在编译期解决掉,将 bug 扼杀在摇篮中,因此我们需要多利用类型系统,多使用静态工具去优化代码正确性

注:这个正确性不是你是否按照要求实现了需求,而是你写的代码是否满足你自己的预期

参考

  • https://michid.wordpress.com/2008/08/13/type-safe-builder-pattern-in-java/

  • https://www.endoflineblog.com/type-safe-builder-pattern-in-java-and-the-jilt-library

  • Lombok原理分析与功能实现:

    http://ipython.mythsman.com/2017/12/19/1/

  • Java-JSR-269-插入式注解处理器:

    https://liuyehcf.github.io/2018/02/02/Java-JSR-269-%E6%8F%92%E5%85%A5%E5%BC%8F%E6%B3%A8%E8%A7%A3%E5%A4%84%E7%90%86%E5%99%A8/

  • Java注解处理器:

    https://race604.com/annotation-processing/

全文完

以下文章您可能也会感兴趣:

  • Java 并发编程 -- 线程池源码实战

  • Java 类型系统从入门到放弃

  • Lombok Builder 构建器做了哪些事情?

  • React Native 项目整合 CodePush 完全指南

  • Facebook、Google、Amazon 是如何高效开会的

  • Tcl 和 Raft 发明人的软件设计哲学

  • 原来你是这样的 Stream —— 浅析 Java Stream 实现原理

  • JVM 揭秘: 一个 class 文件的前世今生

  • 逻辑思维:理清思路,表达自己的技巧

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

你真的懂 Builder 设计模式吗?论如何实现真正安全的 Builder 模式

原文  http://mp.weixin.qq.com/s?__biz=MzUxOTE5MTY4MQ==&mid=2247484242&idx=1&sn=a772a3b804ead11b3a8dad1f554a073d
正文到此结束
Loading...