0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SpringBoot × MyBatis × DataTables × Thymeleaf × PageHelper 1ページ分のみ取得する軽快ページネーション

Posted at

検索ワードをもとに、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>

んんー、簡単かと思ったのに、私には相当難しかったでございます・・・・・

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?