はじめに
Spring Boot学習中の者です。学習のためにアプリケーションを作成していて、無限スクロールを実装する上で考えたことや躓いたポイントをまとめました。
今回はユーザの投稿を検索するページを例とさせていただきます。
参考文献
使用技術
Spring Boot 2.5.6
Spring Security
Thymeleaf
Mybatis 2.2.0
Vue.js
Axios
IntersectionObserver
本当はvue-infinite-loadingを使用した無限スクロールを実現したかったのですが、作成したVueコンポーネントが読み込めず、解決に時間を要すると判断したため別の方法での実装となりました。
余力があれば、vue-infinite-loadingを使用した方法でも記事を書きたいと思います。
実装に際して考えたこと
「ページの下まで移動したら都度コンテンツを読み込み表示させる」という機能を実装する上で「何件読み込ませるようにプログラムに命令するか」ということに悩みました。最初にデータを全件取得してjavascript側の変数に格納して、スクロール時に都度表示させる方法を考えましたが、処理が重くなりそうだったのでボツにしました。
アレコレ考えて、PageAbleを引数にしてコンテンツを取得する方法を思いつきました(本来の用途であるページネーションとはズレていますが...)
Pageableインタフェースからlimit(一度に取得する最大件数)とoffset(データ取得時に除外したい件数)を取得できるので、これを利用して無限スクロールのバックエンド側のコードを記述しました。
以下、ソースコードになります。
バックエンド
Mapper
@Mapper
public interface PostMapper {
List<PostRecord> searchPostRecord(@Param("category") int[] category,
@Param("content") String[] content,
@Param("pageable")Pageable pageable
);
}
<select id = "searchPostRecord" resultMap="PostRecordResultMap">
SELECT
POST.postid,
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>
<if test="category != null">
<foreach item="categoryId" index="i" collection="category"
open="POST.postcategory IN (" separator="," close=")">
#{categoryId}
</foreach>
</if>
<if test="content!= null">
AND <foreach item="text" index="k" collection="content"
open="(" separator=" and " close=")">
POST.content LIKE '%${text}%'
</foreach>
</if>
</where>
ORDER BY POST.createat DESC
LIMIT #{pageable.pageSize} <!--最大取得件数-->
OFFSET #{pageable.offset} <!--先頭から除外する件数-->
</select>
<resultMap id="PostRecordResultMap" type="com.example.text.model.entity.PostRecord">
<result property="postId" column="postid"></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>
searchPostRecordで、投稿種別と投稿内容に一致するユーザの投稿を投稿日時の降順で取得します。pageableのpageSizeで最大取得件数を、offsetで先頭から除外する件数を指定します。
foreach等の説明は本筋から外れてしまうので今回は割愛させていただきます。
Service
@Service
public class PostService {
private final PostMapper postMapper;
public PostService(PostMapper postMapper) {
this.postMapper = postMapper;
}
@Transactional(readOnly = true)
public List<PostRecord> searchPostRecord(int[] category,
String text,int page) {
if(category.length == 0) {
category = null;
}
String[] content;
if(text == null || text.equals("")) {
content = null;
}else {
content = text.replaceAll(" ", " ").replaceAll(" ", " ")
.split(" ");
if(content.length == 0) {
content = null;
}
}
List<PostRecord> records = postMapper
.searchPostRecord(category,content,
PageRequest.of(page, 5));
return records;
}
}
コードが冗長になっておりますが、配列の長さが0ならnullにする処理をしてからMapperのsearchPostRecordを呼び出しています。
先のMapperに空の配列を引数として渡すと「AND関数がみつからないよ、クエリ構文エラーだよ」と怒られてしまいます。Mapperファイルで<if test="category != null">と記述しているため空のオブジェクト(Null以外)が渡されると、オブジェクトが無いにも関わらずwhere句内の条件文を生成してしまうためです。
検索値が必ず渡されるとは限らないのでこのような実装になりました。
PageRequestはPageインタフェースの実装クラスで、ofメソッド(ページ位置,取得件数)でページリクエストを作成しています。今回ページ位置はControllerクラスから受け取り、取得件数は5件としています。例えばページ位置が0なら1~5件目、1なら6~10件目が取得できます。
Controller
@Controller
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@GetMapping("/index/content")
String showPostIndex(Model model) {
model.addAttribute("postCategory", PostCategory.values());
return "PostIndex";
}
}
htmlを表示させるControllerクラスです。
@RestController
public class RestPostRecordController {
private final PostService postService;
public RestPostRecordController(PostService postService) {
this.postService = postService;
}
@ResponseBody
@RequestMapping(value = "/api/search",method=RequestMethod.POST,
produces = "application/json; charset=utf-8")
public String searchPostRecord(@RequestParam(value="category") int[] category,
@RequestParam(value="keyword") String text,
@RequestParam(value="page") int page)
throws JsonProcessingException{
String jsonMsg = null;
List<PostRecord>records = postService.searchPostRecord(category,text,page);
ObjectMapper mapper = new ObjectMapper();
jsonMsg = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(records);
return jsonMsg;
}
}
フロントから受け取った検索値(投稿種別、投稿内容、ページ位置)をServiceクラスに渡して、結果をjsonとして返しています。
フロントエンド
html
<!DOCTYPE HTML>
<html xmlns:th="https://thymeleaf.org">
<head>
<title>投稿一覧</title>
<meta charset="UTF-8">
<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>
<style type="text/css">
.blocks {
display:flex
}
</style>
</head>
<body>
<h1>投稿一覧</h1>
<div id="App">
<div class="blocks">
<div>
<div>投稿種別</div>
<select id="postCategory" name="postCategory" multiple v-model="category">
<option th:each="category:${postCategory}"
th:value="${category.postId}" th:text="${category.postName}"></option>
</select>
</div>
<div>
<div>投稿内容</div>
<input type="text" id="textkeyword" name="textkeyword" v-model="keyword">
</div>
<div>
<div> </div>
<button type="submit" v-on:click="startSearch">検索</button>
<button type="reset" v-on:click="resetAll">検索値の初期化</button>
</div>
</div>
<div v-for="(value,key) in PostRecords" class="blocklink">
<a v-bind:href="`/index/boyaki/${value.postId}`">{{value.nickName}} さん
<div>{{value.content}}
<h6> {{value.createAt}}</h6>
</div>
</a>
</div>
<div ref="observe_element">loading</div>
</div>
<script th:src="@{/js/App.js}">
</script>
</body>
</html>
javascript(vue)
var App = {
data(){
return{
category:[],
keyword:"",
page:0,
PostRecords:[],
observe_element:null,
observer:null
}
},
methods: {
resetAll(){
this.category = []
this.keyword = ""
},
startSearch(){
this.page = 0
this.PostRecords = []
this.observer.observe(this.observe_element)
this.search()
},
search(){
var tokenValue = document.head.querySelector("[name=_csrf]").content;
let params = new FormData()
params.append('category', this.category)
params.append('keyword',this.keyword)
params.append('page',this.page)
params.append('_csrf',tokenValue) //Spring Security用
axios.post('/api/search',params)
.then(response => {
if(response.data.length == 0){
this.observer.unobserve(this.observe_element)
} else{
for(let i=0;i<response.data.length;i++){
this.PostRecords.push(response.data[i])
}
}
})
.catch(error =>{
console.log(error)
})
this.page += 1
}
},
mounted(){
//targetが画面に入るとcallback関数が実行される
this.observer = new IntersectionObserver((entries) => {
const entry = entries[0]; //監視対象の要素が配列で入っている
if (entry && entry.isIntersecting) { //isIntersectingがtrue:ブラウザに入っている
this.search()
}
});
this.observe_element = this.$refs.observe_element
this.observer.observe(this.observe_element)
}
}
Vue.createApp(App).mount('#App');
IntersectionObserverを使用した無限スクロールについてはこちらの参考文献を参考にさせていただき、取得結果がない場合にはtargetの監視を止めるような処理を追加しております。
以下、Spring Bootとの連携で必要な設定について説明させていただきます。
ページ位置
Mapperでのデータ取得にPageableを使用しているので、「ページ位置」と「取得件数」の2つの値が必要です。「取得件数」はServiceクラスで5件としていますが、「ページ位置」はフロント側の変数(Appオブジェクトのdata内の変数)で持たせています。
App.jsのsearchメソッドでデータを取得したあと変数pageをインクリメントしておくことで次の読み込み時に、前回読み込み時の取得データを除外した結果を取得・表示することが出来ます。
Spring Security適用下でのPOST通信
実装にあたってココが一番苦労しました。
Spring SecurityではデフォルトでCSRF対策機能が適用されているため、POST通信を受け取った場合にトークンチェックが発生します。
form要素でThymeleafのth:action属性を使えばCSRFトークンが自動でHTMLに埋め込まれる仕様なのですが、非同期通信を利用する場合はCSRFトークンを取得しリクエストパラメータに含める必要があります。
<meta name="_csrf" th:content="${_csrf.token}"/>
var tokenValue = document.head.querySelector("[name=_csrf]").content;
let params = new FormData()
params.append('_csrf',tokenValue)
該当のコードを上記にまとめました。言葉で説明すると、
・CSRFトークンからトークン値を取得
・javvascriptの変数(tokenValue)にトークン値を代入
・axiosで送信するFormDataにcsrfトークン値を設定
ということを行っています。
結果
拙いですが、検索&無限スクロールの動作が分かるようなgifを作成してみました。
検索
こちらは投稿種別のみのgifですが、投稿内容の検索でも問題なく結果が取得できることを確認しております。
無限スクロール
スクロールバーに注目していただくと、途中でデータの読み込みが発生していることが分かるかと思います。
おわりに
axiosでSpring側へのpost送信が上手くいかないことがキッカケでこの記事を作成いたしました。ソースコードも文章も拙いですが、少しでもお役に立てたのなら幸いです。