Spring Boot 分页查询接口设计与实现 ------ 技术总结与完整示例
本文从一个实际的「白名单分页查询」接口出发,提炼其中的关键技术点、代码设计模式和流程设计思想,并给出一个完整示例代码,可直接用于新项目参考。
一、关键技术点总结
1.1 分层架构(Layered Architecture)
接口严格遵循 Spring Boot 经典分层:
API Interface(接口契约)
↓
Controller(入参校验、默认值处理)
↓
Service(业务编排:查询 + 数据补充 + 枚举翻译)
↓
Mapper/DAO(数据访问:MyBatis 动态 SQL)
↓
Feign Client(远程服务调用)
设计要点:
- 每层职责单一,Controller 不写业务逻辑,Service 不写 SQL
- 接口与实现分离(
interface+impl),便于 Mock 测试和替换实现 - API 接口层独立定义(可被其他服务通过 Feign 引用)
1.2 接口契约优先(Contract-First)
通过独立的 interface 定义 API 契约,Controller 只做 implements:
java
// 接口定义(可打包为独立 jar 供消费方引用)
@RequestMapping("/api/page/xxx")
public interface XxxPageApi {
@PostMapping("/list-pager")
RestControllerResult<PageInfo<ResultDto>> listPager(@RequestBody ParamsDto param);
}
// 实现
@RestController
public class XxxController implements XxxPageApi { ... }
优势:
- 接口定义可以被 Feign Client 直接继承,实现服务间调用零成本
- Swagger/OpenAPI 注解集中在接口层,文档与实现解耦
- 便于生成 SDK 供前端或其他服务使用
1.3 MyBatis 动态 SQL
使用 <where> + <if> + <foreach> 实现灵活的条件组合查询:
xml
<where>
<if test="param.status != null">
AND t.status = #{param.status}
</if>
<if test="param.keyword != null and param.keyword != ''">
AND t.name LIKE CONCAT('%', #{param.keyword}, '%')
</if>
<if test="param.codeList != null and !param.codeList.isEmpty()">
AND t.code IN
<foreach collection="param.codeList" item="code" open="(" separator="," close=")">
#{code}
</foreach>
</if>
</where>
设计要点:
<where>自动处理首个AND的问题<if>实现条件可选,前端不传则不过滤<foreach>处理IN列表查询- 使用
#{}预编译防 SQL 注入(避免${})
1.4 多表 LEFT JOIN 查询
通过 LEFT JOIN 关联多张表,在一次查询中获取完整的展示数据:
sql
SELECT m.*, mb.name, ext.channel_code
FROM main_table m
LEFT JOIN base_table mb ON m.ref_id = mb.id
LEFT JOIN extend_table ext ON m.code = ext.code
设计要点:
- 主表驱动,LEFT JOIN 保证主表数据不丢失
- 关联字段建索引,避免全表扫描
- 只 SELECT 需要的字段,不用
SELECT *
1.5 数据补充模式(Enrichment Pattern)
查询结果中部分字段需要从其他服务获取,采用「批量查询 + Map 映射」模式:
java
// 1. 提取关联 key 集合(去重)
List<String> keys = resultList.stream()
.map(ResultDto::getRelKey)
.distinct()
.collect(Collectors.toList());
// 2. 批量远程调用,结果转 Map
Map<String, RemoteDto> remoteMap = remoteService.batchQuery(keys)
.stream()
.collect(Collectors.toMap(RemoteDto::getKey, v -> v, (k1, k2) -> k1));
// 3. 遍历赋值
resultList.forEach(dto -> {
RemoteDto remote = remoteMap.get(dto.getRelKey());
if (remote != null) {
dto.setExtraField(remote.getValue());
}
});
设计要点:
- 避免 N+1 问题:不在循环中逐条调用远程服务
Collectors.toMap的第三个参数(k1, k2) -> k1处理 key 重复的情况- 远程调用结果需判空和校验 success 状态
1.6 枚举翻译模式
数据库存储编码,展示时翻译为中文名称:
java
public enum StatusEnum {
ACTIVE("A", "有效"),
DELETED("D", "已删除");
private final String code;
private final String name;
public static String getNameByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.map(StatusEnum::getName)
.findFirst()
.orElse("");
}
}
设计要点:
- 枚举集中管理编码与名称的映射关系
- 提供静态方法
getNameByCode供 Service 层调用 - 数据库只存编码,减少存储空间,翻译在应用层完成
1.7 统一响应封装
所有接口返回统一的响应结构:
java
public class RestControllerResult<T> {
private Boolean success;
private T data;
private String errorMsg;
private String errorCode;
}
优势:
- 前端统一处理响应格式
- 异常情况通过 errorCode + errorMsg 传递,不依赖 HTTP 状态码
- 配合全局异常处理器使用
1.8 JWT 认证机制
通过 Spring Security + JWT 公钥验签实现无状态认证:
yaml
security:
jwt:
signing-key: |
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
matchers:
- path: /api/inner/**
attribute: permitAll
- path: /actuator/**
attribute: permitAll
设计要点:
- 默认所有接口需要认证,白名单路径放行
- 使用非对称加密(RSA 公钥验签),认证服务持有私钥签发 Token
/api/page/**路径面向前端页面,需要认证/api/inner/**路径面向内部服务调用,通常放行(由网关控制)
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、流程设计总结
2.1 请求处理流程
客户端请求(携带 JWT Token)
↓
Spring Security Filter(验证 Token 签名和有效期)
↓
Controller(参数默认值处理)
↓
Service
├── 调用 Mapper 查询数据库(分页 + 动态条件)
├── 提取关联 Key,批量调用远程服务补充数据
├── 枚举字段翻译
└── 封装分页结果返回
↓
统一响应封装 → 返回客户端
2.2 分页设计
分页参数由前端传入,框架层(如 PageHelper 拦截器)自动处理:
pageNum:当前页码(从 1 开始)pageSize:每页条数- Controller 层设置默认值,防止空指针
- 返回
PageInfo包含 total、pages、list 等分页元数据
三、完整示例代码
以下是一个「员工管理分页查询」的完整示例,涵盖上述所有技术点。
3.1 项目结构
src/main/java/com/example/demo/
├── api/
│ └── EmployeePageApi.java // API 接口定义
├── controller/
│ └── EmployeeController.java // Controller 实现
├── service/
│ ├── EmployeeService.java // Service 接口
│ └── impl/
│ └── EmployeeServiceImpl.java // Service 实现
├── mapper/
│ └── EmployeeMapper.java // MyBatis Mapper 接口
├── dto/
│ ├── EmployeeQueryParam.java // 查询入参
│ └── EmployeeResultDto.java // 查询出参
├── entity/
│ └── Employee.java // 实体类
├── enums/
│ └── EmployeeStatusEnum.java // 状态枚举
├── feign/
│ └── DepartmentFeign.java // 远程服务调用
└── common/
├── RestResult.java // 统一响应
└── PageInfo.java // 分页封装
src/main/resources/mapper/
└── EmployeeMapper.xml // MyBatis SQL
3.2 API 接口定义
java
package com.example.demo.api;
import com.example.demo.common.PageInfo;
import com.example.demo.common.RestResult;
import com.example.demo.dto.EmployeeQueryParam;
import com.example.demo.dto.EmployeeResultDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@Tag(name = "员工管理", description = "员工信息查询相关接口")
@RequestMapping("/api/page/employee")
public interface EmployeePageApi {
@Operation(summary = "员工分页查询",
description = "支持按姓名、部门、状态等条件筛选",
security = {@SecurityRequirement(name = "bearer-jwt")})
@PostMapping("/list-pager")
RestResult<PageInfo<EmployeeResultDto>> listEmployeePager(
@RequestBody EmployeeQueryParam param);
}
3.3 入参 DTO
java
package com.example.demo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "员工分页查询入参")
public class EmployeeQueryParam {
@Schema(description = "当前页码", example = "1")
private Integer pageNum;
@Schema(description = "每页条数", example = "10")
private Integer pageSize;
@Schema(description = "姓名/工号模糊搜索")
private String keyword;
@Schema(description = "部门编码列表")
private List<String> deptCodeList;
@Schema(description = "状态(0全部、1在职、2离职)")
private Integer status = 1;
@Schema(description = "职级列表")
private List<String> levelList;
}
3.4 出参 DTO
java
package com.example.demo.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
@Data
@Schema(description = "员工分页查询出参")
public class EmployeeResultDto {
@Schema(description = "员工ID")
private Long id;
@Schema(description = "工号")
private String empNo;
@Schema(description = "姓名")
private String name;
@Schema(description = "部门编码")
private String deptCode;
@Schema(description = "部门名称")
private String deptName;
@Schema(description = "职级编码")
private String level;
@Schema(description = "职级名称")
private String levelName;
@Schema(description = "状态编码(A-在职, D-离职)")
private String status;
@Schema(description = "状态名称")
private String statusName;
@Schema(description = "直属上级姓名(远程服务补充)")
private String managerName;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Schema(description = "入职时间")
private Date hireDate;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@Schema(description = "更新时间")
private Date updateTime;
}
3.5 枚举类
java
package com.example.demo.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
@Getter
@AllArgsConstructor
public enum EmployeeStatusEnum {
ACTIVE("A", "在职"),
RESIGNED("D", "离职"),
ON_LEAVE("L", "休假");
private final String code;
private final String name;
/**
* 根据编码获取名称.
*/
public static String getNameByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.map(EmployeeStatusEnum::getName)
.findFirst()
.orElse("");
}
}
3.6 Controller
java
package com.example.demo.controller;
import com.example.demo.api.EmployeePageApi;
import com.example.demo.common.PageInfo;
import com.example.demo.common.RestResult;
import com.example.demo.dto.EmployeeQueryParam;
import com.example.demo.dto.EmployeeResultDto;
import com.example.demo.service.EmployeeService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class EmployeeController implements EmployeePageApi {
private final EmployeeService employeeService;
@Override
public RestResult<PageInfo<EmployeeResultDto>> listEmployeePager(
@RequestBody EmployeeQueryParam param) {
// 分页参数默认值处理
if (param.getPageNum() == null || param.getPageNum() < 1) {
param.setPageNum(1);
}
if (param.getPageSize() == null || param.getPageSize() < 1) {
param.setPageSize(10);
}
return RestResult.success(employeeService.listEmployeePager(param));
}
}
3.7 Service 接口
java
package com.example.demo.service;
import com.example.demo.common.PageInfo;
import com.example.demo.dto.EmployeeQueryParam;
import com.example.demo.dto.EmployeeResultDto;
public interface EmployeeService {
PageInfo<EmployeeResultDto> listEmployeePager(EmployeeQueryParam param);
}
3.8 Service 实现
java
package com.example.demo.service.impl;
import com.example.demo.common.PageInfo;
import com.example.demo.common.RestResult;
import com.example.demo.dto.EmployeeQueryParam;
import com.example.demo.dto.EmployeeResultDto;
import com.example.demo.enums.EmployeeStatusEnum;
import com.example.demo.feign.DepartmentFeign;
import com.example.demo.feign.dto.DeptManagerDto;
import com.example.demo.mapper.EmployeeMapper;
import com.example.demo.service.EmployeeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class EmployeeServiceImpl implements EmployeeService {
private final EmployeeMapper employeeMapper;
private final DepartmentFeign departmentFeign;
@Override
public PageInfo<EmployeeResultDto> listEmployeePager(EmployeeQueryParam param) {
// 1. 分页查询数据库
List<EmployeeResultDto> resultList = employeeMapper.listEmployeePager(
param, param.getPageSize(), param.getPageNum());
if (CollectionUtils.isEmpty(resultList)) {
return new PageInfo<>(Collections.emptyList());
}
// 2. 数据补充:批量查询直属上级信息(远程调用)
List<String> deptCodes = resultList.stream()
.map(EmployeeResultDto::getDeptCode)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
Map<String, String> managerMap = this.batchQueryManagers(deptCodes);
// 3. 遍历结果集,补充字段 + 枚举翻译
resultList.forEach(dto -> {
// 补充直属上级姓名
if (StringUtils.hasText(dto.getDeptCode())) {
dto.setManagerName(managerMap.getOrDefault(dto.getDeptCode(), ""));
}
// 枚举翻译:状态
if (StringUtils.hasText(dto.getStatus())) {
dto.setStatusName(EmployeeStatusEnum.getNameByCode(dto.getStatus()));
}
});
// 4. 封装分页结果
return new PageInfo<>(resultList);
}
/**
* 批量查询部门负责人信息,返回 Map<deptCode, managerName>.
* 通过 Feign 远程调用组织服务,避免 N+1 问题。
*/
private Map<String, String> batchQueryManagers(List<String> deptCodes) {
if (CollectionUtils.isEmpty(deptCodes)) {
return Collections.emptyMap();
}
try {
RestResult<List<DeptManagerDto>> result =
departmentFeign.batchQueryManagerByDeptCodes(deptCodes);
if (result != null && Boolean.TRUE.equals(result.getSuccess())
&& !CollectionUtils.isEmpty(result.getData())) {
return result.getData().stream()
.collect(Collectors.toMap(
DeptManagerDto::getDeptCode,
DeptManagerDto::getManagerName,
(k1, k2) -> k1 // key 冲突取第一个
));
}
} catch (Exception e) {
log.warn("批量查询部门负责人失败, deptCodes={}", deptCodes, e);
}
return Collections.emptyMap();
}
}
3.9 Mapper 接口
java
package com.example.demo.mapper;
import com.example.demo.dto.EmployeeQueryParam;
import com.example.demo.dto.EmployeeResultDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface EmployeeMapper {
List<EmployeeResultDto> listEmployeePager(
@Param("param") EmployeeQueryParam param,
@Param("pageSize") Integer pageSize,
@Param("pageNum") Integer pageNum);
}
3.10 MyBatis XML
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.EmployeeMapper">
<select id="listEmployeePager"
resultType="com.example.demo.dto.EmployeeResultDto">
SELECT
e.id,
e.emp_no AS empNo,
e.name,
e.status,
e.hire_date AS hireDate,
e.update_time AS updateTime,
d.dept_code AS deptCode,
d.dept_name AS deptName,
el.level_code AS level,
el.level_name AS levelName
FROM employee e
LEFT JOIN department d ON e.dept_id = d.id
LEFT JOIN employee_level el ON e.level_id = el.id
<where>
<!-- 状态过滤 -->
<if test="param.status == 1">
AND e.status = 'A'
</if>
<if test="param.status == 2">
AND e.status = 'D'
</if>
<!-- 关键字模糊搜索(姓名或工号) -->
<if test="param.keyword != null and param.keyword != ''">
AND (
e.name LIKE CONCAT('%', #{param.keyword}, '%')
OR e.emp_no LIKE CONCAT('%', #{param.keyword}, '%')
)
</if>
<!-- 部门编码列表过滤 -->
<if test="param.deptCodeList != null and !param.deptCodeList.isEmpty()">
AND d.dept_code IN
<foreach collection="param.deptCodeList" item="deptCode"
open="(" separator="," close=")">
#{deptCode}
</foreach>
</if>
<!-- 职级列表过滤 -->
<if test="param.levelList != null and !param.levelList.isEmpty()">
AND el.level_code IN
<foreach collection="param.levelList" item="levelCode"
open="(" separator="," close=")">
#{levelCode}
</foreach>
</if>
</where>
ORDER BY e.id DESC
</select>
</mapper>
3.11 Feign 远程调用
java
package com.example.demo.feign;
import com.example.demo.common.RestResult;
import com.example.demo.feign.dto.DeptManagerDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List;
@FeignClient(name = "organization-service", path = "/api/inner/department")
public interface DepartmentFeign {
@PostMapping("/batch-query-manager")
RestResult<List<DeptManagerDto>> batchQueryManagerByDeptCodes(
@RequestBody List<String> deptCodes);
}
package com.example.demo.feign.dto;
import lombok.Data;
@Data
public class DeptManagerDto {
private String deptCode;
private String managerName;
}
3.12 统一响应类
java
package com.example.demo.common;
import lombok.Data;
@Data
public class RestResult<T> {
private Boolean success;
private T data;
private String errorCode;
private String errorMsg;
public static <T> RestResult<T> success(T data) {
RestResult<T> result = new RestResult<>();
result.setSuccess(true);
result.setData(data);
return result;
}
public static <T> RestResult<T> fail(String errorCode, String errorMsg) {
RestResult<T> result = new RestResult<>();
result.setSuccess(false);
result.setErrorCode(errorCode);
result.setErrorMsg(errorMsg);
return result;
}
}
3.13 分页封装类
java
package com.example.demo.common;
import lombok.Data;
import java.util.List;
@Data
public class PageInfo<T> {
private List<T> list;
private long total;
private int pageNum;
private int pageSize;
private int pages;
public PageInfo(List<T> list) {
this.list = list;
// 实际项目中 total/pages 由分页插件(如 PageHelper)自动填充
}
}
3.14 Entity 实体
java
package com.example.demo.entity;
import lombok.Data;
import javax.persistence.*;
import java.util.Date;
@Data
@Entity
@Table(name = "employee")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "emp_no")
private String empNo;
@Column(name = "name")
private String name;
@Column(name = "dept_id")
private Long deptId;
@Column(name = "level_id")
private Long levelId;
/** 状态:A-在职, D-离职, L-休假 */
@Column(name = "status")
private String status;
@Column(name = "hire_date")
private Date hireDate;
@Column(name = "create_time")
private Date createTime;
@Column(name = "update_time")
private Date updateTime;
}
3.15 application.yml 安全配置示例
yaml
security:
jwt:
signing-key: |
-----BEGIN PUBLIC KEY-----
YOUR_RSA_PUBLIC_KEY_HERE
-----END PUBLIC KEY-----
resource-ids: your-service-id
matchers:
# 内部服务调用路径放行
- path: /api/inner/**
attribute: permitAll
# 健康检查放行
- path: /actuator/**
attribute: permitAll
# 其余 /api/page/** 路径默认需要 JWT 认证
四、设计模式与最佳实践清单
| 编号 | 实践 | 说明 |
|---|---|---|
| 1 | 接口契约分离 | API interface 独立定义,可被 Feign 继承复用 |
| 2 | 分页默认值 | Controller 层兜底,防止前端漏传导致全表查询 |
| 3 | 动态条件查询 | MyBatis <if> 实现条件可选,不传不过滤 |
| 4 | 批量数据补充 | 提取 key → 批量远程调用 → Map 映射赋值,避免 N+1 |
| 5 | 枚举翻译 | DB 存编码,应用层翻译,枚举类集中管理 |
| 6 | 统一响应格式 | RestResult<T> 封装 success/data/error |
| 7 | LEFT JOIN | 主表驱动关联,保证主表数据完整性 |
| 8 | 倒序排列 | ORDER BY id DESC,最新数据在前 |
| 9 | 远程调用容错 | try-catch 包裹 Feign 调用,失败不影响主流程 |
| 10 | JWT 无状态认证 | 公钥验签 + 路径白名单,框架级统一处理 |
五、扩展建议
5.1 性能优化
- 对模糊搜索字段考虑使用 ES 替代 LIKE 查询
- 远程调用结果可加本地缓存(如 Caffeine),减少重复调用
- 大数据量场景考虑游标分页(
WHERE id > lastId)替代 OFFSET 分页
5.2 健壮性增强
- 入参校验可引入
@Validated+javax.validation注解 - Feign 调用加熔断降级(Sentinel / Resilience4j)
- 分页 pageSize 设置上限(如最大 100),防止恶意大量查询
5.3 可观测性
- Service 层关键方法加
@Slf4j日志 - 远程调用记录耗时和结果状态
- 慢 SQL 监控(MyBatis 拦截器或数据库层面)