Chapter 06

MyBatisを使ったデータの取得、その表示

あしたば
あしたば
2021.03.22に更新

この章で学ぶこと

データを保存することができました。であれば、取得しないと意味がありませんよね。
この章ではMyBatisを使ってデータを取得する方法と、取得したデータをThymeleafで表示する方法を学びましょう。

どちらもわかるよ、ということであれば(以下略)

  • 作成時間をデータベースに追加する
  • MyBatisでデータを取得する
  • 取得したデータを表示する

作成時間をデータベースに追加する

さて、データを取得する前に、schema.sqlを見直しましょう。
いまはユーザの作成したデータのみが入っていますが、このままではこのデータがいつ作成されたものかわかりません。
多くの掲示板では、その投稿がいつされたのか、というのは表示する情報の代表格でしょうから、作成時間を追加しておくことにします。

CREATE TABLE IF NOT EXISTS USER_COMMENT (
    ID NUMBER(10) AUTO_INCREMENT, -- 主キーとしてふさわしいものがないのでIDを採番
    NAME VARCHAR2(20),
    MAILADDRESS VARCHAR2(100),
    TEXT VARCHAR2(400) NOT NULL,  -- NULLを許容しない
    CREATED_AT DATE DEFAULT SYSDATE NOT NULL,
    CONSTRAINT ID_PKC PRIMARY KEY(ID) -- IDをプライマリキーとする。(プライマリキーの名前をID_PKCとする)
);

これで作成時間が保存されます。
DEFAULT SYSDATEは、何も指定されなければ現在時間を採用することを意味しています。
これにより、IDと同じく、明示的にデータを入れて保存する必要はありません。

日時のバリューオブジェクト

日時型はバリューオブジェクトの定番です。
DateTime型を作っておきましょう。

package chalkboard.me.bulletinboard.domain.type;

import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class DateTime {
  private final LocalDateTime value;
  private final DateTimeFormatter formatter =
      DateTimeFormatter.ISO_DATE_TIME;

  public static DateTime from(LocalDateTime dateTime) {
    return new DateTime(dateTime);
  }

  @Override
  public String toString() {
    return value.format(formatter);
  }
}

Javaにおいて、エラーログでそのオブジェクトが表示されるときや、thymeleafで使用されるときには、基本的にObjectクラスtoString()メソッドが利用されます。
デフォルトの実装(Objectクラスの実装)はオブジェクトの文字列表現を返すのみで、無意味な文字列が返されます。
ですので、バリューオブジェクトについてはtoStringをオーバライドしておくのが良いでしょう。

MyBatisでデータを取得する

MyBatisを使ってデータを保存するには、SQLのINSERT文を実行しました。
データを取得する場合はSELECT文を利用します。

SELECT文そのものは、前回最後に出てきていた、

SELECT * FROM USER_COMMENTを使います。

早速、sqlファイルを作成しましょう。

INSERTとは違って、動的に変える余地がないのでシンプルですね。
取得条件を加えるwhere句が入ってくると複雑になっていきます。

では、Javaコードを追加していきましょう。

DTO

INSERTするデータをDTOで作成したように、SELECTしたデータはDTOで受けます。
このとき、DTOを共有するように作るのも一つの方法ですし、書き込みと読み取り(参照)で異なるDTOを使うのも一つの方法です。
業務アプリケーションにおいては、書き込み時と読み取り時の要件はかなり異なることが常ですので、分けておいたほうが良いかもしれません。

今回はわけて見ましょう。

package chalkboard.me.bulletinboard.application.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class UserCommentReadDto {
  private final int id;
  private final String name;
  private final String mailAddress;
  private final LocalDateTime createdAt;
  private final String comment;
}

LocalDateTimeクラスはJava標準のクラスの一つで、日時情報を持つクラスです。MyBatisがTypeHandlerの実装を持っているため値を変換できます。

domain

繰り返しになりますが、書き込み時と読み取り時の要件はかなり異なることが常です。
例えば、この掲示板にしても、書き込み時には「おみくじ」の判定がありますが、読み取り時にまで「おみくじ」の判定をする意味はないですよね。
書き込むときと読み込むときでビジネスロジックに差が出るのであれば、その種類の数だけドメインオブジェクトも存在するはずです。

参照時のユーザのポストは基本的に一覧で取れますから、下記のようなクラスを作ってみましょう。

package chalkboard.me.bulletinboard.domain.model;

import chalkboard.me.bulletinboard.domain.type.Comment;
import chalkboard.me.bulletinboard.domain.type.DateTime;
import chalkboard.me.bulletinboard.domain.type.MailAddress;
import chalkboard.me.bulletinboard.domain.type.Name;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class UserComments {
  private final List<UserComment> values;

  public static UserComments from(List<UserComment> comments) {
    if(CollectionUtils.isEmpty(comments)) return new UserComments(Collections.emptyList());
    return new UserComments(comments);
  }

  @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
  @Getter
  public static class UserComment {
    private final int id;
    private final Name name;
    private final MailAddress mailAddress;
    private final Comment comment;
    private final DateTime dateTime;

    public static UserComment from(
        int id,
        String name,
        String mailAddress,
        String comment,
        LocalDateTime dateTime) {
      return new UserComment(
          id,
          Name.from(name),
          MailAddress.from(mailAddress),
          Comment.from(comment),
          DateTime.from(dateTime)
      );
    }
  }
}

UserCommentsクラスの中に、インナークラスUserCommentがある形になっています。
UserCommentの名前を変えて、インナークラスではない形にしても問題ありません

MapperとRepository

UserCommentMapperクラスを下記のように拡張しましょう。

@Mapper
public interface UserCommentMapper {
  @Insert("sql/insertUserComment.sql")
  void insert(@Param("dto") UserCommentDto dto);
  
  @Select("sql/selectUserComment.sql")
  List<UserCommentReadDto> select();
}

ポイントは、複数個取得する場合はList型でリターンさせることです。

これに合わせて、RepositoryとDatasourceも修正します。

public interface UserCommentRepository {
  void save(UserComment dto);
  UserComments select();
}
@RequiredArgsConstructor
@Repository
public class UserCommentDatasource implements UserCommentRepository {
  private final UserCommentMapper mapper;

  @Override
  public void save(UserCommentDto dto) {
    mapper.insert(dto);
  }

  @Override
  public UserComments select() {
    List<UserCommentReadDto> dtos = mapper.select();
    return UserComments.from(
        dtos.stream().map( dto -> UserComments.UserComment.from(
            dto.getId(),
            dto.getName(),
            dto.getMailAddress(),
            dto.getComment(),
            dto.getCreatedAt()
        )).collect(Collectors.toUnmodifiableList())
    );
  }
}

これでUserCommentテーブルの情報をすべてSelectしてくるRepositoryが完成しました。

Datasourceの補足

下記のコードが見慣れない場合が多いと思われるので補足します。

    return UserComments.from(
        dtos.stream().map( dto -> UserComments.UserComment.from(
            dto.getId(),
            dto.getName(),
            dto.getMailAddress(),
            dto.getComment(),
            dto.getCreatedAt()
        )).collect(Collectors.toUnmodifiableList())
    );

UserComments.from()の中身はストリームAPIとラムダ式の組み合わせです。
dtos.stream().map()が、List型のストリームAPIの一つであるmapの利用を宣言しています。

mapメソッドは、入力されたリストの中身一つ一つに対して、引数として与えられるラムダ式を使っていく役割を持ちます。
今回の場合ですと、
dtoは、dtosから取り出されるUserCommentReadDtoインスタンスです。
そのインスタンスを使って、UserComments.UserComment.fromが実行されていきます。

その結果、UserCommentReadDtoのリストが、UserComments.UserCommentクラスのリストに変換されています。

取得したデータを表示する

表示するためのユースケースを追加しましょう。
UserCommentUseCaseクラスに下記のメソッドを追加します。

  /**
   * 投稿の取得
   * @return 投稿のリスト
   */
  public UserComments read(){
    return repository.select();
  }

いまのところ、これだけです。

これであれば、ControllerからRepositoryを呼んでも良さそうに見えますね。
実際、ここから要件が変わらないのであれば、それも正解でしょう。

しかし、

  • 認証したユーザの投稿だけ返す(認証情報からユーザIDを得るユースケースの追加が必要)
  • ページング処理する(1度に何ページのデータを取るか、今何ページ目なのかの処理が必要)

のような、UserCommentsのビジネスロジックだけでは解決できない、ロジックの組み合わせによる機能実装のパターンが来たときに、構造から変えることになってしまいます。
一般に、その時のコードを書く速さを優先するより、将来的な変更に対する柔軟性を担保しておいたほうが無難なシーンのほうが現実には多いと思っています。

補足1: 「将来的な変更に対する柔軟性」と、「将来必要かもしれない機能」は違います。後者はYAGNIと呼ばれるアンチパターンです。
変化に強い構造にしておくことと、使いもしない機能を実装しておくことは違います。わかりやすい目安として、実際に動かして実行されないコード(デッドコードといいます)の追加はYAGNIです。

補足2: どんなに先を見据え,工夫をこらした構造であっても複雑な構造化は破綻を招きます。これはKISSの法則として知られます。将来的な〜を盾にして複雑さを持ち込むのはやめておきましょう

これを使えば、Controllerに表示するデータを渡すことができます。

  @GetMapping("/board")
  public ModelAndView viewBoard(ModelAndView modelAndView){
    UserComments userComments = userCommentUseCase.read();
    modelAndView.addObject("comments", userComments.getValues());
    
    modelAndView.setViewName("board");
    modelAndView.addObject("commentForm", new CommentForm());
    return modelAndView;
  }

Thymeleafに表示する

表示するには当然HTMLが必要です。テンプレートをかきましょう。

board.htmlの、

    <div id="board-area">
        <h2 class="title">
            【速報】ライスとカレー混ぜる人が3割存在する事が発覚★109
        </h2>
        <div th:replace="~{fragments/thread(comments=${comments})}">
        </div>
    </div>

について、次のように書き換えましょう。 h2要素の中身は好きに書いて構いません。

board.html<div th:replace="~{fragments/thread(comments=${comments})}">は「フラグメント(fragment)」の呼び出しを行っています。

fragmentはthymeleafのテンプレートレイアウトで、HTMLの断片を他のHTMLに組み込むような動作を可能にしています。

今回利用している、th:replaceはフラグメントの扱いを決めるメソッドのうち一つで、フラグメントの内容でこのタグを置換することを意味します。

記述のややこしい(comments=${comments})の部分は変数${comments}commentsという名前で渡しています。
変数${comments}に入っているデータは、Controllerに追加した一文、modelAndView.addObject("comments", userComments.getValues());userComments.getValues())です。
modelAndView.addObject()は第一引数をデータの名前、第二引数をデータとしてthymeleafに渡すメソッドです。

では、使用するフラグメントを作成しましょう。
下記のように、templetes/fragments/thread.htmlを作成してください。

実装は次のとおりです。

<html xmlns:th="http://www.w3.org/1999/xhtml">
<div th:fragment="tableData(comments)" class="thread">
    <div th:each="comment : ${comments}" class="user-comments">
        <div class="row-item">
            <span th:text="${comment.id}" class="number-id"></span>
            <span th:text="${comment.name}" class="name"></span>
            <span th:text="${comment.dateTime}" class="date"></span>
            <span th:text="${comment.mailAddress}" class="address"></span>
        </div>
        <div th:text="${comment.comment}" class="text-area">
        </div>
    </div>
</div>
</html>

フラグメントの宣言(th:fragment)がtableData(comments)となっているのがわかるでしょうか。
このcommentsという引数に値を渡したかったので、渡すboard.html側では
fragments/thread(comments=${comments})
という渡し方をしています。

繰り返しになりますが、このcommentsは大本をたどるとControllerでDBから取得した値を放り込んだものになっています。
これを使うことで、DBに入ったデータをHTMLに動的にレンダリングすることができます。

そして、commentsは複数形の変数で宣言したとおり、リスト型のデータです。ですので、中身を取り出す必要があります。
そこで、th:eachを使います。th:eachの役割は、Javaのコードでいうと拡張for文にほぼ等しいです。右辺のデータを一つずつ取り出し、左辺に格納します。
取り出したデータの数だけ、<div th:each=""> ~ </div>の間を繰り返し描画します。

これでロジックの実装は終了です。お疲れさまでした。

が、これで表示させても味気ない画面が広がるだけです。少しだけ、某掲示板に寄せてみましょう。

CSSでデザインする

board.cssに下記の内容を追記しましょう。

.thread {
    font-size: 1rem;
    color: #333;
}

h2.title {
    color: #485269;
    font-size: 2rem;
}

.user-comments {
    display: flex;
    flex-direction: column;
    background-color: #efefef;
}
.user-comments:not(:last-child){
    margin-bottom: 0.15rem;
}
.row-item > span{
    margin-right: 0.2rem;
}
.row-item > .name {
    color: green;
    font-size: 1.2rem;
}
.row-item > .date {
    font-size: 0.9rem;
}
.row-item > .address {
    color: gray;
}

CSSを使ったデザインをするには、それ相応の勉強、つまりデザイナとしての勉強が必要です。
しかし、CSSを読む、理解するだけであればエンジニアとして可能です。
その一歩としては、まずCSSセレクタを理解することをおすすめします。

上記からいくつか例を出してみます。

クラスセレクタ

htmlタグのclass属性に対応するセレクタです。「.」とclass名で表現されます。
「.」がない場合、それは要素セレクタという別物になるので注意です。

.thread {
    font-size: 1rem;
    color: #333;
}

子セレクタ

「>」が子セレクタです。
下記の例の場合row-itemクラスがついたHTMLタグの直下の階層にあるspan要素にだけCSSが適用されます。

.row-item > span{
    margin-right: 0.2rem;
}

.row-item span というように、スペースで区切ると子孫セレクタになります。

疑似クラスlast-child擬似クラスE:not()

下記のCSSの理解は、擬似クラスを併用しているため少し難しいです。

.user-comments:not(:last-child){
    margin-bottom: 0.15rem;
}

.user-commentsはクラスセレクタです。それに加えて:notという擬似クラスが付け加えられます。:not(s)は、.user-commentsについて、sセレクタが該当しないとき有効化する、という効果を持ちます。
今回、sは:last-childになっています。:last-childは便利な擬似クラスで、適用した要素を「子」として扱ったとき、最後になる要素ならば有効という効果を持ちます。

これらを組み合わせると、「最後の.user-comments以外の.user-comments」にmargin-bottom: 0.15rem;を適用、ということになります。

この章の最後で良いので、後ほどブラウザの検証ツールで適用されているCSSを確認してみてください。

先頭の.user-commentsはmargin-bottomが付きますが、

最後になる.user-commentsはついていません。

[おまけ]データの初期値を加える

毎回毎回データをFormから登録するのも面倒くさいですよね。
アプリケーション起動時に入れてしまう方法があります。

application.ymlのschemaの下に、dataという項目を作りましょう。
そこに、classpath:h2/data.sqlを指定します。

    hikari:
      pool-name: ConnectionPool
      leakDetectionThreshold: 5000
      maximum-pool-size: 20
      minimum-idle: 10
      username: sa
      password:
      driver-class-name: org.h2.Driver
    schema: classpath:h2/schema.sql
    data: classpath:h2/data.sql # これ!!!!

data.sqlを作成します。

中身は次のとおりです。(実際保存するデータは自由で良いです)

INSERT INTO USER_COMMENT(NAME, MAILADDRESS, TEXT) VALUES
('nameA', 'example1@example.com', 'aaaa'),
('nameB', 'example2@example.com', 'bbbb');

INSERT文はデータをDBに保存するSQLでした。
このdata.sqlは、アプリケーションが立ち上がる時に実行され、自動的にDBにテストデータを取り込んでくれるという機能です。

本番環境の運用では役に立たないどころか、事故を招きますが、試験、開発環境で使う分には救われることも多いでしょう。用法用量を守ってご利用ください。

表示を確認する

すこしっぽさが出てきましたかね。。

おすすめ

thymeleafの機能はとても多いです。
チートシートを書いてくださっている方がいるので、紹介致します。何ができるか知っておくだけ知っておくと、後々知識を使えるのでおすすめです。

https://qiita.com/NagaokaKenichi/items/c6d1b76090ef5ef39482

今回のPR

https://github.com/angelica-keiskei/spring-sample/pull/7