検索ワードをもとに、axiosによる非同期通信でデータを取得して一覧表示するプログラムの組み立て方です。題材として、書籍の検索して一覧表示する機能をテーマにしています。
検索結果イメージ
📖 TITLE - 書名 | 👤 AUTHOR - 著者 | 🏷️ GENRE - ジャンル | 🧾 ISBN |
---|---|---|---|
こころ | 夏目漱石 | 文学 | 978-4-00-310101-8 |
ノルウェイの森 | 村上春樹 | 現代小説 | 978-4-10-353422-9 |
コンビニ人間 | 村田沙耶香 | 社会・風刺 | 978-4-10-350951-7 |
使用するツールはタイトルの通り。
SpringBoot(gradle)
MyBatis
DataTables
axios
PageHelper
Thmeleaf
build.gradle(今回の実装に必須でないライブラリも混じってます。ご容赦ください。。。)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
compileOnly 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.2.0'
implementation 'org.webjars:bootstrap:5.2.3'
implementation 'org.webjars:jquery:3.6.0'
implementation 'org.webjars:font-awesome:6.5.1'
implementation 'org.webjars:webjars-locator:0.45'
implementation 'org.webjars.npm:axios:1.6.7'
implementation 'org.webjars.npm:vue:3.4.21'
implementation 'com.github.pagehelper:pagehelper-spring-boot-starter:1.4.6'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.0.M1'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
runtimeOnly 'com.h2database:h2'
}
まずはThymeleaf
検索フォームと、検索結果を表示するテーブルを用意。
<form class="mb-3" th:object="${searchBookForm}" id="searchForm">
<div class="row g-2 align-items-center justify-content-start">
<div class="col-auto">
<input type="text" th:field="*{keyword}" class="form-control form-control-sm"
placeholder="Search in library" style="width: 200px;"/>
</div>
<div class="col-auto">
<button type="button" class="btn btn-ivory btn-sm" id="searchBtn">
<i class="fas fa-search me-1"></i> Search - 検索
</button>
</div>
</div>
</form>
<table id="bookTable" class="table my-table">
<thead class="table-light">
<tr>
<th><i class="fas fa-book-open"></i> TITLE - 書名</th>
<th><i class="fas fa-user"></i> AUTER - 著者</th>
<th><i class="fas fa-tags"></i> GENRE - ジャンル</th>
<th><i class="fas fa-barcode"></i> ISBN</th>
</tr>
</thead>
<tbody id="result">
<!-- JavaScriptで動的にここを更新! -->
</tbody>
</table>
ついでjavascript。
トリガーはページ読み込み時と検索ボタン押下時。
axiosによる非同期通信で検索を実行。
検索フォームの情報に加え、DataTablesのページ情報やソート情報も加えてサーバーにリクエストを飛ばす。
// イベントハンドラ登録:ページ読み込み時と検索ボタン押下時に検索処理を実行
window.addEventListener('DOMContentLoaded', function () {
// ページ読み込み時に検索実行
searchBooks();
// 検索ボタン押下時に再検索
document.getElementById('searchBtn').addEventListener('click', function () {
$('#bookTable').DataTable().ajax.reload();
});
});
// 書籍検索メソッド本体
function searchBooks() {
$('#bookTable').DataTable({
dom: 'lrtip',
retrieve: true,
paging: true,
searching: true,
processing: true,
serverSide: true,
ordering: true,
ajax: fetchBooks,
columns: [
{ data: 'title' },
{ data: 'author' },
{ data: 'genreName' },
{ data: 'isbn' }
],
language: {
url: '/js/i18n/ja.json'
}
});
}
// axiosによるデータ取得
function fetchBooks(data, callback) {
const payload = buildPayload(data);
axios.post('/api/books/search', payload)
.then(response => {
callback(response.data); // DataTablesに結果を渡す
})
.catch(error => {
console.error('検索失敗:', error);
callback({
data: [],
recordsTotal: 0,
recordsFiltered: 0,
draw: data.draw
});
});
}
// 検索フォームの入力値を全て取得してシリアライズ化(JSON形式に変換)
function getSearchFormData() {
const formArray = $('#searchForm').serializeArray();
const formData = {};
formArray.forEach(item => {
formData[item.name] = item.value;
});
return formData;
}
// 検索処理に用いるpayloadを作成する。payloadは検索値とDataTablesの情報を保持する。
function buildPayload(data) {
const SearchFormData = getSearchFormData();
return {
...SearchFormData,
draw: data.draw,
start: data.start,
length: data.length,
order: data.order,
columns: data.columns
};
}
次いでRestController
画面から受け取ったFormを、Dtoと検索キーワードに詰め替え。
引数となるSearchBookFormおよび、Dtoへの詰め替えメソッドは後述。
サービスから得た検索結果に、ページ情報を加えてaxiosのレスポンスとして返す。
@RestController
@RequestMapping("/api/books")
@RequiredArgsConstructor
public class BookRestController {
private final BookService bookService;
/**
* DataTables連携用の検索処理(POST)
*/
@PostMapping("/search")
public Map<String, Object> search(@RequestBody SearchBookForm form) {
// SearchBookForm->Dto詰め替え
PageRequestDto pageRequestDto = PageRequestConverter.toPageRequest(form);
String keyword = form.getKeyword();
PageInfo<BookEntity> pageInfo = bookService.searchBooks(keyword, pageRequestDto);
Map<String, Object> response = new HashMap<>();
response.put("draw", form.getDraw()); // DataTablesから送られてくるdrawをそのまま返す
response.put("recordsTotal", pageInfo.getTotal()); // 全件数
response.put("recordsFiltered", pageInfo.getTotal()); // 検索後の件数(フィルターしてないなら同じ)
response.put("data", pageInfo.getList()); // 実データ
return response;
}
}
画面の値を受け取るフォームクラス
検索値と、DataTablesの情報を併せ持つ。
/** 書籍検索における検索条件とDataTablesのソート・ページング情報を受け取るフォームクラス。*/
@Data
public class SearchBookForm {
// 検索条件
private String keyword = "";
// 以下、DataTablesのソート・ページング情報
private int draw; // DataTablesの描画回数(レスポンスにそのまま返す)
private int start; // 開始位置(ページング用)
private int length; // 1ページの件数
private List<Order> order; // ソート条件
private List<Column> columns; // カラム情報
/** ソート条件を保持する内部クラス。 */
@Data
public static class Order {
private int column;
private String dir; // "asc" or "desc"
}
/** カラム情報を保持する内部クラス。 */
@Data
public static class Column {
private String data;
private String name;
private boolean searchable;
private boolean orderable;
}
}
画面から受け取ったパラメータのうち、DataTablesの情報のみを抽出し、PageRequestDtoクラス(後述)に格納するための変換メソッド。
/**
* DataTables形式の検索フォームからページング情報(ページ番号・サイズ・ソート条件)を抽出し、
* PageRequestオブジェクトへ変換するユーティリティクラス。<br>
*
* ControllerからService層へのページング情報の受け渡しに使用する。
*/
public final class PageRequestConverter {
// インスタンス化防止
private PageRequestConverter() {}
public static PageRequestDto toPageRequest(SearchBookForm form) {
int pageNum = form.getStart() / form.getLength() + 1; // ページ番号
int pageSize = form.getLength(); // ページ数
// ソートについては条件があればそれを指定。未指定なら MyBatis 側でフォールバックするため null で初期化。
String sortBy = null;
Boolean isDescending = null;
if (form.getOrder() != null && !form.getOrder().isEmpty()) {
SearchBookForm.Order order = form.getOrder().get(0);
int columnIndex = order.getColumn();
isDescending = "desc".equalsIgnoreCase(order.getDir());
if (form.getColumns() != null && columnIndex < form.getColumns().size()) {
sortBy = form.getColumns().get(columnIndex).getData();
System.out.println("sortBy:" + sortBy);
}
}
return new PageRequestDto(pageNum, pageSize, sortBy, isDescending);
}
}
画面から受け取ったパラメータの内、DataTablesの情報だけに絞り込んだクラス。
属性(データ)のカテゴライズ用。
/**
* DataTablesから送信されたページング・ソート情報を保持するDTO。
*/
@Data
public class PageRequestDto {
private int pageNum = 1;
private int pageSize = 10;
private String sortBy;
private boolean isDescending;
// 引数なしコンストラクタ(初期化用)
public PageRequestDto() {
}
public PageRequestDto(int pageNum, int pageSize, String sortBy, boolean isDescending) {
this.pageNum = pageNum;
this.pageSize = pageSize;
this.sortBy = sortBy;
this.isDescending = isDescending;
}
}
次いで、サービス層。
インターフェース BookService 。詳細は実装クラス BookServiceImpl へ。
public interface BookService {
PageInfo<BookEntity> searchBooks(String keyword, PageRequestDto pageRequestDto);
}
実装クラスBookServiceImpl
PageHelperによりページングを開始した時点でマッパークラスへ上書きが始まり、1ページ分の情報と、全体の件数を取得することができるそうな。
全件取得することなく、1ページ分しか取得しないため、データ取得が軽快になる。
ただし、ソートについては上書き対象外のため、マッパークラスに引数(sortByとisDescending)を渡し、XMLファイル(後述)にてソートの条件分岐処理を手動で組んでいる。
@Service
@RequiredArgsConstructor
public class BookServiceImpl implements BookService {
private final BookMapper bookMapper;
private final BookConverter bookConverter;
@Override
public PageInfo<BookEntity> searchBooks(String keyword, PageRequestDto pageRequestDto) {
// PageHelperでページング開始(ページ番号は1から始まる)
PageHelper.startPage(pageRequestDto.getPageNum(), pageRequestDto.getPageSize());
// キーワードが空なら全件検索
String searchKeyword = (keyword == null || keyword.trim().isEmpty()) ? "" : keyword;
// Mapperから検索結果を取得し、PageInfoにラップ
String sortBy =pageRequestDto.getSortBy();
Boolean isDescending = pageRequestDto.isDescending();
List<BookEntity> books = bookMapper.searchBooks(searchKeyword, sortBy, isDescending);
return new PageInfo<>(books);
}
}
マッパーインターフェース
@Mapper
public interface BookMapper {
List<BookEntity> searchBooks(String keyword, String sortBy, boolean isDescending);
}
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 namespace="com.takeshi.library.mapper.BookMapper">
<resultMap id="BookResultMap" type="com.takeshi.library.entity.BookEntity">
<result property="id" column="id"/>
<result property="title" column="title"/>
<result property="author" column="author"/>
<result property="isbn" column="isbn"/>
<result property="genreId" column="genre_id"/>
<result property="genreName" column="genre_name"/>
<result property="deleted" column="deleted"/>
</resultMap>
<select id="searchBooks" resultMap="BookResultMap" parameterType="String">
SELECT b.id, b.title, b.author, b.isbn, b.deleted,
g.id AS genre_id, g.name AS genre_name
FROM book b
JOIN genres g ON b.genre_id = g.id
WHERE b.deleted = false
<if test="keyword != null and keyword != ''">
AND (
b.title LIKE CONCAT('%', #{keyword}, '%')
OR b.author LIKE CONCAT('%', #{keyword}, '%')
)
</if>
<choose>
<when test="sortBy != null">
ORDER BY
<choose>
<when test="sortBy == 'title'">title</when>
<when test="sortBy == 'author'">author</when>
<when test="sortBy == 'genreName'">genre_name</when>
<when test="sortBy == 'isbn'">isbn</when>
<otherwise>id</otherwise>
</choose>
<if test="isDescending"> DESC </if>
<if test="!isDescending"> ASC </if>
</when>
</choose>
</select>
</mapper>
んんー、簡単かと思ったのに、私には相当難しかったでございます・・・・・