本文首发于: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
// ……
}
}
我在page
和size
基础上,新增一个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)的工作放到后端,因此,我们第一步就是根据page
和size
计算偏移量。
/**
* @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
}
至此,一个分页接口初步完成。
但刚才也提到了,基于偏移的分页方案存在许多问题,后续我将在实践中慢慢去优化。