NestJS 如何统一处理分页和服务端搜索过滤?
建议用可复用的查询 DTO 定义公共参数,配合全局 ValidationPipe 执行转换和校验,再用纯函数计算分页偏移量。搜索字段和查询条件的组合由业务 Service 或查询层决定。
这些机制可以按职责组合,比把所有逻辑塞进一个全局 Interceptor 更容易维护。
各部分适合承担什么职责?
| 机制 | 适合承担的职责 |
|---|---|
| DTO 基类 | 定义 page、limit、search 的类型、默认值和校验规则 |
全局 ValidationPipe | 根据路由声明的 DTO 执行转换和校验 |
| Helper Utility | 将分页参数转换为 skip、take 等查询选项 |
| 业务 Service 或查询层 | 决定可搜索字段、权限范围、筛选条件和排序规则 |
| 自定义装饰器 | 封装重复的参数提取方式,改善 Controller 的调用形式 |
| Interceptor | 统一分页响应结构等横切行为 |
全局配置 ValidationPipe 不会让所有接口自动使用同一组分页参数。需要这些参数的接口仍应显式声明对应的 DTO。
1. 用 DTO 统一参数规则
以下示例使用 class-transformer 和 class-validator,前提是项目已安装并按 NestJS 的常规方式配置它们。示例默认页码为 1、每页 20 条、最多 100 条,可按业务调整。
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class PaginationQueryDto {
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit: number = 20;
}
export class SearchPaginationQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
}
如果只有部分接口支持搜索,可以让它们使用 SearchPaginationQueryDto;仅支持分页的接口使用 PaginationQueryDto。业务专属的筛选字段可以由各自的 DTO 扩展。
查询参数通常以字符串形式进入应用,TypeScript 的 number 类型标注不会自动完成运行时转换。示例通过 @Type(() => Number) 声明转换规则,再校验整数和取值范围。
在应用启动时注册全局 Pipe:
import { ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
);
transform: true 让转换后的 DTO 实例进入处理函数;whitelist: true 会移除没有校验装饰器的字段。如果现有接口依赖未声明的查询字段,启用前应先补齐 DTO。
2. Controller 只负责接收参数
在已有 Controller 中,可以直接声明 DTO:
@Get()
list(@Query() query: SearchPaginationQueryDto) {
return this.itemsService.list(query);
}
这里使用 NestJS 自带的 @Query() 就够了。只有多个接口确实重复了复杂的参数提取操作时,才需要考虑自定义装饰器。装饰器本身不应负责数据库查询。
DTO 应使用运行时存在的 class;如果改成 interface,或仅通过 import type 导入该类,就无法提供相同的运行时类型信息。
3. 复用分页计算,保留业务查询规则
偏移量计算可以写成纯函数,并约定调用方传入已校验的参数:
export function toOffsetPagination(query: PaginationQueryDto) {
return {
skip: (query.page - 1) * query.limit,
take: query.limit,
};
}
如果项目使用 TypeORM,下面的方法可以放入已有 Service。示例中的 itemsRepository、name 和 id 需要对应实际实体与字段:
async list(query: SearchPaginationQueryDto) {
const { skip, take } = toOffsetPagination(query);
const search = query.search?.trim();
const qb = this.itemsRepository.createQueryBuilder('item');
if (search) {
qb.andWhere('item.name LIKE :search', {
search: `%${search}%`,
});
}
const [items, total] = await qb
.orderBy('item.id', 'ASC')
.skip(skip)
.take(take)
.getManyAndCount();
return {
items,
total,
page: query.page,
limit: query.limit,
};
}
过滤条件应在数据库分页之前加入查询。先取一页数据再用 JavaScript 过滤,会导致页内条数和总数与筛选结果不一致。
参数规则和分页计算可以复用。不同实体的搜索字段、关联条件、数据权限和索引情况通常不同,由具体查询实现决定更合适。等多个查询形成稳定的共同模式后,再提取查询组件。
需要注意的边界
- 全局 Interceptor 不适合决定实体字段和数据库过滤规则。如果它负责包装分页响应,Service 仍需提供总数等必要信息。
- 分页需要明确且稳定的排序。示例按
id排序;使用可能重复的排序字段时,可以追加唯一字段作为排序依据。 - SQL 参数绑定用于安全传值,但不会取消
LIKE中%和_的通配符含义。如果要求按字面搜索,需要根据数据库规则转义;大小写匹配行为也取决于数据库及其配置。 - 客户端传入的过滤字段、排序字段应经过白名单映射,避免直接拼接到 SQL 中。租户和访问权限条件仍由服务端强制添加。
- DTO 校验只覆盖经过相应 Pipe 的调用路径。如果 Service 也被定时任务或其他内部逻辑调用,需要明确这些调用方是否已经完成参数校验。
备注:内容仅供参考。
