1. 问题背景与现象分析
最近在开发一个资金管理系统的查询功能时,遇到了一个典型的日期范围查询报错问题。使用Spring Boot + MyBatis-Plus组合开发查询接口时,当用户不传日期范围参数时,系统会抛出IndexOutOfBoundsException异常。这个错误看似简单,但背后涉及到多个层面的防御性编程问题。
错误堆栈显示了两类异常:
IndexOutOfBoundsException: Index 0 out of bounds for length 0- 发生在尝试访问空列表的第一个元素时NullPointerException: Cannot invoke "java.util.List.isEmpty()"- 发生在调用null列表的isEmpty()方法时
这两个异常都指向同一个核心问题:对日期范围参数的处理不够健壮。作为开发者,我们需要确保代码能够优雅地处理各种边界情况,特别是用户可能不传参数或传参不规范的情况。
2. 错误根源深度解析
2.1 原始代码的问题点
原始代码中日期范围查询部分是这样的:
java复制.between(
queryDTO.getCreateDate().size() == 2,
CapitalInfo::getCreateTime,
DateUtils.parseDateTimeRange(queryDTO.getCreateDate()).get(0),
DateUtils.parseDateTimeRange(queryDTO.getCreateDate()).get(1)
)
这段代码存在三个严重问题:
- 空指针风险:直接调用
queryDTO.getCreateDate().size(),如果getCreateDate()返回null就会抛出NPE - 重复解析问题:两次调用
DateUtils.parseDateTimeRange(),既影响性能又增加了出错概率 - 缺乏空列表检查:即使size检查通过,parseDateTimeRange()返回的列表仍可能为空
2.2 MyBatis-Plus的between方法机制
MyBatis-Plus的between方法签名如下:
java复制Children between(boolean condition, R column, Object val1, Object val2)
当condition为false时,不会添加该条件。但condition的false值并不能阻止参数表达式的执行,这就是为什么即使size检查放在第一个参数,仍然会执行后面的参数获取操作。
3. 解决方案设计与实现
3.1 防御性编程改造
改造后的代码采用分层防御策略:
java复制// 处理日期范围,避免为 null
if (queryDTO.getCreateDate() == null) {
queryDTO.setCreateDate(new ArrayList<>());
}
LocalDateTime beginDateTime = null;
LocalDateTime endDateTime = null;
if (queryDTO.getCreateDate() != null && queryDTO.getCreateDate().size() == 2) {
List<LocalDateTime> dateTimeRange = DateUtils.parseDateTimeRange(queryDTO.getCreateDate());
if (dateTimeRange.size() == 2) {
beginDateTime = dateTimeRange.get(0);
endDateTime = dateTimeRange.get(1);
}
}
// 在查询条件中使用
queryWrapper.between(
beginDateTime != null && endDateTime != null,
CapitalInfo::getCreateTime,
beginDateTime,
endDateTime
)
这个方案有以下改进:
- 空集合初始化:确保getCreateDate()永远不会返回null
- 提前解析:只解析一次日期范围,避免重复调用
- 双重验证:既验证输入参数数量,又验证解析结果
- 显式条件:使用解析后的非空判断作为between条件
3.2 DTO层的优化建议
在CapitalInfoQueryDTO类中,可以设置默认值来彻底避免null问题:
java复制public class CapitalInfoQueryDTO {
private List<String> createDate = new ArrayList<>();
// 其他字段和方法...
}
这样即使不调用setCreateDate(),getCreateDate()也会返回空集合而非null。
4. 完整解决方案代码
以下是经过全面加固的查询方法实现:
java复制/**
* 获取资金信息列表(安全版本)
*/
public List<CapitalInfoVO> queryList(@NotNull CapitalInfoQueryDTO queryDTO) {
// 1. 参数初始化防御
if (queryDTO.getCreateDate() == null) {
queryDTO.setCreateDate(Collections.emptyList());
}
// 2. 日期范围解析
LocalDateTime[] dateRange = parseDateRange(queryDTO.getCreateDate());
// 3. 构建查询条件
LambdaQueryWrapper<CapitalInfo> queryWrapper = new LambdaQueryWrapper<>();
buildQueryConditions(queryWrapper, queryDTO, dateRange);
// 4. 执行查询并转换结果
return capitalInfoMapper.selectList(queryWrapper)
.stream()
.map(this::convertToVO)
.collect(Collectors.toList());
}
/**
* 解析日期范围参数
*/
private LocalDateTime[] parseDateRange(List<String> dateParams) {
if (dateParams == null || dateParams.size() != 2) {
return null;
}
try {
List<LocalDateTime> range = DateUtils.parseDateTimeRange(dateParams);
return range.size() == 2 ? new LocalDateTime[]{range.get(0), range.get(1)} : null;
} catch (Exception e) {
log.warn("日期解析失败: {}", dateParams, e);
return null;
}
}
/**
* 构建查询条件
*/
private void buildQueryConditions(LambdaQueryWrapper<CapitalInfo> wrapper,
CapitalInfoQueryDTO queryDTO,
LocalDateTime[] dateRange) {
wrapper
.like(StringUtils.hasText(queryDTO.getCapitalNo()), CapitalInfo::getCapitalNo, queryDTO.getCapitalNo())
.like(StringUtils.hasText(queryDTO.getCapitalName()), CapitalInfo::getCapitalName, queryDTO.getCapitalName())
.eq(StringUtils.hasText(queryDTO.getCapitalType()), CapitalInfo::getCapitalType, queryDTO.getCapitalType())
.eq(StringUtils.hasText(queryDTO.getCapitalIndexType()), CapitalInfo::getCapitalIndexType, queryDTO.getCapitalIndexType())
.like(StringUtils.hasText(queryDTO.getCapitalAccount()), CapitalInfo::getCapitalAccount, queryDTO.getCapitalAccount())
.eq(StringUtils.hasText(queryDTO.getCapitalSource()), CapitalInfo::getCapitalSource, queryDTO.getCapitalSource())
.eq(StringUtils.hasText(queryDTO.getCapitalIndexSource()), CapitalInfo::getCapitalIndexSource, queryDTO.getCapitalIndexSource())
.eq(queryDTO.getCapitalYear() != null, CapitalInfo::getCapitalYear, queryDTO.getCapitalYear())
.eq(queryDTO.getCapitalState() != null, CapitalInfo::getCapitalState, queryDTO.getCapitalState())
.like(StringUtils.hasText(queryDTO.getRemark()), CapitalInfo::getRemark, queryDTO.getRemark())
.between(dateRange != null, CapitalInfo::getCreateTime, dateRange[0], dateRange[1])
.orderByDesc(CapitalInfo::getCapitalYear)
.orderByDesc(CapitalInfo::getId);
}
/**
* 实体转换
*/
private CapitalInfoVO convertToVO(CapitalInfo entity) {
CapitalInfoVO vo = new CapitalInfoVO();
BeanUtils.copyProperties(entity, vo);
return vo;
}
5. 经验总结与最佳实践
5.1 MyBatis-Plus条件构造的使用技巧
-
条件方法的执行顺序:MyBatis-Plus的条件方法会立即计算所有参数表达式,即使条件参数为false。因此要避免在参数位置调用可能抛出异常的方法。
-
链式调用的优化:对于复杂的查询条件,建议:
- 将条件构建拆分为多个方法
- 对可能重复使用的条件使用变量保存
- 对性能敏感的操作提前计算好参数
-
null值处理:对于可能为null的字段,使用
ObjectUtil.isNotNull等工具方法比直接!= null更安全。
5.2 日期范围查询的工程实践
-
参数设计规范:
- 前端应统一传参格式,如
["2023-01-01", "2023-12-31"] - 对于不完整的日期(如只有年月),应在文档中明确说明处理逻辑
- 考虑添加时区参数处理跨时区场景
- 前端应统一传参格式,如
-
解析工具类增强:
java复制public class DateUtils {
public static Optional<LocalDateTime[]> safeParseDateTimeRange(List<String> dateStrs) {
try {
List<LocalDateTime> dates = parseDateTimeRange(dateStrs);
return dates.size() == 2
? Optional.of(new LocalDateTime[]{dates.get(0), dates.get(1)})
: Optional.empty();
} catch (Exception e) {
return Optional.empty();
}
}
}
- 日志记录建议:
- 记录非法日期参数的原始值
- 对解析失败的情况记录warn日志
- 对高频出现的错误参数进行监控报警
5.3 常见问题排查指南
问题1:日期条件不生效
- 检查DTO中字段命名是否与JSON属性一致
- 确认前端传参格式与后端解析逻辑匹配
- 检查时区配置是否正确
问题2:查询结果不符合预期
- 打印最终生成的SQL语句(开启MyBatis日志)
- 检查各条件字段的数据库实际值
- 验证日期比较是否包含边界值
问题3:性能问题
- 确保日期字段上有适当的索引
- 对于大范围查询考虑分页
- 避免在日期字段上使用函数操作
6. 扩展思考与优化方向
6.1 查询条件的动态化
对于更复杂的查询场景,可以考虑:
- 使用注解校验:
java复制public class CapitalInfoQueryDTO {
@Size(max = 2, message = "日期范围必须包含开始和结束时间")
private List<@DateTimeFormat String> createDate;
}
- 自定义查询构建器:
java复制public class CapitalInfoQueryBuilder {
public static LambdaQueryWrapper<CapitalInfo> build(CapitalInfoQueryDTO dto) {
// 构建逻辑...
}
}
6.2 更安全的参数处理
- 使用Optional避免null:
java复制Optional.ofNullable(queryDTO.getCreateDate())
.orElse(Collections.emptyList())
.stream()
// 其他操作
- 不可变集合:
java复制private List<String> createDate = Collections.emptyList(); // 而不是new ArrayList<>()
6.3 性能优化建议
-
批量查询优化:对于需要多次查询的场景,考虑使用MyBatis-Plus的
selectBatchIds等方法 -
索引提示:在特别复杂的查询中,可以使用SQL注释添加索引提示
-
查询缓存:对于相对静态的数据,考虑添加缓存层
在实际项目中,日期范围查询是最常见的查询条件之一,也是容易出错的场景。通过本文的解决方案,我们不仅解决了当前的异常问题,还建立了一套健壮的处理机制,能够应对各种边界情况。关键在于始终牢记:用户输入是不可信的,必须做好防御性编程。