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要素を動的に出力する */-->
<!--/* BaseFormはIterableを継承しているので 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.htmlCSVファイルの構造と記述内容
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コンテナコースを受講してみましょう。