介绍

动态查询问题是ORM框架中一个长期存在的痛点。
解决这个问题将对简化数据库的交互逻辑和提高代码的可维护性产生深远影响。
本文定位了动态查询中的一种结构型重复,并提出了一种通用的重构方法,以优化动态查询相关的代码。

背景

信息系统的交互界面通常会提供若干个输入控件,用户通过这些控件输入查询条件,而后台则根据用户输入的参数动态构造SQL查询语句。

为了实现查询代码的复用,我们通常使用多个if语句来对查询参数的有效性进行校验,并对相应的查询条件进行拼接。例如,使用if语句拼接查询条件的常见做法如下:

@RestController
@RequestMapping("user")
public class UserController {
    @Resource
    private JdbcTemplate jdbcTemplate;

    @GetMapping("/")
    public List<UserEntity> query(UserQuery query) {
        List<Object> argList = new ArrayList<>();
        StringJoiner where = new StringJoiner(" AND ", " WHERE ", "");
        where.setEmptyValue("");
        if (query.getName() != null && !query.getName().isBlank()) {
            where.add("name = ?");
            argList.add(query.getName());
        }
        if (query.getNameLike() != null && !query.getNameLike().isBlank()) {
            where.add("name LIKE CONCAT('%', ?, '%')");
            argList.add(query.getNameLike());
        }
        if (query.getAgeGt() != null) {
            where.add("age > ?");
            argList.add(query.getAgeGt());
        }
        String sql = "SELECT * FROM user" + where;
        return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(UserEntity.class), argList.toArray());
    }
}

然而,随着查询参数的增加,拼接查询添加的代码变得越来越长。这就意味着我们需要对这段代码进行重构了。

分析

这段代码的问题在于,每增加一个新的查询参数,我们就需要复制粘贴一段if语句,然后修改一下参数名称和查询条件。这显然违反了DRY原则。

在代码中,每段if语句包含了相似的结构,除了参数名称和查询条件,其他代码几乎一样,因此,我把这种结构相似属性不同的代码重复称为属性相关型重复

为了消除这种代码重复,我们可以使用反射替换方法进行重构,即使用反射方法替换属性访问方法来动态获取属性的值,同时利用注解来定义各个属性的查询条件。

如果代码像下面一样,将参数直接平铺在方法签名中,这是无法直接应用反射技术进行替换的。

public List<UserEntity> query(String name, String nameLike, Integer ageGt) {
    //...
    if (ageGt != null) {
        where.add("age > ?");
        argList.add(ageGt);
    }
    //...
}

这种代码被识别为一种被称为长参数列表(Long Parameter List)的坏味道。我们需要先使用引入参数对象(Introduce Parameter Object)的重构手法,通过将多个查询参数封装为一个对象,得到类似上一节的示例代码。

这类对象由于定义的都是用于查询的字段,被称为查询对象

重构方法

接下来,我们开始进行代码重构。

首先,我们需要抽取查询参数的校验代码,通过isValid方法来判断查询参数是否有效:

private boolean isValid(Object value) {
    return value != null && (!(value instanceof String str) || !str.isBlank());
}

于是if语句可被整理如下:

String value = userQuery.getName();
String condition = "name = ?";
if (isValid(value)) {
    where.add(condition);
    argList.add(value);
}

接着,我们使用反射方法来获取属性的值,同时通过注解获取属性对应的查询条件:

Field field = query.getClass().getDeclaredField("name");
field.setAccessible(true);
Object value = field.get(query);
String condition = field.getAnnotation(QueryField.class).and();
if (isValid(value)) {
    where.add(condition);
    argList.add(value);
}

对应地,在UserQuery类中通过注解声明属性绑定的查询条件:

public class UserQuery {
    @QueryField(and = "name = ?")
    private String name;
    
    @QueryField(and = "name LIKE CONCAT('%', ?, '%')")
    private String nameLike;
    
    @QueryField(and = "age > ?")
    private Integer ageGt;
}

由于我们通过getDeclaredField方法访问了UserQuery中的每一个字段,而Class#getDeclaredFields方法会返回类中声明的所有字段,所以我们可以把拼接查询语句的代码合并为一个循环:

for (Field field : query.getClass().getDeclaredFields()) {
    field.setAccessible(true);
    Object value = field.get(query);
    String condition = field.getAnnotation(QueryField.class).and();
    if (isValid(value)) {
        where.add(condition);
        argList.add(value);
    }
}

此时,代码中已不再依赖UserQuery中的方法,于是我们可以把构造动态查询条件的代码抽取为一个适用任意类型的通用方法:

public static String buildWhere(Object query, List<Object> argList) throws Exception {
    StringJoiner where = new StringJoiner(" AND ", " WHERE ", "");
    where.setEmptyValue("");
    for (Field field : query.getClass().getDeclaredFields()) {
        //...
    }
    return where.toString();
}

整段代码宣告重构完毕的同时,我们也得到了一段构造动态查询条件的算法:

  1. 使用反射获取查询对象的所有字段。
  2. 遍历这些字段,并读取它们的值。
  3. 如果字段的值有效(非空或满足特定条件),则根据字段上的注解配置,将对应的SQL片段拼接到查询语句中,并将字段值添加到参数列表中。

当开发者再需要增加新的查询条件时,只需在UserQuery中添加新的字段,并在注解中声明对应的查询条件,而无需修改构造动态查询条件的代码。

类似案例

类似的属性相关型重复还可以在其他场景中找到。例如,从ResultSet中读取数据并映射到属性的代码就符合这样的模式:

public RoleEntity mapRow(ResultSet rs, int rowNum) throws SQLException {
    RoleEntity roleEntity = new RoleEntity();
    roleEntity.setId(rs.getInt("id"));
    roleEntity.setRoleName(rs.getString("roleName"));
    roleEntity.setRoleCode(rs.getString("roleCode"));
    roleEntity.setValid(rs.getBoolean("valid"));
    return roleEntity;
}

我们可以采用上述重构方法,通过反射遍历RoleEntity的字段,读取数据并设值,便能消除这些重复代码。这也是开发ORM框架的基础。

后续优化

我们还可以进一步围绕重构后的buildWhere方法做优化。

外部优化

由于buildWhere方法现在已经支持为任意查询对象构建查询子句,所以我们可以把UserController中有关SQL执行的代码也抽取为一个通用的数据查询方法。

@RestController
@RequestMapping("user")
public class UserController {
    @Resource
    private JdbcDataAccess jdbcDataAccess;

    @GetMapping("/")
    public List<UserEntity> query(UserQuery query) throws Exception {
        return jdbcDataAccess.query(UserEntity.class, query);
    }
}

@Repository
@AllArgsConstructor
public class JdbcDataAccess {
    private JdbcTemplate jdbcTemplate;

    public <E> List<E> query(Class<E> clazz, Object query) throws Exception {
        RowMapper<E> rowMapper = new BeanPropertyRowMapper<>(clazz);
        List<Object> argList = new ArrayList<>();
        String where = buildWhere(query, argList);
        String table = clazz.getAnnotation(Table.class).name();
        String sql = "SELECT * FROM " + table + where;
        return jdbcTemplate.query(sql, rowMapper, argList.toArray());
    }
}

内部优化

目前,我们需要在注解中通过文本来声明字段绑定的查询条件,而字段名称中就包含了查询条件中的列名,例如,查询条件name = ?中包含了字段名称name,因此,我们可以尝试去掉注解,直接从字段定义的信息中推导出查询条件的列名和操作符。于是,便诞生了一门基于查询对象的支持动态查询的领域特定语言(DSL)。

public class UserQuery {
    private String name;     // name = ?
    private String nameLike; // name LIKE ?
    private Integer ageGt;   // age > ?
}

总结

本文定位了动态查询代码中的属性相关型重复问题,并介绍了一种反射替换方法来对这种类型的重复进行重构。

重构后的代码分离了查询条件的定义逻辑和拼接逻辑。当需要增加新的查询条件时,开发者只需在查询对象中添加字段,并通过注解等方式定义对应的查询条件,而无需修改查询条件的拼接逻辑。

这一改进不仅简化了动态查询代码,也为基于查询对象的领域特定语言的设计打下了基础。