正说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
机制。在这篇文章中,就来说一下这种新机制的几个应用,这些应用都是我在实际工作中曾经遇到的。
一、准备工作
为了后面的测试,先做一些准备工作:
-
创建一个
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>
-
添加启动类
@SpringBootApplication public class ArgsBindApplication { public static void main(String[] args) { SpringApplication.run(ArgsBindApplication.class, args); } }
-
添加测试类,启用
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
功能太多,会生成很多方法,而我只是需要getter
、setter
和toString
就可以了。
然后控制器定义如下:
@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
),具体步骤如下:
- 定义公共的
BaseForm
和业务Form
@Getter @Setter @ToString public class BaseForm { private String userId; private String orgId; }
@Getter @Setter @ToString public class BusinessForm { private BaseForm base; }
- 编写
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反过来获取属性名称(可以参考后面的案例),并缓存这些元信息。 - 编写控制器
Controller
@RestController public class BaseFormController { @GetMapping("/baseform") public BusinessForm test(BusinessForm form) { return form; } }
- 添加测试方法
测试案例通过,说明已经按预期设置公共属性了。@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
,除了学习怎么使用,还要学习怎么思考)。
我们来一起处理这种情形:
-
添加用于设别特殊属性的注解:
@Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) public @interface ConfigProperty { String value(); }
为了简单,这里做了一些简化,没有区分配置的类型(文件、DB、环境变量等),也没有添加属性前缀匹配等。
-
在业务
Form
的属性中添加注解:@Getter @Setter @ToString public class ConfigPropertyForm { @ConfigProperty("configName") private String configProperty; }
-
编写
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
注解的元信息可以缓存起来 -
添加控制器,在测试类
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
注解吗?的确,在Controller
或Service
等Spring
容器中的Bean
,可以直接使用@Value
注解,但是我们这里是在Form
中使用配置。
回想一下这个案例,这实际上是一种新的模式:定义一种用于设别的注解,在Form
对象的属性中使用注解,然后根据注解、属性、请求等设置属性值。 下面再看一个这种模式的应用场景:
(三)RSA解密
在Web
应用中,为了安全考虑,在客户端使用JS
库jsencrypt
将用户输入的密码通过RSA
加密,然后传输到服务端,服务端使用SpringMVC
的机制接受参数,但是服务端有一个校验(密码长度在6到16位),这样,使用原生的校验机制,被校验的值是RSA加密后的值(很长),因而通不过校验,我们看看这种场景:
-
为了使用
SpringMVC
的校验机制,先在pom.xml
中添加依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
-
添加识别需要
RSA
解密的标志注解:@Target({ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) public @interface RsaDecrypt { }
-
定义
Form
@Getter @Setter @ToString public class RsaDecryptForm { @Length(min = 6, max = 16, message = "长度只能在6-16位") @RsaDecrypt private String rsa; }
-
编写
Controller
,添加校验注解@Validated
:@RestController public class RsaDecryptController { @GetMapping("/rsaDecrypt") public RsaDecryptForm test(@Validated RsaDecryptForm form) { return form; } }
-
在测试类
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
实现类做一下预处理: -
添加
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; } }
-
再次运行测试案例,发现已经通过测试,说明将加密传输和原生校验完美结合了!
这个案例使用了一个工具类
RSAUtils
,可以从github上查看相关源码,没有任何依赖,只依赖JDK。
好了,PropertyValuesProvider
机制先聊到这,希望对大家有一点点启发,如果你有遇到新的应用场景,也希望能够不惜赐教。
郴江幸自绕郴山,为谁流下潇湘去?
欲将心事付瑶琴,知音少,弦断有谁听?
倩何人,唤取红巾翠袖,揾英雄泪!
零落成泥碾作尘,只有香如故!