MyBatis × SpringBoot チャットアプリでメッセージ・画像送信機能の作成

#はじめに
今回は、自作のチャットアプリで主機能となるメッセージ・画像送信機能の実装を行なったので、実装にあたり詰まったところと解説をアウトプットしていこうと思います。

#環境
- SpringBoot 2.5.5
- MySQL 5.6.51
- MyBatis 2.2.0
- thymeleaf

#ER図
![test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/879778/e2284666-c6b2-1d52-62d6-ae3283747e27.png)

#テーブル定義
## usersテーブル

| Column        | Type    | Options  |
| ------------ | ------- | ---------|
| id(PK・FK)     | BIGINT  | NOT NULL | 
| email         | VARCHAR | NOT NULL |
| password      | VARCHAR | NOT NULL |
| password_conf | VARCHAR  | NOT NULL |
| role          | VARCHAR  |          |
| created_at    | DATETIME |          |
| updated_at    | DATETIME |          |

## roomsテーブル

| Column     | Type     | Options     |
| ---------- | -------- | ----------- |
| id(PK・FK)  | BIGINT   | NOT NULL    |
| room_name  | VARCHAR  | NOT NULL    |
| created_at | DATETIME |             |

## room_usersテーブル

| Column                | Type       | Options  |
| --------------------- | ---------- | -------- |
| id(PK)                | BIGINT     | NOT NULL |
| room_id(FK)           | BIGINT     | NOT NULL |
| current_user_id(FK)   | BIGINT     | NOT NULL |
| user_id               | BIGINT     | NOT NULL |
| created_at            | DATETIME   |          |


## messagesテーブル

| Column      | Type     | Options  |
| ----------- | ---------| -------- |
| content     | VARCHAR  | NOT NULL |
| image       | LONGBLOB |          |
| room_id(FK) | BIGINT   | NOT NULL |
| user_id(FK) | BIGINT   | NOT NULL |
| created_at  | DATETIME |　          |
| updated_at  | DATETIME |          |

#メッセージ送信機能実装

##Entity
```java:TMessage.java
@Data
public class TMessages {

	private int id;
	private String content;
	private int roomId;
	private int userId;
	private byte[] image;
	
	@DateTimeFormat(pattern = "yyyy_MM_dd HH:mm:ss")
	private LocalDateTime createdAt;
	
	@DateTimeFormat(pattern = "yyyy_MM_dd HH:mm:ss")
	private LocalDateTime updatedAt;
	
```

ここでの注意点は、`imageフィールド`の型を`byte[]`とすることです。
画像データはbyte[]で扱うことが多いので、このように定義します。

##Mapper
```java:MessageMapper.java
@Mapper
public interface MessageMapper {

	/**メッセージ登録*/
	public int insertOneMessage(TMessages message);
}
```

```xml:MessageMapper.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.MessageMapper">
	
	<!-- マッピング定義(messages) -->
	<resultMap type="com.example.demo.entity.TMessages" id="message">
		<id column="id" property="id"></id>
		<result column="content" property="content"></result>
		<result column="image" property="image"></result> 
		<result column="room_id" property="roomId"></result>
		<result column="user_id" property="userId"></result>
		<result column="created_at" property="createdAt"></result>
		<result column="updated_at" property="updatedAt"></result>
	</resultMap>
	
	<!-- メッセージ登録 -->
	<insert id="insertOneMessage">
		insert into messages (
			id,
			content,
			image,
			room_id,
			user_id,
			created_at,
			updated_at
		) values (
			#{id,jdbcType=INTEGER},
			#{content,jdbcType=VARCHAR},
			#{image,jdbcType=BLOB},
			#{roomId,jdbcType=INTEGER},
			#{userId,jdbcType=INTEGER},
			#{createdAt,jdbcType=TIMESTAMP},
			#{updatedAt,jdbcType=TIMESTAMP}
		)
	</insert>

</mapper>
```
ここは見たまんまかなと思います。

##Service
```java:MessageService.java
public interface MessageService {
	
	/**メッセージ登録*/
	public void insertMessage(TMessages message, MessageForm form, @AuthenticationPrincipal UserDetailServiceImpll loginUser, int roomId);

}
```

```java:MessageServiceImpl.java
@Service
public class MessageServiceImpl implements MessageService {
	
	@Autowired
	private MessageMapper mapper;

	@Transactional
	@Override
	public void insertMessage(TMessages message, MessageForm form,
			@AuthenticationPrincipal UserDetailServiceImpll loginUser, int roomId) {
		//フォームから入力値取得
		message.setContent(form.getContent());
		
		//チャットルームのID設定
		message.setRoomId(roomId);

		//ログインユーザーのID取得
		int userId = loginUser.getUser().getId();

		//ログインユーザーID設定
		message.setUserId(userId);

		//現在時刻の取得
		LocalDateTime now = LocalDateTime.now();
		message.setCreatedAt(now);
		message.setUpdatedAt(now);
		
		//メッセージ登録
		mapper.insertOneMessage(message);
	}
}
```

ここも見たまんまなので、解説は割愛させていただきます。
なお、`@AuthenticationPrincipalアノテーション`の詳しい実装については、以下の記事にまとめてあるので、よかったら、読んでみてください

> [SpringSecurityとSpringBootでログイン認証と投稿機能を実装する](https://qiita.com/curry__30/items/c63df79b2da94cce9af2)

##form
```java:MessageForm.java
@Data
public class MessageForm {

	@NotBlank
	private String content;
	
	//画像入力時
	private MultipartFile multiPartFile;
}
```
こちらがフォームクラスになります。
普通のフォームクラスなら、入力値とエンティティのフィールド名を合わせますが、画像データの入力となるため、`MultipartFile型`を指定したフィールドを作成しています。

##Controller
```java:MessageController.java
@Controller
@RequestMapping("/")
@Slf4j
public class MessageController {

	@Autowired
	private MessageService service;

	@Autowired
	private RoomService roomService;

	@PostMapping("/rooms/{roomId}/message")
	public String postMessage(Model model, TMessages message, @Validated @ModelAttribute("form") MessageForm form,
			BindingResult result, @AuthenticationPrincipal UserDetailServiceImpll loginUser,
			@PathVariable("roomId") int roomId) throws IOException {

		//チャットルーム1件取得
		MRoom room = roomService.getRoomOne(roomId);
		
		if (result.hasErrors()) {
			//NG:メッセージ送信画面にリダイレクト
			return "redirect:/rooms/{roomId}";
		}

		log.info(form.toString());

		//画像データをフォームから取得し設定
		message.setImage(form.getMultiPartFile().getBytes());
		service.insertMessage(message, form, loginUser, room.getId());

		return "redirect:/rooms/{roomId}";
	}
}
```
私の場合は、このコントローラー部分の実装で詰まりました。
というのも、messagesテーブルには、外部キーで参照しているroom_idの登録が必要だったため、その値の取得が中々うまくいかず、サービスクラスと行ったり来たりを繰り返してました(笑)

ポイントとなるのは`@PathVariableアノテーション`の部分です。
より詳しく言うと、「roomsテーブルのレコード1件取得するようなロジックにしないといけない」ということです。

例えば、roomsテーブルの値を1件取得するだけなら、`@PathVariable("id") int id`という記述でもroomsテーブルの値自体は取得できます。
ただ、messagesテーブルに値を登録するとなると、上記の記述だと、1回目は登録できても、2回目のメッセージ登録時にエラーが出ます。

この理由としては、roomsテーブルのidカラム(PK)を参照しているがために、messagesテーブルのidカラム(PK)にも参照しているroomsテーブルのidカラムの値を登録してしまうためです。
つまり、一意であるはずの**主キーカラムに同じ値が登録され続けてしまう**という事態になり、**Duplicate entry 'x(数値)' for key 'PRIMARY'**とエラーが出ます。

ここで、チャットルーム詳細画面遷移のロジックを実装している、コントローラーを見てみましょう。

```java:RoomController.java
@Controller
@RequestMapping("/")
@Slf4j
public class RoomController {

	@Autowired
	private UserService userService;

	@Autowired
	private RoomService roomService;

	@Autowired
	private RoomUserService roomUserService;

	@GetMapping("/rooms/{roomId}")
	public String getRoom(Model model, @AuthenticationPrincipal UserDetailServiceImpll loginUser, @PathVariable("roomId") int id, @ModelAttribute("form") MessageForm form) {
		
		//ログインユーザーの情報を取得
		String username = loginUser.getUser().getName();
		int loginUserId = loginUser.getUser().getId();
		
		model.addAttribute("username", username);

		//ログインユーザーと選択されたユーザーが保有するチャットルームを取得
		List<MRoom> rooms = roomService.getLoginUserRooms(loginUser);
		model.addAttribute("rooms", rooms);
		
		//room_usersテーブルのレコード(1件)取得
		TRoomUser roomUser = roomUserService.getRoomUserOne(id);
		
		//room_usersに登録されているログインユーザーのIDを取得
		int currentUserId = roomUser.getCurrentUserId();
		//room_usersに登録されているチャットするユーザーのIDを取得
		int userId = roomUser.getUserId();
		
		//ログインユーザーとroom_usersのログインユーザーID、またはログインユーザーとチャット選択されたユーザーのIDが等しい時メッセー送信画面に遷移する
		if(loginUserId == currentUserId || loginUserId == userId) {
			return "redirect:/rooms/{roomId}";
		}
		
		return "redirect:/";
	}
}
```

MessageControllerと`@PathVariableアノテーション`部分を比較すると違っているのがわかると思います。
こちらは、Roomsのコントローラークラスであるために、`@PathVariable("roomId") int id`としていますが、messagesのコントローラークラスでは`@PathVariable("roomId") int roomId`としています。

これで、明確に参照しているカラムを分けることができるため、主キーがダブるエラーは発生しなくなります。
具体的には、RoomControllerはroomsのidカラムを参照し、MessageControllerではmessageのroom_idカラムを参照するようにしています。

あとはサービスクラスのメソッドを呼び出せば機能実装が完了するわけですが、あと1つ注意点があります。
それは、サービスクラスのメソッド呼び出し時の引数に `room.getId()`を渡してあげることです。
さらにいうと、その前に、RoomServiceクラスのメソッドを呼び出し、roomsのレコードを1件取得するロジックを加える必要もあります。

そうしないと、メッセージ送信時に**room_idカラムがnull**というエラーが出るので、記述する必要があります。

##View
```html:main_chat.index
<form class="form" th:action="@{'/rooms/'+${roomId}+'/message'}" method="post" th:object="${form}" enctype="multipart/form-data">
  <div class="form-input">
    <input th:field="*{content}" th:errorclass="is-invalid" class="form-message" placeholder="type a message">
    <div class="invalid-feedback" th:errors="*{content}"></div>
	<label class="form-image"> 
	    <span class="image-file">画像</span> 
	    <input th:field="*{multiPartFile}" type="file" class="hidden">
	</label>
  </div>
  <input class="form-submit" type="submit" name="commit" value="送信">
</form>
```

ポイントは画像投稿の部分です。`formタグ`の`enctype属性`を`enctype="multipart/form-data"`とすることで.pngなどの画像データを送信できるようになります。
あとは、formクラスで定義しているフィールドをinputタグに指定するだけで画像の登録はできます。

以上で解説は終了です。
最後までありがとうございました。

#参考
>・[Java SpringBootで、MultipartFileクラスを使いDBに画像を保持する](https://teratail.com/questions/212403)

>・[springboot + mybatis で画像をDBへアップロードする方法](https://teratail.com/questions/364945)

>・[MySQLのカラム型（有効範囲と必要記憶容量）](https://phpjavascriptroom.com/?t=mysql&p=columntype)