正说PropertyValuesProvider的应用

  • Github地址:https://github.com/andyslin/spring-ext
  • 编译、运行环境:JDK 8 + Maven 3 + IDEA + Lombok
  • spring-boot:2.1.0.RELEASE(Spring:5.1.2.RELEASE)
  • 如要本地运行github上的项目,需要安装lombok插件

在上篇文章从SpringMVC获取用户信息谈起中,由一个典型的应用场景说起,通过分析SpringMVC的源码,引入新接口PropertyValuesProvider,我们给SpringMVC的参数绑定提供了一种新的机制,姑且称之为PropertyValuesProvider机制。在这篇文章中,就来说一下这种新机制的几个应用,这些应用都是我在实际工作中曾经遇到的。

一、准备工作

为了后面的测试,先做一些准备工作:

  1. 创建一个SpringBoot应用,添加maven依赖:

     <dependency>
     	<groupId>org.springframework.boot</groupId>
     	<artifactId>spring-boot-starter-web</artifactId>
     </dependency>
    
     <dependency>
     	<groupId>org.springframework.boot</groupId>
     	<artifactId>spring-boot-starter-test</artifactId>
     </dependency>
    
  2. 添加启动类

    @SpringBootApplication
    public class ArgsBindApplication {
       public static void main(String[] args) {
          SpringApplication.run(ArgsBindApplication.class, args);
       }
    }
    
  3. 添加测试类,启用MockMvc

    @RunWith(SpringRunner.class)
    @SpringBootTest
    @AutoConfigureMockMvc
    public class ArgsBindApplicationTests {
    
       @Autowired
       private MockMvc mvc;
    }
    

二、验证PropertyValuesProvider机制

先纯粹的验证一下PropertyValuesProvider机制,不预设具体的应用场景。添加一个PropertyValuesProvider的实现类,并注册为Spring容器中的Bean

@Component
public class TestPropertyValuesProvider implements PropertyValuesProvider {

    @Override
    public void addBindValues(MutablePropertyValues mpvs, ServletRequest request, Object target, String name) {
        mpvs.add("beforeBindProperty", "beforeBindPropertyValue");
    }

    @Override
    public void afterBindValues(PropertyAccessor accessor, ServletRequest request, Object target, String name) {
        if (target instanceof TestForm) {
            accessor.setPropertyValue("afterBindProperty", "afterBindPropertyValue");
        }
    }
}

这个实现类逻辑非常简单,就是在SpringMVC原生的参数绑定和验证之前,提供了一个候选的属性beforeBindProperty,在参数绑定和验证之后,又修改了目标对象的属性afterBindProperty。当然,为了确保有afterBindProperty这个属性,实现类中先对目标对象做了一个类型判断,在实际应用中,可以做更灵活的处理。目标类型TestForm就是一个简单的POJO:

@Getter
@Setter
@ToString
public class TestForm {

    private String beforeBindProperty;

    private String afterBindProperty;
}

这里没有直接使用@Data注解,是因为@Data功能太多,会生成很多方法,而我只是需要gettersettertoString就可以了。

然后控制器定义如下:

@RestController
public class TestController {

    @GetMapping("/test")
    public TestForm test(TestForm form) {
        return form;
    }
}

最后,在测试类ArgsBindApplicationTests中添加测试方法:

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class ArgsBindApplicationTests {

    @Autowired
    private MockMvc mvc;

    // 添加的测试方法
    @Test
    public void test() throws Exception {
        MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/test")).andReturn();
        MockHttpServletResponse response = result.getResponse();
        Assert.assertEquals(200, response.getStatus());

        JSONObject json = new JSONObject(response.getContentAsString());
        Assert.assertEquals("beforeBindPropertyValue", json.getString("beforeBindProperty"));
        Assert.assertEquals("afterBindPropertyValue", json.getString("afterBindProperty"));
    }
}

通过运行测试案例,可以发现实现类PropertyValuesProviderTest已经生效。如果不熟悉使用MockMVC,也可以本地启动应用后,在浏览器或Postman中手工发起请求。

三、实际应用

(一)公共的BaseForm

很多时候,后端的控制器需要根据会话上下文获取一些公共属性(上篇文章中的用户信息就是一种会话上下文信息),如果在每个控制器中去获取,虽然思路简单,但是编写麻烦,更重要的是不便于维护。这时候,我们可以把需要提取的信息定义一个公共的BaseForm,然后具体的业务Form添加一个类型为BaseForm的属性(或者直接继承BaseForm),具体步骤如下:

  1. 定义公共的BaseForm和业务Form
    @Getter
    @Setter
    @ToString
    public class BaseForm {
    
       private String userId;
    
       private String orgId;
    }
    
    @Getter
    @Setter
    @ToString
    public class BusinessForm {
    
       private BaseForm base;
    }
    
  2. 编写PropertyValuesProvider的实现类,并添加@Component注入到Spring容器中:
    @Component
    public class BaseFormPropertyValuesProvider implements PropertyValuesProvider {
    
       @Override
       public void addBindValues(MutablePropertyValues mpvs, ServletRequest request, Object target, String name) {
          mpvs.add("base", obtainBaseForm());
       }
    
       /**
        * 获取BaseForm,这里直接返回,实际应用可能会获取session、解密jwt或者其它逻辑
        *
        * @return
        */
       private BaseForm obtainBaseForm() {
          BaseForm form = new BaseForm();
          form.setUserId("admin");
          form.setOrgId("0000");
          return form;
       }
    

    当然,这里只是演示。实际应用中不宜写死base名称,可以根据Type反过来获取属性名称(可以参考后面的案例),并缓存这些元信息。

  3. 编写控制器Controller
    @RestController
    public class BaseFormController {
    
       @GetMapping("/baseform")
       public BusinessForm test(BusinessForm form) {
          return form;
       }
    }
    
  4. 添加测试方法
    @Test
    public void baseform() throws Exception {
       MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/baseform")).andReturn();
       MockHttpServletResponse response = result.getResponse();
       Assert.assertEquals(200, response.getStatus());
    
       JSONObject json = new JSONObject(response.getContentAsString());
       JSONObject base = json.getJSONObject("base");
    
       Assert.assertEquals("admin", base.getString("userId"));
       Assert.assertEquals("0000", base.getString("orgId"));
    }
    
    测试案例通过,说明已经按预期设置公共属性了。

(二)配置属性

除了从会话上下文中获取信息之外,在实际工作中还遇到过一种情况,就是需要根据请求从DB中加载配置,当然,这些逻辑可以放在service层,但是放到service层,除了代码散落各处之外,也不能享有SpringMVC中便利的参数校验机制了。而通过PropertyValuesProvider机制,可以将这些代码像AOP一样收敛到一起(我始终以为,AOP不只是提供了一种实用功能,更重要的还是一种编程思想,学习AOP,除了学习怎么使用,还要学习怎么思考)。

我们来一起处理这种情形:

  1. 添加用于设别特殊属性的注解:

    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface ConfigProperty {
    
       String value();
    }
    

    为了简单,这里做了一些简化,没有区分配置的类型(文件、DB、环境变量等),也没有添加属性前缀匹配等。

  2. 在业务Form的属性中添加注解:

    @Getter
    @Setter
    @ToString
    public class ConfigPropertyForm {
    
       @ConfigProperty("configName")
       private String configProperty;
    }
    
  3. 编写PropertyValuesProvider实现类,实现属性注入逻辑:

    @Component
    public class ConfigPropertyPropertyValuesProvider implements PropertyValuesProvider {
    
       @Override
         public void addBindValues(MutablePropertyValues mpvs, ServletRequest request, Object target, String name) {
             for (Class<?> cls = target.getClass(); !cls.equals(Object.class); cls = cls.getSuperclass()) {
                 for (Field field : cls.getDeclaredFields()) {
                     // 实际应用中可以将是否包括@ConfigProperty注解等元信息缓存起来
                     if (field.isAnnotationPresent(ConfigProperty.class)) {
                         mpvs.add(field.getName(), obtainConfigProperty(field.getAnnotation(ConfigProperty.class), field));
                     }
                 }
             }
         }
    
       /**
         * 根据注解和Field获取属性
         */
         private Object obtainConfigProperty(ConfigProperty configProperty, Field field) {
             String propertyName = configProperty.value();
             // 这里直接返回属性值,实际应用中可以根据注解从环境变量、DB或者缓存中获取
             return propertyName + "Value";
         }
     }
    

    属性是否包含@ConfigProperty注解的元信息可以缓存起来

  4. 添加控制器,在测试类ArgsBindApplicationTests中添加测试方法,运行测试案例:

    @RestController
    public class ConfigPropertyController {
    
      @GetMapping("/configProperty")
      public ConfigPropertyForm test(ConfigPropertyForm form) {
            return form;
      }
    }
    
    @Test
    public void configProperty() throws Exception {
       MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/configProperty")).andReturn();
       MockHttpServletResponse response = result.getResponse();
       Assert.assertEquals(200, response.getStatus());
    
       JSONObject json = new JSONObject(response.getContentAsString());
       Assert.assertEquals("configNameValue", json.getString("configProperty"));
    }
    

可能有朋友会说,要使用环境属性或者配置,不是直接可以使用Spring提供的@Value注解吗?的确,在ControllerServiceSpring容器中的Bean,可以直接使用@Value注解,但是我们这里是在Form中使用配置。

回想一下这个案例,这实际上是一种新的模式:定义一种用于设别的注解,在Form对象的属性中使用注解,然后根据注解、属性、请求等设置属性值。 下面再看一个这种模式的应用场景:

(三)RSA解密

Web应用中,为了安全考虑,在客户端使用JSjsencrypt将用户输入的密码通过RSA加密,然后传输到服务端,服务端使用SpringMVC的机制接受参数,但是服务端有一个校验(密码长度在6到16位),这样,使用原生的校验机制,被校验的值是RSA加密后的值(很长),因而通不过校验,我们看看这种场景:

  1. 为了使用SpringMVC的校验机制,先在pom.xml中添加依赖:

    <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
  2. 添加识别需要RSA解密的标志注解:

    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface RsaDecrypt {
    }
    
  3. 定义Form

    @Getter
    @Setter
    @ToString
    public class RsaDecryptForm {
       @Length(min = 6, max = 16, message = "长度只能在6-16位")
       @RsaDecrypt
       private String rsa;
    }
    
  4. 编写Controller,添加校验注解@Validated:

    @RestController
    public class RsaDecryptController {
    
       @GetMapping("/rsaDecrypt")
       public RsaDecryptForm test(@Validated RsaDecryptForm form) {
          return form;
       }
    }
    
  5. 在测试类ArgsBindApplicationTests中添加测试方法,运行测试案例:

    @Test
    public void rsa() throws Exception {
       String src = "abadewew";//原始值
       // 模拟客户端使用RSA加密
       String encrypt = RSAUtils.encryptByPublicKey(src, RSA_PAIR[0]);
       MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/rsaDecrypt").param("rsa", encrypt)).andReturn();
       MockHttpServletResponse response = result.getResponse();
       Assert.assertEquals(200, response.getStatus());
    
       JSONObject json = new JSONObject(response.getContentAsString());
       Assert.assertEquals(src, json.getString("rsa"));
    }
    

    结果发现,第一个断言已经失败,从日志中可以看出抛出了BindException异常,因为没有通过校验:

     MockHttpServletRequest:
         HTTP Method = GET
         Request URI = /rsaDecrypt
         Parameters = {rsa=[envr8Rm72k5c2FxkMjhhPxeHCyvh+IENKTAFO30z6c/dUn8Z3rMv1gyqCAYmaSIy09KH4kFdO90Gsz4uJhzi/riM4bOOBwCcXBvq6J1Md9yiZOgdl/XuDVf7V4IJsE2NUQnhmtfFFJhSOuPzeMJ7HntC1J/CrDUBaL5n40tWW6I=]}
             Headers = {}
                 Body = null
         Session Attrs = {}
    
     Handler:
                 Type = org.autumn.spring.argsbind.rsa.RsaDecryptController
             Method = public org.autumn.spring.argsbind.rsa.RsaDecryptForm org.autumn.spring.argsbind.rsa.RsaDecryptController.test(org.autumn.spring.argsbind.rsa.RsaDecryptForm)
    
     Async:
         Async started = false
         Async result = null
    
     Resolved Exception:
                 Type = org.springframework.validation.BindException
    
     ModelAndView:
             View name = null
                 View = null
                 Model = null
    
     FlashMap:
         Attributes = null
    
     MockHttpServletResponse:
             Status = 400
         Error message = null
             Headers = {}
         Content type = null
                 Body = 
         Forwarded URL = null
     Redirected URL = null
             Cookies = []
    

    为什么会这样呢?这是因为客户端RSA加密之后,传递到服务端的值是加密后的值,长度远远超过16,因而校验失败。现在,我们添加一个PropertyValuesProvider实现类做一下预处理:

  6. 添加RsaDecryptPropertyValuesProvider实现类:

    @Component
    public class RsaDecryptPropertyValuesProvider implements PropertyValuesProvider {
    
       @Override
       public void addBindValues(MutablePropertyValues mpvs, ServletRequest request, Object target, String name) {
             for (Class<?> cls = target.getClass(); !cls.equals(Object.class); cls = cls.getSuperclass()) {
                 for (Field field : cls.getDeclaredFields()) {
                     if (field.isAnnotationPresent(RsaDecrypt.class)) {
                         mpvs.add(field.getName(), obtainConfigProperty(field.getName(), request));
                     }
                 }
             }
       }
    
       /**
         * 根据注解和Field获取属性
         */
         private Object obtainConfigProperty(String name, ServletRequest request) {
             String encrypt = request.getParameter(name);
             if (StringUtils.hasText(encrypt)) {
                 return RSAUtils.decryptByPrivateKey(encrypt, RSA_PAIR[1]);
             }
             return encrypt;
         }
     }
    
  7. 再次运行测试案例,发现已经通过测试,说明将加密传输和原生校验完美结合了!

    这个案例使用了一个工具类RSAUtils,可以从github上查看相关源码,没有任何依赖,只依赖JDK。

好了,PropertyValuesProvider机制先聊到这,希望对大家有一点点启发,如果你有遇到新的应用场景,也希望能够不惜赐教。

原文地址:https://www.cnblogs.com/linjisong/p/11611282.html