Spring Boot で AOP のサンプルプログラムを作ってみた。

Spring Boot で AOP( Aspect Oriented Programming ) のサンプルプログラムを作ってみました。コマンドラインから実行して文字列を画面に標準出力するだけのものですが、AOPによりメソッドの前後で標準出力が追加されることを確認しました。

以下がサンプルプログラムの実行例です。

AOPをしていない状態のときのものです(実行結果①とします)。

処理 : Hello, World. と出力しています。

AOPをしてメソッドの前後で標準出力を追加したときのものです(実行家結果②とします)。

処理 : Hello, World. の前後に 開始 と 終了 の出力が追加されています。

Spring Boot のプロジェクトは Spring Initializr で生成しました。Spring Initializr のURLは https://start.spring.io/ です。

Group、Artifact、Name は以下としました。

Group : com.example
Artifact : demo12
Name : demo12

ビルドは Gradle です。プロジェクトをダウンロードします。

今回の Spring Boot のサンプルプログラムでは、標準出力を行うサービス、および、AOPを行うコンポーネントを作成します。

Applicationクラス
Demo12Application.java

サービス
CommandService.java
CommandServiceImpl.java

AOPのコンポーネント
CommonLog.java

プロジェクト内のファイルの構成は、このようになります。赤字が追加・修正をするファイルです。

Applicationクラスの Demo12Application.java の説明からします。

Demo12Application.java は Spring Initializr により最初から生成されていますが、これを以下のように書き換えます(テキストファイルの画像ですのでソースは最後に載せておきます)。

Demo12Application は ApplicationRunner の実装をしています。ApplicationRunner のrunメソッドをオーバーライドすることによりコマンドラインの引数を取得します。引数は ApplicationArguments が保持しています。

runメソッドではサービスのメソッドを呼び出しています。画面への標準出力の処理はサービスのほうに記載しています。サービスは @Autowired でフィールドインジェクションをしています。

サンプルプログラムでは setBannerMode をオフにして Spring Boot のバナー表示を抑止しています。

サービスの説明です。

サービスはインターフェースの CommandService.java と、それを実装した CommandServiceImpl.java を用意します。サービスのパッケージは別にしているため service フォルダを作成して、その中にこれらのファイルを格納します。

CommandService.java のソースです。

display01( )、および、display02( ) というメソッドを定義しています。ApplicationクラスがサービスをDIして使うようにしているためインターフェイスを用意しています。

CommandServiceImpl.java のソースです。CommandService の実装をします。

Service アノテーションを付けています。これにより CommandServiceImpl がDIコンテナに格納されます。各メソッドでは文字列の標準出力を行っています。

AOPのコンポーネントを説明する前にAOPなしの状態で動かしてみます。

build.gradle の dependencies に太字部分を追加します。


dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	implementation 'org.springframework.boot:spring-boot-starter-aop'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Spring Boot を起動します。

$ ./gradlew bootRun

最初に示した「実行結果①」になります。もしうまくいかない場合は --refresh-dependencies を付けて実行してみてください。

AOPで標準出力を追加します。aop フォルダを作成して、その中に CommonLog.java を新規で作成します。

CommonLog.java のソースです。

Aspect アノテーションと Component アノテーションを付けています。

アスペクト( Aspect )とはプログラムの複数の場所に散在する共通処理をまとめたものです。アスペクトという単位でモジュール化したものらしいですが意味がわからないので「まとめたもの」と書いています。AOPをするクラス(共通処理を行うクラス)に Aspect アノテーション付けておくことでアスペクトとして機能するようです。またこの共通処理のことをアドバイス( Advice )と呼んでいます。アスペクトによって実行されるアクションがアドバイスということです。

Component アノテーションを付けることでアスペクトがDIコンテナに格納されます。

Before アノテーション と AfterReturning アノテーションですが、これはAOPの実行タイミングを指定するものです。AOPの実行タイミングには以下のものがあります。

  • @Before・・・対象メソッドが行われる前に実行する。
  • @After・・・対象メソッドが行われた後に実行する(例外発生の有無に関わらない)。
  • @AfterReturning・・・対象メソッドが正常終了した場合に実行する。
  • @AfterThrowing・・・対象メソッドに例外が発生した場合に実行する。
  • @Around・・・対象メソッドの前後で実行する。

beforeLog( ) には @Before を付けていますので beforeLog( ) が特定の処理の前に実行されます。同様に afterLog( ) には @AfterReturning が付いていますので特定の処理の後に afterLog( ) が実行されます。

では特定の処理とは何かと言うと execution で指定しているメソッドが該当します。サンプルでは以下です。execution で指定するメソッドにはワイルドカードが使えます。

* com.example.demo12.service.*.*(..)

  • アスタリスク ( * ) ・・・任意の1つの文字列にマッチする。
  • ドットドット ( .. ) ・・・任意の0個以上の文字にマッチする。

先頭のアスタリスクは戻り値が任意ということです。対象のクラス、メソッドは com.example.demo12.service.*.* であるため service パッケージに含まれる任意のクラス、メソッドになります。最後の ( .. ) はメソッドの引数を表しています。

サンプルでは service パッケージに CommandServiceImpl があり、このクラスには display01( )、および、display02( ) のメソッドがあります。display01( )、display02( ) とも execution の指定にマッチしますので beforeLog( ) と afterLog( ) が指定のタイミングで実行されます。

なお、このAOPされるメソッド( execution で指定されたメソッド )のことをポイントカットと呼んでいます。

beforeLog( ) と afterLog( ) の引数に JoinPoint というクラスがありますが、これはポイントカットでAOPされた実際のクラス、メソッドの情報です。outputLog( ) では JoinPoint を使ってクラス、および、メソッド名を取得しています。

それではもう一度、gradlew bootRun で Spring Boot を起動してみます。今度の結果は「実行結果②」になります。

AOPのコンポーネントである CommonLog.java を追加したのですが、既存のソースには何も手を加えないで処理の追加ができたことがわかります。AOPはログの出力をする際に役に立ちます。

実行結果①、②ですが、bootRun での実行であるため引数を受け取れていません。jarファイルを作成してコマンドラインに引数を渡します。

ビルドをします。

$ ./gradlew build

jarファイルが /demo12/build/libs 配下にできます。jarファイルを実行します。引数に --SpringBoot を付与するかどうかによって実行結果が変わります。

$ java -jar demo12-0.0.1-SNAPSHOT.jar

$ java -jar demo12-0.0.1-SNAPSHOT.jar --SpringBoot

サンプルプログラムのソースを以下に記載しておきます。

Applicationクラス


package com.example.demo12;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.Banner.Mode;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.ApplicationArguments;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo12.service.CommandService;

@SpringBootApplication
public class Demo12Application implements ApplicationRunner {

	@Autowired
	CommandService service;

	public static void main(String[] args) {

		SpringApplication app = new SpringApplication(Demo12Application.class);
		app.setBannerMode(Mode.OFF);
		app.run(args);

	}

	@Override
	public void run( ApplicationArguments args ) {

		if ( args.containsOption("SpringBoot") )
			service.display01();
		else
			service.display02();

	}

}

サービス


package com.example.demo12.service;

public interface CommandService {

	void display01();
	void display02();

}

package com.example.demo12.service;

import org.springframework.stereotype.Service;

@Service
public class CommandServiceImpl implements CommandService {

	public void display01() {
		System.out.println("処理 : Hello, Spring Boot.");
	}

	public void display02() {
		System.out.println("処理 : Hello, World.");
	}

}

AOPのコンポーネント


package com.example.demo12.aop;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.JoinPoint;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class CommonLog {

	@Before( "execution( * com.example.demo12.service.*.*(..) )" )
	public void beforeLog( JoinPoint jp ) {
		outputLog("開始", jp);
	}

	@AfterReturning( "execution( * com.example.demo12.service.*.*(..) )" )
	public void afterLog( JoinPoint jp ) {
		outputLog("終了", jp);
	}

	private void outputLog( String str, JoinPoint jp ) {

		String className = jp.getTarget().getClass().getSimpleName();
		String methodName = jp.getSignature().getName();
		System.out.println( str + " : " + className + "." +methodName + "()" );

	}

}

Spring MVCの@ModelAttributeとThymeleaf 側の th:object について

Spring MVC でリクエストパラメータをBeanに割り当てる際に @ModelAttribute を使うのですが、GETリクエストの場合はパラメータがないときもあるから @ModelAttribute が不要だと思っていたのですが、そういうわけではないと理解したのでそれについて書いてみました。

サンプルプログラムを作りましたので、そのソースコードを見ながら説明します。

まずサンプルプログラムの動きからです。サンプルプログラムは入力した値をチェックして問題なければチェックOKの画面に遷移、チェックNGであれば元の画面のままエラーメッセージの表示をする、というものです。

初回アクセス時の画面です。

名前、メール、年齢、メモに値を入力をします。各項目はSpringのバリデーションでチェックをしています。

チェックボタンを押下します。入力値に問題がなければチェックOKの画面に遷移します。

SpringのバリデーションでチェックNGとなった場合です。元の画面に入力値とエラーメッセージを表示します。

サンプルプログラムは Spring Boot で作成しています。プロジェクトは Spring Initializr で作成しました。Spring Initializr のURLは https://start.spring.io/ です。

Group、Artifact、Name は以下としました。

Group : com.example
Artifact : demo11
Name : demo11

ビルドは Gradle です。Dependencies には Spring Web、Thymeleaf、Validation を選択します。プロジェクトをダウンロードします。

今回の Spring Boot のサンプルプログラムでは、コントローラー、データ格納用のBean、バリデーションのエラーメッセージ、HTMLファイルを作成します。HTMLファイルは入力フォームとチェックOKの表示をするものの2つを作成します。

コントローラー
FormController.java

データ格納用のBean
Person.java

エラーメッセージ
ValidationMessages.properties

HTMLファイル
form.html
checkok.html

プロジェクト内のファイルの構成は、このようになります。赤字が追加・修正をするファイルです。

データ格納用のBeanから説明します。

Beanのソースは Person.java です。テキストファイルの画像なので、あとでコピペ用のソースを載せておきます。


Personが保持する情報は 名前(name)、メール(mail)、年齢(age)、メモ(memo)です。Springのバリデーションを利用するため各フィールドにアノテーションを付けています。

@NotBlank
未入力を許可しない。半角スペースも不可。

@Email
Email形式であるかのチェック。

@Min(0)
@Max(200)
値の範囲が0〜200の範囲内かのチェック。

バリデーションでチェックNGとなった場合のエラーメッセージは ValidationMessages.properties に記載をします。今回は以下のように設定しています。


jakarta.validation.constraints.NotBlank.message = 何か値を入力してください。
jakarta.validation.constraints.Email.message = メールアドレスの書式が違います。
jakarta.validation.constraints.Min.message = {value}より大きくしてください。
jakarta.validation.constraints.Max.message = {value}より小さくしてください。

HTMLファイルの説明をします。

入力用のフォームの方からです。入力用のフォームは form.html です。

formタグに th:object を付与しています。また各項目のvalueの設定には *{ ... } で値を設定しています。Thymeleafで値を設定する際は ${ ... } を利用しますので異なる書き方です。これについては後述します。

入力項目のチェックNGは以下で判断しています。

#fields.hasErrors( '...' )

シングルクォートで囲まれた部分にはバリデーションチェックをするBeanの属性を指定します。チェックNGであれば hasErrors() がtrueとなります。Thymeleafのifの判定を見た場合、 th:if="${ ... }" としてはtrueと判断され th:errors の内容が表示されます。th:errors はエラーメッセージの表示です。

バリデーションでチェックOKだった場合に出力するHTMLファイルの方です。ファイルは checkok.html です。

コントローラーから受け取った結果を表示しています。 ${ ... } で値を設定します。

コントローラーの説明です。

コントローラーは FormController.java です。@RequestMapping でGETとPOSTの時に処理するメソッドを指定しています。GET時が form01()、POST時が form02() です。メソッド名を同じにしてもよいのですが説明のために変えています。

GET時の form01() のほうですが、@ModelAttribute でリクエストされたパラメータをBeanであるpersonに割り当てています。実際にはこのときのリクエストパラメータは空なのでpersonには何も値は入らないです。ModelAndView のmavに必要な値を設定して form.html をコントローラーが呼び出します。

このときにふと疑問に思ったのが一番最初に書いた @ModelAttribute は不要ではないのか?ということです。ですので、form01() の引数を以下にしても良いのでは?と思いました。


public ModelAndView form01( @ModelAttribute("fm") Person person, ModelAndView mav) { ... }

↓ ↓ ↓


public ModelAndView form01( ModelAndView mav) { ... }

ですが、実際にやってみるとうまくいきません。エラーになります。Spring Boot は起動しますが Whitelabel Error Page になります。

どこでエラーになっているかというと form.html の *{ ... } のところです。

*{ ... } ですが、これは th:object がある前提で動くもののようです。nameを例にとると *{name} ${fm.name} と同じ意味になります。PersonのBeanが form.html に渡ってきていないためエラーになっています。

そうだとするとですが、@ModelAttribute("fm") Person person を form01() の引数に付与すると、なぜうまくいくのか?です。form01() ではリクエストパメータを @ModelAttribute で受け取りpersonに割り当ててはいるものの、ModelAndView のmavには何も設定していないからです。

ここのからくりですが、実はSpringが自動でやってくれているもののようです。以下の記載をしなくてもSpringが裏でやってくれているとのことです。

mav.addObject("fm", person);

そのため form.html にはオブジェクトが渡ってきており *{ ... } で値を設定する際も問題にならないようです。

ちなみに form.html には th:errors="*{ ... }" の記載もあります。推測が入りますが、エラーメッセージも( BindingResult も)裏でSpringが form.html に渡しているのだと思います。

POST時のform02() のほうです。

POST時はリクエストパラメータがありますので @ModelAttribute でpersonに割り当てます。バリデーションのチェックNGがあれば BindingResult のresultで判断をします。

form02() でも同じことですが、personのaddObjectの記載をしなくてもSpirngが裏でmavにaddObjectをしてくれているため form.html でも checkok.html でもpersonの属性を取り出すことができます。

Spring Boot の起動ですが gradlew を使用します。引数に bootRun を付与することで実行ができます。以下は Spring Boot を起動させたときのものです。

$ ./gradlew bootRun

ウェブブラウザから http://localhost:8080/validate にアクセスをするとサンプルプログラムが動きます。

使用したソースを以下に記載しておきます。

データ格納用のBean


package com.example.demo11;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;

public class Person {

	@NotBlank
	private String name;

	@Email
	private String mail;

	@Min(0)
	@Max(200)
	private int age;

	private String memo;

	public String getName() {
		return name;
	}

	public String getMail() {
		return mail;
	}

	public int getAge() {
		return age;
	}

	public String getMemo() {
		return memo;
	}

	public void setName(String name) {
		this.name = name;
	}

	public void setMail(String mail) {
		this.mail = mail;
	}

	public void setAge(int age) {
		this.age = age;
	}

	public void setMemo(String memo) {
		this.memo = memo;
	}

}

コントローラー


package com.example.demo11;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;

@Controller
public class FormController {

	@RequestMapping("/validate")
	public ModelAndView form01( @ModelAttribute("fm") Person person, ModelAndView mav) {

		mav.addObject("title", "Demo 11 page.");
		mav.addObject("msg", "Validated Check.");
		mav.setViewName("form");
		return mav;
		
	}

	@RequestMapping( value = "/validate", method = RequestMethod.POST )
	public ModelAndView form02( @ModelAttribute("fm")  @Validated Person person, 
					BindingResult result, ModelAndView mav ) {

		mav.addObject("title", "Demo 11 page.");

		if ( !result.hasErrors() ) {
			mav.addObject("msg", "Validated Check OK!");
			mav.setViewName("checkok");
		} else {
			mav.addObject("msg", "Validated Check NG..");
			mav.setViewName("form");
		}

		return mav;
	}

}

HTMLファイル


<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<title>Demo 11</title>
</head>
<body bgcolor="#f5f5f5" text="#2f4f4f">
<h2 th:text="${title}"></h2>
<p th:text="${msg}"></p>

<form method="POST" action="/validate" th:object="${fm}" >
<div>
<label>名前 </label>
<input type="text" name="name" size="20" th:value="*{name}" />
<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
</div>
<div>
<label>メール</label>
<input type="text" name="mail" size="20" th:value="*{mail}" />
<span th:if="${#fields.hasErrors('mail')}" th:errors="*{mail}"></span>
</div>
<div>
<label>年齢 </label>
<input type="number" name="age" size="5" th:value="*{age}" />
<span th:if="${#fields.hasErrors('age')}" th:errors="*{age}"></span>
</div>
<div>
<label>メモ </label>
<input type="text" name="memo" size="40" th:value="*{memo}" />
</div><br>
<input type="submit" name="check" value="チェック" />
</form>

</body>
</html>

<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<title>Demo 11</title>
</head>
<body bgcolor="#f5f5f5" text="#2f4f4f">
<h2 th:text="${title}"></h2>
<p th:text="${msg}"></p>

<div><label>名前 :</label><span th:text="${fm.name}"></span></div>
<div><label>メール:</label><span th:text="${fm.mail}"></span></div>
<div><label>年齢 :</label><span th:text="${fm.age}"></span></div>
<div><label>メモ :</label><span th:text="${fm.memo}"></span></div>

[<a href="http://localhost:8080/validate">戻る</a>]
</body>
</html>

ここに記載した Spring Boot のサンプルですが、以下の書籍を参考にさせてもらいました。