LoginSignup
1
0

More than 3 years have passed since last update.

Spring bootとthymeleafでPostgresにINSERT, SELECTをするまで

Last updated at Posted at 2020-05-04

やったこと

spring boot勉強中の途中経過まとめ。
前回書いた記事で、spring bootでHello Worldするところまでやった。結局ローカルで作業しているが。今回やったことは以下。

  • thymeleafで入力フォームを作った
  • 画面の入力データを使ってpostgres DBにINSERT、SELECTのSQLを発行した
  • 結果を表示する

コードやファイルごとにコメントを書く形式にした。あまりまとまっていないから後で見返すの大変そう...。

できたもの

コード

画面遷移図

spring画面遷移.png

擬コラボレーション図

Spring20200503構成.png

学んだこと

  • 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が必要らしいので以下を追加。

build.gradle
dependencies {
         //省略
    compile('org.springframework.boot:spring-boot-starter-jdbc')
}

接続情報記述

application.propatiesはyamlにしてもいいようなのでyamlにした。urlの最後のDB名は必須。

application.yaml
spring:
 datasource:
    driver-class-name: "org.postgresql.Driver"
    # URLの最後のDB名は必須
    url: "jdbc:postgresql://ubuntu:5432/postgres"
    username: "postgres"
    password: "" 

各コードのメモ

後で見返せるように細かくコメント。

Lyric.java

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のコードは省略。実装のみ。

LyricDaoImpl.java
//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のコードは省略。実装のみ。

LyricServiceImple.java
//数多の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の前に共通して処理されるメソッドを定義

WebMvcControllerAdvice.java
@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内のメソッドの引数を一致させること。

SearchForm.java
//importなど少々

public class SearchForm {
    @NotNull(message="歌手名を入力してください")
    private String artistName;

   //セッター、ゲッター、コンストラクタ
}

InsertForm.java

insertボタンを含むformタグにあるth:object="${searchForm}"にバインドされるつもりの型。変数名はhtmlとcontroller内のメソッドの引数を一致させること。

InsertForm.java
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

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に付与されたキーに対応するオブジェクトが受け取れる。

list.html
<!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でアクセス。

form.html
<!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/

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