如何设计分页接口 —— Eggjs最佳实践系列(三)

2,807 阅读4分钟

本文首发于:kapeter.com/post/34

文章列表:

这是Eggjs最佳实践系列的第三篇文章。


前期调研

分页接口是在大型系统中经常出现的接口形式。通过调研,目前主要存在以下三种分页方案:基于偏移、基于游标、基于ID列表。其中基于ID列表仅适用于数量较少(数百条)的应用场景,不做考虑。

基于偏移的分页方案

这是最传统的分页方案,目前多用于PC端,如PC端的谷歌搜索,原理主要通过数据库的Limit获取分页数据。

前端表现为:

  • 通过页码进行分页;
  • 通过点击上/下页按钮可实现页面切换;
  • 通过点击页码可实现页面切换;

请求参数多为:

参数含义备注
page当前页数必传
size / limit每页数据个数可选,可以前端传参,也可以后端写死

还有一种,是前端计算offset(偏移量)后,再传给后端。但考虑到数据完整性,建议把计算offset(偏移量)的工作放在后端。

该方案的优势:

  • 有清晰的边界;
  • 可直接跳转至指定页面。

该方案的劣势:

  • 存在数据重复或者缺失问题(有技术手段可以优化);
  • 数据量大后存在性能问题(有技术手段可以优化)。

基于游标的分页方案

该方案多出现在基于时间轴的社交接口上,如新浪微博。

前端多表现为:

  • 通过滚动/上拉/点击等方式加载新一页
  • 无页码
  • 无上/下页按钮
  • 不可跳转至指定页面

请求参数多为:

参数含义备注
cursor / since_id当前游标,一般为当前列表最后一位的ID必传
size / limit每页数据个数可选,可以前端传参,也可以后端写死

该方案的优势:

  • 无感知刷新;
  • 不存在数据重复或者缺失问题;
  • 性能好于基于偏移的方案

该方案的劣势:

  • 无法直接跳转到对应内容;
  • 数据量大后,前端渲染压力大。

可以发现,两种分页方案各有利弊,需要根据业务需求进行取舍。我目前做的是PC端的内部平台,因此选择基于偏移的分页方案。

技术实现

假设我们现在需要设计一个获取项目列表的(/project/list)接口。

首先,我们来设计一下请求参数。

// 请求参数
{
  "page":1,
  "size": 10,
  "filter": {
    "status": true
    // ……
  }
}

我在pagesize基础上,新增一个filter参数,可以让用户进一步筛选,也就是说将列表接口和搜索接口合在一起。

据此,我们可以写出这个参数的校验规则。

const listRule = {
  page: { type: 'integer', required: true, min: 1 },
  size: { type: 'integer', required: false, max: 100, default: 10 },
  filter: { type: 'object', required: false, default: {} },
};

并完成controller方法的代码编写。

/**
 * @description 路由方法-获取列表
 * @memberof ProjectController
 */
async index() {
  const { ctx } = this;
  try {
    // 参数校验
    ctx.validate(listRule);
    const { page, size, filter } = ctx.request.body;
    // 获取结果
    const result = await ctx.service.project.pagingList({
      page,
      size,
      filter,
    });
    // 接口返回
    ctx.response.success({ data: result });
  } catch (error) {
    ctx.response.handleCommonErr(error);
  }
}

controller部分比较简单,就是校验参数,调用service获取数据,然后返回结果。

重点来看service部分。

/**
 * @description 列表查询
 * @param {Object} query 查询条件
 * @return {Object} 结果
 * @memberof ProjectService
 */
async pagingList(query) {
  const { ctx, app } = this;
  const { Op } = app.Sequelize;
  const { size, page, filter } = query;
  // 计算分页偏移量
  const offset = ctx.helper.calcPagingOffset(page, size);
  // 处理条件约束
  let whereObj = {};
  if (ctx.helper.isObject(filter) && !ctx.helper.isEmptyObject(filter)) {
    for (const x in filter) {
      switch (x) {
        case 'alias':
          whereObj.alias = { [Op.like]: `%${filter.alias}%` };
          break;
        case 'status':
          whereObj.status = filter.status;
          break;
        case 'deleted_at':
          whereObj.deletedAt = { [Op.ne]: null };
          break;
        default:
          break;
      }
    }
  }
  // 请求数据库
  const result = await ctx.model.Project.findAndCountAll({
    attributes: this.attributes,
    where: whereObj,
    limit: size ? size : null,
    offset: offset ? offset : null,
    order: [
      ['created_time', 'desc'],
    ],
  });
  // 格式化数据并返回
  return ctx.helper.formatPagingData({ page, size, count: result.count, list: result.rows });
}

上文提到,需要把计算偏移量(offset)的工作放到后端,因此,我们第一步就是根据pagesize计算偏移量。

/**
 * @description 计算偏移值
 * @param {Number} page 当前页数
 * @param {Number} size 每页数量
 * @return {Number} 数据库中的偏移值
 */
calcPagingOffset(page, size) {
  return (size && page) ? size * (page - 1) : 0;
},

然后去处理前端传入的筛选条件,把他放到where对象中。

接着去数据库中获取数据,根据数据库规范,不能通过select * from获取数据,因此建议,设置一个attributes返回需要获取的字段列表。

数据获取后,我们还要对整个格式进行处理,把分页信息输出给前端。

/**
 * @description 格式化分页数据
 * @param {*} { page, size, count, list } - page - 当前页数,size - 每页数量, count - 总数, list - 数据
 * @return {*} 格式化后的数据
 */
formatPagingData({ page, size, count, list }) {

  const totalPage = Math.ceil(count / size);

  return {
    currentPage: page,
    totalPage,
    size,
    hasPrev: page > 1 && page < totalPage,
    hasNext: page < totalPage,
    total: count,
    list: list || [],
  };
},

最终输出格式为:

{
    "code": "0",
    "message": "success",
    "result": {
        "currentPage": 1,
        "totalPage": 2,
        "size": 10,
        "hasPrev": false,
        "hasNext": false,
        "total": 20,
        "list": [
          // 列表数据
        ]
    },
    "sysTime": 1622626911874
}

至此,一个分页接口初步完成。

但刚才也提到了,基于偏移的分页方案存在许多问题,后续我将在实践中慢慢去优化。

参考资料