はじめに
Spring Boot学習中の者です。学習のためにアプリケーションを作成していて、いいね機能を実装する上で考えたこと等をまとめました。
参考文献
使用技術
Spring Boot 2.6.6
Spring Security
Thymeleaf
MySQL
Mybatis 2.2.0
Vue.js
Axios
いいね機能のロジック
以下の要件を満たすようないいね機能を実装しました。
1.ユーザは、一つの投稿に対し1回「いいね」を押すことができる。
2.ユーザが対象の投稿に既に「いいね」を押していた場合、その「いいね」は取り消される。
テーブル
今回の例で使う「ユーザの投稿情報」を持つPOSTテーブルと、「ユーザがいいねした投稿情報」を持つLIKESテーブルの構成です。
POSTテーブル(主キー:POSTID)
カラム名 | POSTID | USERNAME | NICKNAME | CONTENT | POSTCATEGORY | CREATEAT |
---|---|---|---|---|---|---|
型 | BIGINT | VARCHAR | VARCHAR | VARCHAR | INT | DATETIME |
説明 | 投稿を一意に識別する | ユーザ名 | ユーザが決めたニックネーム | 投稿内容 | 投稿種別 | 投稿作成日時 |
LIKESテーブル(主キー:POSTID & USERNAME)
カラム名 | POSTID | USERNAME | RATE |
---|---|---|---|
型 | BIGINT | VARCHAR | INT |
説明 | 投稿ID | ユーザ名 | いいねフラグ (0か1が入る) |
バックエンド
Mapper
@Mapper
public interface PostMapper {
//一つの投稿を取得
PostRecord findOnePostRecord(long postId);
//ユーザと一つの投稿に対する「いいね」の状態を取得
Optional<Integer> currentRate(@Param("postId")long postId,
@Param("userName")String userName);
//一つの投稿の「総いいね数」を取得
Optional<Integer> sumRate(@Param("postId")long postId);
//ユーザと一つの投稿に対する「いいね」の情報を追加
void insertRate(@Param("postId")long postId,
@Param("userName")String userName,
@Param("rate")int rate);
//ユーザと一つの投稿に対する「いいね」の情報を更新
void updateRate(@Param("postId")long postId,
@Param("userName")String userName,
@Param("rate")int rate);
}
<select id = "findOnePostRecord" resultMap="PostRecordResultMap">
SELECT
POST.postid,
POST.username,
POST.nickname,
P_CATEGORY.postname,
POST.content,
CAST(POST.createat as CHAR) as createat
FROM
post as POST
INNER JOIN post_category as P_CATEGORY
ON POST.postcategory = P_CATEGORY.postid
WHERE
post.postid = #{postid}
</select>
<resultMap id="PostRecordResultMap" type="com.example.text.model.entity.PostRecord">
<result property="postId" column="postid"></result>
<result property="userName" column="username"></result>
<result property="nickName" column="nickname"></result>
<result property="postCategory" column="postname"></result>
<result property="content" column="content"></result>
<result property="createAt" column="createat"></result>
</resultMap>
<select id = "currentRate" resultType="int">
SELECT
rate
FROM
likes
WHERE
postid = #{postId}
AND username = #{userName}
</select>
<select id = "sumRate" resultType="int">
SELECT
sum(rate)
FROM
likes
WHERE
postid = #{postId}
GROUP BY postid
</select>
<insert id = "insertRate">
INSERT INTO
likes(postid,username,rate)
VALUES(
#{postId},
#{userName},
#{rate}
)
</insert>
<update id = "updateRate">
UPDATE
likes
SET rate = #{rate}
WHERE postid = #{postId}
and username = #{userName}
</update>
各クエリの説明はJavaファイルのコメントアウトに記載いたしました。
insertとupdateのクエリは、本当はon duplicate key update ~ で一つのクエリにまとめたかったのですが、何故か構文エラーだと一生怒られたので泣く泣くクエリを2つに分けました。
Service
@Service
public class PostService {
private final PostMapper postMapper;
public PostService(PostMapper postMapper) {
this.postMapper = postMapper;
}
@Transactional(readOnly = true)
public PostRecord findOnePostRecord(long postId) {
return postMapper.findOnePostRecord(postId);
}
@Transactional(readOnly = true)
public int sumRate(long postId) {
return postMapper.sumRate(postId).orElse(0);
}
@Transactional(readOnly = false)
public void updateRate(long postId,String userName) {
int currentRate = postMapper.currentRate(postId, userName).orElse(-1);
if(currentRate == -1) {
postMapper.insertRate(postId, userName,1);
}else if(currentRate == 0){
postMapper.updateRate(postId, userName,1);
}else {
postMapper.updateRate(postId, userName,0);
}
}
sumRateメソッドで一つの投稿の総いいね数を、updateRateメソッドで投稿の「いいね」の状態を更新しています。updateRateでは、
・postMapperのcurrentRateで、ユーザ一人のある投稿に対する「いいね」の値を取得
・-1の場合、「そのユーザの投稿に対するいいねのレコードがない」ということなので、1をinsertする
・0の場合、いいねを1にupdateする
・1の場合、いいねを0にupdateする
という処理を行っています。
Controller
@Controller
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@GetMapping("/index/content/{postId}")
String showPostDetail(@AuthenticationPrincipal AccountUserDetails details,
@PathVariable("postId")long postId,Model model) {
PostRecord record = postService.findOnePostRecord(postId);
if(record == null) {
return "error/404";
}
model.addAttribute("postRecord",record);
if(record.getUserName().equals(details.getUsername())) {
model.addAttribute("ableDeleted","true");
}else {
model.addAttribute("ableDeleted","false");
}
//該当の投稿の総いいね数を取得しhtmlに渡す
int sumRate = postService.sumRate(postId);
model.addAttribute("sumRate",sumRate);
return "Post/PostDetail";
}
@PostMapping("/index/content/rate")
String updateRate(@AuthenticationPrincipal AccountUserDetails details,
@RequestParam(value="postId")long postId,Model model) {
postService.updateRate(postId, details.getUsername());
//該当する投稿のいいねを更新した後の総いいね数を取得しhtmlに渡す
int sumRate = postService.sumRate(postId);
model.addAttribute("sumRate",sumRate);
//PostフォルダのPostDetail.htmlの、rateFragmentを返す
return "Post/PostDetail :: rateFragment";
}
POST通信を受け取るupdateRateメソッドでは、フラグメント(HTMLのある一部分とお考え下さい)を返しております。
後のコードとも説明が重複してしまいますが、今回実装したいいね機能は非同期のPOST通信で値を更新しております。学のない私は当初「非同期通信ならjsonを返すのでは?」と思っておりましたが、参考文献からhtmlも取得出来ることを知りました。addAttributeで指定しているsumRateをフラグメント内に記述してフラグメントを返すようにすれば、htmlの一部分(総いいね数)だけを更新することが出来ます。便利~!
フロントエンド
html
<!DOCTYPE HTML>
<html xmlns:th="https://thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<script src="https://unpkg.com/vue@next"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link th:href="@{/css/style.css}" rel="stylesheet" type="text/css">
<title>投稿詳細</title>
</head>
<body>
<div th:replace="flagment/header :: user-header"></div><hr>
<h2>投稿詳細</h2>
<table id="likesApp" th:each="obj:${postRecord}">
<tr>
<td style="font-size:32px" th:text="${obj.content}"></td>
</tr>
<tr>
<td th:inline="text" style="font-size:14px">[[${obj.nickName}]]さん
</td>
</tr>
<tr>
<td style="font-size:14px" th:inline="text">
<div>投稿種別:[[${obj.postCategory}]] 投稿日時:[[${obj.createAt}]]</div>
</td>
</tr>
<tr>
<td>
<span style="cursor: pointer;font-size:20px;" v-on:click="likes">いいね</span>
<span style="font-size:20px;" id="sumRate" th:fragment="rateFragment">
<span th:text="${sumRate}"></span>
</span>
</td>
</tr>
<tr>
<td>
<form th:action="@{/index/content/post/delete}" method="post">
<input type="hidden" id="postId" name="postId" th:value="${obj.postId}">
<div th:if="${ableDeleted}">
<input type="submit" value="削除">
</div>
</form>
</td>
</tr>
</table>
</body>
<script th:src="@{/js/likesApp.js}">
</script>
</html>
${sumRate}に、該当する投稿の総いいね数を先程のコントローラクラスから渡しており、リンクテキスト「いいね」を押すと後述のjavascriptでhtmlの一部が更新されます。
javascript(vue.js)
var likesApp = {
methods:{
likes(){
var tokenValue = document.head.querySelector("[name=_csrf]").content;
var getId = document.getElementById("postId").value;
let param = new FormData()
param.append('postId', getId)
param.append('_csrf',tokenValue)
axios.post('/index/content/rate',param, {responseType : 'document'})
.then(response =>{
const sumRate = document.getElementById('sumRate');
Array.from(response.data.body.childNodes).forEach((element) => {
sumRate.replaceWith(element)
});
})
.catch(error =>{
console.log(error)
})
}
}
}
Vue.createApp(likesApp).mount('#likesApp');
POST通信の結果(htmlのデータが入っている変数response)からArrayインスタンスを生成し、フラグメントに対応するid属性を、取得したhtmlで置換しています。
Spring側ではSpring Securityを適用しているのでCSRFトークンをパラメータに含めないとPOST通信が出来ません。また、バックエンド(Controller)ではPOST通信のレスポンスとしてhtmlを返したいので、axios.postの第3引数にresponseTypeを指定しています。
結果
いいねの横の値が変わっているのが分かるかと思います。複数回押下した場合の挙動も、記事冒頭で書いた要件を満たしております。
反省点・改善点
いいね機能の実現には成功いたしましたが、まだまだ至らない部分があります。
・自分自身の投稿に「いいね」が出来てしまう
・テキストの「いいね」を押すといいねが出来るようになるが、テキストから画像に変えたい
・アニメーションがないので味気ない
・投稿一覧から投稿詳細へ移らないと、いいねが出来ない
→投稿一覧でのいいねが実装できていない
現状では投稿一つのフラグメントを返していいね機能を実現しましたが、「投稿一覧から一つの投稿を一部分だけ更新する」という実装が出来ておりません。その他列挙した改善点も今後の課題として取り組みたいと思います。
おわりに
今後の課題を残しながらも、いいね機能の実装に成功いたしました。自身でアレコレ調べて実装していなければ、改善点の発見すら出来なかったと思います。やはり手を動かして作ってみることが何より自分のためになると再確認いたしました。
ソースコードも文章も拙いですが、少しでもどなたかのお役に立てたのなら幸いです。