😀

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

2022/01/08に公開

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

#環境

  • SpringBoot 2.5.5
  • MySQL 5.6.51
  • MyBatis 2.2.0
  • thymeleaf

#ER図
test.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

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

MessageMapper.java
@Mapper
public interface MessageMapper {

	/**メッセージ登録*/
	public int insertOneMessage(TMessages message);
}
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

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

}
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でログイン認証と投稿機能を実装する

##form

MessageForm.java
@Data
public class MessageForm {

	@NotBlank
	private String content;
	
	//画像入力時
	private MultipartFile multiPartFile;
}

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

##Controller

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'**とエラーが出ます。

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

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

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に画像を保持する

springboot + mybatis で画像をDBへアップロードする方法

MySQLのカラム型(有効範囲と必要記憶容量)

Discussion