Java Web 开发中的分页与参数校验
在 Java Web 开发中,分页和参数校验是两个非常重要的功能。本文将围绕 分页设计 和 参数校验 进行探讨,包括如何设计合理的分页查询参数,以及如何利用 Java 注解 实现参数校验。
1️⃣ 分页设计
❓ 为什么需要分页?
当数据库表数据量较大时,如果直接查询所有数据,可能会导致 查询缓慢 ,甚至造成 内存溢出(OOM) 。分页是一种常见的优化方式,可以 减少数据库负载 并 提升前端渲染速度。
✅ 如何设计分页查询参数?
分页通常包含以下几个核心参数:
pageNo
:当前页码,默认为1
。pageSize
:每页返回的记录数,默认为20
。sortBy
:排序字段,如id
、create_time
。isAsc
:是否升序,默认为true
。
我们可以设计一个公共的父类PageQuery
来帮助提供默认的参数,同时我们在开发中也会用到mybatisplus,提供出转成page对象的方法
java
@Data
@ApiModel(description = "分页请求参数")
@Accessors(chain = true)
public class PageQuery {
public static final Integer DEFAULT_PAGE_SIZE = 20;
public static final Integer DEFAULT_PAGE_NUM = 1;
@ApiModelProperty(value = "页码", example = "1")
@Min(value = 1, message = "页码不能小于1")
private Integer pageNo = DEFAULT_PAGE_NUM;
@ApiModelProperty(value = "每页大小", example = "5")
@Min(value = 1, message = "每页查询数量不能小于1")
private Integer pageSize = DEFAULT_PAGE_SIZE;
@ApiModelProperty(value = "是否升序", example = "true")
private Boolean isAsc = true;
@ApiModelProperty(value = "排序字段", example = "id")
private String sortBy;
public int from(){
return (pageNo - 1) * pageSize;
}
public <T> Page<T> toMpPage(OrderItem ... orderItems) {
Page<T> page = new Page<>(pageNo, pageSize);
// 是否手动指定排序方式
if (orderItems != null && orderItems.length > 0) {
for (OrderItem orderItem : orderItems) {
page.addOrder(orderItem);
}
return page;
}
// 前端是否有排序字段
if (StringUtils.isNotEmpty(sortBy)){
OrderItem orderItem = new OrderItem();
orderItem.setAsc(isAsc);
orderItem.setColumn(sortBy);
page.addOrder(orderItem);
}
return page;
}
public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc) {
if (StringUtils.isBlank(sortBy)){
sortBy = defaultSortBy;
this.isAsc = isAsc;
}
Page<T> page = new Page<>(pageNo, pageSize);
OrderItem orderItem = new OrderItem();
orderItem.setAsc(this.isAsc);
orderItem.setColumn(sortBy);
page.addOrder(orderItem);
return page;
}
public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() {
return toMpPage(Constant.DATA_FIELD_NAME_CREATE_TIME, false);
}
}
🚀 设计优点
- 默认分页参数,即使前端未传分页参数,也不会报错。
- 支持排序 ,可以根据前端传递的
sortBy
和isAsc
进行排序。 - 与 MyBatis-Plus 兼容 ,直接转换为
Page<T>
,减少重复代码。
📌 使用方式
在实际开发中,我们可以在 Service 层调用 toMpPage()
方法,将 PageQuery
转换为 MyBatis-Plus 的 Page<T>
对象。
java
public Page<User> getUserList(PageQuery query) {
Page<User> page = query.toMpPage();
return userMapper.selectPage(page, new QueryWrapper<>());
}
2️⃣ 参数校验的艺术:从基础校验到深度防御
❓ 为什么参数校验是系统安全的第一道防线?
在实际开发中,我们常遇到这样的问题:
- 用户输入手机号为"1381234abcd"
- 订单金额出现负数
- 状态字段传入非法数值
- 接口被恶意构造异常参数攻击
参数校验如同系统的门卫,负责:
- 拦截80%以上的常规攻击
- 保证业务数据的有效性
- 提高代码可读性和健壮性
- 降低下游服务的校验压力
💡JSR 380规范的核心武器库
基础校验实战
java
@PostMapping("/create")
public Result createCoupon(@Valid @RequestBody CouponFormDTO dto) {
// 业务逻辑
}
@Data
public class CouponFormDTO {
@NotNull(message = "优惠券类型不能为空")
private Integer couponType;
@Range(min=1, max=10, message="限领数量超出范围")
private Integer limitCount;
@EnumValid(enumeration = {0,1}, message="领取方式非法")
private ReceiveEnums receiveType;
}
常用注解矩阵:
注解 | 适用类型 | 适用场景 |
---|---|---|
@NotNull | 任意对象 | 确保字段不能为空 |
@NotBlank | String | 确保字符串不能为空 |
@NotEmpty | 集合/数组 | 确保列表有数据 |
@Size | String/集合 | 限制长度或元素数量 |
@Min/@Max | 数值类型 | 限制最小/最大值 |
@Pattern | String | 正则表达式校验 |
String | 邮箱格式校验 | |
@Future | Date | 时间必须是未来时间 |
@Past | Date | 时间必须是过去时间 |
@Digits | 数值类型 | 限制整数位数和小数位数 |
🔧 深度解析参数校验原理
JSR 380 校验流程
- HTTP 请求进入 Controller 层,绑定参数到 DTO 对象。
**@Valid**
触发校验机制,调用 Hibernate Validator。- 执行校验逻辑,遍历 DTO 字段并检查注解规则。
- 校验失败抛出
**MethodArgumentNotValidException**
。 - 全局异常处理器捕获异常,封装并返回错误信息。
@Valid vs. @Validated 区别
特性 | @Valid | @Validated |
---|---|---|
作用范围 | 单个 DTO | DTO + 分组校验 |
分组支持 | 不支持 | 支持 |
适用场景 | 基础校验 | 复杂业务场景 |
java
@PostMapping("/update")
public Result update(@Validated(UpdateGroup.class) @RequestBody UserDTO userDTO) {
// 业务逻辑处理
}
📌自定义枚举校验的黑科技
数据库设计的隐痛
我们在设计数据库时通常会使用某个数字来代表某个状态,如:
sql
CREATE TABLE coupon (
status TINYINT COMMENT '0-未激活 1-已生效 2-已过期'
)
传统校验方式:
java
if (!Arrays.asList(0,1,2).contains(status)) {
throw new IllegalArgumentException();
}
缺陷:
- 校验逻辑分散
- 可维护性差
- 无法复用
📌自定义注解解决方案
定义枚举校验注解:
java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValid {
int[] value() default {};
String message() default "非法枚举值";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
实现校验逻辑:
java
public class EnumValidator implements ConstraintValidator<EnumValid, Integer> {
private Set<Integer> allowedValues = new HashSet<>();
@Override
public void initialize(EnumValid constraintAnnotation) {
Arrays.stream(constraintAnnotation.value())
.forEach(allowedValues::add);
}
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) return true;
return allowedValues.contains(value);
}
}
原理深度解析
ConstraintValidator生命周期:
- 初始化:读取注解配置
- 校验时:执行isValid方法
- 结果处理:返回布尔值
JSR 380规范实现要点:
- 校验器发现机制:SPI方式加载
- 级联校验:支持对象嵌套校验
- 分组校验:实现不同场景的校验规则
💡复杂校验的终极方案
有时,仅仅靠我们的校验比较复杂,这时我们可能需要自己来编写校验逻辑
我们可以通过自定义注解+AOP来帮我们实现
java
/**
* 实现后在接口访问时如果接口实现了这个接口
* 会被自动自行接口check进行校验
**/
public interface Checker<T> {
/**
* 用于实现validation不能校验的数据逻辑
*/
default void check(){
}
default void check(T data){
}
}
使用示例
java
Data
@ApiModel(description = "章节")
public class CataSaveDTO implements Checker {
@ApiModelProperty("章、节、练习id")
private Long id;
@ApiModelProperty("目录类型1:章,2:节,3:测试")
@NotNull(message = "")
private Integer type;
@ApiModelProperty("章节练习名称")
private String name;
@ApiModelProperty("章排序,章一定要传,小节和练习不需要传")
private Integer index;
@ApiModelProperty("当前章的小节或练习")
@Size(min = 1, message = "不能出现空章")
private List<CataSaveDTO> sections;
@Override
public void check() {
//名称为空校验
if(type == CourseConstants.CataType.CHAPTER && StringUtils.isEmpty(name)) {
throw new BadRequestException(CourseErrorInfo.Msg.COURSE_CATAS_SAVE_NAME_NULL);
}else if(StringUtils.isEmpty(name)){
throw new BadRequestException(CourseErrorInfo.Msg.COURSE_CATAS_SAVE_NAME_NULL2);
}
//名称长度问题
if (type == CourseConstants.CataType.CHAPTER && name.length() > 30){
throw new BadRequestException(CourseErrorInfo.Msg.COURSE_CATAS_SAVE_NAME_SIZE);
}else if(name.length() > 30) {
throw new BadRequestException(CourseErrorInfo.Msg.COURSE_CATAS_SAVE_NAME_SIZE2);
}
if(CollUtils.isEmpty(sections)){
throw new BadRequestException("不能出现空章");
}
}
}
接口方法参数校验器
java
/**
* 接口方法参数校验器
**/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ParamChecker {
}
定义切面类
java
@Aspect
@Slf4j
@SuppressWarnings("all")
public class CheckerAspect {
@Before("@annotation(paramChecker)")
public void before(JoinPoint joinPoint, ParamChecker paramChecker) {
Object[] args = joinPoint.getArgs();
if(ArrayUtils.isNotEmpty(args)){
//遍历方法参数,参数是否实现了Checker接口
for (Object arg : args){
if(arg instanceof Checker) {
//调用check方法,校验业务逻辑
((Checker)arg).check();
}else if(arg instanceof List){
//如果参数是一个集合也要校验
CollUtils.check((List) arg);
}
}
}
}
}
工具方法
java
/**
* 集合校验逻辑
*
* @param data 要校验的集合
* @param checker 校验器
* @param <T> 集合元素类型
*/
public static <T> void check(List<T> data, Checker<T> checker){
if(data == null){
return;
}
for (T t : data){
checker.check(t);
}
}
/**
* 集合校验逻辑
*
* @param data 要校验的集合
* @param <T> 集合元素类型
*/
public static <T extends Checker<T>> void check(List<T> data){
if(data == null){
return;
}
for (T t : data){
t.check();
}
}
注解使用
java
@PostMapping("baseInfo/save")
@ApiOperation("保存课程基本信息")
@ParamChecker
//校验非业务限制的字段
public CourseSaveVO save(@RequestBody @Validated(CourseSaveBaseGroup.class) CourseBaseInfoSaveDTO courseBaseInfoSaveDTO) {
return courseDraftService.save(courseBaseInfoSaveDTO);
}
🚀注意:切面类没有纳入ioc容器管理,如果是单体项目加上component注解即可,如果是多模块项目,使用自动装配功能