Open31

Spring勉強中のメモ

ゆるふうぇいとゆるふうぇいと

ログイン失敗時に、ログイン画面にエラーメッセージを表示したいが、クエリパラメータを使いたくない時の対処法

あくまでも一例。やり方は色々あるはず。
もっとこうした方がいい、とかコメントあれば頂けるとありがたいです。

事象

ログインに失敗したときに、ログイン画面に「ログインに失敗しました」的なエラーメッセージを表示したい。
手っ取り早い方法としては、ログインエラー時にURLに「error」みたいなクエリパラメータを付与し、ログイン画面にリダイレクトさせ、クエリパラメータの有無で処理を分ける…といった方法がある。

ただ、そうするとURLが「~/login?error」みたいな感じになり、以下の点でちょっとださい
・ログインの実施有無にかかわらず、クエリパラメータ付きのこのURLにアクセスするだけでエラーメッセージが表示されてしまう
・ページをリフレッシュするだけではメッセージが消えない

(イメージ)

クエリパラメータを利用しない形で、エラーメッセージが表示されるようにしたい。

結論

AuthenticationFailureHandlerを独自実装し、認証処理失敗時にセッションに埋め込む。
流れイメージ

①認証に失敗する
②AuthenticationFailureHandlerの「onAuthenticationFailure」メソッドが呼び出される
③onAuthenticationFailureの中で、以下を行う
 ③-1.HttpSessionにエラーメッセージを埋め込む
 ③-2. ログイン画面にリダイレクトさせる
④ログイン画面のコントローラでセッションの中身をチェックし、認証エラーを示す情報があった場合、ログイン画面にエラーメッセージを埋め込む
⑤セッションをクリアする

実装イメージ

SpringSecurityにて、ログイン失敗時の処理として、failureHandlerにAuthenticationFailureHandlerの実装クラスのインスタンス設定する。
以下例では「MyAuthenticationFailureHandler」がそれに相当

@Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
	   
       // ログインフォームページの設定
       http.formLogin(form -> {
       	// ログインページを独自実装する場合は指定
       	form
       	.loginProcessingUrl("/login")
       	.loginPage("/login") 
       	.defaultSuccessUrl("/index",true)
        .failureHandler(new MyAuthenticationFailureHandler())
       	.permitAll()
       	;
       }
~~割愛~~
    }

MyAuthenticationFailureHandlerの実装はこんな感じ。
セッションに認証失敗を示す文字列"LOGIN_FAILURE"を埋め込んでいる。
(文字列はべた書きではなく定数にするべきだが)
また、失敗理由の情報を持つAuthenticationExceptionもついでに渡してあげる。
持っておくことで、Exceptionの内容に応じてメッセージを分けるとか、色々できる


public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
		// セッションにログインが失敗したことを埋め込む
		HttpSession session = request.getSession(false);
		if(session != null) {
			request.getSession().setAttribute("LOGIN_FAILURE", exception);
			
		}
		// ログイン画面のURLへのリダイレクト設定
		DefaultRedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
		redirectStrategy.sendRedirect(request, response, Urls.LOGIN.getUrl());		
	}
}

ログイン画面のコントローラ実装イメージ
メソッド部分だけ抜粋

@Controller
public class LoginController {
	@GetMapping(URL_LOGIN)
	public ModelAndView index(
			ModelAndView mav,
			HttpServletRequest request,
			@RequestParam(value = "logout",required = false)final String logout
			) {
		
		// セッションにログイン失敗時の情報がある場合、エラーメッセージを画面に埋め込む。
		// エラーメッセージは一度だけ表示されればよいので、そのあとセッションをクリアする
		HttpSession session = request.getSession(false);
		if(session != null && session.getAttribute("LOGIN_FAILURE") != null) {
			mav.addObject("errorMessage", ログイン失敗!!!!);
			session.removeAttribute("LOGIN_FAILURE");
		}

		
		// logout後の遷移の場合
		// URLのクエリパラメータに"logout"が付与されているか否かで判定している。ダサいので何とかしたい気持ちがある。
		if(logout != null) {
			mav.addObject("logoutMessage", "ログアウトしました");
		}
		
		mav.setViewName(Urls.LOGIN.getViewPath());
		return mav;
	}
}

実行結果

ログインIDとパスワード未入力の状態でログインボタンだけ押下する。
URLに変化はなく、エラーメッセージが表示される。

この状態でページリロードすると、メッセージが消える。

以上

ゆるふうぇいとゆるふうぇいと

バリデーション

画面からの入力を受け取るformの各項目に対するバリデーションを実装する想定

formの実装

formに@NotBlankなどのバリデーション用のアノテーションを付与しておく

	// 選択されたステージ情報
	@NotBlank
	private String selectedStage;

コントローラの実装

1.引数で受け取るformに@Validアノテーションを付与する
2.引数としてBindingResult型の変数を追加する。
 バリデーションエラーが発生している場合、BindingResultにその情報が保存されてくる

hasErrors()メソッドを用い、エラーの発生有無を判定できる。
下記サンプルでは、エラーが発生している場合入力画面にフォワードしている。
フォワードすることで、BindingResultの情報が画面にわたり、バリデーションエラーのメッセージを出力するなどができる

	@PostMapping(URL_INPUT_REC_CONFIRM)
	public ModelAndView save(
			@Valid @ModelAttribute InputRecordForm form,
			BindingResult result,
			ModelAndView mav) {
		if(result.hasErrors()) {
			// バリデーションエラーが発生する場合、入力画面に戻る			
			return index(form,mav);
		}
		
		// 入力内容を確認画面に表示する
		// Viewの設定
		mav.setViewName("redirect:" + Urls.INPUT_REC_REGIST.getUrl());
		
		return mav;
	}

フォワードではなくリダイレクトにしたい場合

RedirectAttributesを利用してリダイレクト先にBindingResultの情報を渡す必要がある。
RedirectAttributesにフラッシュスコープに、下記2つの情報を設定する。
・BindingResult(バリデーションエラーの情報)
 Key値はBindingResult.MODEL_KEY_PREFIX + エラー情報を設定するForm名称
 thymeleafの場合、th:objectで設定しているパラメータと一致させる必要がある
・エラー情報を設定するForm

下記サンプル参照

	public ModelAndView save(
			@Valid @ModelAttribute InputRecordForm inputRecordForm,
			BindingResult result,
			ModelAndView mav,
			RedirectAttributes attributes) {
		if(result.hasErrors()) {
			// バリデーションエラーが発生する場合、入力画面にリダイレクトさせる
			// フォワードにしてしまうと、ブラウザが認識するURLがPOST先のURLのままになってしまう。
			// そのままリロードするとPOSTしか受け付けないエンドポイントに対しGETリクエストが発生し、エラーとなってしまうのを避ける
			attributes.addFlashAttribute(BindingResult.MODEL_KEY_PREFIX + INPUT_RECORD_FORM,result);
			attributes.addFlashAttribute(INPUT_RECORD_FORM,inputRecordForm);
			
			mav.setViewName("redirect:" + Urls.INPUT_REC_REGIST.getUrl());			
			return mav;
		}

thymeleafは下記のようにinputRecordFormを設定している

<form th:action="@{/inputRecord/save}" method="post" th:object="${inputRecordForm}">

リダイレクトを受け取るメソッドの引数として、Modelを設定しておく。
Modelに、フラッシュスコープに詰め込んだ情報が渡される。
画面側(view)が直接modelの中にあるBindingResultおよびFormの情報を抽出し、バリデーションエラー情報を利用してくれる。
また注意点として、画面側にFormオブジェクトの新たなインスタンスを渡すとせっかくmodelに詰め込んだ情報が消失して今うため、modelがFormを保持していない場合のみ、新規インスタンスを渡すようにしてあげる必要がある。
下記サンプル参照・・・

	@GetMapping(URL_INPUT_REC_REGIST)
	public ModelAndView index(
			Model model,
			ModelAndView mav,
			BindingResult result) {
		
		// 入力画面の基本設定
		settingInputScreen(mav);
		
		// バリデーションエラーが発生した際に、エラー情報をmodelに保持した状態で当該画面に遷移することがある
		// その情報が消えてしまわないようにする
		if(!model.containsAttribute(INPUT_RECORD_FORM)) {
			mav.addObject(INPUT_RECORD_FORM, new InputRecordForm());
		}
		
		// Viewの設定
		mav.setViewName(Urls.INPUT_REC_REGIST.getViewPath());
		return mav;
	}
ゆるふうぇいとゆるふうぇいと

AOP

アスペクト指向プログラミングの略
共通処理を入れ込む
例えば、特定のパッケージ配下のサービスクラスが何かしら処理をする前に、ログ出力(共通処理)を仕込む、といったことが可能

用語

・Aspect
 AOPの単位となる横断的な関心ごとを示すモジュール

・JoinPoint
 共通処理を実行するポイント。Springではメソッド

・Advice
 JoinPointで実行される共通処理

・Pointcut
 実行対称のJoinPointを表現する式。正規表現が可能
 例)@Pointcut("execution(* com.splservice.dbaccess.master..(..))")
  com.splservice.dbaccess.master パッケージ配下のサービスクラスのすべてのメソッド

Adviceの種類

共通処理を実行するタイミングにより下記のように種類分け

Advice タイミング
Before JoinPointの前
After Returning JoinPointが正常終了した後
After Throwing JointPointが例外をスローした後
After JoinPointの後。終了状態にかかわらず常に実行
Around JoinPointの前後

Spring AOP

フレームワークはAspectJを利用している
実装として、アノテーションを利用した方法や、xmlで定義する方法等がある。
また、Pointcutを定義し、それを利用することも可能

@Component
@Aspect
public class DefinePointCuts {
	
	@Pointcut("execution(* com.splservice.dbaccess.master.*.*(..))")
	public void inServiceLayer() {}

}

// AOPサンプル(After Returnning)
// アノテーション定義
@Aspect
@Component
public class MethodEndLoggingAspect {
	
	@AfterReturning("DefinePointCuts.inServiceLayer()")
	public void endLog(JoinPoint jp) {
		System.out.println("メソッド終了:" + jp.getSignature());
	}

}

applicationContext.xmlでの定義イメージ

<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:context="http://www.springframework.org/schema/context"
	xmlns:aop="http://www.springframework.org/schema/aop"
	xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
           http://www.springframework.org/schema/context
           http://www.springframework.org/schema/context/spring-context.xsd
           http://www.springframework.org/schema/aop
           http://www.springframework.org/schema/aop/spring-aop.xsd           
           ">
	<!-- beanの定義 -->
	<bean id="loggingAspect" class="com.splatoon.analyze.aspect.MethodStartLoggingAspect" />

	<!-- AOPの定義 -->
	<aop:config>
		<!--  beanはloggingAspectを利用する -->
		<aop:aspect ref="loggingAspect">
			<!-- com.splservice配下の全てのメソッドの前に、startLogメソッドを実行する  -->
			<aop:before pointcut="within(com.splservice..*)" method="startLog" />
		</aop:aspect>
	</aop:config>

</beans>
ゆるふうぇいとゆるふうぇいと

プロパティ値の仕様

プロパティファイルに設定した情報を、Spring内で利用する
例えば、DBへの接続情報などをプロパティファイルに記載しておき、
それを利用する・・・等

application.propertiesにプロパティを記載
@Valueアノテーションで、指定したプロパティの値を取得する
例)
application.propeties

sample.value=sample.value=sample value hugahuga
// プロパティファイルに設定された「sample.value」の値を取得する
public void index(@Value("${sample.value}") String sample,
		) {
	System.out.println(sample); 
}
ゆるふうぇいとゆるふうぇいと

messages.propertiesの管理

messages.propertiesにてメッセージの管理を行う
messages.propertiesは、デフォルトで src/main/resources 直下に配置しておけばよい。
パスを変える場合は、applicationContext.xmlにmessageSourceのbeanを定義する。
例えば、i18n/messages.propertiesに移動する場合は下記
サンプル

<bean id="messageSource"
  class="org.springframework.context.support.ResourceBundleMessageSource">
  <property name="basenames">
      <list>
           <value>i18n/messages</value>
      </list>
   </property>
   <property name="DefaultEncoding">
      <!-- 文字化け回避 -->
      <value>UTF-8</value>
   </property>
</bean>

org.springframework.context.MessageSourceを利用し、プロパティファイルで定義したメッセージを取り出して利用する。

messages.properties

welcome={0}さん、ようこそ

呼び出し

@Controller
public class IndexController extends AbstractSplController {
	@Autowired
	protected MessageSource messageSource;	
	
	@GetMapping(URL_INDEX)
	public ModelAndView index(
			ModelAndView mav,
			) {
		System.out.println(messageSource.getMessage("welcome", new String[] {"太郎"},Locale.JAPAN));
		return mav;
	}
}
ゆるふうぇいとゆるふうぇいと

リソース管理

特定の静的ファイル(以下、リソース)を読み取り、利用する。
リソース読み取りのための仕組みとして、Springは下記Resourceインタフェースと、いくつかの実装クラスを提供している。

クラス名 説明
ClassPathResource クラスパス上のリソースを利用する
FileSystemResource java.ioパッケージのクラスを使用し、ファイスシステム上のリソースを利用する。WritableResourceも実装している
PathResource java.nio.fileパッケージのクラスを使用し、ファイスシステム上のリソースを利用する。
UrlResource URL上のリソースを利用する。HTTPプロトコルでアクセス可能なWebリソースを使用する目的。また、file://とすることでファイルシステム上のリソースも利用できる
ServletContextResource Webアプリケーション上のリソースを利用する

利用例

下記に読み取り対象の静的ファイルを設置

ClassPathResource

	// リソース管理テスト
	private void testResourceManage() {
		// ClassPathResourceを利用し、静的ファイルの中身をロード
		Resource resource = new ClassPathResource("resource/memo.txt");
		try(InputStream in = resource.getInputStream()){
			String content = StreamUtils.copyToString(in, StandardCharsets.UTF_8);
			System.out.println(content);		
		} catch (IOException e) {
			// TODO 自動生成された catch ブロック
			e.printStackTrace();
		}		
	}

UrlResource

	private void testResourceManage() {
		try {
			Resource urlResource = new UrlResource("http://localhost:8080/resource/memo.json");
			try(InputStream in = urlResource.getInputStream()){
				String content = StreamUtils.copyToString(in, StandardCharsets.UTF_8);
				System.out.println(content);		
			} catch (IOException e) {
				// TODO 自動生成された catch ブロック
				e.printStackTrace();
			}			

		} catch (MalformedURLException e1) {
			// TODO 自動生成された catch ブロック
			e1.printStackTrace();
		}
}		
ゆるふうぇいとゆるふうぇいと

プロパティ値の仕様

プロパティファイルに設定した情報を、Spring内で利用する
例えば、DBへの接続情報などをプロパティファイルに記載しておき、
それを利用する・・・等

application.propertiesにプロパティを記載
@Valueアノテーションで、指定したプロパティの値を取得する
例)
application.propeties

sample.value=sample.value=sample value hugahuga
// プロパティファイルに設定された「sample.value」の値を取得する
public void index(@Value("${sample.value}") String sample,
		) {
	System.out.println(sample); 
}
ゆるふうぇいとゆるふうぇいと

トランザクション管理

Springにおけるトランザクション制御

宣言的トランザクション

アノテーションを利用

メソッドやクラスに@Transactionalを付与することで、トランザクションを張ることができる。
また、プロパティを設定ことでトランザクションの設定が可能
下記の例では、saveRecordメソッドにおいて、何かしらの例外(Exception.class、およびその子クラス)が発生した場合、ロールバックされる
デフォルトでは、RuntimeException系列(非検査例外)のみロールバックの対象としている。

	@Transactional(rollbackFor = Exception.class)
	public void saveRecord(BattleRecordEntity entity) throws Exception {
		battleRecordMapper.save(entity);
	}

@Transactionalにて利用できるプロパティは下記参照

プロパティ 説明
value 利用するトランザクションマネージャを指定する。デフォルトでいいなら省略可能。複数のトランザクションマネージャがあり、その中でどれを使うか選択したい場合に利用
transactionManager valueと同じ
propagation トランザクション伝播レベルを指定。後述
isolation トランザクション分離レベルを指定。後述
timeout トランザクションのタイムアウトを設定。単位は秒
readonly トランザクションの読み取り専用フラグを指定。デフォルトはfalse(読み取り専用ではない)
rollbackFor トランザクションのロールバック対象とする例外クラスのリストを指定。デフォルトではRuntimeException
rollbackForClassName トランザクションのロールバック対象とする例外クラス名を指定。デフォルトは空
noRollbackFor トランザクションのコミット対象とする例外クラスのリストを指定。デフォルトは空
noRollbackForClassName トランザクションのコミット対象とする例外クラス名のリストを指定。デフォルトは空

XMLコンフィギュレーション

@Transactionalを利用した場合と同様の設定を、XMLの設定にて行う。
AOPの機能を利用する。
アノテーションを付加しない=ソースをいじる必要ないため、編集できないソース(外部のものとか)に対してもトランザクションを管理することが可能となる、

設定例)
以下では、BattleRecordServiceがもつメソッドすべてにトランザクションを張り、
Exceptionが発生した場合ロールバックが行われるように指定している

<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:context="http://www.springframework.org/schema/context"
	xmlns:aop="http://www.springframework.org/schema/aop"
	xmlns:tx="http://www.springframework.org/schema/tx"
	xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
           http://www.springframework.org/schema/context
           http://www.springframework.org/schema/context/spring-context.xsd
           http://www.springframework.org/schema/aop
           http://www.springframework.org/schema/aop/spring-aop.xsd           
           http://www.springframework.org/schema/tx
           http://www.springframework.org/schema/tx/spring-tx.xsd
           ">
       
	<!-- beanの定義 -->
	<bean id = "transactionManager"
		  class = "org.springframework.jdbc.datasource.DataSourceTransactionManager">
		  <property name="dataSource" ref="dataSource" />	
	</bean>
	
	<!-- トランザクションマネージャーの設定 -->
	<tx:advice id="txAdvice">
		<tx:attributes>
			<tx:method name="*" rollback-for="Exception"/>
		</tx:attributes>	
	</tx:advice>
	
	<!-- 対象のクラス・メソッドを定義 -->
	<aop:config>
		<aop:pointcut id="txPointcut" expression="execution(* com.splservice.dbaccess.tran.BattleRecordService.*(..))" />
		<aop:advisor advice-ref="txAdvice" pointcut-ref="txPointcut"/>
	</aop:config>	

</beans>

明示的トランザクション

直接コミットやロールバックをソースに明示的に記述する方法
方法として、下記2つが提供されている
・PlatformTransactionManagerを利用する
・TransactionTemplateを利用する

PlatformTransactionManagerを利用した方法

PlatformTransactionManager を@Autowiredでインジェクションし利用する。
また、DefaultTransactionDefinitionを利用し、コミット・ロールバックを実行する
以下、サンプル


@Service
public class BattleRecordService {
	
	@Autowired
	BattleRecordMapper battleRecordMapper;
	
	// 明示的トランザクション用
	@Autowired
	PlatformTransactionManager txManager;	
	
	// 明示的トランザクションの制御
	public void saveRecordWithTran(BattleRecordEntity entity) throws Exception {
		
		// トランザクションの設定に必要なインスタンスの生成
		DefaultTransactionDefinition def = new DefaultTransactionDefinition();
		
		// トランザクションに名前を設定
		def.setName("InsertRecord");
		
		// 読み取り専用ではない
		def.setReadOnly(false);
		
		// トランザクション伝搬レベル:REQUIRED=新規に作成
		def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
		TransactionStatus status = txManager.getTransaction(def); // これ以降がトランザクションの範囲
	
		try {
                        // レコードを登録する(インサート)
			battleRecordMapper.save(entity);
		} catch (Exception e) {
			// 明示的ロールバック
			txManager.rollback(status);			
		}
		
		txManager.commit(status);
	}
}

TransactionTemplateを利用する

(後回し)

トランザクションの分離レベル

現在利用しているデータ(参照・変更)を、他のトランザクションとどの程度分離するかのレベル
Springではisolation属性を設定することで、指定が可能となる
ダーティリード、ノンリピータブルリード、ファントムリードのどこまでを許容するか、という話に帰結する。

分離レベル一覧(〇:防止する ×:防止しない)

トランザクション分離レベル ダーティリード ノンリピータブルリード ファントムリード
DEFAULT (DBの設定に依存する) - -
READ_UNCOMMITTED × × ×
READ_COMMITTED × ×
REPEATABLE_READ ×
SERIALIZABLE

各現象の意味

現象 説明
ダーティリード 未コミットの情報を、他のトランザクションが参照できてしまう
ノンリピータブルリード 他トランザクションのコミットにより、レコードが更新されたことにより、トランザクション内で読み込み済みだったレコードデータの情報が更新され、再度読み込むと状態が変わってしまう。(最初に読み込んだデータの状態が変わってしまったため、再度読み込むことができない)
ファントムリード 他トランザクションのコミットにより、新規レコードが追加(or削除)されたことにより、トランザクション内で読みこむテーブルデータが異なってしまう。(同一のSQLで再度テーブルの読み込みを実行すると、取得できるレコードの件数が変わる)

トランザクションの伝播レベル

トランザクションの境界における処理を定義する
新たにトランザクションを張る(入れ子)のか、作成済みのトランザクションに参加するのか…等
Springではpropagation属性を設定することで、指定が可能となる

伝播レベル 詳細
REQUIRED 現在のトランザクションを継続する。もしまだトランザクションが張られていない場合、新規に張る。デフォルトはこれ
REQUIRES_NEW 新規のトランザクションを張る。もしすでにトランザクションが張られている場合、一時的に無効となる。(すでに張られているトランと、今回新たに張るトランは互いに独立した関係となる)
MANDATORY 現在のトランザクションを継続する。もしまだトランザクションが張られていない場合、例外が発生する
NEVER トランザクションの対象外とする。もしすでにトランザクションが張られている場合、例外が発生する
NOT_SUPPORTED トランザクションの対象外とする。もしすでにトランザクションが張られている場合、一時的に無効となる
SUPPORTS 現在のトランザクションを継続する。もしまだトランザクションが張られていない場合、対象外とする
NESTED もしまだトランザクションが張られていない場合、新規に張る。もしすでにトランザクションが張られている場合、入れ子になるようにトランを作成する。NESTED区間内でロールバックされた場合は、NESTED内で行われた処理のみが取り消され、外側のトランザクションは影響を受けない。ただし、外側のトランザクションがロールバックされた場合は、NESTED内で行われた処理も含めてすべてロールバックされる

@Transactionalを利用する場合、プロパティにそれぞれ指定する

	@Transactional(
			isolation = Isolation.READ_COMMITTED,
			propagation = Propagation.REQUIRES_NEW,
			rollbackFor = Exception.class)
	public void saveRecord(BattleRecordEntity entity) throws Exception {
		battleRecordMapper.save(entity);
	}

XMLで設定する場合はこんな感じ

	<!-- トランザクションマネージャーの設定 -->
	<tx:advice id="txAdvice">
		<tx:attributes>
			<tx:method name="*" rollback-for="Exception" isolation="READ_COMMITTED" propagation="REQUIRES_NEW" />
		</tx:attributes>	
	</tx:advice>
ゆるふうぇいとゆるふうぇいと

Spring MVC アーキテークチャ

全体概要

実装が必要なコントローラはHandlerに相当する

DispatcherServlet

エントリーポイント。処理の流れを制御する司令塔の役割
以下のインタフェースを使用している

インタフェース名 役割
HandlerExceptionResolver 例外ハンドリング
LocaleResolver,LocaleContextResolver ロケールやタイムゾーンを解決
ThemeResolver クライアントのテーマ(UIのスタイル)を解決
FlashMapManager FlashMapオブジェクトを管理する。FlashMapは、PRGパターンにおけるRedirectとGet間でModelを共有するために用意されているMapオブジェクト
RequestToViewNameTranslator HandlerがFViewを返却しなかったときに適用するView名を決定
HandlerInterceptor Handlerの実行前後に行う共通処理を実装。開発者が実装し、Spring MVCに登録することで有効になる
MultipartResolver マルチパートリクエストを扱う。デフォルトでは適用されない

Handler

フロントコントローラが受け取ったリクエストに対して具体的な処理を行う役割

コントローラの実装方法
・@RequestMapping(@GetMapping、@PostMapping含)アノテーションを付与したクラスを生成する ★オススメ

@Controller
public class IndexController {
	@GetMapping(URL_INDEX)
	public ModelAndView index(
			ModelAndView mav,
			) {
                         // 割愛
	}
}

・Controllerインタフェースの実装クラスを作成する
実際には、Springから提供されるAbstractControllerを継承して実装するがよい
AbstractControllerが、Controllerをimplmentsしている

@Component("/oldindex")
public class IndexControllerOldStyle extends AbstractController{

	@Override
	protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response)
			throws Exception {
		ModelAndView mav = new ModelAndView();
		mav.addObject("username","old man");
		mav.setViewName(Urls.INDEX.getViewPath());
		return mav;
	}

}

HandlerMapping

リクエストに対応するHandlerを選択する
@RequestMappingで定義された情報を元に、Handlerを選択する

例えば下記の場合、helloメソッド、goodbyeメソッドがそれぞれHandlerとして認識され、
リクエスト情報とマッピングされる
下記の例でいえば、"/hello"、"/goodbye"というリクエストパスとマッピングされる

@Controller
public class HelloContoller {
	
	@RequestMapping("/hello")
	public String hello() {
		return "hello";
	}

	@RequestMapping("/goodbye")
	public String goodbye() {
		return "goodbye";
	}	
	
}

HandlerAdapter

Handlerのメソッドを呼び出す役割
メソッドの引数に渡す値を解決する処理や、メソッドからの返却値をハンドリングする処理などが実装されている。
Handlerが引数として受け取る変数の型の独自定義が可能

ViewResolver

インタフェース。Handlerから返却されたView名を元に、使用するViewインターフェースの実装クラスを解決する

ViewResolverインタフェースの主な実装クラス
・InternalResourceVIewResolver
・BeanNameViewResolver

View

クライアントへ返却するレスポンスデータを返却する役割
主な実装クラス
・InternalResourceView
・JstView

DIコンテナとの連携

アプリケーションコンテキスト

SpringMVCでは、以下2つのアプリケーションコンテキストを使用
・WEBアプリケーション用
 ・WEBアプリケーション全体で一つ
 ・Webアプリケーション全体で使用するコンポーネントが登録される
  ・Servuce、Repository、DataSource、ORMなどのBean
・DispatcherServlet用
 ・DispatcherSevletごとにインスタンスが生成される
  ・HandlerMapping、HandlerAdapter、ViewResolver、Controllerなど
 ・DispatcherServlet用のアプリケーションコンテキストはそれぞれが独立しているため、Beanの共有は不可

アプリケーションコンテキストのライフサイクル

フェーズ 説明
初期化フェーズ Webアプリケーション用のアプリケーションコンテキストと、DispatcherServlet用のアプリケーションコンテキストを生成するためのフェーズ
利用フェーズ アプリケーションコンテキストからBeanを取得して利用するためのフェーズ
破棄フェーズ 破棄するためのフェーズ
ゆるふうぇいとゆるふうぇいと

Webアプリケーションの開発

一通りモジュールの開発の仕方などをざっとまとめる

コントローラクラスの作成

@Contollerを付与したクラスを作成する

@Controller
public class HelloContoller {}

DIコンテナへの登録を行う

コンフィギュレーションクラスに、@ComponentScanを付与し、コンポーネントスキャンの対象となるベースパッケージを指定する。
※SpringBootの場合、@SpringBootApplication事態に@ComponentScanが設定されており、@SpringBootApplicationが付与されたクラスが自動的にベースパッケージとなる

@Configuration
@ComponentScan({"com.splatoon.analyze", "com.splservice"})
public class AppConfig implements WebMvcConfigurer {}
}

Handlerメソッドの作成

Hanlderメソッド = クライアントからのリクエストをハンドリングするメソッド
@RequestMappingや@GetMappingといった、クライアントからのリクエストを受け入れるためのアノテーションが付与されたメソッドのこと

@Controller
public class IndexController extends AbstractSplController {
	
        // Handlerメソッド
	@GetMapping(URL_INDEX)
	public ModelAndView index(){}
}

Handlerメソッドの引数

Handlerメソッドは多様な引数を受け付けている
また、メソッドの引数として受け取ることができるオブジェクトはHandlerMethodArgumentResolverインターフェイスの実装クラスを作成することで拡張が可能

型一覧

  • Model
  • RedirectAttributes
  • フォームクラスなどのJavaBeans
  • BindingResult
  • MulipartFIle
  • HttpEntity<?>
  • Locale
  • TimeZone
  • Principal
  • UriComponentsBuilder
  • SessionStatus

例えばこんな感じ

@Controller
public class SampleController {

    @GetMapping("/sample")
    public String sample(
    		HttpEntity<String> httpEntity,
    		SessionStatus sessionStatus,
    		Principal principal,
    		Locale locale,
    		Model model) {}
}

引数にアノテーションを指定することで、様々なリクエストデータを取得できる

アノテーション 説明
@PathVariable URL内のパス変数の値
@RequestParam リクエストパラメータの値
@RequestHeader リクエストヘッダーの値
@CookieValue クッキーの値
@RequsetBody リクエストボディの値
@ModelAttribute Modelに格納されているオブジェクトを取得
@Value value属性に指定したプレースホルダによって置換された値
@SessionAttribute セッション情報。参照のみ。削除や更新はできない
@RequestAttribute HttpServletRequest内の情報

使用例)
(随時追加していくけど面倒なので気になったやつだけ)
・セッション

	@GetMapping(URL_LOGIN)
	public ModelAndView index(
			ModelAndView mav,
			HttpServletRequest request,
			// @SessionAttributeは、セッションの参照のみ可能。セッションから認可例外情報を取得している
			@SessionAttribute(name = "LOGIN_FAILURE",required = false) AuthenticationException sessionAttribute,
			) {	
		if(sessionAttribute != null) {
			mav.addObject("errorMessage", "ログイン失敗!!!!");
			
			// セッションから情報を削除したい場合は、HttpSessionを利用する
			HttpSession session = request.getSession(false);
			session.removeAttribute("LOGIN_FAILURE");
		}

・クッキー

	@GetMapping(URL_INDEX)
	public ModelAndView index(
			ModelAndView mav,
			// クッキーのテスト
			HttpServletResponse httpServletResponse,
                        // Cookieの取得
			@CookieValue(name = "SPL_KEY",required = false,defaultValue = "default") String cookie
			) {
		
               // 取得したクッキーの内容を出力                
               System.out.println(cookie);

                // Cookieへの値の設定方法
		httpServletResponse.addCookie(new Cookie("SPL_KEY","cookie_test"));
		
		return mav;
	}

View Controller

あるエンドポイントへのリクエストに対し、ただViewを返したいだけであるなら、View Controllerを利用することで実現可能
Configurationクラスにて、addViewControllersメソッドを実装することで設定可能。サンプル参照

@Configuration
// Spring全体のコンフィグクラス
public class AppConfig implements WebMvcConfigurer{	
	// View Controllerの設定
	@Override
	public void addViewControllers(ViewControllerRegistry registry) {
		registry.addViewController("/view").setViewName("viewTest"); // /viewに対し、viewTestを返却
		registry.addRedirectViewController("/viewRedirect", "/index"); // /viewRedirectへのアクセスを、/viewへリダイレクト
		registry.addStatusController("/status", HttpStatusCode.valueOf(200)); // ステータスコード200をかえす
		
	}
}

リクエストマッピング

@RequestMappingを利用したリクエスト処理
HTTPメソッドごとのアノテーションも用意されている。
(@GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@PatchMapping)

リクエストマッピングに利用できる属性一覧

属性 説明
value リクエストパス
path valueの別名
method HTTPメソッド
params リクエストパラメータ
headers リクエストヘッダー
consumes リクエストのContent-Typeヘッダー値
produces リクエストのAcceptヘッダー値
name マッピング情報に任意の名前を指定する

リクエストパスの指定

クラスに指定…ベースパスとなる
メソッドに指定…ベースパスからの相対パス

// ベースパス
@RequestMapping("/analyze")
public class AnalyzeRecordController extends AbstractSplController {

	@GetMapping("/record")
	public ModelAndView index(ModelAndView mav) {}
}

パスパターンの利用

パスパターンは以下

パスパターン
URIテンプレート形式 /analyze/{userId}
正規表現の利用 /analyze/{userId:[a-f0^9]{36}}
Antスタイル /**/analyze/user/

リクエストパラメータの利用

リクエストパラメータをparam属性に指定することで、マッピング条件に指定することができる。
それを利用することで、同じエンドポイントに対しリクエストパラメータの有無や、パラメータの違いで、リクエストを受け付けるハンドラーメソッドを分ける、といったことができる。
以下サンプル
同一の "/index" というエンドポイントに対しリクエストパラメータが異なるGETリクエスト・POSTリクエストを実行する。

HTML

	<!-- GETリクエストテスト(クエリ無し) -->
	<br>
	<a th:href="@{/index}">
		<button>TOPへ戻る(クエリ無し)</button>
	</a>	
		
	<br>
	<!-- GETリクエストテスト(クエリ有り) -->
	<a th:href="@{/index?queryText=getTest}">
		<button>TOPへ戻る(クエリ有り)</button>
	</a>	
	
	<!-- POSTリクエストテスト -->
    <form method="POST" th:action="@{/index}">
      <label for="input-text">文字列を入力してください:</label>
      <input type="text" id="input-text" name="inputText">
      <br>
      <button type="submit">送信</button>
    </form>
    
	<!-- POSTリクエストテスト -->
    <form method="POST" th:action="@{/index}">
      <label for="input-text2">文字列を入力してください:</label>
      <input type="text" id="input-text2" name="inputText2">
      <br>
      <button type="submit">送信</button>
    </form> 

コントローラ

	// GETリクエスト、かつパラメータにqueryText
	@GetMapping(path = URL_INDEX,params = "queryText")
	public ModelAndView getindex(
			@RequestParam("queryText") String queryText,
			ModelAndView mav
			) {
		System.out.println(queryText);
		mav.setViewName(Urls.INDEX.getViewPath());
		return mav;
	}	
	
	// GETリクエスト、かつパラメータにqueryText
	@GetMapping(path = URL_INDEX,params = "queryText")
	public ModelAndView getindex2(
			@RequestParam("queryText") String queryText,
			ModelAndView mav
			) {
		System.out.println(queryText);
		mav.setViewName(Urls.INDEX.getViewPath());
		return mav;
	}	
	
	// ポストリクエスト、かつパラメータにinputText
	@PostMapping(path = URL_INDEX,params = "inputText")
	public ModelAndView postindex(
			@RequestParam("inputText") String inputText,
			ModelAndView mav
			) {
		System.out.println("post index" + inputText);
		mav.setViewName(Urls.INDEX.getViewPath());
		return mav;
	}
	
	
	// ポストリクエスト、かつパラメータにinputText2
	@PostMapping(path = URL_INDEX,params = "inputText2")
	public ModelAndView postindex2(
			@RequestParam("inputText2") String inputText,
			ModelAndView mav
			) {
		System.out.println("postindex2" + inputText);
		mav.setViewName(Urls.INDEX.getViewPath());
		return mav;
	}

POSTリクエストは、Spring securityの設定でCSRF対策が有効になっている場合、csrfトークンを同時に渡す必要がある。FWとしてThymeleafを利用している場合は、th:actionを利用することで自動的にcsrfトークンを埋め込んでくれる。
例えば、th:actionタグを利用している場合、最終的に生成されるhtmlは下記のようになり、自動的に_csrfを埋め込んでくれている。

<form method="POST" action="/index">
<input type="hidden" name="_csrf" value="-rlVPN86H45GlZKA9mF3MTehW5kqZGHp2e7wXZtGJD9k7rihm4o3WLpcfbtroKW0kExDUg-WdqAZUgDEuIrAa_8lE1tUio2W">
      <label for="input-text">文字列を入力してください:</label>
      <input type="text" id="input-text" name="inputText">
      <br>
      <button type="submit">送信</button>
    </form>

リクエストパラメータ以外に、「リクエストヘッダー」「Content-Typeヘッダー」「Acceptヘッダー」を利用してマッピングすることも可能

ヘッダー 属性
リクエストヘッダー headers
Content-Typeヘッダー consumes
Acceptヘッダー produces

例)

@GetMapping(path = URL_INDEX,headers = "Accept-Language=ja",produces = "application/signed-exchange")
	public ModelAndView index(
)

リクエストデータの取得

パラメータとして付与されたリクエストデータの取得方法

取得方法 アノテーション
パス変数値 @PathVariable
リクエストパラメータ値 @RequestParam
リクエストヘッダー値 @RequestHeader
クッキー値 @CookieValud

例えば、以下のようなURLにアクセスした場合に、各リクエストデータを取得する際のサンプルコードは下記
/user/U000000041?level=normal

	@GetMapping("/user/{userId}")
	public ModelAndView userDetail(
			ModelAndView mav,
			@PathVariable String userId, // パス変数
			@RequestParam String level, // クエリパラメータ
			@RequestHeader(name = "Referer",required = false) String refer // リクエストヘッダー,
			@CookieValue(name = "SPL_KEY",required = false) String cookie // Cookie
			) {		
		mav.setViewName(Urls.USER_DETAIL.getViewPath());
		return mav;
	}

バインディング処理

渡されたリクエストデータを受け取る際、引数にバインドする処理をカスタマイズし、任意の型に変換するなどを実現する。
@InitBinderを付与したメソッドをコントローラクラスに実装する必要がある。
Springは、データのバインディングにてWebDataBinderを利用する仕組みになっている。
また、リクエストデータのバインディング処理を行う前に@InitBinderが付与されたメソッドを呼び出す。
@InitBinderが付与されたメソッドにて、WebDataBinderのバインディング処理をカスタマイズする
カスタマイズのために、下記二つのメソッドが用意されているため、どちらかを利用する。
・addCustomFormatter
・registerCustomEditor

例えば以下は、SimpleDateFormatを利用し、
View側から「yyyy-MM-dd」形式で送られてくる文字列をDate型に変換する。

例)

	@InitBinder("targetDate") // リクエストパラメータを指定する場合
	public void initBinder(WebDataBinder binder) {
		 // yyyy-MM-dd形式の文字列で送られてくるリクエストパラメータを、Date型に変換して受け付ける
		 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
             // SimpleDateFormatを利用し、該当するリクエストパラメータをDate型に変換する
	     binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, true));

	@GetMapping(PATH_USER)
	public ModelAndView userDetail(
			ModelAndView mav,
			@RequestParam Date targetDate // Date型に変換される	
			) {
        …

 	}

アノテーションを利用したフォーマット指定

アノテーションを利用してフォーマットを指定することができる
リクエストで受け取るフォームクラスのフィールド変数や、ハンドラーメソッドの引数に指定することが可能。

アノテーション 効果
@DateTimeFormat 日付型
@NumberFormat 数値型

サンプル
・リクエストパラメータへの指定

	@GetMapping(PATH_USER)
	public ModelAndView userDetail(
			ModelAndView mav,
			@DateTimeFormat(pattern = "yyyy-MM-dd") @RequestParam Date targetDate
			) {}

・フォームへの指定

@Data
public class sampleForm {
        // yyyyMMddのフォーマットで渡されることを期待	
	@DateTimeFormat(pattern = "yyyyMMdd")
	private Data accessDate;
}
ゆるふうぇいとゆるふうぇいと

フォームの利用

フォームクラスを利用し、画面とのデータのやり取りを行う
画面に入力された各値をフォームとしてひとまとめに扱う。

フォームのスコープ

フォームのスコープは下記の種類がある。
また、厳密には下記スコープはフォームだけでなくModelに格納するオブジェクトに共通する概念

スコープ 詳細
リクエストスコープ デフォルト。単一のリクエスト間で保持。画面間で共有することはできないので、hiddenを利用してたらい回すなどの工夫が必要になる
フラッシュスコープ PRGパターンにおける、POST→GET間で保持。一時的にHttpSessionに格納され、リダイレクト後自動で消去される
セッションスコープ 同一セッション内の複数のリクエストで共有される。HttpSessionに格納され、明示的に削除されるまで残り続ける

フラッシュスコープ

フォームオブジェクトをフラッシュスコープで扱うために、RedirectAttributesに対象のオブジェクトを追加する。
1.POSTリクエストを受け取るハンドラーメソッドの引数に、RedirectAttributes型の変数を設定する
2. 最後GETリクエストを受け取るハンドラーメソッドの引数に、リダイレクトにより受け取るFormオブジェクトの型を追加する
2. RedirectAttributesのaddFlashAttributeに、管理したいフォームオブジェクトを追加する
3. リダイレクトを実行する


HTML(Themeleaf利用)

	<form th:action="@{/sample}" method="post" th:object="${sampleForm}">
		<input type="text" th:field="*{testStr}">
		<input type="text" th:field="*{testNum}">
		<input type="submit" value="保存" />
	</form>

コントローラ

@Controller
public class SampleController {
    
    @PostMapping("/sample")
    public ModelAndView postSample(
    		ModelAndView mav,
    		@ModelAttribute SampleForm form,
    		RedirectAttributes redirectAttributes
    		) {
    	
    	// フォームをフラッシュスコープに詰め込む
    	redirectAttributes.addFlashAttribute(form);
        // フラッシュスコープに詰め込む属性名を指定する場合はこちら
        redirectAttributes.addFlashAttribute("Sample",form);
    	mav.setViewName("redirect:" + "/sample");
    	return mav;    	
    }

    @GetMapping("/sample")
    public String sample(
@ModelAttribute SampleForm form,
@ModelAttribute("Sample") SampleForm sample // フラッシュスコープから受け取る属性名を指定する場合はこちら
) {}

}

(セッションスコープは後回し)

バリデーション

有効化

リクエストパラメータで受け取ったフォームの各値に対し、バリデーションチェックを行う
引数のフォームクラスに対し、以下のいずれかのアノテーションを付与する
・@Valid
・@Validated

@Validatedを利用すると、Bean Validationのバリデーショングループを利用できるため、こちらが基本(だと思う)
バリデーションエラーが発生した場合、その結果がBindingResultに格納される
@Validatedと、BindingResultは必ずセットで定義し、BindingResultは@Validatedの後となるようにする
じゃないとエラーになる。

public ModelAndView save(
   @Validated InputRecordForm inputRecordForm, // バリデーションの有効化
   BindingResult result // バリデーションエラーの情報を保持
){}

バリデーションエラーの取り扱い

バリデーションエラーの結果はBindingResultに格納されている。
バリデーションエラーが発生した場合に個別の処理を行いたい場合は、BindingResultからその情報を取り出して利用することとなる。
BindingResultが持つ各メソッドは下記の通り

メソッド名 説明
hasErrors() バリデーションエラーが発生している場合 True
hasGlobalErrors() オブジェクトレベルのエラーが発生している場合、True
hasFieldErrors() フィールドレベルのエラーが発生している場合にTrue
hasFieldErrors(String) 引数に指定したフィールドでエラーが発生している場合True

hasErrors()を利用することが多い

	@PostMapping(URL_INPUT_REC_SAVE)
	public ModelAndView save(
			@Validated @ModelAttribute InputRecordForm inputRecordForm,
			BindingResult result, //BindingResultは必ず入力チェックするフォームクラスの直後の引数として指定する
			ModelAndView mav, // BindingResultオブジェクトは、Modelに自動的に追加される
			RedirectAttributes attributes) {

		// バリデーションエラーの有無判定
		if(result.hasErrors()) {(独自処理)
               }}

入力チェックルール

Formが持つ各フィールドに入力チェックルールを設定する
@NotNull、@NotBlank、@NotEmptyの違いは以下のQiitaの記事参考
https://qiita.com/NagaokaKenichi/items/67a63c91a7db8717fc7d

入力チェック アノテーション 説明 サンプル
必須チェック @NotNull 入力値がNULLの場合エラー @NotNull
private String testStr;
ブランクチェック @NotBlank
空チェック @NotEmpty
桁数チェック @Size 文字数やコレクションの要素数の最小値~最大値。数値に対して行うものじゃないよ @Size(min = 2,max = 5)
private String testStr;
文字種チェック @Pattern 正規表現を用いたパターンのチェック @Pattern(regexp = "[a-zA-Z0-9]*")
private String testStr;
数値の範囲チェック @Min 数値の最小値 @Min(1)
private int testNum
数値の範囲チェック @Max 数値の最大値 @Max(100)
private int testNum
数値の範囲チェック(小数含む) @DeximalMin 数値の最小値 @DecimalMin("5.2")
private BigDeximal testDecimal
数値の範囲チェック(小数含む) @DeximalMax 数値の最大値 @DecimalMax("100.5")
private BigDeximal testDecimal
日時過去チェック @Past 過去日時であること @Past
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
日時未来チェック @Future 未来日次であること @Future
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
真偽値チェック(True) @AssertTrue Trueであること @AssertTrue
private boolean isAgree;
真偽値チェック(False) @AssertTrue Falseであること @AssertFalse
private boolean isAgree;

各アノテーションが共通的に持つ予約属性は下記の通り

属性 詳細
message バリデーションエラー時のエラーメッセージを個別に設定する場合に利用
groups バリデーショングループを指定する。バリデーショングループに関しては後述
payload 任意のメタ情報を付与する(らしい)使うことほとんどないと思う

ネストしたBeans(Formとか)をバリデーションのチェック対象にする場合は、@Validを付与する

@Data
public class SampleForm {
	@Valid
	private LoginForm loginForm;
}
ゆるふうぇいとゆるふうぇいと

バリデーション応用編

独自のアノテーションの作成や、バリデータの実装、相関チェックなどなど、バリデーション応用編

独自アノテーションの作成

既成アノテーションの組み合わせ

既成アノテーションの@Patternを利用し、「英数字である」ことをチェックする独自のアノテーションを定義する
例)

@Documented
@Constraint(validatedBy = {})
@Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Pattern(regexp = "[a-zA-Z0-9]*") // 英数字であること
public @interface AlphaNumeric {
	
	String message() default "{com.example.validation.AlphaNumeric.message}";
	Class<?>[] groups() default {};
	Class<? extends Payload>[] payload() default {};
	
	@Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER})
	@Retention(RetentionPolicy.RUNTIME)
	@Documented
	public @interface List{
		AlphaNumeric[] value();
	}

}

使用例

	@AlphaNumeric // 独自バリデーション
	private String testStr;

独自のバリデータを実装する

下記では、2つの項目に設定された値が一致しているかどうかをチェックする
独自のアノテーションを定義し、そのアノテーションで利用するバリデータを実装する
具体的には、ConstraintValidatorインタフェースを実装することで、バリデーターとして認識される。

制約アノテーション

@Documented
@Constraint(validatedBy = {EqualsPropertyValuesValidator.class}) // バリデータの実装クラスを指定
@Target({TYPE,ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface EqualsPropertyValues {
	
	String message() default "{相関チェック対象の各項目の値が不一致です}";
	Class<?>[] groups() default {};
	Class<? extends Payload>[] payload() default {};
	
	String property();
	String comparingProperty();
	
	@Target({TYPE,ANNOTATION_TYPE})
	@Retention(RetentionPolicy.RUNTIME)
	@Documented
	public @interface List{
		EqualsPropertyValues[] value();
	}
}

バリデーター

public class EqualsPropertyValuesValidator implements ConstraintValidator<EqualsPropertyValues, Object> {
	
	private String property; // プロパティ値1
	private String comparingProperty; // プロパティ値2
	private String message;
	
	public void initialize(EqualsPropertyValues constraintAnnotation) {
		this.property = constraintAnnotation.property();
		this.comparingProperty = constraintAnnotation.comparingProperty();
		this.message = constraintAnnotation.message();
	}

	@Override
	// ConstraintValidatorContextは、バリデーションのコンテキスト情報を提供するインタフェース
	// 独自のバリデータを実装する際に利用する
	public boolean isValid(Object value, ConstraintValidatorContext context) {
		// 2つのプロパティ値を取得して比較
		BeanWrapper beanWrapper = new BeanWrapperImpl(value);
		Object propertyValue = beanWrapper.getPropertyValue(property); // beanを名前の情報から取得する
		Object comparingPropertyValue = beanWrapper.getPropertyValue(comparingProperty); 
		
		// 指定されたオブジェクトが等しいかどうかを判別する
		boolean matched = ObjectUtils.nullSafeEquals(propertyValue, comparingPropertyValue);
		if(matched) {
			return true;
		}else {
			context.disableDefaultConstraintViolation();
			// propertyで指定された項目を対象に、バリデーションエラーのメッセージを設定する
			context.buildConstraintViolationWithTemplate(message)
			.addPropertyNode(property).addConstraintViolation();
			return false;
		}
	}

}

相関チェックを実施したいFormクラスに、作成した独自のアノテーションを付与する
(今回作成したアノテーションは、クラスレベルのものなので、クラスに付与する)

@Data
@EqualsPropertyValues(property = "testStr",comparingProperty = "testStrConfirm")
public class SampleForm {
	private String testStr;
	private String testStrConfirm;
}

バリデーショングループの活用

バリデーショングループを利用することで、ある項目に対して実施するバリデーションの内容を、特定の条件に従って切り換え、といったことが可能となる。
例えば、登録の時は必須入力項目だが、更新の時は任意項目としてバリデーションチェックを行いたい、など。

・バリデーショングループの定義
Formクラスの項目に、グループごとのバリデーションを設定する

public class SampleForm {
        // Defaultを継承することで、グループの指定の無いその他のバリデーションも一緒に実行される
	public static interface FreeAccount extends Default {} 
	public static interface PayAccount extends Default {}

    // バリデーショングループを設定
    // グループごとのチェックルールを定義する
    @Size(max = 0, groups = FreeAccount.class)
    @Size(min = 14, max = 16, groups = PayAccount.class)
	private String cardNo;
}

・バリデーションの設定
@Validatedアノテーションに、引数としてバリデーショングループクラスを設定することで、メソッドごとに実行するバリデーションをそれぞれ設定する。

下記サンプルでは、POSTリクエストで受け取るパラメータに応じて呼び出すメソッドを切り替えることで、同時にバリデーションも切り替わる。

    @PostMapping(path = "/sample", params = "type=1")
    public ModelAndView postSampleFree(
    		ModelAndView mav,
                // バリデーショングループを指定したバリデーション
    		@Validated({FreeAccount.class}) @ModelAttribute SampleForm form,
    		BindingResult bindingResult,
    		RedirectAttributes redirectAttributes
    		) {
    	System.out.println("無料アカウントへの処理です");

    	displayInfo(bindingResult, form, redirectAttributes);

    	mav.setViewName("redirect:" + "/sample");
    	return mav;    	
    }
    
    @PostMapping(path = "/sample", params = "type=2")
    public ModelAndView postSamplePay(
    		ModelAndView mav,
                // バリデーショングループを指定したバリデーション
    		@Validated({PayAccount.class}) @ModelAttribute SampleForm form,
    		BindingResult bindingResult,
    		RedirectAttributes redirectAttributes
    		) {
    	System.out.println("有料アカウントへの処理です");
    	
    	displayInfo(bindingResult, form, redirectAttributes);

    	mav.setViewName("redirect:" + "/sample");
    	return mav;    	
    }  

バリデーションエラーメッセージの編集

バリデーションエラーのメッセージを、デフォルトで用意されているものから変更する。
その方法として、以下の3つがある
①messages.propertiesに定義する
②Bean Validation管理のプロパティファイル'ValidationMessages.properties)に定義する
③制約アノテーションのmessages属性に直接メッセージを定義する

優先度は以下の順となっている。
同一のアノテーションに対し、複数の定義がある場合は以下の優先順に従って採用される
①>②>③

(messages.propertiesとValidationMessages.properties両方にNotNullによるメッセージが定義されている場合、messages.propertiesが優先される…といった感じ)

messages.propertiesへの定義方法

以下のように、メッセージを定義したいアノテーションのクラス名に対し、エラーメッセージを定義する

NotNull={0}は必須入力です

上記の書き方では、@NotNullが付与されたいかなる項目へのエラーメッセージを定義している。
条件を細かくし、同じ@NotNullでもFormや項目に応じてメッセージを変えるといったことも可能。
以下記載方法まとめ
・アノテーションクラス名.フォームオブジェクトの属性名.プロパティ名
・アノテーションクラス名.フォームオブジェクトの属性名
・アノテーションクラス名.プロパティ名
・アノテーションクラス名

具体例)

NotBlank.inputRecordForm.selectedJudge={0}は必須です
NotBlank.inputRecordForm={0}は必須だよ
NotBlank.selectedRule={0}は必須なんですよ
NotBlank={0}は必須入力項目です

また、上記例のように{0}とプレースホルダを設定することができる。
デフォルトだと項目の物理名に置換されるが、
以下のようにmessages.propertiesに論理名をあらかじめ設定しておくことで、論理名で置換される。

inputRecordForm.selectedJudge=勝敗

Thymeleafを利用している前提で、th:errors="*{エラーの属性名}" とすることで、設定されたエラーメッセージをViewに返すことができる。

<!-- selectedJudge項目にバリデーションエラーがある場合、定義したメッセージが表示される -->
<font color="red" th:if="${#fields.hasErrors('selectedJudge')}" th:errors="*{selectedJudge}">入力エラー</font>

Bean Validation管理のプロパティファイルに定義する

src/main/resources直下にValidationMessages.propertiesを配置し、そこに各アノテーションごとのメッセージを定義する。
キーとなるアノテーションは、以下のようにちゃんとフルパスで指定してあげる

例)

jakarta.validation.constraints.NotNull.message=入力必須です

messages属性に直接メッセージを定義する

アノテーションを付与した項目に直接メッセージを定義する

	@NotBlank(message = "ルールは必須です")
	private String selectedRule;

Spring Validator

Spring Validatorインタフェース、およびその拡張クラスであるSmartValidatorを利用し、独自のバリデーションを実装する

SpringValidatorを利用した場合

SpringValidatorの詳細
https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/validation/Validator.html

実装するが必要なメソッドと、その役割

メソッド 役割
supports 引数に渡されたクラスが、チェック対象のクラスか否かを判定する。チェック対象の場合true、それ以外の場合falseを返す
validate バリデーションチェックの本体

バリデータの実装例
以下は、SampleFormがもつ「type」と「cardNo」という二つの項目の相関チェックを行う
・typeの値が"1"の場合
 ・cardNoは未入力でなくてはならない
・typeの値が"2"の場合
 ・cardNoは入力されていなければならない
 ・入力された値は14以上16以下の桁数でなければならない

@Component
public class SampleFormValidator implements org.springframework.validation.Validator {
	
	// ModelAndView.classも対象に含めて置かないと失敗になる。原因は調査中
	List<Class> classList = Arrays.asList(SampleForm.class,ModelAndView.class);
	
	@Override
	public boolean supports(Class<?> clazz) {
		// clazzがSampleFormに代入できる型か否かをチェック
		System.out.println(classList.contains(clazz));
		return classList.contains(clazz);
		// return SampleForm.class.isAssignableFrom(clazz);
	}

	@Override
	// typeとcardNoの相関チェックを行う
	public void validate(Object target, Errors errors) {
		if(errors.hasFieldErrors("type")) {
			// そもそもtypeにエラーがある場合は即終了
			return;			
		}
		
		// targetは、supportsがtrue = SampleForm型の変数へ代入が可能
		SampleForm sampleForm = SampleForm.class.cast(target);
		if(StringUtils.equals(sampleForm.getType(), "1")) {
			// 無料の場合、カード番号への入力は許可しない
			if(!StringUtils.isEmpty(sampleForm.getCardNo())) {
				errors.rejectValue("cardNo", "Empty",new Object[] {"カード番号"},null);
			}
			
		}else if(StringUtils.equals(sampleForm.getType(), "2")) {
			// 有料の場合、カード番号への入力は必須で、かつ14文字以上16文字以下である必要がある
			if(StringUtils.isEmpty(sampleForm.getCardNo())
				|| sampleForm.getCardNo().length() < 14
				||sampleForm.getCardNo().length() >16){
				
				// バリデーションエラー時の設定
				errors.rejectValue("cardNo", "Size",new Object[] {"カード番号",14,16},"入力桁数に問題あり");
			}
		}
	}
}

実装したバリデータを利用したいコントローラにて、以下の実装を行いバリデーターを有効化する
・AutowiredでDIする
・initBinderメソッドで、WebDataBinderのバリデーターとして作成したバリデーターを追加する
具体的な実装例は以下

@Controller
public class SampleController extends AbstractSplController {
	

	@Autowired
	SampleFormValidator sampleFormValidator;
		
	@InitBinder
	public void initBinder(WebDataBinder binder) {
		binder.addValidators(sampleFormValidator);
	}}
ゆるふうぇいとゆるふうぇいと

画面遷移

画面遷移の方法色々まとめる

遷移先のView名の指定

遷移先のView名を指定すると、Spring MVCのViewResolverがView名から物理的なViewを特定し、画面遷移を実行する。
コントローラにおけるハンドラーメソッド戻り値の型としてStringを指定することで、View名を指定することになる

@Controller
public class HelloContoller {
	
	@RequestMapping("/hello")
	public String hello() {
		return "hello";
	}
}

ModelAndViewを使う場合は、以下。どっちでもよい

	@RequestMapping("/helloMvc")
	public ModelAndView helloMvc(
			ModelAndView mav
			) {
		mav.setViewName("hello");
		return mav;
	}

リダイレクト

リダイレクトする場合は、view名の前に"redirect:"を付与する

	@RequestMapping("/hello")
	public String hello() {
		return "redirect:hello";
	}

リクエストパラメータの連携

リダイレクト先にリクエストパラメータを連携する場合は、RedirectAttributesに格納する

	@RequestMapping("/hello")
	public String hello(
			RedirectAttributes attributes
			) {
		String hogeStr = "hogehoge";
		attributes.addAttribute("hoge",hogeStr);
		return "redirect:hello";
	}

上記の場合、"hello?hoge=hogehoge"といった形式でリダイレクト先にパラメータが渡される
クエリパラメータとしてURLにもろ出るので注意
クエリパラメータに設定したくない場合は、フラッシュスコープを利用すること(後述)

リダイレクト先とのデータ連携

渡したいデータをRedirectAttributesのフラッシュスコープへ格納することで、Modelに詰めてリダイレクト先に渡すことができる。
フラッシュスコープへの格納は、addFlashAttributeメソッドを利用する
下記例参照

	@RequestMapping("/hello")
	public String hello(
			RedirectAttributes attributes
			) {
	
		String testStr = "redirect test hello!";
		
		// リダイレクト先に、属性名:helloの文字列を連携する
		// → modelにエクスポートされて連携される
		attributes.addFlashAttribute("hello",testStr);
		return "redirect:/helloRedirect";
	}
	
	@RequestMapping("/helloRedirect")
	public String helloRedirect(
			Model model
			) {
		
		// リダイレクト元からフラッシュスコープに詰めて渡されたデータを。modelから抽出する
		System.out.println(model.getAttribute("hello"));
		return "redirect:/index";
	

パス変数の指定

URL内にパス変数を設定し、リダイレクト先のURLを動的に生成する。
RedirectAttributes のaddAttributeを利用すること

以下例

	@RequestMapping("/hello")
	public String hello(
			RedirectAttributes attributes
			) {
		String userId = "USER01";
		attributes.addAttribute("userId",userId);
                // パス変数 {userId}を指定。実際に生成されるURLは"hello/USER01"となる
		return "redirect:hello/{userId}";
	}

フォワード

フォワードする場合は、view名の前に"forward:"を付与する

	@RequestMapping("/hello")
	public String hello(
			RedirectAttributes attributes
			) {
		return "forward:hello";
	}
ゆるふうぇいとゆるふうぇいと

Viewとの連携

Viewは、コントローラなどからModelに詰めて渡されたオブジェクトを操作することができる。
逆に言えば、Viewで扱いたい情報はModelに詰めて渡してあげる必要がある。
Modelへ格納するオブジェクトは、以下のいずれかのスコープで管理される
・リクエストスコープ
・フラッシュスコープ
・セッションスコープ

Modelに格納する方法は主に以下二通り
・ModelのAPIを利用し、直接格納する
・ModelAttributeアノテーションを利用する

ModelのAPIを利用

addAttributeメソッドを利用する

	@RequestMapping("/hello")
	public String hello(
			RedirectAttributes attributes,
			Model model
			) {
	
		// Modelへの詰め込み
		String testStr = "redirect test hello!";
		model.addAttribute("test", testStr);
		return "redirect:/helloRedirect";
	}

@ModelAttributeメソッドを利用

@ModelAttributeアノテーションを付与したメソッドを呼ぶと、メソッドが返却したデータがmodelに格納されるようになる。

	@RequestMapping("/hello")
	public String hello(
			RedirectAttributes attributes,
			Model model
			) {
	
		// Modelへの詰め込み
		getStr();
		return "redirect:/helloRedirect";
	}
	
        // valueで属性名指定。省略可
	@ModelAttribute(value = "test")
	private String getStr() {
		String testStr = "redirect test hello!";
		return testStr;
	}

Thymeleafを利用する際の記述に関しては後でまとめる。。。

ゆるふうぇいとゆるふうぇいと

例外ハンドリング

Spring MVCでの例外が発生する箇所と、その種別

No 発生個所 詳細
エラー① Servlet Filter Servlet Filterを利用した共通的な処理の中での例外。サーブレットコンテナのエラーページ機能を利用する
エラー② DispathcerServlet フロントコントローラが利用するフレームワーク処理内で発生する例外。Spring MVCが提供するHandlerExceptionResolverを利用し、エラー処理を実現する
エラー③ アプリケーション アプリケーション内で発生した例外。try~catchや、Spring MVCが提供するHandlerExceptionResolverを利用し、エラー処理を実現する
エラー④ View Viewでのクライアントへのレスポンスデータ作成時のエラー。サーブレットコンテナのエラーページ機能を利用する

サービレットコンテナのエラーページ機能

web--xmlに、エラー発生時のデフォルトエラーページの設定などを記述する。
Spring Bootではweb.xmlによる設定が不要、かつ現在ローカル環境はSpring bootのため一旦割愛する。。。

例外ハンドラの利用

Spring MVCでは、例外ハンドラとしてHandlerExceptionResolverインタフェース、およびその実装クラスをいくつか提供している。
デフォルトでは、以下の3つ。優先度が高い方から呼ばれ、途中でハンドリングが完了したら以降のハンドラは呼び出されない
つまり、ExceptionHandlerExceptionResolverでハンドリングされた場合、ResponseStatusExceptionResolverは呼ばれない

実装クラス 説明 優先度
ExceptionHandlerExceptionResolver @ExcedptionHandlerを指定したメソッドで、例外ハンドリング
ResponseStatusExceptionResolver @ResponseStatusを付与した例外クラスで例外ハンドリング
DefaultHandlerExceptionResolver SpringMVCのフロントコントローラの処理で発生する例外をハンドリング

@ExceptionHandlerの利用

@ExceptionHanlderを付与したメソッドをコントローラクラスに実装することで、独自の例外処理を実装できる
各コントローラ内で実装した場合は、そのコントローラ固有の例外処理となる。
アプリケーション全体で行う共通的な例外処理を実装したい場合は、ControllerAdviceクラスに実装するのがよさそう。

個別コントローラの場合
以下の例では、SampleController内で「SplException」という例外が発生した時、@ExceptionHandlerが付与されたhandleSplExceptionメソッドが呼ばれる

@Controller
public class SampleController  {

    // SampleController内でSplException(独自例外)が発生した場合にキックされる
    @ExceptionHandler(SplException.class)
	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // クライアントへステータスコード:500を返却
	public String handleSplException(Exception e) {
    	// 固有処理の実装
		return "error/systemError";
	}

    // 例外を発生するだけの意味のないメソッド
    @PostMapping(path = "/sampleException")
    public ModelAndView postSampleException(
    		ModelAndView mav
    		) throws SplException {
    	throw new SplException();
    }
}

ControllerAdviceクラスの場合
アプリケーション全体の共通処理として定義される。
各コントローラ内でハンドリングされずどこにも拾われなかったExceptionがここに行き着くイメージ


@ControllerAdvice
public class GlobalExceptionHandler extends DefaultHandlerExceptionResolver {
	
	@ExceptionHandler(Exception.class)
	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // クライアントへステータスコード:500を返却
	public String handleException(Exception e) {
		return "error/systemError";
	}	
}

DefaultHandlerExceptionResolverのかすたまいず(Spring Boot前提)

デフォルトの例外処理

(特に何も設定していない場合)何らかのエラーが起きた場合、デフォルトでtemplates/error.html を返却する仕組みになっている。
そのため、あらかじめerror.htmlを用意しておくことで、何かしらのエラーが発生した際はそのページが表示される。
また、AbstractErrorControllerが呼ばれ、DefaultErrorAttributesが持つ各属性がModelに格納されて渡されるため、例えば以下のようなerror.htmlを用意しておくだけで、modelからステータス等抽出できるし便利

<body>

	<div th:replace="~{common/header::header}"></div>
	<div class="container">
		<h1 th:text="${status} + ' ' + ${error}">System Error</h1>
		<p>エラーがおきました</p>
		<p>Sorry, an unexpected error has occurred.</p>
		<p>Please try again later or contact customer support.</p>
		<div th:text="'timestamp: ' + ${timestamp}"></div>
		<div th:text="'status: ' + ${status}"></div>
		<div th:text="'error: ' + ${error}"></div>
		<div th:text="'exception: ' + ${exception}"></div>
		<div th:text="'message: ' + ${message}"></div>
		<div th:text="'errors: ' + ${errors}"></div>
		<div th:text="'trace: ' + ${trace}"></div>
		<div th:text="'path: ' + ${path}"></div>
	</div>
	
</body>

実行例

DefaultErrorViewResolverのカスタマイズ

例外が発生すると、AbstractErrorControllerのresolveErrorViewメソッドにて、ErrorViewResolverの実装クラス(DefaultErrorViewResolver)のresolveErrorViewメソッドを呼び出し、例外に対応するViewを解決する仕組みとなっている。
例えば、発生した例外が4xx系エラーだと、error/400.htmlをViewとして解決する。

	protected ModelAndView resolveErrorView(HttpServletRequest request, HttpServletResponse response, HttpStatus status,
			Map<String, Object> model) {
		for (ErrorViewResolver resolver : this.errorViewResolvers) {
			ModelAndView modelAndView = resolver.resolveErrorView(request, status, model);
			if (modelAndView != null) {
				return modelAndView;
			}
		}
		return null;
	}

そこで、DefaultErrorViewResolverを拡張した独自のErrorViewResolverを実装する。
独自のErrorViewResolverがDIされて呼ばれるようになり、発生した例外に応じた独自処理を実現することができる。

@Component
public class SplViewResolver  extends DefaultErrorViewResolver{

	public SplViewResolver(ApplicationContext applicationContext, Resources resources) {
		super(applicationContext, resources);
	}
	
	@Override
	public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, Map<String, Object> model) {
		ModelAndView mav;

		// ステータスコードごと個別処理
		if(status.is4xxClientError()) {
			// 4xxエラー
			mav = new ModelAndView("/error/4xxError",model);
		} else if(status.is5xxServerError()){
			// 5xx以外
			mav = new ModelAndView("/error/5xxError",model);	
		}
		return mav;
	}
}

この方法のメリットとして、modelに格納されているDefaultErrorAttributesをそのまま利用できることである。ErrorControllerを独自実装することでも同様に各例外に応じたの独自実装を行えるが、DefaultErrorAttributesの取得処理も必要となり、デフォルトでやってくれることをわざわざ実装しなおす必要があり面倒。

ゆるふうぇいとゆるふうぇいと

RESTful Webサービス

Springを用いたRESTful Webサービス開発のためのあれこれ

HttpMessageConverter

Spring Webから提供されている部品
RESTful Webサービスにおいて、以下の変換処理を担う
・リクエストボディ⇔Javaオブジェクト の変換
・Javaオブジェクト⇔レスポンスボディ の変換

この辺参照
https://terasolunaorg.github.io/guideline/5.1.0.RELEASE/ja/ArchitectureInDetail/RestClient.html#httpmessageconverter

いくつかのHttpMessageConverter実装クラスがSpringから提供されている。
適宜選択し利用する

クラス名 詳細
ByteArrayHttpMessageConverter 「ボディ部(任意のメディアタイプ)⇔バイト配列 の変換を行う
StringHttpMessageConverter ボディ部(テキスト形式のメディアタイプ)⇔String の変換を行う
ResourceHttpMessageConverter ボディ部(任意のメディアタイプ)⇔ SpringframeworkのResource の変換を行う
AllEncompassingFormHttpMessageConverter ボディ部(フォーム形式またはマルチパート形式のメディアタイプ) ← SpringframeworkのMultiValueMap の変換を行う。これに関しては不可逆
MappingJackson2HttpMessageConverter ボディ部(Json形式のメディアタイプ))⇔JavaBeans の変換を行う
Jaxb2RootElementHttpMessageConverter ボディ部(XML形式のメディアタイプ)⇔JavaBeans の変換を行う

HiddenHttpMethodFilter

クライアントとアプリケーション間の、HTTPメソッドのギャップを調整する。
例えば、クライアント側からは何らかの都合(?)によりPOSTでリクエストしたいが、アプリケーション側はPUTで待ち受けているような状態の時に、クライアント側からのPOSTリクエストをPUTリクエスト扱いにし、処理をするといったことが可能となる。
具体的には、クライアント側がパラメータ「_method=put」といった形式で設定をすることで可能となる。

application.propetiesにて、下記設定を行う

spring.mvc.hiddenmethod.filter.enabled=true

Thymeleaf利用時は、以下のようにth:methodを利用することで、_methodを設定してくれるようになる

	<form th:action="@{/sample}" th:method="put">
		<input type="submit" value="PUTリクエスト" />	
	</form>

上記の場合、エンドポイント/sampleに対するPUTリクエストが発生するため、それ用のハンドラーメソッドをコントローラに用意しておけばよい

    @PutMapping(path = "/sample")
    public ModelAndView putSampleFree(
    		ModelAndView mav
    		) {
    	System.out.println("PUT リクエスト");}

注意:
HiddenHttpMethodFilterは現在では非推奨っぽいので、知識として知っておけばいいんじゃないかな程度
https://qiita.com/kazuhiro1982/items/b8b9965fddf9c5507517

HttpMessageConverterのカスタマイズ

デフォルトのHttpMessageConveterの設定を変更したいときや、独自の実装を適用したいときにカスタマイズする

    @Bean
    public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
    	return new MappingJackson2HttpMessageConverter(
    			Jackson2ObjectMapperBuilder.json().indentOutput(true).build());
    }
   
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    	// デフォルトで用意されているコンバーターのリストに、今回利用したいコンバーターを追加する
    	converters.add(0,mappingJackson2HttpMessageConverter());
    }

@RestControllerについて

REST API 用のコントローラ = ResctControllerと、画面を返すWebアプリケーション用のコントローラとの違いは、主に以下2点
・リクエストデータ、およびレスポンスデータはHttpMessageConveterを使用して取得・返却する
 ・リクエストボディを受け取るための引数に@RequestBodyを設定する
 ・レスポンスボディとして出力するデータを返却するメソッドに、@ResponseBodyを付与する。なお、クラスに全体に@RestControllerが付与されている場合は省略可
・入力チェック(バリデーションチェック)における例外ハンドリングは、共通的に行う
 ・BindingResultは使用できないので、ハンドリング処理を実装し、各例外に対応したレスポンスを返却する

Controllerクラスの作成

書籍情報に対するCRUDを行う

@RestControllerを付与したコントローラクラス、書籍情報を表すリソースクラス、およびビジネスロジックを担うサービスクラスをそれぞれ作成する。

@RestController
@RequestMapping("books") 
public class BooksRestController {
	
	@Autowired
	BookService bookService;
	
        // 書籍情報の取得
	@RequestMapping(path = "{bookId}",method = RequestMethod.GET)
	public BookResource getBook(@PathVariable String bookId) {
		
		Book book = bookService.find(bookId);
		BookResource resource = new BookResource();
		resource.setBookId(book.getBookId());
		resource.setName(book.getName());
		resource.setPublishedDate(book.getPublishedDate());	
		return resource;
	}

        // 書籍情報の新規作成
	@RequestMapping(method = RequestMethod.POST)
	public ResponseEntity<Void> createBook(
			@RequestBody BookResource newResource
			){
		Book newBook = new Book();
		newBook.setName(newResource.getName());
		newBook.setPublishedDate(newResource.getPublishedDate());
		
		Book createdBook = bookService.create(newBook);
		
		// 作成した書籍情報にアクセスするためのURL
                // Locationヘッダーで
		String resourceUrl = "http://localhost:8080/books/" + createdBook.getBookId();
		return ResponseEntity.created(URI.create(resourceUrl)).build();
	}

        // 書籍情報の更新
	@RequestMapping(path = "{bookId}",method = RequestMethod.PUT)
	@ResponseStatus(HttpStatus.NO_CONTENT) // クライアントに返却するコンテンツがないことを示す
	public void put(@PathVariable String bookId,@Validated @RequestBody BookResource resource) {
		
		Book book = new Book();
		book.setBookId(bookId);
		book.setName(resource.getBookId());
		book.setPublishedDate(resource.getPublishedDate());
		
		bookService.update(book);
	}
	
        // 書籍情報の削除 
	@RequestMapping(path = "{bookId}",method = RequestMethod.DELETE)
	@ResponseStatus(HttpStatus.NO_CONTENT) // クライアントに返却するコンテンツがないことを示す
	public void delete(@PathVariable String bookId) {		
		bookService.delete(bookId);
	}	

}

書籍情報を表すリソースクラス

下記クラスが持つ各フィールドが、jsonで返却する情報に相当する
LocalDateは、あらかじめフォーマットを指定しておく

// 書籍情報リソース
@Data
public class BookResource implements Serializable {
	private static final long serialVersionUID = 1L;
	private String bookId;
	private String name;
	
	// Jsonのフォーマットを指定
	@JsonFormat(pattern = "yyyy-MM-dd")
	private LocalDate publishedDate;

}

サービスクラス

@Service
public class BookService {
	
	// 仮のインメモリのリソース
	private final Map<String, Book> bookRepository = new ConcurrentHashMap<>();
	
	@PostConstruct
	public void loadDummyData() {
		Book book = new Book();
		book.setBookId("00000000-0000-0000-000000000");
		book.setName("ほげほげ殺人事件");
		book.setPublishedDate(LocalDate.of(2010,4,20));
		bookRepository.put(book.getBookId(), book);		
	}
	
        // 書籍情報の取得
	public Book find(String bookId) {
		Book book = bookRepository.get(bookId);
		return book;
	}

        // 書籍情報の作成
	public Book create(Book book) {
		String bookId = UUID.randomUUID().toString();
		book.setBookId(bookId);
		bookRepository.put(bookId,book);
		return book;
	}

        // 書籍情報の更新
	public Book update(Book book) {
		return bookRepository.replace(book.getBookId(), book);
	}
	
        // 書籍情報の削除
	public Book delete(String bookId) {
		return bookRepository.remove(bookId);
	}
}

例外クラスの実装

リソースの取得に失敗した場合に、404 Not Foundを返すように設定するため、例外クラスを実装する

@ResponseStatus(HttpStatus.NOT_FOUND)
public class BookResourceNotFoundException extends RuntimeException {
	public BookResourceNotFoundException(String bookId) {
		super("Book is not found bookId = " + bookId);
	}
}

RESTコントローラにて、目的の書籍情報が見つからなかった場合に上記例外を返すように修正する

…割愛…
		Book book = bookService.find(bookId);
		if(book == null) {
			throw new BookResourceNotFoundException(bookId);
		}
…割愛…
ゆるふうぇいとゆるふうぇいと

CORS

・CORS = Cross-Origin Resource Sharing
 AJAXを利用し、別ドメインのサーバーリソースへアクセスするための仕組み
 Spring4.2にて追加され、アプリケーション単位 or ControllerはHandlerメソッド単位で設定が可能

アプリケーション単位

Configを利用して設定


@Configuration
// Spring全体のコンフィグクラス
public class AppConfig implements WebMvcConfigurer{
    @Override
    public void addCorsMappings(CorsRegistry registry) {
    	// /api配下のリソースに対するCORSの許可
    	registry.addMapping("/api/**");
    }
}

コントローラ/メソッド単位

@CrossOriginを付与する
クラスに指定した場合、クラス内のすべてのHandlerメソッドに対しCORSが設定される

@CrossOrigin	
@RestController
@RequestMapping("books") 
public class BooksRestController {
	
	@CrossOrigin(maxAge = 900)
	@RequestMapping(path = "{bookId}",method = RequestMethod.GET)
	public BookResource getBook(@PathVariable String bookId) {}
}

@CrossOriginにはいくつかのオプションが用意されており、設定が可能

ゆるふうぇいとゆるふうぇいと

Jacksonを利用したJSONフォーマットの制御

Jacksonを利用し、JavaオブジェクトとJSONの変換を行うことができる。
Springは、Jackson用のサポートクラスとして、以下の2つが提供されている
・Jackson2ObjectMappterBuilder
https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.html
・Jackson2ObjectMapperFactoryBean
https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBean.html

前者はJava Configを利用した設定が行える。
後者は主にXMLを利用してBean定義を行う。
今回は、前者を利用することとする

Configクラスへの定義例

   @Bean
    public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
    	return new MappingJackson2HttpMessageConverter(
    			Jackson2ObjectMapperBuilder
    				.json()
    				// 以下、オプションを設定
    				.indentOutput(true) // JSONにインデントを設ける
    				.dateFormat(new StdDateFormat()) // ISO 8601の日次形式をサポートする
      				.build());
    }

コンバーターのビルド時に、Builderが持つ様々なオプションを利用できる。
下記参照
https://spring.pleiades.io/spring-framework/docs/current/javadoc-api/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.html

ゆるふうぇいとゆるふうぇいと

REST APIにおける例外ハンドリング

REST APIにおいて、何かしらの例外が発生した場合は、その例外情報をリソース形式(JSON等)でレスポンスとして返すのが通常。
例:

{
  "message":"Not Found",
  "documentation_url":"http://hogehoge"

例外ハンドラの実装

事前準備として、エラー情報を保持するクラスを用意しておく

public class ApiError implements Serializable {
	private static final long serialVersionUID = 1L;
	private String message;
	
	public String getMessage() {
		return message;
	}

	public void setMessage(String message) {
		this.message = message;
	}

	public String getDocumentationUrl() {
		return documentationUrl;
	}

	public void setDocumentationUrl(String documentationUrl) {
		this.documentationUrl = documentationUrl;
	}

	@JsonProperty("documentation_url") // JSONでの属性名
	private String documentationUrl;

}

例外ハンドラクラスの実装例

ResponseEntityExceptionHandlerを継承し、実装することで例外ハンドラクラスを独自実装することができ、レスポンスを色々といじれる。
イメージとしては、JSONとして詰め込みたい属性を持ったオブジェクトを作成し、最終的にResponseEntityExceptionHandlerのhandleExceptionInternalに渡すことで、そのオブジェクトをJSONに組み替えて返却してくれる。(Jsonへのコンバーターを利用しているため)

// ResponseEntityExceptionHandlerを継承したクラスにRestControllerAdviceを付与すると、
// SpringMVC上で発生するすべての例外をハンドリングできるようになる
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
	
	// レスポンスボディの設定
	private ApiError createApiError(Exception ex) {
		ApiError apiError = new ApiError();
		apiError.setMessage(ex.getMessage());
		apiError.setDocumentationUrl("http://hogehoge");
		return apiError;
	}
	
	// レスポンスボディへのエラー情報出力処理
	@Override
	protected ResponseEntity<Object> handleExceptionInternal(
			Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
		
		final ApiError apiError = createApiError(ex);
		return super.handleExceptionInternal(ex, apiError, headers, statusCode, request);
		
	}
}

実行例
日付フォーマットの属性に対し、フォーマットに即していないデータが送られてきたため、例外が発生し、以下のエラーレスポンスが返却される

{
  "message" : "JSON parse error: Cannot deserialize value of type `java.time.LocalDate` from String \"hoge\": Failed to deserialize java.time.LocalDate: (java.time.format.DateTimeParseException) Text 'hoge' could not be parsed at index 0",
  "documentation_url" : "http://hogehoge"
}

上記のままだと、messageの内容がシステムチックすぎたり、本来返却したくない情報までのってしまう可能性があるため、実際に返却するメッセージは別に用意しておく等した方がいい。
例えば、xxExceptionの場合は〇〇を返す…といったマッピングをあらかじめ用意しておく等

カスタム例外クラスのハンドリングを追加

ResponseEntityExceptionHandlerに対し、独自に作成した例外クラスを例外ハンドリングの対象とするよう設定を行う。

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
…割愛
	// 独自エクセプション: BookResourceNotFoundException に対しハンドリングするよう設定
	@ExceptionHandler
	public ResponseEntity<Object> handleBookNotFoundException(
			BookResourceNotFoundException ex, // カスタム例外クラス
			WebRequest request
			){	
		return handleExceptionInternal(ex, null, null, HttpStatus.NOT_FOUND, request);		
	}
}

システム例外のハンドリング

システム例外のメッセージをそのまま返却するのはよろしくないため、適当な固定文言を返却するがベター


        // システム例外のハンドリング
	@ExceptionHandler
	public ResponseEntity<Object> handleSystemException(
			Exception ex, // システム例外
			WebRequest request
			){	
		ApiError apiError = createApiError(ex,"System error is occured"); // 固定文言
		return super.handleExceptionInternal(ex, apiError, null,HttpStatus.INTERNAL_SERVER_ERROR, request);
	}

	private ApiError createApiError(Exception ex,String defaultMessage) {
		ApiError apiError = new ApiError();
		apiError.setMessage(defaultMessage);
		apiError.setDocumentationUrl("http://hogehoge");
		return apiError;
	}	

入力チェックの例外ハンドリング

REST APIにて入力チェックエラー(バリデーションエラー)が発生した場合、以下の例外がスローされる
・BindException
・MethodArgumentNotValidException
(MethodArgumentNotValidExceptionはBindExceptionを継承している)

今回は、MethodArgumentNotValidExceptionに対する例外ハンドリングを実装する
(BindExceptionに関しても同様の方法で対応可)

バリデーションエラーの例外メッセージとして、以下の情報をJSONで返却するようにする
・例外が発生した項目
・例外内容

バリデーションエラー時、それらの情報を含んだJSONを返却するようにする

エラー情報クラスの作成

前述の情報を保持するクラスを作成する
後々、このクラスから生成されたインスタンスをJSONに組み込む

// エラーの詳細情報を示す
public class Detail implements Serializable {
	private static final long serialVersionUID = 1L;
	private String target;
	private String message;
	
	public Detail(String target,String message) {
		this.target = target;
		this.message = message;
	}
…getter、setterは割愛
}

エラーの詳細情報をJSONに追加

エラー詳細情報を示す項目(details)に、@JsonInclude(JsonInclude.Include.NON_EMPTY) を付与することで、エラーが発生しなかった場合(nullの場合)は項目ごと返却しないように設定する

public class ApiError implements Serializable {
	private static final long serialVersionUID = 1L;
	private String message;
	@JsonProperty("documentation_url") // JSONでの属性名
	private String documentationUrl;
	// エラー詳細情報
	@JsonInclude(JsonInclude.Include.NON_EMPTY) // 値がnullの場合、フィールドごと返却しない。
	private List<Detail> details = new ArrayList<>();
…getter、setterは割愛	

バリデーションエラー用ハンドラー(handleMethodArgumentNotValid)をOverride

handleMethodArgumentNotValidをOverrideすることで、
バリデーションチェック時のエラーレスポンスを独自実装できる。
バリデーションエラーの情報は、引数で渡されるMethodArgumentNotValidException 型の変数に、bindingResultとして格納されているため、そこから抽出する。

抽出したエラー詳細情報を利用し、JSONの作成が終わったら、ResponseEntityExceptionHandlerのhandleExceptionInternalにそれらを渡しレスポンスデータを生成してもらう。
以下、実装例

	@Override
	protected ResponseEntity<Object> handleMethodArgumentNotValid(
			MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
		ApiError apiError = createApiError(ex,ex.getMessage());
		
		// オブジェクトに紐づけられるエラーオブジェクト
		ex.getBindingResult().getGlobalErrors().stream()
			.forEach(e -> apiError.addDetail(e.getObjectName(),getMessage(e, request)));
		
		// フィールドに紐づけられるエラーオブジェクト
		ex.getBindingResult().getFieldErrors().stream()
			.forEach(e -> apiError.addDetail(e.getField(), getMessage(e, request)));
		
		return super.handleExceptionInternal(ex, apiError, headers, status, request);
	}

実行例

{
    "message": "Validation Error!! Request value is invalid",
    "details": [
        {
            "target": "name",
            "message": "nameは必須入力項目です"
        }
    ],
    "documentation_url": "http://hogehoge"
}
ゆるふうぇいとゆるふうぇいと

RESTクライアントの実装

RestTemplate

RestTemplateを利用しRest API を呼ぶ、というのが標準的(Spring 3.0以降)

RestTemplateのコンポーネント

RestTemplateで用意されているクラスやインタフェースの代表

名称 役割
HttpMessageConveter HTTPのボディ部とJavaBeansの相互変換を行う
ClientHttpRequestFactory リクエストを送信するオブジェクトを生成する
ClientHttpRequestInterceptor HTTP通信前後の共通処理を組み込む
ResponseErrorHandler エラーレスポンスの判定、およびエラー時の処理を行う

RestTemplateのセットアップ

Configクラスにて、DIコンテナに登録することで使用可能となる

    @Bean
    RestTemplate restTemplate() {
    	return new RestTemplate();
    }

RestTemplateを利用する際は、RestOperationsインタフェースとしてDIする。

	@Autowired
	RestOperations restOperations;

リソースの取得

REST APIを利用し、リソースを取得する場合以下のいずれかのメソッドを利用する
・getForObject
・getForEntity
・exchange
・execute

RestTemplateにおけるgetForObjectを利用した実行例は以下の通り
以下の例では、リソース:BookResourceを、getForObjectメソッドを利用してAPIをコールし、取得している。

// リソースの取得
BookResource resource = restOperations.getForObject("http://localhost:8080/books/00000000-0000-0000-000000000",BookResource.class);	

リソースの作成

REST APIを利用し、リソースを作成する場合以下のいずれかのメソッドを利用する
・postForLocation
・postForObject
・postForEntity
・ecchange
・execute

今回はひとまずpostForLocation利用する
postForLocationを用いてリソースを生成すると、そのリソースにアクセスするためのURIが返却されてくる

		BookResource resource = new BookResource();
		resource.setName("Spring入門");
		resource.setPublishedDate(LocalDate.of(2020, 10, 2));
		
		// 生成に成功した場合、リソースへのアクセスするためのURIが返却されてくる
		URI resourceURI = restOperations.postForLocation("http://localhost:8080/books", resource);
		System.out.println(resourceURI);

結果

http://localhost:8080/books/8062dad8-d1b4-485e-a737-1b8eabaa5f49

リクエストヘッダーの設定

リクエストヘッダーを設定する場合は、RequestEntityのビルダーパターンメソッドを利用するのがよい

以下、実装例

		BookResource resource = new BookResource();
		resource.setName("Spring入門");
		resource.setPublishedDate(LocalDate.of(2020, 10, 2));

		// リクエストヘッダーを設定したうえで、リクエストボディも生成する
		RequestEntity<BookResource> requestEntity = 
				RequestEntity
                                         // リクエストヘッダーの設定
					.post(URI.create("http://localhost:8080/books"))
					.contentType(MediaType.APPLICATION_JSON)
					.header("X-Track-Id", UUID.randomUUID().toString())
                                        // リクエストボディを設定
					.body(resource);
		System.out.println(requestEntity);
		
		// exchangeメソッドを利用し、REST APIをコール
		// 受け取るレスポンスの型を指定している
		ResponseEntity<Void> responseEntity = 
				restOperations.exchange(requestEntity, Void.class);
		System.out.println(responseEntity);

実行結果、以下のようにリクエストヘッダー+ボディが生成される

<POST http://localhost:8080/books,com.splatoon.analyze.api.resource.BookResource@33b1917f,[Content-Type:"application/json", X-Track-Id:"2d0d2bf5-ad09-462a-a265-b50343be1462"]>

HTTPステータスとレスポンスヘッダーの取得

HTTPステータスやレスポンスヘッダーを取得したい場合は、ResponseEntityを取得できるメソッドを使用する。
xxxForEntityやexchangeがそれに該当する。

以下は、postForEntityの実装例

		// リクエストヘッダーを設定したうえで、リクエストボディも生成する
		RequestEntity<BookResource> requestEntity = 
				RequestEntity
					.post(URI.create("http://localhost:8080/books"))
					.contentType(MediaType.APPLICATION_JSON)
					.header("X-Track-Id", UUID.randomUUID().toString())
					.body(resource);
		System.out.println(requestEntity);
		
		// exchangeメソッドを利用し、REST APIをコール
		// 受け取るレスポンスの型を指定している
		ResponseEntity<Void> responseEntity = 
				restOperations.postForEntity("http://localhost:8080/books",requestEntity, Void.class);
		System.out.println("HTTP STATUS:" + responseEntity.getStatusCode());
		System.out.println("HEADER:" + responseEntity.getHeaders());

実行結果

HTTP STATUS:201 CREATED
HEADER:[Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Location:"http://localhost:8080/books/c55a43a0-dfb5-474a-9808-6c960bb6125b", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY", Content-Length:"0", Date:"Sat, 15 Apr 2023 15:48:38 GMT", Keep-Alive:"timeout=60", Connection:"keep-alive"]

例外ハンドリング

RestTemplateは、APIがエラー系のレスポンスを返却してきた場合、ステータスコードに応じた例外が発生する

ステータスコード 例外
クライアントエラー系(4xx) HttpClientErrorException
サーバーエラー系(5xx) HttpServerErrorException
ユーザー定義のステータスコード UnknownHttpStatusCodeException

これらはすべてのRuntimeExceptionであるため、必ずしも補足する必要がない。
必要に応じて、try~catchで例外処理を行う必要がある

タイムアウトの設定

通信のタイムアウト設定を行う
JavaConfig、またはXMLで行う。
以下は、JavaConfigでの実装例

    @Bean
    RestTemplate restTemplate() {
    	SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
    	requestFactory.setConnectTimeout(5000);
    	requestFactory.setReadTimeout(3000);
    	RestTemplate restTemplate = new RestTemplate(requestFactory);
    	return restTemplate;
    }
ゆるふうぇいとゆるふうぇいと

HTTPセッションの利用

Spring MVCにおけるHTTPセッションの利用についてまとめる
Spring MVCにおいて、HTTPセッションを利用するのに、以下の3つの方法がある

利用方法 詳細
セッション属性(@SessionAttributes)の利用 Modelに追加したオブジェクトをHTTPセッション内で管理する
セッションスコープのBeanの利用 HTTPセッションで管理したいオブジェクトを、セッションスコープのBeanとしてDIコンテナに登録して管理数r
HttpSessionのAPIの利用 HttpSessionのAPI(setAttribute、getAttribute、removeAttribute等)を直接利用する

基本的には、HttpSessionのAPIを直接利用しない方法がよい。

セッション属性について

@SessionAttributesは、1つのController内で扱う複数のリクエスト間のデータを共有するのに有効
例えば、入力~入力内容確認~完了といったな画面遷移に対し、ハンドラーメソッドは一つのコントローラで内にすべて実装することを考える。
入力画面で入力された内容(formオブジェクト)をセッションに保持しておき、すべての画面で同一オブジェクトを参照することが可能となる。
以下、雑なイメージ・・

具体的な実装例(コントローラ)

package com.splatoon.analyze.controller.top;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.web.servlet.ModelAndView;

import com.splatoon.analyze.model.form.SampleSessionForm;

// セッションテスト用のサンプルコントローラクラス
@Controller
@RequestMapping("/sessionTest")
@SessionAttributes(types = SampleSessionForm.class) // セッションでの管理対象オブジェクトの指定
public class SessionTestController {
	
	@GetMapping("/input")
	public ModelAndView getFirstPage(
			Model model, // 管理対象オブジェクトはmodelに格納される
			ModelAndView mav
			) {	
		// セッションに保持している場合は、それをViewで利用する
		if(model.getAttribute("sampleSessionForm") == null) {
			mav.addObject("sampleSessionForm",new SampleSessionForm());
		}
		mav.setViewName("/session/input");
		return mav;
	}
	
	@PostMapping("/confirm")
	public ModelAndView postFirstPage(
			ModelAndView mav,
			@Validated @ModelAttribute SampleSessionForm sampleSessionForm,
			BindingResult bindingResult
			) {
		
		// 入力内容確認画面へのリダイレクト
		mav.setViewName("redirect:/sessionTest/confirm");
		return mav;
	}
	
	@GetMapping("/confirm")
	public ModelAndView getSecondPage(
			// セッションから抜き出す
			@ModelAttribute SampleSessionForm sampleSessionForm,
			ModelAndView mav
			) {	
		mav.setViewName("/session/confirm");
		return mav;
	}
	
	@PostMapping("/save")
	public ModelAndView save(
			ModelAndView mav,
			@Validated @ModelAttribute SampleSessionForm sampleSessionForm,
			BindingResult bindingResult
			) {
		
		// 完了画面へのリダイレクト
		mav.setViewName("redirect:/sessionTest/complete");
		return mav;
	}
	
	@GetMapping("/complete")
	public ModelAndView complete(
			@ModelAttribute SampleSessionForm sampleSessionForm,
			ModelAndView mav,
			SessionStatus sessionStatus
			) {	
		
		// セッションから削除
		sessionStatus.setComplete();		
		mav.setViewName("/session/complete");
		return mav;
	}	
	
}

セッションに格納されたオブジェクトは、そのままModelに格納される仕組みになっているため、View側では${sampleSessionForm}とすれば利用できる。

セッションから削除する際は、SessionStatusのsetCompleteメソッドを呼び出すことで可能。
ただし、このメソッドはあくまでも「処理が完了した」ことを示すだけであり、実際に削除するわけではない。
実際にセッションからオブジェクトが削除されるのは、ハンドラーメソッドの処理が完了したタイミングとなる。
また、Modelの中にはオブジェクトは残ったままなので、そのままではView側からは参照が可能な状態となる。

セッションスコープBean

複数の画面遷移を、複数のコントローラで実現するときに有効となる。
(@SessionAttributesは単一のコントローラだった)

セッションスコープBeanの定義

セッションスコープで管理したいBeanを定義する
1.@Scopeアノテーションを付与する
2. proxyMode属性として、 ScopedProxyMode.TARGET_CLASSを指定する。

@Component
@Scope(value = "session",proxyMode = ScopedProxyMode.TARGET_CLASS)
@Data
public class Item {
	private String name;
	private int count;
}

後は、各コントローラ側@Autowiredによりインジェクションを行うだけ

@Controller
@RequestMapping("/sessionBean")
public class InputController {
	
	// セッションスコープのBeanをインジェクション
	@Autowired
	Item item;
	
	@GetMapping("/input")
	public ModelAndView getFirstPage(
			Model model, // 管理対象オブジェクトはmodelに格納される
			ModelAndView mav
			) {	
		
		item.setName("エクスカリバー");
		item.setCount(3);
		mav.addObject(item);
		mav.setViewName("/sessionBean/input");
		return mav;
	}	

}


@Controller
@RequestMapping("/sessionBean")
public class ConfirmController {
	
	// セッションスコープのBeanをインジェクション
	@Autowired
	Item item;	

	@GetMapping("/confirm")
	public ModelAndView getFirstPage(
			Model model, // 管理対象オブジェクトはmodelに格納される
			ModelAndView mav
			) {	
		mav.setViewName("/sessionBean/confirm");
		return mav;
	}	
}

セッションから削除する場合は、HttpSessionを利用する。
属性名は"scopedTarget."+ Bean名 となる。
例えばBean名がitemの場合、以下となる

		HttpSession session = request.getSession(false);
		session.removeAttribute("scopedTarget.item");
ゆるふうぇいとゆるふうぇいと

ファイルアップロード

SpringMVCでは、ファイルアップロードに関して以下の2つの手法がある

手法 概要
Servlet標準のアップロード機能 Spring Webが提供するコンポーネントを利用する。Servletのバージョンが3.0以上のアプリケーションサーバで利用可
Apache Commons FileUploadのアップロード機能 ファイルアップロード用のライブラリ:Apache Commons FileUpload、およびSpring Webが提供するコンポーネントを利用する。Servletのバージョンが3.0未満の場合はこちらになる

基本はServlet標準のアップロードの方法を利用する前提とする

セットアップ

application.propetiesに各設定が可能。設定イメージ

# 合計ファイルサイズの上限
spring.servlet.multipart.max-file-size=128KB 

# 合計リクエストサイズの上限
spring.servlet.multipart.max-request-size=128KB

設定可能なものは以下の通り
https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/autoconfigure/web/servlet/MultipartProperties.html

設定項目 詳細
location アップロードファイルの保存先。デフォルトだと一時ディレクトリが使用される
max-file-size アップロードファイルの最大サイズ
max-request-size リクエストに許可される最大サイズ
file-size-threshold ファイルがディスクに書き込まれるサイズの閾値

アップロードデータ用フォーム

アップロードデータ用のフォームクラスを用意する。
アップロードされたファイルのデータは、MultipartFile型のプロパティにバインドされる

@Data
public class FileUploadForm implements Serializable {
	private MultipartFile file;
}

View

ViewはHTMLフォームから"multipart/form-data"の形式でファイルをアップロードし、リクエストを送信する

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>セッションテスト</title>
<head th:replace="~{common/header::meta_head('TOP')}">
</head>
<body>
	<!-- 共通ヘッダー -->
	<div th:replace="~{common/header::header}"></div>

	<form th:action="@{/file/upload}" method="post" enctype="multipart/form-data" th:object="${fileUploadForm}">
	    ファイル:
		<input type="file" th:path="file" th:field="*{file}" >
		<input type="submit" value="アップロード" />
	</form>
</body>
</html>

コントローラ

Viewから送信されたファイルデータを、事前に用意したアップロードデータ用にフォームオブジェクトから取得する。

	@PostMapping("/upload")
	public ModelAndView upload(
			ModelAndView mav,
			FileUploadForm form // アップロードデータ用フォーム
			) {
		MultipartFile file = form.getFile();
		String contentType = file.getContentType();
		String parameterName = file.getName();
		String originalFilename = file.getOriginalFilename();
		try(BufferedReader br = new BufferedReader(new InputStreamReader(file.getInputStream()))){
			String txt;
			while((txt = br.readLine()) != null) {
				System.out.println(txt);				
			}
			
		} catch (IOException e) {
			// TODO 自動生成された catch ブロック
			e.printStackTrace();
		}
		
		mav.setViewName("redirect:/file");
		return mav;
	}
ゆるふうぇいとゆるふうぇいと

非同期リクエストの実装

非同期処理の所はまだ全体的に理解不足・・・一旦の内容
ひとまずキーとなるのは以下
・非同期処理を有効化するため@EnableAsyncを付与すること
・非同期処理で実行したいメソッドに対し@Asyncを付与すること
・CompletableFutureを利用し@Asyncが付与されたメソッドを非同期で呼び出すこと
・スレッドプールの設定をConfigに定義すること

・・・

非同期リクエストには、大きく分けて以下の2種類が存在する
・非同期実行の処理が終了してからHTTPレスポンスを開始する
・非同期実行の処理中にHTTPレスポンスを開始する(Push型)
 https://qiita.com/kazuki43zoo/items/53b79fe91c41cc5c2e59

CompletableFutureを使用した非同期処理

/createに対するポストリクエストをトリガに、非同期でファイル作成処理を実行する。
ファイル作成処理の完了を待って、レスポンスを返すため、クライアントからは同期処理を見わけはつかない。

@Asyncアノテーションを付与したメソッドの中に、非同期で実行したい処理を記述する。
戻り値として、CompletableFutureを返却する

	// CompletableFutureを使用した非同期処理
	@PostMapping("/create")
	public ModelAndView create(
			ModelAndView mav,
			String fileName
			) throws IOException {
		
		 System.out.println(fileName);
		 // 現在時刻の取得
		 DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd_hh_mm_ss_SSS");
		 Path path = Paths.get("D:/eclipse_2022/temp",fileName + LocalDateTime.now().format(dtf) + ".txt");
		 create(path);
		 
		 mav.setViewName("redirect:/file"); 
		 return  mav;
	}

	@Async //
	public CompletableFuture<String> create(Path path) throws IOException{
		System.out.println("**ファイル作成処理起動**");
		createFileService.createFile(path);
		System.out.println("***起動完了***");

		
		return CompletableFuture.completedFuture("redirect:/file");
	}

SseEmitterを使用したPush型の非同期処理

/greetingに対しPOSTリクエストを実行した結果、1秒おきにレスポンスがあり、内容が表示される。

@PostMapping("/greeting")
	public ResponseBodyEmitter greeting() throws IOException, InterruptedException {
		ResponseBodyEmitter  emitter = new ResponseBodyEmitter();

		// 非同期処理の呼び出し
		greetingMessageSender.send(emitter); 
		return emitter;
	}@Component 
public class GreetingMessageSender {
	
	// 非同期処理
	@Async
	public void send(ResponseBodyEmitter emitter) throws IOException, InterruptedException {

		emitter.send("Good Morning!");
		TimeUnit.SECONDS.sleep(1);

		emitter.send("Hello!");
		TimeUnit.SECONDS.sleep(1);
		
		emitter.send("Good Night!");
		TimeUnit.SECONDS.sleep(1);
		
		emitter.complete();
	}
}
	
ゆるふうぇいとゆるふうぇいと

共通処理の実装

サーブレットフィルタの利用

以下参照しつつまとめていく
https://qiita.com/kazuki43zoo/items/757b557c05f548c6c5db
以下の画像がとても分かりやすい(Qiitaリンク先)
https://camo.qiitausercontent.com/7f6780f877a4739d0f7b1e4e3f68c029ea88c8dc/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3131373331332f63623963666463362d646339662d343936612d363030322d3331643133396532313338332e706e67

フィルタの実装

Springが提供するインタフェースFilterを実装したクラス(Bean)を作成することで、Filterとして登録され機能する。
例えば以下の例では、各リクエストの前後で特定のメッセージが出力されるようにFilterを設定している

// サーブレットフィルタを利用した共通処理
@Component
public class ClientInfoMdcPutFilter implements Filter {
	
	@Override
	 public void init(FilterConfig filterConfig) throws ServletException {
		
	}	

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		
		// リクエスト前	
		System.out.println("****フィルターテスト開始***");
		
		// 後続へ
		chain.doFilter(request, response);

		// リクエスト後
		System.out.println("****フィルターテスト終了***");	
	}
	
	@Override
	public void destroy() {		
	}
}

複数フィルタの実装・優先度設定

複数フィルタを作成した場合、フィルタに優先度をつけて実行順を制御することが可能
優先度をつけた場合、処理の流れとしては以下のようになる。
フィルタ(優先度:高)⇔フィルタ(優先度低)⇔コントローラ⇔…

具体的な実行結果サンプル

****フィルターテスト開始:優先度高***
****フィルターテスト開始:優先度低***
(コントローラ以降の処理)
****フィルターテスト終了:優先度低***
****フィルターテスト終了:優先度高***

実装方法として、主に以下の二通りの方法がある
1.Filterに@Orderを付与し属性値で優先度を設定する
2.Configにて各Filterごとの優先度を設定する

各実装方法は以下にまとめる

Filterに@Orderを付与した場合

各Filterに@Orderを付与するだけなので楽。
Filterの量が増えてくると管理が面倒かもしれない

@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // Filter優先度の設定。優先度が高い方が先に適用される
public class CustomHighestFilter implements Filter {}

@Component
@Order(Ordered.LOWEST_PRECEDENCE) // Filter優先度の設定。優先度が高い方が先に適用される
public class CustomLowestFilter implements Filter {}

Configにて各Filterの設定をした場合

FilterRegistrationBeanのsetOrderメソッドを利用し定義する。
Filterごとにメソッドを実装する必要があるため、やや面倒だが同一のConfigファイル上にすべてのFilterの設定を管理できるため、管理は楽。

    // Filterの設定
    @Bean
    public FilterRegistrationBean<Filter> configClientInfoMdcPutFilter() {
    	FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<Filter>( new CustomHighestFilter());
    	// Filter優先度の設定。優先度が高い方が先に適用される
    	registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
    	
    	return registrationBean;    	
    }
    
    @Bean
    public FilterRegistrationBean<Filter> configClientInfoMdcPutFilter2() {
    	FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<Filter>( new CustomLowestFilter());
    	// Filter優先度の設定。優先度が高い方が先に適用される
    	registrationBean.setOrder(Ordered.LOWEST_PRECEDENCE);
    	
    	return registrationBean;
    }    

HandleInterceptor

Controllerでハンドリングする処理に対し、共通処理を実装したい場合に利用する。
HandlerInterceptorには以下の3つのメソッドが用意されており、共通処理を呼び出したいタイミングに応じて使い分けが可能

メソッド名 詳細
preHandle Controllerのハンドラーメソッドの前に実行する
postHandle Controllerのハンドラーメソッドの後に実行する。ただし、ハンドラーメソッドで例外が発生した場合は呼び出されない
afterCompletion Controllerのハンドラーメソッドの後に実行する。これはハンドラーメソッドで例外が発生しても呼び出される

実装に際し、以下の手順を踏む。
1.HandleInterceptorインタフェースを実装したクラスを作成する
2. 実装したクラスを、configにてinterceptorに追加する

実装例
HandleInterceptor実装クラス

public class SuccessLoggingInterceptor implements HandlerInterceptor {
	private static final Logger logger = LoggerFactory.getLogger(SuccessLoggingInterceptor.class);

	@Override
	public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
			if (logger.isInfoEnabled() && !(handler instanceof ResourceHttpRequestHandler)) {
				HandlerMethod handlerMethod = (HandlerMethod) handler;
				Method method = handlerMethod.getMethod();
				logger.info("[SCCESS CONTROLLER] {}.{}", method.getDeclaringClass().getSimpleName(), method.getName());

			}
	}
}

config

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(new SuccessLoggingInterceptor())
		.addPathPatterns("/**") // Interceptorを適用するパスパターン
		.excludePathPatterns("/resources/**"); // Interceptorを除外するパスパターン
	}
ゆるふうぇいとゆるふうぇいと

HandlerMethodArgumentResolver

Spring MVCのデフォルトでサポートされていないオブジェクトをControllerのHandlerメソッドの引数に渡したい場合に、HnadlerMethodArgumentResolverインタフェースの実装クラスを作成する。
(使いどころがあまりピンとこないが…)

例えば、以下のようなクラス型の引数を設定したいとする

@Data
public class CommonRequestData {
	private String userAgent;
	private String sessionId;
}

HandlerMEthodArgumentResolverの実装例

public class CommonRequestDataMethodArgumentResolver implements HandlerMethodArgumentResolver{

	// 処理対象とする引数の型を判定する
	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		// TODO 自動生成されたメソッド・スタブ
		return CommonRequestData.class.isAssignableFrom(parameter.getParameterType());
	}

	@Override
	public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
		
		HttpSession session = webRequest.getNativeRequest(HttpServletRequest.class).getSession(false);
		
		// リクエストからユーザーエージェントを取得
		String userAgent = webRequest.getHeader(HttpHeaders.USER_AGENT);
		
		// セッションIDを取得
		String sessionId = Optional.ofNullable(session).map(HttpSession::getId).orElse(null);
		
		CommonRequestData commonRequestData = new CommonRequestData();
		commonRequestData.setUserAgent(userAgent);
		commonRequestData.setSessionId(sessionId);
		return commonRequestData;
	}
}

Configへの設定

	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(new CommonRequestDataMethodArgumentResolver());
	}

ハンドラーメソッドへの実装例

	@GetMapping(path = URL_INDEX)
	public ModelAndView index(
			ModelAndView mav,
			CommonRequestData commonRequestData // 独自引数
			) {}		
ゆるふうぇいとゆるふうぇいと

国際化

ロケールに応じた実装を行う方法についてまとめる

ロケールの利用

ロケールの取得方法として、以下の二つがある
1.ハンドラーメソッドの引数としてLocaleを指定する

	@GetMapping(path = URL_INDEX,headers = "Accept-Language=ja",produces = "application/signed-exchange")
	public ModelAndView index(
			Locale locale
){}

2.RequestContextUtils.getLocale(HttpServletRequest)を用いる

RequestContextUtils.getLocale(request)

取得したロケール情報を利用し、ロケールに応じた切り替え処理を実装する。
また、「messages_{locale}.properties」という形式でmessages.propretiesを作成することで、ローケルに応じて利用するmessages.propetiesを切り替えることができる。

ゆるふうぇいとゆるふうぇいと

Spring Test

DIコンテナで管理される各Beanを対象に行うテストについてまとめる。
テスティングフレームワークとして、JUnitを使うことを前提とする。

注意!
@Testアノテーションのインポート先が「org.junit.jupiter.api.Test」だとエラーがでる。
org.junit.Testをインポートすること

モック化を利用したテスト(単体テスト)

Mockitoを用いることで、コンポーネントをモック化しテストすることが可能
手順としては、以下のイメージ
1.@RunwithアノテーションでMockitoJUnitRunner.StrictStubs.classを指定する
2. モックの注入先となるテスト対象クラスに対し、@InjectMocksアノテーションを付与する
3. モック化するコンポーネントに@Mockアノテーションを付与する
4. モック化したコンポーネントをスタブとして働かせるため、挙動を設定する

より詳細なMockitoの説明は以下を参照
https://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/UsageOfLibraryForTest.html#mockito

以下はサンプル
テスト対象クラス:MessageService モックの注入先。@InjectMocksアノテーションを付ける
モック化対象:MessageSource モック化対象のコンポーネント。MessageService の中で利用されている。MessageService自体はテスト対象ではないのでモック化する

テスト対象クラス

@Service
public class MessageService {
	
	@Autowired
	MessageSource messageSource;
	
	public String getMessaget(String code) {
		return messageSource.getMessage(code, null, Locale.getDefault());
	}
}

テストクラス

@RunWith(MockitoJUnitRunner.StrictStubs.class) // モック化したコンポーネントをインジェクションできるようになる
public class MessageServiceTest {
	
	@InjectMocks // モックの注入先となる
	MessageService service;
	
	@Mock // MessageSourceをモック化
	MessageSource mockMessageSource;
	
	
	@Test
	public void testGetMessageByCode() {
                // モックの設定
		// "greeting"という文字列が指定された際に、Helloを返す
		doReturn("Hello")
		.when(mockMessageSource)
		.getMessage("greeting", null, Locale.getDefault());
		
		// テスト
		String actualMessage = service.getMessaget("greeting");
		assertThat(actualMessage,is("Hello"));
		
	}
}

結合テスト

jUnitを利用した結合テスト
1.@SpringBootTest アノテーションをテストクラスに付与する
2. RunWithアノテーションでSpringRunner.classを指定する

@SpringBootTest
@RunWith(SpringRunner.class)
public class MessageServiceIntegrationTest {
		
	@Autowired
	MessageService service;
	
	@Test
	public void testGetMessageByCode() {
		String actualMessage = service.getMessaget("greeting");
		System.out.println(actualMessage);
		assertThat(actualMessage,is("Hello"));
	}
}

テスト用のプロパティの切り替え

@TestPropertySourceを利用することで、テスト時のプロパティファイルのパスを指定できる

@TestPropertySource(locations = {"/test_application.properties"})
public abstract class AbstractTestController {}

メモ

src/test/resources配下にapplicationContext.xmlがあると、それが優先して読み込まれる。

DBアクセスのテスト

jUnitにおいて、@Transactionalを付与するとトランザクションを張ることができる
テスト中に発生したDBへの処理は、テストメソッドが終了したら、自動的にロールバックされる。
ただし、呼び出し先のメソッドがREQUIRES_NEWなどで常に新規のトランを張っている場合はコミットされてしまうので注意

以下はサンプル
1.テストデータ用エンティティを準備
2. 1で作成したエンティティを、テスト対象サービスクラスを経由しDBへ保存
3. 想定通り保存されたかどうか確認するため、対象テーブルからSelectで抽出
4. 結果比較

完了後、特に意識せずとも勝手にロールバックされる。

	@Test
	@Transactional
	public void saveWeapon() {
		WeaponMasterEntity entity = new WeaponMasterEntity();
		entity.setGenre("sample genre");
		entity.setMain("sample main");
		entity.setSub("sample sub");
		entity.setSpecial("sample special");
		try {
			weaponMasterService.saveRecord(entity);
		} catch (Exception e) {
			// 失敗
			e.printStackTrace();
			fail();
		}
		
		List<WeaponMasterDto> dto = weaponMasterService.getWeaponMaster();
		dto.sort(new Comparator<WeaponMasterDto>(								
				) {
					@Override
					public int compare(WeaponMasterDto o1, WeaponMasterDto o2) {
						return o2.getId().compareTo(o1.getId());
					}
		});
		
		// 抽出結果比較
		WeaponMasterDto result = dto.get(0);
		assertThat(result.getGenre(),is(entity.getGenre()));
		assertThat(result.getMain(),is(entity.getMain()));
		assertThat(result.getSpecial(),is(entity.getSpecial()));
		assertThat(result.getSub(),is(entity.getSub()));
	}

SpringMVCのテスト

MockMvcを利用したConrtollerに対するテストの方法についてまとめていく

MockMvcとは

アプリケーションサーバー上にデプロイせず、Spring MVCの動作を再現する仕組み
MockMvcには、以下2つの動作モードがある
・ユーザー指定のDIコンテナと連携するモード
 SpringMVCのコンフィギュレーションも含めてテストを行う場合はこちらを選ぶ
・スタンドアロンモード

MockMvcを利用したテストを行う際に必要なこと

あるコントローラに対するテストを行う流れは以下の通り
・@SpringBootTestアノテーションを付与し、DIコンテナを利用できる状態にする
・@RunWith(SpringRunner.class)を付与
・@WebAppConfigurationを付与し、利用するコンテキストにWebApplicationContextを指定する
・WebApplicationContextをAutowiredで取得し、それを元にmockMvcをビルドする
・mockMvc.performを利用し、GETリクエストを実行することでテスト対象のコントローラを呼び出す
 ・結果を検証する(ステータスが正常であること、view名が想定通りであること、など・・・)

ログインや、認証情報が必要な場合は、テストメソッドに対しさらに以下を行う
・@WithUserDetailsアノテーションを付与
 ・valueに、認証情報として利用するuser名を指定する。実際に利用できるユーザー名じゃないとエラーになる
 ・userDetailsServiceBeanNameに、UserDetailsServiceの実装クラスのBean名を指定する
・SecurityContextHolderから、ログインユーザー情報を取得する
・mockMvcへのリクエスト発行時に、取得したログインユーザー情報を付与する
 ・withメソッドを利用

以下、諸々を含んだサンプル

// DIコンテナを利用可能にする
@SpringBootTest
@RunWith(SpringRunner.class)
// テスト用のDIコンテナをWebアプリケーション向きにする
@WebAppConfiguration
public class IndexControllerTest extends AbstractTestController {

	@Autowired
	WebApplicationContext context;

	MockMvc mockMvc;

	private SplUserDtails splUserDtails;

	@Before
	public void setupMockMvc() {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
	}

	@Test
	@WithUserDetails(value = "user", userDetailsServiceBeanName = "splUserDetailsService")
	public void testIndex() throws Exception {

		// 認証情報の設定
		splUserDtails = (SplUserDtails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();;

                // "/index"へのGETリクエストを発行
		mockMvc.perform(get(URL_INDEX).with(user(splUserDtails)))
				.andExpect(status().isOk())
				.andExpect(view().name("/top/index"))
				.andDo(log());
	}
}

テスト用のリクエストを発行する際に、各ファクトリメソッドを利用することで様々なリクエストデータを付与することができる。例えば以下は、GETリクエストに対しテスト用のクエリパラメータを付与している

	@Test
	@WithUserDetails(value = "user", userDetailsServiceBeanName = "splUserDetailsService")
	public void testIndexWithQuery() throws Exception {

		// 認証情報の設定
		splUserDtails = (SplUserDtails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();;

		
		mockMvc.perform(get(URL_INDEX).with(user(splUserDtails))
				.param("queryText", "test query")) // テスト用にクエリパラメータを付与する
				.andExpect(status().isOk())
				.andExpect(view().name("/top/index"))
				.andDo(log());
	}	

その他ファクトリメソッドは以下

メソッド名 利用シーン
param / params リクエストパラメータを設定
header / headers リクエストヘッダーを設定
cookie クッキーを設定
content リクエストボディを設定
requestAttr リクエストスコープにオブジェクトを設定
flashAttr フラッシュスコープにオブジェクトを設定
sessionAttr セッションスコープにオブジェクトを設定

用意されている実行結果の検証は、MockMvcResultMatchersのメソッド利用する。
主なメソッドは下記の通り

メソッド名 利用シーン
status HTTPステータスコード
header レスポンスヘッダーの状態
cookie クッキーの状態
content レスポンスボディの中身
view 返却されたview名
forwardedUrl 遷移先のパスを検証
redirectedUrl リダイレクト先のパスまたはURLを検証
model Spring MVCのModelの状態を検証
flash フラッシュスコープの状態を検証
request 非同期処理の処理状態、リクエストスコプおよびセッションスコープの状態を検証
ゆるふうぇいとゆるふうぇいと

SpringSecurity

SpringSecurityについて色々まとめる
SpringSecurityは5.4~6.0で色々と仕様が大きく変わった。
詳しくは以下を参照。このページも以下を参考にしつつ色々まとめていく

https://qiita.com/suke_masa/items/908805dd45df08ba28d8

基本機能

・認証機能・・・アプリケーションを利用するユーザーの正当性を確認する機能
・認可機能・・・アプリケーションが提供するリソースや処理に対するアクセスを制御する機能

強化機能

・セッション管理機能
・CSRF対策機能
・ブラウザのセキュリティ対策機能との連携機能

・・・などなど

セキュリティ設定

基本

・クラス全体に@Configurationを付与し、Securityの設定を記述クラスであることを示す
・SecurityFilterChainのBeanを定義し、諸々設定を記述する

@Configuration
public class AuthConfiguration {

   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       // ログインフォームページの設定
       http.formLogin(form -> {}
   }
}

SecurityFilterChainのBeanは、DelegatingFilterProxyによって、サーブレットコンテナに登録される。
SPrin

ログイン/ログアウトの設定

HttpSecurityのformLoginメソッドを利用し、ログイン・ログアウト関連の設定が可能となる。
・ログイン画面のURL
・ログイン成功後のリダイレクト先
・ログイン失敗時に利用するハンドラーの設定(独自処理を埋め込みたいときに利用する)
・ログアウト時のURL
・ログアウト成功後のリダイレクト先
・ログアウト時に利用するハンドラーの設定(独自処理を埋め込みたいときに利用する)

などなど。
以下のサンプルでは、"/login"をログイン画面のURLとし、ログアウト成功後に"/logout"へリダイレクトさせている。
また、ハンドラーを利用して各ログイン失敗時やログアウト成功時に独自処理を埋め込んでいる
ログアウト処理に関しては下記も参照
https://spring.pleiades.io/spring-security/reference/servlet/authentication/logout.html

@Configuration
public class AuthConfiguration {
	
	@Autowired
	private SplLogoutHandler splLogoutHandler;
	
   // ログイン後は/homeに遷移させる
   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
	   	   
       // ログインフォームページの設定
       http.formLogin(form -> {
       	// ログインページを独自実装する場合は指定
       	form
       	.loginProcessingUrl("/login")
       	.loginPage("/login") 
       	.defaultSuccessUrl("/index",false)
        .failureHandler(new MyAuthenticationFailureHandler()) // ログイン失敗時に独自処理を追加
       	.permitAll()
       	;
       }).logout(logout -> {
       	// ログアウトの設定
       	logout
       	.logoutUrl("/logout") // ログアウトURL
       	.logoutSuccessUrl("/login?logout"); // ログアウト成功時の遷移先
       });

}

認証不要なURLの設定

静的リソース等、認証(ログイン)せずともアクセスできるURLを指定する。
その際はHttpSecurityのauthorizeHttpRequestsメソッドを利用する
・authorizeHttpRequestsで、認証不要とするURLパターンを指定し、.permitAll()を実行し認証を伴わないアクセスを許可する
・.anyRequest().authenticated();を付与し、許可された以外のURLには基本的に認証が必要とする

	   
        // 認証不要ページのパスを設定
        http.authorizeHttpRequests(authorize -> {
            authorize
            .requestMatchers("/css/**").permitAll()
            .anyRequest().authenticated(); // 許可されていないURLに対しては認証が必要
        });

モジュール構成

SpringSecurityが提供する主なモジュールは以下

モジュール名 説明
spring-security-core 認証と認可機能を実現するためのコアなコンポーネント
spring-security-web Webアプリケーションのセキュリティ対策を実現するためのコンポーネント
spring-security-config 各モジュールから提供されているコンポーネントのセットアップをサポートするためのコンポーネント
spring-security-taglibs 認証情報や認可機能にアクセスするためのJSPタグライブラリを提供

アーキテクチャ

SpringSecurityは、サーブレットフィルタの仕組みを利用したWebアプリケーションのセキュリティ対策を実現するアーキテクチャを採用しており、以下のような流れで処理が行われる

Filter Chain Proxy

フレームワーク処理のエントリポイントとなるサーブレットフィルタ

HttpFirewall

HttpServletRequestとHttpServletResponseに対してファイアウォール機能を組み込むためのインタフェース。デフォルトでは、DefaultHttpFirewallクラスが使用される

SecurityFilterChain

FilterChainProxyが受け取ったリクエストに対して適用する「SecurityFilterのリスト」を管理するためのインタフェース
デフォルトでは、DefaultSecuritryFilterChainクラスが使用される

SecurityFilter

フレームワーク機能やセキュリティ対策機能を提供するサーブレットフィルタクラス。
複数のSecurityFilterを連鎖させることで、Webアプリケーションのセキュリティ対策を行う仕組みなっている。
認証と認可機能を実現するためのコアナなクラスは以下の通り

クラス名 説明
SecurityContextPersistenceFilter 今は非推奨!! 認証情報をリクエストをまたいで共有するための処理を提供する。デフォルトでは、HttpSessionに認証情報を格納する
UsernamePasswordAuthenticationFilter リクエストパラメータで指定されたユーザー名とパスワードを使用して認証処理を行う
LogoutFilter ログアウト処理を行う
FilterSecurityInterceptor HTTPリクエストに対して認可処理を実行する
ExceptionTranslationFilter FilterSecurityInterceptorで発生した例外をハンドリングし、クライアントへ返却するレスポンスを制御する

認証処理の仕組み

画面(フォーム)に入力されたログインIDとパスワードを用い、ユーザー認証する仕組みについてまとめる。
SpringSecurityによる認証処理の流れは以下の通り

コンポーネント 役割
AuthenticationFilter 認証方式の実装処理を提供するFilter。ログインID(ユーザー名)とパスワードによる認証処理を行う、UsernamePasswordAuthenticationFilterを想定
AuthenticationManager 認証処理を実行するためのインタフェース。デフォルトでは、ProviderManagerが実装されている。実際の認証処理は、AuthenticationProvider(インタフェース)を実装している、DaoAuthenticationProviderに委譲している
AuthenticationProvider 認証処理の実装を提供するためのインタフェース。(代表としてDaoAuthenticationProvider)

フォーム認証

前述の認証処理の仕組みを踏まえ、フォーム認証についてフォーカスする。
フォーム認証処理の流れは以下の通り
(後回し)

コンポーネント 役割
UsernamePasswordAuthenticationFilter リクエストパラメータから資格情報を取得し、AuthenticationManagerの認証処理を呼び出す
AuthenticationSuccessHandler 認証処理に成功した場合のハンドリングを提供するIF
AuthenticationFailureHandler 認証処理に失敗した場合のハンドリングを提供するIF

フォーム認証を適用する際は、Cofnigurationのクラスにて http.formLogin()メソッドを利用してログインフォームの設定諸々を行う

サンプル

       // ログインフォームページの設定
       http.formLogin(form -> {
       	form
       	.loginProcessingUrl("/login")
       	.loginPage("/login") 
       	.defaultSuccessUrl("/index",false)
       	.permitAll()
       	;
       })

ログインフォームの作成の流れ

独自のログインフォームを実装する流れは以下の通り

①ログインフォームとなるページを作成する

以下は、Thymeleafを利用して作成した場合のもの

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
	xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>ログインページ</title>
</head>
<body>
	<h1>ログイン</h1>
	<form name="LoginForm" method="post" action="index.html"
		th:action="@{/login}">
		<p th:text="*{password}"></p>
		<input type="text" name="username" placeholder="ユーザーID"> 
		<input type="text" name="password" placeholder="パスワード" >
		<button type="submit">ログイン</button>
	</form>
</body>
</html>

②ログインページ用のControllerを作成し、ハンドラーメソッドを準備

ログインフォームにアクセスするためのGETリクエスト用ハンドラーメソッドを準備する
以下は、URL"/login"に対しGETリクエストが行われた場合に、ログインフォームを返却するようにしている

@Controller
public class  LoginController {

   @GetMapping("/login")
    public ModelAndView index(ModelAndView mav){
          mav.setViewName("/top/login");
	  return mav;
    }
}

③Java Configにて、ログインフォームのURLを定義する

http.formLoginにおける.loginPageにログインフォーム用のURLを設定する
また、permitAllを付与しすべてのユーザーに対しログインフォームへのアクセス権を付与する
(当たり前だが、ログイン画面にアクセスする際にログインは不要なので)

サンプル

       // ログインフォームページの設定
       http.formLogin(form -> {
       	form
       	.loginProcessingUrl("/login") 
       	.loginPage("/login")  // ログインページのURLを設定
       	.defaultSuccessUrl("/index",false)
       	.permitAll()
       	;
       })

認証成功時の処理

認証処理が成功した場合のハンドラーとして、AuthenticationSuccessHandlerインタフェース、およびその実装クラスが提供されている。デフォルトでは、SavedRequestAwareAuthenticationSuccessHandlerが適用される。

SpringSecurityでは、認証前にアクセス拒否したリクエストをHTTPセッションに保存しておき、認証が成功した際にアクセス拒否したリクエストを復元し、リダイレクトする仕組みになっている。
この仕組みを提供するのがSavedRequestAwareAuthenticationSuccessHandlerである。

ざっくりイメージ

認証成功後のデフォルトの遷移先は、http.formLoginにおける.defaultSuccessUrlに設定する

認証失敗時の処理

認証処理が失敗した場合のハンドラーとして、AuthenticationFailureHandlerインタフェース、およびその実装クラスが提供されている。デフォルトでは、SimpleUrlAuthenticationFailureHandlerが適用される。

認証失敗時のデフォルトの遷移先を変更する場合、http.formLoginにおける.failureUrlに設定する
デフォルトでは"/login?error"にリダイレクトされる

DB認証

通常、ユーザー情報(IDやパスワード)はDBに保存するので、認証処理時はクライアントから連携されたログインID、およびパスワードと、DBに保存されたユーザー情報を突合するという処理を行う必要が生じる。
SpringSecurityでは、認証処理の実態を提供しているDaoAuthenticationProviderの中で、それらの処理を実現するためのインタフェースとしてUserDetailsService、およびUserDetailsを提供している。これらのインタフェースを独自実装する必要がある。

UserDetailsの実装

UserDetailsServiceから渡されるユーザー情報(UserDto)を管理する。
必要なメソッドは適宜オーバーライドで実装するが、getUsername、およびgetPassword()は認証で利用するので必須

package com.splatoon.analyze.security;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.splentity.dto.UserDto;

public class SplUserDtails implements UserDetails {
	
	private final UserDto userDto;
	
	public SplUserDtails (UserDto userDto) {
		this.userDto = userDto;		
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// TODO:ロールを返す
		return null;
	}

	@Override
	public String getPassword() {
		// ハッシュ化済のパスワード
		return userDto.getPassword();
	}

	@Override
	public String getUsername() {
		// ログインで利用するユーザー名
		return userDto.getLoginId();
	}
	
	public String getUserId() {
		return userDto.getUserId();
	}

	@Override
	public boolean isAccountNonExpired() {
		// 期限切れでない場合true
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		// ロックされていない場合true
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		// 期限切れでなければtrue
		return true;
	}

	@Override
	public boolean isEnabled() {
		// 有効であればtrue
		return true;
	}

}

UserDetailsServiceの実装例

渡されたログインIDをキーに、DBからユーザー情報を取得する。
取得に成功した場合、ユーザー情報をUserDetailsに詰め込んで返却する
取得に失敗した場合、UsernameNotFoundExceptionをThrowする

@Service
public class SplUserDetailsService implements UserDetailsService {
	
	@Autowired
	UsersService usersService;

	@Override
	public UserDetails loadUserByUsername(@NonNull String loginId) throws UsernameNotFoundException {
		
		if(StringUtils.isEmpty(loginId)) {
			throw new UsernameNotFoundException("");
		}
		
		// DBからユーザーを検索
		//UserDto userDto = usersService.selectByLoginId(loginId);
		UserDto userDto = Optional.ofNullable(usersService.selectByLoginId(loginId))
				.orElseThrow(() -> new UsernameNotFoundException(""));
		
		return new SplUserDtails(userDto);
	}

}

認証イベントのハンドリング

SpringSecurityでは、認証時の成否(イベント)を通知する仕組みが提供されている。
イベントをキャッチするためのイベントリスナーを用意し、イベントごとに行いたい処理を独自実装することが可能。
これにより、「認証の成功・失敗をログとして保存したい」「パスワードを連続して間違ったアカウントをロックしたい」といったことが可能となる。

イベントリスナーの実装は、
①@EventListnerを付与したメソッドをもつBeanを定義する
② ①で実装したメソッドの引数として、キャッチしたいイベントの型を設定する

サンプル

@Component
public class AuthenticationEventListeners {
	
	// 認証成功イベントをキャッチ
	@EventListener
	public void handleSuccessAuthentication(AuthenticationSuccessEvent successEvent) {
		System.out.println("認証成功イベントのキャッチ");
		System.out.println(successEvent.getAuthentication().getName());
	}

	// 認証失敗イベントをキャッチ
	@EventListener
	public void handleFailureAuthentication(AbstractAuthenticationFailureEvent failureEvent) {
		System.out.println("認証失敗イベントのキャッチ");		
	}	
}

キャッチできる主なイベントの種類は以下の通り

種別 イベント 説明
認証成功 AuthenticationSuccessEvent クライアントが入力した認証情報が正しかったことを通知する。ただし、後続の認証処理でエラーが発生することがありうる
認証成功 SessionFixationProtectionEvent セッション固定攻撃対策の処理が成功したことを通知する
認証成功 InteractiveAuthenticationSuccessEvent 認証処理がすべて成功したことを通知する
認証失敗 AuthenticationFailureBadCredentialsEvent BadCredentialsExceptionが発生したことを通知
認証失敗 AuthenticationFailureDisabledEvent DisabledExceptionが発生したことを通知
認証失敗 AuthenticationFailureLockedEvent LockedExceptionが発生したことを通知
認証失敗 AuthenticationFailureExpiredEvent AccountExpiredExceptionが発生したことを通知
認証失敗 AuthenticationFailureCredentialsExpiredEvent CredentialsExpiredExceptionが発生したことを通知
認証失敗 AuthenticationFailureServiceExceptionEvent AuthenticationServiceExcedptionが発生したことを通知

ログアウト

ログアウト処理時、SpringSecurityは以下の流れで処理を行う
(後で)

LogoutFilterからLogoutHandlerの各実装クラスが呼ばれ、ログアウト時の本処理が行われる。
その後、LogoutSuccessHandlerによりログアウト成功時のレスポンス処理が行われる。

LogoutHandlerの主な実装クラスと、行われている処理は以下の通り。

クラス名 処理
SecurityContextLogoutHandler 認証情報のクリアとセッションの破棄
CookieClearingLogoutHandler 指定したクッキーを削除するためのレスポンスを行う
CsrfLogoutHandler CSRF対策用トークンの破棄を行う

ログアウト処理の設定

設定はログイン処理同様、Configurationクラスにて行う

   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       // ログアウトの設定
       http.logout(logout -> {
       	logout
       	.logoutUrl("/logout") // ログアウト処理が要求されるURL
       	.logoutSuccessUrl("/login?logout") // ログアウト後のリダイレクト先URL
       	;
       });
}

認証情報へのアクセス

認証済ユーザーの認証情報は、デフォルトではセッションに格納jされる。
セッションに格納された認証情報は、リクエストごとにSecurityContextPersistenceFilterクラスにより、SecurityContextHolderクラスに格納される。同一スレッド内であればどこからでもアクセス可能

サンプル

// 認証情報の取得
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

認可処理

アプリケーションの利用者がアクセスできるリソースを制御するための処理
リソースごとにアクセスポリシーを定義しておき、どのリソースにどのユーザーがアクセスできるのかを制御する
SpringSecurityでは、以下に対してアクセスポリシーを定義できる
 ・Webリソース
 ・Javaメソッド
 ・ドメインオブジェクト

認可処理の仕組み

SpringSecurityによる認可処理の流れは以下の通り

コンポーネント 役割
ExceptionTranslationFilter 認可処理で発生した例外をハンドリングするサーブレットフィルタ。未認証ユーザーからのアクセスの場合認証を促す、認証済のユーザーからのアクセスの場合認可エラーを通知する、といった交通整理を行う
FilterSecurityInterceptor HTTPリクエストに対して認可処理を適用するためのサーブレットフィルタ。実際の認可処理は、AccessDecisitonManagerに委譲する。またその際、クライアントがアクセスしようとしたWebリソースに指定されているアクセスポリシーを連携する
AccessDecisionManager アクセスしようとしたリソースに対してアクセス権があるかをチェックするためのインタフェース。AccessDecisionVoterというインタフェースのメソッドを呼びだし、アクセス権を付与するか否かを投票してもらう仕組みになっている
AccessDecisionVoter アクセスしようとしたリソースに指定されているアクセスポリシーを参照し、アクセス権を付与するかを投票するためのインタフェース。デフォルトではWebExpressionVoterが実装クラスとして適用される

Webリソースへの認可(JavaConfigを利用)

JavaConfigを用いたWebリソースへの認可設定の流れは、HttpSecurityのメソッドを利用し以下の流れで行う
1.アクセスポリシーを適用するリソース(HTTP)を指定する
2. 1.で指定したリソースに対し、具体的なアクセスポリシーを設定する

例えば以下のサンプルでは、"/master_manage/"配下のリソースへのアクセスをADMIN権限を持っているユーザーに認可している。それ以外のユーザーは該当リソースへアクセスできない。

        http.authorizeHttpRequests(authorize -> {
            authorize
            .requestMatchers("/master_manage/**").hasRole("ADMIN")
        })

メソッドへの認可

なんかうまくいかないから後回し

画面への認可

Thymeleafを前提とする。

Viewの各項目への認可は、http://www.thymeleaf.org/extras/spring-securityを利用することで、認証情報へアクセスすることで可能

詳しくはこっち
https://github.com/thymeleaf/thymeleaf-extras-springsecurity

例えば以下は、 sec:authorizeを利用することでADMIN権限を持っているユーザーのみにマスター管理画面へのリンクが表示される

	<span sec:authorize="hasRole('ADMIN')">
		<a href="/master_manage" title="マスター管理画面へ">マスター管理画面へ</a>
	</span>

認可エラー時のレスポンス

リソースやメソッドへのアクセス拒否が発生すると、AccessDeniedExceptionがスローされる。
その後AccessDeniedExceptionはExceptionTranslationFilterクラスに補足され、AccessDeniedHandlerまたはAuthenticationEntryPointインタフェースのメソッドを呼び出し、エラー応答を行う。

AccessDeniedHandler

認証済のユーザーからのアクセスを拒否した際のエラーレスポンスを行うためのインタフェース
実装クラスとしては以下が提供されている

クラス 説明
AccessDeniedHandlerImpl HTTPレスポンスコード403を設定し、設定されたエラーページを返却する。デフォルトで利用される
DelegatingAccessDeniedHandler AccessDeniedExceptionとAccessDeniedHandlerインタフェースの実装クラスのマッピングを行い、発生したAccessDeniedExceptionに対応するAccessDeniedHandlerインタフェースの実装クラスに処理を委譲する

AuthenticationEntryPoint

未承認のユーザーからのアクセスを拒否した際のエラーレスポンスを行うためのインタフェース
実装クラスとしては以下が提供されている

クラス 説明
LoginUrlAuthenticationEntryPoint 認証用のログインフォームを表示する
DelegatingAuthenticationEntryPoint RequestMatcherとAuthenticationEntryPointインタフェースの実装クラスのマッピングを行い、HTTPリクエストに対応するAccessDeniedHandlerインタフェースの実装クラスに処理を委譲する

認可エラー時の遷移先

JavaConfigにおいて、HttpSecurityのexceptionHandlingメソッドにて、認可エラー時の遷移先URLを設定できる

http.exceptionHandling().accessDeniedPage("/error/access_denied");
ゆるふうぇいとゆるふうぇいと

CSRF対策

SpringSecurityの続き・・

SpringSecurityによるCSRF対策概要

セッション単位にランダムなトークン(CSRFトークン)を払い出す。
払い出されたCSRFトークンはリクエストパラメータ(HTMLのhidden項目)として送信されてくる。そのCSRFトークンをチェックすることで、そのリクエストが正規のものなのか、攻撃なのかを判断する。SpringSecurityのデフォルト実装では、POST、PUT、DELETE、PATCHのHTTPメソッドを使用したリクエストに対しCSRFチェックを行う。

Thymeleafでは、th:actionを利用することでcsrfトークンが自動的に埋め込まれる。
以下のような感じ

<input type="hidden" name="_csrf" value="rydkXMAd7o2yvNLHoshAYRg3J80f6HDtC5McCNsS7VZqArY1mBQFOvgt27uf2euhwOV0Ai8OCvQo3RXAOfAkbe5w1TNTMNRR">

## CSRF対策機能の適用
CSRF対策機能はデフォルトで有効になっている。
そのため、無効化する場合には明示的に行うことが必要になる。

JavaConfigによる無効化
```java
  // csrf対策完全無効化
  http.csrf().disable();

  // csrf対策の一部無効化		
  http.csrf().ignoringRequestMatchers("/books/**");

トークンチェックエラー

CSRFトークンチェックでエラーが発生した場合、下記例外が発生する。

クラス名 説明
InvalidCsrfTokenException クライアントから送られてきたトークン値と、サーバで管理しているトークン値に差異があった場合に発生
MissingCsrfTokenException サーバー側にトークンが保存されていない場合に発生する

各Exceptionごとに独自実装をしたい場合は、AccessDeniedHandlerを実装したクラス(Bean定義済)を用意し、DelegatingAccessDeniedHandlerを利用し、JavaConfigにて登録する
サンプル

		// AccessDeniedException関連の独自実装
		LinkedHashMap<Class<? extends AccessDeniedException>, AccessDeniedHandler> handlers = new LinkedHashMap<>();
		handlers.put(InvalidCsrfTokenException.class,new SplAccessDeniedHandler());
		handlers.put(AccessDeniedException.class,new AccessDeniedHandlerImpl());
		
		// InvalidCsrfTokenExceptionはSplAccessDeniedHandlerで処理
		// AccessDeniedExceptionはAccessDeniedHandlerImpl で処理
		// それ以外はAccessDeniedHandlerImplで処理(デフォルトハンドラー)
		http.exceptionHandling()
			.accessDeniedHandler(new DelegatingAccessDeniedHandler(handlers,new AccessDeniedHandlerImpl()));

ゆるふうぇいとゆるふうぇいと

セッション管理

JavaConfigにて、HttpSecurityのsessionManagementメソッドを利用しセッションの諸々設定が可能

セッションの作成方針を設定

JavaConfigにて、以下のようにセッションの作成方針を設定できる

// セッションの作成方針
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);

作成方針候補は以下の通り

オプション 説明
always セッションが存在しない場合、無条件でセッションを張る
ifRequired セッションが存在しない場合、セッションにオブジェクトを格納するタイミングでセッションを張る(デフォルト)
never セッションを張らない。ただし、すでにセッションが存在する場合は継続利用
stateless セッションの作成、利用を一切行わない

無効なセッションを使ったリクエストの検知

無効なセッションを利用したリクエストを検知する機能を提供する。タイムアウト後のリクエストが主な事象。
検知した場合の遷移先を設定できる

	http.sessionManagement()
			.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
			.invalidSessionUrl("/error/invalidSession") // 無効なセッションを検知した場合の遷移先
			;

ブラウザのセキュリティ対策機能との連携