YourBatman

YourBatman 查看完整档案

北京编辑长江大学  |  计算机科学与技术 编辑  |  填写所在公司/组织 www.yourbatman.cn 编辑
编辑

分享、成长,拒绝浅尝辄止。公众号:BAT的乌托邦

个人动态

YourBatman 发布了文章 · 2月7日

最好的IDEA debug长文?看完我佛了

你好,我是A哥(YourBatman)。

最近写了几篇IntelliJ IDEA系列的文章,反响蛮好。我想了下,因为并非是分享什么破解方法、推荐插件、主题这种蛋炒饭式哗众取宠的文章,而是真实对工作有帮助,对提高工作效率很有用的内容。同学们对使用IDEA还是有不少痛点,或者姿势不够正确优雅,一直以来A哥坚持写些不随波逐流、有一定深度专栏文章,哪怕只是个工具IDEA而已。

上篇文章 文末做了说明,本计划IDEA系列告一段落,但有收到几条上百字的留言和私信,觉得有些读者确实很用心在看,所以决定宠粉再干几篇。对于IDEA系列,工具嘛,研究原理没有意义,而是站在使用者的角度,介绍正确姿势和最佳实践,用工具提效是唯一目的。

当然,也有私信问我我的主题咋设置的挺好看?用了哪些好用的插件?自定义的插件如何开发?之类的,我认为这种确实没必要单独分享喽,因为用谷歌百度一下就可以找到一推拉,各行大佬写的文章多了去了

本篇介绍IDEA调试debug,因为它确实很重要。会不会debug,有没有debug的意识,懂不懂 debug 的技巧,是有没有入门编程的重要标志。 关于IDEA debug调试的文章我在CSDN里早已发表过,反响还不错(看来有痛点的人不少呀):

今天我就把它“搬过来”,并做“增强”改动分享出来,希望你喜欢。

本文提纲

版本约定

  • IntelliJ IDEA:2020.3.2

小插曲:IDEA刚发布了其2020.3.2这个小版本,启动图换成了20周年图,IntelliJ IDEA 20周岁啦,为期2天的周年庆活动对开发者免费开放,感受一下:

正文

Debug调试对IT从业者不是个陌生概念,工作中经常会用到它,这无关乎于初级、中级、高级程序员。调试程序的方式有多种,如:输出日志、增加辅助变量、拆分函数体、断点调试等等,本文将介绍的是断点调试 -- 一种最行之有效的调试方法。准确的讲,本文讲述是使用IntelliJ IDEA断点调试。

Debug用来追踪代码的运行流程,通常在程序运行过程中出现异常时,启用Debug模式可以分析定位异常发生的位置,以及在运行过程中参数的变化。除此之外,我们也可以使用Debug模式来跟踪代码的运行流程来学习优秀的开源框架。

断点调试有多重要?

俗话说编码5分钟,debug2小时,从这句话就能体现出调试的重要性,毕竟它占据你“大部分”的时间。

为了真实的体现出它的重要性,我“引经据典”,找来了几个资深行业经验的大佬用引用他们的话来表述:

  1. 调试技巧比编码技巧更为重要,因为花费在调试上的时间往往比编码还多,学到的东西比编码中学到的更丰富
  2. 调试技能重要性甚⾄超过学习⼀门语⾔
  3. 不会调试的程序员,肯定编制不出任何好的软件

我把关键词都加粗划重点了,其重要性可见一斑。大佬尚且这么认为,何况是我等?所以,本文好好阅读O(∩_∩)O哈哈~

什么是断点?

突然被这么一问,是不是脑袋懵懵的?

一个天天都在用的“东西”,若是真要你对它下个定义说给别人听,估计一时半会还解释不清。当然喽,大道至简,领会其要义能熟练使用才是硬道理。本文作为一篇“严肃”的技术文章,自然需要先把断点这个概念用文字描述出来。

断点:为了调试而故意让程序暂停的地方。它是一种附加在源代码上面的特殊标记,在debug模式下可以触发特定动作,如暂停执行、打印线程堆栈、计算表达式的值、变量跟踪等等。断点的设置和取消全人为手动管理,若不手动处理(删除)将会和项目一直存在。

如果你看过前两篇文章,一定能解释为何它会一直存在项目里。建议你前往参阅,电梯直达

可见,断点的核心要义是暂停程序,从而在暂停那时刻就可以看到上线文的变量情况、IO情况、线程情况等信息,进而更深入的了解程序,及时发现问题和追踪到错误的根源。

断点参数

断点并不是孤立存在的,它也可以有参数,从而定制出不同的断点行为,让其能在不同条件下生效,这个参数就叫断点参数

我们平时用得比较多的条件断点,它就是断点参数的最典型应用。当然除了条件断点,其它的断点类型也是可以定制化参数的。那到底有哪些断点类型可以使用和定制呢?那么接下来就步入到本文主体内容,开始进入更有意思的部分啦。

断点的基本使用

应该没人不会打断点吧,即使你是产品经理(产品经理莫名躺枪,手动狗头~)。

打断点最简单最直接的方式就是在你想设置断点的哪一行代码的最左边窗栏鼠标左键单击一下,完成后能看到一个小红点,就表示断点设置成功啦,再点击一下就取消。形如这样:

因为我的IDEA界面简洁,尽可能的去掉了“按钮”,所以平时我自己是使用到大量的快捷键来操作IDEA,打断点也是如此经常用快捷键去完成。当然喽,很多时候也用鼠标的啦,毕竟鼠标处理还是有其很大优势的。

说明:我的快捷键是Ctrl + Shift + B,仅供给你参考

管理断点

管理断点包括新增、删除断点。

对于少量断点来讲,鼠标一个个的点击给它删除掉是可以的。但若打了“大量”的断点在代码里(比如看xxx源码的时候),这时让去一个个找来删除是不太现实的,毕竟你可能自己都忘了哪儿有断点。这个时候一个管理页面/窗口就显得格外的重要了,在IDEA中提供了这样的窗口,你有多种方式打开它:

  1. 菜单栏方式:Run -> view breakpoints,缺点是路径太长太麻烦
  2. Actions方式:双击shift调出Actions窗口,输入view breakpoints即可打开
  3. 任意断点处鼠标右键:选择more即可打开管理窗口。缺点是:你至少得找到一个断点作为抓手(当然喽你可以任意处随意打一个点进去也成)
  4. 调试窗口:该打开方式下面会提到
  5. 快捷键方式:毫无疑问,这是我最为推荐的方式喽

在这个管理页面,你可以对断点进行增删改

说明:我的快捷键是Ctrl + Shift + F8,仅供给你参考

如何debug模式运行?

额,这讲得是不是有点过于简单了点。

启动Debug模式运行的方式有多种,比如工具栏的虫子小图标按钮、程序方法入口左键点击、菜单栏、右键菜单栏等等,下面简单演示下:

据我了解,很多同学最常用的方式是点击上方工具栏右上角的虫子图标,因为我“没有”这个图标,所以“教程”中就不演示了。A哥平时99%情况下都是使用快捷键方式启动程序,因为我认为那是最迅速和便捷的(当然不一定适合你)。

此功能我的快捷键分为两大类

  1. 运行右上角当前选中的入口类。它有一组快捷键

    1. Ctrl + Shift + Alt + enter:Run运行
    2. Ctrl + Shift + Alt + \:Debug运行
  2. 因为很多时候需要从新的入口启动程序,做Spring Boot工程开发可能体会不到(入口只有一个),但在做教程、Demo的时候程序入口是经常变化的,所以不可能每次都还人肉去改启动类,效率太低。为此我就新设置了这组快捷键

    1. Ctrl + Shift + Alt + [:Run运行,鼠标焦点所在作为入口
    2. Ctrl + Shift + Alt + ]:Debug运行,鼠标焦点所在入口

另外,若要区分本次是Run运行还是Debug运行,除了看右上角小虫子图标外,更好的方式看底部控制台窗口激活的是哪个。这样看的优点是:即使同一份应用启动多次,也能快速看出来哪些debug哪些run。
Run模式运行

Debug模式运行

值得一提的是:debug模式运行,若没有任何断点被激活(比如你压根就没打断点),效果和run模式启动是一样(但控制台窗口不一样,因此日志输出的位置也就不一样)。

调试窗口详解

调试窗口是我们断点调试的操作面板,熟练的使用此面板推提高效率和掌握更多技巧非常重要。先来认识下它:

此操作面板上按钮不少,对Debug调试有多熟练很大程度上是由操作此面板的熟练度决定的。

调试按钮

最常用的一排按钮,入门必备。

一共9个按钮,从左往右依次解释下:

Show Execution Point:回到当前激活的断点处。效果:若你鼠标现在在别的页面/别的类上面,点击它快速“归位”

Step Over步过:也叫单步调试,一行一行往下走,若这一行是方法也不会进入里面去。这个应该是平时使用得最多的按钮了,没有之一。所以,建议记住你的快捷键来提高效率哈

Step Into步入:进入方法体内部。这里的方法指的你自定义的方法or三方库的方法,不会进入到JDK官方的方法里(如上面的System.out.println()这种它是不会进去的)

Force Step Into强制步入:能进入任何方法,包括JDK的。一般查看底层源码才会用到它

Step Out步出:它是搭配(Force) Step Into一起使用的,当通过step into进入到方法体内部想出来时,一般有两种方案:单步调试慢慢出来,另一个就是step out(推荐)

Drop frame:回到当前方法的调用处,同时上下文内所有的变量的值也回到那个时候。该按钮能够点击的前提条件是:当前所处的方法有上级方法,如果你是main方法里,那么按钮就是灰色喽

Run to Cursor运行到光标处:你想要代码在哪里停一下,就把光标放在哪就成。这个功能实在太好用了,大大缓解了密密麻麻的断点,强烈推荐

Evaluate Expression表达式计算器:看图标就是个计算器嘛,所以你可以在这里执行任何合法的表达式

Trace Current Stream Chain跟踪当前Stream流:只有代码停在Stream流语句上,此图标才点亮可以被点击。这是IDEA提供的由于调试Stream流的杀手锏级别的功能,放在文下详细解释

这一排按钮非常重要,甚至是最重要,一定要熟练掌握,可以大大提高调试代码的效率,亲测有效。

服务按钮

把最左边一竖排定义为服务按钮,为调试过程提供服务。

一共10个,但都比较简单和好理解。同样的从上到下过一遍:

  1. Rerun xxx:关闭当前程序,重新运行
  2. Modify Run Configuration:顾名思义,修改运行的配置。点击此按钮的效果同点击右上角的框框:

点击会弹出这个配置窗口:

每份运行期配置都是具名且唯一的,互相隔离。运行配置可修改的项非常多,大概如下:

说明:我截图的页面可能和你不一样,因为我用的是最新版的IDEA,此页面在2020.3版本做了改版

  1. Resume Program:恢复程序。当断点激活时程序“停止”了,点击这个按钮就是恢复的意思。它给到的效果是:跳到下一个断点(用这句话解释貌似更容易理解些),若后面没有断点就直接运行结束了。这个按钮非常常用
  2. Pause Program:暂停程序。嗯,只要你现在“卡”在断点处,那么状态就是Pause的状态。这时候就有疑问了,难道这个按钮一直是灰色不可点状态?有啥用呢?我网络上看了看,几乎没人能够解释它的作用,这里A哥尝试给你解释下,用张图给你整得明明白白,服服帖帖:

  1. Stop xxx:不解释
  2. View Breakpoints:打开断点管理窗口。文上已详细解释了此窗口的用法
  3. Mute Breakpoints:这个按钮挺有意思的,作用是让所有断点变为灰色,也就是说让它们失效。它是一个批量操作,操作对象是所有断点,而不可针对于某一个。若你现在不想把所有断点删除,但又不想它们阻拦你,那么可用这个按钮实现
  4. Get Thread Dump:拿到当前线程的dump,可以查看到当前线程的状态。如下图:
    main线程sleep了一把

    子线程当前状态为Runnable

  5. Settings:打开设置菜单。属于高级使用,每一项开启后有什么效果,放在文下解释

  1. Pin tab:如果你这会调试xxx这个程序很频繁,那么把它“钉”上会更有助于效率提升

方法调用栈

显示当前方法(位于栈顶)所经过的所有方法。

说明:点击右上角的小漏斗图标可以不显示类库的方法,只显示你自己写的方法,方便调试

变量区Variables

在此区域可以查看当前断点上下文范围内的所有变量值(即使不在本类内也可以点过去查看哦),包括static静态的。

值得注意:此区域里的变量IDEA会自动调用其toString()方法,因此若你遇到正常运行只输出一句日志,debug输出多句这种case很可能就是这个情况哦。

Watches变量跟踪

有的时候变量很多,而只需要重点关注某几个变量,就可以使用Watches。

除了以上这些,还有什么动态改变变量值set Value,跳转到源码处jump to source等都是非常实用的功能,这就留你自己开发和实验哈。

为何调试窗口没自动打开?

有同学遇到过这个情况:明明断点激活了(程序暂停了),但是那个“操作面板”并没有出来,怎么破?

话不多说,检查你的这个配置项是勾选状态即可。这个状态IDEA默认是勾选上的,一般不用操心。

断点调试的奇淫巧技

最后,站在使用层面,介绍些非常实用的“奇淫巧技”给你,这些小技巧可拿来就用。

强制返回(中断debug)

场景描述:调试时,当我走到第三步就发现了问题,这个时候并不希望走完后续流程(比如因为前面有bug后续流程会有删除数据操作等等),这个时候怎么处理?

咔嚓,Stop程序。是的,很长一段时间里我也是这么干的,确实能达到目的。直到我发现了一个更优雅的方法:Force Return,效果为:强制返回方法返回值(自己给个值)来避免后续的流程。

条件断点

指定断点的激活条件,都能称作条件断点。一般情况下,在行断点下给定一个计算表达式,结果为true就激活断点这是最常用的方式。因为上面已有案例,这里省略

多线程调试

多线程程序的好处固然不用多说,但总所周知它调试起来是比较困难的,比如这段:

public static void main(String[] args) {
    // 共放3个"令牌"
    CyclicBarrier cyclicBarrier = new CyclicBarrier(3);

    // 模拟多个线程去抢
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            try {
                String name = Thread.currentThread().getName();

                System.out.println(name + ",准备抢令牌");
                cyclicBarrier.await();
                System.out.println(name + ",已抢到");
            } catch (Exception e) {
            }
        }, "线程" + i).start();

    }

}

这个时候如果你想研究await()方法的实现,需要具备的前提条件是多个线程进入,因此需要hold住多个线程。若只是在await()这一行打个普通的行断点,那结果是这样子的:

所有线程都是Running状态,显示这是不可能的,因为总共只有3个另外,拿完了其它的都得等待才对,所以这个根本就不是真实的执行场景,也就不可能跟踪到await()方法里面去探究其实现。

为了模拟出这种场景进行调试,就对断点阻塞条件设置为这样:

再次运行程序,线程情况如下:

快速计算表达式

都知道调试面板里的Evaluate Expression可以计算表达式/变量的值,但那毕竟还得弹个窗稍显麻烦,其实还有更为方便的方式:

用鼠标操作,效率指数级提升。这个操作方式是:鼠标指针选中表达式(IDEA智能自动选中) + 鼠标左键单击。当然喽,如果你想执行自定义的不存在于代码中的表达式,那必须调起窗口来操作。

Stream流调试

Java 8的流行,彻底让流式编程走进我们的视野。使用Stream编程的好处众多,但一直被大家诟病的是难以阅读和难以调试,特别是后者。

为了调试它,我们经常需要插入其它断点,并分析流中的每个转换,不可为不麻烦。还好IDEA提供了处理该痛点的“能力”:当调试器在Stream API调用链之前或之内停止时,点击Trace Current Stream Chain这个图标即可以“非常好看”的图形化方式展示出来,一目了然:

主动抛出异常

需求场景:你写了一个全局异常组件,现在想测试它生效情况如何,那么时候你就需要主动抛出这种异常,一般的做法是形如这样:

// 自己在程序内主动throw一个
throw new NullPointerException();

// 或者构建个表达式
int i = 1/0;

这种做法均有一定的代码侵入性,用后还得删除。其实IDEA还提供了一种更为优雅的解决方案:


掌握了IDEA断点调试的基本技能,下面进入到本文深水区:断点类型。难度不高,依旧是使用层面的事,但由于很多同学并不知道,因此是你用于超车的好材料。

四大断点类型

对于打断点,估计大部分同学都只会左边鼠标单击这种最基础的方式。所以,看到这个小标题估计你得再懵一次吧。what?断点还有种类?

若你也是只在代码左边鼠标单击打上“小红点”,然后嘎嘎就是干,空中转体720度向后翻腾三周半......一把唆的选手,那么接下来就坐稳喽,准备发车。

这么个姿势也许能帮你定位50%以上的问题,但还有另外一半的case呢?如for循环调试,Stream流调试,lambda调试、异常调试等这些场景,用那“一把唆”的方式就很难搞定甚至说搞不定了。断点是帮我们快速定位问题的,不同的场景打上合适的断点将能事半功倍。

殊不知,IDEA给我们开发者提供了非常的断点类型,以应对不同场景下的调试。在对应的场景下使用合适正确的断点类型,能够大大提高调试的效率,从而别人加班你下班,效率就是时间,而时间就是生命。

如图,IDEA把断点分为四大类型(截图中只有三类):

  1. Line breakpoint(行断点):图中红色小圆圈。顾名思义,在指定代码行设置断点
  2. Field watchpoint(属性断点):图中红色小眼睛。打在类的属性(static or 非static)上的断点,作用是在该属性读取和写入时激活
  3. Method breakpoint(方法断点):图中红色小菱形。标记在方法签名的那一行,在该方法执行的入口/出口处被激活
  4. Exception breakpoint(异常断点):红色小闪电。这是一个特殊但很好用的断点,当程序抛出指定异常时会激活异常断点。和其它断点不同,异常断点是项目全局的,它不需要打在具体某一行上

下面就到了“啃硬骨”的时候了,来吧。

行断点Line breakpoint

使用得最最最广泛的断点类型,平时大部分情况下都使用此种断点。

从“教程”中可以看到该断点有很多的设置项,也就是有很多的断点参数可以配置,来了解下。

断点参数

因为这是第一个介绍断点参数的类型,因此会说得详细些,这样子后面相同功能的参数就不用再赘述了。对照这个截图页:

  • Enabled:不解释。但需注意:若此项不勾选上,小红点并不会消失,而是由实心的变为空心的,当然喽,一般情况下并不会动此项
  • Suspend:众所周知,断点激活时会阻塞程序的继续运行,从而阻塞当前线程。但是当你发现它是个复选框的时候,有没有被诧异到?并且,并且,并且你还可以根本就不勾选它,有何区别:

    • 若不勾选选中:此断点相关活动(如打日志等)依旧正常进行,只是不阻塞进程
    • 若勾选中:

      • All(默认):阻塞该程序内所有线程
      • Thread:只阻塞当前断点所在线程
        不勾选Suspend

        如上图,不勾选Suspend:线程14和线程15正常运行,“畅通无阻”
        勾选Suspend-All

        如上图,勾选Suspend-All:在断点处,所有线程都被阻塞了,统一给我等待。

  如上图,勾选Suspend-Thread:method1的线程被阻塞,但是并不影响另外一个线程调用method2。

试想一下,既然“勾选Suspend-Thread”影响更小,那为何IDEA默认帮你选择All而不是Thread呢?原因是这样子的:调试的目的就是让程序“慢下来”,最好是静止下来方便分析问题。否则,其它线程如果仍旧继续保持执行的话,可能一会这个请求改掉这个数据一会改掉那个数据,增加了不可控性。不确定的增加从而大大增加调试难度和定位问题的难度,所以索性上个“同步锁”来得省心,因此默认选中Suspend-All是合理为之。

说明:很多时候我们需要用本机连接测试环境打断点进行远程调试,若在这个case下强烈建议你使用Thread模式,否则你懂的
  • Condition: 断点被激活的条件。你可以在此处书写表达式,只有表达式返回true时此断点才会被激活

    • 条件断点严格来讲不属于一种断点类型,属于断点参数决定的,很多类型的断点都可加条件

  • Log:它有三个选项,是checkbox哦。也就是说可都选,也可都不选,默认一个都不选

    • Breakpoint hit message:断点激活时输出提示日志
    • Stack trace:断点激活时输出程序调用栈信息
    • Evaluate and log:选择需要输出计算表达式的值。你可选择当前可达的变量,如本例的main函数入参args等
  • remove once hit:断点激活一次后就立马给移除喽,也就是所谓的临时行断点,下面来介绍下它

还有窗口里最右边的这块条件:

见名之意,一系列过滤器:过滤实例、过滤类、过滤调用者等等,一般这些们几乎不会使用(至少我目前是还没用过的),所以就一笔带过。

使用场景

行断点一般配合单步调试一起使用,在看框架源码、定位基础问题等使用得特别多,是最需要掌握的一种断点类型,没得商量。

临时行断点Temporary line breakpoint

它也属于行断点的一种,只是参数不一样而已。由于它比较特殊,所以单摘出来说道说道。创建普通行断点,然后把Remove once hit复选框勾选上即是一个临时行断点,效果如下:

这种断点类型,实际使用场景其实很少。

属性断点Field watchpoint

此类断点是打在属性上的,成员属性和静态属性均可。它不是小红点,而是个红色“小眼睛”。

断点参数

如图,此种断点类型特有个watch参数,两个可选值的含义为:

  • Filed Access:读取此属性时(写入时不管)
  • Filed madification:写入此属性时(读取时不管)

使用场景

当想知道xxx属性的赋值是谁时,由于程序太庞杂没法知道断点打哪儿从哪开始跟踪,这个时候使用属性类型的断点一下子就搞定了,非常的方便。

方法断点Method breakpoint

断点必须打在方法签名的那一行,颜色形状是个红色的小菱形。

断点参数

Watch有三个可选值:

  • Emulated:仿真。作用:提高调试性能,因此默认情况下使用。官方建议:仅在调试远程代码或在没有行号信息的native方法或类中设置断点时,才建议禁用此选项
  • Method entry:进入方法时激活断点
  • Method exit:出去方法时激活断点
若entry和exit都勾选,那在进入之后和出去之前都会激活断点

使用场景

对于此种断点类型,可能你会说没啥卵用。毕竟自己在方法头尾打个行断点就能达到同样效果,没必要单独搞个类型嘛。

其实,它的杀手锏级使用场景是把此种类型断点打在接口方法上,这样子不管哪个实现类方法被调用,都会激活断点,是不是特别给力。

异常断点Exception breakpoint

比较小众,但并不代表不重要。在我理解它比较小众,可能大多数同学不知道如何打一个异常断点,因为它不是鼠标单击就能轻松搞定。

上面介绍了异常断点它是一种全局断点类型,因此并不能在代码处直接单击,而是只能在管理窗口里统一添加:

和其它断点类型相比,至少有如下不一样:

  1. 创建断点只能通过断点管理窗口创建,而不能通过鼠标点击方式
  2. 创建完成后,代码栏处不会有任何显示(没有红色小图标),直到它被激活时才会出现红色小闪电
  3. 异常断点作用于全局:本例中任何地方抛出了NullPointException都会激活此断点

断点参数

Notification有两个可选值:

  • Catch excetion:只有当你自己try-catch了这个异常才会激活断点
  • Uncatch excetion:只有当你自己不try-catch时才会激活断点

默认情况下这两个都会被勾选上,也就是说任何情况下发生此异常,都会激活断点。

使用场景

知晓了异常断点的作用和触发条件,使用场景就有啦。比如当你的程序抛出了一个异常,但是一时半会你并不知道是哪行代码引起的,这个时候通过增加异常断点的方式可以实现迅速的问题定位。

4种断点图标对比

每种断点类型都有自己对应的图标,且有不同的状态。我从官网趴了一张对比图,总结得特别好,在这里一并分享给你:

远程调试(远程Debug)

现在大都是微服务架构方式,每个微服务一般会有N多个上/下游依赖,如此以至于给调试带来了很大困难,毕竟你几乎不可能在本地同时把依赖都启起来用IDEA做调试。所以,远程调试来了,它是调试分布式系统的一个利器。

远程调试:顾名思义,使用本地IDEA调试远程代码(一般为QA环境,线上环境不可能开启调试端口的)。那么如何开启远程调试呢?

开启步骤

开启远程调试只需要两步即可:

第一步:让远程部署的那个应用支持远程调试,也就是暴露远程调试端口。方式方法为在应用启动时加上对应的JVM参数即可,JDK版本不同参数也不一样

  • JDK 9+:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:${debug_port}
  • JDK 5-8:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=${debug_port}
  • JDK 4:-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=${debug_port}
  • JDK 3-:-Xnoagent -Djava.compiler=NONE -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=${debug_port}

第二步:用IDEA创建一个remote运行配置,填上远程主机的ip + 暴露的调试端口即可。操作路径为:Edit Configurations -> Add New Configuration ->

万事俱备,点击debug运行,控制台里能看到如下字样就证明你链接成功了:

值得注意的是:远程调试时请确保你本地代码和远程代码一模一样,以达到最佳效果。

传统Tomcat如何开启远程调试?

若你是个Spring Boot应用,那么在jar -jar时加上JVM参数即可,那么如果是要使用传统的tomcat方式部署呢?这个时候找到传统tomcat的启动脚本startup.sh

#!/bin/sh
os400=false

...

PRGDIR=`dirname "$PRG"`
EXECUTABLE=catalina.sh

...

exec "$PRGDIR"/"$EXECUTABLE" start "$@"

为了加上咱们的JVM参数,只需要在exec xxx之前添加一个变量值即可(以JDK8为例):

JPDA_OPTS='-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=具体的端口号’
注意:这个key名称必须是JPDA_OPTS。

有好奇心的你可能不禁就要问了:为何加个JPDA_OPTS参数就行了呢?也没见exec xxx使用它呀,其实不然,下面简单解释下,不展开。

exec执行时引用了变量 $EXECUTABLE,它代表的是就是catalina.sh这个文件,该文件里面有大量变量判断脚本,当然包括负责对JPDA_OPTS解释:

#!/bin/sh

cygwin=false
darwin=false
...
if [ "$1" = "jpda" ] ; then
  if [ -z "$JPDA_TRANSPORT" ]; then
    JPDA_TRANSPORT="dt_socket"
  fi
  if [ -z "$JPDA_ADDRESS" ]; then
    JPDA_ADDRESS="localhost:8000"
  fi
  if [ -z "$JPDA_SUSPEND" ]; then
    JPDA_SUSPEND="n"
  fi
  if [ -z "$JPDA_OPTS" ]; then
    JPDA_OPTS="-agentlib:jdwp=transport=$JPDA_TRANSPORT,address=$JPDA_ADDRESS,server=y,suspend=$JPDA_SUSPEND"
  fi
  CATALINA_OPTS="$JPDA_OPTS $CATALINA_OPTS"
  shift
fi
...

关于JVM调试平台JPDA更多知识点,可自行用谷歌百度一下学习学习

嵌入式Tomcat如何开启远程调试?

这不就是Spring Boot应用形式麽?所以,如何开启,不用再废话了吧~

总结

人和动物的最大区别之一是人会使用工具,且善于使用工具。工具被创造出来,使命就是提效的,毕竟我们不可能用记事本去写Java程序吧。

IntelliJ IDEA作为最为流行的JVM平台IDE,我们应该尽可能的去挖掘出它的效用,既然作为集成开发环境,其实很多功能都可以一站式搞定,在一个平台里做很多数据都能打通。比如IDEA的rest接口调试、数据库映射、Shell终端等等,应付平时的开发一般搓搓有余,推荐使用,毕竟软件启得越多电脑越卡不是。

用IDEA和会用IDEA是两个层次,除了代码本身,最常用的开发工具也是值得花番心思的。大道至简,知易行难,知行合一,得到功成!

本文思考题

本文所属专栏:IDEA,后台回复专栏名即可获取全部内容,已被https://www.yourbatman.cn收录。

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 断点能打在类上吗?
  2. IDEA能设置哪几种类型的断点呢?各有什么场景?
  3. 如何用IDEA debug调试测试环境的应用?

推荐阅读

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('私聊A哥:fsx1056342982');
console.log("点个赞吧!");
NSLog(@"关注【BAT的乌托邦】!");
print("私聊A哥:fsx1056342982");
echo("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
printf("私聊A哥:fsx1056342982");
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("私聊A哥:fsx1056342982");
alert("点个赞吧!");

A哥(YourBatman):Spring Framework开源贡献者,Java架构师,领域专家。文章不标题党,不哗众取宠,每篇文章都成系列去系统的攻破一个知识点,每个系列可能是全网最佳/唯一。注重基本功修养,底层基础决定上层建筑。现有IDEA系列、Spring N多系列、Bean Validation系列、日期时间系列......关注免费获取

查看原文

赞 1 收藏 0 评论 0

YourBatman 发布了文章 · 2月2日

玩转IDEA项目结构Project Structure,打Jar包、模块/依赖管理全搞定

你好,我是A哥(YourBatman)。

如何给Module模块单独增加依赖?
如何知道哪些Module模块用了Spring框架,哪些是web工程?
IDEA如何打Jar包?打War包?

熟练的正确使用IntelliJ IDEA,是一个“高手”该有的样子,因为那是你的门面上篇文章 重点介绍了IDEA里最为重要的两个概念:Project项目和Module模块。相信你看完后再也不会把IDEA的Project比作Eclipse的Workspace,并且对IDEA有了一份更深的了解。

本文继续理解IDEA对项目、模块的管理。管理项目是一个IDE的基本功能,但往往最基础的是最重要的更是最容易被忽略的。因此本文是你更好去理解IDEA管理maven结构、gradle结构、Spring Boot项目结构的基础,万丈高楼平地起,它就是这个地基。上层结构再怎么繁繁多变,殊途同归最终都由Project Structure来体现,从而给开发者以几近相同的编码体验。

本文提纲

版本约定

  • IntelliJ IDEA:2020.3.1

正文

Project Structure是一个你开发过程中偶尔/经常会打开,但却很少用心留意的窗口。不同于一般设置窗口,它和项目的紧密度非常的高且有一定理解难度,若设置不当项目可能无法运行甚至无法编码(比如编译报错、jar包找不着等),为此我做件一般人都不愿意做的事,对它进行详解,相信做难事必有所得。

本文基于上文已搭建好的hello项目案例,继续研究其项目结构Project Structure的管理。从结构查看,到修改定制,那么问题来了,如何打开一个Project项目的结构页呢?

如何打开Project Structure?

看似一个简单的操作,里面其实蕴藏着你对IDEA Project和Module的理解,否则势必不知从哪下手。据了解,也许你是多年的程序员,也未必知道从哪下手。

按照一般思维,会鼠标选中hello,然后右键:


但对不起,右键菜单里并无Project Structure选项。Project Structure顾名思义,是针对Project维度的结构视窗,而你鼠标选中的hello只是个module,所以自然弹出的是对此module的操作菜单喽,而非Project的。也许你可能会讲:我点击了Open Module Settings也打开了Project Structure视窗呀,是的效果上你可能是打开了但道理并非如此,而仅仅是因为把它俩放在了一起(同一视窗)而已。

说明:理解IDEA的Project和Module两大概念,是对IDEA进行一切操作的基础。前文已非常详细(可能是全网最全)的介绍了它俩,可花几分钟前往学习。点这里电梯直达

三种打开方式

要打开一个Project的结构展示窗口,至少有如下三种办法,本文都例举给你。

  1. 顶部菜单File -> Project Structure

  1. 点击右上角的快捷按钮

  1. 快捷键方式(推荐)

这是我本人最喜欢的方式,至于快捷键是哪个就看你是如何设定的喽,我的快捷键是ctrl + shift + alt + s。

啰嗦一句:建议你操作IDEA多用快捷键,那会大大提高编码的效率,并且看起来像高手。基本上记住50个左右快捷键就够用了,长期以往成了肌肉记忆后这就是你的核心竞争力之一了

打开hello项目的结构页如下图所示:

解释:为何不需要鼠标选中项目?

对于这个动作,敏感的你是否有发现:打开项目结构并不需要鼠标选中任何东西(快捷键随意使用),也就是说鼠标失焦状态都没问题,何解呢?

回答这个问题并不难,前提是你已经对IDEA的Project概念烂熟于胸。一个Project对应一个视窗,它们是严格1:1的关系。换句话讲,当前视窗就代表着Project,因此操作本视窗顶部菜单栏就肯定是作用在该Project上,又何须专门选中什么呢?再者,Project只是个逻辑概念,你想选都没得选中的,所以把视窗当作它就好。有没有觉得,这和Java中的this关键字调用特别像?

最后,这个问题的答案是:只要鼠标还在IDEA视窗内(该视窗是活跃窗口),那么对Project就永远就是“选中”状态。

Project Structure项目结构剖析

项目结构视窗已打开,那接下来重点来喽。可以看到它左边的“菜单栏”,共分为三个part:

  • Project Settings:项目设置(最重要),本文详解
  • Platform Settings:平台设置,也叫全局设置。用于管理SDK们(如JDK、Kotlin的SDK等)、全局库。

    • 一般来讲,全局的JDK都会配置在此处,比如我因为经常要做多版本尝试,就管理了多个JDK版本

  • Problems:问题。一般项目出现了问题都会在此体现(如依赖不一致问题等等),总之问题数量一致让它是0是最优的

其中Project Settings里面的每个标签页是最常用,最关心的。下面就对它的每个tab页作出解释和使用说明。

Project页情况

此视窗可以看到Project本身的基础信息。如:名称、SDK版本、语言等级等等,比较简单。

对于此页面的元素,多啰嗦几句:

  1. 为何是SDK版本而不是JDK版本?答:因为IntelliJ IDEA是JVM平台IDEA,不仅仅支持Java还有其它语言如Kotlin,所以写成SDK更抽象
  2. 为何指定了SDK还要指定语言等级?答:因为SDK版本并不直接决定语言等级。如你用的JDK 11,但依旧可以把语言等级调为8来进行编译/运行

    1. 这是集成开发环境的优势所在,轻松对多环境进行定制化支持
  3. SDK和语言等级Project都可指定,作为全局默认

    1. 这些配置Module默认集成,但可自行修改自己的。比如module 1使用Java 5编译,module 2使用Java 11编译,这是允许的

Module页情况

Module页可谓是重点中的重点,甚至是最重要。毕竟Module作为实际存在形式,所有的源代码、配置、依赖等都在这里,因此大有可学呀。

值得注意:Tests测试包里面的是可以访问Sources源码的,但反过来不行。

每个模块都能独立管理着自己的依赖,这种关系在模块自己的.iml文件中记录着。

知识点:

  1. Project创建时默认会创建一个同名的Module模块
  2. Module默认沿用Project的SDK、语言等级等设置,当然也可自己指定
  3. 每个Module可自行管理依赖,可以是二方库、三方库......
  4. 本模块的依赖情况默认存储在项目的{moduleName}.iml文件里

新增依赖

既然Module可以自行管理依赖,那么如何给该模块新增依赖呢?

举个例子,现在需要向hello模块增加一个commons-io jar包依赖,可以点击Dependencies标签页左下角的+号,选择Library:

然后选择,如果没有就选择New Libarary...创建一个呗(有就直接用就成):

下面分别演示选择Java和选择From Maven两种不同库的方式:

新建Java依赖库

New Library新建菜单选项中选择Java选项:

这种方式简单的讲:从你本机里选择一个jar(或者一个目录里面包含jar、文档)就成。优点是非常轻便,不依赖网络,缺点是这些jar必须是你本机已实际存在的。

新建Maven依赖库

New Library新建菜单选项中选择From Maven选项:

输入GAV(或者关键字查找)就能定位到jar,此种方式使用起来其实非常方便,毕竟maven非常好用嘛。缺点自然就是一般情况下需要都需要依赖于网络喽,除非你本地仓库已存在对应的jar。


通过这两种方式各执行一次添加新的依赖完成后,再看hello模块的依赖情况,效果如图:

既然依赖变化了,自然而然的也会体现在hello.iml文件里喽,来看看:

依赖添加进来,源代码里就可以正常使用啦:

依赖作用范围

在New Library创建依赖的时候,不管用哪种方式选中后,它都会弹出这个窗口让你选择此依赖的作用范围

  • Module Library:模块级别,只能本模块使用,别的模块看都看不见
  • Project Library(默认选中):项目级别,该项目下所有的模块均能看见和选中使用
  • Global Library:全局级别,任何项目均可看见和使用

在本例中commons-io是模块级别,commons-lang3是项目级别。因此hello-client模块添加依赖时也是能够看到commons-lang3这个依赖的(但看不见commons-io):

Libraries页情况

当某Library是所有/大部分模块都需要的依赖时,就可以上升为Project级别的依赖,抽取到Libraries标签页来统一管理。如图,因为上面步骤创建的commons-lang3是项目级别的,所以也会出现在这里。

至于如何创建/添加Project级别的依赖,这里就不用再赘述了吧,上面【新增依赖】章节已讲得很明白。唯一区别在该页面选好后不用再选择Library的作用范围了(因为就是Project级别的嘛),取而代之的是让你选择作用的模块:

当然喽,你也可以一个都不选(点击cancle),那么该jar只是被创建了,而不作用于任何module模块。

说明:对于一个多模块的Project来讲,建议项目使用的所有Jar都放在这里统一管理,模块要使用时直接按需choose就成,而不需要自己再单独add,方便统一管理

Facets页情况

Facets可理解为用于配置Project项目的框架区,它能看到项目的每个Module模块使用的框架、语言等情况,并且还可以对它们进行配置。

比如Spring框架,如果某个模块使用了它就可以来这里统一配置。优点是你会发现借助IDEA强大的功能它都给你想好了哪些地方可配置,你可以更改,让你实现配置界面化。除了Spring,其它框架如Hibernate也是如此~

目前支持的Facets(语言/框架)类型有:

模块对应的Facets IDEA会自动Detection探测,若没有你也可以手动添加。

为了更形象的描述此tab页的作用,这里搬一个我自己生产项目来看看实际效果:

说明:不同的Facet对应的最右端窗口内容配置项是不一样的。

通过此视窗,可以看到你当前Project项目,哪些模块使用了Spring框架,哪些是web项目,一目了然。它有个非常大的作用就是站在Project的视角对每个模块进行整体把控,比如若你发现有个模块不需要是web项目(并不需要对外提供服务接口),那铁定就是多引包了或者职责不清晰导致的,就可立马针对性解决,消除隐患。

在实际工作中我自己比较频繁的使用这个功能,用于对模块性质的定位,比如如果是普通模块,绝对不允许是web工程,如果不需要依赖Spring绝对不允许成为Spring工程。因为严格控制Jar包依赖、工程性质是应对大型项目的有效手段。

当然喽,Facets还有个作用是让IDEA编译器认识你的模块,比如如果你是个web模块,若没有在Facets里体现出来,那IDEA就不认识你,就无法给你提供web的一些便捷操作了。

Artifacts页情况

IDEA如何打Jar包?如何打War包? 来,上菜~

在Maven大行其道的今天,虽然用IDEA打包很少使用了,但是有些时候它对你本地调试还是蛮有用的,并且对理解maven的打包依旧有效,来,了解一下。

Artifacts这个概念不是特别好理解,artifact是maven里的一个概念,被IDEA借鉴过来。表示某个模块要何种打包形式,如jar、war exploded、war、ear等等。Artifact是一个项目资源的组合体,整合编译后的 java 文件,资源文件等。有不同的整合方式,比如jar、war、war exploded等等,对于一个module而言,有了Artifact就可以部署了,类似于maven的package打包。

说明:war 和 war exploded区别就是后者不压缩,开发时选后者便于实时看到修改文件后的效果

来个栗子,这里演示下将hello模块打包成一个Jar:

配置好后,只需顶部菜单栏Build -> Build Artifacts,就可以打出这个Jar包:

执行完此命令后,在Output Directory里就能看到hello.jar这个打包好的文件啦。然后java -jar .\hello.jar就能运行喽(因为咱们打的是可执行Jar包)。关于使用IDEA打包还包括打可执行jar包、Fatjar、包外引用jar包等等,这里就不展开了,后面会放在单独文章里把各种方式汇总在一起聊聊。

总的来说,无论配置Facets还是Artifacts,都是Intellij IDEA要求我们来做的(虽然有些可自动识别),目的是以便其能识别这些文件并整合各插件实现功能(如自动化配置、自动打包),一切为了编码体验和编码效率。

模块如何依赖其它Module

一个中大型项目一般有多个模块,它们各司其职。模块与模块之间一般都存在依赖关系,比如常见的xxx-core模块一般会被其它几乎所有模块所依赖。模块依赖外部库Library知道怎么搞了,那么如何增加本项目的模块依赖呢?

其实道理和步骤基本一样,比如hello-core模块里有个Person类:

hello-service模块也需要用到Person类及其功能,那么就需要把hello-core模块依赖进来,操作步骤如下:

添加Dependency依赖时,请选择Module Dependency...选项:

选择本项目中需要依赖进来的模块:

选中hello-core模块把它依赖到hello-service里来:

点击ok,搞定了。对应的,此依赖关系也会体现在hello-service.iml这个配置文件上:

如此,我们就可以在hello-service模块里正常使用Person类啦:

public static void main(String[] args) {
    System.out.println(new Person());
}

完美。

总结

本文对IntelliJ IDEA的项目结构Project Structure的每个tab页进行了全面分析,据我短浅的目光所及,可能是全网独一份写这个内容的。很多同学觉得IntelliJ IDEA不需要专门的学习分析,会用它导入maven项目,跑跑main函数启动下Spring Boot就成啦,我却不以为然。

衡量一个新手和一个高手的差异不是顺风顺水时,而是遇到问题时谁能够快速解决,谁又只能望洋兴叹,相信薪资的差异也体现在此。我见过的“高手”对自己最常用的工具用得都是很666的,这不正是技术范该有的样子麽?说到底,我们不可能认为用一指禅敲代码的人会是大牛嘛~

好啦,关于IDEA的话题暂且先聊到这。其实我想到的主题还有好几个,如:

  • IDEA如何主动去识别导入不能被自动识别的Maven项目?原理是什么呢?
  • IDEA如何打可执行Jar包?又如何打FatJar?如何打 包外Jar包(散包) 呢?
  • IDEA如何巧用其最新的Http Client脚本能力,结合对Controller的嗅探快速完成本地测试?
  • ......

有你pick的吗?欢迎留言告诉我,需求多就尽快上号,不然这个专题就暂时告一段落啦,把时间继续花在其它专题上啦。

本文思考题

本文所属专栏:IDEA,后台回复专栏名即可获取全部内容。本文已被https://www.yourbatman.cn收录。

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. Module模块如何单独设置JDK版本?
  2. IDEA如何打jar包?
  3. 开个脑洞:Maven用pom管理项目结构,IDEA是如何识别它的呢?

推荐阅读

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('私聊A哥:fsx1056342982');
console.log("点个赞吧!");
NSLog(@"关注【BAT的乌托邦】!");
print("私聊A哥:fsx1056342982");
echo("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
printf("私聊A哥:fsx1056342982");
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("私聊A哥:fsx1056342982");
alert("点个赞吧!");

A哥(YourBatman):Spring Framework开源贡献者,Java架构师,领域专家。文章不标题党,不哗众取宠,每篇文章都成系列去系统的攻破一个知识点,每个系列可能是全网最佳/唯一。注重基本功修养,底层基础决定上层建筑。现有IDEA系列、Spring N多系列、Bean Validation系列、日期时间系列......关注免费获取

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 2月1日

谁再把IDEA的Project比作Eclipse的Workspace,我就跟谁急

你好,我是A哥(YourBatman)。

有一个观点:若一个Java开发者能把IDEA玩得666,则技术一定不会差;但若玩不转IDEA(如不会设置、定制、解决日常问题、快捷键等等),那大概率水平很一般。因为高手一般得有高手的样子,你同意这个观点吗?

通过上篇文章 你也了解到,现今的Javaer绝大部分都使用IntelliJ IDEA作为IDE进行开发,但同时发现(从身边同事调查)大部分同学都并不能很好的使用IDEA,其中表现最为突出的是IDEA里的Project和Module两个概念,混淆不清或者概念完全扭曲。

A哥是一个相对来讲很注重基础知识搭建的Javaer,所以对于最常用的工具也是如此,愿意花些时间去搞明白,包括页布局、功能定制、插件、以及快捷键都会调为自己最顺手的状态,毕竟工欲善其事,必先利其器

本文将着眼于帮你深入的介绍IntelliJ IDEA里最重要的两个概念:Project和Module,它是最最最基础也是最重要的,我认为本文不仅适合使用IDEA的萌新,同样适合使用IDEA的“老手”(曾经eclipse的重度用户尤甚)。

本文提纲

版本约定

  • IntelliJ IDEA:2020.3.1

正文

IntelliJ IDEA相较于Eclipse可谓是后起之秀,2006年开始崭露头角,2012年整体性能上完败Eclipse,2016年市场份额完成全面超越,一步步的逐渐成为JVM平台的主流IDE。

正是由于有这样的历史进程,有大批“老”程序员是从Eclipse过度到IDEA来的,因此就有了一个颇具代表性的概念对比表格,方便“迁移”:

诚然,IntelliJ IDEA的使用成本比eclipse略高,在那样的历史背景下,这张表格确实降低了“老”程序员们的迁移过度成本,即使现在看来这张表格的描述并不准确,设置具有极大的误导作用(副作用开始展现......)。

IDEA和eclipse的概念类比上,最“著名”的当属把IDEA的Project比作Eclipse的Workspace,回忆下你当初是不是经常听到这样的声音?博客文章这样说、培训机构老师这样说、甚至大学的老师也是教你这么去理解的。更有甚者,对于很多“中毒”很深的、曾经的eclipse用户来说,他们是这样使用IDEA的:

实现了所谓的:IDEA在同一窗口显示多个项目。若你发现你身边有这么样管理项目的同事,那么他是你的“前辈”没跑了,因为铁定是eclipse的资深用户,然后迁移到IDEA来。

这种做法是错误的,毫不相干的项目(远程调用不叫有关系)没有理由放在同一视窗内,除了干扰还是干扰。Eclipse里有workspace工作空间的概念尚可理解,可IDEA里是绝对不要这么做。

在 IntelliJ IDEA 中,没有类似于 Eclipse 工作空间(Workspace)的概念,而是提出了Project和Module这两个概念。本文来告诉你,IntelliJ IDEA是如何管理项目Project、模块Module以及它俩关系,看完之后你会发现单这一点IntelliJ IDEA就比Eclipse优秀得多。

Project和Module概念

什么是Project

Eclipse中一个Workspace可以包括多个Project,而在IDEA里Project是顶级概念

Project(翻译为:项目)IntelliJ IDEA的顶级组织单元,它是个逻辑概念。一般来说一个Project代表一个完整的解决方案,如它可包含多个部分,如:

  1. 源代码
  2. 构建脚本
  3. 配置文件
  4. 文档
  5. SDK
  6. 依赖库
  7. ...

也就是说Project是个完整体,是个资源的集合,扔到任何地方都是可以被解释的。

说明:建议把Project翻译为项目,而非工程,不在一个维度。因为一个module其实也可以理解为一个工程,避免混淆

什么是Module

模块是是项目Project的一部分,必须隶属于Project而存在。它可以独立编译、测试、运行甚至部署。

模块是分而治之思想的体现,是降低大型项目复杂度的一种有效手段。模块是可重用的,若需要,一个模块可以被多个模块引用,甚至多个Project项目引用(比如commons模块)。


此处强烈不再建议你把Eclipse的Workspace引入进来做类比,那只会把你带跑偏了。细品这两个概念定义,总结一下:

  1. 在IDEA中,Project项目是最顶级的结构单元,一个IDEA视窗有且只能代表一个Project

    1. 现在知道为何把user、account、order扔到一个视窗里有多么的不合适了吧
  2. 一个Project由一个or多个Module模块组成,对于大型项目来讲一般会有N多个module组成,如dubbo项目结构如下图所示:

  1. 一个module模块里,依旧可以有子模块,曾经可无限延伸(但不建议太多)
  2. Project是个逻辑概念,Module才是最终的存在形式

错误使用

优点:

  1. 一个窗口,能看见全貌

弊端:

  1. 视窗功能不单一。account、order、user属于不同项目,是为了解决不同问题而存在,没有理由放在一起
  2. 干扰性太强。比如他们三都有类叫ProcessService,那么在你查找的时候永远无法“精确定位”

  1. 额外性能开销。比如你只想开发user,但还得把其它的加载进来,完全没有必要嘛。

    1. 说明:idea不能像eclipse一样close project的,毕竟人家那是workspace的概念,而idea同一视窗属于同一项目,总不能说关闭某个模块吧,模块一般相关性很强,完全没必要单独开/关
    2. 想一想,若你一个人负责了20+个项目,每次打开是不是得花上个几分钟呢?
  2. 概念上混乱。这么放在一起,其实就不是user项目、order项目了,而是user模块、order模块,很明显概念上就不准确了

正确使用

这种使用方式界面清爽,运行流畅,解决了上面错误方式的所有弊端。

新建项目Project

万丈高楼平地起,使用IDEA的第一步一定是新建一个项目Project:

或者你也可以在视窗内部新建,顶部菜单栏File -> New -> 三选一

三种创建方式:

  1. 创建一个全新项目
  2. 打开现有项目
  3. 从VCS版本控制系统里clone一个项目

本文就以1为例,因为2和3从本质上讲都叫打开项目,并不会经历创建流程。下面我们按步骤走一篇创建流程:

第一步:选择创建项目的方式,本文选择创建创建Java项目

第二步:选择根据模版创建项目。这个在maven还没出现之前挺有用,现在几乎不用了,因此一般都不勾选

第三步:填写项目名、项目位置(以及同步创建的模块名、位置等,可选)

  • :项目存储位置,一般作为整个项目的根目录
  • :内容根目录
  • :模块文件存放的目录
  • :项目格式文件(IDEA负责识别,后面它还会出镜)

More Setttings选项默认是收起状态,也就是说大多数情况下创建时你并不需要修改同步创建的模块的这些信息,而实际上也确实是这么干的。

点击Finish,IDEA 100%就会在新窗口(或者覆盖本窗口)打开新创建的这个项目:

该项目在硬盘里的表现形式仅仅是一个文件目录而已:

.idea文件夹的作用

每个Project项目都对应1个 .idea文件夹(隐藏目录),该项目所有特定设置都存储在该.idea文件夹下,比如项目模块信息、依赖信息等等。

一般来讲它里面会有这些文件/目录:

  • misc.xml:描述该项目一些混杂信息,如SDK、语言等级、项目输出的目录等等
  • modules.xml:描述该项目有哪些Module模块
  • workspace.xml:描述视窗的信息。如Project窗口在左边还是右边,窗体大小,颜色,是否隐藏,滚动情况等等(每个Project都允许你个性化配置,规则都被记录在这个文件里)
  • vcs.xml:使用的VCS工具信息,如Git

除了这些,一些插件也经常会往这个目录增加文件,如:

  • saveactions_settings.xml:saveaction插件的专属配置文件
  • jarRepositories.xml:远程仓库配置文件
  • encodings.xml:描述模块文件夹编码信息的配置文件
  • compiler.xml:描述每个module模块使用的编译器信息的文件。如使用1.8编译,是否加了编译参数-parameters等等都在这里体现

总的来讲,这个文件夹里面的东西不用关心,由IDEA/插件自己自动维护,我们只需要界面化操作即可。当然喽,若了解一二对于定位一些常见问题(如不知-parameters是否生效)是有帮助的。

新建模块Module

创建好一个Project默认会有一个同名的的module(Empty Project除外),如果项目比较小复杂度较低,一个模块足矣。但是,稍微有点复杂性的项目一般都希望进行模块拆分,建立多个模块,分而治之。比如:

  • hello-service:实现核心业务功能处理
  • hello-persistence:复杂持久化工作
  • hello-client:作为客户端暴露出去

第一步:顶部菜单栏给该项目创建模块

当然还有一种方式是在Project Structure里创建(这个咱们下篇文章再聊):

第二步:选择该模块类型,可以是Java项目、maven项目、Kotlin项目等等都行

第三步:给模块命名,并制定该module模块的存在位置。一般来讲只需要写名称即可,模块的路径默认会放在project目录的子目录

关于目录选择再强调一遍:默认情况下模块路径会在Project(或者父模块)的子目录下,但这并不是必须的,你也可以改为和Project的同级目录也是可以的,逻辑上依旧属于Project的模块,不会有问题。但一般建议保持这种层级关系而不要修改~

若是父子目录,层级关系更明显些,否则是一种plat平铺目录关系,看着会不太“舒服”

点击Finish,在Project视窗就可以看见该模块啦(层级结构展示哦):

这个时候的Project - Module层级结构图是这样子的:

这时我就抛出一个问题,若要实现下图这种层次结构(plat全部平级),新建模块时需要注意些什么呢?


模块创建好后,这时再看看.idea这个文件夹里的modules.xml,内容为:

xxx.iml文件的作用

每个Module模块都对应一个同名的 .iml文件,用于描述该模块的相关信息。如:SDK、语言等级、依赖、源代码所在位置、输出路径等等。

总结

本文主题是介绍IDEA的Project和Module两个重要概念,然后再通过具体示例的方式加深理解,讲的还是比较清楚的(可能是全网最清楚的?),希望可以帮助到你加深对IDEA的理解,再也不要把IDEA的Project比作Eclipse的Workspace

简单总结一下本文内容:

  • Project是一个不具备任何编码设置、构建等开发功能的概念,主要作用就是起到一个项目定义、范围约束的效果(比如user项目,里面所有内容应该是为了解决user问题而存在的),你也可以理解它就是一个目录,然后这个目录的名字就代表项目名
  • Module模块是代码的实际表现形式。在默认情况下,一个Project对应一个Module,它俩“合二为一”,对于中大型项目来说,一般都会使用多模块编程

下篇预告:在IDEA中,对项目结构Project Structure的设置尤为重要,下篇就为你剖析该页面每个tab选项,到底如何玩转它,具备一个高手的样子,这对你理解Maven项目也将非常非常有帮助,敬请关注

本文思考题

本文所属专栏:IDEA,后台回复专栏名即可获取全部内容,已被https://www.yourbatman.cn收录。

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. IDEA的Project和eclipse的workspace的本质区别在哪里?
  2. 如何给Project/module单独添加依赖库?
  3. IDEA模块的.iml文件有什么作用?

推荐阅读

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('私聊A哥:fsx1056342982');
console.log("点个赞吧!");
NSLog(@"关注【BAT的乌托邦】!");
print("私聊A哥:fsx1056342982");
echo("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
printf("私聊A哥:fsx1056342982");
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("私聊A哥:fsx1056342982");
alert("点个赞吧!");

作者简介:A哥(YourBatman),Spring Framework/Boot开源贡献者,Java架构师,领域专家,DDD驱动设计。非常注重基本功修养,底层基础决定上层建筑,才能焕发程序员更强生命力。擅长结构化拆解专题,抽丝剥茧颇具深度。这些专题也许可能大概是全网最好或独一份哦,欢迎自取。

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月28日

IntelliJ IDEA 20周岁啦,为期2天的周年庆活动对开发者免费开放

你好,我是A哥(YourBatman)。题记:A哥2015年入行,2017年9月首次使用IntelliJ IDEA,从最初的排斥抵触,到现在爱不释手。一晃IntelliJ IDEA 20岁了,祝福它越来越好。近一年我分享了好几篇关于IDEA的深度剖析文章,在文首就提前分享给你:

2001年,IntelliJ IDEA诞生,使得开发更富有成效和愉快的体验;2021年,IntelliJ IDEA已受到全球400多万开发者的喜爱和使用!

IntelliJ IDEA是JetBrains公司最为重要的产品,没有之一。回顾20周年,在其创立之初,JetBrains只是布拉格(捷克的首都)一家普通得不能再普通的小公司。起初只有3名员工,做着一款产品为800个客户提供服务。经过20年的持续发展,该公司已然成为一个跨国公司,办事处遍布全球9个国家之多,员工人数也超过1200名,开发者工具达到25款之多,用户规模更是突破800万。

说明:JetBrains产品面向的是开发者/程序员,并非普通C端用户,因此这个数字是非常惊人的

今年(2021年)是IntelliJ IDEA的20周年,为此JetBrains公司计划搞一个盛大的周年庆活动,管它叫IntelliJ IDEA Conf,所有开发者均可免费报名参与,公司创始人Maxim Shafirov会“亲临现场”哦。会议时间、具体日程下待会再做成列。

本文提纲

版本约定

  • IntelliJ IDEA:2020.3.1

正文

IntelliJ IDEA风风雨雨走过20年,取得了众多骄人成绩。站在这个时间节点上,我们先一同来领略下其风采。透过冰冷的数字,我们能体会到IntelliJ IDEA工程师们的骄傲和自豪。

IntelliJ IDEA成绩单

在刚刚过去的2020年整年,全球共400万+开发者在使用IntelliJ IDEA,共被下载了超过1千200万次。

IntelliJ IDEA是全球性的国际化的,它的使用者坐落在世界的各个角落。从官方统计出的这张图能看到各州分部:

从地图上也能看到,亚洲使用IntelliJ IDEA的人是最多的。毕竟,你懂的中国在亚洲嘛(还有印度),人数总量这一块从来都是拿捏得死死的。

截止2021年1月,亚洲总人口45亿,全球77亿,占比60%左右

使用人数国家Top 5

第一有悬念吗?答,没有。毕竟对人数这块中国还没怕过谁~

第一名:中国

第二名:美国

第三名:印度

第四名:德国

第五名:俄罗斯

说明:统计数据来自2020整年

特性使用次数Top 5

在过去20年里,IntelliJ IDEA共发布了40+个主要版本,效率惊人。发布N多特性,其中最被开发者常用的五大特性为:

  • 第一名:代码补全
  • 第二名:显示意图操作
  • 第三名:运行
  • 第四名:跳转到声明地方
  • 第五名:切换行断点
说明:统计数据来自2020整年

IntelliJ平台产品汇总

IntelliJ是个平台,而IntelliJ IDEA是其基础。还有很多的商业/非商业产品,众多成熟的IDE和插件,放一张让人骄傲的产品图:

依托于IntelliJ这么优秀的基因,提供了强大底座,使得每款产品都非常的优秀,而且体验上还能保持高度一致。

IntelliJ IDEA市场份额

IntelliJ IDEA主要面向Java开发者,而Java语言的IDE主要有三座大山:

  • Apache NetBeans
  • Eclipse
  • IntelliJ IDEA

他们三在不同的时间阶段都曾统治过“江山”。IntelliJ IDEA作为后起之秀,站在今天的这个时刻(2021年),它已成为绝对的霸主。下面附图是JVM生态报告2020年版(IDE篇):

2020年IntelliJ IDEA的市场份额达到62%之多,远超第二名的Eclipse的20%。值得一提的是,鼎鼎大名的Eclipse在2019年份额还有38%呢,2020年就下降到了20%,降幅可谓巨大。第三名的NetBeans存在感越来越弱,被彻底淘汰只是时间问题。

说明:Eclipse的“变种”,如Spring的STS、MyEclipse等都被统计到Eclipse的份额内

IntelliJ IDEA完成超越有两个重要的时间节点:

  • 2012年:IDEA的综合表现已全面超过Eclipse,这为开发者开始倒戈提供了条件
  • 2016年:IntelliJ IDEA市场份额首次超过Eclipse,从此开启快速蚕食其剩余市场份额之路

2016年IDEA占比46%,Eclipse为41%

IntelliJ IDEA对比Eclipse

关于IntelliJ IDEA和Eclipse谁更好的问题,众说纷纭,这种问题永远没有答案,毕竟极客还认为用vim写代码它才是最好的编辑器/IDE呢,这个因人而异,我认为展开讨论并无意义。

但是,从大的方面来说,市场份额这个数据说明一切,选择一个工具时,择“第一名”总是最好,毕竟市场的选择总是明智的。倘若你没把自己定义为非常特殊,做这种选择往往是最安全的。

众所周知,IntelliJ IDEA的一大特点是吃内存。有的人说IDEA是伴随着硬件性能的快速发展而起来的,这个观点我个人也非常同意,可谓时势造英雄,最大化榨出硬件性能本就是一项重要能力。在现在标配至少8G的内存下,还在纠结IDEA吃掉1-2G的内存吗?你不给IDEA用准备给谁呢?让它闲着抽烟呢?

A哥自己严格的讲从来没有使用过Eclipse,而是用过2年的STS,然后倒戈IntelliJ IDEA再也回不去。我若要推荐IDEA用一句话推荐语:IntelliJ IDEA它真懂你,想你所想。这就像谈朋友,无需多言便可知晓对方意思,是一种多么好的体验。

要说IntelliJ IDEA的唯一“缺点”就是收费,而且还不便宜。商业收费在市场占有率方面竟然干过了开源免费产品,这在行业内都是极其少见的,IntelliJ IDEA这个公司可不简单。

IntelliJ IDEA 20年旅程回顾

20年沧海桑田,浮光掠影,最后我们打起精神,一起回顾该跨产品这段不可思议的旅程吧。

2001 - 2006

  • 2001年:1月,IntelliJ IDEA第一个版本发布。同年年底发布了其2.5版本,集成了Ant和Junit等重磅能力

  • 2002年:6月发布2.6版本,支持当时最新的JDK 1.4。11月3.0版本正式发布,引入了有“救命稻草”美称的本地历史功能,其提升了重构能力

  • 2003和2004年:2004年2月,4.0版本重磅发布。为IDEA的炫基因奠定了基础。同年7月发布4.5版本,宣布支持划时代的JDK 5版本

  • 2005年:8月,发布IntelliJ IDEA 5.0版本,从此版本开始,IDEA宣布正式支持Web开发(HTML、CSS、JavaScript),有欢迎屏幕了,并且支持将JBuilder/Eclipse 项目快速导入

  • 2006年:2月发布 IntelliJ IDEA 5.1,提供业界最佳 i18n 支持。3月推出博客。10月,JetBrains 宣布推出 IntelliJ IDEA 6.0(本年度第二个版本,从此版本号和年份对齐了),改进了 Java EE 开发,增强了 Swing GUI 设计器、代码覆盖和 JUnit 4 集成

2007 - 2012

  • 2007年:10月JetBrains 发布 IntelliJ IDEA 7.0,提供 Spring 和 Hibernate 全面支持、Web 服务、Maven 和 ClearCase 集成、Ruby 和 Groovy 支持以及其他提供生产效率的功能

  • 2008年:11月发布IntelliJ IDEA 8,引入 SQL 支持、UML 类图、JBoss Seam、FreeMarker、Velocity 等

  • 2009年:10月JetBrains公司推出了IntelliJ IDEA的免费开源版本 - IntelliJ IDEA Community。12月发布的IntelliJ IDEA 9带来广泛的 Java EE 6 支持、出色的 PHP 支持等

  • 2010年:IntelliJ IDEA开启新的10年发展计划发布 IntelliJ IDEA 10,集成数据库表编辑器,支持 Maven 3,增加了新的依赖图和新的 POM 重构等

  • 2011年:10月,正在在GitHub上托管 IntelliJ IDEA Community Edition 和 IntelliJ 平台的源代码。12月发布的IntelliJ IDEA 11版本引入Gradle 集成,支持 Play 框架的 Web 开发等

  • 2012年:2月IntelliJ IDEA 中添加了 Kotlin 支持。12月IntelliJ IDEA 12 深色系的主题为高效开发带来了一股暗黑风

2013 - 2018

  • 2013年:12月发布的IntelliJ IDEA 13 引入了广受赞誉的 Search Everywhere、嵌入式终端等。

  • 2014年:11月发布了 IntelliJ IDEA 14,它为 Java 类添加了一个反编译器,并引入了 Scratch 文件以及其他酷炫功能。12月,Google 宣布推出 Android Studio 1.0,一款基于开源 IntelliJ IDEA 社区版的 Android 应用开源 IDE。

  • 2015年:11月发布了IntelliJ IDEA 15,为调试器配备了一流的 lambda 支持,增加了对 Kotlin 的开箱即用支持。

  • 2016年:3月确定转向每年3个版本的节奏,且版本号以年份进行命名,同时还引入ToolBox App,这一款免费桌面应用程序,可让您轻松安装和更新 IntelliJ IDEA 以及所有其他 JetBrains IDE,然后2016.1版本发布并捆绑了Kotlin1.0环境。7月IntelliJ IDEA 2016.2 更新提供了对 JUnit 5 的支持。11月发布的IntelliJ IDEA 2016.3,包含大量实用 Java 8 重构。

  • 2017年:3月发布的2017.1版本全面支持ava 9、Kotlin 1.1、Go语言等。11月发布的IntelliJ IDEA 2017.3版本为其丰富的集成工具家族增加了新成员:基于编辑器的 HTTP 客户端

  • 2018年:7月2018.2版本发布,增加了对 Java 11 和 MacBook Touch Bar 的支持,引入了 Breakpoint Intention 等。11月2018.2版本发布,支持了Java 12

2019 - 2021

  • 2019年:3月2019.1版本发布,引入了全新主题且可自定义主题。11月2019.3版本发布,大大提升性能和整体质量。12月,面向教学编程的IntelliJ IDEA Edu版本正式亮相

  • 2020年:1月推出Mono字体,一种专为开发者设计的新字体。它后来成为 IntelliJ IDEA 和其他JetBrains IDE的默认字体。4月发布2020.1版本,支持Java 14,可直接从IDEA内部下载JDK。12月发布2020.3版本,对Git的使用改善

  • 2021年:IntelliJ IDEA 20周年,举办盛大虚拟集会活动,介绍IDEA产品细节、惊艳的功能等等

IntelliJ IDEA Conf庆祝活动

活动内容简介:IntelliJ IDEA的缔造者们分享其线路图、惊人的功能以及使用方法介绍,旨在帮助你成为一个更快乐、更高效的开发者
活动时间:2021年2月25日-26日(说明北京时间比捷克布拉格快7小时)
活动费用:免费
参与人员:所有已报名的开发者
报名地址https://www.jetbrains.com/lp/...

对用户说

今天,我们想花一点时间感谢你们,我们了不起的用户。如果没有您和您提供的宝贵反馈,IntelliJ IDEA就不会有今天的成就!

感谢你们的到来,让我们一起庆祝这一里程碑!

总结

冰冻三尺非一日之寒,二十载时光打造出这款非常优秀的IDE,造福全球开发者。20岁已不再年轻,却又风华正茂,让我们一起为它庆祝,为它祝福。

最后不能忘了,依旧要向那些孜孜不倦为IT行业提供优秀工具的人们致敬!!!特别是捷克的那些创造者们,你们创造了非常好的工具提高了生产力,推动了社会产业的进步,功勋卓越!!!

本文思考题

本文所属专栏:IDEA,后台回复专栏名即可获取全部内容。本文已被https://www.yourbatman.cn收录。

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. IntelliJ IDEA是哪一年把代码托管到github开源的?
  2. IntelliJ IDEA什么时候开始提供暗黑主题,尽显B格?
  3. JetBrains的这次20周年庆活动免费吗?如何参与?

推荐阅读

见文首

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('私聊A哥:fsx1056342982');
console.log("点个赞吧!");
NSLog(@"关注【BAT的乌托邦】!");
print("私聊A哥:fsx1056342982");
echo("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
printf("私聊A哥:fsx1056342982");
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("私聊A哥:fsx1056342982");
alert("点个赞吧!");

作者简介:A哥(YourBatman),Spring Framework/Boot开源贡献者,Java架构师,爱分享。非常注重基本功修养,底层基础决定上层建筑,才能焕发程序员更强生命力。非常擅长结构化讲述专题,抽丝剥茧颇具深度。这些专题也许可能大概是全网最好或独一份哦,欢迎自取。

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月27日

一文告诉你Java日期时间API到底有多烂

你好,我是A哥(YourBatman)。

好看的代码,千篇一律!难看的代码,卧槽卧槽~其实没有什么代码是“史上最烂”的,要有也只有“史上更烂”。

日期是商业逻辑计算的一个关键部分,任何企业的程序都需要正确的处理日期时间问题,否则很可能带来事故和损失。为此本系列仅着眼于这一个点就写了好几篇文章,目的是帮助你系统化的搞定所有问题/难题。

平时我们都热衷于吐槽同事的代码有多烂,今天我们就来玩点狠的:吐槽吐槽JDK,看看它的日期时间API设计得到底有多烂。

说明:本文指的日期时间API是Date/Calendar系列,而非Java 8新的API。毕竟一般我们称后者为JSR 310日期时间,请注意区分哈

本文提纲

版本约定

  • JDK:8

正文

诚然,Java的API绝大多数设计得都是非常优秀且成功的,否则Java也不可能成为编程语言界的常青藤,并且还常年霸榜。但是,JDK也有失手的地方,存在设计得非常烂的API,先来了解下。

最烂API投票

谈到对Java API不满意程度的调研,最出名的当属2010年国外一个大佬Tiago Fernandez发起的一个很有意思的投票,投票结果的数据统计图表如下:

对横向标题栏的各个单词解释一下,从左到右依次为:

计算最终得分的公式为:

Score = (I can live with) + (Painful * 2) + (Crappy * 3) + (Hellish * 4)

按照此公式,计算出各API的得分,画成直方图直观的展示出来:

好,排名出来了。从最烂 -> 最好的名次依次为:

  1. EJB 2.x,简直“遥遥领先”
  2. Date/Time/Calendar,今天的猪脚
  3. XML/DOM
  4. AWT/Swing
  5. ...

烂归烂,想一想什么样的烂API对你的产生影响会是最大的呢?答:很常用却很烂的。倘若一个API设计得很烂但你很少用或者几乎不用接触,你也不会对它产生很大厌恶感。打个比方,一堆屎本身很臭,但若你并不需要走到它身旁也就闻不到,自然就不会觉得它有多碍眼了。

回到这个统计结果来,EJB 2.x的API设计得最烂这个结果无可厚非,但站在时间维度的现在(2021年)回头来看,是可以完全忽略它了,毕竟现在的我们绝无可能再接触到它,再烂又有何干呢?

EJB 2.x这个老古董,相信在看文章的绝大部分同学都没见过甚至没听过它吧,A哥2015年入行,一上来Spring 4.x嘎嘎就是干,从未接触过EJB。

说明:这个统计是2010年做的,那会EJB2.x的使用量还比较大,因此上了“榜首”

XML/DOM设计得也不好,但已完全被第三库(如dom4j)取代,后者成为了事实的标准;AWT/Swing是市场的抉择,你用Java开发界面才会用到,否则不会接触,属于正常。

最后再看“屈居”第二名的Date/Time/Calendar日期时间API,它就不得了了。毕竟此API有个很大的特点:哪怕到了现在(2021年)依旧非常常用。所以,它设计得烂带来的实际影响是蛮大的。

下面就来具体了解下它有哪些坑爹的设计和槽点,一起不吐不快。

日期时间API的七宗罪

罪状一:Date同时表示日期和时间

java.util.Date被设计为日期 + 时间的结合体。也就是说如果只需要日期,或者只需要单纯的时间,用Date是做不到的。

@Test
public void test1() {
    System.out.println(new Date());
}

输出:
Fri Jan 22 00:25:06 CST 2021

这就导致语义非常的不清晰,比如说:

/**
 * 是否是假期
 */
private static boolean isHoliday(Date date){
    return  ...;
}

判断某一天是否是假期,只和日期有关,和具体时间没有关系。如果代码这样写语义只能靠注释解释,方法本身无法达到自描述的效果,也无法通过强类型去约束,因此容易出错。

说明:本文所有例子不考虑时区问题,下同

罪状二:坑爹的年月日

@Test
public void test2() {
    Date date = new Date();
    System.out.println("当前日期时间:" + date);
    System.out.println("年份:" + date.getYear());
    System.out.println("月份:" + date.getMonth());
}

输出:
当前日期时间:Fri Jan 22 00:25:16 CST 2021
年份:121
月份:0

what?年份是121年,这什么鬼?月份返回0,这又是什么鬼?

无奈,看看这两个方法的Javadoc:

尼玛,原来 2021 - 1900 = 121是这么来的。那么问题来了,为何是1900这个数字呢?

月份,竟然从0开始,这是学的谁呢?简直打破了我认为的只有index索引值才是从0开始的认知啊,这种做法非常的不符合人类思维有木有。

索引值从0开始就算了,毕竟那是给计算机看的无所谓,但是你这月份主要是给人看的呀

罪状三:Date是可变的

oh my god,也就是说我把一个Date日期时间对象传给你,你竟然还能给我改掉,真是太没安全感可言了。

@Test
public void test() {
    Date currDate = new Date();
    System.out.println("当前日期是①:" + currDate);
    boolean holiday = isHoliday(currDate);
    System.out.println("是否是假期:" + holiday);

    System.out.println("当前日期是②:" + currDate);
}

/**
 * 是否是假期
 */
private static boolean isHoliday(Date date) {
    // 架设等于这一天才是假期,否则不是
    Date holiday = new Date(2021 - 1900, 10 - 1, 1);

    if (date.getTime() == holiday.getTime()) {
        return true;
    } else {
        // 模拟写代码时不注意,使坏
        date.setTime(holiday.getTime());
        return true;
    }
}

输出:
当前日期是①:Fri Jan 22 00:41:59 CST 2021
是否是假期:true
当前日期是②:Fri Oct 01 00:00:00 CST 2021

我就像让你帮我判断下遮天是否是假期,然后你竟然连我的日期都给我改了?过分了啊。这是多么可怕的事,存在重大安全隐患有木有。

针对这种case,一般来说我们函数内部操作的参数只能是副本:要么调用者传进来的就是副本,要么内部自己生成一个副本。

在本利中提高程序健壮性只需在isHoliday首行加入这句代码即可:

private static boolean isHoliday(Date date) {
    date = (Date) date.clone();
    ...
}

再次运行程序,输出:

当前日期是①:Fri Jan 22 00:44:10 CST 2021
是否是假期:true
当前日期是②:Fri Jan 22 00:44:10 CST 2021

bingo。

但是呢,Date作为高频使用的API,并不能要求每个程序员都有这种安全意识,毕竟即使百密也会有一疏。所以说,把Date设计为一个可变的类是非常糟糕的设计。

罪状四:无法理喻的java.sql.Date

来,看看java.util.Date类的继承结构:

它的三个子类均处于java.sql包内。且先不谈这种垮包继承的合理性问题,直接看下面这个使用例子:

@Test
public void test3() {
    // 竟然还没有空构造器
    // java.util.Date date = new java.sql.Date();
    java.util.Date date = new java.sql.Date(System.currentTimeMillis());

    // 按到当前的时分秒
    System.out.println(date.getHours());
    System.out.println(date.getMinutes());
    System.out.println(date.getSeconds());
}

运行程序,暴雷了:

java.lang.IllegalArgumentException
    at java.sql.Date.getHours(Date.java:187)
    at com.yourbatman.formatter.DateTester.test3(DateTester.java:65)
    ...

what?又是一打破认知的结果啊,第一句getHours()就报错啦。走进java.sql.Date的方法源码进去一看,握草重写了父类方法:

还有这么重写父类方法的?还有王法吗?这也算是JDK能干出来的事?赤裸裸的违背里氏替换原则等众多设计原则,子类能力竟然比父类小,使用起来简直让人云里雾里。

java.util.Date的三个子类均位于java.sql包内,他们三是通过Javadoc描述来进行分工的:

  • java.sql.Date:只表示日期
  • java.sql.Time:只表示时间
  • java.sql.Timestamp:表示日期 + 时间

这么一来,似乎可以“理解”java.sql.Date为何重写父类的getHours()方法改为抛出IllegalArgumentException异常了,毕竟它只能表示日期嘛。但是这种通过继承再阉割的实现手法你们接受得了?反正我是不能的~

罪状五:无法处理时区

因为日期时间的特殊性,不同的国家地区在同一时刻显示的日期时间应该是不一样的,但Date做不到,因为它底层代码是这样的:

也就是说它表示的是一个具体时刻(时间戳),这个数值放在全球任何地方都是一模一样的,也就是说new Date()和System.currentTimeMillis()没啥两样。

JDK提供了TimeZone表示时区的概念,但它在Date里并无任何体现,只能使用在格式化器上,这种设计着实让我再一次看不懂了。

罪状六:线程不安全的格式化器

关于Date的格式化,站在架构设计的角度来看,首先不得不吐槽的是Date明明属于java.util包,那么它的格式化器DateFormat为毛却跑到java.text里去了呢?这种依赖管理的什么鬼?是不是有点太过于随意了呢?

另外,JDK提供了一个DateFormat的子类实现SimpleDateFormat专门用于格式化日期时间。但是它却被设计为了线程不安全的,一个定位为模版组件的API竟然被设计为线程不安全的类,实属瞎整。

就因为这个坑的存在,让多少初中级工程师泪洒职场,算了说多了都是泪。另外,因为线程不安全问题并非必现问题,因此在黑盒/白盒测试、功能测试阶段都可能测不出来,留下潜在风险。

这就是“灵异事件”:测试环境测试得好好的,为何到线上就出问题了呢?

罪状七:Calendar难当大任

从JDK 1.1 开始,Java日期时间API似乎进步了些,引入了Calendar类,并且对职责进行了划分:

  • Calendar类:日期和时间字段之间转换
  • DateFormat类:格式化和解析字符串
  • Date类:用来承载日期和时间

有了Calendar后,原有Date中的大部分方法均标记为废弃,交由Calendar代替。

Date终于单纯了些:只需要展示日期时间而无需再顾及年月日操作、格式化操作等等了。值得注意的是,这些方法只是被标记为过期,并未删除。即便如此,请在实际开发中也一定不要使用它们。

引入了一个Calendar似乎分离了职责,但Calendar难当大任,设计上依旧存在很多问题。

@Test
public void test4() {
    Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
    calendar.set(2021, 10, 1); // -> 依旧是可变的

    System.out.println(calendar.get(Calendar.YEAR));
    System.out.println(calendar.get(Calendar.MONTH));
    System.out.println(calendar.get(Calendar.DAY_OF_MONTH));
}

输出:
2021
10
1

年月日的处理上似乎可以接受没有问题了。从结果中可以发现,Calendar年份的传值不用再减去1900了,这和Date是不一样的,不知道这种行为不一致会不会让有些人抓狂。

说明:Calendar相关的API是由IBM捐过来的,所以和Date不一样貌似也“情有可原”

另外,还有个重点是Calendar依旧是可变的,所以存在不安全因素,参与计算改变值时请使用其副本变量。

总的来说,Calendar在Date的基础上做了改善,但仅限于修修补补,并未从根本上解决问题。最重要的是Calendar的API使用起来真的很不方便,而且该类在语义上也完全不符合日期/时间的含义,使用起来更显尴尬。

总之,无论是Date,还是Calendar,还是格式化DateFormat都用着太方便,且存在各式各样的安全隐患、线程安全问题等等,这是API没有设计好的地方。

并不孤单

日期时间API属于基础API,在各个语言中都是必备的。然而不仅仅是Java面临着API设计很烂的处境,有些其它流行语言一样如此,涌现出1个(1堆)三方库比乙方库设计更好的情况,比如:

  • Python:日期时间处理库Arrow
  • JavaScript:日期时间处理库Moment.js
  • .Net:日期时间处理库Joda-Time

所以说,Java它并不孤单(自我安慰一把)

自我救赎:JSR 310

因为原生的Date日期时间体系存在“七宗罪”,催生了第三方Java日期时间库的诞生,如大名鼎鼎的Joda-Time的流行甚至一度成为标配。

对于Java来说,如此重要的API模块岂能被第三方库给占据,开发者本就想简单的处理个日期时间还得导入第三方库,使用也太不方便了吧。当时的Java如日中天,因此就开启了“收编”Joda-Time之旅。

2013年9月份,具有划时代意义的Java 8大版本正式发布,该版本带来了非常多的新特性,其中最引入瞩目之一便是全新的日期时间API:JSR 310。

JSR 310规范的领导者是Stephen Colebourne,此人也是Joda-Time的缔造者。不客气的说JSR 310是在Joda-Time的基础上建立的,参考了其绝大部分的API实现,因此若你之前是Joda-Time的重度使用者,现在迁移到Java 8原生的JSR 310日期时间上来几乎无缝。

即便这样,也并不能说JSR 310就完全等于Joda-Time的官方版本,还是有些许诧异的,例举如下:

  1. 首先当然是包名的差别,org.joda.time -> java.time标准日期时间包
  2. JSR 310不接受null值,Joda-Time把Null值当0处理
  3. JSR 310所有抛出的异常是DateTimeException,它是个RuntimeException,而Joda-Time都是checked exception

简单感受下JSR 310 API:

@Test
public void test5() {
    System.out.println(LocalDate.now(ZoneId.systemDefault()));
    System.out.println(LocalTime.now(ZoneId.systemDefault()));
    System.out.println(LocalDateTime.now(ZoneId.systemDefault()));

    System.out.println(OffsetTime.now(ZoneId.systemDefault()));
    System.out.println(OffsetDateTime.now(ZoneId.systemDefault()));
    System.out.println(ZonedDateTime.now(ZoneId.systemDefault()));

    System.out.println(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()));
    System.out.println(DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now()));
    System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()));
}

JSR 310的所有对象都是不可变的,所以线程安全。和老的日期时间API相比,最主要的特征对比如下:

JSR 310Date/Calendar说明
流畅的API难用的APIAPI设计的好坏最直接影响编程体验,前者大大大大优于后者
实例不可变实例可变对于日期时间实例,设计为可变确实不合理也不安全。都不敢放心的传递给其它函数使用
线程安全线程不安全此特性直接决定了编码方式和健壮性

关于JSR 310日期时间更多介绍此处就不展开了,毕竟前面文章啰嗦过好多次了。总之它是Java的新一代日期时间API,设计得非常好,几乎没有缺点可言,可用于100%替代老的日期时间API。

如果你到现在2021年了还没拥抱它,那么请问你还在等啥呢?

总结

日期时间API因为过于常用,因此你可能都觉得它毫不起眼。坦白的说,如果你没有复杂的日期时间需求要处理,如涉及到时区、偏移量、跨时区转换、国际化显示等等,那么可能觉得Date也能将就。

如果你不想做个将就的人,如果你想拥有更好的日期时间编程体验,弃用Date,拥抱JSR 310吧。

本文思考题

本文所属专栏:JDK日期时间,后台回复专栏名即可获取全部内容。本文已被https://www.yourbatman.cn收录。

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 偏移量Z代表什么含义?
  2. ZoneId和ZoneOffset是如何建立对应关系的?
  3. 若某个城市不在ZoneId列表里面,想要获取其UTC偏移量该怎么破?

推荐阅读

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('点个赞吧!');
NSLog(@"关注【BAT的乌托邦】!");
console.log("点个赞吧!");
print("关注【BAT的乌托邦】!");
printf("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("点个赞吧!");
alert("关注【BAT的乌托邦】!");
echo("点个赞吧!");

作者简介:A哥(YourBatman),Spring Framework/Boot开源贡献者,Java架构师,爱分享。非常注重基本功修养,底层基础决定上层建筑,才能焕发程序员更强生命力。非常擅长结构化讲述专题,抽丝剥茧颇具深度。这些专题也许可能大概是全网最好或独一份哦,欢迎自取。

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月25日

LocalDateTime、OffsetDateTime、ZonedDateTime互转,这一篇绝对喂饱你

你好,我是A哥(YourBatman)。

在JSR 310日期时间体系了,一共有三个API可用于表示日期时间:

  • LocalDateTime:本地日期时间
  • OffsetDateTime:带偏移量的日期时间
  • ZonedDateTime:带时区的日期时间

也许平时开发中你只用到过LocalDateTime这个API,那是极好的,但是不能止步于此,否则就图样图森破了。

随着场景的多样性变化,咱们开发者接触到OffsetDateTime/ZonedDateTime的概率越来越大,但凡和国际化产生上关系的大概率都会用得到它们。本文依然站在实用的角度,辅以具体代码示例,介绍它三。

本文提纲

版本约定

  • JDK:8

正文

下面这张图是一个完整的日期时间,拆解各个部分的含义,一目了然(建议收藏此图):

因为LocalDate、LocalTime等理解起来比较简单,就不用再花笔墨介绍了,重点放在LocalDateTime、OffsetDateTime、ZonedDateTime它三身上。

什么是LocalDateTime?

ISO-8601日历系统中不带时区的日期时间。

说明:ISO-8601日系统是现今世界上绝大部分国家/地区使用的,这就是我们国人所说的公历,有闰年的特性

LocalDateTime是一个不可变的日期-时间对象,它表示一个日期时间,通常被视为年-月-日-小时-分钟-秒。还可以访问其他日期和时间字段,如day-of-year、day-of-week和week-of-year等等,它的精度能达纳秒级别。

该类不存储时区,所以适合日期的描述,比如用于生日、deadline等等。但是请记住,如果没有偏移量/时区等附加信息,一个时间是不能表示时间线上的某一时刻的。

代码示例

最大/最小值:

@Test
public void test1() {
    LocalDateTime min = LocalDateTime.MIN;
    LocalDateTime max = LocalDateTime.MAX;

    System.out.println("LocalDateTime最小值:" + min);
    System.out.println("LocalDateTime最大值:" + max);
    System.out.println(min.getYear() + "-" + min.getMonthValue() + "-" + min.getDayOfMonth());
    System.out.println(max.getYear() + "-" + max.getMonthValue() + "-" + max.getDayOfMonth());
}

输出:
LocalDateTime最小值:-999999999-01-01T00:00
LocalDateTime最大值:+999999999-12-31T23:59:59.999999999
-999999999-1-1
999999999-12-31

构造:

@Test
public void test2() {
    System.out.println("当前时区的本地时间:" + LocalDateTime.now());
    System.out.println("当前时区的本地时间:" + LocalDateTime.of(LocalDate.now(), LocalTime.now()));

    System.out.println("纽约时区的本地时间:" + LocalDateTime.now(ZoneId.of("America/New_York")));
}

输出:
当前时区的本地时间:2021-01-17T17:00:41.446
当前时区的本地时间:2021-01-17T17:00:41.447
纽约时区的本地时间:2021-01-17T04:00:41.450

注意,最后一个构造传入了ZoneId,并不是说LocalDateTime和时区有关了,而是告诉说这个Local指的是纽约,细品这句话。

计算:

@Test
public void test3() {
    LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
    System.out.println("计算前:" + now);

    // 加3天
    LocalDateTime after = now.plusDays(3);
    // 减4个小时
    after = after.plusHours(-3); // 效果同now.minusDays(3);
    System.out.println("计算后:" + after);

    // 计算时间差
    Period period = Period.between(now.toLocalDate(), after.toLocalDate());
    System.out.println("相差天数:" + period.getDays());
    Duration duration = Duration.between(now.toLocalTime(), after.toLocalTime());
    System.out.println("相差小时数:" + duration.toHours());
}

输出:
计算前:2021-01-17T17:10:15.381
计算后:2021-01-20T14:10:15.381
相差天数:3
相差小时数:-3

格式化:

@Test
public void test4() {
    LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
    // System.out.println("格式化输出:" + DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(now));
    System.out.println("格式化输出(本地化输出,中文环境):" + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT).format(now));

    String dateTimeStrParam = "2021-01-17 18:00:00";
    System.out.println("解析后输出:" + LocalDateTime.parse(dateTimeStrParam, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.US)));
}

输出:
格式化输出(本地化输出,中文环境):21-1-17 下午5:15
解析后输出:2021-01-17T18:00

什么是OffsetDateTime?

ISO-8601日历系统中与UTC偏移量有关的日期时间。OffsetDateTime是一个带有偏移量的日期时间类型。存储有精确到纳秒的日期时间,以及偏移量。可以简单理解为 OffsetDateTime = LocalDateTime + ZoneOffset。

OffsetDateTime、ZonedDateTime和Instant它们三都能在时间线上以纳秒精度存储一个瞬间(请注意:LocalDateTime是不行的),也可理解我某个时刻。OffsetDateTime和Instant可用于模型的字段类型,因为它们都表示瞬间值并且还不可变,所以适合网络传输或者数据库持久化。

ZonedDateTime不适合网络传输/持久化,因为即使同一个ZoneId时区,不同地方获取到瞬时值也有可能不一样

代码示例

最大/最小值:

@Test
public void test5() {
    OffsetDateTime min = OffsetDateTime.MIN;
    OffsetDateTime max = OffsetDateTime.MAX;

    System.out.println("OffsetDateTime最小值:" + min);
    System.out.println("OffsetDateTime最大值:" + max);
    System.out.println(min.getOffset() + ":" + min.getYear() + "-" + min.getMonthValue() + "-" + min.getDayOfMonth());
    System.out.println(max.getOffset() + ":" + max.getYear() + "-" + max.getMonthValue() + "-" + max.getDayOfMonth());
}

输出:
OffsetDateTime最小值:-999999999-01-01T00:00+18:00
OffsetDateTime最大值:+999999999-12-31T23:59:59.999999999-18:00
+18:00:-999999999-1-1
-18:00:999999999-12-31

偏移量的最大值是+18,最小值是-18,这是由ZoneOffset内部的限制决定的。

构造:

@Test
public void test6() {
    System.out.println("当前位置偏移量的本地时间:" + OffsetDateTime.now());
    System.out.println("偏移量-4(纽约)的本地时间::" + OffsetDateTime.of(LocalDateTime.now(), ZoneOffset.of("-4")));

    System.out.println("纽约时区的本地时间:" + OffsetDateTime.now(ZoneId.of("America/New_York")));
}

输出:
当前位置偏移量的本地时间:2021-01-17T19:02:06.328+08:00
偏移量-4(纽约)的本地时间::2021-01-17T19:02:06.329-04:00
纽约时区的本地时间:2021-01-17T06:02:06.330-05:00

计算:

格式化:

@Test
public void test7() {
    OffsetDateTime now = OffsetDateTime.now(ZoneId.systemDefault());
    System.out.println("格式化输出(本地化输出,中文环境):" + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT).format(now));

    String dateTimeStrParam = "2021-01-17T18:00:00+07:00";
    System.out.println("解析后输出:" + OffsetDateTime.parse(dateTimeStrParam));
}

输出:
格式化输出(本地化输出,中文环境):21-1-17 下午7:06
解析后输出:2021-01-17T18:00+07:00

转换:
LocalDateTime -> OffsetDateTime

@Test
public void test8() {
    LocalDateTime localDateTime = LocalDateTime.of(2021, 01, 17, 18, 00, 00);
    System.out.println("当前时区(北京)时间为:" + localDateTime);

    // 转换为偏移量为 -4的OffsetDateTime时间
    // 1、-4地方的晚上18点
    System.out.println("-4偏移量地方的晚上18点:" + OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(-4)));
    System.out.println("-4偏移量地方的晚上18点(方式二):" + localDateTime.atOffset(ZoneOffset.ofHours(-4)));
    // 2、北京时间晚上18:00 对应的-4地方的时间点
    System.out.println("当前地区对应的-4地方的时间:" + OffsetDateTime.ofInstant(localDateTime.toInstant(ZoneOffset.ofHours(8)), ZoneOffset.ofHours(-4)));
}

输出:
当前时区(北京)时间为:2021-01-17T18:00
-4偏移量地方的晚上18点:2021-01-17T18:00-04:00
-4偏移量地方的晚上18点(方式二):2021-01-17T18:00-04:00
当前地区对应的-4地方的时间:2021-01-17T06:00-04:00

通过此例值得注意的是:LocalDateTime#atOffset()/atZone()只是增加了偏移量/时区,本地时间是并没有改变的。若想实现本地时间到其它偏移量的对应的时间只能通过其ofInstant()系列构造方法。

OffsetDateTime -> LocalDateTime

@Test
public void test81() {
    OffsetDateTime offsetDateTime = OffsetDateTime.of(LocalDateTime.now(), ZoneOffset.ofHours(-4));
    System.out.println("-4偏移量时间为:" + offsetDateTime);

    // 转为LocalDateTime 注意:时间还是未变的哦
    System.out.println("LocalDateTime的表示形式:" + offsetDateTime.toLocalDateTime());
}

输出:
-4偏移量时间为:2021-01-17T19:33:28.139-04:00
LocalDateTime的表示形式:2021-01-17T19:33:28.139

什么是ZonedDateTime?

ISO-8601国际标准日历系统中带有时区的日期时间。它存储所有的日期和时间字段,精度为纳秒,以及一个时区,带有用于处理不明确的本地日期时间的时区偏移量。

这个API可以处理从LocalDateTime -> Instant -> ZonedDateTime的转换,其中用zone时区来表示偏移量(并非直接用offset哦)。两个时间点之间的转换会涉及到使用从ZoneId访问的规则计算偏移量(换句话说:偏移量并非写死而是根据规则计算出来的)。

获取瞬间的偏移量很简单,因为每个瞬间只有一个有效的偏移量。但是,获取本地日期时间的偏移量并不简单。存在这三种情况:

  • 正常情况:有一个有效的偏移量。对于一年中的绝大多数时间,适用正常情况,即本地日期时间只有一个有效的偏移量
  • 时间间隙情况:没有有效偏移量。这是由于夏令时开始时从“冬季”改为“夏季”而导致时钟向前拨的时候。在间隙中,没有有效偏移量
  • 重叠情况:有两个有效偏移量。这是由于秋季夏令时从“夏季”到“冬季”的变化,时钟会向后拨。在重叠部分中,有两个有效偏移量

这三种情况如果要自己处理,估计头都大了。这就是使用JSR 310的优势,ZonedDateTime全帮你搞定,让你使用无忧。

ZonedDateTime可简单认为是LocalDateTime和ZoneId的组合。而ZoneOffset是其内置的动态计算出来的一个次要信息,以确保输出一个瞬时值而存在,毕竟在某个瞬间偏移量ZoneOffset肯定是确定的。ZonedDateTime也可以理解为保存的状态相当于三个独立的对象:LocalDateTime、ZoneId和ZoneOffset。某个瞬间 = LocalDateTime + ZoneOffset。ZoneId确定了偏移量如何改变的规则。所以偏移量我们并不能自由设置(不提供set方法,构造时也不行),因为它由ZoneId来控制的。

代码示例

构造:

@Test
public void test9() {
    System.out.println("当前位置偏移量的本地时间:" + ZonedDateTime.now());
    System.out.println("纽约时区的本地时间:" + ZonedDateTime.of(LocalDateTime.now(), ZoneId.of("America/New_York")));

    System.out.println("北京实现对应的纽约时区的本地时间:" + ZonedDateTime.now(ZoneId.of("America/New_York")));
}

输出:
当前位置偏移量的本地时间:2021-01-17T19:25:10.520+08:00[Asia/Shanghai]
纽约时区的本地时间:2021-01-17T19:25:10.521-05:00[America/New_York]
北京实现对应的纽约时区的本地时间:2021-01-17T06:25:10.528-05:00[America/New_York]

计算:

格式化:

转换:
LocalDateTime -> ZonedDateTime

@Test
public void test10() {
    LocalDateTime localDateTime = LocalDateTime.of(2021, 01, 17, 18, 00, 00);
    System.out.println("当前时区(北京)时间为:" + localDateTime);

    // 转换为偏移量为 -4的OffsetDateTime时间
    // 1、-4地方的晚上18点
    System.out.println("纽约时区晚上18点:" + ZonedDateTime.of(localDateTime, ZoneId.of("America/New_York")));
    System.out.println("纽约时区晚上18点(方式二):" + localDateTime.atZone(ZoneId.of("America/New_York")));
    // 2、北京时间晚上18:00 对应的-4地方的时间点
    System.out.println("北京地区此时间对应的纽约的时间:" + ZonedDateTime.ofInstant(localDateTime.toInstant(ZoneOffset.ofHours(8)), ZoneOffset.ofHours(-4)));
    System.out.println("北京地区此时间对应的纽约的时间:" + ZonedDateTime.ofInstant(localDateTime, ZoneOffset.ofHours(8), ZoneOffset.ofHours(-4)));
}

输出:
当前时区(北京)时间为:2021-01-17T18:00
纽约时区晚上18点:2021-01-17T18:00-05:00[America/New_York]
纽约时区晚上18点(方式二):2021-01-17T18:00-05:00[America/New_York]
北京地区此时间对应的纽约的时间:2021-01-17T06:00-04:00
北京地区此时间对应的纽约的时间:2021-01-17T06:00-04:00

OffsetDateTime -> ZonedDateTime

@Test
public void test101() {
    OffsetDateTime offsetDateTime = OffsetDateTime.of(LocalDateTime.now(), ZoneOffset.ofHours(-4));
    System.out.println("-4偏移量时间为:" + offsetDateTime);

    // 转换为ZonedDateTime的表示形式
    System.out.println("ZonedDateTime的表示形式:" + offsetDateTime.toZonedDateTime());
    System.out.println("ZonedDateTime的表示形式:" + offsetDateTime.atZoneSameInstant(ZoneId.of("America/New_York")));
    System.out.println("ZonedDateTime的表示形式:" + offsetDateTime.atZoneSimilarLocal(ZoneId.of("America/New_York")));
}

-4偏移量时间为:2021-01-17T19:43:28.320-04:00
ZonedDateTime的表示形式:2021-01-17T19:43:28.320-04:00
ZonedDateTime的表示形式:2021-01-17T18:43:28.320-05:00[America/New_York]
ZonedDateTime的表示形式:2021-01-17T19:43:28.320-05:00[America/New_York]

本例有值得关注的点:

  • atZoneSameInstant():将此日期时间与时区结合起来创建ZonedDateTime,以确保结果具有相同的Instant

    • 所有偏移量-4 -> -5,时间点也从19 -> 18,确保了Instant保持一致嘛
  • atZoneSimilarLocal:将此日期时间与时区结合起来创建ZonedDateTime,以确保结果具有相同的本地时间

    • 所以直接效果和toLocalDateTime()是一样的,但是它会尽可能的保留偏移量(所以你看-4变为了-5,保持了真实的偏移量)

我这里贴出纽约2021年的夏令时时间区间:

也就是说在2021.03.14 - 2021.11.07期间,纽约的偏移量是-4,其余时候是-5。那么再看这个例子(我把时间改为5月5号,也就是处于夏令营期间):

@Test
public void test101() {
    OffsetDateTime offsetDateTime = OffsetDateTime.of(LocalDateTime.of(2021, 05, 05, 18, 00, 00), ZoneOffset.ofHours(-4));
    System.out.println("-4偏移量时间为:" + offsetDateTime);

    // 转换为ZonedDateTime的表示形式
    System.out.println("ZonedDateTime的表示形式:" + offsetDateTime.toZonedDateTime());
    System.out.println("ZonedDateTime的表示形式:" + offsetDateTime.atZoneSameInstant(ZoneId.of("America/New_York")));
    System.out.println("ZonedDateTime的表示形式:" + offsetDateTime.atZoneSimilarLocal(ZoneId.of("America/New_York")));
}

输出:
-4偏移量时间为:2021-05-05T18:00-04:00
ZonedDateTime的表示形式:2021-05-05T18:00-04:00
ZonedDateTime的表示形式:2021-05-05T18:00-04:00[America/New_York]
ZonedDateTime的表示形式:2021-05-05T18:00-04:00[America/New_York]

看到了吧,偏移量变为了-4。感受到夏令时的“威力”了吧。

OffsetDateTime和ZonedDateTime的区别

LocalDateTime、OffsetDateTime、ZonedDateTime这三个哥们,LocalDateTime好理解,一般都没有异议。但是很多同学对OffsetDateTime和ZonedDateTime傻傻分不清,这里说说它俩的区别。

  1. OffsetDateTime = LocalDateTime + 偏移量ZoneOffset;ZonedDateTime = LocalDateTime + 时区ZoneId
  2. OffsetDateTime可以随意设置偏移值,但ZonedDateTime无法自由设置偏移值,因为此值是由时区ZoneId控制的
  3. OffsetDateTime无法支持夏令时等规则,但ZonedDateTime可以很好的处理夏令时调整
  4. OffsetDateTime得益于不变性一般用于数据库存储、网络通信;而ZonedDateTime得益于其时区特性,一般在指定时区里显示时间非常方便,无需认为干预规则
  5. OffsetDateTime代表一个瞬时值,而ZonedDateTime的值是不稳定的,需要在某个瞬时根据当时的规则计算出来偏移量从而确定实际值

总的来说,OffsetDateTime和ZonedDateTime的区别主要在于ZoneOffset和ZoneId的区别。如果你只是用来传递数据,请使用OffsetDateTime,若你想在特定时区里做时间显示那么请务必使用ZonedDateTime。

总结

本着拒绝浅尝辄止的态度,深度剖析了很多同学可能不太熟悉的OffsetDateTime、ZonedDateTime两个API。总而言之,想要真正掌握日期时间体系(不限于Java语言,而是所有语言,甚至日常生活),对时区、偏移量的了解是绕不过去的砍,这块知识有所欠缺的朋友可往前翻翻补补课。

最后在使用它们三的过程中,有两个提醒给你:

  1. 所有日期/时间都是不可变的类型,所以若需要比较的话,请不要使用==,而是用equals()方法。
    2、任何时候,构造一个日期时间(包括它们三)请永远务必显示的指定时区,哪怕是默认时区。这么做的目的就是明确代码的意图,消除语义上的不确定性。比如若没指定时区,那到底是写代码的人欠考虑了呢,还是就是想用默认时区呢?总之显示指定绝大部分情况下比隐式“指定”语义上好得多。

本文思考题

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 如何用LocalDateTime描述美国纽约本地时间?
  2. OffsetDateTime和ZonedDateTime你到底该使用谁?
  3. 一个人的生日应该用什么Java类型存储呢?

推荐阅读

GMT UTC CST ISO 夏令时 时间戳,都是些什么鬼?

全网最全!彻底弄透Java处理GMT/UTC日期时间

全球城市ZoneId和UTC时间偏移量的最全对照表

关注我

分享、成长,拒绝浅尝辄止。关注【BAT的乌托邦】回复关键字专栏有Spring技术栈、中间件等小而美的纯原创专栏。本文已被 https://www.yourbatman.cn 收录。

本文所属专栏:JDK日期时间,公号后台回复专栏名即可获取全部内容。

A哥(YourBatman):Spring Framework/Boot开源贡献者,Java架构师。非常注重基本功修养,相信底层基础决定上层建筑,坚实基础才能焕发程序员更强生命力。文章特点为以小而美专栏形式重构知识体系,抽丝剥茧,致力于做人人能看懂的最好的专栏系列。可加我好友(fsx1056342982)共勉哦!

System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('点个赞吧!');
NSLog(@"关注【BAT的乌托邦】!");
console.log("点个赞吧!");
print("关注【BAT的乌托邦】!");
printf("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("点个赞吧!");
alert("关注【BAT的乌托邦】!");
echo("点个赞吧!");
查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月22日

全球城市ZoneId和UTC时间偏移量的最全对照表

你好,我是A哥(YourBatman)。

如你所知,现行的世界标准时间是UTC世界协调时,时区已不直接参与时间计算。但是呢,城市名称or时区是人们所能记忆和容易沟通的名词,因此我们迫切需要一个对照表,能让只知道城市名或者ID的情况下就迅速知道它的偏移量,从而计算出当地的本地时间。

本文作为时区、偏移量知识点的补充,整体内容相对轻松,但依旧以实用为主,我尽力用本系列助你解决Java日期时间处理的所有痛点,对,是所有。日后应对日期/时间处理的任何问题,只需要看本系列就够了。

本文提纲

版本约定

  • JDK:8

正文

我们在去住酒店的时候,可以看到酒店大厅里的前台墙上一般会挂有好几个时钟,分别展示北京时间、纽约时间、伦敦时间、巴黎时间等等世界主要城市时间。

前面我连续用了两篇文章从概念介绍到Java实战,很全面的介绍了日期时间中GMT/UTC时间、夏令时、时间戳等等Java是如何处理的。从后台数据上看,这两篇文章的收藏量明显高于之前的绝大多数文章,正所谓数据不会骗人,说明日期时间的处理上确实有很同学是有痛点或者盲点的。

前两篇文章篇幅较长,适合收藏备用,可戳这里直达:全网最全!彻底弄透Java处理GMT/UTC日期时间

要想知道某个地方(城市)的本地时间,就需要先确定该城市的偏移量,然后以UTC为基准加上偏移量计算出其本地时间。

说明:本地时间的最小区域单位是城市

那么问题来了,世界上的“城市”那么多,如何知道具体某个城市它的偏移量是多少,从而计算出其本地时间呢?

城市ZoneId - UTC偏移量

下面给你介绍两种方式,任君选择。

方式一:第三方站点

在前面文章我给大家推荐过一个网站,上面列出了全球主要国家/城市ID对应的偏移量数据:

国家/城市ID与偏移量的对应关系,全部内容请参考网址(直接访问,无需梯子):https://www.timeanddate.com/t...

用这种方式的优点是方便快捷,但缺点也非常明显:

  1. 这个网址需要记忆/收藏,一定程度上增加了使用负担
  2. 依赖外网
  3. 由于夏令时的存在,该网站只能显示瞬时(当时)的一个对应情况,而这并非固定不变的

上述第3点是通过网站查询的致命问题,并且这一点还很容易被使用者忽略从而产生错误。作为有众多技法的程序员,当然希望能够“解决”此问题喽,那就看第二种方式吧。

方式二:自写程序(推荐)

前面文章介绍过Java提供了相关API来获取到全量的(已收录的)ZoneId:

ZoneId[] zoneIds = ZoneId.getAvailableZoneIds();

基于它,再经过一些计算就能得到ZoneId对应的偏移量,完全实现自主化,不再“求人”。

下面我先列出世界主要城市时区ID对应的UTC偏移量(文末附全量表格):

时区IDUTC偏移
Asia/Shanghai+08:00
Asia/Chongqing+08:00
America/New_York-05:00
Europe/LondonZ
Europe/Paris+01:00
Europe/Moscow+03:00
Asia/Tokyo+09:00
Asia/Dubai+04:00
Asia/Seoul+09:00
Asia/Bangkok+07:00
Asia/Jakarta+07:00
特别说明:本表格生成于北京时间2021-01-19 08:00:00,由于各地夏令时的存在,不同时间生成的表格UTC偏移值不尽相同

看名称知道这些都是哪些城市了吧?注意:没有Asia/Beijing哦,绝大部分情况下我国使用的都是Asia/Shanghai这个ID。

看英国伦敦(Europe/London)它的偏移量是Z,代表+00:00偏移量,属于0时区、0偏移量地区,毕竟格林威治在那,它是世界的“时间中心”。

值得特别注意:该表格必须绑定上生成时间才有意义,否则因为一些时间规则(如夏令时)的存在会导致不同时间生成的内容不一样。比如看我给你模拟的这个例子打印日志:

public static void main(String[] argv) {
    LocalDateTime bjTime = LocalDateTime.now();
    Map<String, ZoneOffset> allZoneIdOffSetMap = allZoneIdOffSet2MapByBjTime(bjTime);
    System.out.println("北京时间" + bjTime + ",美国纽约的偏移量:" + allZoneIdOffSetMap.get("America/New_York"));

    // 让当前北京时间深处纽约2021年夏令时时间区间内
    bjTime = LocalDateTime.of(2021, 05, 05, 18, 01, 01);
    allZoneIdOffSetMap = allZoneIdOffSet2MapByBjTime(bjTime);
    System.out.println("北京时间" + bjTime + ".000,美国纽约(夏令时期间)的偏移量:" + allZoneIdOffSetMap.get("America/New_York"));
}

运行程序,输出:

北京时间2021-01-19T08:25:13.162,美国纽约的偏移量:-05:00
北京时间2021-05-05T18:01:01.000,美国纽约(夏令时期间)的偏移量:-04:00

由此可见,纽约这个城市因为有夏令时的存在,因此在不同的时间段它的偏移量是不同的。备注:纽约2021年夏令时情况如下表:

当然喽,这个例子的最重点方法是allZoneIdOffSet2MapByBjTime()是如何计算的?为了避免大量贴代码影响阅读,我把它的源码放到了后台。对此部分计算逻辑感兴趣的同学可在公号后台回复“时区”二字来获取源码内容。

附:全部城市对应偏移量

时区IDUTC偏移
Asia/Aden+03:00
America/Cuiaba-04:00
Etc/GMT+9-09:00
Etc/GMT+8-08:00
Africa/Nairobi+03:00
America/Marigot-04:00
Asia/Aqtau+05:00
Pacific/Kwajalein+12:00
America/El_Salvador-06:00
Asia/Pontianak+07:00
Africa/Cairo+02:00
Pacific/Pago_Pago-11:00
Africa/Mbabane+02:00
Asia/Kuching+08:00
Pacific/Honolulu-10:00
Pacific/Rarotonga-10:00
America/Guatemala-06:00
Australia/Hobart+11:00
Europe/LondonZ
America/Belize-06:00
America/Panama-05:00
Asia/Chungking+08:00
America/Managua-06:00
America/Indiana/Petersburg-05:00
Asia/Yerevan+04:00
Europe/Brussels+01:00
GMTZ
Europe/Warsaw+01:00
America/Chicago-06:00
Asia/Kashgar+06:00
Chile/Continental-03:00
Pacific/Yap+10:00
CET+01:00
Etc/GMT-1+01:00
Etc/GMT-0Z
Europe/JerseyZ
America/Tegucigalpa-06:00
Etc/GMT-5+05:00
Europe/Istanbul+03:00
America/Eirunepe-05:00
Etc/GMT-4+04:00
America/Miquelon-03:00
Etc/GMT-3+03:00
Europe/Luxembourg+01:00
Etc/GMT-2+02:00
Etc/GMT-9+09:00
America/Argentina/Catamarca-03:00
Etc/GMT-8+08:00
Etc/GMT-7+07:00
Etc/GMT-6+06:00
Europe/Zaporozhye+02:00
Canada/Yukon-08:00
Canada/Atlantic-04:00
Atlantic/St_HelenaZ
Australia/Tasmania+11:00
Libya+02:00
Europe/GuernseyZ
America/Grand_Turk-05:00
US/Pacific-New-08:00
Asia/Samarkand+05:00
America/Argentina/Cordoba-03:00
Asia/Phnom_Penh+07:00
Africa/Kigali+02:00
Asia/Almaty+06:00
US/Alaska-09:00
Asia/Dubai+04:00
Europe/Isle_of_ManZ
America/Araguaina-03:00
Cuba-05:00
Asia/Novosibirsk+07:00
America/Argentina/Salta-03:00
Etc/GMT+3-03:00
Africa/Tunis+01:00
Etc/GMT+2-02:00
Etc/GMT+1-01:00
Pacific/Fakaofo+13:00
Africa/Tripoli+02:00
Etc/GMT+0Z
Israel+02:00
Africa/BanjulZ
Etc/GMT+7-07:00
Indian/Comoro+03:00
Etc/GMT+6-06:00
Etc/GMT+5-05:00
Etc/GMT+4-04:00
Pacific/Port_Moresby+10:00
US/Arizona-07:00
Antarctica/Syowa+03:00
Indian/Reunion+04:00
Pacific/Palau+09:00
Europe/Kaliningrad+02:00
America/Montevideo-03:00
Africa/Windhoek+02:00
Asia/Karachi+05:00
Africa/Mogadishu+03:00
Australia/Perth+08:00
Brazil/East-03:00
Etc/GMTZ
Asia/Chita+09:00
Pacific/Easter-05:00
Antarctica/Davis+07:00
Antarctica/McMurdo+13:00
Asia/Macao+08:00
America/Manaus-04:00
Africa/FreetownZ
Europe/Bucharest+02:00
Asia/Tomsk+07:00
America/Argentina/Mendoza-03:00
Asia/Macau+08:00
Europe/Malta+01:00
Mexico/BajaSur-07:00
Pacific/Tahiti-10:00
Africa/Asmera+03:00
Europe/Busingen+01:00
America/Argentina/Rio_Gallegos-03:00
Africa/Malabo+01:00
Europe/Skopje+01:00
America/Catamarca-03:00
America/Godthab-03:00
Europe/Sarajevo+01:00
Australia/ACT+11:00
GB-EireZ
Africa/Lagos+01:00
America/Cordoba-03:00
Europe/Rome+01:00
Asia/Dacca+06:00
Indian/Mauritius+04:00
Pacific/Samoa-11:00
America/Regina-06:00
America/Fort_Wayne-05:00
America/Dawson_Creek-07:00
Africa/Algiers+01:00
Europe/Mariehamn+02:00
America/St_Johns-03:30
America/St_Thomas-04:00
Europe/Zurich+01:00
America/Anguilla-04:00
Asia/Dili+09:00
America/Denver-07:00
Africa/BamakoZ
Europe/Saratov+04:00
GBZ
Mexico/General-06:00
Pacific/Wallis+12:00
Europe/Gibraltar+01:00
Africa/ConakryZ
Africa/Lubumbashi+02:00
Asia/Istanbul+03:00
America/Havana-05:00
NZ-CHAT+13:45
Asia/Choibalsan+08:00
America/Porto_Acre-05:00
Asia/Omsk+06:00
Europe/Vaduz+01:00
US/Michigan-05:00
Asia/Dhaka+06:00
America/Barbados-04:00
Europe/Tiraspol+02:00
Atlantic/Cape_Verde-01:00
Asia/Yekaterinburg+05:00
America/Louisville-05:00
Pacific/Johnston-10:00
Pacific/Chatham+13:45
Europe/Ljubljana+01:00
America/Sao_Paulo-03:00
Asia/Jayapura+09:00
America/Curacao-04:00
Asia/Dushanbe+05:00
America/Guyana-04:00
America/Guayaquil-05:00
America/Martinique-04:00
PortugalZ
Europe/Berlin+01:00
Europe/Moscow+03:00
Europe/Chisinau+02:00
America/Puerto_Rico-04:00
America/Rankin_Inlet-06:00
Pacific/Ponape+11:00
Europe/Stockholm+01:00
Europe/Budapest+01:00
America/Argentina/Jujuy-03:00
Australia/Eucla+08:45
Asia/Shanghai+08:00
UniversalZ
Europe/Zagreb+01:00
America/Port_of_Spain-04:00
Europe/Helsinki+02:00
Asia/Beirut+02:00
Asia/Tel_Aviv+02:00
Pacific/Bougainville+11:00
US/Central-06:00
Africa/Sao_TomeZ
Indian/Chagos+06:00
America/Cayenne-03:00
Asia/Yakutsk+09:00
Pacific/Galapagos-06:00
Australia/North+09:30
Europe/Paris+01:00
Africa/Ndjamena+01:00
Pacific/Fiji+12:00
America/Rainy_River-06:00
Indian/Maldives+05:00
Australia/Yancowinna+10:30
SystemV/AST4-04:00
Asia/Oral+05:00
America/Yellowknife-07:00
Pacific/Enderbury+13:00
America/Juneau-09:00
Australia/Victoria+11:00
America/Indiana/Vevay-05:00
Asia/Tashkent+05:00
Asia/Jakarta+07:00
Africa/Ceuta+01:00
Asia/Barnaul+07:00
America/Recife-03:00
America/Buenos_Aires-03:00
America/Noronha-02:00
America/Swift_Current-06:00
Australia/Adelaide+10:30
America/Metlakatla-09:00
Africa/Djibouti+03:00
America/Paramaribo-03:00
Asia/Qostanay+06:00
Europe/Simferopol+03:00
Europe/Sofia+02:00
Africa/NouakchottZ
Europe/Prague+01:00
America/Indiana/Vincennes-05:00
Antarctica/Mawson+05:00
America/Kralendijk-04:00
Antarctica/TrollZ
Europe/Samara+04:00
Indian/Christmas+07:00
America/Antigua-04:00
Pacific/Gambier-09:00
America/Indianapolis-05:00
America/Inuvik-07:00
America/Iqaluit-05:00
Pacific/Funafuti+12:00
UTCZ
Antarctica/Macquarie+11:00
Canada/Pacific-08:00
America/Moncton-04:00
Africa/Gaborone+02:00
Pacific/Chuuk+10:00
Asia/Pyongyang+09:00
America/St_Vincent-04:00
Asia/Gaza+02:00
Etc/UniversalZ
PST8PDT-08:00
Atlantic/FaeroeZ
Asia/Qyzylorda+05:00
Canada/Newfoundland-03:30
America/Kentucky/Louisville-05:00
America/Yakutat-09:00
Asia/Ho_Chi_Minh+07:00
Antarctica/Casey+08:00
Europe/Copenhagen+01:00
Africa/Asmara+03:00
Atlantic/Azores-01:00
Europe/Vienna+01:00
ROK+09:00
Pacific/Pitcairn-08:00
America/Mazatlan-07:00
Australia/Queensland+10:00
Pacific/Nauru+12:00
Europe/Tirane+01:00
Asia/Kolkata+05:30
SystemV/MST7-07:00
Australia/Canberra+11:00
MET+01:00
Australia/Broken_Hill+10:30
Europe/Riga+02:00
America/Dominica-04:00
Africa/AbidjanZ
America/Mendoza-03:00
America/Santarem-03:00
Kwajalein+12:00
America/Asuncion-03:00
Asia/Ulan_Bator+08:00
NZ+13:00
America/Boise-07:00
Australia/Currie+11:00
EST5EDT-05:00
Pacific/Guam+10:00
Pacific/Wake+12:00
Atlantic/Bermuda-04:00
America/Costa_Rica-06:00
America/Dawson-08:00
Asia/Chongqing+08:00
EireZ
Europe/Amsterdam+01:00
America/Indiana/Knox-06:00
America/North_Dakota/Beulah-06:00
Africa/AccraZ
Atlantic/FaroeZ
Mexico/BajaNorte-08:00
America/Maceio-03:00
Etc/UCTZ
Pacific/Apia+14:00
GMT0Z
America/Atka-10:00
Pacific/Niue-11:00
Australia/Lord_Howe+11:00
Europe/DublinZ
Pacific/Truk+10:00
MST7MDT-07:00
America/Monterrey-06:00
America/Nassau-05:00
America/Jamaica-05:00
Asia/Bishkek+06:00
America/Atikokan-05:00
Atlantic/Stanley-03:00
Australia/NSW+11:00
US/Hawaii-10:00
SystemV/CST6-06:00
Indian/Mahe+04:00
Asia/Aqtobe+05:00
America/Sitka-09:00
Asia/Vladivostok+10:00
Africa/Libreville+01:00
Africa/Maputo+02:00
ZuluZ
America/Kentucky/Monticello-05:00
Africa/El_Aaiun+01:00
Africa/OuagadougouZ
America/Coral_Harbour-05:00
Pacific/Marquesas-09:30
Brazil/West-04:00
America/Aruba-04:00
America/North_Dakota/Center-06:00
America/Cayman-05:00
Asia/Ulaanbaatar+08:00
Asia/Baghdad+03:00
Europe/San_Marino+01:00
America/Indiana/Tell_City-06:00
America/Tijuana-08:00
Pacific/Saipan+10:00
SystemV/YST9-09:00
Africa/Douala+01:00
America/Chihuahua-07:00
America/Ojinaga-07:00
Asia/Hovd+07:00
America/Anchorage-09:00
Chile/EasterIsland-05:00
America/Halifax-04:00
Antarctica/Rothera-03:00
America/Indiana/Indianapolis-05:00
US/Mountain-07:00
Asia/Damascus+02:00
America/Argentina/San_Luis-03:00
America/Santiago-03:00
Asia/Baku+04:00
America/Argentina/Ushuaia-03:00
Atlantic/ReykjavikZ
Africa/Brazzaville+01:00
Africa/Porto-Novo+01:00
America/La_Paz-04:00
Antarctica/DumontDUrville+10:00
Asia/Taipei+08:00
Antarctica/South_Pole+13:00
Asia/Manila+08:00
Asia/Bangkok+07:00
Africa/Dar_es_Salaam+03:00
Poland+01:00
Atlantic/MadeiraZ
Antarctica/Palmer-03:00
America/Thunder_Bay-05:00
Africa/Addis_Ababa+03:00
Asia/Yangon+06:30
Europe/Uzhgorod+02:00
Brazil/DeNoronha-02:00
Asia/Ashkhabad+05:00
Etc/ZuluZ
America/Indiana/Marengo-05:00
America/Creston-07:00
America/Punta_Arenas-03:00
America/Mexico_City-06:00
Antarctica/Vostok+06:00
Asia/Jerusalem+02:00
Europe/Andorra+01:00
US/Samoa-11:00
PRC+08:00
Asia/Vientiane+07:00
Pacific/Kiritimati+14:00
America/Matamoros-06:00
America/Blanc-Sablon-04:00
Asia/Riyadh+03:00
IcelandZ
Pacific/Pohnpei+11:00
Asia/Ujung_Pandang+08:00
Atlantic/South_Georgia-02:00
Europe/LisbonZ
Asia/Harbin+08:00
Europe/Oslo+01:00
Asia/Novokuznetsk+07:00
CST6CDT-06:00
Atlantic/CanaryZ
America/Knox_IN-06:00
Asia/Kuwait+03:00
SystemV/HST10-10:00
Pacific/Efate+11:00
Africa/LomeZ
America/Bogota-05:00
America/Menominee-06:00
America/Adak-10:00
Pacific/Norfolk+12:00
Europe/Kirov+03:00
America/Resolute-06:00
Pacific/Tarawa+12:00
Africa/Kampala+03:00
Asia/Krasnoyarsk+07:00
GreenwichZ
SystemV/EST5-05:00
America/Edmonton-07:00
Europe/Podgorica+01:00
Australia/South+10:30
Canada/Central-06:00
Africa/Bujumbura+02:00
America/Santo_Domingo-04:00
US/Eastern-05:00
Europe/Minsk+03:00
Pacific/Auckland+13:00
Africa/Casablanca+01:00
America/Glace_Bay-04:00
Canada/Eastern-05:00
Asia/Qatar+03:00
Europe/Kiev+02:00
Singapore+08:00
Asia/Magadan+11:00
SystemV/PST8-08:00
America/Port-au-Prince-05:00
Europe/BelfastZ
America/St_Barthelemy-04:00
Asia/Ashgabat+05:00
Africa/Luanda+01:00
America/Nipigon-05:00
Atlantic/Jan_Mayen+01:00
Brazil/Acre-05:00
Asia/Muscat+04:00
Asia/Bahrain+03:00
Europe/Vilnius+02:00
America/Fortaleza-03:00
Etc/GMT0Z
US/East-Indiana-05:00
America/Hermosillo-07:00
America/Cancun-05:00
Africa/Maseru+02:00
Pacific/Kosrae+11:00
Africa/Kinshasa+01:00
Asia/Kathmandu+05:45
Asia/Seoul+09:00
Australia/Sydney+11:00
America/Lima-05:00
Australia/LHI+11:00
America/St_Lucia-04:00
Europe/Madrid+01:00
America/Bahia_Banderas-06:00
America/Montserrat-04:00
Asia/Brunei+08:00
America/Santa_Isabel-08:00
Canada/Mountain-07:00
America/Cambridge_Bay-07:00
Asia/Colombo+05:30
Australia/West+08:00
Indian/Antananarivo+03:00
Australia/Brisbane+10:00
Indian/Mayotte+03:00
US/Indiana-Starke-06:00
Asia/Urumqi+06:00
US/Aleutian-10:00
Europe/Volgograd+04:00
America/Lower_Princes-04:00
America/Vancouver-08:00
Africa/Blantyre+02:00
America/Rio_Branco-05:00
America/DanmarkshavnZ
America/Detroit-05:00
America/Thule-04:00
Africa/Lusaka+02:00
Asia/Hong_Kong+08:00
Iran+03:30
America/Argentina/La_Rioja-03:00
Africa/DakarZ
SystemV/CST6CDT-06:00
America/Tortola-04:00
America/Porto_Velho-04:00
Asia/Sakhalin+11:00
Etc/GMT+10-10:00
America/Scoresbysund-01:00
Asia/Kamchatka+12:00
Asia/Thimbu+06:00
Africa/Harare+02:00
Etc/GMT+12-12:00
Etc/GMT+11-11:00
Navajo-07:00
America/Nome-09:00
Europe/Tallinn+02:00
Turkey+03:00
Africa/Khartoum+02:00
Africa/Johannesburg+02:00
Africa/Bangui+01:00
Europe/Belgrade+01:00
Jamaica-05:00
Africa/BissauZ
Asia/Tehran+03:30
WETZ
Europe/Astrakhan+04:00
Africa/Juba+03:00
America/Campo_Grande-04:00
America/Belem-03:00
Etc/GreenwichZ
Asia/Saigon+07:00
America/Ensenada-08:00
Pacific/Midway-11:00
America/Jujuy-03:00
Africa/TimbuktuZ
America/Bahia-03:00
America/Goose_Bay-04:00
America/Virgin-04:00
America/Pangnirtung-05:00
Asia/Katmandu+05:45
America/Phoenix-07:00
Africa/Niamey+01:00
America/Whitehorse-08:00
Pacific/Noumea+11:00
Asia/Tbilisi+04:00
America/Montreal-05:00
Asia/Makassar+08:00
America/Argentina/San_Juan-03:00
Hongkong+08:00
UCTZ
Asia/Nicosia+02:00
America/Indiana/Winamac-05:00
SystemV/MST7MDT-07:00
America/Argentina/ComodRivadavia-03:00
America/Boa_Vista-04:00
America/Grenada-04:00
Asia/Atyrau+05:00
Australia/Darwin+09:30
Asia/Khandyga+09:00
Asia/Kuala_Lumpur+08:00
Asia/Famagusta+02:00
Asia/Thimphu+06:00
Asia/Rangoon+06:30
Europe/Bratislava+01:00
Asia/Calcutta+05:30
America/Argentina/Tucuman-03:00
Asia/Kabul+04:30
Indian/Cocos+06:30
Japan+09:00
Pacific/Tongatapu+13:00
America/New_York-05:00
Etc/GMT-12+12:00
Etc/GMT-11+11:00
Etc/GMT-10+10:00
SystemV/YST9YDT-09:00
Europe/Ulyanovsk+04:00
Etc/GMT-14+14:00
Etc/GMT-13+13:00
W-SU+03:00
America/Merida-06:00
EET+02:00
America/Rosario-03:00
Canada/Saskatchewan-06:00
America/St_Kitts-04:00
Arctic/Longyearbyen+01:00
America/Fort_Nelson-07:00
America/Caracas-04:00
America/Guadeloupe-04:00
Asia/Hebron+02:00
Indian/Kerguelen+05:00
SystemV/PST8PDT-08:00
Africa/MonroviaZ
Asia/Ust-Nera+10:00
Egypt+02:00
Asia/Srednekolymsk+11:00
America/North_Dakota/New_Salem-06:00
Asia/Anadyr+12:00
Australia/Melbourne+11:00
Asia/Irkutsk+08:00
America/Shiprock-07:00
America/Winnipeg-06:00
Europe/Vatican+01:00
Asia/Amman+02:00
Etc/UTCZ
SystemV/AST4ADT-04:00
Asia/Tokyo+09:00
America/Toronto-05:00
Asia/Singapore+08:00
Australia/Lindeman+10:00
America/Los_Angeles-08:00
SystemV/EST5EDT-05:00
Pacific/Majuro+12:00
America/Argentina/Buenos_Aires-03:00
Europe/Nicosia+02:00
Pacific/Guadalcanal+11:00
Europe/Athens+02:00
US/Pacific-08:00
Europe/Monaco+01:00
特别说明:本表格生成于北京时间2021-01-19 08:00:00,由于各地夏令时的存在,不同时间生成的表格UTC偏移值不尽相同

有了这个表格,本文又非常值得收藏喽。有需要的时候直接拿出来ctrl + f查一下即可,不用再进入xxx网站啦。

当然喽,个人建议还是掌握程序的计算逻辑更稳妥些,可以“动态的”获取100%正确的偏移量,因此有需要此计算逻辑的同学直接在公号后台回复“时区”二字即可获取源码内容。

总结

本文围绕时区偏移量,通过自写代码的方式输出所有城市时区ID对应的偏移量值,进一步加深对时区和偏移量,以及夏令时的了解。

这部分内容是时间处理的难点,更是重点,往往程序bug就发生在这些地方(比如我在此趟了一坑),希望能引起各位重视。

下篇文章将继续补充介绍和实战场景非常贴近:LocalDateTime、OffsetDateTime、ZonedDateTime跨时区互转,保持关注。

本文思考题

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 偏移量Z代表什么含义?
  2. ZoneId和ZoneOffset是如何建立对应关系的?
  3. 若某个城市不在ZoneId列表里面,想要获取其UTC偏移量该怎么破?

推荐阅读

GMT UTC CST ISO 夏令时 时间戳,都是些什么鬼?

全网最全!彻底弄透Java处理GMT/UTC日期时间

关注我

分享、成长,拒绝浅藏辄止。关注【BAT的乌托邦】回复关键字专栏有Spring技术栈、中间件等小而美的纯原创专栏。本文已被 https://www.yourbatman.cn 收录。

本文所属专栏:JDK日期时间,公号后台回复专栏名即可获取全部内容。

A哥(YourBatman):Spring Framework/Boot开源贡献者,Java架构师。非常注重基本功修养,相信底层基础决定上层建筑,坚实基础才能焕发程序员更强生命力。文章特点为以小而美专栏形式重构知识体系,抽丝剥茧,致力于做人人能看懂的最好的专栏系列。可加我好友(fsx1056342982)共勉哦!

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月21日

全网最全!彻底弄透Java处理GMT/UTC日期时间

你好,我是A哥(YourBatman)。

本系列的目的是明明白白、彻彻底底的搞定日期/时间处理的几乎所有case。上篇文章 铺设所有涉及到的概念解释,例如GMT、UTC、夏令时、时间戳等等,若你还没看过,不仅强烈建议而是强制建议你前往用花5分钟看一下,因为日期时间处理较为特殊,实战必须基于对概念的了解,否则很可能依旧雾里看花。

说明:日期/时间的处理是日常开发非常常见的老大难,究其原因就是对日期时间的相关概念、应用场景不熟悉,所以不要忽视它

上篇概念,本文落地实操,二者相辅相成,缺一不可。本文内容较多,文字较长,预计超2w字,旨在全面的彻底帮你搞定Java对日期时间的处理,建议你可收藏,作为参考书留以备用。

本文提纲

版本约定

  • JDK:8

正文

上文铺了这么多概念,作为一枚Javaer最关心当然是这些“概念”在Java里的落地。平时工作中遇到时间如何处理?用Date还是JDK 8之后的日期时间API?如何解决跨时区转换等等头大问题。A哥向来管生管养,管杀管埋,因此本文就带你领略一下,Java是如何实现GMT和UTC的?

众所周知,JDK以版本8为界,有两套处理日期/时间的API:

虽然我一直鼓励弃用Date而支持在项目中只使用JSR 310日期时间类型,但是呢,由于Date依旧有庞大的存量用户,所以本文也不落单,对二者的实现均进行阐述。

Date类型实现

java.util.Date在JDK 1.0就已存在,用于表示日期 + 时间的类型,纵使年代已非常久远,并且此类的具有职责不单一,使用很不方便等诸多毛病,但由于十几二十年的历史原因存在,它的生命力依旧顽强,用户量巨大。

先来认识下Date,看下这个例子的输出:

@Test
public void test1() {
    Date currDate = new Date();
    System.out.println(currDate.toString());
    // 已经@Deprecated
    System.out.println(currDate.toLocaleString());
    // 已经@Deprecated
    System.out.println(currDate.toGMTString());
}

运行程序,输出:

Fri Jan 15 10:22:34 CST 2021
2021-1-15 10:22:34
15 Jan 2021 02:22:34 GMT

第一个:标准的UTC时间(CST就代表了偏移量 +0800)
第二个:本地时间,根据本地时区显示的时间格式
第三个:GTM时间,也就是格林威治这个时候的时间,可以看到它是凌晨2点(北京时间是上午10点哦)

第二个、第三个其实在JDK 1.1就都标记为@Deprecated过期了,基本禁止再使用。若需要转换为本地时间 or GTM时间输出的话,请使用格式化器java.text.DateFormat去处理。

时区/偏移量TimeZone

在JDK8之前,Java对时区和偏移量都是使用java.util.TimeZone来表示的。

一般情况下,使用静态方法TimeZone#getDefault()即可获得当前JVM所运行的时区,比如你在中国运行程序,这个方法返回的就是中国时区(也叫北京时区、北京时间)。

有的时候你需要做带时区的时间转换,譬如:接口返回值中既要有展示北京时间,也要展示纽约时间。这个时候就要获取到纽约的时区,以北京时间为基准在其上进行带时区转换一把:

@Test
public void test2() {
    String patternStr = "yyyy-MM-dd HH:mm:ss";
    // 北京时间(new出来就是默认时区的时间)
    Date bjDate = new Date();

    // 得到纽约的时区
    TimeZone newYorkTimeZone = TimeZone.getTimeZone("America/New_York");
    // 根据此时区 将北京时间转换为纽约的Date
    DateFormat newYorkDateFormat = new SimpleDateFormat(patternStr);
    newYorkDateFormat.setTimeZone(newYorkTimeZone);
    System.out.println("这是北京时间:" + new SimpleDateFormat(patternStr).format(bjDate));
    System.out.println("这是纽约时间:" + newYorkDateFormat.format(bjDate));
}

运行程序,输出:

这是北京时间:2021-01-15 11:48:16
这是纽约时间:2021-01-14 22:48:16

(11 + 24) - 22 = 13,北京比纽约快13个小时没毛病。

注意:两个时间表示的应该是同一时刻,也就是常说的时间戳值是相等的

那么问题来了,你怎么知道获取纽约的时区用America/New_York这个zoneId呢?随便写个字符串行不行?

答案是当然不行,这是有章可循的。下面我介绍两种查阅zoneId的方式,任你挑选:

方式一:用Java程序把所有可用的zoneId打印出来,然后查阅

@Test
public void test3() {
    String[] availableIDs = TimeZone.getAvailableIDs();
    System.out.println("可用zoneId总数:" + availableIDs.length);
    for (String zoneId : availableIDs) {
        System.out.println(zoneId);
    }
}

运行程序,输出(大部分符合规律:/前表示所属州,/表示城市名称):

可用zoneId总数:628
Africa/Abidjan
Africa/Accra
...
Asia/Chongqing // 亚洲/重庆
Asia/Shanghai // 亚洲/上海
Asia/Dubai // 亚洲/迪拜
...
America/New_York // 美洲/纽约
America/Los_Angeles // 美洲/洛杉矶
...
Europe/London // 欧洲/伦敦
...
Etc/GMT
Etc/GMT+0
Etc/GMT+1
...

值得注意的是并没有 Asia/Beijing 哦。

说明:此结果基于JDK 8版本,不同版本输出的总个数可能存在差异,但主流的ZoneId一般不会有变化

方式二
zoneId的列表是jre维护的一个文本文件,路径是你JDK/JRE的安装路径。地址在.jrelib目录的为未tzmappings的文本文件里。打开这个文件去ctrl + f找也是可以达到查找的目的的。

这两种房子可以帮你找到ZoneId的字典方便查阅,但是还有这么一种情况:当前所在的城市呢,在tzmappings文件里根本没有(比如没有收录),那要获取这个地方的时间去显示怎么破呢?虽然概率很小,但不见得没有嘛,毕竟全球那么多国家那么多城市呢~

Java自然也考虑到了这一点,因此也是有办法的:指定其时区数字表示形式,其实也叫偏移量(不要告诉我这个地方的时区都不知道,那就真没救了),如下示例

@Test
public void test4() {
    System.out.println(TimeZone.getTimeZone("GMT+08:00").getID());
    System.out.println(TimeZone.getDefault().getID());

    // 纽约时间
    System.out.println(TimeZone.getTimeZone("GMT-05:00").getID());
    System.out.println(TimeZone.getTimeZone("America/New_York").getID());
}

运行程序,输出:

GMT+08:00 // 效果等同于Asia/Shanghai
Asia/Shanghai
GMT-05:00 // 效果等同于America/New_York
America/New_York 

值得注意的是,这里只能用GMT+08:00,而不能用UTC+08:00,原因下文有解释。

设置默认时区

一般来说,JVM在哪里跑,默认时区就是哪。对于国内程序员来讲,一般只会接触到东八区,也就是北京时间(本地时间)。随着国际合作越来越密切,很多时候需要日期时间国际化处理,举个很实际的例子:同一份应用在阿里云部署、在AWS(海外)上也部署一份供海外用户使用,此时同一份代码部署在不同的时区了,怎么破?

倘若时区不同,那么势必影响到程序的运行结果,很容易带来计算逻辑的错误,很可能就乱套了。Java让我们有多种方式可以手动设置/修改默认时区:

  1. API方式: 强制将时区设为北京时区TimeZone.setDefault(TimeZone.getDefault().getTimeZone("GMT+8"));
  2. JVM参数方式:-Duser.timezone=GMT+8
  3. 运维设置方式:将操作系统主机时区设置为北京时区,这是推荐方式,可以完全对开发者无感,也方便了运维统一管理

据我了解,很多公司在阿里云、腾讯云、国内外的云主机上部署应用时,全部都是采用运维设置统一时区:中国时区,这种方式来管理的,这样对程序来说就消除了默认时区不一致的问题,对开发者友好。

让人恼火的夏令时

你知道吗,中国曾经也使用过夏令时。

什么是夏令时?戳这里

离现在最近是1986年至1991年用过夏令时(每年4月中旬的第一个周日2时 - 9月中旬的第一个星期日2时止):
1986年5月4日至9月14日
1987年4月12日至9月13日
1988年4月10日至9月11日
1989年4月16日至9月17日
1990年4月15日至9月16日
1991年4月14日至9月15日

夏令时是一个“非常烦人”的东西,大大的增加了日期时间处理的复杂度。比如这个灵魂拷问:若你的出生日期是1988-09-11 00:00:00(夏令时最后一天)且存进了数据库,想一想,对此日期的格式化有没有可能就会出问题呢,有没有可能被你格式化成1988-09-10 23:00:00呢?

针对此拷问,我模拟了如下代码:

@Test
public void test5() throws ParseException {
    String patterStr = "yyyy-MM-dd";
    DateFormat dateFormat = new SimpleDateFormat(patterStr);

    String birthdayStr = "1988-09-11";
    // 字符串 -> Date -> 字符串
    Date birthday = dateFormat.parse(birthdayStr);
    long birthdayTimestamp = birthday.getTime();
    System.out.println("老王的生日是:" + birthday);
    System.out.println("老王的生日的时间戳是:" + birthdayTimestamp);

    System.out.println("==============程序经过一番周转,我的同时 方法入参传来了生日的时间戳=============");
    // 字符串 -> Date -> 时间戳 -> Date -> 字符串
    birthday = new Date(birthdayTimestamp);
    System.out.println("老王的生日是:" + birthday);
    System.out.println("老王的生日的时间戳是:" + dateFormat.format(birthday));
}

这段代码,在不同的JDK版本下运行,可能出现不同的结果,有兴趣的可copy过去自行试试。

关于JDK处理夏令时(特指中国的夏令时)确实出现过问题且造成过bug,当时对应的JDK版本是1.8.0_2xx之前版本格式化那个日期出问题了,在这之后的版本貌似就没问题了。这里我提供的版本信息仅供参考,若有遇到类似case就升级JDK版本到最新吧,一般就不会有问题了。

发生这个情况是在JDK非常小的版本号之间,不太好定位精确版本号界限,所以仅供参考

总的来说,只要你使用的是较新版本的JDK,开发者是无需关心夏令时问题的,即使全球仍有很多国家在使用夏令时,咱们只需要面向时区做时间转换就没问题。

Date时区无关性

类Date表示一个特定的时间瞬间,精度为毫秒。既然表示的是瞬间/时刻,那它必然和时区是无关的,看下面代码:

@Test
public void test6() {
    String patterStr = "yyyy-MM-dd HH:mm:ss";
    Date currDate = new Date(System.currentTimeMillis());

    // 北京时区
    DateFormat bjDateFormat = new SimpleDateFormat(patterStr);
    bjDateFormat.setTimeZone(TimeZone.getDefault());
    // 纽约时区
    DateFormat newYorkDateFormat = new SimpleDateFormat(patterStr);
    newYorkDateFormat.setTimeZone(TimeZone.getTimeZone("America/New_York"));
    // 伦敦时区
    DateFormat londonDateFormat = new SimpleDateFormat(patterStr);
    londonDateFormat.setTimeZone(TimeZone.getTimeZone("Europe/London"));

    System.out.println("毫秒数:" + currDate.getTime() + ", 北京本地时间:" + bjDateFormat.format(currDate));
    System.out.println("毫秒数:" + currDate.getTime() + ", 纽约本地时间:" + newYorkDateFormat.format(currDate));
    System.out.println("毫秒数:" + currDate.getTime() + ", 伦敦本地时间:" + londonDateFormat.format(currDate));
}

运行程序,输出:

毫秒数:1610696040244, 北京本地时间:2021-01-15 15:34:00
毫秒数:1610696040244, 纽约本地时间:2021-01-15 02:34:00
毫秒数:1610696040244, 伦敦本地时间:2021-01-15 07:34:00

也就是说,同一个毫秒值,根据时区/偏移量的不同可以展示多地的时间,这就证明了Date它的时区无关性。

确切的说:Date对象里存的是自格林威治时间( GMT)1970年1月1日0点至Date所表示时刻所经过的毫秒数,是个数值。

读取字符串为Date类型

这是开发中极其常见的一种需求:client请求方扔给你一个字符串如"2021-01-15 18:00:00",然后你需要把它转为Date类型,怎么破?

问题来了,光秃秃的扔给我个字符串说是15号晚上6点时间,我咋知道你指的是北京的晚上6点,还是东京的晚上6点呢?还是纽约的晚上6点呢?

因此,对于字符串形式的日期时间,只有指定了时区才有意义。也就是说字符串 + 时区 才能精确知道它是什么时刻,否则是存在歧义的。

也许你可能会说了,自己平时开发中前端就是扔个字符串给我,然后我就给格式化为一个Date类型,并没有传入时区参数,运行这么久也没见出什么问题呀。如下所示:

@Test
public void test7() throws ParseException {
    String patterStr = "yyyy-MM-dd HH:mm:ss";

    // 模拟请求参数的时间字符串
    String dateStrParam = "2020-01-15 18:00:00";

    // 模拟服务端对此服务换转换为Date类型
    DateFormat dateFormat = new SimpleDateFormat(patterStr);
    System.out.println("格式化器用的时区是:" + dateFormat.getTimeZone().getID());
    Date date = dateFormat.parse(dateStrParam);
    System.out.println(date);
}

运行程序,输出:

格式化器用的时区是:Asia/Shanghai
Wed Jan 15 18:00:00 CST 2020

看起来结果没问题。事实上,这是因为默认情况下你们交互双发就达成了契约:双方均使用的是北京时间(时区),既然是相同时区,所以互通有无不会有任何问题。不信你把你接口给海外用户调试试?

对于格式化器来讲,虽然说编程过程中一般情况下我们并不需要给DateFormat设置时区(那就用默认时区呗)就可正常转换。但是作为高手的你必须清清楚楚,明明白白的知道这是由于交互双发默认有个相同时区的契约存在

SimpleDateFormat格式化

Java中对Date类型的输入输出/格式化,推荐使用DateFormat而非用其toString()方法。

DateFormat是一个时间格式化器抽象类,SimpleDateFormat是其具体实现类,用于以语言环境敏感的方式格式化和解析日期。它允许格式化(日期→文本)、解析(文本→日期)和规范化。

划重点:对语言环境敏感,也就是说对环境Locale、时区TimeZone都是敏感的。既然敏感,那就是可定制的

对于一个格式化器来讲,模式(模版)是其关键因素,了解一下:

日期/时间模式
格式化的模式由指定的字符串组成,未加引号的大写/小写字母(A-Z a-z)代表特定模式,用来表示模式含义,若想原样输出可以用单引号''包起来,除了英文字母其它均不解释原样输出/匹配。下面是它规定的模式字母(其它字母原样输出):

字母含义匹配类型示例
yYear2020,20
MMonthJuly; Jul; 07
d月中的天数(俗称日,最大值31)Number10
H小时(0-23)Number0,23
m分钟(0-59)Number30,59
s秒(0-59)Number30,59
---------yyyy-MM-dd HH:mm:ss(分隔符可以是任意字符,甚至汉字)
Y当前周所在的年份Year2020(不建议使用,周若跨年有坑)
S毫秒数(1-999)Number999
aam/pmTextPM
z时区通用时区Pacific Standard Time; PST; GMT-08:00
Z时区RFC 822时区-0800,+0800
X时区ISO 8601时区-08; -0800; -08:00
G年代TextAD(公元)、BC(公元前)
D年中的天数(1-366)Number360
w年中的周数(1-54)Number27
W月中的周数(1-5)Number3
E星期几名称TextTuesday; Tue
u星期几数字(1=Monday...)Number1
k小时(1-24)Number不建议使用
K/ham/pm小时数字Number一般配合a一起使用

这个表格里出现了一些“特殊”的匹配类型,做如下解释:

  • Text:格式化(Date -> String),如果模式字母的数目是4个或更多,则使用完整形式;否则,如果可能的话,使用简短或缩写形式。对于解析(String -> Date),这两种形式都一样,与模式字母的数量无关
@Test
public void test9() throws ParseException {
    String patternStr = "G GG GGGGG E EE EEEEE a aa aaaaa";
    Date currDate = new Date();

    System.out.println("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓中文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓");
    System.out.println("====================Date->String====================");
    DateFormat dateFormat = new SimpleDateFormat(patternStr, Locale.CHINA);
    System.out.println(dateFormat.format(currDate));

    System.out.println("====================String->Date====================");
    String dateStrParam = "公元 公元 公元 星期六 星期六 星期六 下午 下午 下午";
    System.out.println(dateFormat.parse(dateStrParam));

    System.out.println("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓英文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓");
    System.out.println("====================Date->String====================");
    dateFormat = new SimpleDateFormat(patternStr, Locale.US);
    System.out.println(dateFormat.format(currDate));

    System.out.println("====================String->Date====================");
    dateStrParam = "AD ad bC Sat SatUrday sunDay PM PM Am";
    System.out.println(dateFormat.parse(dateStrParam));
}

运行程序,输出:

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓中文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
====================Date->String====================
公元 公元 公元 星期六 星期六 星期六 下午 下午 下午
====================String->Date====================
Sat Jan 03 12:00:00 CST 1970
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓英文地区模式↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
====================Date->String====================
AD AD AD Sat Sat Saturday PM PM PM
====================String->Date====================
Sun Jan 01 00:00:00 CST 1970

观察打印结果,除了符合模式规则外,还能在String -> Date解析时总结出两点结论:

  1. 英文单词,不分区大小写。如SatUrday sunDay都是没问题,但是不能有拼写错误
  2. 若有多个part表示一个意思,那么last win。如Sat SatUrday sunDay最后一个生效

对于Locale地域参数,因为中文不存在格式、缩写方面的特性,因此这些规则只对英文地域(如Locale.US生效)

  • Number:格式化(Date -> String),模式字母的数量是数字的【最小】数量,较短的数字被零填充到这个数量。对于解析(String -> Date),模式字母的数量将被忽略,除非需要分隔两个相邻的字段
  • Year:对于格式化和解析,如果模式字母的数量是4个或更多,则使用特定于日历的长格式。否则,使用日历特定的简短或缩写形式
  • Month:如果模式字母的数量是3个或更多,则被解释为文本;否则,它将被解释为一个数字。
  • 通用时区:如果该时区有名称,如Pacific Standard Time、PST、CST等那就用名称,否则就用GMT规则的字符串,如:GMT-08:00
  • RFC 822时区:遵循RFC 822格式,向下兼容通用时区(名称部分除外)
  • ISO 8601时区:对于格式化,如果与GMT的偏移值为0(也就是格林威治时间喽),则生成“Z”;如果模式字母的数量为1,则忽略小时的任何分数。例如,如果模式是“X”,时区是“GMT+05:30”,则生成“+05”。在进行解析时,“Z”被解析为UTC时区指示符。一般时区不被接受。如果模式字母的数量是4个或更多,在构造SimpleDateFormat或应用模式时抛出IllegalArgumentException。

    • 这个规则理解起来还是比较费劲的,在开发中一般不太建议使用此种模式。若要使用请务必本地做好测试

SimpleDateFormat的使用很简单,重点是了解其规则模式。最后关于SimpleDateFormat的使用再强调这两点哈:

  1. SimpleDateFormat并非线程安全类,使用时请务必注意并发安全问题
  2. 若使用SimpleDateFormat去格式化成非本地区域(默认Locale)的话,那就必须在构造的时候就指定好,如Locale.US
  3. 对于Date类型的任何格式化、解析请统一使用SimpleDateFormat

JSR 310类型

曾经有个人做了个很有意思的投票,统计对Java API的不满意程度。最终Java Date/Calendar API斩获第二烂(第一烂是Java XML/DOM),体现出它烂的点较多,这里给你例举几项:

  1. 定义并不一致,在java.util和java.sql包中竟然都有Date类,而且呢对它进行格式化/解析类竟然又跑到java.text去了,精神分裂啊
  2. java.util.Date等类在建模日期的设计上行为不一致,缺陷明显。包括易变性、糟糕的偏移值、默认值、命名等等
  3. java.util.Date同时包含日期和时间,而其子类java.sql.Date却仅包含日期,这是什么神继承?

@Test
public void test10() {
    long currMillis = System.currentTimeMillis();

    java.util.Date date = new Date(currMillis);
    java.sql.Date sqlDate = new java.sql.Date(currMillis);
    java.sql.Time time = new Time(currMillis);
    java.sql.Timestamp timestamp = new Timestamp(currMillis);

    System.out.println("java.util.Date:" + date);
    System.out.println("java.sql.Date:" + sqlDate);
    System.out.println("java.sql.Time:" + time);
    System.out.println("java.sql.Timestamp:" + timestamp);
}

运行程序,输出:

java.util.Date:Sat Jan 16 21:50:36 CST 2021
java.sql.Date:2021-01-16
java.sql.Time:21:50:36
java.sql.Timestamp:2021-01-16 21:50:36.733
  • 国际化支持得并不是好,比如跨时区操作、夏令时等等

Java 自己也实在忍不了这么难用的日期时间API了,于是在2014年随着Java 8的发布引入了全新的JSR 310日期时间。JSR-310源于精品时间库joda-time打造,解决了上面提到的所有问题,是整个Java 8最大亮点之一。

JSR 310日期/时间 所有的 API都在java.time这个包内,没有例外。

当然喽,本文重点并不在于讨论JSR 310日期/时间体系,而是看看JSR 310日期时间类型是如何处理上面Date类型遇到的那些case的。

时区/偏移量ZoneId

在JDK 8之前,Java使用java.util.TimeZone来表示时区。而在JDK 8里分别使用了ZoneId表示时区,ZoneOffset表示UTC的偏移量。

值得提前强调,时区和偏移量在概念和实际作用上是有较大区别的,主要体现在:

  1. UTC偏移量仅仅记录了偏移的小时分钟而已,除此之外无任何其它信息。举个例子:+08:00的意思是比UTC时间早8小时,没有地理/时区含义,相应的-03:30代表的意思仅仅是比UTC时间晚3个半小时
  2. 时区是特定于地区而言的,它和地理上的地区(包括规则)强绑定在一起。比如整个中国都叫东八区,纽约在西五区等等
中国没有夏令时,所有东八区对应的偏移量永远是+8;纽约有夏令时,因此它的偏移量可能是-4也可能是-5哦

综合来看,时区更好用。令人恼火的夏令时问题,若你使用UTC偏移量去表示那么就很麻烦,因为它可变:一年内的某些时期在原来基础上偏移量 +1,某些时期 -1;但若你使用ZoneId时区去表示就很方便喽,比如纽约是西五区,你在任何时候获取其当地时间都是能得到正确答案的,因为它内置了对夏令时规则的处理,也就是说啥时候+1啥时候-1时区自己门清,不需要API调用者关心。

UTC偏移量更像是一种写死偏移量数值的做法,这在天朝这种没有时区规则(没有夏令时)的国家不会存在问题,东八区和UTC+08:00效果永远一样。但在一些夏令时国家(如美国、法国等等),就只能根据时区去获取当地时间喽。所以当你不了解当地规则时,最好是使用时区而非偏移量。

ZoneId

它代表一个时区的ID,如Europe/Paris。它规定了一些规则可用于将一个Instant时间戳转换为本地日期/时间LocalDateTime。

上面说了时区ZoneId是包含有规则的,实际上描述偏移量何时以及如何变化的实际规则由java.time.zone.ZoneRules定义。ZoneId则只是一个用于获取底层规则的ID。之所以采用这种方法,是因为规则是由政府定义的,并且经常变化,而ID是稳定的

对于API调用者来说只需要使用这个ID(也就是ZoneId)即可,而需无关心更为底层的时区规则ZoneRules,和“政府”同步规则的事是它领域内的事就交给它喽。如:夏令时这条规则是由各国政府制定的,而且不同国家不同年一般都不一样,这个事就交由JDK底层的ZoneRules机制自行sync,使用者无需关心。

ZoneId在系统内是唯一的,它共包含三种类型的ID:

  1. 最简单的ID类型:ZoneOffset,它由'Z'和以'+'或'-'开头的id组成。如:Z、+18:00、-18:00
  2. 另一种类型的ID是带有某种前缀形式的偏移样式ID,例如'GMT+2'或'UTC+01:00'。可识别的(合法的)前缀是'UTC', 'GMT'和'UT'
  3. 第三种类型是基于区域的ID(推荐使用)。基于区域的ID必须包含两个或多个字符,且不能以'UTC'、'GMT'、'UT' '+'或'-'开头。基于区域的id由配置定义好的,如Europe/Paris

概念说了一大推,下面给几个代码示例感受下吧。

1、获取系统默认的ZoneId:

@Test
public void test1() {
    // JDK 1.8之前做法
    System.out.println(TimeZone.getDefault());
    // JDK 1.8之后做法
    System.out.println(ZoneId.systemDefault());
}

输出:
Asia/Shanghai
sun.util.calendar.ZoneInfo[id="Asia/Shanghai",offset=28800000,dstSavings=0,useDaylight=false,transitions=29,lastRule=null]

二者结果是一样的,都是Asia/Shanghai。因为ZoneId方法底层就是依赖TimeZone,如图:

2、指定字符串得到一个ZoneId:

@Test
public void test2() {
    System.out.println(ZoneId.of("Asia/Shanghai"));
    // 报错:java.time.zone.ZoneRulesException: Unknown time-zone ID: Asia/xxx
    System.out.println(ZoneId.of("Asia/xxx"));
}

很明显,这个字符串也是不能随便写的。那么问题来了,可写的有哪些呢?同样的ZoneId提供了API供你获取到所有可用的字符串id,有兴趣的同学建议自行尝试:

@Test
public void test3() {
    ZoneId.getAvailableZoneIds();
}

3、根据偏移量得到一个ZoneId:

@Test
public void test4() {
    ZoneId zoneId = ZoneId.ofOffset("UTC", ZoneOffset.of("+8"));
    System.out.println(zoneId);
    // 必须是大写的Z
    zoneId = ZoneId.ofOffset("UTC", ZoneOffset.of("Z"));
    System.out.println(zoneId);
}

输出:
UTC+08:00
UTC

这里第一个参数传的前缀,可用值为:"GMT", "UTC", or "UT"。当然还可以传空串,那就直接返回第二个参数ZoneOffset。若以上都不是就报错

注意:根据偏移量得到的ZoneId内部并无现成时区规则可用,因此对于有夏令营的国家转换可能出问题,一般不建议这么去做。

4、从日期里面获得时区:

@Test
public void test5() {
    System.out.println(ZoneId.from(ZonedDateTime.now()));
    System.out.println(ZoneId.from(ZoneOffset.of("+8")));

    // 报错:java.time.DateTimeException: Unable to obtain ZoneId from TemporalAccessor:
    System.out.println(ZoneId.from(LocalDateTime.now()));
    System.out.println(ZoneId.from(LocalDate.now()));
}

虽然方法入参是TemporalAccessor,但是只接受带时区的类型,LocalXXX是不行的,使用时稍加注意。

ZoneOffset

距离格林威治/UTC的时区偏移量,例如+02:00。值得注意的是它继承自ZoneId,所以也可当作一个ZoneId来使用的,当然并不建议你这么去做,请独立使用。

时区偏移量是时区与格林威治/UTC之间的时间差。这通常是固定的小时数和分钟数。世界不同的地区有不同的时区偏移量。在ZoneId类中捕获关于偏移量如何随一年的地点和时间而变化的规则(主要是夏令时规则),所以继承自ZoneId。

1、最小/最大偏移量:因为偏移量传入的是数字,这个是有限制的哦

@Test
public void test6() {
    System.out.println("最小偏移量:" + ZoneOffset.MIN);
    System.out.println("最小偏移量:" + ZoneOffset.MAX);
    System.out.println("中心偏移量:" + ZoneOffset.UTC);
    // 超出最大范围
    System.out.println(ZoneOffset.of("+20"));
}

输出:
最小偏移量:-18:00
最小偏移量:+18:00
中心偏移量:Z

java.time.DateTimeException: Zone offset hours not in valid range: value 20 is not in the range -18 to 18

2、通过时分秒构造偏移量(使用很方便,推荐):

@Test
public void test7() {
    System.out.println(ZoneOffset.ofHours(8));
    System.out.println(ZoneOffset.ofHoursMinutes(8, 8));
    System.out.println(ZoneOffset.ofHoursMinutesSeconds(8, 8, 8));

    System.out.println(ZoneOffset.ofHours(-5));

    // 指定一个精确的秒数  获取实例(有时候也很有用处)
    System.out.println(ZoneOffset.ofTotalSeconds(8 * 60 * 60));
}

// 输出:
+08:00
+08:08
+08:08:08
-05:00
+08:00

看来,偏移量是能精确到秒的哈,只不过一般来说精确到分钟已经到顶了。

设置默认时区

ZoneId并没有提供设置默认时区的方法,但是通过文章可知ZoneId获取默认时区底层依赖的是TimeZone.getDefault()方法,因此设置默认时区方式完全遵照TimeZone的方式即可(共三种方式,还记得吗?)。

让人恼火的夏令时

因为有夏令时规则的存在,让操作日期/时间的复杂度大大增加。但还好JDK尽量的屏蔽了这些规则对使用者的影响。因此:推荐使用时区(ZoneId)转换日期/时间,一般情况下不建议使用偏移量ZoneOffset去搞,这样就不会有夏令时的烦恼啦。

JSR 310时区相关性

java.util.Date类型它具有时区无关性,带来的弊端就是一旦涉及到国际化时间转换等需求时,使用Date来处理是很不方便的。

JSR 310解决了Date存在的一系列问题:对日期、时间进行了分开表示(LocalDate、LocalTime、LocalDateTime),对本地时间和带时区的时间进行了分开管理。LocalXXX表示本地时间,也就是说是当前JVM所在时区的时间;ZonedXXX表示是一个带有时区的日期时间,它们能非常方便的互相完成转换。

@Test
public void test8() {
    // 本地日期/时间
    System.out.println("================本地时间================");
    System.out.println(LocalDate.now());
    System.out.println(LocalTime.now());
    System.out.println(LocalDateTime.now());

    // 时区时间
    System.out.println("================带时区的时间ZonedDateTime================");
    System.out.println(ZonedDateTime.now()); // 使用系统时区
    System.out.println(ZonedDateTime.now(ZoneId.of("America/New_York"))); // 自己指定时区
    System.out.println(ZonedDateTime.now(Clock.systemUTC())); // 自己指定时区
    System.out.println("================带时区的时间OffsetDateTime================");
    System.out.println(OffsetDateTime.now()); // 使用系统时区
    System.out.println(OffsetDateTime.now(ZoneId.of("America/New_York"))); // 自己指定时区
    System.out.println(OffsetDateTime.now(Clock.systemUTC())); // 自己指定时区
}

运行程序,输出:

================本地时间================
2021-01-17
09:18:40.703
2021-01-17T09:18:40.703
================带时区的时间ZonedDateTime================
2021-01-17T09:18:40.704+08:00[Asia/Shanghai]
2021-01-16T20:18:40.706-05:00[America/New_York]
2021-01-17T01:18:40.709Z
================带时区的时间OffsetDateTime================
2021-01-17T09:18:40.710+08:00
2021-01-16T20:18:40.710-05:00
2021-01-17T01:18:40.710Z

本地时间的输出非常“干净”,可直接用于显示。带时区的时间显示了该时间代表的是哪个时区的时间,毕竟不指定时区的时间是没有任何意义的。LocalXXX因为它具有时区无关性,因此它不能代表一个瞬间/时刻。

另外,关于LocalDateTime、OffsetDateTime、ZonedDateTime三者的跨时区转换问题,以及它们的详解,因为内容过多放在了下文专文阐述,保持关注。

读取字符串为JSR 310类型

一个独立的日期时间类型字符串如2021-05-05T18:00-04:00它是没有任何意义的,因为没有时区无法确定它代表那个瞬间,这是理论当然也适合JSR 310类型喽。

遇到一个日期时间格式字符串,要解析它一般有这两种情况:

  1. 不带时区/偏移量的字符串:要么不理它说转换不了,要么就约定一个时区(一般用系统默认时区),使用LocalDateTime来解析
@Test
public void test11() {
    String dateTimeStrParam = "2021-05-05T18:00";
    LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam);
    System.out.println("解析后:" + localDateTime);
}

输出:
解析后:2021-05-05T18:00
  1. 带时区字/偏移量的符串:
@Test
public void test12() {
    // 带偏移量 使用OffsetDateTime 
    String dateTimeStrParam = "2021-05-05T18:00-04:00";
    OffsetDateTime offsetDateTime = OffsetDateTime.parse(dateTimeStrParam);
    System.out.println("带偏移量解析后:" + offsetDateTime);

    // 带时区 使用ZonedDateTime 
    dateTimeStrParam = "2021-05-05T18:00-05:00[America/New_York]";
    ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeStrParam);
    System.out.println("带时区解析后:" + zonedDateTime);
}

输出:
带偏移量解析后:2021-05-05T18:00-04:00
带时区解析后:2021-05-05T18:00-04:00[America/New_York]

请注意带时区解析后这个结果:字符串参数偏移量明明是-05,为毛转换为ZonedDateTime后偏移量成为了-04呢???

这里是我故意造了这么一个case引起你的重视,对此结果我做如下解释:

如图,在2021.03.14 - 2021.11.07期间,纽约的偏移量是-4,其余时候是-5。本例的日期是2021-05-05处在夏令时之中,因此偏移量是-4,这就解释了为何你显示的写了-5最终还是成了-4。

JSR 310格式化

针对JSR 310日期时间类型的格式化/解析,有个专门的类java.time.format.DateTimeFormatter用于处理。

DateTimeFormatter也是一个不可变的类,所以是线程安全的,比SimpleDateFormat靠谱多了吧。另外它还内置了非常多的格式化模版实例供以使用,形如:

格式化器示例
ofLocalizedDate(dateStyle)'2021-01-03'
ofLocalizedTime(timeStyle)'10:15:30'
ofLocalizedDateTime(dateTimeStyle)'3 Jun 2021 11:05:30'
ISO_LOCAL_DATE'2021-12-03'
ISO_LOCAL_TIME'10:15:30'
ISO_LOCAL_DATE_TIME'2021-12-03T10:15:30'
ISO_OFFSET_DATE_TIME'2021-12-03T10:15:30+01:00'
ISO_ZONED_DATE_TIME'2021-12-03T10:15:30+01:00[Europe/Paris]'
@Test
public void test13() {
    System.out.println(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()));
    System.out.println(DateTimeFormatter.ISO_LOCAL_TIME.format(LocalTime.now()));
    System.out.println(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now()));
}

输出:
2021-01-17
22:43:21.398
2021-01-17T22:43:21.4

若想自定义模式pattern,和Date一样它也可以自己指定任意的pattern 日期/时间模式。由于本文在Date部分详细介绍了日期/时间模式,各个字母代表什么意思以及如何使用,这里就不再赘述了哈。

虽然DateTimeFormatter支持的模式比Date略有增加,但大体还保持一致,个人觉得这块无需再花精力。若真有需要再查官网也不迟
@Test
public void test14() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("第Q季度 yyyy-MM-dd HH:mm:ss", Locale.US);

    // 格式化输出
    System.out.println(formatter.format(LocalDateTime.now()));

    // 解析
    String dateTimeStrParam = "第1季度 2021-01-17 22:51:32";
    LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam, formatter);
    System.out.println("解析后的结果:" + localDateTime);
}

Q/q:季度,如3; 03; Q3; 3rd quarter。

最佳实践

  • 弃用Date,拥抱JSR 310

每每说到JSR 310日期/时间时我都会呼吁,保持惯例我这里继续啰嗦一句:放弃Date甚至禁用Date,使用JSR 310日期/时间吧,它才是日期时间处理的最佳实践。

另外,在使用期间关于制定时区(默认时区时)依旧有一套我心目中的最佳实践存在,这里分享给你:

  • 永远显式的指定你需要的时区,即使你要获取的是默认时区
// 方式一:普通做法
LocalDateTime.now();

// 方式二:最佳实践
LocalDateTime.now(ZoneId.systemDefault());

如上代码二者效果一模一样。但是方式二是最佳实践。

理由是:这样做能让代码带有明确的意图,消除模棱两可的可能性,即使获取的是默认时区。拿方式一来说吧,它就存在意图不明确的地方:到底是代码编写者忘记指定时区欠考虑了,还是就想用默认时区呢?这个答案如果不通读上下文是无法确定的,从而造成了不必要的沟通维护成本。因此即使你是要获取默认时区,也请显示的用ZoneId.systemDefault()写上去。

  • 使用JVM的默认时区需当心,建议时区和当前会话保持绑定

这个最佳实践在特殊场景用得到。这么做的理由是:JVM的默认时区通过静态方法TimeZone#setDefault()可全局设置,因此JVM的任何一个线程都可以随意更改默认时区。若关于时间处理的代码对时区非常敏感的话,最佳实践是你把时区信息和当前会话绑定,这样就可以不用再受到其它线程潜在影响了,确保了健壮性。

说明:会话可能只是当前请求,也可能是一个Session,具体case具体分析

总结

通过上篇文章 对日期时间相关概念的铺垫,加上本文的实操代码演示,达到弄透Java对日期时间的处理基本不成问题。

两篇文章的内容较多,信息量均比较大,消化起来需要些时间。一方面我建议你先搜藏留以当做参考书备用,另一方面建议多实践,代码这东西只有多写写才能有更深体会。

后面会再用3 -4篇文章对这前面这两篇的细节、使用场景进行补充,比如如何去匹配ZoneId和Offset的对应关系,LocalDateTime、OffsetDateTime、ZonedDateTime跨时区互转问题、在Spring MVC场景下使用的最佳实践等等,敬请关注,一起进步。

本文思考题

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. Date类型如何处理夏令时?
  2. ZoneId和ZoneOffset有什么区别?
  3. 平时项目若遇到日期时间的处理,有哪些最佳实践?

推荐阅读

GMT UTC CST ISO 夏令时 时间戳,都是些什么鬼?

关注我

分享、成长,拒绝浅藏辄止。关注【BAT的乌托邦】回复关键字专栏有Spring技术栈、中间件等小而美的纯原创专栏。本文已被 https://www.yourbatman.cn 收录。

本文所属专栏:JDK日期时间,公号后台回复专栏名即可获取全部内容。

A哥(YourBatman):Spring Framework/Boot开源贡献者,Java架构师。非常注重基本功修养,相信底层基础决定上层建筑,坚实基础才能焕发程序员更强生命力。文章特点为以小而美专栏形式重构知识体系,抽丝剥茧,致力于做人人能看懂的最好的专栏系列。可加我好友(fsx1056342982)共勉哦!

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月18日

GMT UTC CST ISO 夏令时 时间戳,都是些什么鬼?

你好,我是A哥(YourBatman)。

日期/时间的处理是平时开发中非常常见的场景,若只是简单的格式化场景那就还好,一旦涉及到时区、跨地域跨时区时间转换场景,甚至当还有GMT时间、UTC时间等一堆概念堆上来的时候,总是心理发虚,招架不住。

在地球村的信息化时代背景下,跨国企业/跨国做生意的公司越来越多,所以我们程序员遇到不同时区之间的日期/时间转换/显示的概率大大增加。譬如说:电商平台的商品下单时间,你给中国人页面里展示北京时间是ok的,但你总不能给美国人也展示北京时间吧?否则美国人看到很多订单的下单时间是凌晨1、2点,还以为午夜凶铃呢。

Java在版本8之前用Date类型来表示日期/时间,自版本8起引入了JSR 310日期/时间类型。两套体系对于本地时间、时区时间、带时区的格式化都有着不同的处理办法。

A哥因为跨时区日期转换问题,最近搞了一起生产事故,为此我痛定思痛,决定把经验整理成文,目的是以后再也不踩这方面的坑,同时也帮助大家。

本部分一共会分两篇文章叙述:

  1. 概念篇:科普GMT、UTC、时区、时间戳、夏令时等常见概念以及背景
  2. 实战篇:在1的基础上(概念必须先知晓,否则实战无法进行),Java是如何来处理GMT/UTC时间、时区、偏移量、夏令时...的

这两篇文章搞完,自己再也不用不担心在日期/时间方面埋bug了。相信我,这两篇文章十分具有收藏价值

本文提纲

版本约定

  • JDK:8

✍正文

下面将围绕一些日期/时间的概念分别做主题讲解,这些名词你无一例外的都听过,但我猜测大概率你并不理解,甚至是知晓它们的区别。

GMT:格林威治时间

格林威治(也称:格林尼治)时间,也叫世界时(Universal Time),也叫世界标准时间。是指位于英国伦敦郊区的【皇家格林尼治天文台】的标准时间,是本初子午线上的地方时,是0时区的区时。本初子午线

众所周知,天朝统一用的北京时间是位于东八区(+8)与标准时间相差8小时。什么含义?举个例子:若GMT(英国伦敦的格林威治)现在是上午11点,那中国北京时间现在就是 11 + 8 = 19点(下午7点)。

将这个公式再抽象一下,可表示为:本地时间=GMT+时区差

北京位于东八区,则时区差N=+8,美国纽约位于西五区,则时区差N=-5。这么算来,若北京时间是晚上23点的话,美国纽约时间就是当天上午10点(23 - 8 - 5 = 10)

凭什么格林威治作为标准时间?

你可能会问,大家都有腰间盘,为何格林威治的那么突出呢?

大背景是这样子的:19世纪开始,世界各国来往开始频繁,而欧洲大陆、美洲大陆和亚洲大陆都有各自的时区,为提高沟通效率避免混乱,各国的代表1884年在美国华盛顿召开了国际大会,选出英国伦敦的格林威治作为全球时间的中心点,并由它负责维护和计算,从1924年开始,格林威治天文台每小时就会向全世界播报时间(截止到1979年)。

在美国华盛顿开会,确定英国伦敦作为时间中心点,还蛮滑稽O(∩_∩)O哈哈~

其实选择英国格林威治最主要原因是:当时大部分的船只都已经以格林威治子午线做为参考标准,毕竟曾经的英国可是日不落帝国,大航海时代末便开始称霸世界,拳头里面出政权。

格林威治天文台在计时领域的权威是非常大的,譬如离我们最近的一次“时间风波”:在即将跨世纪的时候,世界各国对21世纪到底应该从2000年开始还是从2001年开始争论不休,最终还是格林威治天文台出面平息了争论,开新闻发布会宣布21世纪始于2001年
格林威治天文台(旧址)

地球自转

地球绕自转轴自西向东的转动(太阳东起西落),所以东时区的人会比西时区的人一些看到太阳,从而时间上会早一点。

以本初子午线为中心,按照地球自转方向,每隔经度15°划分一个时区的方法,全球共分为24个时区:东1区至东12区,西1区至西12区,其中东西12区跨度都是7.5°也叫半时区。

中国有哪几个时区?

1个,这是一个错得比较合理的答案。合理是因为中国虽然幅员辽阔,但全国使用统一的北京时间,所以很容易被误以为只有一个时区。

错是因为拍脑袋想一想就知道,中国东西横跨5000+公里,怎么可能只躺在一个时区呢?正确答案是:中国大陆共横跨5个时区,各个时区大致的方位图如下:

看图就清晰明了的知道天朝为何选用东八区时间作为全国标准时间了吧?没错,仅就因为北京在东八区,即使地图上只有弹丸大小,但就是这么豪横。

中国用统一时间在沟通上确实方便得多,减少了很多不必要的麻烦。但是也带来一些“小问题”,比如新疆的朋友(位于东5/6区)实际比东八区的北京时间晚了 2-3个小时,我们正常7点天黑准备吃完饭的时候,新疆那边还太阳当空照呢,还蛮有意思的~

美国有哪几个时区?

说到时区,就不得不提及计划再次伟大的美国了。美国同样的幅员辽阔,横跨了4个时区:

如图所示共有四个时区时间,按照图中颜色划分开(并非严格划分,不然出现同一小区隔壁时间比你晚1小时就尴尬了),从右到左依次为:

  1. 东部时区(ET):西5区,代表城市:华盛顿特区、纽约、迈阿密等,也称纽约时间。北京时间 = ET + 13h
  2. 中部时区(CT):西6区,代表城市:芝加哥、休斯顿等。北京时间 = CT + 14h
  3. 山地时区(MT):西7区,代表城市:丹佛、凤凰城等。北京时间 = MT + 15h
  4. 太平洋时区(PST):西8区,代表城市:洛杉矶、拉斯维加斯、西雅图等。北京时间 = PST + 16h

GMT和Http协议的渊源

这是我“偶遇”的一个知识点,在这里也一并分享给你。

Http 1.1协议对日期时间传输格式是有严格规定的,支持如下三种格式:

其中第一种格式是互联网传输的标准格式,也是现行的标准。2、3种纯是为了兼容Http 1.0而设计,现在基本已经淘汰没人再会使用,所以事实上的格式只有第一种这1种,作为一个有经验的程序员对这种格式应该不陌生。

另外,还有个关键的知识点:所有HTTP日期/时间戳都必须用格林威治标准时间(GMT)表示,没有例外。对于HTTP来说,GMT完全等于UTC(协调世界时)。

当然喽,这一切都是由浏览器自动帮你完成的,毕竟Http协议是浏览器去搞的不是

UTC:世界标准时间

Coordinated Universal Time直译为:世界协调时间。它是以原子时作为计量单位的时间,计算结果极其严谨和精密。它比GMT时间更来得精准,误差值必须保持在0.9秒以内,倘若大于0.9秒就会通过闰秒来“解决”。

原子时:物质的原子内部发射的电磁振荡频率为基准的时间计量系统。美国的物理实验市在2014年造出了人类历史上最精确的原子钟,50亿年误差1s,可谓相当靠谱了。中国的铯原子钟也能确保2000万年误差不超过1s。

大事记:1979年12月初内瓦举行的世界无线电行政大会通过决议,确定用“世界协调时间(UTC时间)”取代“格林威治时间(GMT时间)”,作为无线电通信领域内的国际标准时间。

UTC和GMT的区别

UTC和GMT都称作世界标准时间,为毛有了GMT还搞出个UTC,到底有何区别,下面做出简述。

GMT:老的时间计量标准,根据地球的自转和公转来计算时间的,自转一圈是一天,公转一圈是一年。但是呢,地球公转的轨道是椭圆形的:

并且后来人们发现地球的自转时间也并不是恒定的,这么一来就会造成有一天时间长一些,有一天时间短一些的情况,误差较大给人感觉时间不那么“精准”了,因此迫切需要一个更加精准的方案来计时,UTC诞生了。

UTC:1967年人类制作出原子钟,从而“发明”了UTC时间正式投入使用。它是真正意义上的标准时间,以原子钟所定义的秒长为基础,UTC时间认为一个太阳日(一天)总是恒定的86400秒(24小时)。

UTC是协调时间,含义为:一切以我为基准,全部想我看齐。所以称它为世界标准时间是没毛病的,而把GMT称作格林威治当地时间更为合适(也叫旧的标准时间)。

UTC和GMT的联系

由于在大多数情况下,UTC时间能与GMT时间互换。对此很多同学就丈二和尚摸不着头脑了,他俩这不就一样的吗?

其实非也。这里用通俗易懂的一句话来告知它俩的联系:UTC是标准时间参照,像GMT(格林威治时间)、ET(美国东部时间)、PST(太平洋时间)、CST(北京时间)等等都是具体的时区时间。GMT能和UTC直接转换,仅仅是因为碰巧GMT是0时区时间,数值上刚好和UTC是相等的(不需要精确到秒的情况下,二者可以视为相等),看起来一样,但是概念含义上请务必区分开来哈。

UTC与偏移量

在日常生活中,我们所使用的时间肯定是本地时间。在只有GMT的时候,本地时间是通过时区计算出来的,而现在UTC才是标准参考,因此采用UTC和偏移量(Offset)的方式来表示本地时间:

这个偏移量可表示为:UTC -UTC +,后面接小时数,分钟数。如:UTC +9:30表示澳大利亚中央标准时间,UTC +8表示中国标准时间。偏移量常见的表示形式有:±[hh]:[mm]±[hh][mm]±[hh]这三种方式均可。

举个例子:现在UTC时间是10:30z(z表示偏移量=0),那么北京时间现在若是1630 +0800(下午4点半),对应的纽约时间就是0530 -0500(早上5点半)。

注意:在UTC的世界里并无时区的概念,而是偏移量(时间点跟上偏移量才是一个正规的UTC时间),它和时区并无直接关系

可以看到偏移量可以精确到分钟级别控制,非常精细化。全球只有24个时区(只能精确到小时),但偏移量有“无数个”。当然喽为了方便沟通,时间日期联盟组织把世界主要国家/城市的偏移量汇总起来且都给取了个Time zone name名称用于沟通,共好几百个,部分截图如下:

偏移量和国家/城市名称的全部对应关系,请参考网址(直接访问,无需梯子):https://www.timeanddate.com/t...

CST

CST这个缩写比较尴尬的是它可以同时代表四个不同的时间:

  • CST (China Standard Time) :中国标准时间 UTC+8:00
  • Central Standard Time (USA) UTC-6:00
  • Central Standard Time (Australia) UTC+9:30
  • Cuba Standard Time UTC-4:00

CST到底啥意思就看如何翻译喽,所以需要根据上下文语境自行抉择哈。

ISO

在时间日期上它全称是ISO 8601,是一种日期/时间表示方法的规范。规定了一种明确的、国际上都能理解的日历和时钟格式。

这一ISO标准有助于消除各种日-日惯例、文化和时区对全球业务产生的影响。它提供了一种显示日期和时间的方式,这种方式是明确定义的,对人和机器都是可以理解的。当日期用数字表示时,它们可以以不同的方式进行解释。例如,01/05/12可以表示2012年1月5日或2012年5月1日。在个人层面上,这种不确定性可能非常令人沮丧,在商业环境中,它可能非常昂贵。在日期不明确的情况下,组织会议和交付、书写合同和购买机票都是非常困难的。

ISO 8601通过制定一种国际公认的日期表示方式来解决这种不确定性:YYYY-MM-DD。例如 September 27, 2012就会被表示为2012-09-27。

很多开发语言内置了一些常用的ISO标准日期/时间格式,如Java中的:

  • ISO.DATE:yyyy-MM-dd, e.g. "2000-10-31"
  • ISO.TIME:HH:mm:ss.SSSXXX, e.g. "01:30:00.000-05:00"
  • ISO.DATE_TIME:yyyy-MM-dd'T'HH:mm:ss.SSSXXX, e.g. "2000-10-31T01:30:00.000-05:00".

夏令时

DST(Daylight Saving Time),夏令时又称夏季时间(可没有冬令时哦)。它是为节约能源而人为规定地方时间的制度(鼓励人们早睡早起,不要浪费电,夏天日照时间长尽量多用自然资源),全球约40%的国家在夏季使用夏令时,其他国家则全年只使用标准时间。正在使用夏令时的代表国家:美国、欧盟、俄罗斯等等。

每年的夏令时时间段还不一样(一般在3月的第2个周日开始),比如美国2020年夏令时时间是:2020年3月8日 - 2020年11月1日。具体做法是:在3.8号这天将时钟往前拨拨1个小时,11.1号这天还原回来。

中国在1986 - 1992年短暂搞过一段时间,但太麻烦就“废弃”了

大事记:目前全世界有近110个国家每年要实行夏令时。 自2011年3月27日开始俄罗斯永久使用夏令时,把时间拨快一小时,不再调回。

时间戳

现实生活的世界里,时间是不断向前的,如果向前追溯时间的起点,可能是宇宙出生时,又或是是宇宙出现之前,但肯定是我们目前无法找到的,我们不知道现在距离时间原点的精确距离。所以我们要表示时间, 就需要人为定义一个原点。它就是:格林威治时间(GMT)1970年1月1日的午夜0点0分0秒。

时间戳一般指的UNIX时间,或类UNIX系统(比如Linux、MacOS等)使用的时间表示方式。定义为:从UTC时间的1970-1-1 0:0:0起到现在的总秒数(秒是毫秒、微妙、纳秒的总称)。

但是不可忽略的一个case:由于闰秒的存在,那么当闰秒发生时,就极有可能出现同一个时间戳表示两个时刻的情况(类似时钟回拨),而且闰秒还没规律所以无法程序式的避免,怎么破?

这个时候就需要一种专门的对时协议来保证了,它就是:网络时间协议。

网络时间协议

网络时间协议 Network Time Protocol(NTP)是用来使计算机时间同步化的一种协议,它可以使计算机对其服务器或时钟源(如石英钟,GPS等等)做同步化,它可以提供高精准度的时间校正(LAN上与标准间差小于1毫秒,WAN上几十毫秒),且可介由加密确认的方式来防止恶毒的协议攻击。

NTP的目的是在无序的Internet环境中提供精确和健壮的时间服务,各大操作系统(windows/Linux)对NTP都有实现。

✍总结

简单地讲呢,GMT格林威治时间可认为是以前的标准时间,而UTC时间是现在在使用的世界时间标准;时区是以本初子午线为中心来划分的,东为正西为负,本处子午线就位于英国伦敦的格林威治;夏令时是地方的时间制度(并非全球标准),施行夏令时的地方,每年有2天很特殊,即一天只有23个小时,另一天则有25个小时。

从源头上彻底了解了这些概念,将会让我们在处理与时间相关的问题时如虎添翼。本文介绍了好些个日期/时间方面的概念,文字偏多,所以建议你收藏起来当作参考书来使用。

下篇文章将会接着本文内容,站在实战的角度,介绍Java是如何实现GMT和UTC时间的,以及各种case下的使用和避坑指南,欢迎关注我。

♨本文思考题♨

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 中国是南半球还是北半球?东半球还是西半球?
  2. GMT时间和UTC时间有何区别和联系?
  3. 中国有夏令时没?

☀推荐阅读☀

♚声明♚

本文所属专栏:Java进阶,公号后台回复专栏名即可获取全部内容。

分享、成长,拒绝浅藏辄止。关注【BAT的乌托邦】,回复关键字专栏有Spring技术栈、中间件等小而美的原创专栏供以免费学习。本文已被 https://www.yourbatman.cn 收录。

本文是 A哥(YourBatman)原创文章,未经作者允许/开白不得转载,谢谢合作。

BAT的乌托邦

查看原文

赞 0 收藏 0 评论 0

YourBatman 发布了文章 · 1月15日

9. 细节见真章,Formatter注册中心的设计很讨巧

你好,我是A哥(YourBatman)。

Spring设计了org.springframework.format.Formatter格式化器接口抽象,对格式化器进行了大一统,让你只需要关心统一的API,而无需关注具体实现,相关议题上篇文章 有详细介绍。

Spring内建有不少格式化器实现,同时对它们的管理、调度使用也有专门的组件负责,可谓泾渭分明,职责清晰。本文将围绕Formatter注册中心FormatterRegistry展开,为你介绍Spring是如何优雅,巧妙的实现注册管理的。

学习编码是个模仿的过程,绝大多数时候你并不需要创造东西。当然这里指的模仿并非普通的CV模式,而是取精华为己所用,本文所述巧妙设计便是精华所在,任君提取。

这几天进入小寒天气,北京迎来最低-20℃,最高-11℃的冰点温度,外出注意保暖

本文提纲

版本约定

  • Spring Framework:5.3.x
  • Spring Boot:2.4.x

✍正文

对Spring的源码阅读、分析这么多了,会发现对于组件管理大体思想都一样,离不开这几个组件:注册中心(注册员) + 分发器

一龙生九子,九子各不同。虽然大体思路保持一致,但每个实现在其场景下都有自己的发挥空间,值得我们向而往之。

FormatterRegistry:格式化器注册中心

field属性格式化器的注册表(注册中心)。请注意:这里强调了field的存在,先混个眼熟,后面你将能有较深体会。

public interface FormatterRegistry extends ConverterRegistry {

    void addPrinter(Printer<?> printer);
    void addParser(Parser<?> parser);
    void addFormatter(Formatter<?> formatter);
    void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter);
    void addFormatterForFieldType(Class<?> fieldType, Printer<?> printer, Parser<?> parser);

    void addFormatterForFieldAnnotation(AnnotationFormatterFactory<? extends Annotation> annotationFormatterFactory);
}

此接口继承自类型转换器注册中心ConverterRegistry,所以格式化注册中心是转换器注册中心的加强版,是其超集,功能更多更强大。

关于类型转换器注册中心ConverterRegistry的详细介绍,可翻阅本系列的这篇文章,看完后门清

虽然FormatterRegistry提供的添加方法挺多,但其实基本都是在描述同一个事:为指定类型fieldType添加格式化器(printer或parser),绘制成图如下所示:

说明:最后一个接口方法除外,addFormatterForFieldAnnotation()和格式化注解相关,因为它非常重要,因此放在下文专门撰文讲解

FormatterRegistry接口的继承树如下:

有了学过ConverterRegistry的经验,这种设计套路很容易被看穿。这两个实现类按层级进行分工:

  • FormattingConversionService:实现所有接口方法
  • DefaultFormattingConversionService:继承自上面的FormattingConversionService,在其基础上注册默认的格式化器

事实上,功能分类确实如此。本文重点介绍FormattingConversionService,这个类的设计实现上有很多讨巧之处,只要你来,要你好看。

FormattingConversionService

它是FormatterRegistry接口的实现类,实现其所有接口方法。

FormatterRegistryConverterRegistry的子接口,而ConverterRegistry接口的所有方法均已由GenericConversionService全部实现了,所以可以通过继承它来间接完成 ConverterRegistry接口方法的实现,因此本类的继承结构是这样子的(请细品这个结构):

FormattingConversionService通过继承GenericConversionService搞定“左半边”(父接口ConverterRegistry);只剩“右半边”待处理,也就是FormatterRegistry新增的接口方法。

FormattingConversionService:

    @Override
    public void addPrinter(Printer<?> printer) {
        Class<?> fieldType = getFieldType(printer, Printer.class);
        addConverter(new PrinterConverter(fieldType, printer, this));
    }
    @Override
    public void addParser(Parser<?> parser) {
        Class<?> fieldType = getFieldType(parser, Parser.class);
        addConverter(new ParserConverter(fieldType, parser, this));
    }
    @Override
    public void addFormatter(Formatter<?> formatter) {
        addFormatterForFieldType(getFieldType(formatter), formatter);
    }
    @Override
    public void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter) {
        addConverter(new PrinterConverter(fieldType, formatter, this));
        addConverter(new ParserConverter(fieldType, formatter, this));
    }
    @Override
    public void addFormatterForFieldType(Class<?> fieldType, Printer<?> printer, Parser<?> parser) {
        addConverter(new PrinterConverter(fieldType, printer, this));
        addConverter(new ParserConverter(fieldType, parser, this));
    }

从接口的实现可以看到这个“惊天大秘密”:所有的格式化器(含Printer、Parser、Formatter)都是被当作Converter注册的,也就是说真正的注册中心只有一个,那就是ConverterRegistry

格式化器的注册管理远没有转换器那么复杂,因为它是基于上层适配的思想,最终适配为Converter来完成注册的。所以最终注册进去的实际是个经由格式化器适配来的转换器,完美复用了那套复杂的转换器管理逻辑。

这种设计思路,完全可以“CV”到我们自己的编程思维里吧

甭管是Printer还是Parser,都会被适配为GenericConverter从而被添加到ConverterRegistry里面去,被当作转换器管理起来。现在你应该知道为何FormatterRegistry接口仅需提供添加方法而无需提供删除方法了吧。

当然喽,关于Printer/Parser的适配实现亦是本文本文关注的焦点,里面大有文章可为,let's go!

PrinterConverter:Printer接口适配器

Printer<?>适配为转换器,转换目标为fieldType -> String

private static class PrinterConverter implements GenericConverter {
    
    private final Class<?> fieldType;
    // 从Printer<?>泛型里解析出来的类型,有可能和fieldType一样,有可能不一样
    private final TypeDescriptor printerObjectType;
    // 实际执行“转换”动作的组件
    private final Printer printer;
    private final ConversionService conversionService;

    public PrinterConverter(Class<?> fieldType, Printer<?> printer, ConversionService conversionService) {
        ...
        // 从类上解析出泛型类型,但不一定是实际类型
        this.printerObjectType = TypeDescriptor.valueOf(resolvePrinterObjectType(printer));
        ...
    }

    // fieldType -> String
    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
        return Collections.singleton(new ConvertiblePair(this.fieldType, String.class));
    }

}

既然是转换器,重点当然是它的convert转换方法:

PrinterConverter:

    @Override
    @SuppressWarnings("unchecked")
    public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        // 若sourceType不是printerObjectType的子类型
        // 就尝试用conversionService转一下类型试试
        // (也就是说:若是子类型是可直接处理的,无需转换一趟)
        if (!sourceType.isAssignableTo(this.printerObjectType)) {
            source = this.conversionService.convert(source, sourceType, this.printerObjectType);
        }
        if (source == null) {
            return "";
        }

        // 执行实际转换逻辑
        return this.printer.print(source, LocaleContextHolder.getLocale());
    }

转换步骤分为两步:

  1. 类型(实际类型)不是该Printer类型的泛型类型的子类型的话,那就尝试使用conversionService转一趟

    1. 例如:Printer处理的是Number类型,但是你传入的是Person类型,这个时候conversionService就会发挥作用了
  2. 交由目标格式化器Printer执行实际的转换逻辑

可以说Printer它可以直接转,也可以是构建在conversionService 之上 的一个转换器:只要源类型是我处理的,或者经过conversionService后能成为我处理的类型,都能进行转换。有一次完美的能力复用

说到这我估计有些小伙伴还不能理解啥意思,能解决什么问题,那么下面我分别给你用代码举例,加深你的了解。

准备一个Java Bean:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person {

    private Integer id;
    private String name;
}

准备一个Printer:将Integer类型加10后,再转为String类型

private static class IntegerPrinter implements Printer<Integer> {

    @Override
    public String print(Integer object, Locale locale) {
        object += 10;
        return object.toString();
    }
}

示例一:使用Printer,无中间转换

测试用例:

@Test
public void test2() {
    FormattingConversionService formattingConversionService = new FormattingConversionService();
    FormatterRegistry formatterRegistry = formattingConversionService;
    // 说明:这里不使用DefaultConversionService是为了避免默认注册的那些转换器对结果的“干扰”,不方便看效果
    // ConversionService conversionService = new DefaultConversionService();
    ConversionService conversionService = formattingConversionService;

    // 注册格式化器
    formatterRegistry.addPrinter(new IntegerPrinter());

    // 最终均使用ConversionService统一提供服务转换
    System.out.println(conversionService.canConvert(Integer.class, String.class));
    System.out.println(conversionService.canConvert(Person.class, String.class));

    System.out.println(conversionService.convert(1, String.class));
    // 报错:No converter found capable of converting from type [cn.yourbatman.bean.Person] to type [java.lang.String]
    // System.out.println(conversionService.convert(new Person(1, "YourBatman"), String.class));
}

运行程序,输出:

true
false
11

完美。

但是,它不能完成Person -> String类型的转换。一般来说,我们有两种途径来达到此目的:

  1. 直接方式:写一个Person转String的转换器,专用

    1. 缺点明显:多写一套代码
  2. 组合方式(推荐):如果目前已经有Person -> Integer的了,那我们就组合起来用就非常方便啦,下面这个例子将告诉你使用这种方式完成“需求”

    1. 缺点不明显:转换器一般要求与业务数据无关,因此通用性强,应最大可能的复用

下面示例二将帮你解决通过复用已有能力方式达到Person -> String的目的。

示例二:使用Printer,有中间转换

基于示例一,若要实现Person -> String的话,只需再给写一个Person -> Integer的转换器放进ConversionService里即可。

说明:一般来说ConversionService已经具备很多“能力”了的,拿来就用即可。本例为了帮你说明底层原理,所以用的是一个“干净的”ConversionService实例
@Test
public void test2() {
    FormattingConversionService formattingConversionService = new FormattingConversionService();
    FormatterRegistry formatterRegistry = formattingConversionService;
    // 说明:这里不使用DefaultConversionService是为了避免默认注册的那些转换器对结果的“干扰”,不方便看效果
    // ConversionService conversionService = new DefaultConversionService();
    ConversionService conversionService = formattingConversionService;

    // 注册格式化器
    formatterRegistry.addFormatterForFieldType(Person.class, new IntegerPrinter(), null);
    // 强调:此处绝不能使用lambda表达式代替,否则泛型类型丢失,结果将出错
    formatterRegistry.addConverter(new Converter<Person, Integer>() {
        @Override
        public Integer convert(Person source) {
            return source.getId();
        }
    });

    // 最终均使用ConversionService统一提供服务转换
    System.out.println(conversionService.canConvert(Person.class, String.class));
    System.out.println(conversionService.convert(new Person(1, "YourBatman"), String.class));
}

运行程序,输出:

true
11

完美。

针对本例,有如下关注点:

  1. 使用addFormatterForFieldType()方法注册了IntegerPrinter,并且明确指定了处理的类型:只处理Person类型

    1. 说明:IntegerPrinter是可以注册多次分别用于处理不同类型。比如你依旧可以保留formatterRegistry.addPrinter(new IntegerPrinter());来处理Integer -> String是木问题的
  2. 因为IntegerPrinter 实际上 只能转换 Integer -> String,因此还必须注册一个转换器,用于Person -> Integer桥接一下,这样就串起来了Person -> Integer -> String。只是外部看起来这些都是IntegerPrinter做的一样,特别工整
  3. 强调:addConverter()注册转换器时请务必不要使用lambda表达式代替输入,否则会失去泛型类型,导致出错

    1. 若想用lambda表达式,请使用addConverter(Class,Class,Converter)这个重载方法完成注册

ParserConverter:Parser接口适配器

Parser<?>适配为转换器,转换目标为String -> fieldType

private static class ParserConverter implements GenericConverter {

    private final Class<?> fieldType;
    private final Parser<?> parser;
    private final ConversionService conversionService;

    ... // 省略构造器

    // String -> fieldType
    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
        return Collections.singleton(new ConvertiblePair(String.class, this.fieldType));
    }
    
}

既然是转换器,重点当然是它的convert转换方法:

ParserConverter:

    @Override
    @Nullable
    public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        // 空串当null处理
        String text = (String) source;
        if (!StringUtils.hasText(text)) {
            return null;
        }
        
        ...
        Object result = this.parser.parse(text, LocaleContextHolder.getLocale());
        ...
        
        // 解读/转换结果
        TypeDescriptor resultType = TypeDescriptor.valueOf(result.getClass());
        if (!resultType.isAssignableTo(targetType)) {
            result = this.conversionService.convert(result, resultType, targetType);
        }
        return result;
    }

转换步骤分为两步:

  1. 通过Parser将String转换为指定的类型结果result(若失败,则抛出异常)
  2. 判断若result属于目标类型的子类型,直接返回,否则调用ConversionService转换一把

可以看到它和Printer的“顺序”是相反的,在返回值上做文章。同样的,下面将用两个例子来加深理解。

private static class IntegerParser implements Parser<Integer> {

    @Override
    public Integer parse(String text, Locale locale) throws ParseException {
        return NumberUtils.parseNumber(text, Integer.class);
    }
}

示例一:使用Parser,无中间转换

书写测试用例:

@Test
public void test3() {
    FormattingConversionService formattingConversionService = new FormattingConversionService();
    FormatterRegistry formatterRegistry = formattingConversionService;
    ConversionService conversionService = formattingConversionService;

    // 注册格式化器
    formatterRegistry.addParser(new IntegerParser());

    System.out.println(conversionService.canConvert(String.class, Integer.class));
    System.out.println(conversionService.convert("1", Integer.class));
}

运行程序,输出:

true
1

完美。

示例二:使用Parser,有中间转换

下面示例输入一个“1”字符串,出来一个Person对象(因为有了上面例子的铺垫,这里就“直抒胸臆”了哈)。

@Test
public void test4() {
    FormattingConversionService formattingConversionService = new FormattingConversionService();
    FormatterRegistry formatterRegistry = formattingConversionService;
    ConversionService conversionService = formattingConversionService;

    // 注册格式化器
    formatterRegistry.addFormatterForFieldType(Person.class, null, new IntegerParser());
    formatterRegistry.addConverter(new Converter<Integer, Person>() {
        @Override
        public Person convert(Integer source) {
            return new Person(source, "YourBatman");
        }
    });

    System.out.println(conversionService.canConvert(String.class, Person.class));
    System.out.println(conversionService.convert("1", Person.class));
}

运行程序,啪,空指针了:

java.lang.NullPointerException
    at org.springframework.format.support.FormattingConversionService$PrinterConverter.resolvePrinterObjectType(FormattingConversionService.java:179)
    at org.springframework.format.support.FormattingConversionService$PrinterConverter.<init>(FormattingConversionService.java:155)
    at org.springframework.format.support.FormattingConversionService.addFormatterForFieldType(FormattingConversionService.java:95)
    at cn.yourbatman.formatter.Demo.test4(Demo.java:86)
    ...

根据异常栈信息,可明确原因为:addFormatterForFieldType()方法的第二个参数不能传null,否则空指针。这其实是Spring Framework的bug,我已向社区提了issue,期待能够被解决喽:

为了正常运行本例,这么改一下:

// 第二个参数不传null,用IntegerPrinter占位
formatterRegistry.addFormatterForFieldType(Person.class, new IntegerPrinter(), new IntegerParser());

再次运行程序,输出:

true
Person(id=1, name=YourBatman)

完美。

针对本例,有如下关注点:

  1. 使用addFormatterForFieldType()方法注册了IntegerParser,并且明确指定了处理的类型,用于处理Person类型

    1. 也就是说此IntegerParser专门用于转换目标类型为Person的属性
  2. 因为IntegerParser 实际上 只能转换 String -> Integer,因此还必须注册一个转换器,用于Integer -> Person桥接一下,这样就串起来了String -> Integer -> Person。外面看起来这些都是IntegerParser做的一样,非常工整
  3. 同样强调:addConverter()注册转换器时请务必不要使用lambda表达式代替输入,否则会失去泛型类型,导致出错

二者均持有ConversionService带来哪些增强?

说明:关于如此重要的ConversionService你懂的,遗忘了的可乘坐电梯到这复习

对于PrinterConverter和ParserConverter来讲,它们的源目的是实现 String <-> Object,特点是:

  • PrinterConverter:出口必须是String类型,入口类型也已确定,即Printer<T>的泛型类型,只能处理 T(或T的子类型) -> String
  • ParserConverter:入口必须是String类型,出口类型也已确定,即Parser<T>的泛型类型,只能处理 String -> T(或T的子类型)

按既定“规则”,它俩的能力范围还是蛮受限的。Spring厉害的地方就在于此,可以巧妙的通过组合的方式,扩大现有组件的能力边界。比如本利中它就在PrinterConverter/ParserConverter里分别放入了ConversionService引用,从而到这样的效果:

ConversionService

通过能力组合协作,起到串联作用,从而扩大输入/输出“范围”,感觉就像起到了放大镜的效果一样,这个设计还是很讨巧的。

✍总结

本文以介绍FormatterRegistry接口为中心,重点研究了此接口的实现方式,发现即使小小的一枚注册中心实现,也蕴藏有丰富亮点供以学习、CV。

一般来说ConversionService天生具备非常强悍的转换能力,因此实际情况是你若需要自定义一个Printer/Parser的话是大概率不需要自己再额外加个Converter转换器的,也就是说底层机制让你已然站在了“巨人”肩膀上。

♨本文思考题♨

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. FormatterRegistry作为注册中心只有添加方法,why?
  2. 示例中为何强调:addConverter()注册转换器时请务必不要使用lambda表达式代替输入,会有什么问题?
  3. 这种功能组合/桥接的巧妙设计方式,你脑中还能想到其它案例吗?

☀推荐阅读☀

♚声明♚

本文所属专栏:Spring类型转换,公号后台回复专栏名即可获取全部内容。

分享、成长,拒绝浅藏辄止。关注【BAT的乌托邦】,回复关键字专栏有Spring技术栈、中间件等小而美的原创专栏供以免费学习。本文已被 https://www.yourbatman.cn 收录。

本文是 A哥(YourBatman)原创文章,未经作者允许/开白不得转载,谢谢合作。

BAT的乌托邦

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 39 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-05-05
个人主页被 3.6k 人浏览