CLOVER🍀

That was when it all began.

Recordsのビルダーを生成するJilt、RecordBuilderを試す

これは、なにをしたくて書いたもの?

JavaのRecordsを使っているとビルダーが欲しいなと思うのですが、自動生成してくれるライブラリーがあるようなので試してみました。

Recordsのビルダーを生成するライブラリー

今回見つけたのはこちらです。

Jilt。Recordsでなくてもビルダーを生成することができます。

GitHub - skinny85/jilt: Java annotation processor library for auto-generating Builder (including Staged Builder) pattern classes

RecordBuilder。こちらはRecords専用のようです。

GitHub - Randgalt/record-builder: Record builder generator for Java records

この手のライブラリーといえばLombokですね。

Project Lombok

Lombokは1.18.20からRecordsをサポートしたようです。

@Builder

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).

changelog

個人的に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から。

GitHub - skinny85/jilt: Java annotation processor library for auto-generating Builder (including Staged Builder) pattern classes

繰り返しますが、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
使うように設定します。

Jilt / Getting Jilt

確認はテストコードで行います。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 {

    // ここに、テストを書く!!
}
シンプルに使ってみる

まずはシンプルな例から。

Jilt / Example

こんな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です。

Jilt / Staged Builders

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を確認しましょう。

そしてこちらのブログエントリーですね。

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の時とほぼ変わらないのですが

RecordsBuilder / Usage

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.STAGEDYRecordBuilder.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に限定されないところもポイントですしね。

どちらにしても、こういったビルダーを使いたくなるケースはあると思うので覚えておこうと思います。