阶段二:RESTful进阶
当API返回的数据量增大时,分页、过滤、排序三大功能成为必需品。它们决定了API的查询能力和性能上限。本课深入偏移分页vs游标分页、复杂过滤表达式、多字段排序,以及性能优化的关键技巧。
# 偏移分页——最简单直观
GET /api/users?page=2&limit=20
# 优点:可以跳转到任意页
# 缺点:大数据量下性能差,数据变化时可能重复/遗漏
# SQL对应
SELECT * FROM users LIMIT 20 OFFSET 20;
# 游标分页——高性能、数据一致性
GET /api/users?cursor=eyJpZCI6NDJ9&limit=20
# cursor是上一页最后一条记录的编码标识
# 优点:大数据量下性能稳定,不会因数据变化导致重复
# 缺点:不能跳转到任意页,只能上一页/下一页
# SQL对应
SELECT * FROM users WHERE id > 42 ORDER BY id LIMIT 20;
| 特性 | 偏移分页 | 游标分页 |
|---|---|---|
| 跳转到任意页 | ✅ | ❌ |
| 大数据量性能 | ❌ O(offset) | ✅ O(1) |
| 数据一致性 | ❌ 可能有重复/遗漏 | ✅ 稳定 |
| 实现复杂度 | 简单 | 中等 |
| 总页数 | ✅ 可计算 | ❌ 不易计算 |
| 适用场景 | 管理后台、小数据集 | 无限滚动、大数据集 |
// pagination-api.js - 完整分页过滤排序实现
const express = require('express');
const app = express();
app.use(express.json());
// ===== 模拟数据 =====
const products = [];
const categories = ['电子产品', '服装', '食品', '图书', '家居', '运动'];
const statuses = ['active', 'inactive', 'discontinued'];
for (let i = 1; i <= 150; i++) {
products.push({
id: i,
name: `产品${String(i).padStart(3, '0')}`,
category: categories[i % categories.length],
price: Math.round((Math.random() * 5000 + 10) * 100) / 100,
stock: Math.floor(Math.random() * 500),
status: statuses[i % 3],
rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
tags: [categories[i % categories.length], i % 2 === 0 ? '热销' : '新品'],
createdAt: new Date(2025, 0, Math.min(i, 28)).toISOString(),
});
}
// ===== 偏移分页实现 =====
function offsetPaginate(items, page, limit) {
page = Math.max(1, parseInt(page) || 1);
limit = Math.min(100, Math.max(1, parseInt(limit) || 20));
const total = items.length;
const totalPages = Math.ceil(total / limit);
const offset = (page - 1) * limit;
const data = items.slice(offset, offset + limit);
return {
data,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
nextPage: page < totalPages ? page + 1 : null,
prevPage: page > 1 ? page - 1 : null,
},
};
}
// ===== 游标分页实现 =====
function cursorPaginate(items, cursor, limit, sortBy = 'id') {
limit = Math.min(100, Math.max(1, parseInt(limit) || 20));
let startIndex = 0;
if (cursor) {
try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
const cursorVal = decoded[sortBy];
startIndex = items.findIndex(item => {
if (sortBy === 'id') return item.id > cursorVal;
return item[sortBy] > cursorVal || (item[sortBy] === cursorVal && item.id > decoded.id);
});
if (startIndex === -1) startIndex = items.length;
} catch (e) {
startIndex = 0;
}
}
const data = items.slice(startIndex, startIndex + limit);
const hasMore = startIndex + limit < items.length;
let nextCursor = null;
if (hasMore && data.length > 0) {
const lastItem = data[data.length - 1];
nextCursor = Buffer.from(JSON.stringify({ id: lastItem.id, [sortBy]: lastItem[sortBy] })).toString('base64');
}
return {
data,
pagination: {
limit,
hasMore,
nextCursor,
// 游标分页没有page和total(计算total需要全表扫描)
},
};
}
// ===== 过滤引擎 =====
function filterItems(items, filters) {
let result = [...items];
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === '') continue;
switch (key) {
case 'category':
// 精确匹配
result = result.filter(item => item.category === value);
break;
case 'status':
// 多值匹配(逗号分隔)
const statuses = value.split(',');
result = result.filter(item => statuses.includes(item.status));
break;
case 'minPrice':
result = result.filter(item => item.price >= Number(value));
break;
case 'maxPrice':
result = result.filter(item => item.price <= Number(value));
break;
case 'minStock':
result = result.filter(item => item.stock >= Number(value));
break;
case 'rating':
// 大于等于
result = result.filter(item => item.rating >= Number(value));
break;
case 'tags':
// 包含任一标签
const tagList = value.split(',');
result = result.filter(item => item.tags.some(t => tagList.includes(t)));
break;
case 'q':
// 全文搜索
const keyword = value.toLowerCase();
result = result.filter(item =>
item.name.toLowerCase().includes(keyword) ||
item.category.toLowerCase().includes(keyword)
);
break;
case 'createdAfter':
result = result.filter(item => item.createdAt >= value);
break;
case 'createdBefore':
result = result.filter(item => item.createdAt <= value);
break;
}
}
return result;
}
// ===== 排序引擎 =====
function sortItems(items, sortStr) {
if (!sortStr) return items;
const sortFields = sortStr.split(',').map(field => {
let direction = 1;
let name = field.trim();
if (name.startsWith('-')) {
direction = -1;
name = name.substring(1);
}
return { name, direction };
});
return [...items].sort((a, b) => {
for (const { name, direction } of sortFields) {
if (a[name] < b[name]) return -1 * direction;
if (a[name] > b[name]) return 1 * direction;
}
return 0;
});
}
// ===== 路由 =====
// 偏移分页端点
app.get('/api/products', (req, res) => {
const { page, limit, sort, ...filters } = req.query;
// 过滤
let result = filterItems(products, filters);
// 排序
result = sortItems(result, sort);
// 分页
const paginated = offsetPaginate(result, page, limit);
res.json({ success: true, ...paginated });
});
// 游标分页端点
app.get('/api/products/cursor', (req, res) => {
const { cursor, limit, sort, sortBy, ...filters } = req.query;
// 过滤
let result = filterItems(products, filters);
// 排序(游标分页要求稳定排序)
const sortField = sortBy || 'id';
result = sortItems(result, sort || sortField);
// 追加id排序保证稳定性
result.sort((a, b) => {
const cmp = a[sortField] < b[sortField] ? -1 : a[sortField] > b[sortField] ? 1 : 0;
return cmp !== 0 ? cmp : a.id - b.id;
});
// 游标分页
const paginated = cursorPaginate(result, cursor, limit, sortField);
res.json({ success: true, ...paginated });
});
// 统计端点——返回过滤后的统计信息
app.get('/api/products/stats', (req, res) => {
const { ...filters } = req.query;
const filtered = filterItems(products, filters);
const stats = {
total: filtered.length,
avgPrice: filtered.reduce((sum, p) => sum + p.price, 0) / filtered.length,
priceRange: {
min: Math.min(...filtered.map(p => p.price)),
max: Math.max(...filtered.map(p => p.price)),
},
byCategory: {},
byStatus: {},
};
filtered.forEach(p => {
stats.byCategory[p.category] = (stats.byCategory[p.category] || 0) + 1;
stats.byStatus[p.status] = (stats.byStatus[p.status] || 0) + 1;
});
res.json({ success: true, data: stats });
});
app.listen(3000, () => console.log('📄 分页过滤排序API运行在 http://localhost:3000'));
# 基础偏移分页
curl -s "http://localhost:3000/api/products?page=1&limit=5" | jq '.pagination'
{
"page": 1, "limit": 5, "total": 150,
"totalPages": 30, "hasNext": true, "hasPrev": false,
"nextPage": 2, "prevPage": null
}
# 过滤 + 排序 + 分页
curl -s "http://localhost:3000/api/products?category=电子产品&minPrice=500&maxPrice=3000&sort=-price,name&limit=5" | jq .
# 游标分页——第一页
curl -s "http://localhost:3000/api/products/cursor?limit=5&sortBy=price&category=电子产品" | jq '.pagination'
# 使用返回的nextCursor获取下一页
curl -s "http://localhost:3000/api/products/cursor?cursor=eyJpZCI6MjMsInByaWNlIjoxMjM0LjU2fQ==&limit=5" | jq .
# 多状态过滤
curl -s "http://localhost:3000/api/products?status=active,inactive&limit=5" | jq '.pagination'
# 标签过滤
curl -s "http://localhost:3000/api/products?tags=热销,新品&limit=5" | jq '.pagination'
# 统计信息
curl -s "http://localhost:3000/api/products/stats?category=电子产品" | jq .
| 操作 | 参数格式 | 示例 |
|---|---|---|
| 等于 | field=value | ?status=active |
| 不等于 | field_ne=value | ?status_ne=deleted |
| 大于 | field_gt=value | ?price_gt=100 |
| 大于等于 | field_gte=value | ?price_gte=100 |
| 小于 | field_lt=value | ?price_lt=1000 |
| 小于等于 | field_lte=value | ?price_lte=1000 |
| 包含 | field_like=value | ?name_like=键盘 |
| 在列表中 | field_in=a,b,c | ?status_in=active,pending |
| 范围 | field_min=X&field_max=Y | ?price_min=100&price_max=500 |
# Ruby on Rails Ransack风格的复杂过滤
GET /api/products?q[price_gte]=100&q[price_lte]=500&q[category_eq]=电子产品&q[name_cont]=键盘
# 或使用JSON过滤(POST搜索端点)
POST /api/products/search
{
"filter": {
"and": [
{"field": "price", "op": ">=", "value": 100},
{"field": "price", "op": "<=", "value": 500},
{"field": "category", "op": "in", "value": ["电子产品", "图书"]},
{"or": [
{"field": "name", "op": "contains", "value": "键盘"},
{"field": "tags", "op": "contains", "value": "热销"}
]}
]
},
"sort": [{"field": "price", "order": "desc"}],
"pagination": {"type": "offset", "page": 1, "limit": 20}
}
sort=-price,name格式,-前缀表示降序扩展过滤引擎,支持field_gt、field_lt、field_like、field_in操作符。
当前游标分页只支持"下一页",增加"上一页"能力。提示:返回prevCursor,解码时反向查询。
生成10000条数据,对比偏移分页(page=500)和游标分页的响应时间。在什么数据量下差异开始明显?