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?

Wijmo FlexGrid + Spring でサーバ側ページングするための実装メモ

Posted at

要点(超短)

  • FlexGrid は表示だけ。ページング・ソートは CollectionView とサーバの協調で実現する。
  • 大量データはサーバ側ページング(PageablePage<T>)を基本とする。
  • ソートアイコンは CollectionView.sortDescriptions と同期すれば正しく表示される。
  • COUNT(*) と大きな OFFSET はパフォーマンスの地雷。必要ならキーセット(cursor)ページングへ。

目次

  1. API 仕様(推奨)
  2. Spring 側 実装例(Pageable 版)
  3. Spring 側 実装例(キーセット / cursor 版)
  4. クライアント(Wijmo + FlexGrid)実装例 — pageOnServer=true 相当
  5. ソート同期の仕組み(複数列含む)
  6. 運用でよく問題になる点と対策チェックリスト
  7. デバッグ時の確認ポイント
  8. 付録:小ネタ

1) API 仕様(推奨)

クライアントとサーバの契約を簡潔にしておくとトラブルが減る。

  • リクエスト例

    GET /api/items?page=0&size=20&sort=col,asc&sort=col2,desc
    
  • レスポンス例(ボディ)

    {
      "items": [ { /* DTO */ }, ... ],
      "total": 123456
    }
    

total を返すとクライアント側でページ数などが計算しやすい。ヘッダで返す場合は CORS 設定に注意。


2) Spring 側 実装例(Pageable 版)

Spring Data JPA を想定。簡潔に動く最小構成。

PagedResponse DTO

public class PagedResponse<T> {
    private List<T> items;
    private long total;

    public PagedResponse(List<T> items, long total) {
        this.items = items;
        this.total = total;
    }
    // getters/setters
}

Repository

public interface ItemRepository extends JpaRepository<Item, Long>, JpaSpecificationExecutor<Item> {
    // 必要なカスタムクエリがあればここに
}

Service

@Service
public class ItemService {
    private final ItemRepository repo;
    public ItemService(ItemRepository repo) { this.repo = repo; }

    public Page<Item> search(String q, Pageable pageable) {
        Specification<Item> spec = buildSpec(q); // 実装は適宜
        return repo.findAll(spec, pageable);
    }
}

Controller

@RestController
@RequestMapping("/api/items")
public class ItemController {
    private final ItemService svc;
    public ItemController(ItemService svc) { this.svc = svc; }

    @GetMapping
    public ResponseEntity<PagedResponse<ItemDto>> list(Pageable pageable, @RequestParam(required=false) String q) {
        Page<Item> page = svc.search(q, pageable);
        List<ItemDto> dtos = page.map(ItemDto::from).getContent();
        return ResponseEntity.ok(new PagedResponse<>(dtos, page.getTotalElements()));
    }
}

注意

  • Pageablepage (0-origin), size, sort を自動でバインドする。
  • page.getTotalElements() は総件数だが、大量テーブルでは COUNT(*) が重い。

3) Spring 側 実装例(キーセット / cursor ページング)

総件数不要で「次へ/前へ」だけ欲しい UX 向け。afterId / beforeId で取得する方法。

リクエスト例

  • GET /api/items?size=50&afterId=12345

Repository(例)

public interface ItemRepository extends JpaRepository<Item, Long> {
    List<Item> findByIdGreaterThanOrderByIdAsc(Long afterId, Pageable pageable);
    List<Item> findTopNByOrderByIdAsc(Pageable pageable);
}

Controller(簡易)

@GetMapping("/cursor")
public ResponseEntity<Map<String,Object>> cursor(
    @RequestParam(required=false) Long afterId,
    @RequestParam(defaultValue="50") int size) {

    Pageable p = PageRequest.of(0, size);
    List<Item> items;
    if (afterId == null) {
        items = repo.findTopNByOrderByIdAsc(p);
    } else {
        items = repo.findByIdGreaterThanOrderByIdAsc(afterId, p);
    }
    Long nextCursor = items.isEmpty() ? null : items.get(items.size()-1).getId();
    Map<String,Object> res = Map.of("items", items, "nextCursor", nextCursor, "hasMore", items.size() == size);
    return ResponseEntity.ok(res);
}

メリット: COUNT 不要、OFFSET 無しで高速。

デメリット: 任意ページジャンプ不可、複数列ソートの扱いが面倒。


4) クライアント(Wijmo + FlexGrid)実装例 — pageOnServer=true 相当

公式 RestCollectionView を使ってもいいが、ここでは自前 fetch 実装の方がバージョン差で安心。

HTML + JS(最小実装)

<div id="pager"></div>
<div id="theGrid" style="height:400px"></div>
<script>
  const cv = new wijmo.collections.CollectionView([], { pageSize: 20 });
  const grid = new wijmo.grid.FlexGrid('#theGrid', { itemsSource: cv, allowSorting: true });

  // 簡易ページャ(prev/next)
  document.getElementById('prev').addEventListener('click', () => {
    if (cv.pageIndex > 0) fetchPage(cv.pageIndex - 1);
  });
  document.getElementById('next').addEventListener('click', () => {
    if (cv.pageIndex < (cv.pageCount || 1) - 1) fetchPage(cv.pageIndex + 1);
  });

  // ソート変更監視 -> サーバ再取得
  cv.sortDescriptions.collectionChanged.addHandler(() => fetchPage(0));

  async function fetchPage(pageIndex) {
    const size = cv.pageSize;
    const sorts = [];
    for (let i=0;i<cv.sortDescriptions.length;i++){
      const sd = cv.sortDescriptions[i];
      sorts.push(`sort=${encodeURIComponent(sd.property)},${sd.ascending ? 'asc' : 'desc'}`);
    }
    const url = `/api/items?page=${pageIndex}&size=${size}` + (sorts.length ? '&' + sorts.join('&') : '');

    const res = await fetch(url);
    if (!res.ok) throw new Error(res.statusText);
    const json = await res.json(); // { items: [...], total: N }

    cv.beginUpdate();
    cv.sourceCollection = json.items || [];
    cv.totalItemCount = json.total || 0;
    cv.pageIndex = pageIndex;
    cv.endUpdate();
  }

  fetchPage(0);
</script>

ポイント

  • cv.sortDescriptions をそのまま sort パラメータに変換してサーバに渡す。
  • サーバは同じソート順でデータを返すこと。
  • cv.totalItemCount をセットすると cv.pageCount が計算される。

5) ソート同期の具体ルール(実装で失敗しやすいところ)

  • Grid のソートアイコンは CollectionView.sortDescriptions を基に表示される。
  • 複数列ソートは 順序が重要。クライアントから送る順序を壊すな。
  • ソートを変えたら通常は 1 ページ目に戻す。
  • サーバがソートに対応していないと表示は崩れる。必ずサーバ側で同じ sort パラメータを処理する。

6) 運用でよく問題になる点と対策チェックリスト

  • COUNT(*) が遅い

    • 対策: ページ数表示をやめる、近似値、ページキャッシュ、keyset への切り替え
  • 大きな OFFSET

    • 対策: keyset pagination(cursor)を採用
  • ソート・フィルタにインデックスが効いてない

    • 対策: 実行計画を見てインデックス追加
  • CORS + ヘッダで total を返す場合

    • 対策: Access-Control-Expose-Headers: X-Total-Count
  • プロパティ名と DB カラム名が一致しない

    • 対策: クライアント→サーバのマップを作る(例: name -> full_name

7) デバッグ時の確認ポイント(コピペチェックリスト)

  • クライアントが投げる URL は期待通りか?(page, size, sort)
  • サーバで受け取った Pageable の内容をログに出して確認
  • サーバが返す JSON に itemstotal があるか
  • フロント側で cv.sortDescriptions の順序と内容が正しいか
  • DB の実行計画で COUNT / OFFSET のコストを確認

8) 付録:小ネタ

  • ヘッダ方式で total を返す例(Spring)
return ResponseEntity.ok()
    .header("X-Total-Count", String.valueOf(page.getTotalElements()))
    .body(page.getContent());

ブラウザで読むには Access-Control-Expose-Headers を忘れるな。

  • クライアントのプロパティ→DB カラム変換(簡易)
Map<String,String> propToColumn = Map.of("name","full_name","createdAt","created_at");
// sort パラメータを受けて変換して Sort オブジェクト作る
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?