SpringMVC日期格式属性自动转成时间戳实现源码分析

背景介绍

SpringMVC搭建的微服务系统,后端数据库对时间类型的存储使用的是Long类型,而前端框架倾向于使用yyyy-MM-dd HH:mm:ss这种标准显示格式,前端JSON格式的请求报文与后台的接口交互都需要进行格式转换,这部分转换功能由后台实现。

使用时我们发现,前端定义的JSON请求,时间格式为yyyy-MM-dd HH:mm:ss,如果后台定义的POJO相应的属性为Long类型,可以自动转换为时间戳,对此非常好奇,框架是如何实现这一功能的?

框架选型、版本及主要功能

  1. spring boot 2.1.6.RELEASE
  2. spring cloud Greenwich.SR3
  3. alibaba fastjson 1.2.60

注意json框架使用的是fastjson

代码演示

为了方便演示,定义一个特别简单的POJO类:

public class DateReq {

    private String dateFormat;
    private Long timestamp;

    // 省略getter/setter/toString方法
}

再定义一个简单的Controller方法:

@RestController
public class DemoController {

    @PostMapping(value = "/json/demo/info")
    public ApiResponse<?> dateJson(@RequestBody DateReq request) {
        System.out.println(request);
    }
}

请求报文如下:

{
    "dateFormat": "2020-08-07 18:50:00",
    "timestamp": "2020-08-07 18:50:00"
}

响应的结果:DateReq{dateFormat='2020-08-07 18:50:00', timestamp=1596797400000}

从结果可以发现,dateFormat字段我们定义的是String类型,timestamp定义的是Long类型,请求报文两个字段使用相同的值,但是到了Controller方法里,timestamp自动变成Long类型的时间戳了,并且是按东8区转换的。

在这里我们可以得到一个使用经验:POJO的时间格式是可以自动转换成Long类型时间戳的,默认时区取操作系统的时区,或者通过jvm参数-Duser.timezone=GMT+08设置。

源码阅读

既然看到了自动转换的效果,非常好奇框架是怎么实现的,我们通过断点查找堆栈:

deserialze:79, LongCodec (com.alibaba.fastjson.serializer)
parseField:85, DefaultFieldDeserializer (com.alibaba.fastjson.parser.deserializer)
parseField:1224, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:850, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseRest:1538, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
deserialze:-1, FastjsonASMDeserializer_3_DateReq (com.alibaba.fastjson.parser.deserializer)
deserialze:284, JavaBeanDeserializer (com.alibaba.fastjson.parser.deserializer)
parseObject:692, DefaultJSONParser (com.alibaba.fastjson.parser)
parseObject:383, JSON (com.alibaba.fastjson)
parseObject:448, JSON (com.alibaba.fastjson)
parseObject:556, JSON (com.alibaba.fastjson)
readType:263, FastJsonHttpMessageConverter (com.alibaba.fastjson.support.spring)
read:237, FastJsonHttpMessageConverter (com.alibaba.fastjson.support.spring)
readWithMessageConverters:204, AbstractMessageConverterMethodArgumentResolver (org.springframework.web.servlet.mvc.method.annotation)
readWithMessageConverters:157, RequestResponseBodyMethodProcessor (org.springframework.web.servlet.mvc.method.annotation)
resolveArgument:130, RequestResponseBodyMethodProcessor (org.springframework.web.servlet.mvc.method.annotation)
resolveArgument:124, HandlerMethodArgumentResolverComposite (org.springframework.web.method.support)

发现了两处有价值的信息:

  1. 触发消息类型转换类是FastJsonHttpMessageConverter
  2. 真正完成类型映射是fastjson框架

有这个思路,阅读源码时可以把重点放在fastjson上,从JSON反序列化为POJO,Long类型字段处理,找到这段代码:

public <T> T deserialze(DefaultJSONParser parser, Type clazz, Object fieldName) {
        JSONLexer lexer = parser.lexer;

        Long longObject;
        try {
            int token = lexer.token();
            if (token == 2) {
                long longValue = lexer.longValue();
                lexer.nextToken(16);
                longObject = longValue;
            } else if (token == 3) {
                BigDecimal number = lexer.decimalValue();
                longObject = TypeUtils.longValue(number);
                lexer.nextToken(16);
            } else {
                if (token == 12) {
                    JSONObject jsonObject = new JSONObject(true);
                    parser.parseObject(jsonObject);
                    longObject = TypeUtils.castToLong(jsonObject);
                } else {
                    Object value = parser.parse();
                    // 关注这一行,yyyy-MM-dd HH:mm:ss会执行这一行代码
                    longObject = TypeUtils.castToLong(value);
                }

                if (longObject == null) {
                    return null;
                }
            }
        } catch (Exception var9) {
            throw new JSONException("parseLong error, field : " + fieldName, var9);
        }

        return clazz == AtomicLong.class ? new AtomicLong(longObject) : longObject;
    }

重点关注longObject = TypeUtils.castToLong(value);yyyy-MM-dd HH:mm:ss格式的数据会执行这一行代码,跟进去查看源码:

public static Long castToLong(Object value) {
        if (value == null) {
            return null;
        } else if (value instanceof BigDecimal) {
            return longValue((BigDecimal)value);
        } else if (value instanceof Number) {
            return ((Number)value).longValue();
        } else {
            if (value instanceof String) {
                String strVal = (String)value;
                if (strVal.length() == 0 || "null".equals(strVal) || "NULL".equals(strVal)) {
                    return null;
                }

                if (strVal.indexOf(44) != 0) {
                    strVal = strVal.replaceAll(",", "");
                }

                try {
                    return Long.parseLong(strVal);
                } catch (NumberFormatException var4) {
                    // 在异常里做最后的挣扎,今天的案例是执行到这里的
                    JSONScanner dateParser = new JSONScanner(strVal);
                    Calendar calendar = null;
                    if (dateParser.scanISO8601DateIfMatch(false)) {
                        calendar = dateParser.getCalendar();
                    }

                    dateParser.close();
                    if (calendar != null) {
                        return calendar.getTimeInMillis();
                    }
                }
            }

            if (value instanceof Map) {
                Map map = (Map)value;
                if (map.size() == 2 && map.containsKey("andIncrement") && map.containsKey("andDecrement")) {
                    Iterator iter = map.values().iterator();
                    iter.next();
                    Object value2 = iter.next();
                    return castToLong(value2);
                }
            }

            throw new JSONException("can not cast to long, value : " + value);
        }
    }

可以看到在castToLong方法里,对假想的数据类型做各种假设处理,很不幸的是我们试验的数据格式,是在NumberFormatException异常里完成的最后挣扎,使用JSONScanner类接收的请求数据。

可以看到在这里通过调用dateParser.scanISO8601DateIfMatch对数据进行解析,得到calendar对象实例,最终通过calendar获取时间戳,scanISO8601DateIfMatch方法逻辑很复杂,总共有450多行,这里截取了其中一部分展现一下:

private boolean scanISO8601DateIfMatch(boolean strict, int rest) {
        if (rest < 8) {
            return false;
        }

        char c0 = charAt(bp);
        char c1 = charAt(bp + 1);
        char c2 = charAt(bp + 2);
        char c3 = charAt(bp + 3);
        char c4 = charAt(bp + 4);
        char c5 = charAt(bp + 5);
        char c6 = charAt(bp + 6);
        char c7 = charAt(bp + 7);

        if ((!strict) && rest > 13) {
            char c_r0 = charAt(bp + rest - 1);
            char c_r1 = charAt(bp + rest - 2);
        }

        char c10;
       

        if (rest < 9) {
            return false;
        }

        char c8 = charAt(bp + 8);
        char c9 = charAt(bp + 9);

        int date_len = 10;
        char y0, y1, y2, y3, M0, M1, d0, d1;
        if ((c4 == '-' && c7 == '-') // cn
                ||  (c4 == '/' && c7 == '/') // tw yyyy/mm/dd
        ) {
            y0 = c0;
            y1 = c1;
            y2 = c2;
            y3 = c3;
            M0 = c5;
            M1 = c6;
            d0 = c8;
            d1 = c9;
        } else if ((c4 == '-' && c6 == '-') // cn yyyy-m-dd
        ) {
            y0 = c0;
            y1 = c1;
            y2 = c2;
            y3 = c3;
            M0 = '0';
            M1 = c5;

            if (c8 == ' ') {
                d0 = '0';
                d1 = c7;
                date_len = 8;
            } else {
                d0 = c7;
                d1 = c8;
                date_len = 9;
            }
        } else if ((c2 == '.' && c5 == '.') // de dd.mm.yyyy
                || (c2 == '-' && c5 == '-') // in dd-mm-yyyy
        ) {
            d0 = c0;
            d1 = c1;
            M0 = c3;
            M1 = c4;
            y0 = c6;
            y1 = c7;
            y2 = c8;
            y3 = c9;
        } else if (c8 == 'T') {
            y0 = c0;
            y1 = c1;
            y2 = c2;
            y3 = c3;
            M0 = c4;
            M1 = c5;
            d0 = c6;
            d1 = c7;
            date_len = 8;
        } else {
            if (c4 == '年' || c4 == '년') {
                y0 = c0;
                y1 = c1;
                y2 = c2;
                y3 = c3;

                if (c7 == '月' || c7 == '월') {
                    M0 = c5;
                    M1 = c6;
                    if (c9 == '日' || c9 == '일') {
                        d0 = '0';
                        d1 = c8;
                    } else if (charAt(bp + 10) == '日' || charAt(bp + 10) == '일'){
                        d0 = c8;
                        d1 = c9;
                        date_len = 11;
                    } else {
                        return false;
                    }
                } else if (c6 == '月' || c6 == '월') {
                    M0 = '0';
                    M1 = c5;
                    if (c8 == '日' || c8 == '일') {
                        d0 = '0';
                        d1 = c7;
                    } else if (c9 == '日' || c9 == '일'){
                        d0 = c7;
                        d1 = c8;
                    } else {
                        return false;
                    }
                } else {
                    return false;
                }
            } else {
                return false;
            }
        }

        if (!checkDate(y0, y1, y2, y3, M0, M1, d0, d1)) {
            return false;
        }

        setCalendar(y0, y1, y2, y3, M0, M1, d0, d1);

        char t = charAt(bp + date_len);
       

        if (charAt(bp + date_len + 3) != ':') {
            return false;
        }
        if (charAt(bp + date_len + 6) != ':') {
            return false;
        }

        char h0 = charAt(bp + date_len + 1);
        char h1 = charAt(bp + date_len + 2);
        char m0 = charAt(bp + date_len + 4);
        char m1 = charAt(bp + date_len + 5);
        char s0 = charAt(bp + date_len + 7);
        char s1 = charAt(bp + date_len + 8);

        if (!checkTime(h0, h1, m0, m1, s0, s1)) {
            return false;
        }

        setTime(h0, h1, m0, m1, s0, s1);

        char dot = charAt(bp + date_len + 9);
        int millisLen = -1; // 有可能没有毫秒区域,没有毫秒区域的时候下一个字符位置有可能是'Z'、'+'、'-'
        int millis = 0;
       
        calendar.set(Calendar.MILLISECOND, millis);

        int timzeZoneLength = 0;
        char timeZoneFlag = charAt(bp + date_len + 10 + millisLen);

        if (timeZoneFlag == ' ') {
            millisLen++;
            timeZoneFlag = charAt(bp + date_len + 10 + millisLen);
        }

        char end = charAt(bp + (date_len + 10 + millisLen + timzeZoneLength));
        if (end != EOI && end != '"') {
            return false;
        }
        ch = charAt(bp += (date_len + 10 + millisLen + timzeZoneLength));

        token = JSONToken.LITERAL_ISO8601_DATE;
        return true;
    }

支持的格式还是挺多,不过基本上符合国内的日期使用习惯,像2020-08-08和2020/08/08,甚至2020年08月08日都行,解析的思路是按位截取判断,然后作为Calendar的参数,上述节选的代码有删节,有兴趣可以查看原代码。

小结

简单做个小结,fastjson在SpringMVC中注册了FastJsonHttpMessageConverter转换器,并且由该转换器驱动fastjson的反序列化能力,对一些常用格式的数据进行自动转换,加快了研发效率。本篇内容从一个好奇心开始,到查阅源码,了解框架内组件的协同,并在源码中证实自己的想法,学习框架内解决问题的思路,希望这份好奇心,能够驱动对框架源码的阅读。

专注Java高并发、分布式架构,更多技术干货分享与心得,请关注公众号:Java架构社区

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,717评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,501评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,311评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,417评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,500评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,538评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,557评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,310评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,759评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,065评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,233评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,909评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,548评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,172评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,420评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,103评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,098评论 2 352