• 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中的查询报表,老板们懒得选日期,你得给出默认值,还得出环比、周同比、月同比、年同比......

二、初步实现:借用第一篇文章中大牛的说法,这是一个很挫的实现

拿到这个需求,怎么实现呢?你可能会说,很简单啊,没有一点难度,只是有一点点繁琐而已,很快,一个ControllerHandlerMethod就出来了:

@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有什么比较常用的套路呢?让我好好想想,好像是:

  1. 定义一个识别注解
  2. 在表单对象中使用注解
  3. 添加新的PropertyValuesProvider实现类,根据请求、表单对象和属性、注解等获取属性值
  4. 编写测试方法

(一)定义@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()FieldgetName()优先);如果参数名对应的值为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,错误提示也要国际化
  • 如果时间精确到小时、分钟,又需要怎么去绑定和校验呢?

这些优化,就有赖读者诸君了。

PropertyValuesProvider在日期绑定和校验中的应用的更多相关文章

  1. (十七)springMvc 对表单提交的日期以及JSON中的日期的参数绑定

    文章目录 前言 `Ajax`提交表单数据 `Ajax`提交`JSON` 格式数据 解决输出JSON乱码的问题 控制JSON输出日期格式 小记 前言 springMVC 提供强大的参数绑定功能,使得我们 ...

  2. 【坑】关于springMvc对JSON日期绑定,得到的日期后面多个时间的问题

    文章目录 前言 Mysql的Date() 后记 前言 当我们翻过 解决springMvc对JSON日期绑定 眼前这座大山以后,发现并没有 IG 的荣光在等着我们,反而有个大坑在等着我们.... 比如博 ...

  3. 背水一战 Windows 10 (21) - 绑定: x:Bind 绑定, x:Bind 绑定之 x:Phase, 使用绑定过程中的一些技巧

    [源码下载] 背水一战 Windows 10 (21) - 绑定: x:Bind 绑定, x:Bind 绑定之 x:Phase, 使用绑定过程中的一些技巧 作者:webabcd 介绍背水一战 Wind ...

  4. 绑定: x:Bind 绑定, x:Bind 绑定之 x:Phase, 使用绑定过程中的一些技巧

    背水一战 Windows 10 之 绑定 x:Bind 绑定 x:Bind 绑定之 x:Phase 使用绑定过程中的一些技巧 示例1.演示 x:Bind 绑定的相关知识点Bind/BindDemo.x ...

  5. Spring MVC 多选框 绑定 Entity 中的 list 属性

    问题描述: 有两个类:Record.java 和 User.java,Record中有个attenders属性,是List<User>类型. 我想绑定Record中的attenders.网 ...

  6. vs 2015 rdlc报表绑定datagridview中的数据

    这几天一直想要实现rdlc报表绑定datagridview中的数据,始终在虚拟表向rdlc报表绑定这一步上出错.今天从下午4点到七点四十一直在尝试.最终还是实现了,最然并不知所以然,这个问题还是以后在 ...

  7. EasyUI 中 DataGrid 控件 列 如何绑定对象中的属性

    EasyUI 中 DataGrid 控件 是我们经常用到的控件之一, 但是 DataGrid 控件 在绑定显示列时却不支持对象属性绑定. 模型如下: public class Manager impl ...

  8. My安卓知识2--使用listview绑定sqlite中的数据

    我想在我的安卓项目中实现一个这样的功能,读取sqlite数据库中的数据并显示到某个页面的listview控件中. 首先,我建立了一个Service类,来实现对数据库的各种操作,然后在这个类中添加对数据 ...

  9. jquery事件函数和原生事件绑定函数中return false的区别

    一直听说jquery中事件函数返回false,相当于调用了event.preventDefault()和event.stopPropagation()两个方法,今天就想看看dom中0级.1级.2级事件 ...

随机推荐

  1. AngularJS实现数据列表的增加、删除和上移下移等功能实例

      转: http://www.jb51.net/article/91991.htm 这篇文章给大家分享了AngularJS循环实现数据列表的增加.删除和上移下移等基础功能,对大家学习AngularJ ...

  2. Solr——Java应用

    Solr有一个客户端SolrJ 创建一个Java Project 引入Jar包 添加test类 package com.solr.test; import java.io.IOException; i ...

  3. ssh客户端命令

  4. React组件:拖拽布局Dragact v0.1.6 发布

    仓库地址:Dragact爽滑的拖拽组件 大家好,新年已经过去,大家又投入了繁忙的工作当中,由于我在国外,因此压根儿没有休息... 少说废话,上周一周的时间里,我陆陆续续的为Dragact组件进行了一系 ...

  5. elementUI + vue 输入框只能输入正整数 不能输入字母 e 以及+ - 号

    <el-input :inline="true" v-model="dialogForm.closeTime" onKeypress="retu ...

  6. Java-ZipUtil工具类

    import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedRead ...

  7. vue路由登录拦截(vue router登录权限控制)

    实现原理: 哪些路由需要验证需要在路由文件router/index.js中指定: { path: '/', component: Home, name: 'Home', iconCls: 'fa fa ...

  8. 交换机配置——跨交换机划分VLAN配置

    一.实验要求:实现跨交换地划分vlan的配置任务,使同一vlan下的主机能相互通讯 二.拓扑图如下; 三.具体实验步骤: S1交换机配置: S1>enable  --进入特权模式S1#confi ...

  9. elastic search&logstash&kibana 学习历程(二)es基础知识

    简介:es的index索引,document文档对象,副本,多节点集群等基础知识 1.通俗的解释: 在Elasticsearch中,文档归属于一种类型(type),而这些类型存在于索引(index)中 ...

  10. 用python进行服务器的监控

    用python进行服务器的监控 在linux服务器中,一切皆为文件,就是说,服务器运行的个中信息,其实是可以从某些文件中查询得到的:百度后,你会知道,在Linux系统中,有一个/proc的虚拟文件系统 ...