ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring JDBC条件查询进阶:从字符串拼接到可复用资产

Spring JDBC条件查询进阶:从字符串拼接到可复用资产 当业务查询的条件超过两个SpringJDBC 的代码就会开始变得难看起来。这是很多开发者从 MyBatis 切到 Spring JDBC 之后的第一反应也是“条件进阶”真正要解决的事情不是多学几个 API而是把条件组织、参数绑定、结果映射和边界处理整理成一套可控流程。如果只是用字符串拼接 SQL条件少时感觉挺好用一旦条件多起来代码会越来越脆项目里最容易被反复改出 Bug 的往往就是这类查询方法。条件进阶本质上是围绕“动态条件”做工程化。你需要的不是背更多的类名而是知道自己写的每一条 SQL、每一个参数、每一次结果映射在真实业务里会如何被组合、被复用、被维护。这篇文章我会从最原始的 SQL 拼接讲起逐步拆到 NamedParameterJdbcTemplate、动态条件容器、分页排序、结果映射和常见坑位最后沉淀出一套可以直接落到项目里的验证顺序和抽象思路。1. 条件进阶先要解决的是“条件组织”问题不是多学几个 API1.1 从最原始的字符串拼接说起很多新手第一次写 Spring JDBC 条件查询用的是类似这样的代码String sql SELECT * FROM user WHERE 11; if (name ! null !name.isEmpty()) { sql AND name name ; } if (status ! null) { sql AND status status; } jdbcTemplate.queryForList(sql);这段代码在本地跑通时你会觉得“还挺灵活”。但只要你把 name 换成一段带引号的输入或者把 status 变成一个前端传入的字符串问题就出现了。SQL 注入只是最明显的风险更隐蔽的是类型转换、引号处理、日期格式、字段歧义以及条件一旦变多之后SQL 变得完全不可读。字符串拼接的条件进阶最麻烦的问题不是慢而是不可维护。每加一个查询条件就要在 if 块里追加一段字符串每删一个条件就要小心翼翼地检查前后有没有多余的 AND。等到条件达到五六个这段代码基本没人敢动。我这里并不是要否定所有动态 SQL 拼接。实际上Spring JDBC 本身没有内置类似 MyBatis 的动态 SQL 标签很多场景下你必须自己拼。关键是拼接方式需要具备可控性条件要不要拼进去由业务代码决定但参数不能被塞进 SQL 字符串SQL 结构要和参数分离开。1.2 为什么条件一多Bug 就跟着来条件一多Bug 会集中在三个位置。第一个是参数位置错位。使用?占位符时SQL 里的?顺序必须和参数列表顺序完全一致。加一个条件、减一个条件、调整一个条件顺序都有可能让参数错位。而且这种错位通常在特定输入组合下才会暴露不容易在一开始被发现。第二个是分支组合爆炸。假设有五个可选条件组合方式有 2 的 5 次方种。你用 if 拼接 SQL 时每个组合都要在逻辑上正确。单元测试很难覆盖全部组合实际运行中就可能出现“查询条件 A 和 C 同时传时 SQL 少了一个空格”这种诡异问题。第三个是字段与业务规则耦合。一个查询条件在页面上叫“关键词”但落到 SQL 里可能是 name、mobile、email 三个字段的 OR 组合一个状态条件可能是 status1 或 deleted0 的固定片段。如果不把这类规则封装起来每个调用方都会按自己的理解拼一遍结果越来越不一致。条件进阶的核心判断是条件多不可怕可怕的是条件没有统一入口。你需要一个地方负责“哪些条件参与查询”“参数如何收集”“SQL 如何组装”这样调用方只需要传条件不需要知道 SQL 结构。2. NamedParameterJdbcTemplate给条件一个名字而不是位置2.1 基本用法把“?”换成“:参数名”Spring JDBC 提供给条件查询最实用的能力是命名参数。相比传统的?占位符命名参数让 SQL 里的每个条件都有名字参数绑定不再依赖顺序。NamedParameterJdbcTemplate namedTemplate new NamedParameterJdbcTemplate(jdbcTemplate); String sql SELECT * FROM user WHERE name :name AND status :status; MapString, Object params new HashMap(); params.put(name, 张三); params.put(status, 1); ListMapString, Object list namedTemplate.queryForList(sql, params);这段代码里SQL 中出现的:name和:status必须和 params 中的 key 对应。好处是肉眼可读SQL 结构清晰参数传错时也能通过日志很快定位。我一般建议项目里从一开始就使用 NamedParameterJdbcTemplate而不是 JdbcTemplate。它不会带来额外复杂度但对条件查询的可维护性提升非常明显。尤其是当条件从三个增加到五个时命名参数的优势会立刻体现出来。2.2 动态条件时的参数容器设计动态条件场景下参数不能再用简单的 HashMap 一路传到底因为不同条件组合下Map 里的 key 数量会变化。更好的做法是使用MapSqlParameterSourceMapSqlParameterSource params new MapSqlParameterSource(); params.addValue(name, name); params.addValue(status, status);MapSqlParameterSource的好处是可以在 addValue 时额外指定 SQL 类型便于数据库驱动正确处理 null、日期等特殊值。它在构建动态 SQL 时更自然条件决定 SQL 片段是否拼接同时也决定参数是否需要 add。实际项目里我建议遵守一个原则只有真正被拼进 SQL 的条件才向参数容器里放值。不要提前放一堆可能用不到的 key。虽然不同数据库对多余参数的容忍度不同但保持“SQL 中出现什么参数名容器里就放什么 key”会大大降低排查成本。动态条件最常见的错误是 SQL 里用了:keyword但参数容器里没有这个 key结果运行时报No value supplied for the SQL parameter keyword。这类问题很好排查因为报错信息已经指出了参数名。真正难排查的反而是参数名都对、但值不是预期值的情况这就要回到参数构造的过程去确认。3. 条件查询的五个关键层次从能查到放心用我把 Spring JDBC 条件进阶拆成五个层次。你不一定每个项目都要走到第五层但至少要清楚当前代码处在哪一层以及下一步往哪里演进。3.1 第一层固定条件简单查询第一层是静态条件。SQL 不变参数固定直接使用 NamedParameterJdbcTemplate 查询即可String sql SELECT id, name, status FROM user WHERE status :status; MapSqlParameterSource params new MapSqlParameterSource(); params.addValue(status, 1);这一层的关键不是 SQL 本身而是结果映射。你可以直接用queryForList得到ListMapString, Object但 map 里的 key 是数据库字段名大小写和类型都不一定符合 Java 端期望。更推荐的做法是使用 RowMapper 或提前定义好 DTO。3.2 第二层动态拼接条件第二层开始处理可选条件。推荐的做法是把 SQL 拆成主干和条件片段然后用StringBuilder组装StringBuilder sql new StringBuilder(SELECT id, name, status FROM user WHERE 11); MapSqlParameterSource params new MapSqlParameterSource(); if (StringUtils.hasText(keyword)) { sql.append( AND (name LIKE :keyword OR mobile LIKE :keyword)); params.addValue(keyword, % keyword %); } if (status ! null) { sql.append( AND status :status); params.addValue(status, status); }这里用了WHERE 11主要原因是条件片段都以AND开头代码写起来简单不用额外判断是不是第一个条件。缺点是 SQL 里多了一个恒真条件对优化器影响极小对可读性稍有影响。如果你介意11可以把条件片段放进集合最终统一拼ListString conditions new ArrayList(); if (...) { conditions.add(name :name); } String whereSql conditions.isEmpty() ? : WHERE String.join( AND , conditions);这种方式更干净但代码量多一点点。实际项目里两种方案都能用关键在于条件片段和参数必须同时维护不要出现 SQL 里有条件但参数没加的情况。3.3 第三层排序、分页与统计条件条件查询一旦进入列表页就会遇到排序和分页。排序字段不能使用占位符绑定因为绝大多数数据库不允许ORDER BY ?这种写法你只能把字段名拼进 SQL。这里必须做白名单校验String orderBy create_time; // 默认值 if (id.equals(sortField)) { orderBy id; } else if (create_time.equals(sortField)) { orderBy create_time; } sql.append( ORDER BY ).append(orderBy);永远不要直接把前端传的 sortField 拼进 SQL即使有后端校验也要防止外部参数直接控制排序字段。排序方向同理建议只允许asc或desc然后做三元判断。分页参数则可以直接绑定sql.append( LIMIT :limit OFFSET :offset); params.addValue(limit, pageSize); params.addValue(offset, (pageNum - 1) * pageSize);如果查询还需要返回总数记得使用独立的 count SQL。两套 SQL 的条件片段应该保持一致推荐先把条件片段和参数生成出来再分别拼到 count SQL 和 data SQL 里避免条件不一致导致列表和总数对不上。3.4 第四层结果映射与空值处理动态条件查询的另一个坑是结果映射。使用queryForList时每个字段会被自动转为 Map 的 value但日期、BigDecimal、Boolean 等类型在不同数据库驱动下可能返回不同 Java 类型这会给前端序列化带来不确定性。更稳妥的做法是使用 RowMapperString sql SELECT id, name, status, create_time FROM user WHERE status :status; ListUser list namedTemplate.query(sql, params, (rs, rowNum) - { User u new User(); u.setId(rs.getLong(id)); u.setName(rs.getString(name)); u.setStatus(rs.getInt(status)); u.setCreateTime(rs.getTimestamp(create_time)); return u; });RowMapper 明确告诉你每个字段会被映射成什么类型空值时会变成 null 还是默认值边界可控。还需要注意queryForObject的空结果问题。如果不确定查询一定返回一行建议用query方法先拿到 List再判断集合是否为 null 或空ListUser list namedTemplate.query(sql, params, mapper); User user list.isEmpty() ? null : list.get(0);这样就不会因为结果为空而抛出EmptyResultDataAccessException。3.5 第五层批量条件与复杂关联查询当条件里出现IN时NamedParameterJdbcTemplate 有天然优势可以直接传集合ListLong ids Arrays.asList(1L, 2L, 3L); String sql SELECT id, name FROM user WHERE id IN (:ids); MapSqlParameterSource params new MapSqlParameterSource(); params.addValue(ids, ids);Spring JDBC 会自动把集合展开成多个参数省去手写占位符的过程。这里要特别注意如果 ids 集合为空生成的 SQL 可能变成IN ()不同数据库行为不同。所以业务上最好先判断集合是否为空空集合应该直接返回空列表而不是执行查询。复杂关联查询也可以沿用条件组织思路只是 SQL 里会出现多张表。这时字段名容易歧义建议给 SQL 里的字段都加上表别名或表名前缀尤其是排序字段String sql SELECT u.id, u.name, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id WHERE u.status :status ORDER BY u.create_time DESC;条件参数和映射方式不变SQL 结构复杂了但管理的核心还是一样条件片段、参数容器、结果映射三层要解耦。4. 实战先跑通一个带多个查询条件的列表接口4.1 场景定义和实体结构假设要做一个用户列表查询接口支持这些条件keyword模糊匹配用户名status用户状态精确匹配startTime / endTime创建时间范围pageNum / pageSize分页sortField / sortOrder排序字段和方向对应的查询对象可以设计成public class UserQuery { private String keyword; private Integer status; private LocalDateTime startTime; private LocalDateTime endTime; private Integer pageNum 1; private Integer pageSize 10; private String sortField create_time; private String sortOrder desc; }这一步看似简单但对条件进阶很重要。把条件封装成对象后后面的查询逻辑只需要接受这一个对象而不是在方法签名里传一大串参数。4.2 用 MapSqlParameterSource 管理条件接着实现查询方法。先创建基础 SQL 和条件集合再根据 query 对象里的字段值决定是否拼接条件public PageResultUser queryUserList(UserQuery query) { StringBuilder dataSql new StringBuilder(); dataSql.append(SELECT id, name, status, create_time ); dataSql.append(FROM user ); ListString conditions new ArrayList(); MapSqlParameterSource params new MapSqlParameterSource(); if (StringUtils.hasText(query.getKeyword())) { conditions.add((name LIKE :keyword)); params.addValue(keyword, % query.getKeyword() %); } if (query.getStatus() ! null) { conditions.add(status :status); params.addValue(status, query.getStatus()); } if (query.getStartTime() ! null) { conditions.add(create_time :startTime); params.addValue(startTime, query.getStartTime()); } if (query.getEndTime() ! null) { conditions.add(create_time :endTime); params.addValue(endTime, query.getEndTime()); } if (!conditions.isEmpty()) { dataSql.append( WHERE ).append(String.join( AND , conditions)); } // 排序字段做白名单 String sortField create_time; if (id.equals(query.getSortField())) { sortField id; } else if (status.equals(query.getSortField())) { sortField status; } String sortOrder desc.equalsIgnoreCase(query.getSortOrder()) ? desc : asc; dataSql.append( ORDER BY ).append(sortField).append( ).append(sortOrder); // 分页 dataSql.append( LIMIT :limit OFFSET :offset); params.addValue(limit, query.getPageSize()); params.addValue(offset, (query.getPageNum() - 1) * query.getPageSize()); ListUser list namedTemplate.query(dataSql.toString(), params, userRowMapper); // count 查询复用同样的条件 StringBuilder countSql new StringBuilder(SELECT COUNT(*) FROM user ); if (!conditions.isEmpty()) { countSql.append( WHERE ).append(String.join( AND , conditions)); } Long total namedTemplate.queryForObject(countSql.toString(), params, Long.class); return new PageResult(list, total, query.getPageNum(), query.getPageSize()); }这段代码里data SQL 和 count SQL 共用同一个conditions和params。只要条件拼到 data SQL就会拼到 count SQL只要参数加到容器两个 SQL 都能使用。这是避免“列表总数对不上”最直接的手段。4.3 验证顺序输入、SQL、参数、映射、日志条件查询出问题时我建议按下面这个顺序排查每次只查一层输入前端传的参数是否真的到了后端字段名和类型是否正确。SQL打印最终生成的 SQL确认条件片段、排序、分页位置是否正确。参数确认MapSqlParameterSource里是否有 SQL 中出现的参数名值是否符合预期。映射如果 SQL 和参数都对确认 RowMapper 是否把字段映射到正确的 Java 属性。日志最后才看整体堆栈避免被无关日志带偏。实际开发时我一般先把最终 SQL 和参数容器用日志打出来log.info(UserQuery SQL: {}, dataSql); log.info(UserQuery Params: {}, params.getValues());看到这两行输出大部分条件问题都能立刻定位。如果 SQL 和参数都正确但返回结果数量不对再去看 RowMapper 或数据库数据本身。这套验证顺序可以抽象成通用排查链路任何 Spring JDBC 条件查询异常都按“输入 - SQL - 参数 - 映射 - 日志”的顺序推进不要把时间浪费在猜测上。5. 条件查询最容易踩的六个坑5.1 参数名不匹配NamedParameterJdbcTemplate 对参数名敏感SQL 里写:userName参数容器里却 put 了name运行时会直接报错。这类错误通常很快暴露但如果你在复杂 SQL 里复制粘贴了条件片段很容易把参数名搞混。建议在创建参数容器时把 SQL 里的命名参数和 addValue 的 key 集中写在一起保持一一对应。不要在一个地方维护 SQL另一个地方维护参数中间隔了几十行代码。5.2 条件为 null 或空串导致结果异常null 和空字符串在业务上可能代表完全不同的语义。如果某个字段允许查询空字符串直接用StringUtils.hasText判断就可能把真实条件过滤掉反之如果应该忽略空串却用了! null判断SQL 就会被拼成name 导致查不到数据。最稳妥的做法是先在需求层面确认这个条件为空时是要忽略还是要查空值。然后代码里明确写出来不要靠猜。5.3 表别名和字段歧义多表关联查询里如果两个表都有id字段SQL 里ORDER BY id会报字段歧义。即使不报错结果也可能不是你预期的那个字段。我的建议是只要 SQL 里出现了两个以上的表所有字段都带上表别名尤其是 select 列表、where 条件、order by 条件。虽然写起来麻烦但能避免数据库优化器和驱动在不同版本下产生不一致行为。5.4 分页条件与排序条件拼接顺序SQL 的语法顺序固定WHERE在ORDER BY之前LIMIT在ORDER BY之后。很多人调优时会把 order by 写错位置调试半小时发现只是拼接顺序问题。预防方法是在代码里固定 SQL 主干结构先拼 select 字段再拼 from再拼 where 条件再拼 order by最后拼 limit。如果项目里多个查询都遵守这个顺序代码 review 会轻松很多。5.5 特殊字符与 SQL 注入虽然命名参数能防大部分 SQL 注入但 LIKE 查询里的%和_是通配符用户输入这些字符会影响匹配结果。比如搜索“100%”可能把“10001”“10002”都查出来。更严重的是排序字段、表名等无法用参数绑定的部分必须用白名单或枚举控制不能直接拼用户输入。params.addValue(keyword, % keyword %);这段代码里keyword 本身不是直接拼进 SQL 的参数绑定保证了安全性但%还是通配符需要结合业务判断要不要转义。5.6 数据库函数和时区问题条件里使用日期函数、字符串函数时不同数据库写法差异很大。比如 MySQL 用DATE_FORMATPostgreSQL 用TO_CHAR。如果项目要在多种数据库上运行建议把函数逻辑收敛到 SQL 片段里不要散落在各种查询方法中。时区问题更隐蔽。Java 端传一个LocalDateTime数据库驱动会按照会话时区转换如果应用服务器和数据库服务器时区不一致查询结果可能差 8 小时。条件查询中涉及时间范围时建议统一确认时间类型、时区和参数类型别到上线后才发现边界不对。6. 把条件查询变成可复用资产而不是每张表重写一遍6.1 从单表条件查询到通用查询条件对象上面实战代码已经具备了一定复用性但每张表都写一遍代码量还是很大。当项目中有很多列表查询接口时可以考虑把公共逻辑抽成通用查询条件对象。public class PageQuery { private Integer pageNum 1; private Integer pageSize 10; private String sortField; private String sortOrder; }然后让具体业务查询对象继承 PageQuery把所有查询方法共有的分页、排序逻辑收敛到基类。再配合一个简单的 SQL 构建工具负责把条件片段、参数容器、分页 SQL 和 count SQL 组装起来。但这里要提醒一点抽象一定要在至少两三个真实业务场景出现重复之后再做。如果只有一个查询方法提前抽出通用工具反而会增加理解成本。条件进阶的价值在于“重复流程的固化”而不是“为抽象而抽象”。6.2 返回结构统一分页对象与列表对象列表查询的返回值建议统一成一个分页对象public class PageResultT { private ListT list; private Long total; private Integer pageNum; private Integer pageSize; }好处是 Controller、前端、调用方都只需要对接一个结构。条件查询方法的入口和出口都稳定后后续不管是换数据库方言、加缓存还是做统计改动范围都能控制在一个层级内。如果你在 Spring JDBC 里维护多个列表查询建议每个查询方法都返回PageResultT不要有的返回 List有的返回 Map有的直接返回 long。统一返回结构是复用的前置条件。6.3 适合用 Spring JDBC 的场景以及什么情况下该换方案不是所有条件查询都适合用 Spring JDBC。我个人的判断标准是适合表结构清晰条件数量和 SQL 复杂度可控查询相对固定需要精确控制 SQL 和性能。不太适合动态条件非常多SQL 片段需要高度复用实体关系映射复杂团队已经围绕 ORM 建立了完整开发模式。如果你发现一个查询方法里条件拼接代码超过 100 行或者 SQL 里出现大量动态表名、动态字段、动态 join那么 Spring JDBC 手写条件管理可能已经不是最优解。这时候可以考虑引入更成熟的 ORM 框架、查询构造器或专门的 SQL 构建库。但这不代表 Spring JDBC 条件管理没有价值。相反理解条件组织、参数绑定、结果映射之后你再用任何 ORM 都会更清楚底层发生了什么。工具可以换思路是一致的。条件进阶的最后一件事是把这套方式变成你日常开发的默认选项。不是等到条件变多了才想起重构而是一开始写条件查询时就用命名参数、条件容器、白名单排序、统一返回结构这套组合。先在一个真实接口里把流程跑通再慢慢优化比一上来追求“通用框架”要靠谱得多。
返回列表