やったこと
spring boot勉強中の途中経過まとめ。
前回書いた記事で、spring bootでHello Worldするところまでやった。結局ローカルで作業しているが。今回やったことは以下。
- thymeleafで入力フォームを作った
- 画面の入力データを使ってpostgres DBにINSERT、SELECTのSQLを発行した
- 結果を表示する
コードやファイルごとにコメントを書く形式にした。あまりまとまっていないから後で見返すの大変そう...。
できたもの
コード
画面遷移図
擬コラボレーション図
学んだこと
- spring bootのDI (Dependency Injection)のされ方。シングルトン。
- spring bootでDB接続して結果を表示するまでの流れ
- いくつかのアノテーション
- Formオブジェクトとth:objectのバインドのされ方
- th:objectやth:text自体はnullでもいいけど、プロパティにアクセスしようとするとエラーになるから空インスタンスを
model
に詰めてhtml返却する - 二重送信防止策の一つ、PRG (POST-REDIRECT-GET)
- spring jdbc + prepared statementによる複数INSERTは遅そう?
コード以外の準備
テーブル作成
以下のSQLでテーブルを手動で生成した。
CREATE TABLE IF NOT EXISTS lyricTable (
artist VARCHAR(32),
title VARCHAR(32),
words_writer VARCHAR(16),
music_composer VARCHAR(16),
lyric VARCHAR(1024),
url VARCHAR(256),
PRIMARY KEY (artist,title)
);
postgres接続用設定
依存関係追加
前回書いた記事時点ではpostgresqlがあればいいと思っていたが、jdbcが必要らしいので以下を追加。
dependencies {
//省略
compile('org.springframework.boot:spring-boot-starter-jdbc')
}
接続情報記述
application.propatiesはyamlにしてもいいようなのでyamlにした。urlの最後のDB名は必須。
spring:
datasource:
driver-class-name: "org.postgresql.Driver"
# URLの最後のDB名は必須
url: "jdbc:postgresql://ubuntu:5432/postgres"
username: "postgres"
password: ""
各コードのメモ
後で見返せるように細かくコメント。
Lyric.java
public class Lyric {
private String artist;
private String title;
private String words_writer;
private String music_composer;
private String lyric;
private String url;
//セッター、ゲッター、引数のないコンストラクタ
//(省略)
}
- セッター、ゲッター、引数のないコンストラクタがないとエラー出た気がする
-
@Entity
はつけなかった(DIしないからかな?)
LyricDaoImpl.java
interfaceのコードは省略。実装のみ。
//import文多数
@Repository
public class LyricDaoImpl implements LyricDao {
// DIするためのテンプレ。newせずにAutoWiredで呼び出すだけでよい。
private final JdbcTemplate jdbcTemplate;
//上とセットで必要な時にDIでインスタンスが参照される
@Autowired
public LyricDaoImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
//指定歌手の曲情報を取得
@Override
public List<Lyric> getSongs(String artistName) {
// preparedStatementがSQLインジェクション対策になるので書き換えたいが、今回はこのまま。
String sql = "SELECT * FROM lyricTable WHERE artist='" + artistName + "';";
List<Map<String, Object>> resultList = jdbcTemplate.queryForList(sql);
List<Lyric> list = new ArrayList<Lyric>();
for(Map<String, Object> result:resultList){
Lyric lyric = new Lyric();
lyric.setArtist((String)result.get("artist"));
lyric.setTitle((String)result.get("title"));
lyric.setWords_writer((String)result.get("words_writer"));
lyric.setMusic_composer((String)result.get("music_composer"));
lyric.setLyric((String)result.get("lyric"));
lyric.setUrl((String)result.get("url"));
list.add(lyric);
}
return list;
}
//リストの曲情報を複数INSERT。
@Override
public void insertSongs(List<Lyric> list) {
String sql = "INSERT INTO lyricTable VALUES (?,?,?,?,?,?)";
//preparedstatement複数INSERT。テンプレとしてこのまま使った。これでもう動く。
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter(){
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
Lyric lyric = list.get(i);
ps.setString(1, lyric.getArtist());
ps.setString(2, lyric.getTitle());
ps.setString(3, lyric.getWords_writer());
ps.setString(4, lyric.getMusic_composer());
ps.setString(5, lyric.getLyric());
ps.setString(6, lyric.getUrl());
}
@Override
public int getBatchSize() {
return list.size();
}
});
}
}
-
insertSongs()
は複数INSERTできるようにしたが、画面からは1曲しか来ない。 -
batchUpdate
はbatch sizeごとにクエリをまとめて送ってくれるらしいが、コネクションがまとめられるだけで結局1行ずつINSERTするため遅い。設定すれば一括INSERTもできるらしい。
LyricServiceImpl.java
interfaceのコードは省略。実装のみ。
//数多のimport文
@Service
public class LyricServiceImpl implements LyricService {
// DI用テンプレ。
private final LyricDao dao;
@Autowired
public LyricServiceImpl(LyricDao dao) {
//実装クラスのインスタンスがDIされる
this.dao = dao;
}
@Override
public List<Lyric> getSongs(String artistName) {
// TODO Auto-generated method stub
return dao.getSongs(artistName);
}
@Override
public void insertSongs(List<Lyric> list) {
// TODO Auto-generated method stub
dao.insertSongs(list);
}
}
- DI用のコンストラクタの引数はinterface型。それで勝手に実装クラスのインスタンスをDIしてくれるよう。まだあまりわかっていない。今のところは、シングルトンでいいオブジェクトはnewしないということで覚えておく(要確認)。
- interface名しか書かないため、実装するクラス名が変更されてもこちらは書き換えずに済む
- 実装するクラスができていなくてもエラーが出ない???(未確認)
WebMvcControllerAdvice.java
Controllerの前に共通して処理されるメソッドを定義
@ControllerAdvice
public class WebMvcControllerAdvice {
//htmlから送信された空文字をnullとして扱うらしい
@InitBinder
public void initBinder(WebDataBinder dataBinder) {
dataBinder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
//DBエラーが起こった場合にはエラーメッセージと、空のFormオブジェクトをmodelに入れてhtml返却
@ExceptionHandler(PSQLException.class)
public String sqlException(PSQLException e, Model model){
model.addAttribute("errorMessage",e.getMessage());
model.addAttribute("searchForm", new SearchForm());
model.addAttribute("insertForm", new InsertForm());
return "form.html";
}
}
- 空のFormオブジェクトを
model
に入れている。htmlでプロパティにアクセスしようとしているため、オブジェクトがないとエラーになる(後述)。ベストプラクティスはどうすればいいんだ。
SearchForm.java
selectボタンを含むformタグにあるth:object="${searchForm}"
にバインドされるつもりの型。変数名はhtmlとcontroller内のメソッドの引数を一致させること。
//importなど少々
public class SearchForm {
@NotNull(message="歌手名を入力してください")
private String artistName;
//セッター、ゲッター、コンストラクタ
}
InsertForm.java
insertボタンを含むformタグにあるth:object="${searchForm}"
にバインドされるつもりの型。変数名はhtmlとcontroller内のメソッドの引数を一致させること。
public class InsertForm {
//import文など少々
@NotNull(message="登録には歌手名の入力は必須です")
private String artistName;
@NotNull(message = "登録には曲名の入力は必須です")
private String title;
private String wordsWriter;
private String musicComposer;
private String lyric;
private String url;
//セッター、ゲッター、コンストラクタ
}
LyricController.java
//import文など
//ローカルの場合"http://localhost:8080/lyric"に来たリクエストがこのクラス内部にマッピング
@Controller
@RequestMapping("/lyric")
public class LyricController {
//newしないでDI
private final LyricService lyricService;
@Autowired
public LyricController(LyricService lyricService) {
this.lyricService = lyricService;
}
//modelのキーに"insertCompleteMessage"があればそのまま使用、なければadd
@GetMapping("/form")
public String form(Model model, @ModelAttribute("insertCompleteMessage") String message){
model.addAttribute("title", "artist name");
//templateフォルダ内の該当ファイルを返す
return "form.html";
}
//SearchFormクラスのvalidation anotationに引っかかるとresultにエラーが入る
@GetMapping("/select")
public String select(@Validated SearchForm searchForm, BindingResult result, Model model){
if(result.hasErrors()){
model.addAttribute("title", "Error Page");
return "form.html";
}
List<Lyric> list = lyricService.getSongs(searchForm.getArtistName());
if(list.size()!=0){
model.addAttribute("lyricList", list);
}else{
model.addAttribute("noResultMessage", "該当アーティストの曲が登録されていない");
}
return "list.html";
}
//PRGのためにリダイレクトとフラッシュスコープ(リクエスト1回終わると消える属性)を使用
@PostMapping("/insert")
public String insert(@Validated InsertForm insertForm, BindingResult result, Model model, RedirectAttributes redirectAttributes){
if(result.hasErrors()){
model.addAttribute("title", "Error Page");
return "form.html";
}
// まずは入力フォームは一つ。DAOはリスト形式のため、要素が1つのリストにする
List<Lyric> list = convertInsertForm(insertForm);
// TDDO エラーがあった場合の処理を書くこと
lyricService.insertSongs(list);
//@GetMapping("/form")にリダイレクト。フラッシュスコープ設定。
redirectAttributes.addFlashAttribute("insertCompleteMessage", "insert complete");
return "redirect:form";
}
//上記の引数にSearchFormが入ってなくても勝手にmodelに入れてくれる。メソッド名は何でもいい。
@ModelAttribute
SearchForm setSearchForm(){
return new SearchForm();
}
@ModelAttribute
InsertForm setInsertForm(){
return new InsertForm();
}
//InseartFormからLyricへ変換
public static List<Lyric> convertInsertForm(InsertForm insertForm){
List<Lyric> list = new ArrayList<>();
Lyric lyric = new Lyric();
lyric.setArtist(insertForm.getArtistName());
lyric.setTitle(insertForm.getTitle());
lyric.setWords_writer(insertForm.getWordsWriter());
lyric.setMusic_composer(insertForm.getMusicComposer());
lyric.setLyric(insertForm.getLyric());
lyric.setUrl(insertForm.getUrl());
list.add(lyric);
return list;
}
}
- 引数の
BindingResult result
はFormオブジェクトの直後に置かないとアクセス時にwhitelabel errorになる。引数の順番が関係ある。
PRGとは
@PostMapping
の最後のリダイレクトが二重送信対策のPRGと呼ばれるもの。
- 防止できる二重送信
- INSERT後、ブラウザの更新による再送信
- INSERT後に表示されたページで、そのままもう一度INSERTボタンを押すことによる再送信
- 防止できなさそうな二重送信
- 頑張って高速で2回INSERTボタンを押す
最後をreturn form.html
にしてリダイレクトなしでhtmlを返却すると、
1. model
に属性が残ったまま → ブラウザではオブジェクトがバインドされたまま
2. 最終リクエストがPOSTになる → ブラウザ更新時にPOSTされる
ということになる。これを防ぐために
1. model
のバインドを解除
2. 強制的にGETリクエストさせてhtmlを返却
をするのがPRG (POST-REDIRECT-GET)。
リダイレクト時はmodel
のプロパティは引き継がれない(デバッガでリダイレクト時にmodelの中のFormオブジェクトが初期化されることは確認済み)。そのため、redirectAttributes
に入ったinsert complete
の文字以外は初期化された状態でレスポンスされる。さらに、insert complete
のメッセージはフラッシュスコープ(1回のリクエストで消える)に入っているため、ブラウザを更新すると消える。
list.html
${プロパティ名}
でサーバサイドでmodel
に付与されたキーに対応するオブジェクトが受け取れる。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeLeaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${title}">Insert</title>
</head>
<body>
<h1 th:text="${title}">title</h1>
<p th:text="${noResultMessage}"></p>
<table th:if="${lyricList}">
<tr>
<th>Artist Name</th><th>song title</th><th>lyric</th>
</tr>
<tr th:each="lyric:${lyricList}">
<td th:text="${lyric.artist}"></td>
<td th:text="${lyric.title}"></td>
<td th:text="${lyric.lyric}"></td>
</tr>
</table>
</body>
form.html
メインの画面。http://localhost:8080/lyric/formでアクセス。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeLeaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${title}">Insert</title>
</head>
<body>
<h1 th:text="${title}">title</h1>
<h1 th:text="${redirectTitle}">title</h1>
<form method="GET" action="#" th:action="@{/lyric/select}" th:object="${searchForm}">
<label for="selArtistNameId">Artist Name:</label>
<input id="selArtistNameId" name="artistName" type="text" th:value="*{artistName}">
<div th:if="${#fields.hasErrors('artistName')}" th:errors="*{artistName}"></div>
<input type="submit" value="search">
</form>
<h1>曲登録</h1>
<form method="POST" action="#" th:action="@{/lyric/insert}" th:object="${insertForm}">
<label for="insArtistNameId">Artist Name:</label>
<input id="insArtistNameId" name="artistName" type="text" th:value="*{artistName}">
<div th:if="${#fields.hasErrors('artistName')}" th:errors="*{artistName}"></div>
<label for="title">Song Title:</label>
<input id="titleId" name="title" type="text" th:value="*{title}">
<div th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></div>
<label for="wordsWriter">作詞:</label>
<input id="wordsWriterId" name="wordsWriter" type="text" th:value="*{wordsWriter}">
<label for="musicComposer">作曲:</label>
<input id="musicComposerId" name="musicComposer" type="text" th:value="*{musicComposer}">
<label for="lyric">歌詞:</label>
<input id="lyricId" name="lyric" type="text" th:value="*{lyric}">
<label for="url">歌詞URL:</label>
<input id="urlId" name="url" type="text" th:value="*{url}">
<input type="submit" value="insert">
</form>
<p th:text="${insertCompleteMessage}"></p>
<p th:if="${errorMessage}" th:text="'エラーメッセージ:'+${errorMessage}"></p>
</body>
</html>
- 例えば、
${insertForm}
オブジェクトは、送信する時点でname=
の値とController
内のマッピングメソッドの引数InsertForm
のプロパティ名が紐づけられる。- nameとFormオブジェクトのプロパティ名が異なると拾ってもらえない
-
SearchForm
を引数に入れてしまうとartistName
のプロパティ名が同じため、こちらも同時に紐づけられてしまった。@ModelAttribute
すると関係ないFormに勝手に値が紐づけられなかったのでよし。
-
model
に必要なオブジェクトを詰めて返さないと、th:value="$*{artistName}"
とかで引っかかってwhitelabel errorになる。WebMvcControllerAdvice.java
のエラーハンドラでわざわざインスタンス生成してmodel
に入れているのはこのため
調べること
- DIのメリットと挙動
<div th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></div>
- POST後のURLに出てきたjsesessionidってなんだ
参考:
会社がudemy for Business に入れてくれたおかげでGWは色々あさっていますが、今回の記事は以下の講座を多分に参考にしています。内容は全然違いますが、一部そのままお借りしているメソッド等があります。https://www.udemy.com/course/java_spring_beginner/