Spring MVC表单上传场景下的验证

Spring 验证、数据绑定和类型转换》那篇Spring官方Doc文档的翻译并没有涉及具体使用的细节,本篇结合Spring MVC表单数据上传这个通用应用场景写一下笔者的实践。

(转载请注明出处,谢谢)

  1. POST方式新增业务模型类,Spring MVC通过 @ModelAttribute 注解绑定接收参数,使用Spring Validator方式验证。
  2. PUT方式更新业务模型类,Spring MVC通过 @ModelAttribute 注解绑定接收参数,使用Spring Validator方式验证。
  3. PATCH方式更新业务模型类,Spring MVC通过 @ModelAttribute 注解绑定接收参数,使用Spring Validator方式验证。
  4. GET/POST name/value/pk参数更新业务模型类,Spring MVC 接收基本类型参数,使用JSR 303 API手动验证。

写在前面

Spring中使用Validation的两种方式:

以上两个均不提供验证实现,Bean Validation默认实现由Hibernate Validation(官方文档请查阅Hibernate Validation - Reference)提供。

Bean Validation内嵌约束

注解 支持的数据类型 说明 版本
@AssertFalse Boolean, boolean 检查被注解的元素是否为false 1.0
@AssertTrue Boolean, boolean 检查被注解的元素是否为true 1.0
@DecimalMax BigDecimal, BigInteger, String, byte, short, int, long及封装对象,Number and CharSequence的子类型. 检查被注解的元素是否为数字,且小于等于注解内的值 1.0
@DecimalMin BigDecimal, BigInteger, String, byte, short, int, long及封装对象,Number and CharSequence的子类型. 检查被注解的元素是否为数字,且大于等于注解内的值 1.0
@Digits(integer=, fraction=) BigDecimal, BigInteger, String, byte, short, int, long及封装对象,Number and CharSequence的子类型. 检查被注解的元素是否为数字,且符合要求范围,integer参数表示整数位数,fraction表示小数位数. 1.0
@Future java.util.Date, java.util.Calendar, ReadablePartial和ReadableInstant的实现类;如果classpath中有joda time库,支持; 检查被注解的时间是否是将来时间 1.0
@Max BigDecimal, BigInteger, String, byte, short, int, long及封装对象,Number and CharSequence的子类型. 检查被注解的元素是否小于等于注解内的值 1.0
@Min BigDecimal, BigInteger, String, byte, short, int, long及封装对象,Number and CharSequence的子类型. 检查被注解的元素是否大于等于注解内的值 1.0
@NotNull 任何类型 检查被注解的元素是否不为null 1.0
@Null 任何类型 检查被注解的元素是否为null 1.0
@Past java.util.Date, java.util.Calendar, ReadablePartial和ReadableInstant的实现类;如果classpath中有joda time库,支持; 检查被注解的时间是否是过去时间 1.0
@Pattern(regex=, flag=) String, CharSequence的子类型. 检查被注解的元素是否符合正则表达式 1.0
@Size(min=, max=) String, Collection, Map, arrays以及CharSequence的子类型. 检查被注解元素的Size是否在min和max范围内 1.0
@Valid 任意非基本类型 对被注解元素对象执行验证。如果对象是集合类collection或者array,对集合类中的元素进行逐一验证。如果对象是map,则对value进行验证 1.0

Bean Validation约束保留属性

  • groups,用于分组验证,支持同一个待验证对象在不同验证场景下的不同验证规则
  • message,验证错误后的提示信息
  • payload,验证负载

版本兼容性

  • JDK1.6、Spring 3、JSR-303、Hibernate Validaiton 4

尽量匹配,笔者使用Spring 3.2.4 + Hibernate Validation 5.1.3.Final,报错,回退为Hibernate Validation 4.2.0.Final,正常。

本篇主要使用JSR-303。

Spring环境配置

在Spring MVC配置文件中注入validator,在validator中注入全局验证器和验证消息类。
局部validator在具体Controller类的DataBinder中添加即可。

<!-- 打开Spring MVC注解驱动 -->
<mvc:annotation-driven validator="validator" />
<bean >
	<property name="basenames">
		<list>				
            <!--对应classpath下的messages文件夹下的messages.properties和messages_zh_CN.properties以及其它国际化文件-->
            <!--如果有多个消息国际化文件,在下面添加即可,value不需要添加文件扩展名-->
			<value>classpath:messages/messages</value>
			<value>classpath:org/hibernate/validator/ValidationMessages</value>
		</list>
	</property>
	<!-- 默认值为none, 使用java.utils.properties -->
	<property name="defaultEncoding" value="UTF-8" />
	<!-- 默认值为-1, 缓存永远不刷新 -->
	<property name="cacheSeconds" value="60" />
	<!-- 父类AbstractMessageSource该属性默认值为false -->
	<!-- 
	<property name="useCodeAsDefaultMessage" value="false" />
	 -->
</bean>
<!-- mvc:annotation-driven默认会注入该类,但是如果需要定制,显式注入-->
<bean >
	<!-- 指定自定义的Spring MessageSource对象,用来解析验证消息,替换JSR-303默认的classpath根路径下的ValidationMessages.properties文件-->
	<property name="validationMessageSource" ref="messageSource" />
	<!-- 指定Validation实现类,若不指定,JSR-303按照默认机制查找,即在classpath中查找 -->
	<!-- 注意,这里并没有使用ref, 因为参数是类名称,而不是对象-->
	<property name="providerClass" value="org.hibernate.validator.HibernateValidator" />
</bean>    

待验证的JavaBean

Java Bean Validation规范中,有3类验证注解。

  • 对象注解,注解在类定义上,值为对象验证器。
  • 字段/属性注解,可以注解在字段上,也可以注解在getter方法上。
  • 图注解,笔者认为可以理解为级联注解,待验证类中不止包含基本类型(String、Integer等)属性,还包含类类型。使用级联注解来对验证进行解耦,验证只在本级执行,对象属性的验证交给对象自己的验证规则执行。

本篇使用Field注解。

分组验证

使用约束的groups参数,类型为数组,数组中的元素类型为Class

验证消息

本篇使用的是Spring 的 LocalValidationFactoryBean对象,在该对象中注入了messageSource对象,使用国际化文件来根据用户Locale显示不同语种的消息。

注入的实现MessageSource接口的具体类为org.springframework.context.support.ReloadableResourceBundleMessageSource,相比于org.springframework.context.support.ResourceBundleMessageSource,可以设置刷新时间,在指定时间过期后,重新载入message配置文件,及时更新message字符串所代表的实际文本。

为message属性赋值国际化文件中的字符串,加入了EL表达式。在J2EE classpath中要求javax.el包。

类定义

public class CategoryTypeDetail {
    @NotNull(groups={UpdateEntity.class},message="{com.vimisky.dms.backend.restful.id.notnullerror}")
	@Min(groups={UpdateEntity.class,PatchEntity.class},value=1,message="{com.vimisky.dms.backend.restful.id.minerror}")
	@Max(groups={UpdateEntity.class,PatchEntity.class},value=10000000,message="{com.vimisky.dms.backend.restful.id.maxerror}")//本处验证不严谨
	private int id;
    
	@NotNull(groups={NewEntity.class}, message="{com.vimisky.dms.backend.restful.name.notnullerror}")
	@Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=1,max=255,message="{com.vimisky.dms.backend.restful.name.lengtherror}")
	private String name;
	@Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=0,max=255,message="{com.vimisky.dms.backend.restful.secondaryname.lengtherror}")
	private String secondaryName;
	//如果没有定义分组,而在应用validated时,指定了分组,则不会使用该条验证约束
	@Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=0,max=1024,message="{com.vimisky.dms.backend.restful.description.lengtherror}")
	private String description;
	
    @Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=0,max=255,message="{com.vimisky.dms.backend.restful.language.lengtherror}")
	private String language;
	@Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=0,max=255,message="{com.vimisky.dms.backend.restful.thumbnailurl.lengtherror}")
	private String thumbnailUrl;
	
    //如果没有注释不能为空,则为空时,不检验Size等
	@Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=1,max=255,message="{com.vimisky.dms.backend.restful.thumbnailuri.lengtherror}")
	private String thumbnailUri;
	
    @Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=0,max=255,message="{com.vimisky.dms.backend.restful.thumbnailicon.lengtherror}")
	private String thumbnailIcon;
	
    @Size(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},min=0,max=255,message="{com.vimisky.dms.backend.restful.code.lengtherror}")
	private String code;
	
    @Null(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class}, message="{com.vimisky.dms.backend.restful.createtime.nullerror}")
	private Date createTime;
	
    @Null(groups={NewEntity.class,UpdateEntity.class,PatchEntity.class},message="{com.vimisky.dms.backend.restful.lastmodifytime.nullerror}")
	private Date lastModifyTime;
	
	public CategoryTypeDetail(){
		super();
    
	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getSecondaryName() {
		return secondaryName;
	}

	public void setSecondaryName(String secondaryName) {
		this.secondaryName = secondaryName;
	}

	public String getDescription() {
		return description;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	public String getLanguage() {
		return language;
	}

	public void setLanguage(String language) {
		this.language = language;
	}

	public String getThumbnailUrl() {
		return thumbnailUrl;
	}

	public void setThumbnailUrl(String thumbnailUrl) {
		this.thumbnailUrl = thumbnailUrl;
	}

	public String getThumbnailUri() {
		return thumbnailUri;
	}

	public void setThumbnailUri(String thumbnailUri) {
		this.thumbnailUri = thumbnailUri;
	}

	public String getThumbnailIcon() {
		return thumbnailIcon;
	}

	public void setThumbnailIcon(String thumbnailIcon) {
		this.thumbnailIcon = thumbnailIcon;
	}

	public String getCode() {
		return code;
	}

	public void setCode(String code) {
		this.code = code;
	}
	@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
	public Date getCreateTime() {
		return createTime;
	}

	public void setCreateTime(Date createTime) {
		this.createTime = createTime;
	}
	@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
	public Date getLastModifyTime() {
		return lastModifyTime;
	}

	public void setLastModifyTime(Date lastModifyTime) {
		this.lastModifyTime = lastModifyTime;
	}

	@Override
	public String toString() {
		return "CategoryTypeDetail [ + name
				+ ", secondaryName=" + secondaryName + ", description="
				+ description + ", language=" + language + ", thumbnailUrl="
				+ thumbnailUrl + ", thumbnailUri=" + thumbnailUri
				+ ", thumbnailIcon=" + thumbnailIcon + ", code=" + code
				+ ", createTime=" + createTime + ", lastModifyTime="
				+ lastModifyTime + "]";
	}
}

为支持分组验证,新建三个接口

新建

public interface NewEntity {

}

部分更新

public interface PatchEntity {

}

完全更新

public interface UpdateEntity {

}

POST+ModelAttribute

Spring MVC使用WebDataBinder类来管理属性编辑器PropertyEditor和验证器validator等,用来进行数据绑定、数据类型转换和数据验证。如果需要在Spring MVC中增加局部验证器,可以通过webDataBinder.addValidator(...)方法加入。本章中由于只使用Field/Property验证,Java Bean Validation即可满足,不需要另行新增类验证器。

使用Spring MVC的 @ModelAttribute 注解绑定表单数据到业务对象时,在前面增加验证注解 @Valid (Java Bean Validation 标准) 或者 @Validated (Spring 特有)。

本篇使用了分组验证,而 @Valid 没有分组验证的参数,只能使用 @Validated 。在业务对象参数后面,需要紧跟一个实现了Errors接口的对象。在Spring MVC中,BindingResult接口为Errors子接口,由Spring MVC注入具体实现对象。

@RequestMapping(value="/categorytype", method=RequestMethod.POST)
@ResponseBody
public RestfulResult insertCategoryType(
		@Validated(value={NewEntity.class}) @ModelAttribute CategoryTypeDetail categoryTypeDetail,
		BindingResult bResult,
		HttpServletRequest webRequest){
	logger.debug("接收到的分类类型数据为:"+categoryTypeDetail.toString());
	//在客户端的Request头中,增加Content-Type:x-www-form-urlencoded;charset=utf8;这里就能获取到,否则获取不到
	logger.debug(webRequest.getCharacterEncoding());
	try {
		webRequest.setCharacterEncoding("");
	} catch (UnsupportedEncodingException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
	}
	//补偿验证,避免用户上传ID
	categoryTypeDetail.setId(0);
	if(bResult.hasErrors()){
		StringBuffer errorString = new StringBuffer();
		logger.error("分类类型数据新增接口接收表单数据出现错误:");
		//对前端而言,不需要显示全部错误
		for (ObjectError objectError : bResult.getAllErrors()) {
			logger.error(objectError.getObjectName()+"赋值验证出错:"+objectError.getDefaultMessage()+",错误代码为:"+objectError.getCode());
		}
		for (FieldError fieldError : bResult.getFieldErrors()){
			logger.error(fieldError.getObjectName()+"的"+fieldError.getField()+"字段赋值验证出错:"+fieldError.getDefaultMessage()+",错误代码为:"+fieldError.getCode());				
			errorString.append(fieldError.getDefaultMessage()+"
");
		}
		return new RestfulResult(false, "表单提交数据错误:"+errorString);
	}
	OperationServiceResult osr = this.backendCategoryService.insertCategoryType(categoryTypeDetail);
	return new RestfulResult(osr);
}

PUT+ModelAttribute

为支持Method 为PUT的HTTP Request,需要增加一个响应Method 为OPTIONS的接口。

@RequestMapping(value="/categorytype/{id}", method=RequestMethod.OPTIONS)
@ResponseBody
public RestfulResult updateCategoryTypeOPTIONS(@PathVariable int id){
	logger.info("响应HTTP OPTIONS Method");		
	return new RestfulResult(true);
}

另外,需要增加一个拦截器,修改HTTP Response报头,Accept-Method中增加PUT。(超出本篇范围,不再粘贴相关代码)

PUT更新的验证规则见上面“待验证的JavaBean”,groups里含有UpdateEntity.class的注解。

@RequestMapping(value="/categorytype/{ctid}", method=RequestMethod.PUT)
@ResponseBody
public RestfulResult updateCategoryType(
		@PathVariable int ctid, 
		@Validated(value={UpdateEntity.class}) @ModelAttribute CategoryTypeDetail categoryTypeDetail, 
		BindingResult bResult){
	logger.info("通过HTTP PUT方法对分类"+categoryTypeDetail.getName()+"进行全部升级");
	if(bResult.hasErrors()){
		StringBuffer errorString = new StringBuffer();
		logger.error("分类类型数据更新接口接收表单数据出现错误:");
		//对前端而言,不需要显示全部错误
		for (ObjectError objectError : bResult.getAllErrors()) {
			logger.error(objectError.getObjectName()+"赋值验证出错:"+objectError.getDefaultMessage()+",错误代码为:"+objectError.getCode());
		}
		for (FieldError fieldError : bResult.getFieldErrors()){
			logger.error(fieldError.getObjectName()+"的"+fieldError.getField()+"字段赋值验证出错:"+fieldError.getDefaultMessage()+",错误代码为:"+fieldError.getCode());				
			errorString.append(fieldError.getDefaultMessage()+"
");
		}
		return new RestfulResult(false, "表单提交数据错误:"+errorString);
	}		
	//该判断补充验证框架中的值验证
	if(categoryTypeDetail.getId() != ctid){
		logger.warn("URL资源ID与表单ID数据不一致");
		return new RestfulResult(false, "URL资源ID与表单ID数据不一致");			
	}
	OperationServiceResult osr = this.backendCategoryService.updateCategoryType(categoryTypeDetail);
	return new RestfulResult(osr);
}

PATCH+ModelAttribute

为支持Method 为PATCH的HTTP Request,需要增加一个响应Method 为OPTIONS的接口。

@RequestMapping(value="/categorytype/{id}", method=RequestMethod.OPTIONS)
@ResponseBody
public RestfulResult updateCategoryTypeOPTIONS(@PathVariable int id){
	logger.info("响应HTTP OPTIONS Method");		
	return new RestfulResult(true);
}

另外,需要增加一个拦截器,修改HTTP Response报头,Accept-Method中增加PUT。(超出本篇范围,不再粘贴相关代码)

PATCH更新的验证规则见上面“待验证的JavaBean”,groups里含有PatchEntity.class的注解。

@RequestMapping(value="/categorytype/{id}", method=RequestMethod.PATCH)
@ResponseBody
public RestfulResult updateCategoryTypePart(
		@PathVariable int id, 
		@Validated(value={PatchEntity.class}) @ModelAttribute CategoryTypeDetail categoryTypeDetail,
		BindingResult bResult){
	logger.info("通过HTTP PATCH方法对分类类型进行部分升级");
	logger.debug("原始CategoryType为:"+categoryTypeDetail.toString());
	if(bResult.hasErrors()){
		StringBuffer errorString = new StringBuffer();
		logger.error("分类类型部分数据更新接口接收表单数据出现错误:");
		//对前端而言,不需要显示全部错误
		for (ObjectError objectError : bResult.getAllErrors()) {
			logger.error(objectError.getObjectName()+"赋值验证出错:"+objectError.getDefaultMessage()+",错误代码为:"+objectError.getCode());
		}
		for (FieldError fieldError : bResult.getFieldErrors()){
			logger.error(fieldError.getObjectName()+"的"+fieldError.getField()+"字段赋值验证出错:"+fieldError.getDefaultMessage()+",错误代码为:"+fieldError.getCode());				
			errorString.append(fieldError.getDefaultMessage()+"
");
		}
		return new RestfulResult(false, "表单提交数据错误:"+errorString);
	}
	//与完全更新不同,表单中无需提供ID,使用URL中的ID即可
	if(categoryTypeDetail.getId() == 0)
		categoryTypeDetail.setId(id);
	OperationServiceResult osr = backendCategoryService.updateCategoryTypePart(categoryTypeDetail);
	return new RestfulResult(osr);
}

GET/POST + name/value/pk

这种方式是为了适应更快速的更改单个属性,上传表单时,参数为属性名(name),属性值(value),主键ID(pk)。

首先通过Java反射机制,为实体类赋值,然后手动调用javax.validation.validator进行验证。这里选择java标准validator的原因是,Spring validator并未提供分组验证的参数。

Spring validator的API为 validator.validate(Object obj, Errors errors),而java标准validator API为validator.validate(Object obj, Class class)。

但是在注入validator时,使用Spring配置文件中的LocalValidatorFactoryBean即可。可以通过 @Autowired 注入,javax.validation.Validator validator;

@RequestMapping(value="/categorytype/updatefield", method=RequestMethod.POST, params={"name","value","pk"})
@ResponseBody
public RestfulResult updateCategoryTypeField(
		@RequestParam(value="name", required = true) String name,
		@RequestParam(value = "value", required = true) String value,
		@RequestParam(value = "pk", required = true) int pk,
		WebRequest webRequest){
	//验证
	try {
		Class<CategoryTypeDetail> categoryTypeDetailClass =(Class<CategoryTypeDetail>) Class.forName("com.vimisky.dms.entity.backend.CategoryTypeDetail");
		CategoryTypeDetail categoryTypeDetail = categoryTypeDetailClass.newInstance();
		Field field = categoryTypeDetailClass.getDeclaredField(name);
		field.setAccessible(true);
		field.set(categoryTypeDetail, value);
		categoryTypeDetail.setId(pk);

		Set<javax.validation.ConstraintViolation<CategoryTypeDetail>> constraintViolations = validator.validate(categoryTypeDetail, PatchEntity.class);
		if(constraintViolations.size()>0) {
			Iterator<javax.validation.ConstraintViolation<CategoryTypeDetail>> iterator = constraintViolations.iterator();
			StringBuffer errorString = new StringBuffer();
			while(iterator.hasNext()){
				javax.validation.ConstraintViolation<CategoryTypeDetail> constraintViolation = iterator.next();
				logger.warn(constraintViolation.getMessage());
				errorString.append(constraintViolation.getMessage());
			}
			return new RestfulResult(false, "表单提交数据错误:"+errorString);
		}
		

	} catch (ClassNotFoundException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
		return new RestfulResult(false,"服务器后台出错");
	} catch (InstantiationException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
		return new RestfulResult(false,"服务器后台出错");
	} catch (IllegalAccessException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
		return new RestfulResult(false,"服务器后台出错");
	} catch (SecurityException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
		return new RestfulResult(false,"服务器后台出错");
	} catch (NoSuchFieldException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
		return new RestfulResult(false,"服务器后台出错");
	}
	
	OperationServiceResult osr = 
    backendCategoryService.updateCategoryTypePart(pk, name, value);
	return new RestfulResult(osr);
	
}

写在后面

源码中的RestfulResult类和OperationServiceResult类是为前端服务而自定义的类,比较简单,与具体实现的业务相关,与本文无关,所以不贴了。

关于Bean Validation自定义约束,在以后的博文中再单写。

关于Bean ValidationValidator接口自定义实现类,在以后的博文中再单写。可以在Spring MVC中通过给databinder添加validator的方式加入。

关于Bean Validation和Spring Validation的框架选择上,使用自己熟悉的就好,但是遇到的 @Valid 注解加不了分组,Spring validator validate方法手工验证加不了分组这两种情况,就只能使用另外一种了。