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
的参数绑定过程中引入了PropertyValuesProvider
机制,并列举了一些应用。写完后又发现了一例新的应用场景:日期绑定与校验,这篇文章就来简单介绍一下。
一、业务场景
业务场景非常简单,就是使用FormBean
接受前端传入的日期值。在这个过程中要满足几点要求:
- 后端接受到的日期满足设定的格式,比如
20190929(yyyyMMdd格式)
- 允许前端传入多种兼容的日期格式,比如
20190929、2019-09-29、2019/09/29、29/09/2019
等等 - 对前端传入的值,能够进行简单的计算,比如前端传入
2019-09-29
,但是后端接受的值为20190928(前一天)
- 对前端传入的一个参数值,可以赋给多个属性,并且每个属性值各不相同,比如前端传入
date=2019-09-29
,后端的yesterday=20190928
,而theDayOfLastMonth=20190829
- 如果前端没有传入日期值,可以计算一个默认值,比如默认为当前日期的前一天
不要说这些要求很奇葩,实际工作中就是有这么奇葩的要求,比如在APP中的查询报表,老板们懒得选日期,你得给出默认值,还得出环比、周同比、月同比、年同比......
二、初步实现:借用第一篇文章中大牛的说法,这是一个很挫的实现
拿到这个需求,怎么实现呢?你可能会说,很简单啊,没有一点难度,只是有一点点繁琐而已,很快,一个Controller
的HandlerMethod
就出来了:
@GetMapping("/test")
public void test(HttpServletRequest request){
String date = request.getParameter("date");
if(!StringUtils.hasText(date)){
date = DateUtils.getYesterday();
}else{
date = date.replaceAll("-","");
}
// 其它代码
}
顺便还提取了一个日期工具类DateUtils
,简直轻松加愉快......,如果有多个日期字段(开始日期、结束日期什么的)呢,Copy
一段代码修改一下名称好了;如果多个模块呢?没关系,Copy
一个类,然后改名称......;如果要兼容多种格式呢?那也简单,再替换一下date = date.replaceAll("-","").replaceAll("//","");
,一个一个模块加起来,代码量一下子就上去了,成就感爆棚;突然有一天,要求多添加几种兼容格式,不怕,还有IDE的批量替换,只是这次得小心翼翼了......
不要以为没有这样的代码,你要是接手一个老系统,你会发现有一堆又一堆这样的屎一样的代码
作为有追求的程序员,你肯定在想:怎么去优雅的实现呢?重要的事情说三遍:优雅、优雅、优雅
三、使用PropertyValuesProvider实现:你要的优雅来了
按照第二篇文章中总结的模式一步一步来:
模式?别逗了,我只听过GoF设计模式,没听过还有其它的什么模式。
别急呀,我们这里的模式就是套路的意思,为了听上去不那么土匪,借用了模式的概念,一下子逼格就上去了,那使用PropertyValuesProvider
有什么比较常用的套路呢?让我好好想想,好像是:
- 定义一个识别注解
- 在表单对象中使用注解
- 添加新的
PropertyValuesProvider
实现类,根据请求、表单对象和属性、注解等获取属性值 - 编写测试方法
(一)定义@DateField注解
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DateField {
/**
* 参数名,默认为被注解属性的名称
*
* @return
*/
String name() default "";
/**
* 最终接受的格式
*
* @return
*/
String format() default "yyyyMMdd";
/**
* 允许的格式,默认和{@link #format()}相同
*
* @return
*/
String[] allowFormats() default {};
/**
* 偏移量,使用整数数组表示针对当前时间的偏移,数组长度最长4位,依次表示日、月、周、年,不足4位的可省略
* <p>
* 如[0]表示今天,[-1]表示昨天,[0,-1]表示上月同一天,[0,0,-1]表示上周同一天,[0,0,0,-1]表示上年同一天
*
* @return
*/
int[] offsets() default {};
}
(二)编写DateFieldPropertyValuesProvider实现类
@Component
public class DateFieldPropertyValuesProvider implements PropertyValuesProvider {
private final Map<Class<?>, List<Field>> dateFields = new ConcurrentHashMap<>();
@Override
public void addBindValues(MutablePropertyValues mpvs, ServletRequest request, Object target, String name) {
Map<String, LocalDate> cached = new HashMap<>();
for (Class<?> cls = target.getClass(); !cls.equals(Object.class); cls = cls.getSuperclass()) {
for (Field field : getDateFields(cls)) {
dealDateField(mpvs, request, field, cached);
}
}
}
private List<Field> getDateFields(Class<?> cls) {
if (!dateFields.containsKey(cls)) {
synchronized (dateFields) {
if (!dateFields.containsKey(cls)) {
List<Field> fields = new ArrayList<>();
for (Field field : cls.getDeclaredFields()) {
if (null != AnnotationUtils.getAnnotation(field, DateField.class)) {
fields.add(field);
}
}
dateFields.put(cls, fields.isEmpty() ? Collections.emptyList() : fields);
}
}
}
return dateFields.get(cls);
}
private void dealDateField(MutablePropertyValues mpvs, ServletRequest request, Field field, Map<String, LocalDate> cached) {
DateField annotation = AnnotationUtils.getAnnotation(field, DateField.class);
String format = annotation.format();
String paramName = annotation.name();
if (!StringUtils.hasText(paramName)) {
paramName = field.getName();
}
String parameter = request.getParameter(paramName);
int[] offsets = annotation.offsets();
int length = offsets.length;
// 未传入值,且无偏移量,则不做处理
if (!StringUtils.hasText(parameter) && length == 0) {
return;
}
// 获取日期对象
LocalDate localDate;
Object property = cached.get(paramName);//尝试从本地缓存中获取已解析过的日期对象
if (property instanceof LocalDate) {
localDate = (LocalDate) property;
} else {
localDate = getLocalDate(format, annotation.allowFormats(), paramName, parameter);
cached.put(paramName, localDate);
}
//计算偏移量 日 月 周 年
localDate = localDate.plusDays(length >= 1 ? offsets[0] : 0)
.plusMonths(length >= 2 ? offsets[1] : 0)
.plusWeeks(length >= 3 ? offsets[2] : 0)
.plusYears(length >= 4 ? offsets[3] : 0);
mpvs.add(field.getName(), localDate.format(DateTimeFormatter.ofPattern(format)));
}
private LocalDate getLocalDate(String format, String[] allowFormats, String paramName, String parameter) {
LocalDate localDate;
if (StringUtils.hasText(parameter)) {// 解析参数值
localDate = parseDate(parameter, format);
if (null == localDate) {
for (String allowFormat : allowFormats) {
localDate = parseDate(parameter, allowFormat);
if (null != localDate) {
break;
}
}
}
if (null == localDate) {//格式不符合要求,抛出异常
//throw new BindException(target, name);
throw new RuntimeException("[param: " + paramName + "][value: " + parameter + "][format: " + format + "] does not matches... ");
}
} else {
localDate = LocalDate.now();
}
return localDate;
}
private LocalDate parseDate(String date, String format) {
try {
return LocalDate.parse(date, DateTimeFormatter.ofPattern(format));
} catch (DateTimeParseException e) {
return null;
}
}
}
说明一下:
- 第4行的
Map
用于缓存Class
与这个Class
中定义的加有DateField
注解的字段列表,以避免多次获取这些元信息,第16-31行的方法getDateFields()
就是获取元信息的实现 - 第8行的
Map
用于缓存当前请求中的参数名以及与这个参数名对应的LocalDate
对象,以避免多次计算相同参数名的日期值,这些缓存是在计算相同名称中的第一个字段时建立起来的,因此日期格式的校验也只和相同名称中的第一个字段上的注解定义有关系 - 第9-13行的循环就是依次处理
Class
及所有父Class
中的所有标有@DateField
注解的属性了。具体逻辑则是委托给方法dealDateField()
去执行 dealDateField()
方法分为四个部分:首先计算参数名(注解中的name()
比Field
的getName()
优先);如果参数名对应的值为null
,并且没有设置偏移量offsets
,就直接返回,相当于该字段的值为null
了;根据参数名获取日期对象并缓存,在此过程中会按照合法的日期格式去解析日期对象;最后就是根据日期对象和注解计算偏移量offset
,并且根据格式format
格式化为日期字符串。
(三)编写用于测试的DateFieldForm和DateFieldController
表单对象:
@Getter
@Setter
@ToString
public class DateFieldForm {
// 默认yyyyMMdd
@DateField
private String date1;
// 允许多种格式
@DateField(format = "yyyy-MM-dd", allowFormats = {"yyyyMMdd", "yyyy/MM/dd"})
private String date2;
// 传入参数的前一天
@DateField(name = "date2", offsets = -1)
private String prevDate;
// 传入参数的上月同一天
@DateField(name = "date2", offsets = {0, -1})
private String sameDateOfPrevMonth;
// 昨天
@DateField(offsets = -1)
private String date3;
// 上月同一天
@DateField(offsets = {0, -1})
private String date4;
}
控制器:
@RestController
public class DateFieldController {
@GetMapping("/dateField")
public DateFieldForm test(DateFieldForm form) {
return form;
}
}
(四)添加测试方法
@Test
public void dateField() throws Exception {
MvcResult result = mvc.perform(MockMvcRequestBuilders.get("/dateField").param("date2", "20190929")).andReturn();
MockHttpServletResponse response = result.getResponse();
Assert.assertEquals(200, response.getStatus());
JSONObject json = new JSONObject(response.getContentAsString());
// 未设置偏移,也未传入参数,为null
Assert.assertEquals("null", json.getString("date1"));
// 传入格式为yyyyMMdd,但接受格式为yyyy-MM-dd
Assert.assertEquals("2019-09-29", json.getString("date2"));
// 传入日期的前一天
Assert.assertEquals("20190928", json.getString("prevDate"));
// 传入日期的上个月同一天
Assert.assertEquals("20190829", json.getString("sameDateOfPrevMonth"));
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");//默认格式
LocalDateTime dateTime = LocalDateTime.now();
String yesterday = dateTime.minusDays(1).format(dateTimeFormatter);
String theDayOfLastMonth = dateTime.minusMonths(1).format(dateTimeFormatter);
// 当前日期前一天
Assert.assertEquals(yesterday, json.getString("date3"));
// 当前日期的上个月同一天
Assert.assertEquals(theDayOfLastMonth, json.getString("date4"));
}
测试通过,现在可以直接在表单对象上添加注解的形式来实现日期的绑定和校验了。
上面没有测试所有可能的情形,这个艰巨的任务就留个读者自己了
敏锐的你,一定发现了,如果要添加新的兼容格式,还是需要修改每个属性上@DateField
里面的allowFormats
,还是得小心翼翼的依赖IDE的批量替换。
纳尼?绕了一圈,你告诉我要小心翼翼?
别急,别急,再借用大牛的话,除了优雅,我们还有很优雅的方式,对,再说三次:很优雅、很优雅、很优雅
四、使用复合注解:你要的很优雅也来了
这次我们在前面的基础上进行一些改进,就不贴所有代码了,有兴趣的朋友就上
github
看,顺便点个star
什么的......
为了更好的将格式与属性隔离,首先添加一个@DateFormat
注解
// 可以直接添加在Field,也可以添加在其它注解上
@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DateFormat {
/**
* 最终接受的格式
*
* @return
*/
String format() default "yyyyMMdd";
/**
* 允许的格式,默认和{@link #format()}相同
*
* @return
*/
String[] allowFormats() default {};
}
第二步,修改一下DateFieldPropertyValuesProvider
,添加对@DateFormat
的处理:
private List<Field> getDateFields(Class<?> cls) {
// ... 省略未修改的代码
if (null != AnnotationUtils.getAnnotation(field, DateField.class)
|| null != AnnotationUtils.getAnnotation(field, DateFormat.class)) {
fields.add(field);
}
// ... 省略未修改的代码
}
private void dealDateField(MutablePropertyValues mpvs, ServletRequest request, Field field, Map<String, LocalDate> cached) {
DateField dateField = AnnotationUtils.getAnnotation(field, DateField.class);
DateFormat dateFormat = AnnotationUtils.getAnnotation(field, DateFormat.class);
String paramName = null != dateField ? dateField.name() : null;
String format = null != dateFormat ? dateFormat.format() : dateField.format();
String[] allowFormats = null != dateFormat ? dateFormat.allowFormats() : dateField.allowFormats();
int[] offsets = null != dateField ? dateField.offsets() : new int[0];
// ... 省略未修改的代码
localDate = getLocalDate(format, allowFormats, paramName, parameter);
// ... 省略未修改的代码
}
第三步,添加一个复合注解(复合注解可以根据业务来定,一种类型的业务定义一个复合注解)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@DateFormat(format = "yyyy-MM-dd", allowFormats = {"yyyyMMdd", "yyyy-MM-dd", "yyyy/MM/dd", "dd/MM/yyyy"})
public @interface NewDateField {
}
好像是什么都没有?不对,仔细看一下,在NewDateField
注解上我们添加了@DateFormat
注解了。
好了,准备工作已经做好,修改一下FormBean
和测试方法:
@Getter
@Setter
@ToString
public class DateFieldForm {
// ... 省略未修改的代码
// 单独使用新注解
@NewDateField
private String newDate;
// 组合使用两个注解
@DateField(name = "newDate")
@NewDateField
private String newDateFormat;
}
测试方法:
@Test
public void dateField() throws Exception {
MvcResult result = mvc.perform(
MockMvcRequestBuilders.get("/dateField")
.param("date2", "20190929")
.param("newDate", "29/09/2019")
).andReturn();
// ... 省略未修改的代码
// 复合注解
Assert.assertEquals("2019-09-29", json.getString("newDate"));
Assert.assertEquals("2019-09-29", json.getString("newDateFormat"));
}
测试通过。现在如果要添加新的兼容格式,只需要修改@NewDateField
这个复合注解就可以了,因为复合注解表示了一种业务类型,所以我们也就可以很好的应对业务变化了。
最后,还有没有哪些可以改进的呢?学无止境,优化也无止境,不但有可以改进的,而且还很多,比如:
- 输入参数不为空,但是也不符合任意一种合法日期格式,这个时候可以抛出
SpringMVC
原生的BindException
,错误提示也要国际化 - 如果时间精确到小时、分钟,又需要怎么去绑定和校验呢?
这些优化,就有赖读者诸君了。
郴江幸自绕郴山,为谁流下潇湘去?
欲将心事付瑶琴,知音少,弦断有谁听?
倩何人,唤取红巾翠袖,揾英雄泪!
零落成泥碾作尘,只有香如故!