😀

SpringBoot × MyBatis 中間テーブルへ登録する方法

2021/12/24に公開

#はじめに
今回は、自作している学習用チャットアプリで中間テーブルを実装したところ、かなり詰まったので、中間テーブルへの登録方法を備忘録として残そうと思います。
同じように悩んでいる方がいたら参考にしてみてください。

#アプリ概要
ログインユーザーが他の登録ユーザーを選択してチャットを開始するという、DMのようなものが主機能のアプリです。

  • リレーション
    test.png

usersテーブルとroomsテーブルが多対多の関係性のため、N+1問題が懸念されるので、中間テーブルとしてroom_usersテーブルを作成しました。

#環境

  • Spring2.5.5
  • gradle
  • MyBatis2.2.0
  • MySQL

#中間テーブルへのinsertを実装
今回の実装では、

①チャットルーム作成時に、roomsテーブルにチャットルーム情報を登録する
②作成者のユーザーIDと作成者が選択したユーザーのユーザーID、作成したチャットルームのIDを中間テーブルに登録する

上記2つの処理が必要です。

ということで実装内容を解説します。

##entity

MUser.java
@Data
public class MUser {

	private int id;
	private String name;
	private String email;
	private String password;
	private String passwordConfirmation;
	private String role;
	
	@DateTimeFormat(pattern = "yyyy_MM_dd HH:mm:ss")
	private LocalDateTime createdAt;
	
	@DateTimeFormat(pattern = "yyyy_MM_dd HH:mm:ss")
	private LocalDateTime updatedAt;
	
}
MRoom.java
@Data
public class MRoom {

	private int id;
	private String roomName;
	
	@DateTimeFormat(pattern = "yyyy_MM_dd HH:mm:ss")
	private LocalDateTime createdAt;
	
}
TRoomUser.java
@Data
public class TRoomUser {

	private int id;
	private int roomId;
	private int currentUserId;
	private int userId;
	
	@DateTimeFormat(pattern = "yyyy_MM_dd HH:mm:ss")
	private LocalDateTime createdAt;
	
}

entityクラスのポイントは中間テーブルのTRoomUserクラスです。
roomsテーブルのIDにroomId、チャットルーム作成者のIDをcurrentUserIdに、選択されたユーザーのIDをuserIdとして定義しています。

##RoomMapper

RoomMapper.java
@Mapper
public interface RoomMapper {
	
	/**チャットルーム登録*/
	public int insertOneRoom(MRoom room);

}
RoomMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- Mapperとxmlのマッピング -->
<mapper namespace="com.example.demo.repository.RoomMapper">
	<!-- マッピング定義(rooms) -->
	<resultMap type="com.example.demo.entity.MRoom" id="room">
		<id column="id" property="id"></id>
		<result column="room_name" property="roomName"></result>
		<result column="created_at" property="createdAt"></result>
		<collection property="roomUserList" resultMap="roomUser"></collection>
	</resultMap>

	
	<!-- チャットルーム登録 -->
	<insert id="insertOneRoom">
		insert into rooms (
			id,
			room_name,
			created_at
		) values (
			#{id,jdbcType=INTEGER},
			#{roomName,jdbcType=VARCHAR},
			#{createdAt,jdbcType=TIMESTAMP}
		)
		<selectKey resultType="int" keyProperty="id" order="AFTER">
      		select @@IDENTITY
  		</selectKey>
	</insert>
	
</mapper>

まず①の処理ですが、roomsテーブルへの登録なので単純にinsert文を作成するだけで大丈夫です。
また、selectKeyタグで主キーであるidを取得していますが、後ほど使用するために取得しています。

##RoomUserMapper

RoomUserMapper.java
@Mapper
public interface RoomUserMapper {

	public int insertRoomUser(TRoomUser roomUser);

}
RoomUserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- Mapperとxmlのマッピング -->
<mapper namespace="com.example.demo.repository.RoomUserMapper">

	<!-- マッピング定義(room_user) -->
	<resultMap type="com.example.demo.entity.TRoomUser" id="roomUser">
		<id column="id" property="id"></id>
		<result column="room_id" property="roomId"></result>
		<result column="current_user_id" property="currentUserId"></result>
		<result column="created_at" property="createdAt"></result>
		<result column="user_id" property="userId"></result>
	</resultMap>
	
	<!-- room_user登録 -->
	<insert id="insertRoomUser">
		insert into room_users (
			id,
			room_id,
			current_user_id,
			created_at,
			user_id
		) values (
			#{id,jdbcType=INTEGER},
			#{roomId,jdbcType=INTEGER},
			#{currentUserId,jdbcType=INTEGER},
			#{createdAt,jdbcType=TIMESTAMP},
			#{userId,jdbcType=INTEGER}
		)
	</insert>
</mapper>

中間テーブルもMapperに関してはroomsテーブルと同様、登録の処理を記述するだけでOKです。

##RoomService

RoomService.java
public interface RoomService {

	/**チャットルーム登録*/
	public void insertRoom(MRoom room, RoomForm form);

}
RoomServiceImpl.java
@Service
public class RoomServiceImpl implements RoomService {

	@Autowired
	private RoomMapper mapper;
	
	/**
	 *チャットルーム登録
	 */
	@Transactional
	@Override
	public void insertRoom(MRoom room, RoomForm form) {
		//チャットルーム名取得
		room.setRoomName(form.getRoomName());
		
		//現在時刻の取得
		LocalDateTime now = LocalDateTime.now();
		room.setCreatedAt(now);
		
		//チャットルーム登録
		mapper.insertOneRoom(room);
	}
	
}

Serviceクラスは一つにまとめることもできますが、Mapperと同じメソッド名になるとわかりにくくなるため、自分の場合は、インターフェースと実装クラスで分けています。

ロジックですが、チャットルーム名はフォームに入力された値を取得して、それをセットするだけです。
作成日時も現在時刻を取得し、それをセットするだけで簡単に実装できます。

最後にroomsテーブルに登録するためRoomMapperインターフェースの登録メソッドを呼び出して登録処理を行います。

##Form

RoomForm.java
@Data
public class RoomForm {

	@NotBlank
	private String roomName;
	
	private int userId;
}

チャットルーム登録画面のフォームクラスです。
自分の実装はプルダウンから、選択したユーザーとチャットができる仕様のため、プルダウンに入力されるユーザーの情報を取得するためuserIdを定義しています。

##RoomUserService

RoomUserService
public interface RoomUserService {

	/**room_user登録*/
	public void registRoomUser(RoomForm form, TRoomUser roomUser, @AuthenticationPrincipal UserDetailServiceImpll loginUser);
}
RoomUserServiceImpl.java
@Service
public class RoomUserServiceImpl implements RoomUserService {

	@Autowired
	private RoomUserMapper mapper;
	
	@Autowired
	private RoomService service;
	
	@Transactional
	@Override
	public void registRoomUser(RoomForm form, TRoomUser roomUser, @AuthenticationPrincipal UserDetailServiceImpll loginUser) {
		
		//formをMRoomクラスに変換
		MRoom room = new MRoom();
				
		//チャットルーム登録
		service.insertRoom(room, form);
		
		//ログインユーザーのユーザーID取得
		int currentUserId = loginUser.getUser().getId();
		
		//roomsテーブルのIDを設定(FK)
		roomUser.setRoomId(room.getId());
		//ログインユーザーのIDを設定
		roomUser.setCurrentUserId(currentUserId);
		//プルダウン選択されたユーザーIDを設定
		roomUser.setUserId(form.getUserId());
		
		//現在時刻の取得
		LocalDateTime now = LocalDateTime.now();
		roomUser.setCreatedAt(now);
		
		//roomUserTBL登録
		mapper.insertRoomUser(roomUser);
	}
}

少し処理が多めですが、中間テーブルへの登録ロジックです。
ポイントは、このロジックの中でRoomServiceインターフェースinsertRoom()メソッドを呼び出している点です。

これにより、roomsテーブルの登録と同時にroom_usersテーブルの登録も行うことができ、roomsテーブルのidを取得して、room_usersテーブルのroomIdカラムに値を設定できます。

また、roomsテーブルに登録するメソッド(insertRoom())の前にroomsテーブルのエンティティのインスタンス(MRoom room = new MRoom();)を作成しておくことも重要です。

これがないと、チャットルーム登録処理時に引数としてMRoomのエンティティを渡せないため登録処理自体が行えないため、最初にインスタンスを作成することが必要です。

その後、UserDetailServiceImpllクラスで取得したログインユーザーのIDを取得、フォームから送られるユーザーIDを取得して、各自セッターで中間テーブルへ登録する値を設定します。

最後に、中間テーブルのMapperに定義している登録メソッドを呼び出せば、roomsテーブルの登録と同時に、中間テーブルへも値を登録できるというロジックが完成します。

なお、ログインユーザーの取得については、以下の記事で詳しく解説しているので、参考にしてみてください。

SpringSecurityとSpringBootでログイン認証と投稿機能を実装する

##RoomController

RoomController
@Controller
@RequestMapping("/")
@Slf4j
public class RoomController {
	
	@Autowired
	private UserService userService;
	
	@Autowired
	private RoomService roomService;
	
	@Autowired
	private RoomUserService roomUserService;


	@GetMapping("/rooms/new")
	public String getRoomsNew(Model model, @ModelAttribute("form") RoomForm form, @AuthenticationPrincipal UserDetailServiceImpll loginUser) {
		
		//ログインユーザーのユーザーID取得
		int currentUserId = loginUser.getUser().getId();
		
		//ユーザー取得(複数件)
		List<MUser> users = userService.getUsers(currentUserId);
		model.addAttribute("users", users);
		
		return "rooms/new";
	}
	
	@PostMapping("/rooms/new")
	public String postRoomsNew(Model model, @Validated @ModelAttribute("form") RoomForm form, BindingResult result, TRoomUser roomUser, @AuthenticationPrincipal UserDetailServiceImpll loginUser) {
		
		//入力チェック
		if(result.hasErrors()) {
			/* NG:チャットルーム作成画面に戻る*/
			return "redirect:/rooms/new";
		}
		
		log.info(form.toString());
		
		//チャットルーム・roomUserTBL登録
		roomUserService.registRoomUser(form, roomUser, loginUser);
		
		return "redirect:/";
	}
}

コントローラーの処理ですが、postRoomsNew()メソッドから解説します。
ここでは単純にバリデーションのチェックと、ロジックの呼び出しを行うだけです。

次に、getRoomsNew()メソッドの解説ですが、UserServiceのgetUsers()メソッド`がプルダウンを実装する上で重要になってくるので、解説します。

先に結論から言うと、このメソッドの処理はログインユーザー以外のユーザーを取得するメソッドです。

###UserMapper

UserMapper.java
@Mapper
public interface UserMapper {

	/**ログインユーザー以外のユーザー取得(複数件)*/
	public List<MUser> findMany(int id);
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!-- Mapperとxmlのマッピング -->
<mapper namespace="com.example.demo.repository.UserMapper">

	<!-- マッピング定義(ユーザー) -->
	<resultMap type="com.example.demo.entity.MUser" id="user">
		<id column="id" property="id"></id>
		<result column="email" property="email"></result>
		<result column="password" property="password"></result>
		<result column="password_confirmation" property="passwordConfirmation"></result>
		<result column="name" property="name"></result>
		<result column="role" property="role"></result>
		<result column="created_at" property="createdAt"></result>
		<result column="updated_at" property="updatedAt"></result>
	</resultMap>
	
	<!-- ログインユーザー以外のユーザー取得(複数件) -->
	<select id="findMany" resultType="MUser">
		select
			*
		from users
		where not
			id = #{id}
	</select>
</mapper>

ポイントはxmlファイルのSQLです。
WHERE NOT句で条件を指定することで、引数に指定されるID以外の値を取得することができます。

###UserService

UserService.java
public interface UserService {

/**ログインユーザー以外のユーザー取得(複数件)*/
	public List<MUser> getUsers(int id);
}
UserServiceImpl.java
@Service
public class UserServiceImpl implements UserService {

	@Autowired
	private UserMapper mapper;
	
/**ログインユーザー以外のユーザー取得(複数件)*/
	@Override
	public  List<MUser> getUsers(int id) {
		return mapper.findMany(id);
	}
}

##View

new.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ChatApp</title>
<link rel="stylesheet" th:href="@{/css/rooms/room.css}">
</head>
<body>
	<div class='chat-room-form'>
		<h1>新規チャットルーム</h1>
		<form th:action="@{/rooms/new}" method="post" th:object="${form}">
			<div class='chat-room-form__field'>
				<div class='chat-room-form__field--left'>
					<label for="roomName" th:text="#{roomName}" class="chat-room-form__label"></label>
				</div>
				<div class='chat-room-form__field--right'>
					<input type="text" th:field="*{roomName}" th:errorclass="is-invalid" class="chat__room_name chat-room-form__input" placeholder="チャットルーム名を入力してください">
				</div>
				<div class="invalid-feedback" th:errors="*{roomName}"></div>
			</div>
			<div class='chat-room-form__field'></div>
			<div class='chat-room-form__field'>
				<div class='chat-room-form__field--left'>
					<label class='chat-room-form__label' for='chat_room_チャットメンバー'>チャットメンバー</label>
				</div>
				<div class='chat-room-form__field--right'>
					<select id="userId" name="userId">
						<option value="">チャットするユーザーを選択してください</option>
						<option th:each="user: ${users}" th:value="${user.id}" th:text="${user.name}"></option>
					</select> 
				</div>
			</div>
			<div class='chat-room-form__field'>
				<div class='chat-room-form__field--left'></div>
				<div class='chat-room-form__field--right'>
					<input type="submit" name="commit" class="chat-room-form__action-btn">
				</div>
			</div>
		</form>
	</div>
</body>
</html>

ポイントはプルダウンのselectタグの部分です。
通常のフォームタグ内ならth:field="*{userId}"としますが、それだとエラーになるため、selectタグの場合はid属性とname属性に入力させたい(DBに送りたい)値を設定します。

プルダウンの初期値を設定する方法は色々ありますが、自分の場合は、シンプルにoptionタグを二つ作り、一つ目のvalue属性の値を空にして設定しています。

二つ目のoptionタグでは、送信したい値をth:value属性に指定し、表示させたい内容をth:name属性に指定します。

先ほど、コントローラーで呼び出したgetUsers()メソッドはList型のため、th:each属性で値を一つずつ表示・取得できるようにしています。

これで、中間テーブル+αの実装は完了です。
自分の場合はこのロジックを考え出すのに半日くらいかかり、実装するのに3時間くらいかかりました。

まだまだJavaであったりSpringBootの文献は非常に少ないので、同志がいたら参考になれば幸いかと思います。

Discussion