Spring4新特性——整合Bean Validation 1.1(JSR-349)到SpringMVC
Bean Validation 1.1當前實現是Hibernate validator 5,且spring4才支援。接下來我們從以下幾個方法講解Bean Validation 1.1,當然不一定是新特性:
- 整合Bean Validation 1.1到SpringMVC
- 分組驗證、分組順序及級聯驗證
- 訊息中使用EL表示式
- 方法引數/返回值驗證
- 自定義驗證規則
- 類級別驗證器
- 指令碼驗證器
- cross-parameter,跨引數驗證
- 混合類級別驗證器和跨引數驗證器
- 組合多個驗證註解
- 本地化
因為大多數時候驗證都配合web框架使用,而且很多朋友都諮詢過如分組/跨引數驗證,所以本文介紹下這些,且是和SpringMVC框架整合的例子,其他使用方式(比如整合到JPA中)可以參考其官方文件:
1、整合Bean Validation 1.1到SpringMVC
1.1、專案搭建
首先新增hibernate validator 5依賴:
Java程式碼- <dependency>
- <groupId>org.hibernate</groupId>
- <artifactId>hibernate-validator</artifactId>
- <version>5.0.2.Final</version>
- </dependency>
如果想在訊息中使用EL表示式,請確保EL表示式版本是 2.2或以上,如使用Tomcat6,請到Tomcat7中拷貝相應的EL jar包到Tomcat6中。
- <dependency>
- <groupId>javax.el</groupId>
- <artifactId>javax.el-api</artifactId>
- <version>2.2.4</version>
- <scope>provided</scope>
- </dependency>
請確保您使用的Web容器有相應版本的el jar包。
對於其他POM依賴請下載附件中的專案參考。
1.2、Spring MVC配置檔案(spring-mvc.xml):
- <!-- 指定自己定義的validator -->
- <mvc:annotation-driven validator="validator"/>
- <!-- 以下 validator ConversionService 在使用 mvc:annotation-driven 會 自動註冊-->
- <bean id="validator"class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
- <property name="providerClass" value="org.hibernate.validator.HibernateValidator"/>
- <!-- 如果不加預設到 使用classpath下的 ValidationMessages.properties -->
- <property name="validationMessageSource" ref="messageSource"/>
- </bean>
- <!-- 國際化的訊息資原始檔(本系統中主要用於顯示/錯誤訊息定製) -->
- <bean id="messageSource"class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
- <property name="basenames">
- <list>
- <!-- 在web環境中一定要定位到classpath 否則預設到當前web應用下找 -->
- <value>classpath:messages</value>
- <value>classpath:org/hibernate/validator/ValidationMessages</value>
- </list>
- </property>
- <property name="useCodeAsDefaultMessage" value="false"/>
- <property name="defaultEncoding" value="UTF-8"/>
- <property name="cacheSeconds" value="60"/>
- </bean>
此處主要把bean validation的訊息查詢委託給spring的messageSource。
1.3、實體驗證註解:
Java程式碼- publicclass User implements Serializable {
- @NotNull(message = "{user.id.null}")
- private Long id;
- @NotEmpty(message = "{user.name.null}")
- @Length(min = 5, max = 20, message = "{user.name.length.illegal}")
- @Pattern(regexp = "[a-zA-Z]{5,20}", message = "{user.name.illegal}")
- private String name;
- @NotNull(message = "{user.password.null}")
- private String password;
- }
1.4、錯誤訊息檔案messages.properties:
Java程式碼- user.id.null=使用者編號不能為空
- user.name.null=使用者名稱不能為空
- user.name.length.illegal=使用者名稱長度必須在5到20之間
- user.name.illegal=使用者名稱必須是字母
- user.password.null=密碼不能為空
1.5、控制器
Java程式碼- @Controller
- publicclass UserController {
- @RequestMapping("/save")
- public String save(@Valid User user, BindingResult result) {
- if(result.hasErrors()) {
- return"error";
- }
- return"success";
- }
- }
1.6、錯誤頁面:
Java程式碼- <spring:hasBindErrors name="user">
- <c:if test="${errors.fieldErrorCount > 0}">
- 欄位錯誤:<br/>
- <c:forEach items="${errors.fieldErrors}" var="error">
- <spring:message var="message" code="${error.code}" arguments="${error.arguments}" text="${error.defaultMessage}"/>
- ${error.field}------${message}<br/>
- </c:forEach>
- </c:if>
- <c:if test="${errors.globalErrorCount > 0}">
- 全域性錯誤:<br/>
- <c:forEach items="${errors.globalErrors}" var="error">
- <spring:message var="message" code="${error.code}" arguments="${error.arguments}" text="${error.defaultMessage}"/>
- <c:if test="${not empty message}">
- ${message}<br/>
- </c:if>
- </c:forEach>
- </c:if>
- </spring:hasBindErrors>
大家以後可以根據這個做通用的錯誤訊息顯示規則。比如我前端頁面使用validationEngine顯示錯誤訊息,那麼我可以定義一個tag來通用化錯誤訊息的顯示:showFieldError.tag。
1.7、測試
Java程式碼- name------使用者名稱必須是字母
- name------使用者名稱長度必須在5到20之間
- password------密碼不能為空
- id------使用者編號不能為空
基本的整合就完成了。
如上測試有幾個小問題:
1、錯誤訊息順序,大家可以看到name的錯誤訊息順序不是按照書寫順序的,即不確定;
2、我想顯示如:使用者名稱【zhangsan】必須在5到20之間;其中我們想動態顯示:使用者名稱、min,max;而不是寫死了;
3、我想在修改的時候只驗證使用者名稱,其他的不驗證怎麼辦。
接下來我們挨著試試吧。
2、分組驗證及分組順序
如果我們想在新增的情況驗證id和name,而修改的情況驗證name和password,怎麼辦? 那麼就需要分組了。
首先定義分組介面:
Java程式碼- publicinterface First {
- }
- publicinterface Second {
- }
分組介面就是兩個普通的介面,用於標識,類似於java.io.Serializable。
接著我們使用分組介面標識實體:
Java程式碼- publicclass User implements Serializable {
- @NotNull(message = "{user.id.null}", groups = {First.class})
- private Long id;
- @Length(min = 5, max = 20, message = "{user.name.length.illegal}", groups = {Second.class})
- @Pattern(regexp = "[a-zA-Z]{5,20}", message = "{user.name.illegal}", groups = {Second.class})
- private String name;
- @NotNull(message = "{user.password.null}", groups = {First.class, Second.class})
- private String password;
- }
驗證時使用如:
Java程式碼- @RequestMapping("/save")
- public String save(@Validated({Second.class}) User user, BindingResult result) {
- if(result.hasErrors()) {
- return"error";
- }
- return"success";
- }
即通過@Validate註解標識要驗證的分組;如果要驗證兩個的話,可以這樣@Validated({First.class, Second.class})。
接下來我們來看看通過分組來指定順序;還記得之前的錯誤訊息嗎? user.name會顯示兩個錯誤訊息,而且順序不確定;如果我們先驗證一個訊息;如果不通過再驗證另一個怎麼辦?可以通過@GroupSequence指定分組驗證順序:
Java程式碼- @GroupSequence({First.class, Second.class, User.class})
- publicclass User implements Serializable {
- private Long id;
- @Length(min = 5, max = 20, message = "{user.name.length.illegal}", groups = {First.class})
- @Pattern(regexp = "[a-zA-Z]{5,20}", message = "{user.name.illegal}", groups = {Second.class})
- private String name;
- private String password;
- }
通過@GroupSequence指定驗證順序:先驗證First分組,如果有錯誤立即返回而不會驗證Second分組,接著如果First分組驗證通過了,那麼才去驗證Second分組,最後指定User.class表示那些沒有分組的在最後。這樣我們就可以實現按順序驗證分組了。
另一個比較常見的就是級聯驗證:
如:
Java程式碼- publicclass User {
- @Valid
- @ConvertGroup(from=First.class, to=Second.class)
- private Organization o;
- }
1、級聯驗證只要在相應的欄位上加@Valid即可,會進行級聯驗證;@ConvertGroup的作用是當驗證o的分組是First時,那麼驗證o的分組是Second,即分組驗證的轉換。
3、訊息中使用EL表示式
假設我們需要顯示如:使用者名稱[NAME]長度必須在[MIN]到[MAX]之間,此處大家可以看到,我們不想把一些資料寫死,如NAME、MIN、MAX;此時我們可以使用EL表示式。
如:
Java程式碼- @Length(min = 5, max = 20, message = "{user.name.length.illegal}", groups = {First.class})
錯誤訊息:
Java程式碼- user.name.length.illegal=使用者名稱長度必須在{min}到{max}之間
其中我們可以使用{驗證註解的屬性}得到這些值;如{min}得到@Length中的min值;其他的也是類似的。
到此,我們還是無法得到出錯的那個輸入值,如name=zhangsan。此時就需要EL表示式的支援,首先確定引入EL jar包且版本正確。然後使用如:
Java程式碼- user.name.length.illegal=使用者名稱[${validatedValue}]長度必須在5到20之間
使用如EL表示式:${validatedValue}得到輸入的值,如zhangsan。當然我們還可以使用如${min > 1 ? '大於1' : '小於等於1'},及在EL表示式中也能拿到如@Length的min等資料。
另外我們還可以拿到一個java.util.Formatter型別的formatter變數進行格式化:
Java程式碼- ${formatter.format("%04d", min)}
4、方法引數/返回值驗證
5、自定義驗證規則
有時候預設的規則可能還不夠,有時候還需要自定義規則,比如遮蔽關鍵詞驗證是非常常見的一個功能,比如在發帖時帖子中不允許出現admin等關鍵詞。
1、定義驗證註解
Java程式碼- package com.sishuok.spring4.validator;
- import javax.validation.Constraint;
- import javax.validation.Payload;
- import java.lang.annotation.Documented;
- import java.lang.annotation.Retention;
- import java.lang.annotation.Target;
- importstatic java.lang.annotation.ElementType.*;
- importstatic java.lang.annotation.RetentionPolicy.*;
- /**
- * <p>User: Zhang Kaitao
- * <p>Date: 13-12-15
- * <p>Version: 1.0
- */
- @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
- @Retention(RUNTIME)
- //指定驗證器
- @Constraint(validatedBy = ForbiddenValidator.class)
- @Documented
- public@interface Forbidden {
- //預設錯誤訊息
- String message() default"{forbidden.word}";
- //分組
- Class<?>[] groups() default { };
- //負載
- Class<? extends Payload>[] payload() default { };
- //指定多個時使用
- @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
- @Retention(RUNTIME)
- @Documented
- @interface List {
- Forbidden[] value();
- }
- }
2、 定義驗證器
Java程式碼- package com.sishuok.spring4.validator;
- import org.hibernate.validator.internal.engine.constraintvalidation.ConstraintValidatorContextImpl;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.context.ApplicationContext;
- import org.springframework.util.StringUtils;
- import javax.validation.ConstraintValidator;
- import javax.validation.ConstraintValidatorContext;
- import java.io.Serializable;
- /**
- * <p>User: Zhang Kaitao
- * <p>Date: 13-12-15
- * <p>Version: 1.0
- */
- publicclass ForbiddenValidator implements ConstraintValidator<Forbidden, String> {
- private String[] forbiddenWords = {"admin"};
- @Override
- publicvoid initialize(Forbidden constraintAnnotation) {
- //初始化,得到註解資料
- }
- @Override
- publicboolean isValid(String value, ConstraintValidatorContext context) {
- if(StringUtils.isEmpty(value)) {
- returntrue;
- }
- for(String word : forbiddenWords) {
- if(value.contains(word)) {
- returnfalse;//驗證失敗
- }
- }
- returntrue;
- }
- }
驗證器中可以使用spring的依賴注入,如注入:@Autowired private ApplicationContext ctx;
3、使用
Java程式碼- publicclass User implements Serializable {
- @Forbidden()
- private String name;
- }
4、當我們在提交name中含有admin的時候會輸出錯誤訊息:
Java程式碼- forbidden.word=您輸入的資料中有非法關鍵詞
問題來了,哪個詞是非法的呢?bean validation 和 hibernate validator都沒有提供相應的api提供這個資料,怎麼辦呢?通過跟蹤程式碼,發現一種不是特別好的方法:我們可以覆蓋org.hibernate.validator.internal.metadata.descriptor.ConstraintDescriptorImpl實現(即複製一份程式碼放到我們的src中),然後覆蓋buildAnnotationParameterMap方法;
Java程式碼- private Map<String, Object> buildAnnotationParameterMap(Annotation annotation) {
- ……
- //將Collections.unmodifiableMap( parameters );替換為如下語句
- return parameters;
- }
即允許這個資料可以修改;然後在ForbiddenValidator中:
Java程式碼- for(String word : forbiddenWords) {
- if(value.contains(word)) {
- ((ConstraintValidatorContextImpl)context).getConstraintDescriptor().getAttributes().put("word", word);
- returnfalse;//驗證失敗
- }
- }
通過((ConstraintValidatorContextImpl)context).getConstraintDescriptor().getAttributes().put("word", word);新增自己的屬性;放到attributes中的資料可以通過${} 獲取。然後訊息就可以變成:
Java程式碼- forbidden.word=您輸入的資料中有非法關鍵詞【{word}】
這種方式不是很友好,但是可以解決我們的問題。
典型的如密碼、確認密碼的場景,非常常用;如果沒有這個功能我們需要自己寫程式碼來完成;而且經常重複自己。接下來看看bean validation 1.1如何實現的。
6、類級別驗證器
6.1、定義驗證註解
Java程式碼- package com.sishuok.spring4.validator;
- import javax.validation.Constraint;
- import javax.validation.Payload;
- import javax.validation.constraints.NotNull;
- import java.lang.annotation.Documented;
- import java.lang.annotation.Retention;
- import java.lang.annotation.Target;
- importstatic java.lang.annotation.ElementType.*;
- importstatic java.lang.annotation.RetentionPolicy.*;
- /**
- * <p>User: Zhang Kaitao
- * <p>Date: 13-12-15
- * <p>Version: 1.0
- */
- @Target({ TYPE, ANNOTATION_TYPE})
- @Retention(RUNTIME)
- //指定驗證器
- @Constraint(validatedBy = CheckPasswordValidator.class)
- @Documented
- public@interface CheckPassword {
- //預設錯誤訊息
- String message() default"";
- //分組
- Class<?>[] groups() default { };
- //負載
- Class<? extends Payload>[] payload() default { };
- //指定多個時使用
- @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
- @Retention(RUNTIME)
- @Documented
- @interface List {
- CheckPassword[] value();
- }
- }
6.2、 定義驗證器
Java程式碼- package com.sishuok.spring4.validator;
- import com.sishuok.spring4.entity.User;
- import org.springframework.util.StringUtils;
- import javax.validation.ConstraintValidator;
- import javax.validation.ConstraintValidatorContext;
- /**
- * <p>User: Zhang Kaitao
- * <p>Date: 13-12-15
- * <p>Version: 1.0
- */
- publicclass CheckPasswordValidator implements ConstraintValidator<CheckPassword, User> {
- @Override
- publicvoid initialize(CheckPassword constraintAnnotation) {
- }
- @Override
- publicboolean isValid(User user, ConstraintValidatorContext context) {
- if(user == null) {
- returntrue;
- }
- //沒有填密碼
- if(!StringUtils.hasText(user.getPassword())) {
- context.disableDefaultConstraintViolation();
- context.buildConstraintViolationWithTemplate("{password.null}")
- .addPropertyNode("password")
- .addConstraintViolation();
- returnfalse;
- }
- if(!StringUtils.hasText(user.getConfirmation())) {
- context.disableDefaultConstraintViolation();
- context.buildConstraintViolationWithTemplate("{password.confirmation.null}")
- .addPropertyNode("confirmation")
- .addConstraintViolation();
- returnfalse;
- }
- //兩次密碼不一樣
- if (!user.getPassword().trim().equals(user.getConfirmation().trim())) {
- context.disableDefaultConstraintViolation();
- context.buildConstraintViolationWithTemplate("{password.confirmation.error}")
- .addPropertyNode("confirmation")
- .addConstraintViolation();
- returnfalse;
- }
- returntrue;
- }
- }
其中我們通過disableDefaultConstraintViolation禁用預設的約束;然後通過buildConstraintViolationWithTemplate(訊息模板)/addPropertyNode(所屬屬性)/addConstraintViolation定義我們自己的約束。
6.3、使用
Java程式碼- @CheckPassword()
- publicclass User implements Serializable {
- }
放到類頭上即可。
7、通過指令碼驗證
Java程式碼- @ScriptAssert(script = "_this.password==_this.confirmation", lang = "javascript", alias = "_this", message = "{password.confirmation.error}")
- publicclass User implements Serializable {
- }
通過指令碼驗證是非常簡單而且強大的,lang指定指令碼語言(請參考javax.script.ScriptEngineManager JSR-223),alias是在指令碼驗證中User物件的名字,但是大家會發現一個問題:錯誤訊息怎麼顯示呢? 在springmvc 中會新增到全域性錯誤訊息中,這肯定不是我們想要的,我們改造下吧。
7.1、定義驗證註解
Java程式碼- package com.sishuok.spring4.validator;
- import org.hibernate.validator.internal.constraintvalidators.ScriptAssertValidator;
- import java.lang.annotation.Documented;
- import java.lang.annotation.Retention;
- import java.lang.annotation.Target;
- import javax.validation.Constraint;
- import javax.validation.Payload;
- importstatic java.lang.annotation.ElementType.TYPE;
- importstatic java.lang.annotation.RetentionPolicy.RUNTIME;
- @Target({ TYPE })
- @Retention(RUNTIME)
- @Constraint(validatedBy = {PropertyScriptAssertValidator.class})
- @Documented
- public@interface PropertyScriptAssert {
- String message() default"{org.hibernate.validator.constraints.ScriptAssert.message}";
- Class<?>[] groups() default { };
- Class<? extends Payload>[] payload() default { };
- String lang();
- String script();
- String alias() default"_this";
- String property();
- @Target({ TYPE })
- @Retention(RUNTIME)
- @Documented
- public@interface List {
- PropertyScriptAssert[] value();
- }
- }
和ScriptAssert沒什麼區別,只是多了個property用來指定出錯後給實體的哪個屬性。
7.2、驗證器
Java程式碼- package com.sishuok.spring4.validator;
- import javax.script.ScriptException;
- import javax.validation.ConstraintDeclarationException;
- import javax.validation.ConstraintValidator;
- import javax.validation.ConstraintValidatorContext;
- import com.sishuok.spring4.validator.PropertyScriptAssert;
- import org.hibernate.validator.constraints.ScriptAssert;
- import org.hibernate.validator.internal.util.Contracts;
- import org.hibernate.validator.internal.util.logging.Log;
- import org.hibernate.validator.internal.util.logging.LoggerFactory;
- import org.hibernate.validator.internal.util.scriptengine.ScriptEvaluator;
- import org.hibernate.validator.internal.util.scriptengine.ScriptEvaluatorFactory;
- importstatic org.hibernate.validator.internal.util.logging.Messages.MESSAGES;
- publicclass PropertyScriptAssertValidator implements ConstraintValidator<PropertyScriptAssert, Object> {
- privatestaticfinal Log log = LoggerFactory.make();
- private String script;
- private String languageName;
- private String alias;
- private String property;
- private String message;
- publicvoid initialize(PropertyScriptAssert constraintAnnotation) {
- validateParameters( constraintAnnotation );
- this.script = constraintAnnotation.script();
- this.languageName = constraintAnnotation.lang();
- this.alias = constraintAnnotation.alias();
- this.property = constraintAnnotation.property();
- this.message = constraintAnnotation.message();
- }
- publicboolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
- Object evaluationResult;
- ScriptEvaluator scriptEvaluator;
- try {
- ScriptEvaluatorFactory evaluatorFactory = ScriptEvaluatorFactory.getInstance();
- scriptEvaluator = evaluatorFactory.getScriptEvaluatorByLanguageName( languageName );
- }
- catch ( ScriptException e ) {
- thrownew ConstraintDeclarationException( e );
- }
- try {
- evaluationResult = scriptEvaluator.evaluate( script, value, alias );
- }
- catch ( ScriptException e ) {
- throw log.getErrorDuringScriptExecutionException( script, e );
- }
- if ( evaluationResult == null ) {
- throw log.getScriptMustReturnTrueOrFalseException( script );
- }
- if ( !( evaluationResult instanceof Boolean ) ) {
- throw log.getScriptMustReturnTrueOrFalseException(
- script,
- evaluationResult,
- evaluationResult.getClass().getCanonicalName()
- );
- }
- if(Boolean.FALSE.equals(evaluationResult)) {
- constraintValidatorContext.disableDefaultConstraintViolation();
- constraintValidatorContext
- .buildConstraintViolationWithTemplate(message)
- .addPropertyNode(property)
- .addConstraintViolation();
- }
- return Boolean.TRUE.equals( evaluationResult );
- }
- privatevoid validateParameters(PropertyScriptAssert constraintAnnotation) {
- Contracts.assertNotEmpty( constraintAnnotation.script(), MESSAGES.parameterMustNotBeEmpty( "script" ) );
- Contracts.assertNotEmpty( constraintAnnotation.lang(), MESSAGES.parameterMustNotBeEmpty( "lang" ) );
- Contracts.assertNotEmpty( constraintAnnotation.alias(), MESSAGES.parameterMustNotBeEmpty( "alias" ) );
- Contracts.assertNotEmpty( constraintAnnotation.property(), MESSAGES.parameterMustNotBeEmpty( "property" ) );
- Contracts.assertNotEmpty( constraintAnnotation.message(), MESSAGES.parameterMustNotBeEmpty( "message" ) );
- }
- }
和之前的類級別驗證器類似,就不多解釋了,其他程式碼全部拷貝自org.hibernate.validator.internal.constraintvalidators.ScriptAssertValidator。
7.3、使用
Java程式碼- @PropertyScriptAssert(property = "confirmation", script = "_this.password==_this.confirmation", lang = "javascript", alias = "_this", message = "{password.confirmation.error}")
和之前的區別就是多了個property,用來指定出錯時給哪個欄位。 這個相對之前的類級別驗證器更通用一點。
8、cross-parameter,跨引數驗證
直接看示例;
Java程式碼- <bean class="org.springframework.validation.beanvalidation.MethodValidationPostProcessor">
- <property name="validator" ref="validator"/>
- </bean>
8.2、Service
Java程式碼- @Validated
- @Service
- publicclass UserService {
- @CrossParameter
- publicvoid changePassword(String password, String confirmation) {
- }
- }
通過@Validated註解UserService表示該類中有需要進行方法引數/返回值驗證; @CrossParameter註解方法表示要進行跨引數驗證;即驗證password和confirmation是否相等。
8.3、驗證註解
Java程式碼- package com.sishuok.spring4.validator;
- //省略import
- @Constraint(validatedBy = CrossParameterValidator.class)
- @Target({ METHOD, CONSTRUCTOR, ANNOTATION_TYPE })
- @Retention(RUNTIME)
- @Documented
- public@interface CrossParameter {
- String message() default"{password.confirmation.error}";
- Class<?>[] groups() default { };
- Class<? extends Payload>[] payload() default { };
- }
8.4、驗證器
Java程式碼- package com.sishuok.spring4.validator;
- //省略import
- @SupportedValidationTarget(ValidationTarget.PARAMETERS)