要点(超短)
- FlexGrid は表示だけ。ページング・ソートは CollectionView とサーバの協調で実現する。
- 大量データはサーバ側ページング(
Pageable
→Page<T>
)を基本とする。 - ソートアイコンは
CollectionView.sortDescriptions
と同期すれば正しく表示される。 -
COUNT(*)
と大きなOFFSET
はパフォーマンスの地雷。必要ならキーセット(cursor)ページングへ。
目次
- API 仕様(推奨)
- Spring 側 実装例(Pageable 版)
- Spring 側 実装例(キーセット / cursor 版)
- クライアント(Wijmo + FlexGrid)実装例 —
pageOnServer=true
相当 - ソート同期の仕組み(複数列含む)
- 運用でよく問題になる点と対策チェックリスト
- デバッグ時の確認ポイント
- 付録:小ネタ
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()));
}
}
注意
-
Pageable
はpage
(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 に
items
とtotal
があるか - フロント側で
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 オブジェクト作る