これは、なにをしたくて書いたもの?
JavaのRecordsを使っているとビルダーが欲しいなと思うのですが、自動生成してくれるライブラリーがあるようなので試してみました。
Recordsのビルダーを生成するライブラリー
今回見つけたのはこちらです。
Jilt。Recordsでなくてもビルダーを生成することができます。
RecordBuilder。こちらはRecords専用のようです。
GitHub - Randgalt/record-builder: Record builder generator for Java records
この手のライブラリーといえばLombokですね。
Lombokは1.18.20からRecordsをサポートしたようです。
PLATFORM: All lombok features updated to act in a sane fashion with JDK16's record feature. In particular, you can annotate record components with @NonNull to have lombok add null checks to your compact constructor (which will be created if need be).
個人的にLombokはあまり扱わないようにしているのですが、今回はJiltとRecordBuilderを簡単に試してみたいと思います。
なお、いずれにも言えますがPluggable Annotation Processing APIを使ってコンパイル時にビルダーを生成するタイプのライブラリーです。
環境
今回の環境はこちら。
$ java --version openjdk 21.0.4 2024-07-16 OpenJDK Runtime Environment (build 21.0.4+7-Ubuntu-1ubuntu222.04) OpenJDK 64-Bit Server VM (build 21.0.4+7-Ubuntu-1ubuntu222.04, mixed mode, sharing) $ mvn --version Apache Maven 3.9.8 (36645f6c9b5079805ea5009217e36f2cffd34256) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 21.0.4, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-117-generic", arch: "amd64", family: "unix"
Jilt
ここからは、それぞれのライブラリーを似たような内容で試していってみます。サンプルを書きながらだいたいの雰囲気を掴む感じで。
まずはJiltから。
繰り返しますが、Jiltは対象がRecordsでなくても利用できます。
準備
Maven依存関係など。
<properties> <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>cc.jilt</groupId> <artifactId>jilt</artifactId> <version>1.6.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.3</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.26.3</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>cc.jilt</groupId> <artifactId>jilt</artifactId> <version>1.6.1</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build>
アプリケーションに対する依存関係としてはprovided
スコープで、Maven Compiler Pluginに対してはPluggable Annotation Processing APIを
使うように設定します。
確認はテストコードで行います。JUnit 5とAssertJが入っているのはこれが理由です。
テストコードの雛形はこちら。
src/test/java/org/littlewings/recordclass/JiltTest.java
package org.littlewings.recordclass; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class JiltTest { // ここに、テストを書く!! }
シンプルに使ってみる
まずはシンプルな例から。
こんなRecordsを用意。ドキュメントのサンプルはふつうのクラスですが、Recordsでも使えます。
src/main/java/org/littlewings/recordclass/Person.java
package org.littlewings.recordclass; import org.jilt.Builder; @Builder public record Person(String firstName, String lastName, int age) { }
@Builder
アノテーションをつけます。
コンパイルすると
$ mvn compile
こういうビルダーが生成されます。
target/generated-sources/annotations/org/littlewings/recordclass/PersonBuilder.java
package org.littlewings.recordclass; import java.lang.String; import javax.annotation.processing.Generated; @Generated("Jilt-1.6.1") public class PersonBuilder { private String firstName; private String lastName; private int age; public static PersonBuilder person() { return new PersonBuilder(); } public PersonBuilder firstName(final String firstName) { this.firstName = firstName; return this; } public PersonBuilder lastName(final String lastName) { this.lastName = lastName; return this; } public PersonBuilder age(final int age) { this.age = age; return this; } public Person build() { return new Person(firstName, lastName, age); } }
使ってみます。とても素直なビルダーが生成されていますね。
@Test void simple() { Person katsuo = PersonBuilder .person() .firstName("カツオ") .lastName("磯野") .age(11) .build(); assertThat(katsuo).isEqualTo(new Person("カツオ", "磯野", 11)); PersonBuilder builder = PersonBuilder .person() .lastName("磯野"); }
型を確認するために、対象のインスタンスを生成せずにビルダーで1度止めています。
今回はあまり意味がありませんが、このあとの確認で違いがわかるようになります。
ちなみに、このスタイルのビルダーはClassic Builderと呼ぶようです。Jiltのデフォルトのビルダーです。
Staged Builder
次はStaged Builderです。
Staged Builderは宣言されたプロパティの順に構築することを強制し、ビルダーを使った時にプロパティの設定漏れを防止することができます。
The Type-Safe Builder pattern in Java, and the Jilt library | End of Line Blog
たとえば、先ほどのビルダーの場合はあるプロパティの設定を忘れてしまっても元の定義を覚えてないとわかりませんからね。
@Test void missingProperty() { Person unknown = PersonBuilder .person() .lastName("磯野") .build(); assertThat(unknown).isEqualTo(new Person(null, "磯野", 0)); }
src/main/java/org/littlewings/recordclass/Book.java
Staged Builderを使うには、@Builder
アノテーションのstyle
属性にBuilderStyle.STAGED
を指定します。
package org.littlewings.recordclass; import org.jilt.Builder; import org.jilt.BuilderStyle; import org.jilt.Opt; @Builder(style = BuilderStyle.STAGED) public record Book(String isbn, String title, @Opt String tag, int price) { }
また@Opt
アノテーションを指定することで、そのプロパティが任意であることを示します。
Jilt / Staged Builders / Optional properties
今回はちょっと不自然な位置につけているのですが、これは他のパターンとの比較のためです。
生成されるビルダー。
target/generated-sources/annotations/org/littlewings/recordclass/BookBuilder.java
package org.littlewings.recordclass; import java.lang.String; import javax.annotation.processing.Generated; @Generated("Jilt-1.6.1") public class BookBuilder implements BookBuilders.Isbn, BookBuilders.Title, BookBuilders.Price, BookBuilders.Optionals { private String isbn; private String title; private String tag; private int price; private BookBuilder() { } public static BookBuilders.Isbn book() { return new BookBuilder(); } public BookBuilder isbn(final String isbn) { this.isbn = isbn; return this; } public BookBuilder title(final String title) { this.title = title; return this; } public BookBuilder tag(final String tag) { this.tag = tag; return this; } public BookBuilder price(final int price) { this.price = price; return this; } public Book build() { return new Book(isbn, title, tag, price); } }
妙にたくさんインターフェースを実装しているのですが、
public class BookBuilder implements BookBuilders.Isbn, BookBuilders.Title, BookBuilders.Price, BookBuilders.Optionals {
これはもうひとつの生成されたファイルに含まれています。
target/generated-sources/annotations/org/littlewings/recordclass/BookBuilders.java
package org.littlewings.recordclass; import java.lang.String; import javax.annotation.processing.Generated; @Generated("Jilt-1.6.1") public interface BookBuilders { interface Isbn { Title isbn(final String isbn); } interface Title { Price title(final String title); } interface Price { Optionals price(final int price); } interface Optionals { Optionals tag(final String tag); Book build(); } }
使ってみます。
@Test void stagedBuilder() { Book book1 = BookBuilder .book() .isbn("978-4798161488") .title("MySQL徹底入門 第4版 MySQL 8.0対応") .price(4180) .tag("Database") .build(); assertThat(book1).isEqualTo( new Book("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", "Database", 4180) ); Book book2 = BookBuilder .book() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)") .price(3520) .build(); assertThat(book2).isEqualTo( new Book("978-4297132064", "改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)", null, 3520) ); }
tagは任意のプロパティなのですが、こう見ると最初のビルダーと違いがわかりませんね。
構築途中を見ると、違いがわかります。
@Test void stagedBuilderDetail() { BookBuilders.Isbn builder1 = BookBuilder .book(); BookBuilders.Title builder2 = BookBuilder .book() .isbn("978-4798161488"); BookBuilders.Price builder3 = BookBuilder .book() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)"); BookBuilders.Optionals builder4 = BookBuilder .book() .isbn("978-4798160436") .title("PostgreSQL徹底入門 第4版 インストールから機能・仕組み、アプリ作り、管理・運用まで") .price(3608); }
こんな感じで、ビルダーの各メソッドの戻り値が次に要求するプロパティの型になっています。任意のプロパティになると、Optionals
という
型になります。今回は任意のプロパティはひとつだけですが、複数ある場合でもこうやって最後に設定することになるようです。
なお、title
の次はprice
を要求しているところがポイントです。
BookBuilders.Price builder3 = BookBuilder .book() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)");
生成されたビルダーのコードを載せるのは、このスタイルまでにしておきます。
'Staged, but preserving order' Builder
次は「'Staged, but preserving order' Builder」ですが…なんと呼んだらいいんでしょうね、そのままでしょうか。
Jilt / Staged Builders / 'Staged, but preserving order' Builder style
Staged Builderとの違いは、プロパティの設定順を宣言したまま保つことです。
style
属性をBuilderStyle.STAGED_PRESERVING_ORDER
に指定すると、'Staged, but preserving order' Builderになります。
src/main/java/org/littlewings/recordclass/BookPerservingOrder.java
package org.littlewings.recordclass; import org.jilt.Builder; import org.jilt.BuilderStyle; import org.jilt.Opt; @Builder(style = BuilderStyle.STAGED_PRESERVING_ORDER) public record BookPerservingOrder(String isbn, String title, @Opt String tag, int price) { }
使ってみると、先ほどのStaged Builderとは違って任意にしているプロパティであっても宣言順に設定することになります。
@Test void stagedBuilderPreservingOrder() { BookPerservingOrder book1 = BookPerservingOrderBuilder .bookPerservingOrder() .isbn("978-4798161488") .title("MySQL徹底入門 第4版 MySQL 8.0対応") .tag("Database") .price(4180) .build(); assertThat(book1).isEqualTo( new BookPerservingOrder("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", "Database", 4180) ); BookPerservingOrder book2 = BookPerservingOrderBuilder .bookPerservingOrder() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)") .price(3520) .build(); assertThat(book2).isEqualTo( new BookPerservingOrder("978-4297132064", "改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)", null, 3520) ); }
Staged Builderは、必須のプロパティを先に設定することを要求します。なので、途中のビルダーの型も変わって任意のプロパティのtag
の型が
現れたりします。
@Test void stagedBuilderPreservingOrderDetail() { BookPerservingOrderBuilders.Isbn builder1 = BookPerservingOrderBuilder .bookPerservingOrder(); BookPerservingOrderBuilders.Title builder2 = BookPerservingOrderBuilder .bookPerservingOrder() .isbn("978-4798161488"); BookPerservingOrderBuilders.Tag builder3 = BookPerservingOrderBuilder .bookPerservingOrder() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)"); BookPerservingOrderBuilders.Build builder4 = BookPerservingOrderBuilder .bookPerservingOrder() .isbn("978-4798160436") .title("PostgreSQL徹底入門 第4版 インストールから機能・仕組み、アプリ作り、管理・運用まで") .price(3608); }
ドキュメントでは、'Staged, but preserving order' Builderは構築対象のプロパティの数が少ない場合か、プロパティの定義順が
自然である場合に使うことを勧めています。
Staged Builderと違って任意のプロパティであってもビルダーで指定できるタイミングは1度しかないので、プロパティの定義順が自然でない
場合はどこで設定したらいいのかわかりにくくなる、とされています。
Functional Builder
最後はFunctional Builderです。
Jilt / Functional Builder style
Functional Builderは、これまでのようなメソッドチェーンのスタイルではありません。単一のファクトリメソッドを使って、プロパティごとに
生成されたstaticメソッドを使ってプロパティを指定します。
style
属性にはBuilderStyle.FUNCTIONAL
を指定します。
src/main/java/org/littlewings/recordclass/OperatingSystem.java
package org.littlewings.recordclass; import org.jilt.Builder; import org.jilt.BuilderStyle; import org.jilt.Opt; @Builder(style = BuilderStyle.FUNCTIONAL) public record OperatingSystem(String name, String type, @Opt String version) { }
使い方はこんな感じですね。
@Test void functionalBuilder() { OperatingSystem operatingSystem1 = OperatingSystemBuilder .operatingSystem( OperatingSystemBuilder.name("Ubuntu Linux"), OperatingSystemBuilder.type("Linux"), OperatingSystemBuilder.Optional.version("22.04 LTS") ); assertThat(operatingSystem1).isEqualTo(new OperatingSystem("Ubuntu Linux", "Linux", "22.04 LTS")); OperatingSystem operatingSystem2 = OperatingSystemBuilder .operatingSystem( OperatingSystemBuilder.name("Rocky Linux"), OperatingSystemBuilder.type("Linux") ); assertThat(operatingSystem2).isEqualTo(new OperatingSystem("Rocky Linux", "Linux", null)); }
これで使い方のイメージはできたと思います。
その他
Jiltの使い方、特にビルダーのスタイルですが、README.md
以外にはこれはBuilderStyle
列挙型のJavadocを見るのがよいと思います。
https://github.com/skinny85/jilt/blob/1.6.1/src/main/java/org/jilt/BuilderStyle.java
カスタマイズやその他のアノテーションの属性などはREADME.md
を確認しましょう。
- Jilt / Customizing the generated code
- Jilt / Other @Builder attributes
- Jilt / @BuilderInterfaces annotation
- Jilt / Meta-annotations
- Jilt / Supporting classes with private constructors
そしてこちらのブログエントリーですね。
The Type-Safe Builder pattern in Java, and the Jilt library | End of Line Blog
RecordBuilder
次はRecordBuilderです。
GitHub - Randgalt/record-builder: Record builder generator for Java records
こちらはRecords向けですね。
準備
Maven依存関係など。
<properties> <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>io.soabase.record-builder</groupId> <artifactId>record-builder-processor</artifactId> <version>42</version> <scope>provided</scope> </dependency> <dependency> <groupId>com.google.code.findbugs</groupId> <artifactId>jsr305</artifactId> <version>3.0.2</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.3</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.26.3</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>io.soabase.record-builder</groupId> <artifactId>record-builder-processor</artifactId> <version>42</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build>
設定はJiltの時とほぼ変わらないのですが
Recordsに任意のプロパティを定義する場合は、それがわかるアノテーションが必要です。今回はJSR-305を使うことにします。
<dependency> <groupId>com.google.code.findbugs</groupId> <artifactId>jsr305</artifactId> <version>3.0.2</version> <scope>provided</scope> </dependency>
これ、ちょっとわかりにくかったです…。
テストコードの雛形はこちら。
src/test/java/org/littlewings/recordclass/RecordBuilderTest.java
package org.littlewings.recordclass; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class RecordBuilderTest { // ここに、テストを書く!! }
シンプルに使ってみる
まずはシンプルに使ってみましょう
RecordBuilder / RecordBuilder Example
@RecordBuilder
アノテーションを付与します。
src/main/java/org/littlewings/recordclass/Person.java
package org.littlewings.recordclass; import io.soabase.recordbuilder.core.RecordBuilder; @RecordBuilder public record Person(String firstName, String lastName, int age) { }
$ mvn compile
生成されたビルダー。
target/generated-sources/annotations/org/littlewings/recordclass/PersonBuilder.java
// Auto generated by io.soabase.recordbuilder.core.RecordBuilder: https://github.com/Randgalt/record-builder package org.littlewings.recordclass; import java.util.AbstractMap; import java.util.Map; import java.util.Objects; import java.util.function.Consumer; import java.util.stream.Stream; import javax.annotation.processing.Generated; @Generated("io.soabase.recordbuilder.core.RecordBuilder") public class PersonBuilder { private String firstName; private String lastName; private int age; @Generated("io.soabase.recordbuilder.core.RecordBuilder") private PersonBuilder() { } @Generated("io.soabase.recordbuilder.core.RecordBuilder") private PersonBuilder(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } /** * Static constructor/builder. Can be used instead of new Person(...) */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public static Person Person(String firstName, String lastName, int age) { return new Person(firstName, lastName, age); } /** * Return a new builder with all fields set to default Java values */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public static PersonBuilder builder() { return new PersonBuilder(); } /** * Return a new builder with all fields set to the values taken from the given record instance */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public static PersonBuilder builder(Person from) { return new PersonBuilder(from.firstName(), from.lastName(), from.age()); } /** * Return a "with"er for an existing record instance */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public static PersonBuilder.With from(Person from) { return new _FromWith(from); } /** * Return a stream of the record components as map entries keyed with the component name and the value as the component value */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public static Stream<Map.Entry<String, Object>> stream(Person record) { return Stream.of(new AbstractMap.SimpleImmutableEntry<>("firstName", record.firstName()), new AbstractMap.SimpleImmutableEntry<>("lastName", record.lastName()), new AbstractMap.SimpleImmutableEntry<>("age", record.age())); } /** * Return a new record instance with all fields set to the current values in this builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public Person build() { return new Person(firstName, lastName, age); } @Generated("io.soabase.recordbuilder.core.RecordBuilder") @Override public String toString() { return "PersonBuilder[firstName=" + firstName + ", lastName=" + lastName + ", age=" + age + "]"; } @Generated("io.soabase.recordbuilder.core.RecordBuilder") @Override public int hashCode() { return Objects.hash(firstName, lastName, age); } @Generated("io.soabase.recordbuilder.core.RecordBuilder") @Override public boolean equals(Object o) { return (this == o) || ((o instanceof PersonBuilder r) && Objects.equals(firstName, r.firstName) && Objects.equals(lastName, r.lastName) && (age == r.age)); } /** * Set a new value for the {@code firstName} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public PersonBuilder firstName(String firstName) { this.firstName = firstName; return this; } /** * Return the current value for the {@code firstName} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public String firstName() { return firstName; } /** * Set a new value for the {@code lastName} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public PersonBuilder lastName(String lastName) { this.lastName = lastName; return this; } /** * Return the current value for the {@code lastName} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public String lastName() { return lastName; } /** * Set a new value for the {@code age} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public PersonBuilder age(int age) { this.age = age; return this; } /** * Return the current value for the {@code age} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public int age() { return age; } /** * Add withers to {@code Person} */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") public interface With { /** * Return the current value for the {@code firstName} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") String firstName(); /** * Return the current value for the {@code lastName} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") String lastName(); /** * Return the current value for the {@code age} record component in the builder */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") int age(); /** * Return a new record builder using the current values */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") default PersonBuilder with() { return new PersonBuilder(firstName(), lastName(), age()); } /** * Return a new record built from the builder passed to the given consumer */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") default Person with(Consumer<PersonBuilder> consumer) { PersonBuilder builder = with(); consumer.accept(builder); return builder.build(); } /** * Return a new instance of {@code Person} with a new value for {@code firstName} */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") default Person withFirstName(String firstName) { return new Person(firstName, lastName(), age()); } /** * Return a new instance of {@code Person} with a new value for {@code lastName} */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") default Person withLastName(String lastName) { return new Person(firstName(), lastName, age()); } /** * Return a new instance of {@code Person} with a new value for {@code age} */ @Generated("io.soabase.recordbuilder.core.RecordBuilder") default Person withAge(int age) { return new Person(firstName(), lastName(), age); } } @Generated("io.soabase.recordbuilder.core.RecordBuilder") private static final class _FromWith implements PersonBuilder.With { private final Person from; @Generated("io.soabase.recordbuilder.core.RecordBuilder") private _FromWith(Person from) { this.from = from; } @Override @Generated("io.soabase.recordbuilder.core.RecordBuilder") public String firstName() { return from.firstName(); } @Override @Generated("io.soabase.recordbuilder.core.RecordBuilder") public String lastName() { return from.lastName(); } @Override @Generated("io.soabase.recordbuilder.core.RecordBuilder") public int age() { return from.age(); } } }
けっこう大きいですね。これは後で載せるWitherのためですね。
使ってみます。
@Test void simple() { Person katsuo = PersonBuilder .builder() .firstName("カツオ") .lastName("磯野") .age(11) .build(); assertThat(katsuo).isEqualTo(new Person("カツオ", "磯野", 11)); PersonBuilder builder = PersonBuilder .builder() .lastName("磯野"); }
パッと見た感じはJiltと同じですね。ビルダーの最初のメソッド名がbuilder
になっているくらいです。
ちょっとした違いとしては、ビルダーに構築済みのRecodsのインスタンスを渡して、その値を元に新しいインスタンスを作ることも
できます。
@Test void copy() { Person isono = new Person(null, "磯野", 0); Person katsuo = PersonBuilder .builder(isono) .firstName("カツオ") .age(11) .build(); assertThat(katsuo).isEqualTo(new Person("カツオ", "磯野", 11)); }
デフォルトだと順序指定などはありません。
@Test void missingProperty() { Person unknown = PersonBuilder .builder() .lastName("磯野") .build(); assertThat(unknown).isEqualTo(new Person(null, "磯野", 0)); }
Staged Required Only Builder
README.md
を見ていると、あとはWitherくらいしか見当たらないように見えるのですが、@RecordBuilder.Options
アノテーションで
いろいろカスタマイズができます。
https://github.com/Randgalt/record-builder/blob/record-builder-42/options.md
この中にStaged Builderも含まれています。
RecordBuilder Options / Miscellaneous / Staged Builders
以下の4種類から選ぶことができます。
- RecordBuilder.BuilderMode.STAGED
- RecordBuilder.BuilderMode.STAGED_REQUIRED_ONLY
- RecordBuilder.BuilderMode.STANDARD_AND_STAGED
- RecordBuilder.BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY
まずはRecordBuilder.BuilderMode.STAGEDY
とRecordBuilder.BuilderMode.STAGED_REQUIRED_ONLY
のどちらかからというところですが、
ここではRecordBuilder.BuilderMode.STAGED_REQUIRED_ONLY
にします。
src/main/java/org/littlewings/recordclass/Book.java
package org.littlewings.recordclass; import io.soabase.recordbuilder.core.RecordBuilder; import javax.annotation.Nullable; @RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STAGED_REQUIRED_ONLY) @RecordBuilder public record Book(String isbn, String title, @Nullable String tag, int price) { }
名前にREQUIRED_ONLY
がついているビルダーでは、任意のプロパティを定義することができます。
任意のプロパティは@RecordBuilder.Options(nullablePattern = "regex")
で指定した正規表現に一致するアノテーションで指定するのですが、
このデフォルト値は(?i)^((null)|(nullable))$
です。
なので今回は@Nullable
を使っています。これがJSR-305を持ち込んでいる理由ですね。
ビルダーを使ってみます。
@Test void stagedRequiredOnlyBuilder() { Book book1 = BookBuilder .builder() .isbn("978-4798161488") .title("MySQL徹底入門 第4版 MySQL 8.0対応") .price(4180) .tag("Database") .build(); assertThat(book1).isEqualTo( new Book("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", "Database", 4180) ); Book book2 = BookBuilder .builder() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)") .price(3520) .build(); assertThat(book2).isEqualTo( new Book("978-4297132064", "改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)", null, 3520) ); }
必須のプロパティを先に、任意のプロパティを後で指定するスタイルになります。
組み上げている様子はこんな感じですね。
@Test void stagedRequiredOnlyBuilderDetail() { BookBuilder.IsbnStage builder1 = BookBuilder .builder(); BookBuilder.TitleStage builder2 = BookBuilder .builder() .isbn("978-4798161488"); BookBuilder.PriceStage builder3 = BookBuilder .builder() .isbn("978-4297132064") .title("改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)"); BookBuilder.BookBuilderStage builder4 = BookBuilder .builder() .isbn("978-4798160436") .title("PostgreSQL徹底入門 第4版 インストールから機能・仕組み、アプリ作り、管理・運用まで") .price(3608); }
Standard And Staged Required Only Builder
次はStandard And Staged Required Only Builderを扱います。
指定するのはRecordBuilder.BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY
ですね。
src/main/java/org/littlewings/recordclass/BookStandard.java
package org.littlewings.recordclass; import io.soabase.recordbuilder.core.RecordBuilder; import javax.annotation.Nullable; @RecordBuilder.Options(builderMode = RecordBuilder.BuilderMode.STANDARD_AND_STAGED_REQUIRED_ONLY) @RecordBuilder public record BookStandard(String isbn, String title, @Nullable String tag, int price) { }
Standardとは?という気分になるのですが、使うとこんな感じになります。
@Test void standardStagedRequiredOnlyBuilder() { BookStandard book1 = BookStandardBuilder .builder() .isbn("978-4798161488") .title("MySQL徹底入門 第4版 MySQL 8.0対応") .price(4180) .tag("Database") .build(); assertThat(book1).isEqualTo( new BookStandard("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", "Database", 4180) ); BookStandard book2 = BookStandardBuilder .builder(new BookStandard( "978-4297132064", "改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)", null, 0 ) ) .price(3520) .build(); assertThat(book2).isEqualTo( new BookStandard( "978-4297132064", "改訂3版]内部構造から学ぶPostgreSQL―設計・運用計画の鉄則 (Software Design plus)", null, 3520 ) ); }
ビルダーにソースとなるRecordsを渡し、その内容をベースに新しいRecordsのインスタンスを構築できるようです。
なにがStandardなのかはよくわかりませんでしたが…。
Wither
最後はWitherです。
RecordBuilder / Wither Example
これはちょっと変わっていて、生成したビルダーに含まれてるインターフェースを実装します。
src/main/java/org/littlewings/recordclass/OperatingSystem.java
package org.littlewings.recordclass; import io.soabase.recordbuilder.core.RecordBuilder; import javax.annotation.Nullable; @RecordBuilder public record OperatingSystem(String name, String type, @Nullable String version) implements OperatingSystemBuilder.With { }
つまり、最初にこう書いて
@RecordBuilder public record OperatingSystem(String name, String type, @Nullable String version) { }
ビルド後にこう書くことになります。
@RecordBuilder public record OperatingSystem(String name, String type, @Nullable String version) implements OperatingSystemBuilder.With { }
アノテーション自体は@RecordBuilder
でかまいません。RecordBuilder.BuilderMode.STAGED_REQUIRED_ONLY)
などを指定しても
Witherには影響しません。
通常のビルダーとの比較を含めて載せてみます。
@Test void withBuilder() { OperatingSystem operatingSystem1 = OperatingSystemBuilder .builder() .name("Ubuntu Linux") .type("Linux") .version("22.04 LTS") .build(); assertThat(operatingSystem1).isEqualTo(new OperatingSystem("Ubuntu Linux", "Linux", "22.04 LTS")); OperatingSystem operatingSystem2And3Base = new OperatingSystem("Fedore", "Linux", null); OperatingSystem operatingSystem2 = operatingSystem2And3Base .withVersion("40"); assertThat(operatingSystem2).isEqualTo(new OperatingSystem("Fedore", "Linux", "40")); OperatingSystem operatingSystem3 = operatingSystem2And3Base .withType("LINUX"); assertThat(operatingSystem3).isEqualTo(new OperatingSystem("Fedore", "LINUX", null)); }
つまり、WitherはRecordsにwith[プロパティ名]
のメソッドを追加して、元のRecordsのインスタンスの値をベースにして新しいインスタンスを
作るパターンですね。
ビルダーにもすでにあるRecordsのインスタンスを元にして新しいインスタンスを作るメソッドがありましたが、こちらはRecords自体に
メソッドが追加されているところがポイントです。
その他
RecordBuilderを使うにあたって見ておいた方がよさそうなものとしては、@RecordBuilder.Options
アノテーションの説明、
https://github.com/Randgalt/record-builder/blob/record-builder-42/options.md
それからカスタマイズでしょうか。
https://github.com/Randgalt/record-builder/blob/record-builder-42/customizing.md
おわりに
Recordsのビルダーを生成するライブラリーとして、JiltとRecordBuilderを試してみました。
RecordBuilderのWitherはおもしろいと思うのですが、RecordBuilder自体が最初はとっつきにくかったり(ドキュメントの印象ですが…)
任意のプロパティの扱いの独特さなどがあって、なんとなくJiltの方が体感としては良かったです。
JiltだとRecordsに限定されないところもポイントですしね。
どちらにしても、こういったビルダーを使いたくなるケースはあると思うので覚えておこうと思います。