📖 第08课:分页过滤排序

阶段二:RESTful进阶

当API返回的数据量增大时,分页、过滤、排序三大功能成为必需品。它们决定了API的查询能力和性能上限。本课深入偏移分页vs游标分页、复杂过滤表达式、多字段排序,以及性能优化的关键技巧。

📄 分页策略

偏移分页(Offset-based)

# 偏移分页——最简单直观
GET /api/users?page=2&limit=20

# 优点:可以跳转到任意页
# 缺点:大数据量下性能差,数据变化时可能重复/遗漏

# SQL对应
SELECT * FROM users LIMIT 20 OFFSET 20;

游标分页(Cursor-based)

# 游标分页——高性能、数据一致性
GET /api/users?cursor=eyJpZCI6NDJ9&limit=20

# cursor是上一页最后一条记录的编码标识
# 优点:大数据量下性能稳定,不会因数据变化导致重复
# 缺点:不能跳转到任意页,只能上一页/下一页

# SQL对应
SELECT * FROM users WHERE id > 42 ORDER BY id LIMIT 20;

两种分页策略对比

特性偏移分页游标分页
跳转到任意页
大数据量性能❌ O(offset)✅ O(1)
数据一致性❌ 可能有重复/遗漏✅ 稳定
实现复杂度简单中等
总页数✅ 可计算❌ 不易计算
适用场景管理后台、小数据集无限滚动、大数据集

🛠️ 实战:完整分页过滤排序API

// 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

RANSACK风格过滤(高级)

# 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}
}

📝 本课小结

  1. 偏移分页简单直观,但大数据量性能差;游标分页高性能但不支持跳页
  2. 管理后台用偏移分页,无限滚动用游标分页
  3. 过滤参数用统一命名规范:范围用min/max,枚举用逗号分隔
  4. 排序用sort=-price,name格式,-前缀表示降序
  5. 游标分页必须有稳定排序(追加id排序)
  6. 复杂过滤条件用POST搜索端点
  7. 提供统计端点补充聚合查询能力

💪 练习

练习1:实现RANSACK风格过滤

扩展过滤引擎,支持field_gtfield_ltfield_likefield_in操作符。

练习2:实现双向游标分页

当前游标分页只支持"下一页",增加"上一页"能力。提示:返回prevCursor,解码时反向查询。

练习3:对比性能

生成10000条数据,对比偏移分页(page=500)和游标分页的响应时间。在什么数据量下差异开始明显?

🏆 本课成就:查询架构师