1
0

SpringBootでシンプルなCRUDアプリを作ったときの備忘録

Posted at

はじめに

SpringBootによるシンプルなCRUDアプリケーション作成を学んだ際に、特に記憶しておきたいと思ったことを備忘録として残します。

アプリの概要

  • シンプルなユーザ管理アプリ
  • ユーザ登録、一覧、検索、更新、削除、管理者専用ページ遷移、認証、認可機能を持つ

開発環境

  • Eclipse(Pleiades) Version: 2023-12 (4.30.0)
  • Java SE 21
  • MacBook air

依存ライブラリ

pom.xmlの内容抜粋
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-devtools</artifactId>
    <scope>runtime</scope>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.5.1</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>bootstrap</artifactId>
    <version>5.3.3</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>webjars-locator</artifactId>
    <version>0.52</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>nz.net.ultraq.thymeleaf</groupId>
    <artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>
<!--MyBatis-->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
</dependency>
<!--Model Mapper-->
<dependency>
    <groupId>org.modelmapper.extensions</groupId>
    <artifactId>modelmapper-spring</artifactId>
    <version>3.0.0</version>
</dependency>
<!--Spring AOP-->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aop</artifactId>
</dependency>
<!--AspectJ-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
<!-- SpringSecurity -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Thymeleaf拡張ライブラリ(セキュリティ)-->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
    <version>3.1.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>datatables</artifactId>
    <version>1.10.21</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>datatables-plugins</artifactId>
    <version>1.10.21</version>
    <scope>runtime</scope>
</dependency>

学習内容

@Controller

コントローラとなるクラスには、クラス名の上に@Controllerアノテーションをつける。

@GetMapping

GETメソッドのリクエストを受け付けるためには、@GetMappingアノテーションを、受け付けるURLを引数に設定して付ける。メソッドの戻り値には、表示したいHTMLのファイルパスを、拡張子を除いた上でsrc/main/resources/templatesからの相対パスとして設定する。このとき、戻り値の先頭に/は不要。

@PostMapping

POSTメソッドのリクエストを受け付けるためのアノテーション。利用法は@GetMappingに倣う。
アノテーションの属性にparams属性を設定すると、同じページのコントローラでも、別々のメソッドに処理を振り分けることができる。値には、画面のbuttonタグのname属性と同じ値を指定する。なお、value属性にはURLを設定する。

@RequestParam

@PostMappingをつけたメソッドの引数の型の前につける。@RequestParamの引数には、画面の要素のname属性の値を渡す。

@PathVariable

動的URLを受け取るために使う。@GetMapping@PostMappingの引数(URL)内で{変数名}と記述する。その変数を受け取るには、メソッドの引数に@PathVariable("変数名") 型 引数を設定する。

Model

Modelクラスを使うことで、別の画面に値を渡すことができる。ModelクラスのaddAttribute(キー名, 値)メソッドを利用する。@PostMappingをつけたメソッドの引数にModel modelを渡せば、そのメソッドの内部で利用できる。

サービス

アプリケーションの機能を実装したクラス。コントローラーから指令されてサービスが実行される。こうすることで、別の画面で同じ処理を行う場合に、サービスを再利用できるようになる。

リポジトリー

データベース関連の処理を行うクラスのこと。@Repositoryアノテーションをクラスにつける。

H2データベース

インメモリ型のデータベース。application.propertiesに設定を書く。下記は一例。

spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver spring.datasouce.username=sa spring.datasouce.password= spring.datasource.sql-script-encoding=UTF-8 spring.datasource.initialize=true spring.datasource.schema=classpath:schema.sql spring.datasource.data=classpath:data.sql
spring.h2.console.enabled=true
  • spring.datasource.initialize
    trueを指定すると、テーブル作成とデータ投入のSQLを実行する
  • spring.datasource.schema
    テーブル作成のSQLを実行する
  • spring.datasource.data
    初期データ投入のSQLを実行する
  • classpath:
    src/main/resourcesのこと

Dependency Injection

利点

インターフェースを使うことで、処理内容の追加・変更ができる。また、スタブを用意して簡単にテストを行うことができる。

対象となるアノテーション

Bean

DIコンテナーに登録されるクラスのことを、Beanと呼ぶ。

@Autowired

DIを行うためのアノテーション。フィールドなどにつける。

@Autowired
private SampleComponent component;

JdbcTemplate

JdbcTemplateクラスを使えば、JDBCを使ってSQLを実行することができる。queryForMapメソッドなどがある。

Thymeleaf

Modelに登録された値の表示

${キー名}として取得できる。登録されているものがインスタンスの場合、${キー名.フィールド名}とする。

<td th:text=${myAddress}></td>

メッセージプロパティの表示

th:text属性の値に#{キー名}を指定する。

<label for="userName" th:text="#{userName}"></label>

layoutライブラリ

Thymeleafでレイアウトを作るにはlayoutライブラリを利用した方が効率的。ライブラリの依存関係をpom.xmlに追加して、xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"をhtmlタグに追加する。

レイアウト側の処理

HTMLが埋め込まれる側のHTMLをレイアウトという。
他のHTMLファイルを読み込むためには、読み込みたい場所のタグにlayout:replace属性を使用する。
layout:replace="~{ファイルパス::キー名}"
ファイルパスにはsrc/main/resources/templatesからの相対パスを指定する(拡張子は不要)。
ちなみに、他のHTMLを埋め込むためにはlayout:replaceの他にlayout:fragmentという属性も使用できる。違いは下記のとおり。

  • layout:replace
    この属性を持つ要素が丸ごと入れ替わる
  • layout:fragment
    この属性を持つ要素内に追加される
コンテンツ側の処理

埋め込むためのHTMLをコンテンツという。
どのレイアウトに、このHTMLを組み込むかを指定するには、layout:decorate属性をhtmlタグのに入れる。
layout:decorate="~{ファイルパス}"
ファイルパスは、src/main/resources/templatesからの相対パスを指定する。(拡張子は不要)
埋め込みたい部分を示すキー名は、コンテンツ側で属性layout:fragmentに記述する。
layout:fragment="キー名"

th:if

th:if属性を使うと、条件によってタグを生成するかどうかを決定できる。値がtrueであれば、タグを生成する。

動的URL

例|th:href="@{'/user/detail/' + ${item.userId}}
これで、ユーザIDごとの詳細ページへのURLを生成したりできる。

th:textとth:fieldの使い分けのポイント

  • 値を表示するだけの場合|th:textを使う
    例|単純なデータの表示。th:textは動的にテキストを表示するためのもので、モデルの値や変数の内容をそのまま表示します。
  • ユーザーからの入力がある場合|th:fieldを使う
    例|フォームでのデータ入力。th:fieldはユーザーがフォームに入力するためのフィールドを作るためのもので、モデルオブジェクトとフォームのフィールドを自動的にバインドする。

th:field

th:fieldでは、生成されたHTMLに、id属性とname属性がフィールド名を値に持って追加される。

th:block

th:blockタグ内の属性であるth:if属性の値がtrueの時、th:blockタグ自体はなくなり、中のコードのみ残る。divタグなどでは、divタグ自体も残る。

webjars

JavaScriptやCSSなどのライブラリを、Mavenで利用することができるようにするライブラリ。

webjars-locator

JavaScriptやCSSのライブラリを読み込む際に、パスのバージョン番号を省略することができる。

メッセージプロパティ

固定文言をプロパティファイルから読み込むようにする。

利点

  • 画面の標準化
    メッセージを統一すればユーザーにとってわかりやすくなる
  • 一括管理
    プロパティファイルを編集するだけで、固定文言を一括で変更できる

利用法

  • messages.propertiesに、画面に表示する文字列を「キー=値」の形で設定する。例|userName=ユーザー名
  • javaクラスでの読み込みは、MessageSourceのgetMessageメソッドを利用する
@Autowired
private MessageSource messageSource;
String hoge = messageSource.getMessage("hoge", null, Locale.JAPAN);
  • messages.propertiesのパスは、src/main/resources配下がデフォルト値として設定されている。変更する場合は、application.propertiesを編集して、messages.propertiesのパスを変更する。例|spring.messages.basename=i18n/messages(.propertiesは記述しない)(i18nは、国際化対応の意)

バインド

画面の入力内容とJavaのクラスをマッピングすること。画面の入力内容をサーバー側のコントローラークラスで受け取ることができる。

手順

  1. formパッケージを作り、formクラスを作成する
  2. formの内容と対応するprivate変数を宣言する

コントローラから画面に渡す

メソッドの引数に@ModelAttribute ExampleForm formのように加える。@ModelAttributeは自動でModelにインスタンスを登録する。クラス名の先頭を小文字にした文字列(exampleForm)がModelのキーに登録される。

@Data

formクラスには、@Dataアノテーションをつける必要がある。これは、@ToString@EqualsAndHashCode@Getter@Setter@RequiredArgsConstructorをつけるためのショートカット。

バインドのエラーメッセージ

ValidationMessage.propertiesなどmessages.propertiesとは別ファイルにする場合、application. propertiesにファイル名を追加する 形式は、typeMismatch.Modelのキー名.フィールド名=エラーメッセージ

Thymeleafテンプレート側の処理

th:errorclassは、同じタグ内にth:fieldを使っていると有効になる。th:fieldで指定したフィールドにおいてエラーが発生していた場合、この属性で指定したクラスを追加する。
th:errorsにはフィールド名を指定する。指定されたフィールドでエラーが発生していた場合、エラーメッセージをタグ内に表示する。
なお、これを利用する際は、Bootstrap使用の場合、th:errorclassにis-invalidを設定し、Bootstrapのinvalid-feedbackというクラスをつけたタグでエラーメッセージを表示する。invalid-feedbackは、同じタグ内にis-invalidクラスがつけられたタグが存在しないと、タグが表示されない。この機能を利用してエラーメッセージを表示しているわけである。

<div class="form-group">
    <label for="userId" th:text="#{userId}"></label>
    <input type="text" class="form-control" th:field="*{userId}" th:errorclass="is-invalid"/>
    <div class="invalid-feedback" th:errors="*{userId}"></div> </div>

Bootstrap 5では、.form-inline, .form-row, .form-groupが廃止されている。代わりに、トップのタグにrow row-cols-*クラスを使う。

@BindingResult

バインドエラーや後述するバリデーションエラーが発生しているかどうかは、BindingResultのメソッドで確認できる。コントローラにおいて、@PostMappingをつけたメソッドの引数にBindingResult bindingResultを渡し、その中でbindingResult.hasErrors()メソッドの結果がtrueとなる場合、バインドエラーかバリデーションエラーが発生している。

バリデーション

フィールドにつけるアノテーション

formクラスのフィールドに、以下のようなアノテーションをつける。

バリデーションの実行

バリデーションしたいformクラスを使用するコントローラの@PostMappingをつけたメソッドの引数において(@ModelAttributeを使用している場合を想定)、formクラスの型の前に@Validatedアノテーションをつけると、バリデーションが実行される。

エラーメッセージの編集

バリデーションエラーメッセージの編集方法はいくつかあり、その中でも、アノテーション名=エラーメッセージと設定する方法が一番簡単。
例|Length={0}は、{2}桁以上、{1}桁以下で入力してください
コードの中の{0}や{1}などの文字は、メッセージ内のパラメーター。{0}にはフィールド名が入り、以降の数字には、アノテーション内に設定した属性の値が表示される。
例|@Length(min = 4, max = 100)ならば、 {1} = maxの値(100) {2} = minの値(4)。
どの数字にどの属性の値が入れられるかは、属性名の昇順で設定される。

バリデーションの実行順序の設定

概要

バリデーションの実行順序を設定したい場合、バリデーションのグループを設定する。
例えば、

  1. 必須入力チェック
  2. 入力内容のチェック
    という順番で実行したい場合など。
方法
  1. インターフェースをグループの数だけ作成する。中身はなくて良い。例えば、ValidGroup1とValidGroup2というインターフェースを作成する
  2. グループの順番を設定するインターフェースを作成する。中身はなくて良い。編集が必要な部分は下記のとおり。なお、@GroupSequenceアノテーションでバリデーションの順番を設定する。左から設定されたインターフェースの順番でバリデーションをしていく
    インターフェースの一部抜粋
    @GroupSequence({ ValidGroup1.class, ValidGroup2.class }) 
    public interface GroupOrder {}
    
  3. フォームクラスの各フィールドの宣言部分を次のように編集する
    @NotBlank(groups = ValidGroup1.class)
    @Email(groups = ValidGroup2.class)
    private String userId;
    
  4. コントローラの@Validatedアノテーションの引数にGroupOrder.classを指定すると、バリデーションの順番設定を反映できる

MyBatis

高度な機能が用意されたO/Rマッパー。O/Rマッパーとは、Javaクラス(オブジェクト)と、データベースをマッピングするフレームワークのこと。

applications.propertiesの設定

例|mybatis.mapper-locations=classpath*:/mapper/h2/*.xml
MyBatisではxmlファイル内にSQLを書く。mybatis.mapper-locationsプロパティでxmlファイルの置き場所を指定する。

ModelMapperの登録

ModelMapperをBeanに登録する。
JavaConfig.javaとうクラスを作り、その中に次のように記述する。

JavaConfig.java一部抜粋
@Configuration
public class JavaConfig { 
    @Bean 
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }
}

ModelMapperは、インスタンスの内容をコピーしてくれるライブラリである。コントローラにおいて、フォームクラスからエンティティへの変換をする場合などに用いる。次の例では、formに収められているフォームクラスのインスタンスを、エンティティMUserに変換している。
例|MUser user = modelMapper.map(form, MUser.class);
ModelMapperのmapメソッドを使えば、フィールドの内容を簡単にコピーできる。フィールドをコピーするためには、コピー元とコピー先のフィールド名が一致している必要がある。
.class はクラス型を表現する特別な記法である。これを指定することで、ModelMapperに「このソースオブジェクトを、どのクラスに変換するのか」を明示的に教えることができる。

Formクラスをそのままサービスに渡さないようにする。画面に変更があってもサービスの修正が不要になり、他の画面からもサービスを再利用できるようにするためである。

MyBatisにおけるデータベース操作部分の作成・修正の流れ

  1. エンティティの作成・修正
  2. リポジトリ(マッパー)の作成・修正
  3. SQL文(mapper.xml)の作成・修正
  4. サービスのインターフェースの作成・修正
  5. サービスの実装の作成・修正
  6. 画面の作成・修正

エンティティの作成・修正

テーブルのエンティティクラスを作成する。クラスにLombokの@Dataアノテーションをつけ、テーブルのカラムに対応したフィールドを用意する。

リポジトリの作成・修正

MyBatisでリポジトリーを作成するためには、Javaのインターフェースに@Mapperアノテーションを付ける。

@Mapper
public interface UserMapper {
    /** ユーザー登録 */
    public int insertOne(MUser user);

    /** ユーザ更新(1件) */
    public void updateOne(@Param("userId") String userId, @Param("password") String password, @Param("userName") String userName);
}

select文で結果が複数件となる場合は、メソッドの戻り値をListにする。
パラメーターを使用する場合、@Paramアノテーションをつける。アノテーションの値は、パラメーター名。

xmlファイルにSQLを書く

mapper.xmlの記述例
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- Mapperとxmlのマッピング -->
<mapper namespace="com.example.repository.UserMapper">
<!-- ユーザー1件登録 -->
    <insert id="insertOne">
        insert into m_user( user_id , password , user_name , birthday , age , gender , department_id , role )
        values ( #{userId} , #{password} , #{userName} , #{birthday} , #{age} , #{gender} , #{departmentId} , #{role} )
    </insert>
</mapper>
mapperタグ

mapperタグのnamespace属性で、Mapper(Javaインターフェイス)とxmlをマッピングする。

insertタグ

insertタグのid属性には、リポジトリ(Mapper)のメソッド名を指定する。Mapperメソッドの引数をSQLのパラメーターに入れる。形式は、#{メソッド引数名} 。

selectタグ

selectタグの場合は、resultTypeという属性に返り値のデータ型を設定する。その際、次の設定をすると便利。

  • mybatis.configuration.map-underscore-to-camel-case=true
    このプロパティにtrueを設定すると、select結果のカラム名のアンダースコアをキャメルケースに変換する。例|select結果のカラム名:user_id→JavaクラスのuserId
  • mybatis.type-aliases-package
    通常、resultTypeにクラスのデータ型を指定する場合は、パッケージ名も含める必要があるが、このプロパティに設定したパッケージ名は省略できる。
resultMapタグ

resultMapタグは、select結果とJavaクラスをマッピングするためのタグ。特に、複雑なselect文を実行する場合などで用いる。 type属性にパッケージ名を含めたクラス名を入れ、id属性にはこのresultMapタグを識別するための任意の値を入れる。

記述例
<resultMap type="com.example.domain.user.model.MUser" id="user">
    <id column="user_id" property="userId" />
    <result column="password" property="password" />
    <result column="user_name" property="userName" /> 
    <result column="birthday" property="birthday" /> 
    <result column="age" property="age" />
    <result column="gender" property="gender" /> 
    <result column="department_id" property="departmentId" />
    <result column="role" property="role" /> 
</resultMap>

resultMapタグ内で、idタグ又はresultタグを用意し、それらのタグ内に以下の属性を設定してマッピングをしていく。

  • column
    select結果のカラム名を設定する。
  • property
    Javaクラスのフィールド名を設定する。
    idタグに設定されたカラムの値がユニークになるように戻り値が作られるため、idタグは必須。そのほかはresultタグ。
    resultMapを使用する場合は、selectタグにresultMap属性を付ける。この属性の値には、resultMapタグのidを指定する。
パラメーター

マッパーのメソッド引数において、@Paramアノテーションで指定した値をSQL内に埋め込むには、#{パラメーター名}と指定する。パラメーターがクラス型の場合、#{パラメーター名.フィールド名}とする。

ifタグ

動的SQLを生成するためには、ifタグを使う。test属性に条件式を書く。条件式の結果がtrueであれば、このタグ内のSQLが追加される。結果がfalseであれば、SQLは追加されない。

whereタグ

whereタグ内のifタグが1つでもtrueとなれば、where句が追加される。つまり、where句が必ず付くかどうか分からない場合に、このタグを使う。where句が必ず付くSQLを使用する場合であれば、ifタグだけを使う。

マッピング定義内に別のマッピング定義を追加する
  • 結合先データが1件の場合
    assosiationタグを用いる。例|<association property="department" resultMap="department"/>
  • 結合先データが複数件の場合
    collectionタグを用いる。例|<collection property="salaryList" resultMap="salary" columnPrefix="salary_"/>。selectの結果が他のテーブルのカラム名とかぶってしまう時などにprefixを使って区別したいときにcolumnPrefix属性を使う。ここでは、salaryテーブルのカラム名にprefixとしてsalary_をつけて区別している。

サービスのインターフェースを作成・修正する

例(抜粋)
public interface UserService {
    /** ユーザー登録 */
    public void signup(MUser user);
}

サービスの実装クラスを作成・修正する

このクラスから、Mapperのメソッドを呼び出す。フィールドでマッパーをDIし、それを用いる。

トランザクション

@Transactionalをメソッドやクラスにつけると、トランザクションを実装できる。例外が発生すると、自動でロールバックされる。クラスにつけた場合は、中の全メソッドでトランザクションが実装される。

AOP

概要

共通する処理を抜き出してまとめて管理すること。
本質的でないが記述しなければいけないコード(ログとか)をまとめる。

Advice

AOPで実行する処理内容のこと

Pointcut

##### 定義
処理を実行する対象(クラスやメソッド)のこと

指定方法

どのクラスやメソッドをAOPの対象にするか指定するためには、@Beforeなどのアノテーション内にPointcut(実行対象)を指定する。次のような指定方法がある。

  • execution
    正規表現を使って任意のクラス、メソッドを指定する。形式|execution(戻り値 パッケージ名.クラス名.メソッド名(引数))。正規表現の使い方は次のとおり。
    • ∗ (アスタリスク)
      任意の文字列を表す。パッケージ部分では、アスタリスクが1個のパッケージ名を表す。メソッドの引数部分では、アスタリスクが1個の引数を表す
    • .. (ドット2文字)
      0個以上の任意の値を表す。パッケージ部分では、ドット2文字が0個以上のパッケージを表す。メソッドの引数部分では、ドット2文字が0個以上の引数を表す
    • +(プラス)
      クラス名の後にプラスを指定すると、指定クラスのサブクラスが含まれる。
  • bean
    DIコンテナーに登録されているBean名を指定する。形式|bean(Bean名)。正規表現も使える。
  • @annotation
    パッケージ名を含めたアノテーション名を使って、実行対象を指定する。指定したアノテーションが付いているメソッドが対象となる。
  • @within
    パッケージ名を含めたアノテーション名を指定する。指定したアノテーションが付いているクラスの全てのメソッドがAOPの対象となる。

JoinPoint

定義

処理を実行するタイミングのこと

指定方法

JoinPointを指定するためには、メソッドにJoinPointと同じ名前のアノテーションを付ける。つまり、以下のようなアノテーションをメソッドに付ける。

  • @Before
  • @After
  • @AfterReturning
  • @Around
    @Aroundが付けられたメソッド内では、開始時処理の後、ProceedingJoinPointproceedメソッドにより、AOP実行対象のメソッドを呼び出す必要がある。その後、その結果(型は、Object型)を最後にreturnする必要がある。
  • @AfterThrowing

AOPの仕組み

呼び出し元がProxyに依頼し、ProxyがBean(@Controllerとか@Serviceとか)をDIコンテナから呼び出す。そのとき、ProxyがAOPの処理もする。

AOPクラス

AOPのクラスには、@Aspect@Componentアノテーションを付ける。

エラー処理

概要

Springデフォルトのエラー画面を表示すると、次のような問題がある。

  • エラー内容などが表示されて、セキュリティ上の問題がある。
  • エラーが起きたときに、ユーザーが何の操作をすればいいのか分からない。
    そのため、ログインページに飛べる共通のエラー画面を作成する。

共通のエラー画面の作成方法

src/main/resources/templates配下に、error.htmlというファイルを作る。

エラー内容の取得

以下のキーを指定することで、エラー内容の詳細を取得することができる。これらの値は、Springが自動で設定してくれる。

  • ${status}
    HTTPのエラーコードが格納されている。
  • ${error}
    HTTPのエラー概要が表示される。
  • ${message}
    エラーメッセージが表示される。

HTTPエラー毎のエラー画面

src/main/resources/templates/error配下に、ファイル名をHTTPエラーコードと同じにしたhtmlファイルを用意すれば良い。例えば、404エラーに対応した画面を作る場合は、404.htmlというファイルを用意する。

@AfterThrowing

@AfterThrowingアノテーションを付けることで、例外発生時のAOPを実装できる。throwing属性には、例外クラスのメソッド引数を指定する。

@ExceptionHandler

@ExceptionHandlerアノテーションを付けたメソッドを用意すると、例外処理を実装できる。アノテーションの引数に例外クラスを指定することで、例外毎の処理を実行できる。

Webアプリケーション全体の例外処理

コントローラークラス毎に例外処理を用意すれば、画面毎に適切な例外処理ができるが、例外処理の実装を忘れてしまう可能性もある。そこで、Webアプリケーション全体の例外処理を用意する。それにより、例外処理を一括で実装することができる。@ControllerAdviceアノテーションをクラスに付けると、全てのコントローラーで共有するメソッドを用意することができる。ただし、以下のいずれかのアノテーションが付いたメソッドのみ、コントローラー間で共有できる。

Spring Security

Spring Security6では、以前のバージョンとは仕様が大幅に変わっているところがあり、参考書の記述とは異なる方法でコーディングしているところが多々ある。その際に参考とした情報は次のとおり。

https://qiita.com/suke_masa/items/908805dd45df08ba28d8
https://zenn.dev/kktworks/books/spring_security_6_sample_book/viewer/ss6s_3
https://spring.pleiades.io/spring-security/site/docs/current/api/org/springframework/security/config/annotation/web/builders/HttpSecurity.html#csrf()
https://zenn.dev/peishim/articles/6946f72e15affa
https://qiita.com/yuji38kwmt/items/5bc5a509681d294afdb2

参考書から変更した主な部分

  • ログイン処理などをラムダ式による記述に変更
  • CSRFに関するメソッドの記述方法
  • インメモリ認証において、andを使わない形にしている
  • SecurityConfigに@EnableWebSecurity及び@Configurationをつける
  • SecurityFilterChainをDIコンテナに登録
  • requestMatchersを使用するように変更
  • sec:authorizeについては、extraがspring6とspring security6に対応していなかったため、未実装

概要

Spring Securityには認証と認可の機能がある。Springでは、認証によるログイン機能や認可による権限に応じた機能制限を容易に利用できる。認可の機能では、特定のURLへのアクセスを禁止や画面表示項目の変更が可能

SecurityConfigクラス

各種設定は、SecurityConfigクラスに書いていく。
このクラスには、@EnableWebSecurity及び@Configurationをつける。

設定例
/** パスワードの暗号化 */
@Bean
public PasswordEncoder passwordEncoder() {
    //return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    return new BCryptPasswordEncoder();
}

/** セキュリティの各種設定 */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/webjars/**").permitAll()
            .requestMatchers("/css/**").permitAll()
            .requestMatchers("/js/**").permitAll()
            .requestMatchers("/h2-console/**").permitAll()
            .requestMatchers("/login").permitAll()
            .requestMatchers("/user/signup").permitAll()
            .requestMatchers("/user/signup/rest").permitAll()
            .requestMatchers("/admin").hasAuthority("ROLE_ADMIN")// ここまでは、ログイン不要の設定
            .anyRequest().authenticated())
            .formLogin(login -> login //フォームログイン
                    .loginProcessingUrl("/login") //ログイン処理のパス
                    .loginPage("/login") //ログインページの指定
                    .failureUrl("/login?error")
                    .usernameParameter("userId")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/user/list", true)
                    .permitAll())
            .logout(logout -> logout // ログアウト処理
                    .logoutUrl("/logout")
                    .logoutSuccessUrl("/login?logout"));
    return http.build();
}

/** インメモリ認証の設定 */
@Bean
public InMemoryUserDetailsManager userDetailsService() {
    List<UserDetails> users = new ArrayList<UserDetails>();
    UserDetails user = User
            .withUsername("user")
            .password(passwordEncoder().encode("user"))
            .roles("GENERAL")
            .build();
    users.add(user);
    UserDetails admin = User
            .withUsername("admin")
            .password(passwordEncoder().encode("admin"))
            .roles("ADMIN")
            .build();
    users.add(admin);
    return new InMemoryUserDetailsManager(users);
}

/** データベース認証の設定 */
public void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}

ログイン処理

ログイン処理を追加するには、http.formLogin()メソッドを呼び出す。そのメソッドから、メソッドチェーンで条件を追加していく。

  • userNameParameter("ユーザーIDのname属性")
    ログイン画面のユーザーID入力欄のname属性を設定する。
  • passwordParameter("パスワードのname属性")
    ログイン画面のパスワード入力欄のname属性を設定する。
  • defaultSuccessUrl("ログイン成功後の遷移先", 第1引数の遷移先に遷移するか)
    ログイン成功後の遷移先を指定する。第2引数にtrueを指定すると、第1引数のパスに強制的に遷移する。
  • ログインエラーメッセージ
    <p th:if="${session['SPRING_SECURITY_LAST_EXCEPTION']} != null" th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}" class="text-danger"> ログインエラーメッセージ></p>
    
    セッションにセキュリティのエラーがあれば、エラーメッセージを表示する、という内容。
    ログイン時に各種のチェックをすることが可能であり、それらのチェックに対応したメッセージを変更できる。messages.propertiesに記述する。

PasswordEncoder

パスワードを暗号化するためのインターフェースである。BCryptPasswordEncoderの使用が推奨されている。その理由は、パスワードが盗まれても復号することが困難なこと。BCryptPasswordEncoderのインスタンスを返すメソッドを作って、(返り値の型はPasswordEncoder)それを使ってインスタンスを作成、メソッドencode(“エンコードしたい文字列”)とする。

ユーザー認証サービス

ユーザー認証のサービスを作るためには、UserDetailsServiceを実装したクラスを用意する。 そして、loadUserByUsernameメソッドをオーバーライドする。このメソッドの戻り値は、UserDetailsインターフェース。UserDetailsには、ユーザーID、パスワード、権限リストを必須で設定する。

設定例抜粋
@Autowired
private UserService service;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

    // ユーザ情報取得
    MUser loginUser = service.getLoginUser(username);

    // ユーザが存在しない場合
    if (loginUser == null) {
        throw new UsernameNotFoundException("user not found");
    }

    // 権限List作成
    GrantedAuthority authority = new SimpleGrantedAuthority(loginUser.getRole());
    List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
    authorities.add(authority);

    // UserDetails作成
    UserDetails userDetails = (UserDetails) new User(loginUser.getUserId(), loginUser.getPassword(), authorities);

    return userDetails;
}

循環参照をスルーする設定

循環参照が発生したため、application.propertiesに次のように設定した。spring.main.allow-circular-references=true

CSRFトークン

CSRF対策として、CSRFトークンを送信する必要がある。そのためには、formタグ内に以下の記述が必要。

<input type="hidden" th:name="${_csrf.parameterName}"th:value="${_csrf.token}" />

しかし、th:actionを使う場合は、自動的にトークンが送信されるため、上の記述は不要。th:actionを使うこと。

URLによる認可

指定のURLに対して認可を設定するためには、hasAuthorityなどのメソッドを使用する。このメソッドには権限を指定する。その権限をユーザーが持っていれば、URLにアクセスできる。

権限による画面項目表示

sec:authorize属性を使います。この属性内でhasRoleやhasAuthorityメソッドを呼び出す。それらのメソッドにはロールを設定する。そのロールをログインユーザーが持っていれば、項目が表示される。 なお、sec:authorize属性を使用するためには、htmlタグにxmlns:sec="http://www.thymeleaf.org/extras/spring-security"を追加する必要がある。

ロール名

認可のメソッドでロールを指定する際に、"ROLE_"を先頭に付けなければならないメソッドと、"ROLE_"を省略して指定するメソッドがある。

  • hasRole
    hasRole("ADMIN")
  • hasAuthority
    hasAuthority("ROLE_ADMIN")
    メソッドによってロールの指定方法が異なるので、使用するメソッドを統一した方が良い。また、ロール名の先頭には必ず"ROLE_"を付けた方が良い。

REST API

概要

通常のHTTPリクエストでは、レスポンスのHTMLをブラウザが解釈して、その結果を画面として表示する。一方で、RESTでリクエストを送ると、Webサービスの実行結果が返ってくる。Webサービスとは、天気や地図の情報、またはAIなどのサービスである。
RESTではレスポンス結果を解釈して、ユーザーに色々な形で提供することができる。そのため、アプリケーションから外部のWebサービスを利用することができるようになる。既にあるサービスを使えば、自前で用意する部分が簡略化できるため、開発効率も上がる。
RESTではJSON(JavaScript Object Notation)形式でレスポンスを返すのが主流。。XMLよりも通信量が少ない上に読みやすいため、RESTではよく使われるデータフォーマットである。
RESTを使うメリットとして、通信量の削減による処理速度向上、ユーザー操作性の向上がある。
RESTを使う場合は、検索結果だけをJSONで取得するため、検索結果部分だけの描画処理で済む。一方、RESTを使わない場合は、検索するたびにHTMLの全てを取得し、画面全体の描画処理が必要となる。

@RestController

@RestControllerアノテーションをクラスに付けると、そのクラス内のメソッドの戻り値をRESTで受け取ることができる。正確には、メソッドの戻り値がHTTPのレスポンスボディとして返される。

fetch APIでPOSTリクエストを送信する方法

'use strict';

/** 画面ロード時の処理 */
document.addEventListener('DOMContentLoaded', function() {
    // 更新ボタンを押した時の処理
    const updateButton = document.getElementById('btn-update');
    if (updateButton) {
        updateButton.addEventListener('click', function(event) {
            event.preventDefault(); //デフォルトのフォーム送信を無効化
            updateUser();
        });
    }
    // 削除ボタンを押した時の処理
    const deleteButton = document.getElementById('btn-delete');
    if (deleteButton) {
        deleteButton.addEventListener('click', function(event) {
            event.preventDefault(); //デフォルトのフォーム送信を無効化
            deleteUser();
        });
    }
});

/** ユーザ更新処理 */
function updateUser() {
    // フォームの値を取得
    const formElement = document.querySelector('#user-detail-form');
    const formData = new FormData(formElement);

    // fetch APIを使った非同期通信
    fetch('/user/update', {
        method: 'PUT',
        body: new URLSearchParams(formData),
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Accept': 'application/json'
        }
    })
    .then(response => {
        if (!response.ok) {
            throw new Error('ネットワークの応答が問題です');
        }
        return response.json();
    })
    .then(data => {
        //成功時の処理
        alert('ユーザを更新しました');
        // ユーザ一覧画面にリダイレクト
        window.location.href = '/user/list';
    })
    .catch(error => {
        // エラー時の処理
        alert('ユーザ更新に失敗しました');
        console.error('エラー', error);
    });
}

/** ユーザ削除処理 */
function deleteUser() {
    // フォームの値を取得
    const formElement = document.querySelector("#user-detail-form");
    const formData = new FormData(formElement);

    // fetch APIを使った非同期通信
    fetch('/user/delete', {
        method: 'DELETE',
        body: new URLSearchParams(formData),
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            'Accept': 'application/json'
        }
    })
    .then(response => {
        if (!response.ok) {
            throw new Error('ネットワークの応答が問題です');
        }
        return response.json();
    })
    .then(data => {
        // 成功時の処理
        alert('ユーザを削除しました');
        // ユーザ一覧画面にリダイレクト
        window.location.href = '/user/list';
    })
    .catch(error => {
        // エラー時の処理
        alert('ユーザ削除に失敗しました');
        console.error('エラー:', error);
    })
}

参考|https://apidog.com/jp/blog/fetch-api-post/

FieldError

バリデーション結果がNGとなったフィールドの名称は、FieldErrorクラスから取得することができる。ただし、FieldErrorから取得できるのは、単項目チェックに引っかかったフィールドだけ。

戻り値

REST用のコントローラーで、メソッドの戻り値をJavaクラスにする。すると、そのJavaクラスがJSONに自動で変換され、HTTPのレスポンスにJSONが設定される。

その他

@DateTimeFormat

変数に@DateTimeFormat(pattern = "yyyy/MM/dd")のようにアノテーションをつけると、Date型にバインドできる。

@NumberFormat

数値型にするなら、@NumberFormat(pattern = "#,###")のようにする。

画面遷移(通常のフォワード)とリダイレクトを使い分ける基準

  • リダイレクト
    URLの変更が必要な場合、POST-Redirect-GETパターンを使いたい場合、ブラウザでのページ再読み込みに影響を与えないようにしたい場合
  • フォワード(通常の画面遷移)
    同じリクエスト内で画面をレンダリングしたい場合、リクエストスコープのデータをそのまま利用したい場合

ログレベル変更方法

application.propertiesに次のように設定する。logging.level.パッケージ名=ログレベル

デバッグ関連

  • org.h2.jdbc.JdbcSQLSyntaxErrorException: SQLステートメントに文法エラーがあります
    エラーメッセージの[*]の付近にエラーがあることを示す。
  • @Slf4j
    Lombokのアノテーション。これをクラスに付けると、slf4jを使って、簡単にログ出力ができる。 このアノテーションをクラスに付けると、logというstatic変数が用意され、その変数のメソッドを使えば、簡単にログが出力できる。例|log.info(form.toString());。LombokのlogメソッドにExceptionクラスを渡すと、スタックトレースを出してくれる。
  • org.thymeleaf.exceptions.TemplateInputException:
    エラーメッセージから line を検索すると該当箇所がわかる
  • An error happened during template parsing
    次の方法でmodelに登録されているものを確認できる。System.out.println(model.asMap()); 。また、次の方法でフィールドが渡されているかどうか確認できる。<p th:utext="${userDetailForm}"></p>

参考書

後悔しないためのSpring Boot 入門書 Spring 解体新書(第2版) Spring Bootが丸分かり
田村達也 氏 著

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0