谷本 心 in せろ部屋

はてなダイアリーから引っ越してきました

Bootiful SQL Template 2.1.0でRecord対応をしました。

年に一回くらい、突然Javaの話をブログに書き始めるJava Championの @cero_t です。

数年前、「Spring BootでもSQLファイルを使いたいな」と思って勢いで作った Bootiful SQL Template ですが、何気に仕事で使うことがたまにあるので細々とメンテナンスを続けています。

最近、APIを全面的に見直してFluent API化したバージョン2.0.0をリリースし、日本語のドキュメントも書き始めました。

sqltemplate/README_ja.md at main · cero-t/sqltemplate · GitHub

今回は、これをRecordに対応させたというお話です。Record対応されたバージョンは2.1.0になります。

TL;DR

  • JdbcTemplateがRecordに対応してないから自分で対応したよ
  • Java16から追加された Class#getRecordComponents とリフレクションを使えば良いよ
  • ライブラリをJava8でも使えるようにするために、Record関連の処理をリフレクションにしたよ

はじめに

Recordとは

Recordは、Java 16から使えるようになったデータクラスというか値オブジェクトというかエンティティクラスというかDTOというか、まぁなんかそういうヤツを良い感じに作れるようにする機能です。説明が雑。

Recordの定義はこうなります。

public record EmpRecord(Integer empno,
                        String ename,
                        String job,
                        Integer mgr,
                        LocalDate hiredate,
                        BigDecimal sal,
                        BigDecimal comm,
                        Integer deptno) {
}

このようにフィールドを列挙するだけで、そのフィールドすべてを使ったコンストラクタが自動的に生成されるため、これを使ってインスタンスを作成します。

EmpRecord emp = new EmpRecord(1000, "cero-t", "MANAGER", 7839,
        LocalDate.of(2004, 4, 1), new BigDecimal(4000), new BigDecimal(400),
        10);

Recordの値はコンストラクタでしか設定できません。そのためRecordのインスタンスはイミュータブルということになります。

値を取り出す時は、フィールド名と同じ名前のメソッドを使います。

System.out.println(emp.ename()); // cero-tが出力される

Recordを使えば、getterやsetterをたくさん書かなくて良くなりますし、toStringやhashCodeメソッドも動的に生成されるようになって生産性アゲアゲですね! まぁもともと自動生成してましたけど。

先月LTS版のJava 17がリリースされたので、このRecordを使う機会も増えることになりそうですよね。

Spring BootのRecord対応

そんなRecordですが、じゃぁSpring Bootで使えるのかって話ですよね。

まずspring-webでは、既にRecordが使えます。JSONJavaオブジェクトを相互変換するJacksonがRecordの対応をしていて、spring-webではそれを使っているためです。ちょっとどのバージョンから使えるようになったかは確認してないですけど。

ただ、DBアクセスをするライブラリであるJdbcTemplate(spring-jdbc)はRecordには対応していません。既に試された人がいらっしゃいました。

ashishtechmill.com

JdbcTemplateは新しい機能が追加されることがほとんどなく、Spring Framework 5.3でStreamを返すAPIが追加されただけでも、「クララが立った!」に匹敵するレベルで興奮しましたね。たとえ話が昭和ですね。

そんなわけで、たとえJdbcTemplateがRecordに対応しなくても、それを対応するのがBootiful SQL Templateの役割なので、今回実装してみようと思い立ちました。

Recordクラス対応の流れ

Bootiful SQL TemplateをRecordに対応させる流れは、次のようになりました。

  1. 与えられたクラスがRecordか否かを判定する
  2. Recordのフィールドの一覧を取り出す
  3. Recordのコンストラクタを取り出す
  4. Recordのコンストラクタを使ってインスタンスを作成する
  5. Recordから値を取り出す側は?
  6. Java 16以降に依存しないコードに書き換える

順を追って見ていきます。

1. 与えられたクラスがRecordか否かを判定する

まずは与えられたクラスがRecordなのか、ただのValue Objectなのかを判定する必要があります。RecordであればRecord対応の処理を行い、Recordでなければ既存の処理を行う、というような分岐をしたいためです。

Recordクラスは java.lang.Record を継承したクラスとなるため、次のようなコードで判定できます。

if (Record.class.isAssignableFrom(targetClass)) {
    return new RecordMapper<>(targetClass); // Recordを扱う処理
}

return new BeanMapper<>(targetClass); // 既存処理

ここで instanceof も使えるのですが、なんやかんや色々あって僕はいつも isAssignableFrom を使うようにしています。

2. Recordのフィールドの一覧を取り出す

次にRecordのフィールドの一覧を取得する方法を確認します。Java 16でClassクラスに新たに追加された getRecordComponents メソッドが使えるようです。

RecordComponent[] components = targetClass.getRecordComponents();

// フィールドの数を取る。最初の例に書いたEmpRecordなら 8
System.out.println(components.length); 

// 最初のフィールドの名前を取る。EmpRecordなら "empno"
System.out.println(components[0].getName());

// 最初のフィールドの型を取る。EmpRecordのempnoなら java.lang.Integer
System.out.println(components[0].getType());

説明はコメントで書いたので、割愛しますね。
どうあれRecordの情報は簡単に取れるようです。複雑な継承関係をあまり考えなくて済むので、通常のクラスよりも扱いが楽ですね。

3. Recordのコンストラクタを取り出す

続いて、コンストラクタの取得です。
今回やりたいことを簡単に言うと、データベースの検索結果である ResultSet から値を取り出して、Recordのコンストラクタにそれを渡してオブジェクトを作成する、ということになります。そのためにコンストラクタが必要なのです。

先にも書いたとおり、Recordはフィールドを列挙するだけでそのフィールドすべてを使ったコンストラクタを自動的に生成します。これはcanonical constructorと呼ばれています。コンストラクタがcanonical constructor 1つのみであれば、次のようなコードでコンストラクタを取得できます。

targetClass.getDeclaredConstructors()[0]

ただ、Recordでは自分でコンストラクタを追加することもできるのです。

public record EmpRecord(Integer empno,
                        String ename,
                        String job,
                        Integer mgr,
                        LocalDate hiredate,
                        BigDecimal sal,
                        BigDecimal comm,
                        Integer deptno) {
    public EmpRecord(String ename) {
        this(null, ename, null, null, null, null, null, null);
    }
}

こうなると、上に書いた方法でコンストラクタを取得しても、canonical constructorが取得できる保証はないですよね。そのため、フィールドの型を列挙してコンストラクタを取得することにしました。こんなコードになります。

Class<?>[] parameterTypes = Arrays .stream(EmpRecord.class.getRecordComponents())
        .map(RecordComponent::getType)
        .toArray(Class<?>[]::new);
Constructor<?> constructor = targetClass.getDeclaredConstructor(parameterTypes);

ちなみに後から知ったのですが、Class#getRecordComponentsJavadocにもこれと全く同じ実装が掲載されています。みんなやることは同じだ。

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Class.html#getRecordComponents%28%29

Java標準のコードと一致してたら、著作権違反だって訴えられちゃうかな? #やめなさい

4. Recordのコンストラクタを使ってインスタンスを作成する

そうやって取得したコンストラクタに、データベースの検索結果である ResultSet から取り出した値を渡せばインスタンスを生成できます。

おおむねこういうコードになります。

Object[] params = new Object[constructor.getParameterCount()];

// 何やかんやResultSetから値をきちんと取り出してparamsに代入する

return constructor.newInstance(params);

実際には「何やかんや」の部分で、大文字小文字を無視したり、スネークケースとキャメルケースを同一視するみたいなコードを入れてるのですが、あくまでもRecordに関係する処理に限ると、この部分だけになります。

これで、データベースから検索してその結果からRecordのインスタンスを作成する部分ができました。
もう少し詳しく処理を見たい人は、このコミットログを見てください。

https://github.com/cero-t/sqltemplate/commit/2910ae82fc8b3e46149635e5c237d74081cf5d98

5. Recordから値を取り出す側は?

ここまでの処理で、SELECT文に相当する処理は書けたのですが、INSERT文についても考えなくてはなりません。INSERTの処理はここまでのSELECTの処理とは逆で、Recordのインスタンスから値を取り出す必要があります。

・・・なのですが、これは既存のBootiful SQL Templateのコードでも問題なく動作しました。
というのも、JdbcTemplateでは元々データクラスのオブジェクトに対してprivateフィールドからアクセサメソッドを経由して値を取り出す仕組みがあり、その仕組みがRecordにも機能しているためです。

なのでINSERT側は実装なしで対応済みということになりました。

6. Java 16以降に依存しないコードに書き換える

今回このようにしてRecord対応を行いましたが、Java 16以降で追加されたAPIやクラスを使っているため、これをJava 15以前のバージョンで動作させようとすると、たとえば java.lang.Record#isAssignableFrom を呼んだ際などに ClassNotFoundException が発生してしまいます。

Exception in thread "main" java.lang.NoClassDefFoundError: java/lang/Record
    at ninja.cero.sqltemplate.core.mapper.MapperBuilder.mapper(MapperBuilder.java:20)
    at ninja.cero.sqltemplate.core.executor.ArrayExecutor.forList(ArrayExecutor.java:37)
    at ninja.cero.sqltemplate.example.SampleProcess.gettingStarted(SampleProcess.java:25)
    at ninja.cero.sqltemplate.example.SampleProcess.process(SampleProcess.java:123)
    at ninja.cero.sqltemplate.example.SampleApplication.main(SampleApplication.java:22)
Caused by: java.lang.ClassNotFoundException: java.lang.Record
    at java.net.URLClassLoader.findClass(URLClassLoader.java:382)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:352)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
    ... 5 more

Bootiful SQL Template自体はJava 8でも動くようにしたいため、ここに対応する必要があります。

①局所的にリフレクションにする(ビルドはJava 17、sourceとtargetは1.8)

手っ取り早く対応するのであれば、isAssignableFrom の判定を行うところを、次のように修正すれば動きます。

try {
    if (Record.class.isAssignableFrom(targetClass)) {
        return new RecordMapper<>(targetClass); // Recordを扱う処理
    }
} catch (ClassNotFoundException e) {
    // ignore
}

return new BeanMapper<>(targetClass); // 既存処理

このようにすればJava 15以前ではClassNotFoundExceptionが発生して既存処理を実行することになるため、Record対応の処理は実行されません。そのため、これ以降でClassNotFoundExceptionが発生することもありません。

この修正を行う場合、Record対応の処理(RecordMapperの実装)のコンパイルにはJava 17が必要となるため、Bootiful SQL TemplateのビルドはJava 17で行い、sourceとtargetを1.8にすれば、「Java 8でも動く、Java 17でビルドされたライブラリ」ができます。

<properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
</properties>

これは一つの方法としてアリですが、なんかちょっとしたはずみで動かなくなりそうですし、今回はもう少し慎重な方法を選びました。

Java 16以降で追加されたAPIをすべてリフレクションにする(Java 8でビルド)

より慎重を期すために、Bootiful SQL Template自体をJava 8でビルドするようにしました。そうすれば、うっかりJava 16に以降で追加されたAPIを使ったコードを書いてしまっても、コンパイルエラーが起きて気づけるためです。

そのために、Java 16以降で追加されたAPIはリフレクションで扱うことになります。まず上に書いたisAssignableFromのコードは次のようになります。

try {
    Class<?> recordClass = Class.forName("java.lang.Record");
    if (recordClass.isAssignableFrom(targetClass)) {
        return new RecordMapper<>(targetClass); // Recordを扱う処理
    }
} catch (ClassNotFoundException e) {
    // ignore
}

return new BeanMapper<>(targetClass); // 既存処理

このコードであればJava 8でもコンパイルも実行もでき、Java 16以降で使えばRecord対応されるようになります。

また、この先の処理も同じように文字列を使ってリフレクションで操作することになります。この辺りはJacksonの実装を参考にして、次のようなコードにしました。

Method classGetRecordComponents = Class.class.getMethod("getRecordComponents");
Class<?> c = Class.forName("java.lang.reflect.RecordComponent");
Method recordComponentGetName = c.getMethod("getName");
Method recordComponentGetType = c.getMethod("getType");

Object[] components = (Object[]) classGetRecordComponents.invoke(targetClass);
paramTypes = new Class<?>[components.length];
for (int i = 0; i < components.length; i++) {
    paramTypes[i] = (Class<?>) recordComponentGetType.invoke(components[i]);
}

// この後もう少しコードが続く

リフレクションのための処理をリフレクションで行うという、なかなか妙な感じになっていますが、こういう事をやろうとする時のあるあるな気がしますね。

どうあれこれで、Bootiful SQL Template本体はJava 8でビルド、テストができるようになり、Java 16以降で利用した際にはRecordが使えるようになりました。
もう少し詳しく処理を見たい人は、このコミットログを見てください。

https://github.com/cero-t/sqltemplate/commit/2d5a7e07693a0faf5ad47841767552da78268bea

無事に完成しました

このような対応をして、Bootiful SQL TemplateのRecord対応は無事に完成しました。

ちなみに、今回は作業を始めてからMaven Centralリポジトリにアップするところまで、3時間弱と意外と短時間で済みました。リフレクションの知識さえあれば、Record関連の処理はわりと素直に書けるように思います。

さらにちなむと、このブログを書き始めてからここまで4時間くらい掛かりました。実装よりも説明のほうが大変ですよね・・・。

そんなわけで、このRecord対応が少しでも皆さんのお役に立てば、ブログを書いたかいがあるってものです。

それではね、See You!