Spring Boot 分页查询接口设计与实现 —— 技术总结与完整示例

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 拦截器或数据库层面)
相关推荐
一条泥憨鱼1 小时前
深入理解Java反射(超详细)
java·开发语言·spring·mybatis·反射
J-query1 小时前
修改AndroidStudio的Boot Java Runtime for the IDE后,AndroidStudio启动就报错
java·开发语言·ide·android studio
Han.miracle1 小时前
Java HashMap 与 ConcurrentHashMap 核心原理总结:从 Hash 冲突到 LongAdder
java·算法·哈希算法
Gauss松鼠会1 小时前
GaussDB(DWS) SQL性能问题案例集
java·数据库·经验分享·spring boot·后端·sql·gaussdb
NiceCloud喜云2 小时前
Anthropic 发布 Project Glasswing:未公开模型 Mythos 已挖出 10000+ 漏洞,含 OpenBSD 27 年老 bug
android·java·数据库·c++·python·docker·bug
鬼才血脉2 小时前
IDEA中集成Tomcat后重新部署、重启服务器、更新资源、更新类和资源的使用
java·服务器·intellij-idea
码农的小菜园2 小时前
Java创建单例
java·开发语言·单例模式
曹牧2 小时前
C#:基类中定义泛型方法
java·开发语言·c#
兰令水2 小时前
leecodecode【滑动窗口】【2026.5.27打卡-java版本】
java·数据结构·算法