增改业务中参数校验逻辑与业务逻辑分离的编码方式(hibernate validator)

介绍一种基于spring框架的增改业务代码架构,架构主要改善了参数转换和验证两部分的代码结构,目的在于减少代码量,增强代码简洁性和可维护性,特别是可维护性。

传统架构方式

众所周知,编写增改业务逻辑是很枯燥的,业务代码大多类似以下代码:

controller接收页面提交的参数的封装对象stu

1
2
3
4
5
@ResponseBody
@RequestMapping(value = "/url", method = RequestMethod.POST)
public Object insert(@RequestBody StudentVo stu) {
// 其他代码 略
}

controller接收到参数之后进行验证,一般会将验证代码整理到外部,使controller更加简洁明确。验证方法validateParam(StudentVo stu)执行stu对象的检测,如果发现错误,会返回字符串提示,如果检测不通过,那么controller返回错误对象到前端。

1
2
String res = validateParam(stu);
if (res != null) return res;

在validateParam(StudentVo stu)检测方法体内部进行各项参数的检查,通常这一步的编码是非常繁琐和枯燥的,validateParam方法会引用很多错综复杂的具体检测内容的外部方法,如判空、判重、判有效性、判相关性等方法,不仅编码繁琐,还容易漏掉检测项,更痛苦的是后期极难维护,还可能出现重复代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private String validateParam(StudentVo stu) {
// 检测学号
String stuNo = stu.getStuNo();
if (stuNo == null || "".equals(stuNo.trim())) return "学号不能为空";
if (isStuNoRepeat(stuNo)) return "学号不能重复";
if (!isStuNoValid(stuNo)) return "学号格式无效";

// 其他字段检测 略

// 学院和专业需要做关联性检查
if (isCollegeMarjorRelate(stu.getCollegeName(), stu.getMajorName()))
return "学院专业不匹配";

return null;
}

为了便于阅读和后面的内容介绍,将上述的StudentVo属性代码展示如下:

1
2
3
4
5
6
7
8
9
10
public class StudentVo {

private String stuNo;
private String name;
private Integer age;
private String collegeName;
private String majorName;

// getter getter 略
}

数据检测通过后,需要进行数据转换,类似学院专业这类数据一般是码表,数据库里面不会直接存储学院名称这类字符串,所以collegeName要转换为collegeId。为了转换为数据库存储对象,需要编写StudentPo类,再将所有属性转换并存入StudentPo对象(类似collegeName等这类数据在页面一般使用下拉菜单选择,因此传到后台的一般是对应的id值,为了方便介绍后面的内容,所以假设传到后台的数据是字符串类型的学院名称)。

1
2
3
4
5
6
7
8
9
10
public class StudentPo {

private String stuNo;
private String name;
private Integer age;
private Integer collegeId;
private Integer majorId;

// getter getter 略
}

对于字段比较少的po类,可以逐个属性进行转换和设置,如果比较多,就比较繁琐了,比较优雅的办法是写一个转换类用于类之间的转换,另一种方法是指定一个公共静态方法返回需要的po对象。前者一般会用到反射,需要配置字段映射,代码比较复杂,但一劳永逸,比较适用于字段多而且需要转换的类多的情况,也可以使用第三方的转换工具类,比如apache的PropertyUtils的copyProperties方法,而后者写起来繁琐冗长,每个转换都需要重复写,但代码清晰,适用于字段较少的情况。以后者为例,如在StudentVo中添加StudentPo to(StudentVo vo)方法:

1
2
3
4
5
6
7
8
9
10
public static StudentPo to(StudentVo vo) {

StudentPo po = new StudentPo();
po.setCollegeId(toCollegeId(vo.getCollegeName()));
po.setCollegeId(toMajorId(vo.getMajorName()));

// 其他代码 略

return po;
}

转换方法设置在vo中更好,因为po是数据库的对应关系,属于底层代码,不建议引入上层业务代码vo类,所以我认为转换代码写在vo类中更合理。

上述就是常规的增改业务代码,多写几次会越来越熟练,但再多写几次就会彻底崩溃。我一直在思考有没有好的方法或模式,能将这些类似的代码(如验证和转换代码)分离出来封装一套通用的业务方案,或者至少能将这部分代码逻辑分离出来,不与系统业务纠缠在一起。最终我想到了解决方案,下面介绍数据逻辑和系统业务分离的代码架构。

逻辑业务分离架构方式

架构更该包含两部分:数据检验和数据转换。

1、数据检验部分基于spring集成的hibernate的validation框架,该框架符合JSR-303和JSR-349规范,重点关注一下JSR-303的注解。

2、数据转换部分是基于spring集成的jackson框架。

先梳理一下后台数据处理流程,spring接收到json参数后会通过jackson对参数进行解析,然后封装为对象,也就是上述使用的stu,如果在controller中对对象参数标注了@Valid注解,那么spring还会调用validation对参数进行检测,并将验证失败的结果封装到Error对象中,与参数对象一并传到controller中。(这里只讨论json参数,即@RequestBody标注的参数,其他类型的参数解析器不一样)

下面先介绍验证代码,首先需要编写vo类,按照JSR-303规范和hibernate自定义的注解进行验证注解的标注。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class StudentInsertVo {

@NotEmpty(message = "学号不能为空")
private String stuNo;

@NotEmpty(message = "姓名不能为空")
private String name;

@Range(max = 100, min = 15, message = "年龄范围{min}~{max}")
private Integer age;

private String collegeName;

private String majorName;

@AssertTrue(message = "学院专业不匹配")
public boolean isCollegeMajorMatch() {
// 验证学院专业匹配 略
}
}

spring集成了validation的验证,在controller中配置@Valid注解和Errors对象即可获得验证结果,Errors对象必须跟在@Valid注解参数后面。

1
2
3
4
5
6
public Object insert(@RequestBody @Valid StudentInsertVo stu, Errors errors) {
if (errors.hasErrors()) {
// 验证结果返回 代码略
}
// 其他代码 略
}

值得注意的是,JSR-303的注解可以标注在属性和getter方法上,验证时的值就是属性或者getter返回的值,换句话说,如果修改过getter方法致使返回的值与属性的值不同,那么用于验证的值也会不同。如果属性和方法均标注了注解,那么都会做检测。

这样,验证逻辑就独立出来,在vo中进行维护,剩下的工作仅需要将vo转换为po即可。

既然spring利用框架对参数进行封装和验证,那么框架一定留有接口让我们扩展,实际也是这样的,在程序设计过程中,对参数的转换需要留出接口方便以后扩展,我也是利用了这些接口对数据进行转换。

前面说过spring将json参数封装为对象是通过jackson进行转换,而jackson优先使用setter方法对对象设值,那么更改vo的setter方法可以对值进行转换。当然jackson也提供了专门的注解@com.fasterxml.jackson.databind.annotation.JsonDeSerialize和抽象类com.fasterxml.jackson.databind.JsonDeSerializer,但是使用起来比较复杂,不赘述。

下面代码是修改过的StudentInsertVo,将collegeName和majorName替换为collegeId和majorId,后者正是我们需要的,然后更改setCollegeName方法setMajorName方法将String类型的参数转换为Integer类型的参数。

当页面传来参数名为collegeName的参数时,会调用setCollegeName方法进行设值,而方法中真正设值的属性是collegeId,这样就达到collegeName转换为collegeId的目的了。majorName和majorId也是同样的道理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StudentInsertVo {

// Vo中没有colleageName和majorName属性
private Integer collegeId;
private Integer majorId;

public void setCollegeName(String collegeName) {
this.collegeId = toCollegeId(collegeName);
}

public void setMajorName(String majorName) {
this.majorId = toMajorId(majorName);
}

// 其他代码 略
}

参数转换要考虑异常的问题,如果转换过程中抛出异常,那么前端会收到400 Bad Request,这是就需要将错误信息传递到controller中与Errors对象一并处理,可以使用ThreadLocal传递异常。

1
2
public static final ThreadLocal<List<Exception>>
context = ThreadLocal.withInitial(ArrayList::new);

vo相应的更改,以setCollegeId方法为例。对方法体进行异常捕获,并将异常存入ThreadLocal中。

1
2
3
4
5
6
7
8
9
public void setCollegeId(String collegeName) {

try {
this.collegeId = toCollegeId(collegeName);
} catch (Exception e) {
context.get().add(e);
}

}

controller中添加转换异常的处理。

1
2
3
4
5
6
7
8
9
10
11
public Object insert(@RequestBody @Valid StudentInsertVo stu, Errors errors) {

if (context.get().size() > 0) {
// 转换结果返回 代码略
}

if (errors.hasErrors()) {
// 验证结果返回 代码略
}
// 其他代码 略
}

所有的非默认转换方法均需要捕获异常,会有很多重复代码,因此将异常传递的方法和异常处理的方法进行封装。封装为ParamErrorHandler类,并提供spreadException方法用于传递异常(将异常存入ThreadLocal),以及getExceptionList方法用于获取异常(将转换和验证的异常合并)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class ParamErrorHandler {
private static final ThreadLocal<List<Exception>>
context = ThreadLocal.withInitial(ArrayList::new);

/**
* 传递异常,即将解析时发生的异常存入ThreadLocal,传递到controller中处理
*/
public static void spreadException(Exception e) {
context.get().add(e);
}

/**
* 获取参数解析和验证的异常列表
*/
public static List<Exception> getExceptionList(Errors errors) {

try {
List<Exception> res = new ArrayList<>();

// spring jackson解析参数错误
List<Exception> parseErrorList = context.get();
res.addAll(parseErrorList);

// validation验证参数错误
if (errors == null || !errors.hasErrors()) return res;
List<ObjectError> validErrorList = errors.getAllErrors();
for (ObjectError validError : validErrorList)
res.add(new IllegalArgumentException(validError.getDefaultMessage()));
return res;

} finally {
// 清空ThreadLocal,非常重要
context.remove();
}
}
}

vo的setter方法相应的更改:

1
2
3
4
5
6
7
8
9
public void setCollegeId(String collegeName) {
try {
this.collegeId = toCollegeId(collegeName);
} catch (Exception e) {

ParamErrorHandler.spreadException(e);

}
}

controller更改:

1
2
3
4
5
6
7
8
public Object insert(@RequestBody @Valid StudentInsertVo stu, Errors errors) {

if (ParamErrorHandler.getExceptionList(errors).size() > 0) {
// 返回异常代码 略
};

// 其他代码 略
}

按照上述代码架构,数据逻辑与系统业务就分离开了,数据逻辑独立在vo中,更易于维护,也使得系统业务更明确更精简。代码中一些细节未详细说明,无大碍,依然可以按照思路进行架构。

但一定要注意,ParamErrorHandler.getExceptionList()在finally中对context进行了清理,这一步非常重要。应用大多会使用线程池,就可能出现多次请求使用的是同一个线程,如果不清理ThreadLocal就会造成获取到同一个ThreadLocal和其存储的检测结果,就会导致报出错误的参数错误信息。

最后,至于这样的架构是否合理我并没有太多的意见,个人是比较喜欢的,即先将参数转换后封装为对象,再将对象传到controller,因为这样更符合流程逻辑。当然也许有人更喜欢先对参数代表进行检测后,再进行转换。这里没有定论,待后期的经验验证。

附表:JSR-303和hibernate自定义验证注解

注解不一定全

JSR-303

Constraint 详细信息
@Null 被注释的元素必须为 null
@NotNull 被注释的元素必须不为 null
@AssertTrue 被注释的元素必须为 true
@AssertFalse 被注释的元素必须为 false
@Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min) 被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
@Pattern(value) 被注释的元素必须符合指定的正则表达式

hibernate自定义注解

Constraint 详细信息
@Email 被注释的元素必须是电子邮箱地址
@Length 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的字符串的必须非空
@Range 被注释的元素必须在合适的范围内