← 中文资源

简体中文版本

构建游标分页 REST API

用不透明游标分页的大数据集只读 API:稳定排序、页大小上限,并证明翻页不跳不重。

阅读英文原文

项目目标

本项目构建一个用不透明游标分页的只读 API,取代易失效的偏移量分页。游标记录稳定排序中的位置而非计数,因此即使数据在翻页途中变化,也不会有记录被跳过或重复。测试会遍历全部页面并断言结果等于完整集合。

核心特性

  • 用不透明游标编码上一次的排序键
  • 以 id 作为稳定排序的平局决胜
  • 强制页大小上限并提供默认值
  • 返回 next 游标与 hasMore 标志

架构与分层

数据源暴露键有序集合;游标编解码把排序键编码为 URL 安全字符串;分页器越过该键取切片;HTTP 层把查询参数映射到这些调用。编码被隔离,游标格式变化不影响数据源。

实施阶段

六个阶段为:有序数据源、游标编解码、分页器、路由与参数处理、HTTP 服务、不变量测试。全局遍历断言是本项目的核心。

测试与验收

用 vitest 运行。游标测试覆盖往返、被篡改令牌与 Unicode 键;分页测试逐页遍历整个集合,断言拼接结果等于全集且无重复;HTTP 测试断言页大小被夹取、非法游标返回 400。

延伸方向

过滤在排序键之上自然组合;反向分页是第二个游标方向;对 SQL 的键集分页是把同一次取越翻译成 WHERE 子句。

本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。

代码示例

export function page(sorted, { cursor, size }) {
  let start = 0;
  if (cursor) {
    const key = decodeCursor(cursor);
    start = sorted.findIndex((item) => compareKey(item, key) > 0);
    if (start === -1) start = sorted.length;
  }
  const slice = sorted.slice(start, start + size);
  const last = slice[slice.length - 1];
  return {
    items: slice,
    nextCursor: last && start + size < sorted.length ? encodeCursor(last) : null,
    hasMore: start + size < sorted.length,
  };
}
mkdir cursor-api && cd cursor-api
npm init -y
npm install -D vitest@^2.1.0
{
  "name": "cursor-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "vitest run"
  }
}
export function compareKey(a, b) {
  if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}

export function generate(n, seed = 1) {
  let x = seed;
  const rand = () => (x = (x * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
  const items = [];
  for (let i = 0; i < n; i++) {
    items.push({
      id: 'doc-' + String(i).padStart(5, '0'),
      createdAt: new Date(1700000000000 + Math.floor(rand() * 1e9)).toISOString(),
      title: 'Article ' + i,
    });
  }
  return items.sort(compareKey);
}
export function encodeCursor(item) {
  const payload = JSON.stringify({ t: item.createdAt, i: item.id });
  return Buffer.from(payload, 'utf8').toString('base64url');
}

export function decodeCursor(token) {
  try {
    const parsed = JSON.parse(Buffer.from(String(token), 'base64url').toString('utf8'));
    if (typeof parsed.t !== 'string' || typeof parsed.i !== 'string') throw new Error('shape');
    return { createdAt: parsed.t, id: parsed.i };
  } catch {
    throw new Error('invalid_cursor');
  }
}
import { decodeCursor, encodeCursor } from './cursor.js';
import { compareKey } from './data.js';

export function page(sorted, { cursor, size }) {
  let start = 0;
  if (cursor) {
    const key = decodeCursor(cursor);
    start = sorted.findIndex((item) => compareKey(item, key) > 0);
    if (start < 0) start = sorted.length;
  }
  const items = sorted.slice(start, start + size);
  const hasMore = start + size < sorted.length;
  const last = items[items.length - 1];
  return { items, hasMore, nextCursor: hasMore && last ? encodeCursor(last) : null };
}
import { page } from './paginate.js';

const MAX_SIZE = 50;
const DEFAULT_SIZE = 20;

export function handleList(sorted, query) {
  const rawSize = Number(query.get('size') ?? DEFAULT_SIZE);
  const size = Number.isFinite(rawSize) && rawSize > 0 ? Math.min(MAX_SIZE, rawSize) : DEFAULT_SIZE;
  const cursor = query.get('cursor') ?? undefined;
  try {
    const result = page(sorted, { cursor, size });
    return { status: 200, body: result };
  } catch (err) {
    return { status: 400, body: { error: err.message } };
  }
}
import { createServer } from 'node:http';
import { generate } from './data.js';
import { handleList } from './routes.js';

const sorted = generate(1000, 42);

const server = createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (req.method === 'GET' && url.pathname === '/articles') {
    const result = handleList(sorted, url.searchParams);
    res.writeHead(result.status, { 'content-type': 'application/json' });
    return res.end(JSON.stringify(result.body));
  }
  res.writeHead(404, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ error: 'not_found' }));
});

server.listen(process.env.PORT ?? 3000);
export { server };
import { page } from '../src/paginate.js';
import { generate } from '../src/data.js';

test('full walk yields every item once', () => {
  const sorted = generate(137, 7);
  const seen = [];
  let cursor;
  for (;;) {
    const result = page(sorted, { cursor, size: 10 });
    seen.push(...result.items.map((i) => i.id));
    if (!result.hasMore) break;
    cursor = result.nextCursor;
  }
  expect(seen).toEqual(sorted.map((i) => i.id));
  expect(new Set(seen).size).toBe(seen.length);
});