Byte Buddyで遊んでみる

こんにちは!株式会社カサレアルの菊池です。

今更ながら(?)、Byte Buddyで遊んでみたので、その内容を紹介します。

Byte Buddyとは?

Google GeminiにByte Buddyについて質問しました。

Javaのランタイム(実行時)において、コードを動的に生成・操作するためのライブラリです。

一言で言えば、「Javaプログラムの実行中に、クラスを新しく作ったり、既存のメソッドの中身を書き換えたりする魔法の杖」のようなツールです。  

なんとなく素晴らしい技術であることが分かります。

公式サイトは https://bytebuddy.net/#/ です。

今回は「Javaプログラムの実行中に、クラスを新しく作ったり」の部分に焦点を当てて遊びます。

早速遊んでみる

Spring Initializrでひな形プロジェクトを作成します。

最初の段階ではCUIアプリケーションでByte Buddyを試すだけなので、Spring Initializrで依存ライブラリを追加する必要はありません。

pom.xmlの実装例

ひな形プロジェクトが出来上がったらByte Buddyを依存ライブラリに追加します。Mavenの場合は下記をpom.xmlに追記します。

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
</dependency>

ℹ️ Byte BuddyはSpring Boot Starter Parentが管理しているライブラリです。バージョン番号の明記は不要です。


では、実装に取り掛かりましょう。

最初のサンプルでは中身が空のインタフェースを宣言し、そのインタフェースを実装したクラスを動的に生成します。

宣言するインタフェース

中身が空のインタフェースを宣言します。

package jp.co.casareal.demo.web.form;

public interface BaseForm {
}

インタフェース名がBaseFormとなっていますが、読み進めるとその理由がわかると思います。

Byte Buddyの利用例

Byte Buddyのインスタンスを生成し、実装するインタフェースやクラス名を設定し、プロパティを定義します。

@SpringBootApplication
public class DemoApplication implements ApplicationRunner {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        DynamicType.Builder builder = new ByteBuddy()
            // ①. subclassメソッドで実装するインタフェースや継承する親クラスを設定
            .subclass(BaseForm.class)
            // ②. nameメソッドでパッケージ名を含むクラス名を設定
            .name(BaseForm.class.getName() + "$FormId")
            // ③. definePropertyメソッドでプロパティを定義
            //  すなわちフィールドとアクセサメソッドを一気に定義
            .defineProperty("kanjiName", String.class)
            .defineProperty("age", Integer.class)
            .defineProperty("userAddress", String.class)
            // ④. フィールド名とフィールドが保持する値を結合した文字列を返す
            //  toStringメソッドを設定
            .withToString();
        try (
            // ⑤. 動的に生成するクラスが持つプロパティやメソッドなどを設定したら
            //  DynamicType.Builderのmakeメソッドを呼び出し
            //  クラスの元となるオブジェクトを取得する
            DynamicType.Unloaded<? extends BaseForm> unloaded = builder.make();
            // ⑥. DynamicType.Unloadedのloadメソッドにクラスローダーを渡し
            //  動的に生成したクラスをロードする
            DynamicType.Loaded<? extends BaseForm> loaded = unloaded.load(getClass().getClassLoader())) {
            // ⑦. DynamicType.LoadedのgetLoadedメソッドを呼び出し
            //  動的に生成したクラスオブジェクトを取得する
            Class<? extends BaseForm> loadedClass = loaded.getLoaded();
            // ⑧. リフレクションを利用し引数なしコンストラクタを呼び出しインスタンスを生成
            //  Spring FrameworkのBeanUtilsを利用するとインスタンス化が簡単に行える
            BaseForm baseForm = BeanUtils.instantiateClass(loadedClass);
            // ⑨. リフレクションを利用しsetterを呼び出し値を設定する
            //  BeanUtilsを利用するとプロパティ名でアクセスできるので便利
            BeanUtils.getPropertyDescriptor(loadedClass, "kanjiName")
                .getWriteMethod().invoke(baseForm, "カサレアル太郎");
            BeanUtils.getPropertyDescriptor(loadedClass, "age")
                .getWriteMethod().invoke(baseForm, 30);
            BeanUtils.getPropertyDescriptor(loadedClass, "userAddress")
                .getWriteMethod().invoke(baseForm, "港区立芝浦中央公園");
            // ⑩. 出力結果は
            //  "BaseForm$FormId{kanjiName=カサレアル太郎, age=30, userAddress=港区立芝浦中央公園}"
            System.out.println(baseForm);
        }
    }
}

ℹ️ Spring Boot Applicationクラスが実装しているApplicationRunnerはSpring Bootのインタフェースです。ApplicationRunnerを実装したクラスをBean定義することで、DIコンテナ作成後にオーバライドしたrunメソッドを自動実行できます。


mainメソッドを実行すると

  • クラスの動的な生成
  • インスタンスの生成
  • setterを呼び出し値の代入

を行います。

このサンプルプログラムでは次の様なクラスを動的に生成します。

class BaseForm$FormId implements BaseForm {
    private String kanjiName;
    private Integer age;
    private String userAddress;

    // getter、setter、toStringも宣言される
}

ここまでが、CUIアプリケーションでByte Buddyを試してみたサンプルです。次からはSpring MVCを利用したWebアプリケーションでByte Buddyを試してみます。

フォームクラスを動的に作ってみる

次にSpring MVCのコントローラーメソッドの引数に設定するフォームクラスをByte Buddyで動的に作ってみます。

プロジェクトフォルダ全体像

個々の実装を紹介する前にプロジェクトフォルダの全体像を掲載します。

├── pom.xml
└── src
    └── main
        ├── java
        │   └── jp
        │       └── co
        │           └── casareal
        │               └── demo
        │                   ├── DemoApplication.java <= Spring Boot Application(エントリーポイント)
        │                   └── web
        │                       ├── controller
        │                       │   └── FormController.java <= コントローラークラス(入力画面の初期表示と結果画面の表示を行う)
        │                       └── form
        │                           └── BaseForm.java <= フォームクラスの元となるインタフェース
        └── resources
            ├── application.properties
            ├── static
            └── templates
                ├── index.html <= 入力画面のViewテンプレート
                └── result.html <= 結果画面のViewテンプレート
pom.xmlの実装例
  • Spring Web
  • Thymeleaf

のStarterライブラリを追加します。

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
DemoApplicationクラスを元に戻す

DemoApplicationクラスに実装したByte Buddyを利用したサンプルコードは、この後紹介するコントローラークラスに移します。

DemoApplicationクラスはSpring Initializrで自動生成した状態に戻します。

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}
入力画面初期表示までの実装

では、入力画面の初期表示までの実装を紹介します。

コントローラークラス

コントローラークラスでは、通常のコントローラーメソッドのほかに@ModelAttributeを付加したメソッドを宣言します。

Spring MVCは実行対象のコントローラーメソッドが宣言されたクラスに、@ModelAttributeを付加したメソッドが存在すれば、そちらを先に実行します。

実行して得られる戻り値をModelに格納してからコントローラーメソッドを実行します。

@Controller
public class FormController {

    // 初期表示① @ModelAttributeを付加したメソッドを宣言すると
    //  コントローラーメソッドが実行される前に実行され
    //  その戻り値は自動的にModelに格納される
    //  属性名はユーザ定義型の場合クラス名の先頭を小文字にしたもの
    // 入力値受取① 初期表示時と同様
    @ModelAttribute
    public BaseForm form() {
        // ByteBuddyで動的にクラスを生成する処理は先ほどとほぼ同じ
        DynamicType.Builder builder = new ByteBuddy()
            .subclass(BaseForm.class)
            .name(BaseForm.class.getName() + "$FormId")
            .defineProperty("kanjiName", String.class)
            .defineProperty("age", Integer.class)
            .defineProperty("userAddress", String.class)
            .withToString();
        try (DynamicType.Unloaded<? extends BaseForm> unloaded = builder.make();
             DynamicType.Loaded<? extends BaseForm> loaded = unloaded.load(getClass().getClassLoader())) {
            Class<? extends BaseForm> loadedClass = loaded.getLoaded();
            // クラスを生成したらインスタンスを生成しそのまま返す
            return BeanUtils.instantiateClass(loadedClass);
        }
    }

    // 初期表示② リクエストメソッド: GET、URI: /
    //  のリクエストを受信したら index.html に遷移する
    @GetMapping("/")
    public String index() {
        return "index";
    }
}

このサンプルプログラムでは次の様なフォームクラスを動的に生成し、そのインスタンスをModelに格納しています。

class BaseForm$FormId implements BaseForm {
    private String kanjiName;
    private Integer age;
    private String userAddress;

    // getter、setter、toStringも宣言される
}

ℹ️ indexメソッドが呼び出された時点で動的に生成したフォームインスタンスがモデルに格納済みである点がポイントです。

これによりViewテンプレート上のform/input要素とフォームインスタンスとの結びつけ、いわゆるフォームバインディングが可能です。


BaseFormインタフェースの変更

BaseFormインタフェースでIterableを継承し、プロパティ名を順々に返す反復子を定義します

public interface BaseForm extends Iterable<String> {

    // 初期表示③ Iterableを継承しiteratorメソッドをオーバーライドする
    @Override
    default Iterable<String> iterator() {
        try {
            // 初期表示④ プロパティの一覧を取得してプロパティ名(文字列)の一覧に変換している
            //  なお、commons-beanutilsを利用するとプロパティの一覧を簡単に取得できる
            Map<String, Object> properties = PropertyUtils.describe(this);
            return Arrays.stream(getClass().getDeclaredFields())
                .map(Field::getName)
                .filter(properties::containsKey)
                .iterator();
        } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }
}

プロパティ名の一覧を順番に取得可能にする理由は、Viewテンプレートの実装を見ると明白です。

Viewテンプレート

Viewテンプレートではフォームインスタンスのプロパティに応じて動的にinput要素を出力します。BaseFormインタフェースはIterableを継承しているので、th:eachで繰り返し処理を実装できます。プロパティ名を順番に取得しinput要素を繰り返し出力します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>フォーム</title>
</head>
<body>
<form method="post"
      action="result.html"
      data-th-action="@{result}"
      data-th-object="${baseForm}">
    <!--/* 初期表示⑤ 動的に生成したフォームクラスのプロパティに応じたinput要素を動的に出力する */-->
    <!--/*  BaseFormIterableを継承しているので data-th-each(th:each) で繰り返し対象にできる */-->
    <div data-th-each="field : ${baseForm}">
        <label for="field"
               data-th-for="${field}"
               data-th-text="${field}">ラベル</label>
        <!--/* 初期表示⑥ data-th-field=*{kanjiName} のように記述をしたいので */-->
        <!--/*  式の前処理を利用する */-->
        <!--/*  __(アンダースコア2つ) で囲まれた部分が式の前処理である */-->
        <!--/*  __${field}__ を先に評価してからアスタリスク構文に設定する */-->
        <input id="field"
               data-th-id="${field}"
               data-th-field="*{__${field}__}">
    </div>
    <button>送信</button>
</form>
</body>
</html>

ℹ️動的要素の埋め込みにth:で始まる属性ではなく、HTMLのデータ属性と同じ書式の属性を利用しています。この記述はHTMLを出力する場合のみ有効です。

名前空間を記述せずにth:で始まる属性を利用すると、未定義の属性としてIDEが警告を発しますが、データ属性スタイルであればその警告を抑止できます。


アプリケーションを起動しWebブラウザでアクセスすると、kanjiName・age・userAddressの3つの入力欄を持つ入力画面に遷移します。

また、Webブラウザに読み込まれたHTMLを確認することで、フォームのプロパティから動的にinput要素が出力できていることが分かります。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>フォーム</title>
</head>
<body>
<form method="post"
      action="result">
    <div>
        <label for="kanjiName">kanjiName</label>
        <input id="kanjiName" name="kanjiName" value="">
    </div>
    <div>
        <label for="age">age</label>
        <input id="age" name="age" value="">
    </div>
    <div>
        <label for="userAddress">userAddress</label>
        <input id="userAddress" name="userAddress" value="">
    </div>
    <button>送信</button>
</form>
</body>
</html>
結果画面表示までの実装

フォーム送信されたパラメータを受け取り、その値を結果画面に表示するまでの実装を紹介します。

コントローラーメソッドの追加

「リクエストメソッド: POST、URI: /result」のリクエストを受信したら result.html に遷移するコントローラーメソッドを新たに宣言します。

@Controller
public class FormController {

    // 入力値受取① 初期表示時と同様
    @ModelAttribute
    public BaseForm form() {
        DynamicType.Builder builder = new ByteBuddy()
            .subclass(BaseForm.class)
            .name(BaseForm.class.getName() + "$FormId")
            .defineProperty("kanjiName", String.class)
            .defineProperty("age", Integer.class)
            .defineProperty("userAddress", String.class)
            .withToString();
        try (DynamicType.Unloaded<? extends BaseForm> unloaded = builder.make();
             DynamicType.Loaded<? extends BaseForm> loaded = unloaded.load(getClass().getClassLoader())) {
            Class<? extends BaseForm> loadedClass = loaded.getLoaded();
            return BeanUtils.instantiateClass(loadedClass);
        }
    }

    @GetMapping("/")
    public String index() {
        return "index";
    }

    // 入力値受取② リクエストメソッド: POST、URI: /result
    //  のリクエストを受信したら result.html に遷移する
    //  postメソッドの前にformメソッドが実行され
    //  動的に生成したフォームクラスのインスタンスをModelに格納する
    //  Spring MVCはModelにフォームクラスのインスタンスがある場合には
    //  そのインスタンスに対しパラメータを代入し引数として渡す
    @PostMapping("/result")
    public String post(BaseForm form) {
        return "result";
    }

}

注目ポイントは、@ModelAttributeを付加したメソッドが存在すれば、必ずそちらが先に実行される点です。POSTのリクエストを受信した時もformメソッドを実行してからpostメソッドを実行します。

Spring MVCはModelにフォームクラスのインスタンスがあれば、それに対してパラメータの埋め込みを行い、コントローラーメソッドの引数として渡す仕組みとなっています。


ℹ️フォームインスタンスの生成処理をアプリケーションロジック側でコントロールすることで、コントローラーメソッドの引数の型をインタフェースや抽象クラスにできます。


Viewテンプレート

Modelに格納しているフォームインスタンスを参照し、ユーザの入力値を表示します。

<!DOCTYPE html>
<html lang="ja">
    <head>
    <meta charset="UTF-8">
<title>入力内容確認</title>
</head>
<!--/* 入力値受取③ フォームインスタンスを参照しユーザの入力値を表示する */-->
<body data-th-object="${baseForm}">
    <ul>
        <!--/* 入力値受取④ フォームインスタンスは data-th-each(th:each) で繰り返し対象にできる */-->
        <!--/* data-th-text=*{kanjiName} のように記述をしたいので */-->
        <!--/* 式の前処理を利用する */-->
        <li data-th-each="field : ${baseForm}"
            data-th-text="${field} + ': ' + *{__${field}__}">入力値: aaa
        </li>
    </ul>
</body>
</html>

入力画面で「kanjiName: カサレアル太郎、age: 30、userAddress: 東京都港区」と入力し送信ボタンをクリックした結果は下記です。

ユーザの入力値が動的に埋め込まれた結果画面に遷移します。

Jakarta Validationを組み込んでみる

フォームクラスを動的に作れるようになったので、Jakarta Validationも組み込みましょう。

Byte Buddyはアノテーションを付加することもサポートしているので簡単に実現できます。

pom.xmlの実装例

ValidationのStarterライブラリをさらに追加します。

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
@ModelAttributeを付加したメソッドの変更

@ModelAttributeを付加したメソッドを変更し、動的に定義したフィールドにJakarta Validationのアノテーションを付加します。

@ModelAttribute
public BaseForm form() {
    DynamicType.Builder builder = new ByteBuddy()
        .subclass(BaseForm.class)
        .name(BaseForm.class.getName() + "$FormId");
    { // kanjiName
        // ByteBuddyのAnnotationDescriptionを利用すると
        // 動的に定義したプロパティにアノテーションを付加できる
        // ofTypeで付加したいアノテーションの型を指定する
        AnnotationDescription notBlank = AnnotationDescription.Builder.ofType(NotBlank.class).build();
        // アノテーションの属性値を設定する場合はdefineメソッドで定義する
        AnnotationDescription length = AnnotationDescription.Builder.ofType(Length.class).define("max", 10).build();
        // definePropertyに続けてannotateFieldを呼び出すとアノテーションを付加できる
        // サンプルでは
        //   @NotBlank
        //   @Length(max = 10)
        //   private String kanjiName;
        // と付加している
        builder = builder.defineProperty("kanjiName", String.class)
            .annotateField(notBlank, length);
    }
    { // age
        AnnotationDescription notNull = AnnotationDescription.Builder.ofType(NotNull.class).build();
        AnnotationDescription range = AnnotationDescription.Builder.ofType(Range.class).define("min", 0L).define("max", 120L).build();
        builder = builder.defineProperty("age", Integer.class)
            .annotateField(notNull, range);
    }
    { // userAddress
        AnnotationDescription notBlank = AnnotationDescription.Builder.ofType(NotBlank.class).build();
        AnnotationDescription length = AnnotationDescription.Builder.ofType(Length.class).define("max", 64).build();
        builder = builder.defineProperty("userAddress", String.class)
            .annotateField(notBlank, length);
    }
    builder.withToString();
    try (DynamicType.Unloaded<? extends BaseForm> unloaded = builder.make();
         DynamicType.Loaded<? extends BaseForm> loaded = unloaded.load(getClass().getClassLoader())) {
        Class<? extends BaseForm> loadedClass = loaded.getLoaded();
        // クラスを生成したらインスタンスを生成しそのまま返す
        return BeanUtils.instantiateClass(loadedClass);
    }
}

ポイントはAnnotationDescriptionを利用して付加するアノテーションの情報を作成する部分です。アノテーションの属性値も細かく設定可能です。

このサンプルプログラムでは次の様なフォームクラスを動的に生成し、そのインスタンスをModelに格納しています。

class BaseForm$FormId implements BaseForm {
    @NotBlank
    @Length(max = 10)
    private String kanjiName;

    @NotNull
    @Range(min = 0L, max = 120L)
    private Integer age;

    @NotBlank
    @Length(max = 64)
    private String userAddress;

    // getter、setter、toStringも宣言される
}
Viewテンプレートの変更

入力画面のViewテンプレートにエラーメッセージの出力処理を追加します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>フォーム</title>
</head>
<body>
<form method="post"
      action="result.html"
      data-th-action="@{result}"
      data-th-object="${baseForm}">
    <div data-th-each="field : ${baseForm}">
        <label for="field"
               data-th-for="${field}"
               data-th-text="${field}">ラベル</label>
        <input id="field"
               data-th-id="${field}"
               data-th-field="*{__${field}__}">
        <!--/* data-th-errors(th:errors)でエラーメッセージを表示する */-->
        <!--/* data-th-field(th:field)同様に式の前処理を利用し */-->
        <!--/* プロパティ単位で表示する */-->
        <div data-th-errors="*{__${field}__}" style="color: red">エラーメッセージ</div>
    </div>
    <button>送信</button>
</form>
</body>
</html>
postメソッドの変更

メソッドの引数に@Validatedを付加するなど、入力検証に必要な変更を加えます。

// . @Validatedの付加、②. 仮引数の追加(BindingResult)
@PostMapping("/result")
public String post(@Validated BaseForm form, BindingResult bindingResult) {
    // . 制約違反発生時は入力画面に遷移する
    if (bindingResult.hasErrors()) {
        return "validation/index";
    }
    return "validation/result";
}

アプリケーションを再起動してWebブラウザで再度アクセスします。

制約違反が発生するとエラーメッセージを含んだ入力画面を再表示します。

外部リソースの内容から動的に生成する

今まで紹介したサンプルプログラムは、「kanjiName、age、userAddress」の3つのプロパティを持つフォームクラスを固定的に生成していました。つまり、Byte Buddyで動的に生成する必要性はありません。

本記事の締めくくりとして、外部リソースから取得した情報をもとに動的にフォームクラスを生成してみたいと思います。

今回はCSVファイルをフォームクラスを生成するための情報源としますが、RDBなどでも応用できます。

プロジェクトフォルダ全体像

プロジェクトフォルダの全体像を掲載します。

├── pom.xml
└── src
    └── main
        ├── java
        │   └── jp
        │       └── co
        │           └── casareal
        │               └── demo
        │                   ├── DemoApplication.java
        │                   ├── entity
        │                   │   └── FormField.java <= CSVファイル1行の内容を保持するクラス
        │                   └── web
        │                       ├── controller
        │                       │   └── FormController.java
        │                       └── form
        │                           └── BaseForm.java
        └── resources
            ├── application.properties
            ├── form1.csv <= 動的フォームクラスの情報①
            ├── form2.csv <= 動的フォームクラスの情報②
            ├── form3.csv <= 動的フォームクラスの情報③
            ├── static
            └── templates
                ├── index.html
                └── result.html
CSVファイルの構造と記述内容

6つの列を持つCSVファイルを用意します。

  • name
    • フォームクラスのプロパティ名
  • integer
    • フォームクラスのプロパティの型が整数型か、否か
  • required
    • 必須項目か、否か
  • length
    • 最大文字数(文字列型の場合)
  • rangeMin
    • 最小値(整数型の場合)
  • rangeMax
    • 最大値(整数型の場合)
form1.csv
"name","integer","required","length","rangeMin","rangeMax"
"kanjiName","false","true","10","",""
"age","true","true","","0","120"
"userAddress","false","true","64","",""
form2.csv
"name","integer","required","length","rangeMin","rangeMax"
"aaa","false","true","10","",""
"bbb","true","true","","0","120"
form3.csv
"name","integer","required","length","rangeMin","rangeMax"
"ccc","false","true","10","",""
"ddd","true","true","","0","120"
"eee","false","true","64","",""
pom.xmlの実装例

CSVファイルの読み書きを行うためのJackson Dataformat CSVを、依存ライブラリに追加します。

<!-- 下記をさらに追加 -->
<dependency>
    <groupId>tools.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-csv</artifactId>
</dependency>
エンティティクラスの実装例

次にCSVファイルに書かれた内容を保持するレコードクラスを宣言します。

package jp.co.casareal.demo.entity;

import com.fasterxml.jackson.annotation.JsonPropertyOrder;

// CSVファイルの1行分の内容を保持するレコードクラス
// name => フォームクラスのプロパティ名
// integer => フォームクラスのプロパティの型が整数型か、否か
// required => 必須項目か、否か
// length => 最大文字数(文字列型の場合)
// rangeMin => 最小値(整数型の場合)
// rangeMax => 最大値(整数型の場合)
@JsonPropertyOrder({"name", "integer", "required", "length", "rangeMin", "rangeMax"})
public record FormField(String name,
                        Boolean integer,
                        Boolean required,
                        Integer length,
                        Long rangeMin,
                        Long rangeMax) {
}
コントローラークラスの変更

@ModelAttributeを付加したメソッドは削除します。代わりにindexメソッドでフォームインスタンスの生成とModelへの明示的な格納を行います。

クラスレベルに@SessionAttributesを付加して、indexメソッドでModelに格納したフォームインスタンスをセッションスコープで管理します。

@Controller
@RequestMapping("/resource")
// @SessionAttributesを付加し属性名:"baseForm"のインスタンスを
// セッションスコープで管理する
@SessionAttributes("baseForm")
public class FormWithResourceController {

    @GetMapping("/index")
    public String index(@RequestParam Integer formId, Model model) {
        // リクエストパラメータで連携されたIDを名前に含むCSVファイルを取得する
        Resource resource = new ClassPathResource("form" + formId + ".csv");
        // 取得したCSVファイルを読み込み一行一行をFormFieldインスタンスに変換する
        CsvMapper mapper = new CsvMapper();
        CsvSchema schema = mapper.schemaFor(FormField.class).withHeader();
        try (MappingIterator iterator = mapper.readerFor(FormField.class)
            .with(schema)
            .readValues(resource.getFile())) {
            DynamicType.Builder builder = new ByteBuddy()
                .subclass(BaseForm.class)
                .name(BaseForm.class.getName() + "$FormId$" + formId);
            // CSVファイルの内容に応じてフォームクラスのプロパティを動的に定義する
            for (FormField row : iterator.readAll()) {
                Class<?> targetType;
                List annotations = new ArrayList<>();
                if (row.integer()) { // 整数型の場合
                    targetType =  Integer.class;
                    if (row.required()) {
                        // 必須項目のアノテーションは@NotNullを利用する
                        annotations.add(AnnotationDescription.Builder.ofType(NotNull.class).build());
                    }
                    if (row.rangeMin() != null && row.rangeMax() != null) {
                        // 最小値、最大値の設定を読み込み@Rangeに設定する
                        annotations.add(AnnotationDescription.Builder.ofType(Range.class)
                            .define("min", row.rangeMin())
                            .define("max", row.rangeMax())
                            .build());
                    }
                } else { // 文字列型の場合
                    targetType = String.class;
                    if (row.required()) {
                        // 必須項目のアノテーションは@NotBlankを利用する
                        annotations.add(AnnotationDescription.Builder.ofType(NotBlank.class).build());
                    }
                    if (row.length() != null) {
                        // 最大文字数の設定を読み込み@Lengthに設定する
                        annotations.add(AnnotationDescription.Builder.ofType(Length.class)
                            .define("max", row.length())
                            .build());
                    }
                }
                // プロパティと検証ルール(アノテーション)を定義する
                builder = builder.defineProperty(row.name(),  targetType)
                    .annotateField(annotations);
            }
            builder.withToString();
            try (DynamicType.Unloaded<? extends BaseForm> unloaded = builder.make();
                 DynamicType.Loaded<? extends BaseForm> loaded = unloaded.load(getClass().getClassLoader())) {
                Class<? extends BaseForm> loadedClass = loaded.getLoaded();
                // 動的に生成したフォームクラスのインスタンスを"baseForm"という名前でModelに格納する
                // コントローラークラスに付加した@SessionAttributesの作用によってセッションスコープで管理される
                model.addAttribute("baseForm", BeanUtils.instantiateClass(loadedClass));
                return "validation/index";
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @PostMapping("/result")
    // セッションスコープで管理される"baseForm"に対して値の埋め込みと入力検証が行われる
    public String post(@Validated BaseForm form, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "validation/index";
        }
        return "validation/result";
    }

}

ℹ️@ModelAttributeを付加したメソッドでも@RequestParamでパラメータを受け取れますが、POSTリクエスト送信時にもフォームIDをhiddenで含める必要があります。

パラメータを改変されると、入力画面とは異なるフォームへのリクエストを受信してしまうので、コントロールが必要です。


プログラムを再起動してWebブラウザでhttp://localhost:8080/?formId=1にアクセスします。

次に、http://localhost:8080/?formId=2にアクセスします。

最後にhttp://localhost:8080/?formId=3にアクセスします。

リクエストパラメータに対応したCSVファイルの内容で動的にフォームクラスとそのインスタンスを生成できていることが確認できます。

さいごに

今回の記事で紹介したプログラムがなぜ動くのか?

その動作原理を詳しく知りたい方は、ぜひSpring Inside・Spring Web MVCコースを受講してみましょう。

DIコンテナの詳細を知りたい方はSpring Inside・DIコンテナコースを受講してみましょう。

--------------------------
開発支援・技術研修のご要望・ご相談はこちらから
--------------------------
【この技術ブログを読んだエンジニアの皆様へ】
カサレアルブログをお読みいただき、ありがとうございます!

私たちは、常に新しい技術に挑戦し、ユーザーのニーズに応えるサービスを提供しています。
もし、当社の技術への情熱や、会社・チーム・社員の雰囲気に共感いただけたなら、
ぜひ私たちと一緒に働きませんか?
現在、株式会社カサレアルでは事業拡大に伴い、新たな仲間となるエンジニアを積極的に募集しています。

少しでも興味をお持ちいただけましたら、まずは弊社のことを知っていただけると嬉しいです。
▼採用サイト
https://www.casareal.co.jp/recruit/career
▼社員インタビュー
https://hrmos.co/pages/casareal/jobs/0000016
▼エンジニアの仲間になる! エントリーはこちらから
https://hrmos.co/pages/casareal/jobs

皆様のエントリーを心よりお待ちしています!

大量のエラーログにフリーズしていた私が「視線の落とし方」を学んで少しずつ向き合えるようになるまで

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

コメント ※

名前 ※

メール ※

サイト