Chapter 09

Userの概念をデータに紐付ける / REST API

この章で学ぶこと

DB認証を使ってログイン、ログアウトの概念を作ることができました。
しかしながら、現時点でこの認証はほとんど意味がありません。

せっかく認証という概念があるのですから、ユーザと投稿内容のデータを組み合わせてみましょう。
そうすれば、自分の書き込みの一覧をとるとか、書き込んだ誰かの一覧を取る、といったことができるようになります。(需要があるかは置いておくとして)

  • Userのデータとしてコメントを管理するにはどうすればいいか
  • ログイン中のユーザの書き込みを返すAPIを作る

Userのデータとしてコメントを管理するにはどうすればいいか

そのコメントがそのユーザであるものかどうかを判断するには、UserテーブルとUserCommentテーブルの間に関係性があればいいわけですね。

なので、UserテーブルのプライマリキーであるuserNameをUserCommentに入れてしまえば良さそうです。
そうすれば、ログインしているユーザの名前でUserCommentを探すことができます。

UserCommentテーブルを拡張する

まず、UserCommentにログインIDでもあるUserNameが入力できるようにしましょう。

しかし、UserNameという名前では既存からあるNameと差がわかりませんので、USER_ID という名前で管理するようにします。
schema.sqlに、USER_IDを追加します。

CREATE TABLE IF NOT EXISTS USER_COMMENT (
    ID NUMBER(10) AUTO_INCREMENT, -- 主キーとしてふさわしいものがないのでIDを採番
    USER_ID VARCHAR2(50) NOT NULL,
    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とする)
);

この結果、data.sqlに矛盾が生じますので、USER_COMMENTに対するINSERTを修正します。

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

このように、INSERT文を変更しておきましょう。

[TIPS]外部キー制約について

RDBMSには、主キー制約(PK)の他に、外部キー制約(FK)というものが存在します。
比較的、初心者向けのコンテンツやプログラミングスクール等でも取り上げられており、実際あったほうがデータをきれいに管理できるというメリットがあります。

が、このFKの導入は本番では相当に検討しなければならない要注意のオプションです。
例えば、あるDBでは外部キー制約をつけるとパーティションという機能が使えません。この結果、高負荷なアクセスに対する耐性が下がります。
例えば、DBマイグレーション時のテーブルロックの危険性が高まります。この結果、無停止でサービスのバージョンアップができなくなるかもしれません。
例えば、データ変更、削除のユースケースの管理を厳格に行う必要があります。

つまり、本当にデータの整合性を厳格に守る必要のあるところにつけるべきオプションであり、何から何までこの外部キー制約を使っていると大変な目にあってしまうでしょう。
運用するチームのDBに関する知識レベルに合わせて、用法用量を守ってお使いください。

この種の話題として有名なのは、SQLアンチパターンの「キーレスエントリ」がそれに当たります。
また、内容が難しいですが外部キーの扱いについてはこちらの記事も大変有用です。

https://blog.j5ik2o.me/entry/2020/06/16/105311

この本ではこれ以上触れないでおきます! すいません!

UserIDを扱うオブジェクト

例によって、UserIDのためのドメインオブジェクトを用意しましょう。
ロジックがなにもないなら値オブジェクトでもよいですが、少しさせたいことがあるのでビジネスにします

内容は下記です。

package chalkboard.me.bulletinboard.domain.model;

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

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class UserId {
  private final String value;

  public static UserId from(String userName) {
    return new UserId(userName);
  }

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

  /**
   * fnv132ハッシュによる変換
   * @return fnv132ハッシュされた文字列
   */
  public String toHash() {
    final int FNV_32_PRIME = 0x01000193;
    int hval = 0x811c9dc5;

    byte[] bytes = value.getBytes();

    int size = bytes.length;

    for (byte aByte : bytes) {
      hval *= FNV_32_PRIME;
      hval ^= aByte;
    }
    return Integer.toHexString(hval);
  }
}

toHashは某掲示板にあるようなIDの代わりに、ユーザ名からハッシュを作成しています。
これはFowler–Noll–Vo hashという軽量なハッシュアルゴリズムです。今回は生成される文字列長が短いというだけでこれを選び、実装しました。
これを理解する必要はないですが、Web開発をしている時、こういったアカデミックな側面が覗くことは多々あります

UserCommentを扱っている箇所の修正

UserIDを使えるように、各所を修正しましょう。

まず、DBに入れたり取ったりするDatasourceです。

  • 登録時のUserCommentクラスにUserIDを追加
  • 取得時のUserCommentReadDtoクラス, UserCommentsクラスにUserIDを追加
    する必要がありそうですね。

まずUserCommentを修正してみます。

package chalkboard.me.bulletinboard.domain.model;

import chalkboard.me.bulletinboard.domain.type.Comment;
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.util.Random;

@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class UserComment {
  private final Name name;
  private final UserId userId;
  private final MailAddress mailAddress;
  private final Comment comment;

  public Name getName() {
// 省略
  }

  public static UserComment from(String name, String userId, String mailAddress, String comment) {
    return new UserComment(
        Name.from(name),
        UserId.from(userId),
        MailAddress.from(mailAddress),
        Comment.from(comment)
    );
  }
}

これで良さそうです。
IDEを使っていれば、この時点で UserCommentUseCase クラスで引数変動によるコンパイルエラーが出ていることがわかります。

引数を解決するために、認証ユーザのUserNameを渡すようにしてみましょう。

認証中のユーザの情報を受取る

そもそも、どうやって認証中(ログイン中)のユーザの情報を取ればいいのでしょうか。
それには、SpringSecurityの@AuthenticationPrincipalアノテーションを利用します。

このアノテーションはControllerで使う必要があるため、BoardControllerを変更しましょう。

package chalkboard.me.bulletinboard.presentation;

import chalkboard.me.bulletinboard.application.form.CommentForm;
import chalkboard.me.bulletinboard.application.usecase.UserCommentUseCase;
import chalkboard.me.bulletinboard.domain.model.UserComments;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequiredArgsConstructor
public class BoardController {
  private final UserCommentUseCase userCommentUseCase;

  // 省略

  @PostMapping("/board")
  public ModelAndView postComment(
      @AuthenticationPrincipal User user,
      @Validated @ModelAttribute CommentForm comment,
      BindingResult bindingResult) {
    if(bindingResult.hasErrors()) {
      ModelAndView modelAndView = new ModelAndView("/board");
      modelAndView.addObject("commentForm", comment);
      return modelAndView;
    }
    // エラーが無ければ保存する
    userCommentUseCase.write(comment, user);
    return new ModelAndView("redirect:/board");
  }
}

そして、UserCommentUseCaseクラスでUserを受け取れるように拡張します。

package chalkboard.me.bulletinboard.application.usecase;

import chalkboard.me.bulletinboard.application.form.CommentForm;
import chalkboard.me.bulletinboard.domain.model.UserComment;
import chalkboard.me.bulletinboard.domain.model.UserCommentRepository;
import chalkboard.me.bulletinboard.domain.model.UserComments;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserCommentUseCase {
  private final UserCommentRepository repository;

  /**
   * ユーザの書き込みをDBに反映し、表示するデータをプレゼンテーション層に渡す
   * @param commentForm ユーザの入力データ
   * @return 表示するデータ
   */
  public void write(CommentForm commentForm, User user) {
    // フォームオブジェクトからドメインオブジェクトへ変換
    UserComment userComment = UserComment.from(
        commentForm.getName(),
        user.getUsername(),
        commentForm.getMailAddress(),
        commentForm.getComment()
    );

    // 例えばここで、直近の投稿の一覧を取得し、今回と同じ内容の投稿がないかチェックする

    repository.save(userComment);
  }

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

これで、DBに保存する手前までUserIDを持ってくる事ができたはずです。
最後に、UserCommentDtoinsertUserComment.sqlを修正しましょう。

public class UserCommentDto {
  private final String name;
  private final String userId;
  private final String mailAddress;
  private final String comment;

  public static UserCommentDto from(
      UserComment userComment) {
    return new UserCommentDto(
        userComment.getName().toString(),
        userComment.getUserId().toString(),
        userComment.getMailAddress().toString(),
        userComment.getComment().toString()
    );
  }
}
INSERT INTO USER_COMMENT(NAME, USER_ID, MAILADDRESS, TEXT) VALUES (
  /*[# mb:p="dto.name"]*/ 'name' /*[/]*/,
  /*[# mb:p="dto.userId"]*/ 'userId' /*[/]*/,
  /*[# mb:p="dto.mailAddress"]*/ 'mailaddress' /*[/]*/,
  /*[# mb:p="dto.comment"]*/ 'text' /*[/]*/
);

これで、DBには書き込んだユーザのuserIdが保存されます。

UserCommentReadDto, UserCommentsの修正

次に、読み取り側を対応しましょう。

読み取り時はすべてをSelectで取得するので、まず受け皿のUserCommentReadDtoを修正します。

package chalkboard.me.bulletinboard.application.dto;

import chalkboard.me.bulletinboard.domain.model.UserComments;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

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

  /**
   * RestAPIのレスポンス用変換
   * @param comments
   * @return
   */
  public static List<UserCommentReadDto> from(UserComments comments) {
    return comments.getValues().stream().map(
        comment -> new UserCommentReadDto(
            comment.getId(),
            comment.getUserId().toString(),
            comment.getName().toString(),
            comment.getMailAddress().toString(),
            comment.getComment().toString(),
            LocalDateTime.parse(comment.getDateTime().toString())
        )
    ).collect(Collectors.toUnmodifiableList());
  }
}

APIのレスポンスには、ドメインオブジェクトやバリューオブジェクトはそのままでは使うことができません。その理由は後述します。

そして、DTO変換先のUserCommentsを対応します。

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 org.springframework.util.CollectionUtils;

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 UserId userId;
    private final Name name;
    private final MailAddress mailAddress;
    private final Comment comment;
    private final DateTime dateTime;

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

コンパイルエラーを解決しましょう。 UserCommentDatasourceのselectメソッドを修正します。

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

これで、thymeleafでもつかっているUserCommentクラスまでuserIdが浸透しました。

UserIdを表示してみる

thread.htmlのmailAddress表示タグの下に、下記のコードを追加してみましょう。

<span th:text="${comment.userId.toHash()}" class="address"></span>

adminユーザのUserIdをハッシュ化したものを表示することができました。

ログイン中のユーザの書き込みを返すAPIを作る

最後に、REST APIを作成してみましょう。

せっかくユーザ投稿の識別が可能になったので、ユーザの投稿したコメントだけを返す、というAPIを作成します。

SQL

selectMyComment.sqlを作成しましょう。

SELECT * FROM USER_COMMENT where USER_ID = /*[# mb:p="userId"]*/ 1 /*[/]*/

SELECTした結果を絞り込むには、where句を使います。
これは、指定したUserIDのUSER_COMMENTしか返さないためのSQLです。

Mapper/Datasource

SQLファイルが増えたらMapperも増えます。

@Mapper
public interface UserCommentMapper {
  @Insert("sql/insertUserComment.sql")
  void insert(@Param("dto") UserCommentDto dto);

  @Select("sql/selectUserComment.sql")
  List<UserCommentReadDto> select();

  @Select("sql/selectMyComment.sql")
  List<UserCommentReadDto> selectById(@Param("userId") String userId);
}

このようにしておきましょう。

そして、UserCommentDatasourceを修正したいので、インタフェースを拡張します。

public interface UserCommentRepository {
  void save(UserComment dto);
  UserComments select();
  UserComments select(UserId userId);
}

Datasourceは下記のように少しリファクタリングしておきます。

package chalkboard.me.bulletinboard.infrastructure.datasource;

import chalkboard.me.bulletinboard.application.dto.UserCommentDto;
import chalkboard.me.bulletinboard.application.dto.UserCommentReadDto;
import chalkboard.me.bulletinboard.domain.model.UserComment;
import chalkboard.me.bulletinboard.domain.model.UserCommentRepository;
import chalkboard.me.bulletinboard.domain.model.UserComments;
import chalkboard.me.bulletinboard.domain.model.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Repository
public class UserCommentDatasource implements UserCommentRepository {
  private final UserCommentMapper mapper;

  @Override
  public void save(UserComment userComment) {
    mapper.insert(UserCommentDto.from(userComment));
  }

  @Override
  public UserComments select() {
    List<UserCommentReadDto> dtos = mapper.select();
    return convert(dtos);
  }

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

これを使っているUserCommentUseCaseに、下記のメソッドを追加しましょう。

  public UserComments read(UserId userId) {
    return repository.select(userId);
  }

これで準備は完了です。

RestController

UserApiControllerを作成しましょう。

package chalkboard.me.bulletinboard.presentation;

import chalkboard.me.bulletinboard.application.dto.UserCommentReadDto;
import chalkboard.me.bulletinboard.application.usecase.UserCommentUseCase;
import chalkboard.me.bulletinboard.domain.model.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/user")
public class UserApiController {
  private final UserCommentUseCase useCase;

  @GetMapping("/my")
  public List<UserCommentReadDto> myComments( @AuthenticationPrincipal User user) {
    return UserCommentReadDto.from(useCase.read(UserId.from(user.getUsername())));
  }
}

これでAPIが提供できました。
applicationを起動、ADMINユーザでログインして

http://localhost:8080/v1/user/my
にアクセスしてみましょう。

すると、下記のような文字列が表示されるはずです。

※きれいな表示にするにはプラグインが必要です

適当なユーザを作成し、コメントを投稿してからもう一度行ってみましょう。

別のユーザで実行したので、adminユーザの投稿は含まれていません。

API(Web API)

これが、Web APIと呼ばれるものです。
ある一定のルールに従ったWebAPIを REST API またはより厳密なことを表して RESTful API と呼びます。

昨今では、フロントエンド側でReact.jsVue.jsを始めとしたクライアントサイドレンダリングと呼ばれる技術がとても発達しました。
これに伴い、Webアプリケーションはフロントとバックエンドが明確に別れて設計されることも多くなっています。この場合、今回のようにSpringBootがThymeleafを使ってHTMLをレンダリングすることは必要最低限に抑えられるか、全くなくなります。

代わりに、フロントエンドで動くReact.jsなどからつかわれるのがWebAPIです。

先程の文字列はJSONと呼ばれる形式で、様々なプログラミング言語でサポートされており、文字列とオブジェクトの相互変換を用意にしています。
シリアライズ/デシリアライズ、といいます

このJSONをつかって、React.jsは表示したい情報を受け取り、SpringBootはDBから情報を引いたり保存したり、あるいは計算したりといったロジックに集中することができるようになります。

ドメインオブジェクト、バリューオブジェクトをレスポンスにする

単純に、ドメインオブジェクトやバリューオブジェクトをレスポンスにしようとすると下記のような状態になります。

JSONへシリアライズしてくれているライブラリであるJacksonを設定することである程度回避することもできますが、なれないうちは今回のようにPOJOを使うとシンプルと思います。

今回のPR

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