2024年現在の Spring Boot についての覚え書き。 目次 †
JDKのインストール †(Mac) VsCode環境設定 †https://code.visualstudio.com/docs/java/java-spring-boot 以下の拡張機能をインストール
プロジェクトの作成 †build.gradle †まだ認証機能を構築していないので spring security を無効化しておく : dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' //implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.session:spring-session-jdbc' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' //testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } : application.properties †とりあえず最初の起動に必要な分だけ記載。(DBは事前準備) spring.application.name=demo spring.datasource.url=jdbc:postgresql://localhost:5432/sample spring.datasource.username=sample spring.datasource.password=sample spring.datasource.driver-class-name=org.postgresql.Driver spring.session.store-type=jdbc spring.session.jdbc.initialize-schema=always spring.session.jdbc.platform=postgresql TOPページ作成 †コントローラ †src/main/java/com/example/demo/controller/IndexController.java package com.example.demo.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class IndexController { @GetMapping("/") public String index() { return "index"; } } Thymeleafテンプレート †src/main/resources/templates/index.html <!doctype html> <html lang="ja"> <head> <meta charset="utf-8" /> </head> <body> Index Page! </body> </html> テスト起動 †![]() 起動したら http://localhost:8080/ にアクセスしてTOPページを表示してみる。 以降は、諸々の解説を記載する コントローラについて †コントローラの基本 †
例) RestAPI用のコントローラ package com.example.demo.controller; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class SampleRestController { @GetMapping("api1") public Map<String, String> samplejson() { Map<String, String> res = new HashMap<String, String>(); res.put("var1", "test1"); return res; } @GetMapping("api2") public ResponseEntity<Map<String, String>> samplejson3() { Map<String, String> res = new HashMap<String, String>(); res.put("var1", "test1"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8)); return new ResponseEntity<Map<String, String>>(res, headers, HttpStatus.BAD_REQUEST); } } 例) SSR(サーバサイドレンダリング)を行う場合 package com.example.demo.controller; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @Controller public class HelloController { @GetMapping("/") public String index(Model model) { model.addAttribute("var1", "Test1"); return "index"; } @GetMapping("/hello") public String hello(Model model) { model.addAttribute("name", "山田"); return "hello"; } } リクエストデータの受け取り †アノテーションを使用してリクエストデータやセッションデータ、Cookieデータ、HTTPヘッダ等をコントローラーのメソッド引数にバインド出来る。
例) @RequestParam によるパラメータ取得 package com.example.demo.controller; import org.springframework.web.bind.annotation.RequestParam; : public class SampleController { public String index(@RequestParam(name="param1", required=false, defaultValue="0") String param1) { System.out.println("param1: " + param1); : } Pathパラメータは PathVariable アノテーションで取得 package com.example.demo.controller; : import org.springframework.web.bind.annotation.PathVariable; : public class BookController { @GetMapping("/books/{isbn}") public String edit(@PathVariable(name="isbn") String isbn, Model model) { System.out.println("isbn: " + isbn); : } } レスポンスデータの設定 †Rest コントローラの場合は返却するモデルをそのまま返す事が可能(自動的にJSONに変換される) package com.example.demo.controller; : import org.springframework.web.bind.annotation.PathVariable; : public class BookController { @GetMapping("/books/{isbn}") public Book edit(@PathVariable(name="isbn") String isbn) { Optional<Book> book = repository.findById(isbn); return book.isPresent() ? book.get() : null; } } テンプレート(Thymeleaf)に展開する値は引数にバインドした Model に設定し、戻り値でテンプレート名を返す。 package com.example.demo.controller; : import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; : @Controller public class BookController { // 書籍リポジトリ private final BookRepository repository; // コンストラクタ public BookController(BookRepository repo) { this.repository = repo; } @GetMapping("/books/{isbn}") public String edit(@PathVariable(name="isbn") String isbn, Model model) { if ("new".equals(isbn)) { model.addAttribute("book", new Book()); } else { Optional<Book> book = this.repository.findById(isbn); model.addAttribute("book", book.isPresent() ? book.get() : null); } return "book_edit"; } } セッションの利用 †初期設定 (以下、セッションストアをPostgreSQLにする場合の例) †build.gradle dependencies { : implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.session:spring-session-jdbc' runtimeOnly 'org.postgresql:postgresql' : } application.properties spring.datasource.url=jdbc:postgresql://localhost:5432/sample spring.datasource.username=sample spring.datasource.password=sample spring.datasource.driver-class-name=org.postgresql.Driver spring.session.store-type=jdbc spring.session.jdbc.initialize-schema=always spring.session.jdbc.platform=postgresql spring.session.jdbc.initialize-schema = always の場合、自動的にセッション管理用のテーブルが作成される。 セッション情報の取得/設定方法 †コンストラクタインジェクションで HttpSession セッションを取得する場合 package com.example.demo.controller; : import jakarta.servlet.http.HttpSession; @Controller public class BookController { private final HttpSession session; public BookController(HttpSession session) { this.session = session; } @GetMapping("/sample") public String sample(Model model) { int count = 0; // セッション情報の取得 Object countObj = session.getAttribute("sample_count"); if (countObj != null) { count = (int)countObj; } // セッション情報の設定 session.setAttribute("sample_count", ++count); System.out.println("sample_count: " + count); : } } RequestContextHolder から HttpSession セッションを取得する場合 package com.example.demo.controller; : import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; : import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @Controller public class BookController { @GetMapping("/sample2") public String sample2(Model model) { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (attributes != null) { ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) attributes; HttpServletRequest request = servletRequestAttributes.getRequest(); HttpSession session = request.getSession(); int count = 0; // セッション情報の取得 Object countObj = session.getAttribute("sample_count"); if (countObj != null) { count = (int)countObj; } // セッション情報の設定 session.setAttribute("sample_count", ++count); System.out.println("sample_count: " + count); model.addAttribute("sample_count", count); } return "sample"; } } DIの使用 †コンストラクタ、メソッド、フィールドに @Autowired を使用する事で依存性注入が可能となっている。
コンストラクタインジェクション †@Controller public class SampleController { private final BookRepository repository; private final HttpSession session; // Spring Framework 4.3 以降ではコンストラクタが1つの場合は Autowired は不要 // @Autowired public SampleController(HttpSession session, BookRepository repository) { this.session = session; this.repository = repository; } : } フィールド 及び メソッドインジェクション †: import org.springframework.beans.factory.annotation.Autowired; : @Controller public class SampleController { @Controller public class SampleController { // フィールドインジェクトション @Autowired private BookRepository repository; private HttpSession session; // メソッドインジェクトション @Autowired public void setSession(HttpSession session) { this.session = session; } : } AOPの使用 †Spring によるアスペクト指向プログラミング AOP の概念 依存ライブラリの追加 †build.gradle dependencies { : implementation 'org.springframework.boot:spring-boot-starter-aop' : } @AspectJ のサポートを有効にする †https://spring.pleiades.io/spring-framework/reference/core/aop/ataspectj/aspectj-support.html @EnableAspectJAutoProxy アノテーションを追加 package com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; @Configuration @EnableAspectJAutoProxy public class MyConfig { : } アスペクト、ポイントカット、アドバイスの宣言 †Before、After 、Around などのアノテーションを使用して処理の前後に任意の処理を差し込む事ができる。
例) コントローラの前後に任意の処理を差し込む package com.example.demo.advise; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Aspect @Component public class MyControllerAdvise { @Around("@within(org.springframework.stereotype.Controller)") public Object aroundController(ProceedingJoinPoint jp) throws Throwable { System.out.println("START " + jp.getSignature()); Object result = jp.proceed(); System.out.println("END " + jp.getSignature()); return result; } } 例) アドバイスからセッション情報にアクセスする package com.example.demo.advise; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @Aspect @Component public class MyControllerAdvise { @Around("@within(org.springframework.stereotype.Controller)") public Object aroundController(ProceedingJoinPoint jp) throws Throwable { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (attributes != null) { ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) attributes; HttpServletRequest request = servletRequestAttributes.getRequest(); HttpSession session = request.getSession(); : } System.out.println("START " + jp.getSignature()); Object result = jp.proceed(); System.out.println("END " + jp.getSignature()); return result; } } Spring Data JDBCの使用 †Spring Data JDBC 設定 †build.gradle dependencies { : implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' : } application.properties spring.datasource.url=jdbc:postgresql://localhost:5432/sample spring.datasource.username=sample spring.datasource.password=sample spring.datasource.driver-class-name=org.postgresql.Driver logging.level.org.springframework.jdbc.core=DEBUG モデル定義 †package com.example.demo.entity; import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Table; import lombok.Data; @Data @Table("books") public class Book { @Id private String isbn; private String title; private int price; // Lombok を使用している為、個別のsetter 、getter は定義なし } DAO定義 †インターフェースを定義する事により様々なSQLを発行する事ができる。 上記より一部抜粋
DAOの定義例 package com.example.demo.entity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jdbc.repository.query.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; public interface BookRepository extends CrudRepository<Book, String> { // ... where isbn like 'xxxx%' としてSQL生成される public Iterable<Book> findByIsbnStartingWith(String isbn); // ページネーションを利用する場合 public Page<Book> findByIsbnStartingWithAndTitleStartingWith(String isbn, String title, Pageable pageable); // 任意のSQLを発行する場合 @Query("select * from books where 任意の条件") public Iterable<Book> findByCustomConditions(@Param("field1") String field1, @Param("field2") String field2); } 利用例 †コントローラからの利用例 †@Controller public class BookController { private final BookRepository repository; public BookController(HttpSession session, BookRepository repo) { this.repository = repo; } @GetMapping("/books") public String index(@RequestParam(name="isbn", required=false, defaultValue="") String isbn, Model model) { Iterable<Book> books = this.repository.findByIsbnStartingWith(isbn); model.addAttribute("books", books); return "books"; } } ページネーションを利用する場合 †: import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; : @Controller public class BookController { private final BookRepository repository; public BookController(HttpSession session, BookRepository repo) { this.repository = repo; } @GetMapping("/books") public String index(@RequestParam(name="isbn", required=false, defaultValue="") String isbn, @RequestParam(name="title", required=false, defaultValue="") String title, @RequestParam(name="page", required=false, defaultValue="0") int page, @RequestParam(name="limit", required=false, defaultValue="5") int limit, Model model) { Pageable pageable = PageRequest.of(page, limit, Sort.by("isbn")); Page<Book> bookPage = this.repository.findByIsbnStartingWithAndTitleStartingWith(isbn, title, pageable); List<Book> books = bookPage.getContent(); model.addAttribute("pager", bookPage); model.addAttribute("books", books); return "books"; } } テンプレート側 : <div th:if="${pager}"> Page: <span th:text="${pager.number}" /> Count: <span th:text="${pager.numberOfElements}" /> Total Count: <span th:text="${pager.totalElements}" /> Total Pages: <span th:text="${pager.totalPages}" /> </div> <table> <tr> <th>ISBN</th> <th>タイトル</th> <th>価格</th> </tr> <tr th:each="book : ${books}"> <td><a th:text="${book.isbn}" th:href="|/books/${book.isbn}|">isbn</a></td> <td th:text="${book.title}">title</td> <td th:text="${book.price}">price</td> </tr> </table> : ログレベルの設定 †application.properties logging.level.org.springframework.jdbc.core=TRACE Spring Security(JDBC)の使用 †設定 †build.gradle dependencies { : implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' : } application.properties spring.session.store-type=jdbc spring.session.jdbc.initialize-schema=always spring.session.jdbc.platform=postgresql スキーマ †デフォルトでは以下の構成を前提として認証、認可が行われる。 create table users( username varchar_ignorecase(50) not null primary key, password varchar_ignorecase(500) not null, enabled boolean not null ); create table authorities ( username varchar_ignorecase(50) not null, authority varchar_ignorecase(50) not null, constraint fk_authorities_users foreign key(username) references users(username) ); 但し、以降では以下の通りカスタマイズして認証機能を利用する 手順を記載する。
create table users( username varchar(50) not null primary key, username_disp varchar(50) not null, password varchar(500) not null, enabled boolean not null ); create table user_roles ( username varchar(50) not null, authority varchar(50) not null ); create unique index user_roles_ix1 on user_roles (username,authority); 認証用のエンティティ等 †MyUser.java package com.example.demo.entity; import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Table; import lombok.Data; @Data @Table("users") public class MyUser { @Id private String username; private String usernameDisp; private String password; private boolean enabled; } MyUserRole.java package com.example.demo.entity; import org.springframework.data.relational.core.mapping.Table; import lombok.Data; @Data @Table("user_roles") public class MyUserRole { private String username; private String authority; } MyUserDetails.java package com.example.demo.entity; import java.util.Collection; import java.util.List; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import lombok.Data; @Data public class MyUserDetails implements UserDetails { private String username; private String password; private String usernameDisp; private List<GrantedAuthority> authorities; public MyUserDetails(String username, String password, String usernameDisp, List<GrantedAuthority> authorities) { this.username = username; this.password = password; this.usernameDisp = usernameDisp; this.authorities = authorities; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } } MyUserRepository.java (DAO) package com.example.demo.entity; import org.springframework.data.jdbc.repository.query.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; public interface MyUserRepository extends CrudRepository<MyUser, String> { /** * ユーザの保持しているロールを取得する. * @param username ユーザ名 * @return ロール一覧 */ @Query("select * from user_roles where username = :username") public Iterable<MyUserRole> findUserRoles(@Param("username") String username); } 認証用のサービス作成 †package com.example.demo.service; import org.springframework.stereotype.Service; import com.example.demo.entity.MyUser; import com.example.demo.entity.MyUserDetails; import com.example.demo.entity.MyUserRepository; import com.example.demo.entity.MyUserRole; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Optional; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Service public class MyAuthService implements UserDetailsService { private final MyUserRepository repository; public MyAuthService(MyUserRepository repo) { this.repository = repo; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // ユーザ情報取得 Optional<MyUser> foundUser = this.repository.findById(username); if (!foundUser.isPresent()) { throw new UsernameNotFoundException("User not found"); } else if (!foundUser.get().isEnabled()) { throw new UsernameNotFoundException("User not found"); } MyUser user = foundUser.get(); // ユーザの保持ロールを取得 List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(); Iterator<MyUserRole> roles = this.repository.findUserRoles(username).iterator(); while (roles.hasNext()) { MyUserRole role = roles.next(); authorities.add(new SimpleGrantedAuthority(role.getAuthority())); } // DBのパスワードが暗号化済みの場合 String encodedPassword = user.getPassword(); if (!encodedPassword.matches("\\{[a-zA-Z+0-9]+\\}.+")) { // DBのパスワードが暗号化されていない場合 encodedPassword = "{bcrypt}" + new BCryptPasswordEncoder().encode(user.getPassword()); } // UserDetails生成 return (UserDetails) new MyUserDetails(user.getUsername(), encodedPassword, user.getUsernameDisp(), authorities); } } ログイン画面作成 †login.html <!DOCTYPE html> <html lang="ja"> <head> <title>Spring Security Example </title> </head> <body> <div th:if="${param.error}"> Invalid username and password. </div> <div th:if="${param.logout}"> You have been logged out. </div> <form th:action="@{/login}" method="post"> <div><label> User Name : <input type="text" name="username"/> </label></div> <div><label> Password: <input type="password" name="password"/> </label></div> <div><input type="submit" value="Sign In"/></div> </form> </body> </html> 許可がないページにアクセス時のエラー画面 †login.html <!doctype html> <html lang="ja" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6" > <head> <meta charset="utf-8" /> </head> <body> Access Denined! </body> </html> 未ログイン時はログイン画面に飛ばす設定 †WebSecurityConfig.java package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableMethodSecurity @EnableAspectJAutoProxy public class WebSecurityConfig { // エラーハンドラ(許可されていないページへのアクセス時用) private final Customizer<ExceptionHandlingConfigurer<HttpSecurity>> authErrorHandler = new Customizer<ExceptionHandlingConfigurer<HttpSecurity>>() { @Override public void customize(ExceptionHandlingConfigurer<HttpSecurity> t) { t.accessDeniedPage("/access_denied"); } }; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((requests) -> requests .requestMatchers("/dummy").permitAll() // for life check .anyRequest().authenticated() ) .formLogin((form) -> form .loginPage("/login") .successForwardUrl("/") .permitAll() ) .logout((logout) -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login") .deleteCookies("SESSION") .invalidateHttpSession(true) .permitAll() ) .exceptionHandling(authErrorHandler); return http.build(); } } コントローラの作成 †IndexController.java package com.example.demo.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import lombok.RequiredArgsConstructor; @Controller @RequiredArgsConstructor public class IndexController { // ログイン後の表示 @PostMapping("/") public String logined() { return "redirect:/"; } // TOPページ @GetMapping("/") public String index() { return "index"; } // アクセス不可 @GetMapping("/access_denied") public String accessDenied() { return "access_denied"; } } 認可の設定 †コントローラにアノテーションを設定する事により、権限の有無によってアクセス制限をかける事ができる。 : @GetMapping("/books") @PreAuthorize("hasAuthority('ADMIN') || hasAuthority('BOOK_SEARCH')") public String index(@RequestParam(name="isbn", required=false, defaultValue="") String isbn, @RequestParam(name="title", required=false, defaultValue="") String title, @RequestParam(name="page", required=false, defaultValue="0") int page, @RequestParam(name="limit", required=false, defaultValue="5") int limit, Model model) { : } テンプレート(Thymeleaf)側で権限の有無によってボタン表示などを切り替えたい場合は、以下の通り制御できる。 <html lang="ja" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6" > <body> : <div sec:authorize="hasAuthority('BOOK_UPDATE')"> <!-- 登録ボタン --> <div th:if="!${book.createdAt}"> <button name="_method" value="post">登録</button> </div> <!-- 更新/削除ボタン --> <div th:if="${book.createdAt}"> <button name="_method" value="put">更新</button> <button name="_method" value="delete">削除</button> </div> </div> : </body> </html> ログインユーザ情報を画面に表示する †ここではヘッダにログインユーザ表示名を表示してみる。 Model にログインユーザを設定する為のアドバイスを作成する。 package com.example.demo.advise; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ModelAttribute; import com.example.demo.entity.MyUserDetails; @ControllerAdvice public class MyLoginUserAdvise { @ModelAttribute("currentUserName") String currentUserName(Authentication authentication) { if (authentication != null) { MyUserDetails myUserDetails = (MyUserDetails) authentication.getPrincipal(); return myUserDetails.getUsernameDisp(); } return null; } } ヘッダ用のhtml : <div th:fragment="header" id="header"> <div id="header_bar"> <div id="header_left"> Spring Boot サンプル </div> <div id="header_right"> <!-- ユーザ表示名 --> <span th:text="${currentUserName}" /> <!-- ログアウトボタン --> <form th:action="@{/logout}" method="post"> <input type="submit" value="ログアウト"/> </form> </div> </div> </div> : Thymeleaf の使用 †TODO:
|