暂时未有相关云产品技术能力~
打开/查找测试光标定位在类内部的任何位置,可快速跳转到对应的测试类or快速创建该类的测试用例。如果你也“喜欢”写UT,保证代码质量,相信这个快捷键能节约你不少时间。✌查找、替换文字/内容类似于全文查找,功能强大。在当前文件查找在所有文件查找是查找当前文件的加强版(所以看到没,加了shift功能键)。Tips:默认情况下也只会在当前项目(In Project)下查找。如上图所示,Scope也是可调整的哈这个快捷键因为“强大”,被很多同学误用。正所谓通用性和精确性往往不可兼得,建议专键专用。再提醒一次:不要误用,不要误用,不要误用✌查找代码查找代码是个很宽泛的说法,比如说我们经常需要知道这个类/变量在哪些地方被用到了、类的继承结构是怎么样的、在哪儿申明的…这些都可认为是查找代码的范畴。所有使用的地方(窗口形式) 注意:这个快捷键不是command组合哟选中类元素(类、接口、变量、属性、方法…),按此快捷键可以显示出该元素在哪些地方被使用到了。这种窗口方式对于某元素被很多地方使用的时候比较有好,因为可以分工程、分jar包、分目录的进行展示,清晰明了,一般用于查找中大型框架里面的引用情况(如Spring框架、MyBatis框架等)。但是,对于我们自己书写的一些元素,毕竟不太可能被很多地方用到,这个时候使用下面的列表形式可能更为合适。所有使用的地方(列表形式) 这种方式显示的信息没有那么的全,所以快捷性更强,操作方便。笔者的习惯是,使用它为主,使用上面的窗口形式为辅。元素声明处/使用处该快捷键有两个作用:跳到元素的声明处(若光标处在元素使用的地方)99.99%情况下声明是不存在歧义的,所以按下此快捷键会直接调转到“目的地”(列表形式)展示出使用该元素的地方,效果同option + command + F7现在知道笔者为何喜欢使用列表形式了吧,因为大部分情况下这一个快捷键搞定:声明、使用两大功能,岂不快哉。Tips:该快捷键功能同鼠标操作的 按住command + 鼠标单击。即使如此,还是建议,远离鼠标,远离鼠标,远离鼠标实现 注意:接口的实现、类的继承都属于该范畴。这里笔者以一个抽象类为例:若某接口/类的实现有多个,则会弹窗让你选择,否则(只有一个实现)就直导航过去了。当然喽,若没有任何实现,就会弹窗提示说没有任何实现Tips:该快捷键功能同鼠标操作的 按住command + option + 鼠标单击。即使如此,还是建议,远离鼠标,远离鼠标,远离鼠标请注意本功能和上面的“元素声明处/使用处”的区别哈,前者要求必须要有继承/实现关系,后者只需有使用就能查找到(毕竟,继承/实现也属于一种使用嘛)。类的层次/继承树 某些复杂的场景,某个类的继承关系相当的多,这个时候通过层次/树的方式来查看是最好的。该快捷键很好的对上面的command + option + b(实现)形成了补充:当某个类/接口的实现较少时,使用command + option + b即可快速导航当某个类/接口的实现较多时,或者想关注层次结构时,那就使用本快捷键吧文件结构(列表方式)结构就相当于“解刨”,该快捷键可以快速看到某个类的所有元素,包括:所有成员、所有方法。说明:加入某个类的结构元素很多,那在下面的窗口方式查看更为合适。文件结构(窗口方式)该窗口上面附有工具栏,可格局需要进行展示、隐藏、展开、排序等操作,非常方便。✌其它导航其它导航并非不重要,而是不方便分大类,所以列为其它吧。上一次修改这个快捷键巨好用且非常智能。好用体现在:将你从任意地方瞬间带回到现场,接着思绪敲代码智能体现在:你在“同一个地方”编辑多次只会算作一次,智能决策比如你在浏览了其它代码、框架源码若干时间后,不知道自己写到哪了,使用它即可一键召回。按1次回到上次编辑处,按2次回到上上次,3次就是上上上次,依此类推…Tips:IDEA会记住你最近编辑的地方,所以能快速回去。但是,一旦重启IDEA就会清空“记忆”哈你可能会问有没有Next Edit Location(下一次修改)的功能,答案是有的,但IDEA默认并没有帮你绑定快捷键,笔者估计是IDEA觉得(大部分场景下)这可以使用前进、后退快捷键代替,让使用者可以少记忆一个吧。下一个错误 当一个类里错误较多时,使用该快捷键可快速定位错误所在地,非常方便和快速。用鼠标操作看似问题不大,但谨记咱们的目标:尽量的脱离鼠标。前进、后退这两个快捷键非常非常非常常用,不解释。行、列快速定位到当前文件的行、列位置。该快捷键最常见的使用场景:抛出异常时,异常栈里会显示错误的行、列代码静态检查时,会展示错误的具体行、列使用此快捷键可快速定位到错误处,非常方便。行头/行尾还记得操作系统级别的行头、行尾快捷键吗?回忆一下下图:IDEA因为处在编辑框里,因此针对性的提供了响应快捷键。Tips:总结来看,回到行头/行尾的快捷键有好几个了,具体使用哪个?根据个人习惯使用即可另外,还有些基础键可结合(以上快捷键)一起使用:option+左右:一次移动一个单词shift+左右:选中✍总结21世纪的今天,没有导航软件,如同盲人?在IDEA里亦是如此,没有(快捷键)导航,使用的效率将大打折扣。本文主要介绍了IDEA快捷键—导航篇,提纲挈领了一下,剩下的就是练习,练习,再练习了!快捷键没有任何技巧性,练就完了!假期里多操练几遍,你就是下一个高手。
✍前言春节快乐,阖家幸福! 今天,你能阅读完这篇技术文吗?上篇文章(【方向盘】使用IDEA的60+个快捷键分享给你,权为了提效(操作系统、终端篇))向jar人们介绍了笔者在操作系统、终端层面经常使用的一些快捷键,本文继续,上主菜:IntelliJ IDEA快捷键。在Java开发者中,一直存在着很多鄙视链。如(前者bs后者):IntelliJ IDEA → Eclipse → NetBeansUnix → Linux → Mac OS→ Windows → DOSEmacs → Vim → Sublime → Word → Power Point就笔者自己来讲,算是一个IDEA快捷键重度依赖患者,一个在IDEA里不使用快捷键,几乎没法工作的选手。 版本约定Mac OS 12.2iTerm2 3.4.14(zsh 5.8)IntelliJ IDEA 2021.3.2✍正文几乎每个软件或多或少都会有快捷键,由此来提高使用效率。几乎每个人都愿意相信快捷键是能够提高效率的,但常常还是一个鼠标走天下。研发人员效率的最大障碍是什么 鼠标,鼠标,还是tm的鼠标。诚然,鼠标是现代计算机不可或缺的外设。它极大程度降低了使用计算机的门槛,但作为各自领域的专业人士,追求效率应当:重(双手操作的)键盘,轻(单手操作的)鼠标。本文将以IDEA为例,笔者分享自己在使用快捷键上的一些实战心得。IDEA快捷键模板如何选择?说明:每个软件的快捷键,(没有冲突的情况下)保持默认是最好的,十分不建议自定义关于IDEA快捷键模板这块,笔者得承认自己是走了好几年“弯路”的,经验教训在这里分享出来。如下图所示,这是笔者很长时间(2017-2021长达4年之久)使用的快捷键模板:基于Eclipse快捷键模板的私人定制版 下面对这两个“关键词”进行解释解释。✌Eclipse快捷键模板对于大多数新生代程序员(2016年之后入行)来讲,Eclipse大概率只听过但没用过,那值得恭喜:没有快捷键切换的包袱。IntelliJ IDEA相较于Eclipse是后起之秀,早已成为JVM生态IDE领域绝对霸主。如下图所示(2021年统计的结果):JVM圈最受欢迎的IDE,IntelliJ IDEA可谓遥遥领先。 IDEA超越Eclipse有几个关键时间点,留个印象:2001年1月:首个IntelliJ IDEA版本正式发布2012年12月:IntelliJ IDEA支持炫黑主题。也是这一年,IntelliJ IDEA的综合表现实现了对老牌免费IDE Eclipse的超越,然后慢慢侵蚀着它的市占率2016年:这一年在市占率上,IntelliJ IDEA也完成了对Eclipse的超越。自此,IntelliJ IDEA来到舞台中央,成为JVM圈使用最广泛的IDE奈何笔者入行较早,2015年8月就已入行(开始使用基于Eclipse的STS),2017年8月入职新公司才首次接触到IntelliJ IDEA。2年,你知道这2年我怎么过的吗? 这2年经过不断操练,Eclipse快捷键早已成为肌肉记忆,难以“摆脱”。2017年入职新公司必须使用IntelliJ IDEA的时候,碍于工作压力,我毅然决定沿用Eclipse的快捷键习惯,该决定便是我走弯路的开始。当初为何没有选择适应IntelliJ IDEA而选择沿用Eclipse的快捷键呢?这可能就是没有逃脱人性的弱点喽:舒适区。回头想想,这是一个只看到短期收益而忽略了长期价值的决定,是不明智的。如果上天再给我一次机会,我觉得正确的做法是:短期内(比如半年内)先沿用Eclipse的快捷键方案以确保入职新公司后不会因为IDE问题而让开发效率打折扣,但之后(比如半年以后)对公司业务、人员比较熟悉后,能腾出时间了就一定要记得回归“正道”,全面拥抱变化。笔者目前情况:已全部切回IntelliJ IDEA原生方式(默认的Mac OS快捷键模板),可喜可贺😄。✌个人定制版每个IDE都提供自定义快捷键的能力,IntelliJ IDEA自然也不例外。所以,我在这里又走了弯路:自定义了很多快捷键。我自定义了不少快捷键,好处是:自个用起来更顺手。但缺点非常明显:在其它人电脑上,我就像个“盲人”。自定义虽好,但也不要贪用哦。个人经验,若真需要DIY快捷键,那么:只做增加,不做修改,以保持和别人的最大公约数不会改变,自然也就拥有更好的“兼容性”。说明:快捷键表面看起来是私有行为,但其实它的普适性也是非常重要的IntelliJ IDEA快捷键接下就是“正文”了,笔者将自己常用的一些快捷键分享出来,供你参考。按照功能大类,分别展开。✌导航/查找物件在我眼中,这部分最重要的。查找的重要性不言而喻,如何能快速定位到自己想要的类、文件、地点,将能直接体现出对IDEA的熟练程度,自然也会节约你非常非常多的时间。终极查找/导航 上来就放大招:终极导航。一般的快捷键是执行某个Action,而它是查找Action,只需知道Action Name就能通过它(间接)导航过去,并且还帮你显示了对应快捷键哦。说实话,此快捷键笔者使用得并不多,毕竟通过它得经过2步才能“到达”目的地,但它对“新手”是很友好的,所以也推荐一下。打开/查找类IntelliJ IDEA里笔者认为最最最常用的快捷键,甚至没有之一。command + o在整个操作系统层面都具有非常明确的语义,所有用户都知道不需要“教训”。该快捷键在IDEA里被定义为查找/打开类,足矣见得它的高频性和重要性。其实,不仅仅是IDEA,IntelliJ旗下的其它IDE产品都赋予了command + o非常重要的语义,如:goland和DataGrip Tips:这种查找方式只匹配类名/表名/go文件名,不关心里面的内容,所以检索速度非常非常非常快。小技巧有时候需要打开某个二方、三方库里的某个类的源代码,使用command + o找对应的类可能找不到:如ArrayList默认情况下command + o检索的scope范围是:Project Files(当前工程)。上图可以看到(当前工程)没有找到ArrayList,IDEA非常“智能”的告诉了你如何去做:将搜索范围改为All places。说明:具有确定性的搜索结果能给予用户最好的使用体验,而并非检索出来一大推结果还需人工二次筛选,(无法盲操作)大大降低效率。确定性一般通过缩小检索范围 + AI人工智能推测来实现,IntelliJ IDEA就是这么做的,很高级将搜索范围改为All places有两种做法:使用鼠标点击选择:依旧没有脱离鼠标,不推荐 2.再按一次command + o:全键盘操作,推荐通过command + o可以实现Project Files和All Places的自由切换,非常方便。另外,还有一个查找小技巧是模糊搜索,也很常用。比如你在检索时是否遇到这种情况:不记得类的全名,只记得前面部分,或者中间部分,或者最后部分,甚至只记得零零碎碎的几个字母类名太长,不想全部输入(太慢)举个栗子,要打开EntityManagerFactoryDependsOnPostProcessor这个类,我的做法是只在搜索框里这么输入就可以迅速打开我想要的: 说明:从Eclipse迁移过来的小伙伴喜欢使用*这列通配符去模糊匹配,在IntelliJ IDEA里就duck不必这么做了,因为它足够智能(当然你写*也是阔以的)。使用误区非常非常非常多同学在查找/打开类时有个使用误区:使用File in Files搜索一切,就像这样对于这样的结果,你不觉得乱花渐欲迷人眼么?如果你也是这么做的,那么从看到笔者这篇文章开始就默默的改正哈。问题来了,这两种检索有什么区别呢?Go to Class…:有且仅检索类名,所以内部类也是可以被非常快速检索到的注意:不是文件名,而是类名。只是单文件单类在99.9999%情况下名称都相同而已,所以不要有误解哈Find in Files:用于检索文件的内容。当你需要关心文件内容的时候(如配置key的名称、字符串内容等)就使用它总结:如若你现在还使用Find in Files来进行打开类的话,会发现可能90%都是干扰项,觉得还能提高效率嘛?那你还会继续使用吗?打开/查找文件 它用于根据文件名查找/打开文件。可以看到,它会将文件的后缀名也显示出来。理由很简单:它检索的是文件,文件,文件(后缀名不一样就是不同文件)。我再“变个戏法”以加深理解可以看到,“同样名称”的.java和.class文件都被搜到了,这就是查找文件的“强大之处”。一般来讲,shift功能键是同类功能做加强,这里应该能体会到吧笔者眼中的最佳实践:找类用command + o,而command + shift + o专用来找配置文件(当前project或者lib包内)、普通文件。 打开/查找符号理解这个快捷键的关键,在于理解什么叫Symbol(符号),笔者根据自己的使用经验,尝试帮你总结一下,包括:Class类方法名在Spring容器里的Bean名称枚举项全局(静态)属性名成员(静态)属性名Rest URI路径…非常“强悍”有木有,总而言之,能够成为类的一部分的都可以通过它找到,这得益于Java强类型、静态语言的特性,用好了可大大提效,谁还说Java语言编程没有脚本语言快呢?Tips:这个检索的内容虽然多,但速度也还是非常快的。道理很简单:结构化的元素找起来就是快。
✍正文每个程序员都相信快捷键可以成倍提高效率,但只有少数人愿意为之。笔者以自己为例,从操作系统层面、终端层面、IDE层面分享常用的快键键操作。申明:以下列出快捷键都是本人平时最常使用的,属于经验之谈而并非未经实战的文档性教程。操作系统层面快捷键以下快捷键以Mac OS为例。✌ 通用快捷键command是mac里最重要的组合键,以简写的cmd代替。这部分快捷键具有普适性:几乎在每个App里都有同样的行为。所以是最简单、最常用,当然也是最重要的。✌ 场景快捷键下面这些快捷键,在常见的一些场景里派上用场。拖动顶部菜单栏图标顺序你知道吗,Mac顶部菜单栏的图标顺序是可以拖动,自定义排序的。做法是:按住cmd键,使用鼠标移动预览内容这是Mac非常实用的功能:不用打开文件/文件夹,快速预览内容。这在看图、看文件时非常好用。用法为:选中文件/文件夹,单击“空格键”即可。强制退出应用在windows时,当软件卡死点击x无法关闭应用时,可以调起资源管理器,来进行强行关闭。在Mac中也有类似的操作方式:强制退出。快键键是:cmd + option + esc(这几个键左手单手很难完成,建议左手cmd+esc+右手option)个人习惯建议:对于option这个组合键,一般都使用右手来触达比较方便些文件重命名Mac对文件的重命名非常方便,操作为:选中文件,按回车。这里可能有些同学就不习惯了:按回车一般不都是打开文件/文件夹吗?是的,这确实是和windows有点不一样的地方。在Mac里打开文件/文件夹是使用cmd + o组合键来完成的。✌ 神奇的option键它在Mac里的作用挺神奇的,很多老粉称它为神奇、魔力键。显示文件路径和windows会在顶部实时显示当前路径不一样,Mac若想查看选中文件的完整路径,可以这么操作:选中文件,按下option键一小会,底部状态栏就可以看到该文件的全路径啦。加强右键菜单Mac的右键菜单看似没有Windows那样来得丰富,其实不然。比如:这是正常的右键菜单按住option的右键菜单查看ip地址这是一个很常见的“需求”吧。有了option键我们就不必去ifconfig那么麻烦啦,直接按住option键点击wifi图标即可:option键是个“神奇”的组合键,更多功能针对不同的App还可以自行发掘。总之,当你觉得某功能应该有,但是直接看又没看见的时候,那就试试option组合键吧,时常会发现惊喜哦!✌ 超实用的文本快捷键诚然,文字工作者(包括程序员)绝大部分情况下操作的都是文本内容,所以文本快捷键是最重要,必知必会的提效神器。下面的快捷键几乎可以用于任何文本输入的地方,包括但不限于记事本、备忘录、IDE、终端、搜索框…Home键和End键用惯快捷键的小伙伴知道,这两个键非常好用,可以说不可或缺。Home:任意位置快速回到行头End:任意位置快速回到行尾不同于Windows,纵观Mac的键盘并未发现这两个按键 :难道如此智能的Mac系统不支持这种便捷操作?当然不是,它提供了组合键来达到同样的效果:fn + ←:效果同Home键fn + →:效果同End键所有的文本编辑场景(如txt、word、IDEA、搜索框、终端)里,这套组合快捷键都有Home/End的语义。但你或许可能会疑问:为何在浏览器里写文字时,有时候好使(如在输入框填写内容),有时候不好使(如在CSDN的编辑器里写文章),怎么回事???不卖关子了,直接说根本原因:如果是在浏览器的输入框里(input、textarea等)编辑文本,这套组合键的语义是正确的,否则语义就变为了:fn + ←:回到页顶fn + →:回到页尾那么问题来了,为何在CSDN的编辑器里写文章(同样是编辑文本呀),怎么不好使呢?其实,本质原因是CSDN的md编辑器是用Html画出来的(这样才能显示图片、粗细、颜色嘛),而非输入组件: 这就很容易解释为何这看起来也是在浏览器里编辑文本,但fn那套组合键的语义变为了页顶/页尾了吧。那么怎么破?难道在类似于CSDN的md编辑器这种情况就无法实现Home/End了?当然不是,这时候可以使用:control + a:效果同Home键control + e:效果同End键这组快捷键在文本编辑的大部分情况下和fn + ←/→有着相同的语义,可以通用。对此,分享下我的个人习惯是:优先使用fn + ←/→完成功能,只有它俩不好使了(比如CSDN编辑器下)才会采用control + a/e替补。说明:笔者喜欢使用fn + ←/→的原因是可以配合shift键快速完成文本的选中,若使用control + a/e的话就感觉不顺手回到页顶和回到页尾若页面很长,用鼠标/触摸板慢慢滚动显得效率过低,这时用这两个快捷键就更加得心应手了。这组快捷键在浏览“大”网页时很常用,对应快捷键为:cmd + ↑:回到页顶cmd + ↓:回到页尾这组快捷键在浏览器里效果等价于(👆🏻已经说了在浏览器里它俩的效果了哈):fn + ←:回到页顶fn + →:回到页尾总而言之,建议优先使用cmd + ↑/↓这组快捷键来表达语义。其它翻页快捷键:fn + ↑:相当于Page Upfn + ↓:相当于Page Down按行滚动快捷键:control + p:上一行(相当于↑)control + n:下一行(相当于↓)这哥俩用得很少,毕竟使用↑↓方向键来得更直接。但在某些没有方向键的键盘里(如HHKC),这对组合键就非常有用喽。“截断式”删除:control + k:光标所在位置的后面内容全部删除,相当于截断丢弃尾部一样按单词(根据空格识别单词分隔)移动光标:option + ←:向左移动一个单词option + →:向右移动一个单词←/→是一位一位的移动光标,Home/End是直接将光标干到头部/尾部,这是一块一慢的两个极端。然后,大多数时候我们只是需要在中间移动,但也希望能快一点移动,这个时候这组快捷键就非常好使啦,对提高移动效率的效果显著。值得注意的是:只能按照单词移动。中文并不属于此行列,换句话讲:即使成千上万的中文字、中文符号都被认为是一个单词,直到遇上英文符号。因此该功能对于中文文字编辑者几乎没啥用武之地,但对于程序员真的非常非常好用!终端层面快捷键终端笔者使用的Iterm 2,shell使用的zsh。 上面介绍过的快捷键,在终端输入里很多都是同样的语义。但由于终端的特殊性,对某些组合键有特殊处理,所以还是有必要单独聊聊的。✌ 移动光标b:back,回退;f:front,前进。可以看到,在终端里按单词移动,不能使用option组合键了哈。✌ 删除字符control + h和control + d使用←/→ + delete键可代替且语义更清晰些;control + u和control + k是最常用的。✌ 其它另外,Iterm自己有几个快捷键平时使用还比较多的:✍总结把快捷键三个字拆分开来有两部分含义:快捷:方便触达,节省时间键:使用键盘完成任何东西并非越多越好,快捷键也是一样。当设置的/使用的快捷键非常的多,导致在使用的时候常常会想不起来使用哪个时,那么就失去了快捷的意义。为了使用而使用显然并非明智之举,一定要找到最适合自己的方式才是最好的。使用快捷键提效没什么高深的技巧,就是先记忆,然后多使用,后者最为关键。用得多了,最终便能形成肌肉记忆,进而运用自如,方才大大提高工作效率。最后说明一下,快捷键文章本计划用一篇分享的,但写完发现篇幅有点长,所以裁剪了一下:操作系统、终端篇;Intellij IDEA篇。那,咱们下篇再见!
✍正文Spring Cloud 2021.0.0版本的pom依赖:<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2021.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> 值得注意的是,Spring Boot版本请使用2.6.1及以上,而非2.6.0。尴尬不,这和Spring Cloud 2020.0.0依赖的最低Spring Boot版本为2.5.1而非2.5.0如出一辙。即使强如Spring技术团队都会因为bug导致出现这种“对不齐”的现象,洁癖患者看着着实有点小难受有木有。所以,程序员平时多多宽容自己O(∩_∩)O老生常谈关于Spring Cloud,每每都有些老生常谈的议题,很基础,但又不得不知,不得不提。✌和Spring Boot的对应关系Spring Cloud作为云计算框架,以Spring Boot作为基石,因此它和Spring Boot的版本对应关系非常重要。这是官方给出的对应关系图: 我把它整理为更为详细的表格:、按目前节奏,Spring Boot每年发布2个中版本、一个大版本升级,Spring Cloud保持每年一次大版本升级的用以匹配节奏。✌版本管理Spring Cloud管理着众多功能组件,整体上分为几大类。从源码处这里可以看出,以2021.0.0版本为例:笔者制作成表格,方便你收藏:发现没,如果团队正在使用kubernetes,那么spring-cloud-kubernetes也将是不错的解决方案哦,类似于Netflix的全家桶嘛。✌当前支持的版本Pivotal公司(Spring的母公司)对核心产品有个OSS支持协议,简而言之:主要版本提供3年支持,Spring Cloud作为Java领域云计算框架遵循了此协议。特别注意:这里指的主要版本才是3年,主要版本可不常有的哦每每新版本发布,就会有一些老版本需要退位让贤。2021.0.0版本已发布,各版本的官方支持情况如下:2021.0版本:他不算一个主要版本,但作为2020.0这个主要版本的升级版,是当前最被推荐使用的2020.0版本:他是一个主要版本。按计划会支持到2023年12月份Hoxton版本:(支持Spring Boot 2.2.x和2.3.x)作为Finchley发行系列的一个次要版本,它的常规维护将持续到2021年6月底。从2020-07开始进入到特殊维护期(不加新功能,只改紧急bug),2021-12月底就只会发布重大错误/安全补丁了。2022-2-28彻底结束支持Greenwich版本 :(支持Spring Boot 2.1.x)2020-01就停止维护了,2020年过完就结束生命周期Finchley版本 :(支持Spring Boot 2.0.x)它是一个主要版本的开始,2018年发布,2021年过完生命周期也就结束了更老版本 :嗯,忘了吧新特性作为主要版本2020.0.0的常规升级版本,这次动作肯定没有上个版本那么大,稍微关注下即可。✌Spring Cloud Commons支持为每个Load-balancer(负载均衡器)配置参数,相关代码在LoadBalancerClientsProperties这里。 该特性同时也添加到了Gateway、Contract 和Openfeign项目中。✌Spring Cloud Config集成了AWS Secrets Manager、AWS Parameter Store 和 GCP Secret Manager。这个没啥好说的,毕竟我天朝里配置中心,真有使用Spring Cloud Config的吗?✌Spring Cloud Gateway作为Spring Cloud非常非常亮眼、常用的组件,这个升级还是可圈可点的:功能更丰富了。支持 Redis 路由存储库。也就是新增的RedisRouteDefinitionRepository这个实现类喽支持 HTTP 2。支持 gRPC。✌Spring Cloud Openfeign支持@Cachable缓存注解,这个必须点赞,很方便很实用!支持此新功能的核心API为:FeignCachingInvocationHandlerFactory,复用了Spring Cache的能力。✍总结本次Spring Cloud升级点并不多,可能刺激不到你的神级。但或许这也是好事呀,毕竟每次搞那么大的话,真升不动了,躺平走起。
✍正文关于版本号,从2.4.x 版本开始版本号不带 .RELEASE 后缀了!通过表格描述下Spring Boot各个版本现在的更新、维护状况:Spring Boot每年会在5月份和11月份发布两个中型版本(一般都会有部分不向下兼容的情况,升级需谨慎),每个中型版本提供1年的支持(免费),提供2年+的商业支持(付费)。按此节奏可知:Spring Boot 2.6.0发布也宣布着2.4.x版本停止(免费)支持,而2.7.0版本预计会在2022年的5月份和大家见面。2.6版本主要新特性✌禁止循环引用Spring Boot终究忍不住,禁止(Bean的)循环引用了!!!注意:只是Spring Boot默认禁止了,但Spring Framework默认还是允许的哦对于有代码洁癖的开发者来说,看到循环引用的代码是“不舒服”的。在业务开发中,有一种声音是:循环引用不可避免,但实际上应该思考:若出现了循环引用,必定是结构设计上不合理导致,有优化空间!若你是个有追求的程序员,是可以很容易发现这种不合理的。什么是循环引用?如图,循环引用一般指A引用B,B又引用了A。更极端一点的循环引用case可以是:A引用A,本文将以此为例进行代码演示。什么是循环依赖?它是循环引用的一种具象形式,如Spring Bean之间的循环依赖就属于循环引用。大多数情况下,可认为循环依赖和循环引用语义上是相同的。在Spring Boot场景下,准备Bean循环依赖的基础代码: /** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/12/11 20:43 * @since 0.0.1 */ @Service public class AService { @Autowired private AService aService; @PostConstruct private void init() { System.out.println("循环依赖:" + (this == aService)); } } 2.6.0之前版本(以2.5.x为例)<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.7</version> </parent> 启动Spring Boot应用,控制台输出:结果:正常启动。这便是我们口头上常说的:Spring已经解决了Bean的循环依赖问题2.6.0及之后版本<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.0</version> </parent> 启动Spring Boot应用,控制台输出:结果:启动失败。这便是从Spring Boot 2.6.0版本起禁止了循环引用的结果如何解决循环引用?文上有说到,循环引用属于不合理的设计,但并非不能正常工作。这就像每个程序员都吐槽过屎山代码依旧能正常work同一个道理:它不好,但有意义。既然“不合理”,那就有理由规避。针对循环引用的解决方案,总结一下主要有两种:确保循环引用不再存在:整改/优化业务逻辑允许循环引用:无需改代码方案一:确保循环引用不再存在好,这很好!难,这很难!本方案是最好的,也是最难的,Spring团队当然最喜欢你这么去做,做难事必有所得嘛!从Spring Boot 2.6.0开始的这个默认行为(不允许循环引用)能感受到:循环引用的编码方式是不被推荐的,是坏味道的代码。为此,期望正在看本文的coder给自己立个flag哈:不再写循环引用的代码,尽量吧😄。奈何,好的东西/方案实现起来一般都很难,循环引用亦是如此。在笔者认为难点主要在程序员本身,主要表现在这三点:思考不足。提起需求就开工看起来效率很高,实则往往相反眼光不远。这是短期利益和长期收益的PK,短期利益更具诱惑性,然而长期收益才具备更高价值追求不够。明明知道这么做不太好,但就是这么做了。克服困难好比打怪升级,过关斩将方能提高自己的上限 从A点到B点,若距离只有10m,走路的方式是最快的;若有1km,自行车是最佳;若超过10km,就是小汽车;若超过1000km,当选火车/飞机!总而言之:能够积累才叫多,不用重来才叫快!方案二:允许循环引用此方案更像是绕过问题而非解决问题本身!!!它是一种妥协方案而非最佳实践。在Spring Boot 2.6.0之前版本无需担心此问题(默认允许循环引用),若你准备使用2.6.x但现实情况依旧必须允许循环引用那该怎么办呢?有哪些现实情况呢?诸如:老项目升级Spring Boot版本需要保持向下兼容性;公司coder的水平不一,强制高标准的要求将会严重影响到生产效率等等为此,做法只有一个:禁用默认行为(允许循环引用)。具体做法也很简单,其实在文上启动失败的报错详情里Spring Boot已非常贴心的告诉你了:所以只需在配置文件application.properties里加上这个属性:spring.main.allow-circular-references = true再次启用Spring Boot 2.6.0版本的应用:正常启动。除了加属性这个方法之外,也可以通过启动类API的方式来设置,能达到同样效果:public static void main(String[] args) { new SpringApplicationBuilder(Application.class) .allowCircularReferences(true) // 允许循环引用 .run(args); } 我们知道,允许循环引用与否其实是Spring Framework的能力,Spring Boot只是将其暴露为属性参数方便开发者来控制而已。那么问题来了,如果是一个构建在纯Spring Framework上的应用,如何禁止循环引用呢?你知道怎么做吗?欢迎在留言区讨论作答,或私聊我探讨学习~加餐:允许循环引用了但依旧报错也许你一直认为Spring已经解决循环引用问题了,所以在使用过程中可以“毫无顾忌”。非也,某些“特殊”场景下可能依旧会碰壁,并且问题还很隐蔽不好定位,不信你看我层层递进的给你描述这个场景:说明:以下代码在允许循环引用的Spring Boot场景下演示运行基础代码:本例使用@PostConstruct来模拟触发方法调用,效果和Controller里调Service方法一样哈 @Service public class AService { @PostConstruct private void init() { String threadName = Thread.currentThread().getName(); System.out.printf("线程号为%s,开始调用业务fun方法\n", threadName); fun(); } public void fun() { String threadName = Thread.currentThread().getName(); System.out.printf("线程号为%s,开始处理业务\n", threadName); } } 启动应用即触发动作,控制台输出为:线程名为main,开始调用业务fun方法 线程名为main,fun方法开始处理业务完美!此时,你发现fun方法执行时间太长,需要做异步化处理。你就立马想到了使用Spring提供的@Async注解轻松搞定:@Async public void fun() { ... } 再次运行,控制台输出:线程名为main,开始调用业务fun方法 线程名为main,fun方法开始处理业务 what?木有生效呀!这时你灵机一动,原因是没用开启该模块嘛。所以你迅速的使用@EnableAsync注解启用Spring的异步模块,满怀期待的再次运行应用,控制台输出:线程名为main,开始调用业务fun方法 线程名为main,fun方法开始处理业务 what a …?怎么还是不行。你挠了挠头,想起来之前踩过的“事务不生效的坑”,场景和这类似,所以你模仿着采用了相同的方式来解决:自己注入自己(循环依赖)@Autowired private AService aService; // 自己注入自己 @PostConstruct private void init() { ... aService.fun(); // 通过代理对象调用而非this调用 } 这次满怀信心的再次运行,没想到,启动抛出BeanCurrentlyInCreationException异常org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'AService': Bean with name 'AService' has been injected into other beans [AService] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesForType' with the 'allowEagerInit' flag turned off, for example. at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:649) ~[spring-beans-5.3.13.jar:5.3.13] ... 异常关键字:circular reference循环引用!!!不是说好了允许循环引用的吗?怎么肥四?怎么破???至此,笔者将此问题抛出,有兴趣的同学可思考一下问题根因、解决方案哈。最终的效果应该是不同线程异步执行的:线程名为main,开始调用业务fun方法 线程名为task-1,fun方法开始处理业务 Tips:笔者在之前的文章里对此问题有过非常非常详细的叙述,感兴趣的可自行向前翻哈!!!主动学习😄✌更加灵活的自定义脱敏规则对于/env和/configprops这两个端点,常常会有敏感信息存在,比如:数据库密码等等。为了避免敏感信息外泄,一般做法是禁用这两个端点,但粒度太粗,在很多时候是不合适的,因为这可能大大增加调试程序、定位问题的复杂程度,所以对该端点的某些信息脱敏不失为一个折中的好办法。Spring Boot使用Sanitizer(中文意思:消毒杀菌剂)来进行脱敏。比如属性配置有如下配置: mysql.password = 123456 redis.pwd = 654321 这时候访问端点/actuator/env,得到的结果是这样子的:如图所示,感觉有点厚此薄彼有木有???其实一切事出有因,EnvironmentEndpoint使用Sanitizer进行脱敏处理,而它自带一些默认行为:若不再这个范围内的key(比如上面的redis.pwd)也需要脱敏,很简单,价格配置项即可:management.endpoint.env.additional-keys-to-sanitize = redis.pwd #management.endpoint.env.additional-keys-to-sanitize = pwd # 脱敏范围更大 效果如下:完美脱敏!!!这么做可以搞定绝大部分场景,但是某些特殊情况下,通过这种配置不是很好做,比如:同一个key,在不同的属性源里表现不一样。在application.properties里的话脱敏,而在application-dev.properties里不需要脱敏(开发环境嘛,明文裸奔更有助于调试程序)。这个case若适用上面配置的方式不可处理,确切点说很不方便吧。Spring Boot意识到了这个“难点”,在2.6.0版本了新增了更灵活的自定义脱敏规则的能力,做法很简单:自定义SanitizingFunction类型的Bean即可。 // Since: 2.6.0 @FunctionalInterface public interface SanitizingFunction { SanitizableData apply(SanitizableData data); } 比如关于Redis的配置项放redis.properties文件里,然后读进来:@PropertySource("classpath:redis.properties") @Configuration(proxyBeanMethods = false) public class AppConfiguration {} redis.properties文件内容: redis.pwd = 654321 要求:redis.properties文件里面所有包含pwd的key的值都做脱敏处理,而其它属性源不管。这时使用上面配置方式就无法实现了(或者说很难实现吧),Spring Boot 2.6.0新增的特性,API方式可以非常灵活方便的搞定:@Bean public SanitizingFunction pwdSanitizingFunction() { return data -> { org.springframework.core.env.PropertySource<?> propertySource = data.getPropertySource(); String key = data.getKey(); // 仅对redis.properties里面的某些key做脱敏 if (propertySource.getName().contains("redis.properties")) { if (key.equals("redis.pwd")) { return data.withValue(SANITIZED_VALUE); } } return data; }; } 再次请求/actuator/env端点,结果如下:✌Spring MVC默认使用全新匹配策略在Spring Framework 5之前,关于路径匹配一直以来有且只有一种方式:基于Ant风格的url匹配,也就是熟悉的AntPathMatcher。在5.0版本之后引入了全新的路径匹配器:PathPattern。关于它俩都啥意思,怎么用,有什么区别,不是本文的重点。笔者前面文章有详细介绍,建议阅读哈。这里给个电梯直达:Spring5新宠:PathPattern,AntPathMatcher:那我走?Spring Boot从2.0.0版本开始构建在Spring Framework 5之上,但它直到2.6.0版本才彻底的将Spring MVC的默认匹配从AntPathMatcher切换为了PathPattern,这也是本次版本升级的一大特征之一。代码上体现在这里: // 2.5.7 public static class Pathmatch { private MatchingStrategy matchingStrategy = MatchingStrategy.ANT_PATH_MATCHER; } // 2.6.0 public static class Pathmatch { private MatchingStrategy matchingStrategy = MatchingStrategy.PATH_PATTERN_PARSER; } 若你需要回到Ant的匹配方式上(比如担心兼容性),只需加上一行简单配置就成:spring.mvc.pathmatch.matching-strategy = ant-path-matcher✌Redis自动开启连接池现在,只要classpath里存在commons-pool2这个jar,就会自动为Redis开启连接池(包括Jedis和Lettuce哦)。在2.6.0之前的版本,配置Redis时是否启用连接池是由使用者显示来决定的,现在自动了,说明Spring Boot是推荐使用Redis时用连接池的哦。从源代码的角度,区别主要在这(以现在更为常用的Lettuce为例):LettuceConnectionConfiguration 下面代码是2.6.0版本做的改动:可以看到策略是有变化的:之前默认关闭连接池需要显示开启,2.6.0之后是默认开启需要显示关闭。✌Spring Boot 2.4.x停止维护按照Spring Boot现在版本规则:官方只免费维护当前主线版本和次版本,发布新版本后上上个版本自然就停止维护喽,倒逼开发者保持升级,用新版本产品,享受技术红利呀!说明:这里指的停止维护是官方免费维护,不包含商业付费维护✌依赖升级这部分一般不用太关心,稍微留一下主要的组件版本即可。Spring Data 2021.1Spring Kafka 2.8Apache Kafka 3.0(Spring果然站在最前沿呀)Commons Pool 2.11Elasticsearch 7.15Hibernate 5.6Mockito 4.0…✌删除和弃用按照规约,在Spring Boot 2.4.0里被标注为弃用@Deprecated的类在此版本将会被删除。回忆2.4.0版本弃用了哪些? Spring Boot 2.4.0最大升级就是对ConfigFileApplicationListener的升级。电梯直达:Spring Boot 2.4.0正式发布,全新的配置文件加载机制(不向下兼容)那时壮志雄心计划下下个版本(也就是2.6.0版本)就可以移除此类,但Spring团队这次还是担心步子迈得太大扯着dan,留下了它并改口将在3.0里移除掉。 弃用类:JDBC的AbstractDataSourceInitializer体系,使用DataSourceScriptDatabaseInitializer体系替代Hibernate的SpringPhysicalNamingStrategy,使用CamelCaseToUnderscoresNamingStrategy替代测试框架的AbstractApplicationContextRunner类的几个方法被启用,使用新的RunnerConfiguration类替代✌官网新增SUPPORT标签页由于Spring Boot的更新迭代速度非常快,每个版本的发版时间、维护周期一直困扰着广大开发者,为此随着2.6.0版本的发布,官网上非常暖心的提供了一个SUPPORT标签来展示各个版本的情况: 以及当天所处的一个状态:地址:https://spring.io/projects/spring-boot#support✍总结Spring Boot 2.6.0的更新点还是比较多的,值得肯定,当然也值得升级。Java领域的云原生时代,虽然受到了挑战,但毫无疑问在未来的5年甚至10年,Spring Boot依旧是标准的脚手架,是云原生应用的基础设施。它的能力能解放开发者的精力,时间用于业务设计、开发上。最后,多分享一句。笔者从中觉得的每次版本升级符合Spring的决策哲学:先服从,再引领。毕竟对于庞大的Spring体系来说,每个重要决策都并非拍脑袋就可以,背后需要宏观思想作为指导。拿循环引用这个例子来讲,Spring Framework最初默认允许循环依赖:设计上似乎留下了“不和谐”,但那会Spring初出茅庐,话语权不够,所以拥抱大众,活下来才是第一位。Spring技术栈发展到现在成为了实际的开发标准,在Java领域可谓已有绝对的话语权,因此它开始引领:默认不允许循环引用。
✍正文我如何高效使用IDEA?鉴于事实,我在文首还是先介绍下自己使用IDEA吧。不过话说在前头,任何时候都是人外有人,天外有天,我写的所有文章、教程、做的所有视频都仅限于我当时所处的认知范畴,绝不代表最好、最深。由于我个人比较注重工具的使用,所以我对每次自己写出来的代码不管是排版、还是质量、还是写代码的效率都非常自信,这种效率提升IDEA功不可没。还有每次我在公司内分享,很多时候都会一字一句的现场编码实时演示,却一点都不怯场,这很大程度上也来自于我对IDEA的熟练掌握。可能一不小心点破了一个小现象:很多时候(特别是较小范围分享时)不是主讲人不编码给你看,而是编码容易露馅出自己连“饭碗”都用得不太熟练的尴尬笔者2015年8月入行的是一家外包公司(关于笔者送外卖->程序员转行经历,有兴趣的可点这里),用的MyEclipse 10,就是它,现在回看是古董了有木有: 离开第一家公司后开始用STS(Spring Tools Suite),当时其他同事都用Eclipse而我用STS,因为对Spring框架的支持度更好:直到2017年8月份,在新公司第一次接触到IDEA。这里面还有个小插曲:刚开始接触使用IDEA时感觉十分不顺手,所以在长达1个月多月的时间里我都是STS + IDEA双用:在STS上开发、看代码在IDEA上拉取代码、提交代码果不其然,出问题了:我不小心把eclipse工程的相关文件提交到了git仓库,导致组内其他人拉取代码时出现了一系列莫名其妙的问题,浪费了他人时间。我深刻记得当时被领导和一些同事直呼业余!!!当时宝宝心里很苦,心里还在反抗:STS/Eclipse这么好用还免费为啥偏要用IDEA呢?殊不知,那会IDEA早已全面超过Eclipse,这个历史背景在我这篇文章里有提及: 为此,我痛定思痛完成自己学习后,就逐渐开始了我的IDEA“布道”之路:向个人、团队内、公司内分享这个Java程序员开发利器。IDEA作为一款IDE(集成开发环境),功能不可谓不强大,缺的就是你的探索。对我自己而言,最能提升我效率的主要有亮点:快捷键主题✌快捷键早些年如果你对在命令行里敲来敲去的同学心生羡慕,那么在工程开发场景下应该会对快捷键使用666的同学赞叹不已。快捷键我们天天都在用,毕竟这几个键大家都很熟: 对我而言,我觉得快捷键对提升开发效率是立竿见影的,它就是一种捷径。所以在我学IDEA的那会,会用有道云笔记记录下我用的快捷键,有70+个“之多”:我最常用的大概有近50个,经过几年的反复“练习”,这50个已经形成了肌肉记忆,对我效率提升非常大。Tips:任何事情不是越多越好。快捷键也是一样,只有常用,快捷键设置得才具意义✌主题如下图,这是我正在使用的IDEA主题2012年2月,IntelliJ IDEA发布了暗黑主题,瞬间提升了工程师的B格,从此找女朋友好像容易了许多。正所谓外行看热闹,内行看门道,主题绝不能说明一个工程师编程水平的高低,但为何我觉得它是我的提效利器呢?我当然同意主题不能代表工程师的编程水平,但并不妨碍它帮我提效。这个主题样式其实是从我开始使用MyEclipse就一脉相承过来的,我先说说它对我的帮助:我喜欢暗色调,所以暗色调整体看上去我会觉得舒服颜色都是我自己定制,所以整体呈现出来是我最喜欢的格调每个颜色自己会说话。这是最最最重要的一点,比如以我的主题为例:纯黑色:interface接口灰黑色:abstract抽象类浅灰色:@annotation注解绿色:class类粉色:成员属性红色:定义局部变量红色+斜体:使用局部变量…太多了,不用一一例举快捷键和主题是对我提效帮助最大的两方面,当然还有很多其它较小的方面的设置。文后我会将我这么做的一些思考和建议分享给你,供你参考,酌情取之为什么有的大神认为使用记事本写代码很牛逼?这源自知乎上的一个提问:其中有几个回答我觉得还蛮有意思:能跑马拉松的人自然比开车42公里的人牛逼。但是你出门走42公里不开车用跑的那就是SB了…啊?为什么不是有的 菜鸟 认为用记事本写代码很牛逼? 大神 才没空关心这些呢…实在没有IDE可用的情况下能用记事本熟练coding,确实nb。放着IDE不用非要用记事本coding显示自己多nb,大神也sb。总会有一些小白对大神们在极端情况下的无奈之举顶礼膜拜,为虚荣心强的大神披上了皇帝的新衣…总之我觉得,如果能在记事本上飞舞的码代码的大神,那么使用IDEA这种工具的话它的效率99.9999%可能性会更高。我记得曾经有个读者跟我私聊,说面试官让他在纸上(或者在电脑上用记事本)写一个控制台输出hello word的程序,要求很简单:能javac直接编译运行。然后,他挂了!可能有人会觉得问这种面试题没有意义,毕竟没有人会在记事本上写Java。但谁让现在面试就是这么卷呢?作为开发工程师,最终不就是拼这些么,正如郭德纲所说:相声拼到最后拼的是文化。 为什么IDEA“不卡”?IDEA和Eclipse哪个更好,这似乎是一个没有标准答案的提问,但市场会给出解答。在我细目中,那必然是IDEA更好,还是用我有道云笔记上记录的一句话来解释:(相对)不卡,解决方案一般只有一个:大量使用内存,用空间换时间。所以IDEA吃内存不是盖的(只打开了2个项目): 在现在存储价格越来越低的趋势下,这么做显然是值得的,苹果系产品也是这样为之。看看你的iPhone、iPad、Mac是不是内存长期占用85%以上高居不下。不同于CPU的算力,内存这种资源,不用也就等于浪费。为此,开发者均愿意为此买单而拥抱IDEA。当然啦,内存大了怎么使用也是很重要,这就是IDEA与Eclipse拉开差距的“核心竞争力”了:索引。✌无处不在的索引作为程序员,对索引二字我们并不会陌生。比如最长接触的数据库索引:只有SQL慢,加索引一般能解决90%以上的问题;还有比如Mac的文件搜索系统索引: 索引有两个明显的特点:能够大大提高查找的效率只需创建一次创建时往往比较耗时(数据越多越耗时)在IDEA里,当打开一个新项目 or 点这个按钮重新启动时你一般会经历一个较为“漫长”的建立索引过程,这个过程一般是阻塞式的,你什么都干不了(新版本的IDEA对此有优化,有些步骤可并行):为当前工程的文件,建立索引为Spring体系依赖建立索引、为其它依赖建立索引为JDK建立索引这个过程可能比较耗时(项目越大、依赖越多就越耗时),并且十分耗CPU资源,所以这个时候你的CPU风扇很可能会高速转起来,就像这样:正因为IDEA创建了这么多的索引,所以代码提示、懂你的智能提示、代码自动修复等就成为了可能,最终给与你的体感就是IDEA好像不怎么卡!正确使用IDEA的姿势和思考下面我仅结合自己的理解,给些比较常用的、IDEA的最佳实践以及我的思考,供以你参考,酌情获取。✌查找类class这个功能是最常用,同时也是我最想拿出来说的。说实在话,我眼睛看到的几乎一半 同学想要找到某个class类时,用的它: 不知看到这个搜索结果你会是什么感觉?明明只需要找到Application这个类,为毛搞出这么一大片搜索结果呢?看得人眼花缭乱~这当然不能怪IDEA,是你姿势不对嘛。这个功能叫全文检索,也就是说只要标题/内容包含关键字就匹配,它的能力非常强大,同时也相对来讲更耗性能,更容易造成电脑卡顿。注意:该搜索只搜索内容,不搜索标题/文件名。好在一般来讲Java中的类显然用它查找class类并不合适,干扰项特多了。最佳实践应当是它(Navigate -> Class…): 新版本的IDEA越发聪明了,好似能做到自动切换,想你所想,大部分情况下都是准的。若需要切换,我还是建议你用快捷键完成快速切换而不是用效率较低的鼠标。总的来讲,用这个功能来查找class类效率最高,并且最为精准,所以是最佳实践。✌查找文件file举个例子:我们经常遇到要找到application.properties/yaml配置文件去写配置,一种方式是一层一层的打开文件夹,另一种当然是更快的方式:查找喽。这个时候,很多同学依旧拿出自己的“全文检索”: woho,不灵了!原因其实上面已经提到了:这个搜索只检索内容,而不检索标题。所以你可以用下面这个方式,它才是最佳实践(Navigate -> File…):毕竟大部分时候我们都是根据文件名而非文件内容去找文件。✌Project视窗放在右边如果说上面两项是我的“强制”建议,那么这个只是我的一个小小建议,请根据个人习惯参考使用。看到下面这张图,不知你作何感想:本来诺达可视区域,代码区域竟只剩1/5左右了。据我所知这是很多开发同学的常态,特别是在debug断点调试的时候,可视区域可能更小。好不容易买块外置显示器大屏幕…虽然说这和个人窗口管理的习惯有关(有人爱整洁,有人邋遢嘛),我这里推荐一个把project视窗放在右边(一般左边也只会放project,所以这里我只强调project视窗哈)的解决方案。我的理由如下:第一点,也是最重要的一点。它能让最重要代码区域具有稳定性。我们都是从左向右写代码,而每行代码的长度一般是不会太长的,因此大部分情况下代码区域的右边会大量留白,所以理应利用起来第二点,视窗都放在右边,能确保代码区域的视觉稳定性。若视窗在左边(如project视窗),我们把它收起or展开,会让代码区域一会左移一会右移,视觉上也更容易累些第三点,几乎没有视窗需要一直独占空间区域,所以放在一边管理起来,共享性会更好。从而就可以省出更多空间区域用于显示代码当把视窗统一放在右边后,代码显示区域得到明显提升(且更稳定),即使是debug也不怕: 当然,当然,当然,一切以你的习惯为准。✌云化自己的IDEA配置IDEA从下载下来,到顺手的使用,需要做的步骤其实还是比较多的。比如:字体大小字体风格tab风格关闭拼写检查智能提示是否区分大小写单行函数自动折叠…这些小配置设置起来不难,花点时间弄弄即可。但是,若:IDEA卸载(干净)重装换台电脑对于失去的已经习惯了的配置,是不是想死的心都有了?还好IDEA有配置导出的能力,可以导出一个jar包,新的IDEA导入这个jar即可。但更安全、方便的办法是将配置云化。起初IDEA还不支持云存储的时候(没记错的话应该是2019年6月之前的版本),我的做法是把这个jar云存储起来,我就是放在有道云笔记里的: 现在好了,IDEA支持云存储了:它支持两种方式:跟着JetBrains账号走优点:最方便缺点:毕竟是墙外的服务器,速度堪忧自定义一个仓库管理优点:墙内有码云、CSDN等都可以提供免费仓库缺点:需要配置一个仓库地址很明显,我选择了方式2✌为自己定制一款主题IDEA已经内置了多款主题:亮色和暗黑色本文上部分介绍了主题对我开发效率的提升,希望能给你带来启发。当然喽,主题和皮肤一样,萝卜白菜,各有所爱,所以各位同学酌情考虑。考虑到确实有不少同学“喜欢”笔者这个主题,所以我把它公开了,有需要的同学公号后台回复:IDEA主题,即可获得。✍总结IDEA作为Java程序员每天都在用的工具,我还是建议可以专门的花点时间造一造,毕竟,磨刀是不误砍柴工的。这篇是比较笼统的介绍了IDEA的一些实践经验,还有很多细一点但非常好用、强大的功能点会在这个IDEA专栏里分享给你,比如代码分析、代码检查、代码自动优化等,最终分享给你《我是如何高效的用IDEA保证团队代码质量的》,感兴趣的敬请关注。
✍前言你好,我是方同学(YourBatman)A哥 -> 方同学。是的,中文昵称改了。自知道行不深无以用“哥”字称呼,虽已毕业多年,同学二字寄寓心态一直积极、热情、年轻时间拨回到2018年5月,阿里巴巴获邀加入JCP最高执行委员会,以替代恩智浦被选举为该委员会委员,成为第一家加入JCP的中国企业。一时间铺天盖地的新闻报道,轰动IT圈。JCP的执行委员任期2年,时至现在的2021年,午夜梦回中忆起此事,再去JCP官网查阅一下,还好牛皮可以继续吹,(2020年投票选举)连任成功!很明显这篇文章绝非吹捧阿里,旨在向你介绍作为一名Java程序员必知必会的JSR规范、JCP组织。程序员是信息时代的技术人才,常听到的一句话是“我现在有一个想法可以改变世界、可以颠覆行业、可以超越BAT,但就差一个程序员了”,可见程序员的重要性。中国软件行业发展至今(2021年)已有700万的程序员,虽然人数在全球遥遥领先,但声音一直非常小,影响力也微乎其微。阿里巴巴作为国内Javaer的“圣地”,almost代表着最高水平。作为一枚Java开发者,听过无数J字缩略语:JDK、JRE、JVM、JSE、JCP、JPA、Jakarta…本文将以此为背景,为你介绍与每个Java程序员都息息相关的JCP(含JSR规范),然后就会感受到加入JCP这件事多么牛X了。 版本约定JSR、JCP✍正文若有人问你Java平台分为哪三个版本,你应该能答出来:Java标准版(Java SE)Java企业版(Java EE)Java Micro Edition(Java ME)很好,60分及格。若夺命连环问继续追问:听过JSR、JCP吗?如果说上道题还在嘿嘿的说是送分题,那这道题估摸可能大概就成为你的送命题了。什么是JSR?Java Specification Requests:Java规范请求/提案。JSR是指的向JCP提出新增一个标准化技术规范的正式请求。每个JSR都是正式的、开放的标准文档,由个人或组织提交给JCP组织进行审议,根据审议结果决定JSR是否最终发布。课代表帮你总结一下JSR定义的关键点:任何人(个人/组织)都可以向JCP提交JSR,当然前提是你要注册成为JCP的会员(个人会员免费,现在由Oracle管理维护)每个JSR都是正式的文档,必须符合JCP的规定的格式(这个门槛不低)JSR是开放的,任何人都应该可以很方便获取到它JSR的内容是对Java技术平台提出的修改、补充和改进,包括Java技术栈增加新功能、修改bug、提升性能等每个JSR都只是一个抽象的规范(还只是文档,在纸面上的规范),通过设计API落地成为接口代码规范,最后通过参考实现来提供具体的功能落地,进而供以使用。JCP规定每个JSR规范都必须有一个官方参考实现,言外之意:可以有多个其它实现。如图,API代码规范可认为是JSR规范的代码表现形式,被纳入Java体系内。值得注意的是:JSR并非Java EE的专属,三个版本里都有,个数如下:说明:某些JSR直接与一个或多个Java平台相关,还有许多JSR不是平台的一部分,而是对平台的扩充。此表格只列出了与特定版本相关的JSR(表格数据截止到2021年7月)虽然Java每个平台都有JSR,但我们常常只把JSR和Java EE一起提及。Java EE中的每个API实际上都是由审核通过了的某个JSR规范所定义的。换句话讲:Java EE由各种组件构成,这些组件遵从JSR规范所规定的内容、功能。一个JSR从提出后,生命周期交由JCP组织管理,有可能被拒绝不通过,也有可能最终 Final RELEASE。一旦某个JSR通过了JCP的审核, 它就变成了Java技术栈的一部分,可以安全地用于生产环境。但这个门槛非常非常非常高,时间周期也会很长。常见JSR及参考实现举例在日常开发中,JSR出现的频率还是蛮高的。即使你不用EJB开发,而是使用Spring技术栈、又或者是Dubbo都经常能看到JSR的身影。JSR种类繁多,仅已经毕业(Final Release)的就有260+个: 我们不可能逐个去了解每个,下面课代表就帮搜集了些常见(现在大都在Spirng技术体系下开发,所以常用二字是以此为背景解释)、常用、仍旧流行的JSR规范以及对应的主流实现(主流实现并非都是官方参考实现哦):这都是Java EE相关的JSR规范(一般Java EE接触多),其实Java SE也有熟悉的规范,比如JSR 310日期时间(也叫Java 8日期时间)就是典型代表。每个JSR都有一个.pdf文件做说明,这里都帮你收藏好了:https://github.com/yourbatman/JSR-JCP什么是JCP?Java Community Process:Java非正式过程(形式化和标准化Java技术的过程),官网地址:https://jcp.org一个开放的国际组织,主要由Java开发者以及被授权者组成,职能是发展和更新。JCP由Sun公司于1995年创造,演进到如今有数百名来自世界各地Java代表成员一同监督Java发展的正式程序。如果说JSR代表着一种具体的规范技术,那么JCP就是管理这些JSR发展、更新的组织。JCP是为Java技术开发标准技术规范的机制。任何人都可以注册该站点,参与对Java规范请求(JSR)的审查和提供反馈,任何人都可以注册成为JCP成员,然后加入JSR专家组,甚至提交自己的JSR建议。 JCP它主要包含这几块内容:Java技术规范:JSR规范参考实现(RI):JCP要求每个JSR都需要有一个参考实现技术兼容包(TCK):兼容测试工具包,一套测试、工具和文档,用于测试实现是否符合规范JCP Members:由个人or组织构成JCP的成员Executive Committee (EC) :(最高)执行委员会。EC代表了主要的利益相关者和Java社区的一个代表性部门。EC负责批准通过JCP关键点的规范,并协调规范及其相关测试套件之间的差异JCP Member还可细分为准会员、合作伙伴会员、正式会员,每种身份都有不同权利,这个对“不打算”参与提交JSR的我们来讲没啥用,so…对于JCP这个组织,我们或许对这些更感兴趣些:参与一个JSR规范生命周期的主要角色有哪些?执行委员会(EC)这个组织有哪些成员?一个JSR从提出到最终RELEASE需要经历哪些过程,要多久?参与JSR生命周期的角色开发一个JSR标准技术的成本是非常昂贵的,JCP对此有严格的流程,期间会涉及到三个角色:贡献者专家组规范领导者当然还少不了最高决策者:执行委员会EC。各个角色的分工、关系如下图: 以Bean Validation 2.0(JSR 380)为例,团队成员如下:Gunnar Morling是谁?答:Hibernate Validator的作者,所以说JSR 380规范是由Hibernate主导的毫不为过。1、贡献者类似于Github上的Contributor,一般都是个人(JCP的准会员)。在1个或多个JSR上帮忙过,比如帮助测试、帮助修正、开发了JSR特征等。这是加入JCP“往上爬”的第一步,如果表现突出的话就会被放进专家组候选人,从而可能进入专家组。类似于小组的一线员工2、专家组专家组对该JSR起到核心作用,均由对本JSR基础十分熟悉、功底十分深厚、有一定权威的人担任。他们算是JSR规范实施的“基层员工”,直接参与到JSR开发的具体工作。类似于小组的技术优秀员工3、规范领导者一般是JSR的申请者/作者。领导者的作用主要职责是指导专家组和贡献者实施,统揽本JSR全局,为它负责。主要工作有:提供JSR参考实现完成TCK测试,确保JSR合法、合规、兼容性强时刻关注JSR的进程,并且将状态同步到jcp.org的页面上。并且提供草稿review、公开review、最终文档等类似于小组组长执行委员会EC执行委员会(EC),也叫最高执行委员会,在JCP中扮着决定性作用,它就像最高权力机关,掌握着每个JSR的生杀大权。该委员会的成员必须分析、评论、投票并决定批准提交给JCP的所有JSR。除此之外还得指导整个平台的发展,以及JCP本身,使其能够一直符合期望。真乃权利越大,责任越大。类似于技术总监、CTO。总体来讲EC有这4项任务:审核并投票批准或拒绝 新的 JSR提案审查和投票批准或拒绝public公开审查的JSR草案决定何时撤回JSR,比如过时的技术合作修订JCP计划几十年以来JCP最高执行委员会的18个席位都被美国、欧洲、日本等国家企业牢牢占据,直至2018年阿里巴巴的加入。阿里巴巴入选JCP执行委员会EC委员会的成员通过年度选举产生。时间拨回到2018年5月18日,阿里巴巴成功替代恩智浦被选举为JCP最高委员会委员,任期2年(2020年的选举继续连任成功)。成为第一家加入JCP的中国企业,可谓是国人的骄傲。这是最新的EC成员表(共18个席位): 有个比较有趣的点:Alibaba以字母“A”打头可谓占尽优势,“排名第一”!这份EC成员表单有这三大看点可以关注:一个JSR经历的阶段JCP管理着所有的JSR规范,每个JSR都需要按照JCP组织制定的流程进行。我以已经发布的、大家相对比较熟悉的JSR 380: Bean Validation 2.0为例,看看它的经历过程:Final Approval Ballot最终投票结果:从步骤上看一共8大步,从时间轴上看共历时13个月,看似时间很长。但其实像Bean Validation这种路途其实是非常非常非常顺利的,属于全部一次性过。存在那种投票了N次才最终Final甚至一直没有Final的,比如JSR 377: Desktop|Embedded Application API的路就比较坎坷: 看上面的投票截图也许你会疑问,投票成员中为嘛没有Alibaba呢?看看投票日期:2017-07,而阿里巴巴于2018-05才首次成为JCP的EC成员,咱们找一个投票日期近一点看看就有阿里的身影,这是来自东方的声音 :一个JSR从0到1需要经历这些个步骤:起草写一个JSR将此JSR提交到JCP公开早期的JSR接收review形成专家组EG(希望很大了)早期草案审查公开review拟议的最终草案最后的选票(可能经过多轮)时间轴安排大致如下:总之,一个JSR想要最终毕业,少则1-2年多则5年+,甚至流产。JCP的影响力在减弱随着2009年Oracle收购Sun公司,使得它在JCP里处于一言堂的地位 ,别人都没法玩了。2010年是个关键节点,Apache当年直接斥责Oracle滥用Java,于是“一生气”退出了JCP,至今未归。2017年8月份,Oracle对外宣称要让Java EE更加敏捷、灵活(都是套话,就是觉得自己玩不转了想当甩手掌柜),计划交给开源社区。随后的9月份便“卖给”了Eclipse基金会,但Java商标、Java SE部分仍旧自己牢牢掌握着。所以Java EE从此不得不改名为Jakarta EE且正式脱离JCP(参考文章:从Java EE到Jakarta EE,企业版Java的发展历程)。至此,JCP本为三大平台Java ME、Java SE、Java EE贡献JSR规范,现在仅剩Java SE(Java ME可认为基本已死)。而Java SE独属于Oracle,所以JCP的影响力逐渐步入衰落。作为开发者的我们也有切身感受:随着EJB的败下阵来,随着Spring家族的异军突起,打得Java EE都快找不着北了。 瘦死骆驼比马大,在若干年内了解JCP了解JSR规范依旧非常非常非常重要,是刚需,因为考试要考、面试会问、工作要用。✍总结JCP组织辉煌时通过管理的JSR规范驱动着Java的三驾马车(Java SE、EE、ME)向前发展,严格的流程规范是确保Java技术高质量、高兼容性的保证。随着Apache基金会决裂的退出,随着Spring家族的崛起,随着Java EE的光芒越来越暗淡,正所谓眼看他起高楼,眼看他宴宾客,眼看他楼塌了,JCP组织影响力也在逐日减弱。规范永远属于“一流技术”,作为一个Java程序员建议这是每个同学的必修课,因为它决定着自己的上限。而且这不仅仅是谈资,而是实战中切实会用到知识点。下篇文章将为你回答“Java EE是什么?”,了解它和JSR有哪些关系,为何一般说到JSR都是和Java EE相关。
✍正文回忆2.4版本主要新特性不仅time flies,Spring Boot的版本也是越发越勤:基本是半年一个中型版本。在肯定社区繁荣的同时,也感叹一句:学不动了。Spring Boot 2.4.0是一次非常重要的版本升级(配合有Spring Cloud 2020.0),下面简单回忆下其新特性:首个使用新版本号规则的Spring Boot版本 什么是Spring技术栈新版本号规则?点这里:Spring改变版本号命名规则:此举对非英语国家很友好全新的配置文件处理(properties/yaml):这是该版本最重磅的升级,提供了新功能spring.config.import支持,旨在简化和合理化外部配置的加载方式,不向下兼容哦,这决心足以体现Spring家族进击云原生的决心。当然,一向关注“向下兼容”的Spring Boot不会突然一刀切,而是提供了外部化配置的兼容方案,配上spring.config.use-legacy-processing = true即可一键切回到旧模式(不建议)spring-boot-starter-test中删除Vintage Engine依赖。Vintage Engine属于Junit5的一个模块,它的作用是:允许用JUnit 5运行用JUnit 4编写的测试,从而提供了向下兼容的能力。这次去掉意味着从Spring Boot 2.4.0开始不再兼容JUnit 4(若实在需要兼容,请自行导入相关jar)不再注册DefaultServlet。在绝大多数的应用中,Spring MVC提供的DispatcherServlet是唯一需要被注册的Servlet。若还需要,增加属性server.servlet.register-default-servlet = true还原Spring Framework升级到5.3.0版本(Spring Framework 5.3.0正式发布,在云原生路上继续发力)支持Java 152.5版本主要新特性作为2.4的升级版,其实该版本升级并不算多,了解一下。✌暗黑模式连iPhone都支持暗黑模式了,Spring岂能不紧跟潮流呢。 其实除了暗黑模式外,字也变更清晰了、外观更新颖了等等。✌脚本数据源初始化Spring Boot的脚本数据源初始化功能在开发中用得不多,但在单元测试上用得挺多的(单元测试使用嵌入式DB居多)。该功能简单的讲:在应用启动阶段执行一些SQL脚本(包括DML、DDL)。说明:默认情况下,SQL脚本也只在嵌入式数据源的时才会执行(该行为由spring.sql.init.mode=xxx控制)Spring Boot 2.5.0版本重新设计了用于支持schema.sql和data.sql脚本的底层实现,在使用层面用spring.sql.init.*属性来进行配置,参照本类: 而之前版本的spring.datasource.*已被弃用(标记为过时,但并未删除仍可以使用哈):# 使用spring.sql.init.username代替 spring.datasource.schema-username=YourBatman # 使用spring.sql.init.schema-locations代替 spring.datasource.schema=mysql/schema.sql # 使用spring.sql.init.username代替 spring.datasource.data-username=YourBatman_data # 使用spring.sql.init.data-locations代替 spring.datasource.data=mysql/schema.sql # 使用spring.sql.init.mode代替 spring.datasource.initialization-mode=embedded 值得注意的是:新的方式不再支持在配置上为schema和data独立配置凭证(用户名和密码),因为绝大部分情况下我们并不需要这么做。倘若真的有需求,Spring Boot建议你去自定义一个org.springframework.jdbc.datasource.init.DataSourceInitializer这样的Bean即可。关于数据源初始化,Spring Boot 2.5.0还提供了一个新的注解:@DependsOnDatabaseInitialization。顾名思义,它表示那些需要依赖于DataSource初始化(数据源初始化了自己才能初始化)的Bean可标记上次注解,Spring Boot来管理这个顺序。此注解功能和底层原理同@DependsOn注解,区别在于前者是自动的,而后者是手动的(具体依赖哪些Bean需要自己一一指出)✌系统环境变量可指定前缀从此版本开始,可以为系统环境变量指定前缀,以便可以在同一环境中运行多个不同的Spring引导应用程序。使用SpringApplication.setEnvironmentPrefix(…)设置绑定属性时要使用的前缀,就像这样: SpringApplication application = new SpringApplication(MyApp.class); application.setEnvironmentPrefix("yourbatman"); application.run(args); 这个功能大大方便了单机混合部署。✌移除Spring Data Solr由于Spring Data项目在2021.0.0版本已移除了Solr的支持,因此在此版本里Spring Boot也将其干掉了。值得注意的是:是干掉了Spring Data下的Solr,全路径org.springframework.boot.autoconfigure.data.solr下的自动配置类SolrRepositoriesAutoConfiguration及其相关API。而自动配置类org.springframework.boot.autoconfigure.solr.SolrAutoConfiguration依旧是存在的哦。Spring一向是Java领域的风向标,可以看到ElasticSearch的崛起也预示着Solr将要成为历史。✌/info端点默认不再公开在此本文启动应用后访问curl localhost:8080/actuator/info得到的将是404,但在之前(如2.4.0)和之后(是的你没听过,如2.6.0)版本都能正常访问,所以这还蛮滑稽的。想自定义打开/关闭Endpoint端点,这样配即可:management.endpoints.web.exposure.include=info,metrics,xxx✌更改EL表达式实现通过笔者数据校验专栏或者Java EE专栏知道:EL是Bean Validation实现必备的功能组件之一。Spring Boot通过spring-boot-starter-validation启动器来管理相关依赖,之前用的org.glassfish:jakrta.el,此版本之后改为org.apache.tomcat.embed.tomcat-embed-el(可独立使用,和Tomcat容器没关系),对使用者无感哈!✌日志Shutdown Hooks这是一个比较实用的功能:基于jar的应用程序注册一个默认的日志关闭钩子,以确保在JVM退出时释放日志资源。如果应用是war形式部署则无需此功能,因为web容器/应用容器会负责做相关资源的清理工作。相关代码在这,默认情况下该钩子是会被注册的: 那钩子程序到底做了什么呢?其实就是资源回收嘛(比如close()),以Logback为例:✌删除2.3已被标记为过期的代码这是Spring Boot一贯用的规则/规律:标记为@Deprecated的API会在隔一个版本后删除代码。如删除org.springframework.boot.autoconfigure.elasticsearch.rest.RestClientBuilderCustomizer,代替者:org.springframework.boot.autoconfigure.elasticsearch.RestClientBuilderCustomizer。✌其它/actuator/startup支持Get方法啦(之前只能post方法)支持Java 16支持Gradle 7支持Jetty 10Apache HttpClient 5配置到WebClient里依赖升级:Spring Data 2021.0、Spring Session 2021.0、Spring Kafka 2.7.0✍总结Spring Boot 2.5相较于2.4动作并不大,但也存在一些不兼容性,升级时需多加注意。如若你现在已经在使用Spring Boot 2.4.x版本了,那么升级上来将毫无压力,推荐升级,为升级到2.6.x打好基础!
✍前言你好,我是方同学(YourBatman)A哥 -> 方同学。是的,中文昵称改了。自知道行不深无以用“哥”字称呼,虽已毕业多年,同学二字寄寓心态一直积极、热情、年轻这次的标题🐂吹得有点大,倍感压力。不过没关系,毕竟吹牛不用上睡,也不犯法。在信息大爆炸的时代,连技术圈的标题党也不少啦:30分钟教你手撸一个ORM框架。其实就一个反射注解拼接字符串5分钟教你玩转Docker。额,5分钟后包就业吗?玩转亿级流量高并发缓存方案。全国(乃至全球)能达如此流量级别的屈指可数,你确定?…我标榜自己从不标题党,是的这次也不例外。本文将分析/和/*的区别这个老生常谈的问题,看别的博文总是看了忘忘了看,本文不同的是,关于此问题这一篇文章就够了,它将成为你的永久记忆(一不小心又吹牛了🥶 )所属专栏点拨-Servlet本文提纲版本约定JDK:8Servlet:4.xtomcat:9.x✍正文什么样的答案终身难忘?学生时代关于记忆经常能听见两种论调:死记硬背:见效快,但也忘得快,且一般不会灵活运用(指标不治本)理解性记忆:见效慢,但记忆持久且会灵活运用(治标又治本)如果是你,你愿意pick哪种?正所谓授人以鱼不如授人以渔,后者方能形成永久记忆。不谋而合,本文将采用后种讲述方式,帮你记忆持久化。关于/和/*的区别这个问题,依稀记得2015年我自学那会就能把它俩搞得明明白白,并且通过理解形成了“永久记忆”,所以至那会其就从来没有犯过迷糊,难道我就这么重视基础么(md,又在吹牛。。。)点拨“市面上”的错误答案如果用谷歌百度一下关键字:/和/*的区别,搜索出来的答案不客气的说,基本全错!!! 错误的姿势基本还一模一样,原因你懂的。 各种错误case,且听我娓娓道来。搜集了下有如下4种主流答案,一一点拨。环境说明:使用原生Servlet,war包方式部署至外置Tomcat作为服务器,端口号8080,context-path为:appcontext1、/用于Servlet,/*用于Filter反例:@WebFilter(urlPatterns = {"/*"}) public class FakeServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("FakeServlet收到请求:" + req.getRequestURI()); } } 启动服务器,浏览器访问:http://localhost:8080/appcontext/api/demo1,控制台输出:FakeServlet收到请求:/appcontext/api/demo1一般来讲/确实用于Servlet,/*用于Filter,但并不代表这是正确的。说明:Filter路径模式使用/无效2、/不会匹配.jsp请求,而/*可以匹配到.jsp请求这个结论表面上看没有问题,但是往深了想一步,是否能够推导出这个结论:“/不会匹配.html请求,而/*可以匹配到.html请求”。试试看: @WebServlet(urlPatterns = {"/"}) public class FakeServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("FakeServlet收到请求:" + req.getRequestURI()); } } @WebFilter(urlPatterns = {"/*"}) public class FakeFilter extends HttpFilter { @Override protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { System.out.println("FakeFilter收到请求:" + req.getRequestURI()); super.doFilter(req, res, chain); } } 启动服务器,浏览器访问:http://localhost:8080/appcontext/api/demo1.jsp,控制台输出:FakeFilter收到请求:/appcontext/api/demo1.jspservlet并未匹配上,似乎符合此结论:/不会匹配.jsp请求,而/*可以。浏览器再访问:http://localhost:8080/appcontext/api/demo1.html,控制台输出:FakeFilter收到请求:/appcontext/api/demo1.html FakeServlet收到请求:/appcontext/api/demo1.htmlFilter和Servlet都匹配成功,破功了吧!所以说,局限于该回答本身没有问题,而问题在于.jsp后缀是一种特殊的请求,拿特殊案例当做通用结论肯定是站不住脚的。3、/*匹配范围比/大通过本文下面的讲解你就会知道:/属于最大的的匹配范围,而/*恰好是范围和/一样了而已,但/*的优先级比/高,并不是它的匹配范围比/大。4、/匹配所有url(路径+后缀),/*只匹配路径型用一句话反驳:/*也能匹配上/api/demo1.html这种后缀型url(其实上面已经给出示例了)这4个结论搜索排名非常靠前,不知误导了多少小朋友呀。与其每次将信将疑,倒不如花点时间写代码自己做个试验来得靠谱。我一向推崇的代码多动手,人云亦云不如自己来上一发。带着这几个❌结论,接下来开始发大招啦:从根本上带你理解Servlet规范的URL匹配机制,从而理解到/和/*的区别,授之以渔让你终身难忘。Servlet的urlPatterns路径映射说明:本文所指的Servlet是广义的(规范),所以也包含Filter的urlPatternsServlet/Filter是服务端的一段小程序,用于处理Http请求。每个Servlet可以映射1个or多个路径,在xml时代这么写(url-pattern标签可写多个): <servlet-mapping> <servlet-name>Demo1Servlet</servlet-name> <url-pattern>/api/demo1</url-pattern> <url-pattern>/api/demo2</url-pattern> </servlet-mapping> @WebServlet注解方式这么写:@WebServlet(urlPatterns = {"/api/demo1", "/api/demo2"}) public class Demo1Servlet extends HttpServlet { ... }此时,该Servlet就能处理这两种 URL了。问题来了,如果希望本Servlet处理某一类请求,该怎么破呢?一类请求显然是无法一一枚举出来的,这时就需要用到Servlet的模式匹配了。urlPatterns除了写字面量的字符串,还支持pattern模式的字符串(从该属性的命名你应该也能看出来)。接下来聚焦于Servlet的匹配方式展开详细讲解,这是本文的核心内容。Servlet四种匹配方式在Servlet规范中一共约定了四种匹配方式,无一例外,每种方式都非常重要和常用,下面逐一介绍。1. 精确匹配顾名思义,urlPatterns是个无通配符的精确字符串,如: @WebServlet(urlPatterns = {"/api/demo1", "/api/demo2"}) // 精确匹配 public class UrlPatternDemoServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.printf("收到请求:%s ServletPath:%s PathInfo:%s\n", req.getRequestURI(), req.getServletPath(), req.getPathInfo()); } } 打印里输出servletPath和pathInfo信息,让日志更具对比性浏览器访问http://localhost:8080/appcontext/api/demo1和/api/demo2均能收到该请求,控制台分别打印:收到请求:/appcontext/api/demo1 ServletPath:/api/demo1 PathInfo:null 收到请求:/appcontext/api/demo2 ServletPath:/api/demo2 PathInfo:null 2. 路径匹配pattern规则:以/开头,且以/*结尾。如:@WebServlet(urlPatterns = {"/api/*", "/*"}) // 路径匹配 public class UrlPatternDemoServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 同上 } } 浏览器访问http://localhost:8080/appcontext/api/demo1,控制台输出(匹配的/api/*):收到请求:/appcontext/api/demo1 ServletPath:/api PathInfo:/demo1访问http://localhost:8080/appcontext/apiapi/demo1,控制台输出(匹配的/*:收到请求:/appcontext/apiapi/demo1 ServletPath: PathInfo:/apiapi/demo关注点:当匹配上/*模式时,ServletPath的值为空串,但PathInfo的值更为“丰富”了。3. 后缀名匹配patten规则:以*.开头(注意是开头,所以/api/*.jsp这么写是非法的)。如:@WebServlet(urlPatterns = {"*.jsp", "*.*"}) // 后缀名匹配 public class UrlPatternDemoServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 同上 } } 访问http://localhost:8080/appcontext/api/demo1,结果404,因为没有后缀嘛;访问http://localhost:8080/appcontext/api/demo1.jsp,控制台输出(匹配*.jsp):收到请求:/appcontext/api/demo1.jsp ServletPath:/api/demo1.jsp PathIn访问http://localhost:8080/appcontext/api/demo1.servlet,结果404,因为urlPatterns里没有匹配.servlet后缀的模式;访问http://localhost:8080/appcontext/api/demo1.,结果404,原因同上访问http://localhost:8080/appcontext/api/demo1.*,控制台打印(匹配*.*): 收到请求:/appcontext/api/demo1.* ServletPath:/api/demo1.* PathInfo:null 发现没,这种匹配方式还蛮“特殊”的,需要注意这两点:该模式以*.开头,后面的均是常量,即使是*也是常量。比如*.*匹配的后缀必须是.*而不能是其它该匹配方式下,pathInfo永远是null,servletPath永远是“全部”4. 缺省匹配pattern规则:固定值/。如:想一想,这不就是我们熟悉的DispatcherServlet的匹配路径么? @WebServlet(urlPatterns = "/") // 缺省匹配 public class UrlPatternDemoServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 同上 } } 这个时候匹配任意路径。访问http://localhost:8080/appcontext,控制台打印:收到请求:/appcontext/ ServletPath:/ PathInfo:null访问http://localhost:8080/appcontext/api/demo1,控制台打印:收到请求:/appcontext/api/demo1 ServletPath:/api/demo1 PathInfo:null访问http://localhost:8080/appcontext/api/demo1,控制台打印:收到请求:/appcontext/api/demo1 ServletPath:/api/demo1 PathInfo:null此匹配规则下,pathInfo永远是null,servletPath永远是“全部”。关于pathInfo:pathInfo只有当Servlet是路径匹配时,才有值。其它情况永远为nullURL匹配注意事项Servlet对URL的匹配既不是Ant风格,也不是Regex。特殊符号只有单个的*,且使用位置有强约束,切忌想当然的随意拼凑。举例两种典型的错误理解,应该能帮助到你:/api/*.jsp:该urlPatterns是非法的,启动时会报错“IllegalArgumentException: servlet映射中的[/api/*.jsp]无效”。原因为:若当路径匹配,/*后面不能再有任何东西若当后缀名匹配,*.必须是最前面/api/*/demo:这个urlPatterns是合法的。只不过它属于精确匹配,也就是说别看它中间有*,仍旧有且仅能匹配/api/*/demo这个请求路径匹配顺序有时候一个URL会被多个urlPatterns所匹配,这时谁优先呢?Servlet同样遵循“国际惯例”:越精确越优先,越模糊越靠后。站在pattern模式的角度换句话讲就是:范围越小越优先,范围越大越靠后。因此Servlet四种匹配方式顺序按范围从小到大(优先级从高到底)排序为:精确匹配 > 路径匹配 > 后缀名匹配 > 缺省匹配。/和/*的区别终于,来到了今天的主菜。从上至下的阅读到这里,再看这个问题,是不是觉得答案已经浮出水面?那么,最后我还是来总结一下它俩的异同点:相同点绝大部分场景下具有相同的表现:匹配所有。不同点就是由于它们的相同点(如此相似),所以才让我们难以区分。关于/:servlet中特殊的匹配模式(用在Filter中无效),因为是缺省匹配代表匹配所有路径,所以只可能存在一个实例(若存在多个就覆盖)优先级最低(兜底),这是和/*的最大区别。它不会覆盖任何其它的url-pattern,只会覆盖Servlet容器(如Tomcat)内建的DefaultServlet关于/*:属于4中匹配模式中的路径匹配,可用于Servlet和Filter优先级很高(仅次于精确匹配)。所以它会覆盖所有的后缀名匹配,从而很容易引起404问题,所以这种模式的“伤害性”是非常强的,一般有且仅用在Filter上DispatcherServlet不拦截.jsp请求根因分析/只能用于Servlet上,/*一般只用于Filter上。大家熟悉的Spring MVC的DispatcherServlet的匹配路径默认就是/,它会拦截各种各样的请求,诸如下面这种请求都会拦截:/api/demo1/html/demo1.html/static/main.js但是,它不会拦截/api/demo1.jsp这种以.jsp结尾的请求。据此现象就出现了:/不拦.jsp请求而/*拦截(/*的范围比/大)这种“错误”言论。下面告诉你此现象的根因:Servlet容器(如Tomcat)内置有专门匹配.jsp这种请求的Servlet处理器,如下图所示: 而后缀名匹配优先级高于缺省匹配,所以.jsp结尾的请求不会被DispatcherServlet所“截胡”而是交给了JspServlet处理。有了这波分析后,就问你,是不是就不用死记答案了?是不是就终身难忘啦?✍总结Servlet的urlPatterns匹配方式是学习Java Web的重要一环,也是深入理解Spring MVC原理的大门,毕竟Spring MVC依旧是做业务开发的首选,而且还会持续很久、很久。本文对Servlet的匹配方式做了全覆盖讲解,包括:四种匹配方式匹配顺序(优先级)Servlet和Filter匹配的区别模式匹配中/和/*区别的根本原因通过本文希望能让你不再被Servlet的模式匹配所困扰,更不要被一些似可非可的结论所迷惑,摇摆不定时大不了编码验证一下嘛。本文通过授人以渔的方式道出/和/*的区别,期待能成为你的永久记忆,我做到了吗?
代理服务器/网关方式众所周知,一般的架构不会是浏览器->后端服务点对点, 而是会设计(很多)中间层,比如代理服务器、网关等。就像这样:既然如此,我们就多了一些手段来处理Cors。从“距离”上看,我们可以在离浏览器最近的地方(流量入口处如Nginx,Gateway等)把Cors跨域问题搞定,这样后端Web Server就无需再操心了,可谓十分方便。下面以Nginx为例,看看如何落地?# # Wide-open CORS config for nginx ### 没有保护的(潜台词:有安全风险的)NG Cors配置 # location / { ### 在Ng层就把Options请求全部拦截掉,不会下层到后面的web应用 if ($request_method = 'OPTIONS') { ### 使用*通配符表示允许所有的Origin源 add_header 'Access-Control-Allow-Origin' '*'; # # Om nom nom cookies # add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; ### 若有需要,可增加PUT、DELETE等请求 # # Custom headers and headers various browsers *should* be OK with but aren't # ### 允许自定义的请求头(根据需要,自行删减哈) add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; # # Tell client that this pre-flight info is valid for 20 days # ### 允许预检请求缓存20天之久(根据需要自行调整) add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain charset=UTF-8'; add_header 'Content-Length' 0; return 204; } ### 因为上面OPTIONS只允许了GET/POST所以这里就只列出两,根据需要自行增减哦 ### if ($request_method = 'POST') { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; } if ($request_method = 'GET') { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; } } 这是一段比较“著名”的、通用的Nginx解决Cors问题的配置。这段配置基本能够解决绝大多数的跨域请求case,但也正是因为它的通用性,带有如下不足:Access-Control-Allow-Origin为通配符*,表示所有的Origin都能访问本站资源,安全性低Access-Control-Allow-Origin响应头只允许有一个(有多个就会报错),而把它写进了NG,导致后端Web应用无法对它进行精细化控制了Access-Control-Allow-Credentials的值恒定设置为true。在本系列第二篇文章提到:当需要跨域请求携带cookie等验证信息时,Access-Control-Allow-Origin头的值是不允许为*的,而NG这一层对此又限制了总而言之言而总之,在离浏览器最近的地方处理Cors有优有劣。优点是通用性很好、“体验”也最好(web server无需感知),但也应当知晓它的劣势,如安全性低、个性化性差(因为无法感知到业务需求嘛)。万物具有两面性,请勿一刀切,要因地制宜呀。一般来讲纯前端静态资源的跨域资源共享可用Ng形式统一处理,但对于服务端(后端)Web应用的API接口资源管理,由于场景较为复杂,对安全性要求颇高,因此还是交给给应用自行管理更为合适Gateway网关方式网关也可认为是一种代理服务器,属于中间层中的一层。不过相较于Nginx来讲,它的可编程性更强一些,因此很多时候将Cors逻辑放到网关层具有更大的灵活性(特别是内网网关),起到一个折中的效果。Web应用方式Web应用是离浏览器“最远”的地方,在这里解决Cors对应用侵入性最大。但是呢,由于能感知到业务(如知道有哪些接口、哪些功能)的存在,所以就能做到精细化控制,安全性最高,个性化最强,因此具体落地处理方式也有多种。1. 硬编码方式顾名思义,就是在实际处理请求的代码前/中/后通过硬编码的方式解决。本系列前面文章给出的代码示例,为了便于理解均是这种硬编码方式。/** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/cors") public class CorsServlet extends HttpServlet { @Override protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { super.doOptions(req, resp); setCrosHeader(resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); resp.getWriter().write("hello cors..."); setCrosHeader(resp); } private void setCrosHeader(HttpServletResponse resp) { resp.setHeader("Access-Control-Allow-Origin", "http://localhost:63342"); resp.setHeader("Access-Control-Expose-Headers", "token,secret"); resp.setHeader("Access-Control-Allow-Headers", "token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同) } } 优点:个性化极强,可以针对接口级别给出不同的CORS逻辑,精细化控制缺点:侵入性从应用级别上升到了业务代码级别,显得十分臃肿,粒度太细后期维护成本高2. 自定义Filter/Interceptor既然是Filter那便属于“批处理”方案:对整个应用做Cors的统一逻辑处理 /** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/14 09:50 * @since 0.0.1 */ public class CORSFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; resp.addHeader("Access-Control-Allow-Credentials", "true"); resp.addHeader("Access-Control-Allow-Origin", "*"); resp.addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT"); resp.addHeader("Access-Control-Allow-Headers", "Content-Type,X-CAF-Authorization-Token,sessionToken,X-TOKEN"); if (((HttpServletRequest) request).getMethod().equals("OPTIONS")) { resp.getWriter().println("ok"); return; } chain.doFilter(request, resp); } } 优点:应用级别的统一处理,对业务代码无侵入性。应用内集中化处理Cors逻辑,维护方便缺点:无法做到接口级别的粒度,对于某些特殊要求的细粒度控制自然就无能为力说到底,上例中的自定义Filter的方式仍属于硬编码方式(将影响Cors的相关头信息写死的),不够灵活。其实可以再优化一下,让其更富弹性。为此,早在N年之前就有eBay开源的过滤器方案:cors-filter.java 供以参考。 <dependency> <groupId>org.ebaysf.web</groupId> <artifactId>cors-filter</artifactId> <version>1.0.1</version> </dependency> 它可以让允许的origins、methods、headers等都支持可配置化,更富弹性。3、Spring Framework方式调研一下:现在做Java(Web)开发,应该没有不使用Spring Framework的吧?Spring自4.2版本(2015-06)开始,就提供了对Cors的全面支持,大大简化应用级Cors问题的处理。其中面向开发者提供了两个用于优雅处理Cors问题的组件:@CrossOrigin:借助此注解可以通过声明式方式,对类级别、甚至接口级别进行跨域的资源控制CorsFilter:Spring也提供了用于“全局处理”的过滤器,兼具了普适性和灵活性WebMvcConfigurer:这是一种配置方式,严格来讲不算一种解决方案而是一种落地方式而已由于Java开发者一直和Spring打交道,因此深入理解此场景下的解决方案,打通其执行原理方可使用起来得心应手,所以这也是本系列关心的重中之重。关于此part本系列下文会单独成篇解读,包括使用姿势到设计思想、源码分析…4、Spring Boot方式如你所知,Spring Boot是构建在Spring Framework之上的。在Cors这块Spring Boot并未对其做增强or扩展,因此使用姿势上同Spring Framework。这是不是再一次验证了那句话:在Spring Boot上能走多远由你对Spring Framework的了解深度而决定Cors安全漏洞浏览器的同源策略(SOP)是一个安全基石。SOP是一个很好的策略,但是随着Web应用的发展,网站由于自身业务的需求,需要实现一些跨域的功能,能够让不同域的页面之间能够相互访问各自页面的内容,这就导致SOP策略不是那么的凑效了。Cors作为当下解决浏览器跨域问题的标准方案,如若使用不当是会带来安全漏洞,造成隐患的。其中最常见的便是:Access-Control-Allow-Origin: *到底。殊不知,*用于表示允许任意域访问,这种配置一般只用于共享公开资源。如果用于非公共资源的话,那就相当于击穿了浏览器的同源策略,给所有Origin授权。其实这和授权授信有点像,当授权范围越大,方便的是操作/管理上,但这就容易被利用而被攻击。因此在允许的情况下,能粒度小点就尽量精细化控制(特别是敏感资源、接口),毕竟安全无小事。Access-Control-Allow-Origin既然不建议配置为*,那么如何允许多域名呢?本系列上篇文章有详细分析,请参考:Access-Control-Allow-Origin安全性这个东西是相对的,没有绝对的安全,也做不到绝对的安全。我们能做的,就是尽量去解决已知的安全性问题,不要让“入侵”来得很容易即可。JSONP与CORS对比JSONP与CORS的使用目的相同,并且都需要服务端和客户端同时支持,虽然功能上讲CORS更为强大,但…下面进行对比下1.JSONP的最主要优势是对(老)浏览器的支持很好,而CORS由于出现较晚(2014年确定)这面稍差一些~不过,还是那句话:现在都2021年了,在浏览器支持方面可以几乎不用再作考虑2.JSONP 只能 用于Get请求,而CORS能用于所有的Http Method。这一点上JSONP被完虐3.JSONP的错误处理机制不完善(其实是没有),当发生错误时开发者无法进行处理。而CORS可以通过onerror监听到错误事件,从而就可以看到错误详情方便排查问题4.JSONP只会发送一次请求,而CORS的非简单请求会发送两次(大部分情况下的请求都会属于非简单请求)还不懂什么是简单请求和非简单请求,看本系列第一篇:Cors跨域(一):深入理解跨域请求概念及其根因5.安全问题上,二者也有较大差异:JSONP不是跨域的规范,它存在明显的安全漏洞。表现在:callback参数注入(这是由于这些元素都是裸露的),以及资源授权方面无法限制(也就说他能接受所有Origin的请求从而易不太安全)CORS是跨域的规范,并且能够对资源授权方面做控制。Access-Control-Allow-Origin响应头就是最重要的一个响应头,当然喽若你把它恒定设为*,那它的安全性就大大退化总的来讲,CORS相较于JSONP 优势明显 ,在实际生产使用上,忘了JSONP吧✍总结JSONP作为解决跨域问题的曾经的唯一方案,立下汗马功劳,现在是该退役了。但我们有理由记得它,毕竟英雄迟暮也希望不被遗忘(扯淡了,主要是这个名词在很多新/老文章中还经常被提起,注意分辨不要被弄迷糊啦)。总而言之,作为新时代的开发人员,心里可认为跨域问题的解决方案只有一种:那便是Cors。下一篇将是“激动人心”的内容:讲述Cors在Spring环境中的实施,见识下那有多优雅吧
✍前言你好,我是方同学(YourBatman)A哥 -> 方同学。是的,中文昵称改了。自知道行不深无以用“哥”字称呼,虽已毕业多年,同学二字寄寓心态一直积极、热情、年轻挖掘机技术哪家强,山东技校找蓝翔;跨域问题怎么解,CORS还是JSONP?关于浏览器跨域问题的解决方案,坊间一直“传闻”着两种解决方案:JSONP和CORS。由于文章的历史背景不同,作者偏好不一样,搞得好些同学迷惑得很,去谷歌里百度搜寻答案时经常就是这种赶脚。作为一家负责任的“技校”(负责人的技术专栏),今天通过此文彻底给你解释清楚并给出确定的答案,助你快速选择正确的道路解决问题。所属专栏点拨-Cors跨域本文提纲 版本约定JDK:8Servlet:4.xtomcat:9.x✍正文同源策略是浏览器最核心也最基本的安全功能。当被浏览器半信半疑的脚本运行在沙箱时,它们应该只被允许访问来自同一站点的资源,而不是那些来自其它站点可能怀有恶意的资源。但是呢,在现在的互联网场景中,跨域访问是一种必须,所以才有了解决跨域问题的方案。两大方案:JSONP和CORS对于跨域共享资源,一共有两大解决方案JSONP:老一代浏览器解决方案CORS:全新一套标准的解决方案JSONP方案 和iPhone 7和iPhone 7P不一样,JSONP 不等于 JSON Plus,全称是JSON with Padding。JSON是一种基于文本的数据交换格式,而JSONP是一种使用模式,可以让网页从别的域访问资源,从而完成跨域资源共享。本系列第一篇文章就说到:<script>标签的src是没有跨域这么一说的,可以基于这一点实现Get请求的跨域。JSONP的实现跨域的基本原理是:利用script标签的src没有跨域限制 + 回调的方式来完成跨域访问。代码实现示例前端页面:托管在63342端口 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>JSONP跨域请求</title> <!--导入Jquery--> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script> </head> <body> <script> // 请求完成后会回调此函数 function jsonpCallback(result) { console.log("这是JSONP请求的响应结果:" + result); } </script> <!--注:这个script必须放在上面function的下面--> <script type="text/javascript" src="http://localhost:8080/jsonp?callback=jsonpCallback"></script> </body> </html> 说明:利用script的src发送http请求到服务端,因此此script标签务必放在function的下面(因为浏览器是从上至下渲染)服务端代码:托管在8080端口/** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/jsonp") public class JSONPServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String callback = req.getParameter("callback"); resp.getWriter().write(callback + "('hello jsonp...')"); } } 说明:可以看到服务端的代码非常的清爽,不涉及到任何请求头/响应头打开页面,发送JSONP请求,结果如下:请求的响应体:浏览器控制台输出:完美。通过JSONP我们实现了访问不同域的资源,实现了跨域。用jQuery的ajax发送异步JSONP请求上例是使用<script>标签的src属性发送同步跨域请求,在实际开发中(特别是前后端分离)大多数情况下发送的均为Ajax异步请求,下面来试试。说明:异步请求用原生XMLHttpRequest还是Ajax或者Promis方式发出,底层原理都归一是相同的使用jQuery发送异步JSONP请求非常的简单,连<script>和函数都不用写:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>JSONP跨域请求</title> <!--导入Jquery--> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script> </head> <body> <!--<script>--> <!-- // 请求完成后会回调此函数--> <!-- function jsonpCallback(result) {--> <!-- console.log("这是JSONP请求的响应结果:" + result);--> <!-- }--> <!--</script>--> <!--&lt;!&ndash;注:这个script必须放在上面function的下面&ndash;&gt;--> <!--<script type="text/javascript" src="http://localhost:8080/jsonp?callback=jsonpCallback"></script>--> <script> $.ajax({ // type: "get", // 不用写方法,因为JSONP只支持GET请求 url: "http://localhost:8080/jsonp", // 使用jQuery的Ajax后面是没有参数 dataType: 'jsonp', success: function (data) { console.log("这是JSONP请求的响应结果(jQuery Ajax):" + data); }, }); </script> </body> </html> 通过jQuery大大改善了js代码的书写方式,使得结构更加优雅、直观。这就是jQuery最厉害的语法糖能力~说明:JsonP only works with type: GET。也就说type即使你写成post,jQuery也会给你转成get服务端不变,发送异步请求,结果如下: 关注点:Ajax的callback回调函数名是动态生成的,并且确保了唯一性由于服务端并不关心回调的函数名名称,因此回调函数名的长短没有关系(浏览器自己能识别就成)影响体如下:浏览器控制台打印:完美。对于有技术敏感性的你来讲,应该能发现底层原理依旧还是script的src,只是写法不一样,仅此而已。优缺点JSONP跨域方案作为一种“古老”方式,有如下优缺点:优点:对老浏览器(如IE8、7等)有非常好的兼容性书写起来比较简单,容易理解(毕竟没有那么多的请求头、响应头需要考虑嘛)缺点:只能发送get请求,不支持POST、PUT等请求方式,这是硬伤安全度不高。因为JSONP是利用函数回调来由浏览器执行目标函数,这样宿主web其实是比较容易受到各类攻击的总的来讲,随着Cors规范在2014年的正式确定,现代的浏览器100%支持Cors规范。由于浏览器的更新换代,JSONP的最大优势(兼容老浏览器)也就不复存在了,所以在实际开发中的使用建议是:不要使用JSONP,而应拥抱CORS。CORS方案 由于JSONP方案存在一些不足(比如只支持Get请求就是硬伤),并不能很好的满足对跨域资源共享的需求,因此就出现了当下主流的跨域规范:CORS(Cross-origin resource sharing)不同于JSONP的方案,CORS方案更强大实用,但稍微复杂那么一丢丢。其背后的基本思想是:使用自定义的HTTP头部和浏览器“沟通”,让浏览器和服务器相互“了解”对方,从而决定请求或响应成功与否。说明:CORS 并不是为了解决服务端安全问题而出现,而是为了解决如何跨域调用资源。至于如何设计出安全的、开放的API,这就是安全范畴了(如可加上token验证、请求有效期、ip来源验证等手段)CORS的WD(工作草案)从2009-03-17开始,2014-01-16进入REC(推荐标准)阶段,可谓正式毕业。起初CORS的推广的主要障碍是当时市面上的老浏览器并不支持它(比如当时市场占有率极大的IE 6、7、8这种老家伙),毕竟这个规范是新的只有升级的新浏览器才会支持到。但历史的巨轮永远是滚滚向前,现在已经2021年了,现今市面上的浏览器对CORS规范的支持情况如下图所示(数据来源于:http://caniuse.com): 看到这张图,应该可以毫不客气的说:所有的浏览器(包括手机、PAD等浏览器)均已支持CORS规范版本上拿Chrome浏览器举例:我现在使用的版本是91.0.xxxx.xxx,完美支持:当下阶段,已完全无需考虑浏览器兼容问题,所以JSONP的优势也就不复存在,可以放心的、积极的拥抱CORS。既然要用CORS,作为程序员不能只停留在概念上层面,接下来就来聊点干的,看看从实操层面有哪些具体做法落地CORS呢?CORS的核心要义是和服务端和浏览器进行沟通,服务端架构一般是分层的,理论上可以在任意层次完成沟通。从负责完成沟通的层次上来讲,一般分为这两大类:代理服务器/网关负责Web应用自行负责
快速创建缺省的实例上面了解到,PathPattern的构造器不是public的,所以有且仅能通过PathPatternParser创建其实例。然而,为快速满足绝大多数场景,Spring还提供了一种快速创建缺省的PathPattern实例的方式:PathPatternParser提供一个全局共享的、只读的实例用于快速创建缺省的PathPattern实例,类似于实例工厂的作用。毕竟绝大部分场景下用PathPattern的缺省属性即可,因此有了它着实方便不少。注意:虽然该PathPatternParser实例是全局共享只有1个,但是,创建出来的PathPattern可是不同实例哦(基本属性都一样而已)代码示例PathPattern的匹配方式和AntPathMatcher基本保持一致:使用的基于Ant风格模式匹配。但是发现没,这里不再强调Ant字样,也许Spring觉得Ant的概念确实已廉波老矣?不符合它紧跟潮流的身份?相比于AntPathMatcher,PathPattern主要有两处地方不一样:说明:PathPattern只支持两种分隔符(/和.),而AntPathMatcher可以随意指定。虽然这也是不同点,但这一般无伤大雅所以就不单独列出了1. 新增{*pathVariable}语法支持这是PathPattern新增的“语法”,表示匹配余下的path路径部分并将其赋值给pathVariable变量。 @Test public void test1() { System.out.println("======={*pathVariable}语法======"); PathPattern pattern = PathPatternParser.defaultInstance.parse("/api/yourbatman/{*pathVariable}"); // 提取匹配到的的变量值 System.out.println("是否匹配:" + pattern.matches(PathContainer.parsePath("/api/yourbatman/a/b/c"))); PathPattern.PathMatchInfo pathMatchInfo = pattern.matchAndExtract(PathContainer.parsePath("/api/yourbatman/a/b/c")); System.out.println("匹配到的值情况:" + pathMatchInfo.getUriVariables()); } ======={*pathVariable}语法====== 是否匹配:true 匹配到的值情况:{pathVariable=/a/b/c} 在没有PathPattern之前,虽然也可以通过/**来匹配成功,但却无法得到匹配到的值,现在可以了!和**的区别我们知道/**和/{*pathVariable}都有匹配剩余所有path的“能力”,那它俩到底有什么区别呢?/**能匹配成功,但无法获取到动态成功匹配元素的值/{*pathVariable}可认为是/**的加强版:可以获取到这部分动态匹配成功的值正所谓一代更比一代强嘛,如是而已。和**的优先级关系既然/**和/{*pathVariable}都有匹配剩余path的能力,那么它俩若放在一起,优先级关系是怎样的呢?妄自猜测没有意义,跑个案例一看便知:由于PathPattern实现了比较器接口,因此本例利用SortedSet自动排序即可,排第一的证明优先级越高 @Test public void test2() { System.out.println("======={*pathVariable}和/**优先级======"); PathPattern pattern1 = PathPatternParser.defaultInstance.parse("/api/yourbatman/{*pathVariable}"); PathPattern pattern2 = PathPatternParser.defaultInstance.parse("/api/yourbatman/**"); SortedSet<PathPattern> sortedSet = new TreeSet<>(); sortedSet.add(pattern1); sortedSet.add(pattern2); System.out.println(sortedSet); } ======={*pathVariable}和/**优先级====== [/api/yourbatman/**, /api/yourbatman/{*pathVariable}] 测试代码的细节:故意将/{*pathVariable}先放进set里面而后放/**,但最后还是/**在前。结论:当二者同时出现(出现冲突)时,/**优先匹配。2. 禁用中间**语法支持在上篇文章对AntPathMatcher的详细分析文章中,我们知道是可以把/**放在整个URL中间用来匹配的,如: @Test public void test4() { System.out.println("=======**:匹配任意层级的路径/目录======="); String pattern = "/api/**/yourbatman"; match(1, MATCHER, pattern, "/api/yourbatman"); match(2, MATCHER, pattern, "/api//yourbatman"); match(3, MATCHER, pattern, "/api/a/b/c/yourbatman"); } =======**:匹配任意层级的路径/目录======= 1 match结果:/api/**/yourbatman 【成功】 /api/yourbatman 2 match结果:/api/**/yourbatman 【成功】 /api//yourbatman 3 match结果:/api/**/yourbatman 【成功】 /api/a/b/c/yourbatman 与AntPathMatcher不同,**仅在模式末尾受支持。中间不被允许了,否则实例创建阶段就会报错:@Test public void test3() { System.out.println("=======/**放在中间语法======"); PathPattern pattern = PathPatternParser.defaultInstance.parse("/api/**/yourbatman"); pattern.matches(PathContainer.parsePath("/api/a/b/c/yourbatman")); } =======/**放在中间语法====== org.springframework.web.util.pattern.PatternParseException: No more pattern data allowed after {*...} or ** pattern element at org.springframework.web.util.pattern.InternalPathPatternParser.peekDoubleWildcard(InternalPathPatternParser.java:250) ... 从报错中还能看出端倪:不仅**,{*xxx}也是不能放在中间而只能是末尾的PathPattern这么做的目的是:消除歧义。那么问题来了,如果就是想匹配中间的任意层级路径怎么做呢?答:首先这在web环境里有这样需求的概率极小(PathPattern只适用于web环境),若这依旧是刚需,那就只能蜕化到借助AntPathMatcher来完成喽。PathPattern对比AntPathMatcher二者目前都存在于Spring技术栈内,做着“相同”的事。虽说现在还鲜有同学了解到PathPattern,我认为淘汰掉AntPathMatcher只是时间问题(特指web环境哈),毕竟后浪总归有上岸的一天。但不可否认,二者将在较长时间内共处,那么它俩到底有何区别呢?了解一下出现时间AntPathMatcher是一个早在2003年(Spring的第一个版本)就已存在的路径匹配器,而PathPattern是Spring 5新增的,旨在用于替换掉较为“古老”的AntPathMatcher。功能差异PathPattern去掉了Ant字样,但保持了很好的向下兼容性:除了不支持将**写在path中间之外,其它的匹配规则从行为上均保持和AntPathMatcher一致,并且还新增了强大的{*pathVariable}的支持。因此在功能上姑且可认为二者是一致的,极特殊情况下的不兼容除外。性能差异Spring官方说PathPattern的性能优于AntPathMatcher,我抱着怀疑的态度做了测试,示例代码和结果如下: // 匹配的模板:使用一个稍微有点复杂的模板进行测试 private static final String pattern = "/api/your?atman/{age}/**"; // AntPathMatcher匹配代码:使用单例的PathMatcher,符合实际使用情况 private static final PathMatcher MATCHER = new AntPathMatcher(); public static void antPathMatcher(String reqPath) { MATCHER.match(reqPath); } // PathPattern代码示例:这里的pattern由下面来定义 private static final PathPattern PATTERN = PathPatternParser.defaultInstance.parse(pattern); public static void pathPattern(String reqPath) { PATTERN.matches(PathContainer.parsePath(reqPath)); } 匹配的测试代码:@Test public void test1() { Instant start = Instant.now(); for (int i = 0; i < 100000; i++) { String reqPath = "/api/yourBatman/" + i + "/" + i; antPathMatcher(reqPath); // pathPattern(reqPath); } System.out.println("耗时(ms):" + Duration.between(start, Instant.now()).toMillis()); } 不断调整循环次数,且各执行三次,将结果绘制成如下表格:循环100000次:循环1000000次:循环10000000次:结论:PathPattern性能比AntPathMatcher优秀。理论上pattern越复杂,PathPattern的优势越明显。最佳实践既然路径匹配器有两种方案,那必然有最佳实践。Spring官方对此也是持有态度的:Web环境如果是Servlet应用(webmvc),官方推荐PathPattern(只是推荐,但默认的依旧是AntPathMatcher哈),相关代码体现在PathPattern里: // Since: 07.04.2003 public abstract class AbstractHandlerMapping ... { private UrlPathHelper urlPathHelper = new UrlPathHelper(); private PathMatcher pathMatcher = new AntPathMatcher(); ... @Nullable private PathPatternParser patternParser; // Since: 5.3 public void setPatternParser(PathPatternParser patternParser) { this.patternParser = patternParser; } } 注意:setPatternParser()从5.3版本开始才被加入,也就说虽然PathPattern从Spring 5就有了,但直到5.3版本才被加入到webmvc里,且作为可选(默认依旧是AntPathMatcher)。换句话讲:在Spring 5.3版本之前,仍旧只能用AntPathMatcher。在WebMvc里启用PathPattern默认情况下,Spring MVC依旧是使用的AntPathMatcher进行路径匹配的,那如何启用效率更高的PathPattern呢?通过上面源码知道,就是要调用AbstractHandlerMapping的setPatternParser方法嘛,其实Spring为此是预留了扩展点的,只需这么做即可:/** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/20 18:33 * @since 0.0.1 */ @Configuration(proxyBeanMethods = false) public class WebMvcConfiguration implements WebMvcConfigurer { @Override public void configurePathMatch(PathMatchConfigurer configurer) { configurer.setPatternParser(PathPatternParser.defaultInstance); } } 如果是Reactor应用(webflux),那PathPattern就是唯一解决方案。这体现在org.springframework.web.reactive.handler.AbstractHandlerMapping: // Since: 5.0 public abstract class AbstractHandlerMapping... { private final PathPatternParser patternParser; ... public AbstractHandlerMapping() { this.patternParser = new PathPatternParser(); } } webflux里早已不见AntPathMatcher的踪影,因为webflux是从Spring 5.0开始的,因此没有向下兼容的负担,直接全面拥抱PathPattern了。结论:PathPattern语法更适合于web应用程序,其使用更方便且执行更高效。非Web环境嗯,如果认真“听课”了的同学就知道:非Web环境依旧有且仅有一种选择,那便是AntPathMatcher,因为PathPattern是专为Web环境设计,不能用于非Web环境。所以像上面资源加载、包名扫描之类的,底层依旧是交给AntPathMatcher去完成。说明:由于这类URL的解析绝大多数情况下匹配一次(执行一次)就行,所以微小的性能差异是无所谓的(对API来讲收益较大)可能有小伙伴会说:在Service层,甚至Dao层我也可以正常使用PathPattern对象呀,何解?这个问题就相当于:HttpServletRequest属于web层专用组件,但你依旧可以将其传到Service层,甚至Dao层供以使用,在编译、运行时不会报错。但你可深入思考下,这么做合适吗?举个生活上的例子:马桶可以装在卫生间,也可以安装在卧室的床旁边,都能完成大小便功能,但你觉得这么做合适吗?Java这门语言对访问权限的控制设计得还是很优秀的,很多隔离性的问题在编译器就能搞定。但有很多规范性做法是无法做到强约束的,只能依靠工程师自身水平。这就是经验,也是区别初级工程师和高级工程师的重要因素。总结技术的日新月异,体现在一个个像PathPattern这个更好的API上。Spring 5早在2017-09就已发布,可能是由于它“设计得过于优秀”,即使大版本的发布也几乎保持100%向下兼容,使得一般开发者感受不到它的升级。但是,这对框架二次开发者并不可能完全透明,因为二次开发经常会用到其Low-Level的API,比如今天的主角PathPattern就算其中之一,所以说我们要与时俱进呀o(╥﹏╥)o!Spring 5虽然新增了(更好的)PathPattern,但它不能完全替代掉AntPathMatcher,因为前者专为web设计,所以在web领域是可完全替代掉AntPathMatcher的。但在非web领域内,AntPathMatcher依旧不可替代。
前言你好,我是YourBatman。依稀记得3年前的在“玩”Spring WebFlux的时候,看到PathPattern在AbstractHandlerMapping中起到了重要作用:用于URL的匹配。当时就很好奇:这一直不都是AntPathMatcher的活吗?于是乎我就拿出了自己更为熟悉的Spring WebMvc对于类进行功能比对,发现PathPattern扮演的角色和AntPathMatcher一毛一样,所以当时也就没去深入研究啦。正所谓念念不忘必有回响。时隔3年最近又回到搞WebFlux了,欠下的债总归要还呀,有必要把PathPattern深入解读,毕竟它是Spring5在路径解析器方面的新宠,贯穿WebFlux上下。重点是号称比AntPathMatcher拥有更好的使用体验以及更快的匹配效率,咦,勾起了兴趣了解一下~正值周末,说干就干。所属专栏点拨-Spring技术栈本文提纲 版本约定JDK:8Spring Framework:5.3.x正文PathPattern是Spring5新增的API,所在包:org.springframework.web.util.pattern.PathPattern,所属模块为spring-web。可见它专为Web设计的“工具”。不同于AntPathMatcher是一个“上帝类”把所有活都干了,新的路径匹配器围绕着PathPattern拥有一套体系,在设计上更具模块化、更加面向对象,从而拥有了更好的可读性和可扩展性。 下面深入了解下该技术体系下的核心元素。 主要有:PathElement:路径元素。一个URL模板根据/可以拆分成N多个路径元素对象PathContainer:URL的结构化表示。一个URL对应一个PathContainer对象实例PathPattern:路径解析的模式。路径模式匹配器的最核心APIPathPatternParser:将一个String类型的模式解析为PathPattern实例,这是创建PathPattern实例的唯一方式PathElement:路径元素顾名思义,它表示路径节点。一个path会被解析成N多个PathElement节点。核心属性: // Since: 5.0 abstract class PathElement { protected final int pos; protected final char separator; @Nullable protected PathElement next; @Nullable protected PathElement prev; } pos:该节点在path里的起点位置separator:该path使用的分隔符next:后节点,可以为null(如最后一个节点)prev:前节点,可以为null(如第一个节点)所有的PathElement之间形成链状结构,构成一个完整的URL模板。Tips:我个人意见,并不需要太深入去了解PathElement内部的具体实现,在宏观角度了解它的定义,然后认识下它的子类实现不同的节点类型即可它有如下子类实现: SeparatorPathElement分离器元素。代表用于分离的元素(默认是/,也可以是.)@Test public void test1() { PathPatternParser parser = new PathPatternParser(); PathPattern pathPattern = parser.parse("/api/v1"); System.out.println(pathPattern); } 断点调试查看解析后的pathPattern变量拥有的元素情况:可以看到这是标准的链式结构嘛,这种关系用图画出来就是这样子:其中绿色的/都是SeparatorPathElement类型,蓝色都是LiteralPathElement字面量类型。将一个Pattern拆解成为了一个个的Element对象,后面就可以方便的面向对象编程,大大增加了可读性、降低出错的概率。说明:由于这是第一个元素,所以才举了个实际的代码示例辅助理解。下面的就只需描述概念啦,举一反三即可~WildcardPathElement通配符元素。如:/api/*/yourbatman说明:在路径中间它至少匹配1个字符(//不行,/ /可行),但在路径末尾可以匹配0个字符SingleCharWildcardedPathElement单字符通配符元素。如:/api/your??tman说明:一个?代表一个单字通配符,若需要适配多个用多个?即可WildcardTheRestPathElement通配剩余路径元素。如:/api/yourbatman/**说明:**只能放在path的末尾,这才是rest剩余的含义嘛CaptureVariablePathElement将一段路径作为变量捕获的路径元素。如:/api/yourbatman/{age}说明:{age}就代表此元素类型被封装进来CaptureTheRestPathElement捕获路径其余部分的路径元素。如:/api/yourbatman/{*restPath}说明:若待匹配的路径是/api/yourbatman/a/b/c,那么restPath=a/b/cLiteralPathElement字面量元素。不解释~RegexPathElement正则表达式元素。如:api/*_*/*_{age}说明:*_*和*_{age}都会被解析为该元素类型,这种写法是从AntPathMatcher里派生来过的(但不会依赖于AntPathMatcher)总之:任何一个字符串的pattern最终都会被解析为若干段的PathElement,这些PathElement以链式结构连接起来用以表示该pattern,形成一个对象数据。不同于AntPathMatcher的纯字符串操作,这里把每一段都使用对象来描述,结构化的表示使得可读性更强、更具灵活性,甚至可以获得更好的性能表现。PathContainer:URL的结构化表示和PathPattern类似,待匹配的path的每一段都会表示为一个元素并保存其元数据信息。也就是说:每一个待匹配的URL路径都会被解析为一个PathContainer实例。PathContainer虽然是个接口,但我们无需关心其实现,类同于Java 8的java.util.stream.Collector接口使用者无需关心其实现一样。因为提供了静态工具方法用于直接生成对应实例。体验一把: @Test public void test2() { PathContainer pathContainer = PathContainer.parsePath("/api/v1/address", PathContainer.Options.HTTP_PATH); System.out.println(pathContainer); } debug模式运行,查看pathContainer对象详情:这和解析为PathPattern的结构何其相似(不过这里元素们是通过有序的集合组织起来的)。对比看来,拍脑袋应该能够猜到何新版的匹配效率会更高了吧。补充说明:value和valueToMatch的区别:value是原值,valueToMatch是(处理过的,比如已解码的)最终参与匹配的值parameters代表路径参数。若希望它有值只需使用;号分隔填值即可。如:/api;abc/v1,此参数一般都用不着因为Http中是允许这样携带参数的,但是目录(.形式)就不能这么写啦PathPattern:路径解析的模式表示解析路径的模式。包括用于快速匹配的路径元素链,并累积用于快速比较模式的计算状态。它是直接面向使用者进行匹配逻辑的最重要API,完成match操作。PathPattern所在包是org.springframework.web.util.pattern.PathPattern,位于spring-web模块,专为web(含webmvc和webflux)设计的全新一套路径匹配API,具有更高的匹配效率。认识下它的成员属性: // Since: 5.0 public class PathPattern implements Comparable<PathPattern> { // pattern的字符串形式 private final String patternString; // 用于构建本实例的解析器 private final PathPatternParser parser; // 分隔符使用/还是.,默认是/ private final PathContainer.Options pathOptions; // 如果pattern里结尾没/而待匹配的有,仍然让其匹配成功(true),默认是true private final boolean matchOptionalTrailingSeparator; // 是否对大小写敏感,默认是true private final boolean caseSensitive; // 链式结构:表示URL的每一部分元素 @Nullable private final PathElement head; private int capturedVariableCount; private int normalizedLength; private boolean endsWithSeparatorWildcard = false; private int score; private boolean catchAll = false; } 以上属性是直接读取,下面这些个是计算出来的,比较特殊就特别照顾下:capturedVariableCount:在这个模式中捕获的变量总数。也就是{xxx}或者正则捕获的总数喽normalizedLength:通配符批到的变量长度的总和(关于长度的计算有个约定:如?是1,字面量就是字符串长度),这个变量对提升匹配速度有帮助endsWithSeparatorWildcard:标记该模式是否以隔离符或者通配符*结尾score:分数用于快速比较该模式。不同的模式组件被赋予不同的权重。分数越低越具体,如:捕获到的变量分数值为1,通配符值是100catchAll:该pattern是否以**或者{*xxx}结尾score、catchAll等标记用于加速匹配的速度,具体体现PathPattern.SPECIFICITY_COMPARATOR这个比较器上,这是PathPattern速度比AntPathMatcher快的根因之一值得注意的是:所有属性均不提供public的set方法,也就是说PathPattern实例一旦创建就是只读(不可变)实例了。
前言你好,我是YourBatman。@RequestMapping的URL是支持Ant风格的@ComponentScan的扫描包路径是支持Ant风格的@PropertySource导入资源是支持Ant分隔的(如:classpath:app-*.properties)…在描述路径时有个常见叫法:Ant风格的URL。那么到底什么是Ant风格?关于这个概念,我特地的谷歌一下、百度一下、bing一下,无一所获(没有一个确切的定义),难道这个盛行的概念真的只能意会吗?直到我在Spring中AntPathMatcher的描述中看到一句话:这是从Apache Ant借用的一个概念。 “年轻”的朋友可能从没用过甚至没听过Ant,它是一个构建工具,在2010年之前发挥着大作用,但之后逐渐被Maven/Gradle取代,现已几乎销声匿迹。虽然Ant“已死”,但Ant风格似乎要千古。借助Spring强大的号召力,该概念似乎已是规范一样的存在,大家在不成文的约定着、交流着、书写着。那么,既然Ant风格贯穿于开发的方方面面,怀着一知半解的态度使用着实为不好。今天咱们就深入聊聊,进行全方位的讲解。所属专栏点拨-Spring技术栈本文提纲 版本约定JDK:8Spring Framework:5.3.x正文在Spring 5之前,Spring技术栈体系内几乎所有的Ant风格均由AntPathMatcher提供支持。PathMatcher路径匹配器PathMatcher是抽象接口,该接口抽象出了路径匹配器的概念,用于对path路径进行匹配。它提供如下方法:细节:PathMatcher所在的包为org.springframework.util.PathMatcher,属于spring-core核心模块,表示它可运用在任意模块,not only for web。 // Since: 1.2 public interface PathMatcher { boolean isPattern(String path); boolean match(String pattern, String path); boolean matchStart(String pattern, String path); String extractPathWithinPattern(String pattern, String path); Map<String, String> extractUriTemplateVariables(String pattern, String path); Comparator<String> getPatternComparator(String path); String combine(String pattern1, String pattern2); } 一个路径匹配器提供这些方法在情理之中,这些方法见名知意理解起来也不难,下面稍作解释:boolean isPattern(String path):判断path是否是一个模式字符串(一般含有指定风格的特殊通配符就算是模式了)boolean match(String pattern, String path):最重要的方法。判断path和模式pattern是否匹配(注意:二者都是字符串,传值不要传反了哈)boolean matchStart(String pattern, String path):判断path是否和模式pattern前缀匹配(前缀匹配:path的前缀匹配上patter了即可,当然全部匹配也是可以的)String extractPathWithinPattern(String pattern, String path):返回和pattern模式真正匹配上的那部分字符串。举例:/api/yourbatman/*.html为pattern,/api/yourbatman/form.html为path,那么该方法返回结果为form.html(注意:返回结果永远不为null,可能是空串)Map<String, String> extractUriTemplateVariables(String pattern, String path):提取path中模板变量。举例:/api/yourbatman/{age}为pattern,/api/yourbatman/18为path,那么该方法返回结果为Map值为{"age" : 18}Comparator getPatternComparator(String path):路径比较器,用于排序确定优先级高低String combine(String pattern1, String pattern2):合并两个pattern模式,组合算法由具体实现自由决定该接口规定了作为路径匹配器一些必要的方法,同时也开放了一些行为策略如getPatternComparator、combine等由实现类自行决定。或许你还觉得这里某些方法有点抽象,那么下面就开始实战。正则表达式 vs Ant风格 正则表达式(regular expression):描述了一种字符串匹配的模式(pattern),可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串等。正则表达式是由普通字符(例如字符a到z)以及特殊字符(又称"元字符")组成的文字模式。重点是它的元字符,举例说明:?:匹配前面的子表达式零次或一次*:匹配前面的子表达式零次或多次+:匹配前面的子表达式一次或多次.:匹配除换行符 \n 之外的任何单字符…正则表达式几乎所有编程语言都支持的通用模式,具有普适性(适用于任意字符串的匹配)、功能非常强大等特点。除此之外正则表达式还有“重”、“难”等特点,具有一定上手门槛、高并发情况下执行效率低也都是它摆脱不了的“特性”。 Ant风格(Ant Style):该风格源自Apache的Ant项目,若你是个“老”程序员或许你还用过Apache Ant,若你是个小鲜肉也许闻所未闻,毕竟现在是Maven(Gradle)的天下。Ant风格简单的讲,它是一种精简的匹配模式,仅用于匹配路径or目录。使用大家熟悉的(这点很关键)的通配符: 看到没,这才比较符合咱们的习惯:*代表任意通配符才是正解嘛,而不是像正则一样代表匹配的数量来得让人“费解”。**直接用于目录级别的匹配,可谓对URL这种字符串非常友好最佳实践场景正则表达式具有功能非常强大的特性,从理论上来讲,它可以用于任何场景,但是有些场景它并非最佳实践。举个例子:在自定义的登录过滤器中,经常会放行一些API接口让免登录即可访问,这是典型的URL白名单场景,这个时候就会涉及到URL的匹配方式问题,一般会有如下方案:精确匹配:url.equals("/api/v1/yourbatman/adress")。缺点:硬编码式一个个罗列,易造成错误且不好维护前缀匹配:url.startsWith("/api/v1/yourbatman")。这也算一种匹配模式,可以批量处理某一类URL。缺点是:匹配范围过大易造成误伤,或者范围过小无法形成有效匹配,总之就是欠缺灵活度包含匹配:url.contains("/yourbatman")。这个缺点比较明显:强依赖于URL的书写规范(如白名单的URL都必须包含指定子串),并且极易造成误伤正则表达式匹配:Pattern.compile("正则表达式")..matcher(url).find()。它的最大优点是可以满足几乎任意的URL(包括精确、模式等),但最大的缺点是书写比较复杂,用时多少这和coder的水平强相关,另外这对后期维护也带来了一定挑战~经常会听到这样一句话:“通过正则表达式或者Ant风格的路径表达式来做URL匹配”。正所谓“杀鸡何必用牛刀”,URL相较于普通的字符串具有很强的规律性:标准的分段式。因此,使用轻量级Ant风格表达式作为URL的匹配模式更为合适:轻量级执行效率高通配符(模式)符合正常理解,使用门槛非常低*和**对层级路径/目录的支持感觉就是为此而生的对于复杂场景亦可包含正常表达式来达到通用性总的来讲,所谓为谁更好。Ant风格和正则表达式都有它们场景的最佳实践:Ant风格:用于URL/目录这种标准分段式路径匹配正则表达式:用于几乎没规律(或者规律性不强)的普通字符串匹配AntPathMatcher:基于Ant风格的路径匹配器PathMatcher接口并未规定路径匹配的具体方式,在Spring的整个技术栈里(包括Spring Boot和Cloud)有且仅有一个实现类AntPathMatcher:基于Ant风格的路径匹配器。它运用在Spring技术栈的方方面面,如:URL路径匹配、资源目录匹配等等。这里有个有趣的现象:AntPathMatcher是Since:16.07.2003,而其接口PathMatcher是Since:1.2(2005.12)整整晚了2年+才出现。Spring当初的设想是把路径匹配抽象成为一种模式(也就是PathMatcher)而不限定具体实现,但奈何近20年过去了AntPathMatcher仍旧为PathMatcher接口的唯一实现。说明:Spring 5新增了更高效的、设计更好的、全新的路径匹配器PathPattern,但它并未实现PathMatcher接口而是一套全新“生态”,用于逐步替换掉AntPathMatcher。关于此,下篇文章有详尽分析说一千,道一万。了解PathMatcher/AntPathMatcher最为重要的是什么?当然是了解它的匹配规则,做到心里有数。安排,下面我就通过代码示例方式演示其匹配,尽量做到全乎,让你一文在手全部都有。代码演示Path匹配规则要什么记忆,有这些代码示例收藏起来就足够了。在具体代码示例之前,公示所有示例的公有代码如下: private static final PathMatcher MATCHER = new AntPathMatcher(); 使用缺省的 AntPathMatcher实例,并且API的使用全部面向PathMatcher接口而非具体实现类。private static void match(int index, PathMatcher matcher, String pattern, String reqPath) { boolean match = matcher.match(pattern, reqPath); System.out.println(index + "\tmatch结果:" + pattern + "\t" + (match ? "【成功】" : "【失败】") + "\t" + reqPath); } private static void extractUriTemplateVariables(PathMatcher matcher, String pattern, String reqPath) { Map<String, String> variablesMap = matcher.extractUriTemplateVariables(pattern, reqPath); System.out.println("extractUriTemplateVariables结果:" + variablesMap + "\t" + pattern + "\t" + reqPath); } 对PathMatcher最常用的match方法、extractUriTemplateVariables方法做简易封装,主要为了输出日志,方便控制台里对应着查看。?:匹配任意单字符因为是匹配单字符,所以一般“夹杂”在某个path片段内容中间@Test public void test1() { System.out.println("=======测试?:匹配任意单个字符======="); String pattern = "/api/your?atman"; match(MATCHER, pattern, "/api/youratman"); match(MATCHER, pattern, "/api/yourBatman"); match(MATCHER, pattern, "/api/yourBatman/address"); match(MATCHER, pattern, "/api/yourBBBatman"); } =======匹配任意单字符======= 1 match结果:/api/your?atman 【失败】 /api/youratman 2 match结果:/api/your?atman 【成功】 /api/yourBatman 3 match结果:/api/your?atman 【失败】 /api/yourBatman/address 4 match结果:/api/your?atman 【失败】 /api/yourBBBatman 关注点:?表示匹配精确的1个字符,所以0个不行(如结果1)即使?匹配成功,但“多余”部分和pattern并不匹配最终结果也会是false(如结果3,4)*:匹配任意数量的字符因为是匹配任意数量的字符,所以一般使用*来代表URL的一个层级@Test public void test2() { System.out.println("=======*:匹配任意数量的字符======="); String pattern = "/api/*/yourbatman"; match(1, MATCHER, pattern, "/api//yourbatman"); match(2, MATCHER, pattern, "/api/ /yourbatman"); match(2, MATCHER, pattern, "/api/yourbatman"); match(3, MATCHER, pattern, "/api/v1v2v3/yourbatman"); } =======*:匹配任意数量的字符======= 1 match结果:/api/*/yourbatman 【失败】 /api//yourbatman 2 match结果:/api/*/yourbatman 【成功】 /api/ /yourbatman 3 match结果:/api/*/yourbatman 【失败】 /api/yourbatman 4 match结果:/api/*/yourbatman 【成功】 /api/v1v2v3/yourbatman 关注点:路径的//间必须有内容(即使是个空串)才能被*匹配到*只能匹配具体某一层的路径内容**:匹配任意层级的路径/目录匹配任意层级的路径/目录,这对URL这种类型字符串及其友好。@Test public void test3() { System.out.println("=======**:匹配任意层级的路径/目录======="); String pattern = "/api/yourbatman/**"; match(1, MATCHER, pattern, "/api/yourbatman"); match(2, MATCHER, pattern, "/api/yourbatman/"); match(3, MATCHER, pattern, "/api/yourbatman/address"); match(4, MATCHER, pattern, "/api/yourbatman/a/b/c"); } =======**:匹配任意层级的路径/目录======= 1 match结果:/api/yourbatman/** 【成功】 /api/yourbatman 2 match结果:/api/yourbatman/** 【成功】 /api/yourbatman/ 3 match结果:/api/yourbatman/** 【成功】 /api/yourbatman/address 4 match结果:/api/yourbatman/** 【成功】 /api/yourbatman/a/b/c **其实不仅可以放在末尾,还可放在中间。@Test public void test4() { System.out.println("=======**:匹配任意层级的路径/目录======="); String pattern = "/api/**/yourbatman"; match(1, MATCHER, pattern, "/api/yourbatman"); match(2, MATCHER, pattern, "/api//yourbatman"); match(3, MATCHER, pattern, "/api/a/b/c/yourbatman"); } =======**:匹配任意层级的路径/目录======= 1 match结果:/api/**/yourbatman 【成功】 /api/yourbatman 2 match结果:/api/**/yourbatman 【成功】 /api//yourbatman 3 match结果:/api/**/yourbatman 【成功】 /api/a/b/c/yourbatman 关注点:**的匹配“能力”非常的强,几乎可以匹配一切:任意层级、任意层级里的任意“东西”**在AntPathMatcher里即可使用在路径中间,也可用在末尾{pathVariable:正则表达式(可选)}该语法的匹配规则为:将匹配到的path内容赋值给pathVariable。 @Test public void test5() { System.out.println("======={pathVariable:可选的正则表达式}======="); String pattern = "/api/yourbatman/{age}"; match(1, MATCHER, pattern, "/api/yourbatman/10"); match(2, MATCHER, pattern, "/api/yourbatman/Ten"); // 打印提取到的内容 extractUriTemplateVariables(MATCHER, pattern, "/api/yourbatman/10"); extractUriTemplateVariables(MATCHER, pattern, "/api/yourbatman/Ten"); } ======={pathVariable:可选的正则表达式}======= 1 match结果:/api/yourbatman/{age} 【成功】 /api/yourbatman/10 2 match结果:/api/yourbatman/{age} 【成功】 /api/yourbatman/Ten extractUriTemplateVariables结果:{age=10} /api/yourbatman/{age} /api/yourbatman/10 extractUriTemplateVariables结果:{age=Ten} /api/yourbatman/{age} /api/yourbatman/Ten 熟不熟悉?一不小心,这不碰到了Spring-Web中@PathVariable的底层原理(之一)么?可能你能察觉到,age是int类型,不应该匹配到Ten这个值呀。这个时候我们就可以结合正则表达式来做进一步约束啦。@Test public void test6() { System.out.println("======={pathVariable:可选的正则表达式}======="); String pattern = "/api/yourbatman/{age:[0-9]*}"; match(1, MATCHER, pattern, "/api/yourbatman/10"); match(2, MATCHER, pattern, "/api/yourbatman/Ten"); // 打印提取到的内容 extractUriTemplateVariables(MATCHER, pattern, "/api/yourbatman/10"); extractUriTemplateVariables(MATCHER, pattern, "/api/yourbatman/Ten"); } ======={pathVariable:可选的正则表达式}======= 1 match结果:/api/yourbatman/{age:[0-9]*} 【成功】 /api/yourbatman/10 2 match结果:/api/yourbatman/{age:[0-9]*} 【失败】 /api/yourbatman/Ten extractUriTemplateVariables结果:{age=10} /api/yourbatman/{age:[0-9]*} /api/yourbatman/10 java.lang.IllegalStateException: Pattern "/api/yourbatman/{age:[0-9]*}" is not a match for "/api/yourbatman/Ten" 关注点:该匹配方式可以结合正则表达式一起使用对具体值做约束,但正则表示式是可选的只有匹配成功了,才能调用extractUriTemplateVariables(...)方法,否则抛出异常路径匹配注意事项请确保模式和路径都属于同一种类型的路径才有匹配的意义:要么都是绝对路径,要么都是相对路径。当前,强烈建议是绝对路径(以/开头)。在实操中,建议在调用匹配逻辑之前统一对path路径进行“清理”(如Spring提供的StringUtils#cleanPath方法的做法):使得确保其均以/开头,因为这样在其上下文中匹配才是有意义的。其它接口方法对于路径匹配器接口PathMatcher来讲最最最重要的当属match方法。为了雨露均沾,下面对其它几个方法捎带解释以及用代码示例一波。isPattern()方法 @Test public void test7() { System.out.println("=======isPattern方法======="); System.out.println(MATCHER.isPattern("/api/yourbatman")); System.out.println(MATCHER.isPattern("/api/your?atman")); System.out.println(MATCHER.isPattern("/api/*/yourBatman")); System.out.println(MATCHER.isPattern("/api/yourBatman/**")); } false true true true 关注点:只要含有? * ** {xxx}这种特殊字符的字符串都属于模式matchStart()方法它和match方法非常像,区别为:match:要求全路径完全匹配matchStart:模式部分匹配上,然后其它部分(若还有)是空路径即可@Test public void test8() { System.out.println("=======matchStart方法======="); String pattern = "/api/?"; System.out.println("match方法结果:" + MATCHER.match(pattern, "/api/y")); System.out.println("match方法结果:" + MATCHER.match(pattern, "/api//")); System.out.println("match方法结果:" + MATCHER.match(pattern, "/api")); System.out.println("matchStart方法结果:" + MATCHER.matchStart(pattern, "/api//")); System.out.println("matchStart方法结果:" + MATCHER.matchStart(pattern, "/api")); System.out.println("matchStart方法结果:" + MATCHER.matchStart(pattern, "/api///a/")); } =======matchStart方法======= match方法结果:true match方法结果:false match方法结果:false matchStart方法结果:true matchStart方法结果:true matchStart方法结果:false 关注点:请对比结果,看出和match方法的差异性matchStart方法的使用场景少之又少,即使在代码量巨大的Spring体系中,也只有唯一使用处:PathMatchingResourcePatternResolver#doRetrieveMatchingFilesextractPathWithinPattern()方法该方法通过一个实际的模式来确定路径的哪个部分是动态匹配的,换句话讲:该方法用户提取出动态匹配的那部分说明:该方法永远不可能返回null@Test public void test9() { System.out.println("=======extractPathWithinPattern方法======="); String pattern = "/api/*.html"; System.out.println("是否匹配成功:" + MATCHER.match(pattern, "/api/yourbatman/address") + ",提取结果:" + MATCHER.extractPathWithinPattern(pattern, "/api/yourbatman/address")); System.out.println("是否匹配成功:" + MATCHER.match(pattern, "/api/index.html") + ",提取结果:" + MATCHER.extractPathWithinPattern(pattern, "/api/index.html")); } =======extractPathWithinPattern方法======= 是否匹配成功:false,提起结果:yourbatman/address 是否匹配成功:true,提起结果:index.html 关注点:该方法和extractUriTemplateVariables()不一样,即使匹配不成功也能够返回参与匹配的那部分,有种“重在参与”的赶脚下面再看个复杂点pattern情况(pattern里具有多个模式)表现如何:@Test public void test10() { System.out.println("=======extractPathWithinPattern方法======="); String pattern = "/api/**/yourbatman/*.html/temp"; System.out.println("是否匹配成功:" + MATCHER.match(pattern, "/api/yourbatman/address") + ",提取结果:" + MATCHER.extractPathWithinPattern(pattern, "/api/yourbatman/address")); System.out.println("是否匹配成功:" + MATCHER.match(pattern, "/api/yourbatman/index.html/temp") + ",提取结果:" + MATCHER.extractPathWithinPattern(pattern, "/api/yourbatman/index.html/temp")); } =======extractPathWithinPattern方法======= 是否匹配成功:false,提取结果:yourbatman/address 是否匹配成功:true,提取结果:yourbatman/index.html/temp 关注点:该方法会返回所有参与匹配的片段,即使这匹配不成功若有多个模式(如本例中的**和*),返回的片段不会出现跳跃现象(只截掉前面的非pattern匹配部分,中间若出现非pattern匹配部分是不动的)getPatternComparator()方法此方法用于返回一个Comparator<String>比较器,用于对多个path之间进行排序。目的:让更具体的 path出现在最前面,也就是所谓的精确匹配优先原则(也叫最长匹配规则(has more characters))。 @Test public void test11() { System.out.println("=======getPatternComparator方法======="); List<String> patterns = Arrays.asList( "/api/**/index.html", "/api/yourbatman/*.html", "/api/**/*.html", "/api/yourbatman/index.html" ); System.out.println("排序前:" + patterns); Comparator<String> patternComparator = MATCHER.getPatternComparator("/api/yourbatman/index.html"); Collections.sort(patterns, patternComparator); System.out.println("排序后:" + patterns); } =======getPatternComparator方法======= 排序前:[/api/**/index.html, /api/yourbatman/*.html, /api/**/*.html, /api/yourbatman/index.html] 排序后:[/api/yourbatman/index.html, /api/yourbatman/*.html, /api/**/index.html, /api/**/*.html] 关注点:该方法拥有一个入参,作用为:用于判断是否是精确匹配,也就是用于确定精确值的界限的(根据此界限值进行排序)越精确的匹配在越前面。其中路径的匹配原则是从左至右(也就是说左边越早出现精确匹配,分值越高)combine()方法将两个方法“绑定”在一起。PathMatcher接口并未规定绑定的“规则”,完全由底层实现自行决定。如基于Ant风格的匹配器的拼接原则如下: 记得@RequestMapping这个注解吧,它既可以标注在类上,亦可标注在方法上。把Pattern1比作标注在类上的path(若木有标注值就是null嘛),把Pattern2比作标注在方法上的path,它俩的结果不就可以参考上图了麽。一不小心又加深了点对@RequestMapping注解的了解有木有。使用细节AntPathMatcher作为PathMatcher路径匹配器模式的唯一实现,这里有些使用细节可以帮你加深对AntPathMatcher的了解。一些默认值默认决定了AntPathMatcher的一些缺省行为,了解一下: public static final String DEFAULT_PATH_SEPARATOR = "/"; 默认使用/作为路径分隔符。若是请求路径(如http、RPC请求等)或者是Linux的目录名,一切相安无事。但若是Windows的目录地址呢?说明:windows目录分隔符是\,如C:\ProgramData\Microsoft\Windows\Start Menu\Programs\7-Zip private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{[^/]+?}"); 当路径/目录里出现{xxx:正则表达式}这种模式的字符串就被认定为是VARIABLE_PATTERNprivate static final char[] WILDCARD_CHARS = {'*', '?', '{'};不解释原理AntPathMatcher采用前缀树(trie树) 算法对URL进行拆分、匹配。本类代码量还是不少的,整体呈现出比较臃肿的状态,代码行数达到了近1000行:或许可以对它进行关注点的拆分,但这似乎已无必要。因为Spring 5已新增的PathPattern能以更高的运行效率、更优雅的代码设计来替代扎根已久的AntPathMatcher,不知这是否能勾起你对PathPattern兴趣呢?说明:这里对原理实现点到即止,对前缀树(trie树) 感兴趣的同学可专门研究,至少我也只了解个大概即止,这里就不班门弄斧了应用场景AntPathMatcher作为PathMatcher的唯一实现,所以Spring框架的逻辑处理里随处可见: 说明:AntPathMatcher默认使用/作为分隔符。你可根据实际情况在构造时自行指定分隔符(如windows是\,Lunux是/,包名是.)在应用层面,Ant风格的URL、目录地址、包名地址也是随处可见:扫包:@ComponentScan(basePackages = “cn.yourbatman.**.controller”)加载资源:classpath: config/application-*.yamlURL映射:@RequestMapping("/api/v1/user/{id}")…PathMatcher路径匹配器是spring-core核心包的一个基础组件,它带来的能力让Spring框架在路径/目录匹配上极具弹性,使用起来也是非常的方便。再次强调:PathMatcher它属于spring-core,所以not only for web.总结Ant风格虽然概念源自Apache Ant,但是借由Spring“发扬光大”。在整个Spring体系内,涉及到的URL匹配、资源路径匹配、包名匹配均是支持Ant风格的,底层由接口PathMatcher的唯一实现类AntPathMatcher提供实现。AntPathMatcher不仅可以匹配Spring的@RequestMapping路径,也可以用来匹配各种字符串,包括文件资源路径、包名等等。由于它所处的模块是spring-core无其它多余依赖,因此若有需要(比如自己在写框架时)我们也可以把它当做工具来使用,简化开发。
前言你好,我是YourBatman。本系列前两篇文章用文字把跨域、Cors相关概念介绍完了,从下开始进入实战阶段。毕竟学也学了,看也看了,是骡子是马该拉出来遛一遛。本文将实战Cors解决跨域问题中最为重要的响应头:Access-Control-Allow-Origin。它用于服务端告诉浏览器允许共享本资源的Origin,那么如何允许多个域名呢?所属专栏点拨-Cors跨域本文提纲 版本约定JDK:8Servlet:4.xtomcat:9.x正文正如前文所述,响应头Access-Control-Allow-Origin 用于在跨域请求中告诉浏览器服务端允许的Origin,浏览器拿到这个头的值跟自己的Origin对比决定是否正常接收响应。从命名上就有所察觉:Access-Control-Allow-Origin值是单数,否则就会叫Access-Control-Allow-Origins(浏览器)官方对此响应头的可能值有明确规定: 也就说此响应头的取值只可能是上图中的3选1。null值的作用:让data:和file:打开的页面也能够共享跨域资源(因为这种协议下有Origin头,但是值是null,比较特殊)那么问题来了,倘若服务端本资源需要允许多个域来共享,又该如何指定Access-Control-Allow-Origin 的值呢?这是一个开发中常见的场景,本文将继续深入讨论和介绍最佳实践。环境准备因为要构造不同的Origin来发送http://localhost:8080/multiple_origins_cors这个跨域请求,因此需要不同的域名,所以我需要在本机模拟出来。我的实践方案为:用本机Tomcat作为静态页面服务器,托管html页面修改本机host文件,达到支持多域名的目的1. Tomcat托管静态html页面之前我都是用的IDEA内建的静态服务器来托管html页面,但由于它不支持绑定多域名而无法模拟出本例需要的效果,因此我就不得不开辟新的方法喽。做Java开发的小伙伴对Tomcat再熟悉不过,但由于Spring Boot的普及它屏蔽了开发者对Web Server的感知,所以可能虽然天天用但其实鲜有接触,特别是standalone的Tomcat服务器。所以我这里稍微介绍下我的做法(关键步骤)。去到Tomcat的目录,仅需修改它的server.xml文件即可:步骤一:修改端口为9090(因为我Server端服务器也是Tomcat,端口为8080,避免冲突) 步骤二:在host里托管Context上下文,关联到你的html文件夹(Tips:这只是托管的方式之一)说明:docBase表示静态页面所在的文件夹(绝对路径),path表示对应的url访问路径完成后,启动tomcat sh startup.sh后即可通过http://localhost:9090/static/xxx.html访问到静态页面啦。2. 修改Host支持多域名这个就比较简单了,无需多言,粘张图就懂。这样通过如图中的3个域名就都可对页面进行正常访问啦3. 书写前端html页面multiple_origins_cors.html内容如下<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>多Origin响应CORS跨域请求</title> <!--导入Jquery--> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script> </head> <body> <button id="btn">多Origin响应CORS跨域请求</button> <div id="content"></div> <script> $("#btn").click(function () { // 跨域请求 $.get("http://localhost:8080/multiple_origins_cors", function (result) { $("#content").append(result).append("<br/>"); }); }); </script> </body> </html> 4. 书写服务端代码/** * 多Origin响应 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/multiple_origins_cors") public class MultipleOriginsCorsServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); resp.getWriter().write("hello multiple origins cors..."); setCrosHeader(resp); } /** * 写跨域响应头 */ private void setCrosHeader(HttpServletResponse resp) { resp.setHeader("Access-Control-Allow-Origin", "http://localhost:9090"); } } 至此,环境已经准备好。此页面有三个地址/域名可以访问到(不包括localhost),也就是Origin可能有这三种情况:http://foo.baidu.com:9090http://bar.baidu.com:9090http://static.yourbatman.cn:9090Access-Control-Allow-Origin支持多域名现实场景中,服务端资源如若是完全公开的,那么可以使用Access-Control-Allow-Origin: *。但在现实场景中大多数资源并非完全public的,因此需要指定Access-Control-Allow-Origin具体值来达到控制的目的。那么,如何让Access-Control-Allow-Origin支持多域名呢?下面示范一下常见的错误方式,最后给出最佳实践。要实现Access-Control-Allow-Origin允许多个域名共享资源,按照“常规思维”,有好些个使用误区,这里我尝试罗列出来。误区一:Access-Control-Allow-Origin值使用,分隔,分隔在程序员的世界很常见,很多时候可表示多值。那在这里是否好使呢?试一试 private void setCrosHeader(HttpServletResponse resp) { resp.setHeader("Access-Control-Allow-Origin", "http://foo.baidu.com:9090,http://bar.baidu.com:9090"); } 点击按钮,发送跨域请求,失败详情可以看到不仅没实现多值,连foo.baidu.com:9090这个域名都不能访问啦~误区二:写多个Access-Control-Allow-Origin响应头这种方式也是“正常思维”之一。试一下:private void setCrosHeader(HttpServletResponse resp) { resp.addHeader("Access-Control-Allow-Origin", "http://foo.baidu.com:9090"); resp.addHeader("Access-Control-Allow-Origin", "http://bar.baidu.com:9090"); } 小细节:这里将setHeader改用为addHeader(xxx)了哟,你懂的点击按钮,发送跨域请求,失败详情:多说一句:在实际开发中这种出现两个Access-Control-Allow-Origin响应头的case还是比较常见的。根据经验一般原因是:Web Server设置了一个头,而Nginx(或者Gateway网关)又添加了一个头(一般值为*)。强调:浏览器只要收到两个Access-Control-Allow-Origin响应头,不论值是什么(即使一模一样),都不会接受。误区三:Access-Control-Allow-Origin值使用正则当需要允许的多域名符合某个规律时,会想到使用简单的正则去匹配,那么是否支持呢?试一下: private void setCrosHeader(HttpServletResponse resp) { resp.addHeader("Access-Control-Allow-Origin", "http://*.baidu.com:9090"); } 点击按钮,发送跨域请求,失败详情:强调:浏览器拿Access-Control-Allow-Origin的值和Origin进行匹配的规则是完全匹配,通配符只认*。误区四:Access-Control-Allow-Origin值使用*通配符这是一个特殊的使用“误区”:它能正常work,但并不能“很好的work”。试一下 private void setCrosHeader(HttpServletResponse resp) { resp.addHeader("Access-Control-Allow-Origin", "*"); } 点击按钮,发送跨域请求,正常响应:既然能够正常响应完成跨域请求,为何我会认为这么处理属于误区呢?其原因主要为:使用*通配符属于暴力配置,表示任意源都可以访问此资源,对大部分场景来讲这违背了安全原则,存在安全漏洞,所以实际生产中并不建议这么做(除非是public资源)。使用*通配符的漏洞为何对使用*乐此不疲?答:因为简单,似乎能够解决“所有”跨域问题,且能一劳永逸。正所谓天下哪有那么多岁月静好,黑客们在那蠢蠢欲动。在与浏览器“沟通”过程中,不恰当的使用Cors会造成一些可能的漏洞,比如最常见的便是当允许多个域名跨域请求时,很多同学为了方便就将Access-Control-Allow-Origin写为*,或者在Ng上直接赋值为$http_origin(效果完全同*)。这种暴力配置是很危险的,相当于任意网站都可以直接访问你的资源,那就失去跨域限制的意义了。这么配置的话,在最基本的渗透测试中都是过不去的。如若你这么做且公司有安全部门,没过多久应该就会有人找你聊天喝茶了。别问我为什么会知道,因为我就曾被安全部门同事招呼过😄最佳实践来了,期待的最佳实践它来了。允许多域名跨域是如此常见的场景,本文当然要给出最佳实践(供以参考)。既然浏览器是精确的完整匹配这个规则我们无法修改,那只有唯一的一个办法:在服务端给Access-Control-Allow-Origin赋值之前做逻辑:若允许跨域,将请求的Origin赋值给它若不允许跨域,不返回此头(或者给赋值一个默认值也是可以的)有了理论支撑,用代码实现乃分分钟之事: private List<String> ALLOW_ORIGINS = new ArrayList<>(); @Override public void init() throws ServletException { ALLOW_ORIGINS.add("http://localhost:9090"); ALLOW_ORIGINS.add("http://foo.baidu.com:9090"); ALLOW_ORIGINS.add("http://bar.baidu.com:9090"); ALLOW_ORIGINS.add("http://static.yourbatman.cn:9090"); } private void setCrosHeader(String reqOrigin, HttpServletResponse resp) { if (reqOrigin == null) { return; } // 匹配算法:equals if (ALLOW_ORIGINS.contains(reqOrigin)) { resp.addHeader("Access-Control-Allow-Origin", reqOrigin); } } 如果是Ng,可以这么写(简单举例而已):location / { // 枚举列出允许跨域的domian(可以使用NG支持的匹配方式) set $cors_origin ""; if ($http_origin ~* "^http://foo.baidu.com$") { set $cors_origin $http_origin; } if ($http_origin ~* "^http://bar.baidu.com$") { set $cors_origin $http_origin; } add_header Access-Control-Allow-Origin $cors_origin; } 既然接管了Access-Control-Allow-Origin赋值逻辑。脑洞更大一点,这可极具个性化和扩展性:ALLOW_ORIGINS:不需要再hard code,可以支持外部化配置,甚至打通配置中心匹配算法:可以支持完全匹配、前缀匹配、正则匹配,设置更复杂的匹配逻辑都可…说了这么多,这些个性化扩展性都需要代码去实现,那到底有没有现成可用的最佳实践代码呢?当然,有!!!作为Java开发者yyds:Spring框架。怎能没考虑到这么常见的Cors跨域场景呢?它提供的org.springframework.web.filter.CorsFilter就是真实可用的最佳实践,可以拿来就用或者作为参考和学习。说明:关于Spring/Spring Boot场景下对Cors跨域问题的解决方案以及原理分析,本系列已安排在下下篇详细剖析补充:Vary: Origin解决缓存问题在文章最后想补充一个“小知识点”:有关于浏览器缓存和Vary的问题。关于Vary,平时比较细心的同学应该会比较有印象。Vary中文含义:变化。它是一个HTTP响应头,决定了对于下一个请求,应该使用缓存还是向源服务器请求一个新的Response,和内容协商(你知道的,内容协商也属于我的一个技术专栏)有关。现在的浏览器都支持这个响应头~标准语法是: Vary: * // 告诉浏览器,所有的响应头都是变得所以都不缓存 Vary: <header-name>, <header-name>, ... // 告诉浏览器,有些头都是变的就不要缓存了 说了这么多,它和本文有何关系呢?由于这和浏览器缓存(cache-control)背景知识强关联,并非本文重点无需详细展开。因此这里只是提示你:如若出现同一份URL(相同的Referer),不同的Origin(如foo.baidu.com和bar.baidu.com)请求时一个能行一个不能行,那很有可能就是浏览器缓存导致,这时就可以增加一个响应头Vary: Origin来解决。说明:这里假设服务端对Access-Control-Allow-Origin的赋值逻辑一切正常,也就是说服务端没有问题总结本文围绕Access-Control-Allow-Origin这个响应头,从几大误区到最佳实践,希望能够帮助你加深对它的理解。当然最重要的是:尽量不要一碰到Access-Control-Allow-Origin就只会赋值*啦,多些思考多些安全性考虑,毕竟安全部门的茶水最好还是不要喝。本文思考题本文已被https://yourbatman.cn收录。公号后台回复专栏列表即可进入专栏详情。看完了不一定懂,看懂了不一定会。来,3个思考题帮你复盘:Access-Control-Allow-Origin可以设置多个头吗?如何让多个域名都可以访问到本地的Html文件?在Spring Framework场景下,解决跨域问题的最佳方案是什么?
代码模拟跨域Cookie共享前端页面:发送跨域请求,为了方便模拟这里发送跨域的简单请求即可(还不知道什么叫简单请求?戳这里)<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Cookie交互机制(跨域)</title> <!--导入Jquery--> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script> </head> <body> <button id="btn">Cookie交互机制(跨域)</button> <div id="content"></div> <script> $("#btn").click(function () { $.get("http://localhost:8080/corscookie"); }); </script> </body> </html> 前端页面托管在本地的63342端口上:http://localhost:63342/...后端代码:后端接口托管在8080端口上:http://localhost:8080/...这就是最简单的一个跨域场景,两个域具有相同的domain,因此才有共享Cookie的可能。 /** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/corscookie") public class CorsCookieServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); // 读取Cookie List<Cookie> myCookies = new ArrayList<>(); if (req.getCookies() != null) { myCookies = Arrays.stream(req.getCookies()).filter(c -> c.getName().equals("name") || c.getName().equals("age")).collect(toList()); } if (myCookies.isEmpty()) { // 种植Cookie Cookie cookie = new Cookie("name", "YourBatman"); // cookie.setDomain("baidu.com"); cookie.setMaxAge(3600); resp.addCookie(cookie); cookie = new Cookie("age", "18"); cookie.setMaxAge(3600); resp.addCookie(cookie); } else { myCookies.stream().forEach(c -> { log.info("name:{} value:{} domain:{} path:{} maxAge:{} secure:{}", c.getName(), c.getValue(), c.getDomain(), c.getPath(), c.getMaxAge(), c.getVersion(), c.getSecure()); }); } setCrosHeader(resp); resp.getWriter().write("hello cookie..."); } private void setCrosHeader(HttpServletResponse resp) { resp.setHeader("Access-Control-Allow-Origin", "http://localhost:63342"); } } 点击按钮,发送请求:注意看,服务端代码虽然resp.addCookie(cookie);添加了Cookie,但是Response响应里并没有Set-Cookie这个头哦。查看浏览器发现木有Cookie:也许你会说,当然没有啦,因为Response里没有Set-Cookie头嘛,但我们代码里明明已经addCookie了呀。这半截理论当然没问题,现在我在服务端程序里补充一个响应头: private void setCrosHeader(HttpServletResponse resp) { resp.setHeader("Access-Control-Allow-Origin", "http://localhost:63342"); resp.setHeader("Access-Control-Allow-Credentials", "true"); } 重启服务端应用),再次发送请求,响应如下:可以看到响应中已经有Set-Cookie响应头了,再次查看Cookie是否已被浏览器保存,同样的比比脸还干净:浏览器没有存储Cookie。What?难道翻车了?No,下面教你如何解释以及怎么破?跨域Cookie共享的关键点这里要讨论的是跨域中Cookie的存储问题:默认情况下,浏览器是不会去为你保存下跨域请求响应的Cookie的。具体现象是:跨域请求的Response响应了即使有Set-Cookie响应头(且有值),浏览器收到后也是不会保存此cookie的。要实现Cookie的跨域共享,有3个关键点:服务端负责在响应中将Set-Cookie发出来(由Access-Control-Allow-Credentials响应头决定)浏览器端只要响应里有Set-Cookie头,就将此Cookie存储(由异步对象的withCredentials属性决定)浏览器端发现只要有Cookie,即使是跨域请求也将其带着(由异步对象的withCredentials属性决定)为了满足这三个关键点,在实施层面就有三要素来指导我们开发来解决此类问题。跨域Cookie共享的三要素首先确保服务端能正确的在响应中有Set-Cookie响应头,这由Access-Control-Allow-Credentials: true来保证。因此服务端只需要做多加这一步即可: resp.setHeader("Access-Control-Allow-Credentials", "true"); Access-Control-Allow-Credentials该头是可选的,是个bool值,它若为true就有两个作用:在跨域请求的响应中允许Set-Cookie响应头浏览器收到响应后,浏览器根据此头判断是否让自己的withCredentials属性生效所以就来到了第二个要素:XMLHttpRequest对象的withCredentials属性。该属性是一个Boolean类型,它指示了是否该使用类似cookies,authorization headers(头部授权)或者TLS客户端证书这一类资格证书来创建一个跨站点访问控制(cross-site Access-Control)请求。 var xhr = new XMLHttpRequest(); ... xhr.withCredentials = true; Jquery的Ajax写法与此不同,但底层原理一样官方的语言理解起来总是那么晦涩,翻译成人话:当异步对象设置了withCredentials=true时,浏览器会保留下响应的Cookie等信息,并且下次发送请求时将其携带。因此要指示浏览器存储Cookie并且每次跨域请求都携带,仅需加上此参数即可: $.ajax({ url: "http://localhost:8080/corscookie", type: "GET", xhrFields: { withCredentials: true }, crossDomain: true }); 以上两个要素完成后,影响“结果”的还有最后一个要素。这个要素比较隐晦,也是很多同学/文章忽略的点。服务端的Access-Control-Allow-Origin这个响应头的值不能是通配符*,而只能是具体的值。否则出现报错:换句话讲:浏览器端跨域请求对象一旦开启withCredentials=true属性,服务端跨域Origin将不能再用*通配符,否则CORS error!三要素都满足后(Access-Control-Allow-Credentials:true;Access-Control-Allow-Origin:http://localhost:63342;withCredentials=true),再次点击发送请求,结果如下: 完美。总结上篇文章对Cors进行了全面介绍,本文以跨域Cookie共享为场景,很好的对跨域知识点进行了补充,并且也补足了Cors里一个重要的响应头Access-Control-Allow-Credentials的解释,相信通过本文同学你能加深对Web中Cookie的了解,以及跨域情况下Cookie信息如何共享。本系列下篇将着眼于跨域请求解决方案的阐述,欢迎关注。本文思考题本文已被https://yourbatman.cn收录。所属专栏:点拨-Cors跨域,后台回复“专栏列表”即可查看详情。看完了不一定懂,看懂了不一定会。来,3个思考题帮你复盘:Access-Control-Allow-Origin值设置为通配符*是万金油吗?如何通过Cookie技术实现SSO单点登录?实现跨域Cookie共享的三要素是什么?
前言你好,我是YourBatman。上篇文章(Cors跨域(一):深入理解跨域请求概念及其根因)用超万字的篇幅把Cors几乎所有概念都扫盲了,接下来将逐步提出解决方案等实战性问题以及查漏补缺。本文主角是大家耳熟能详的Cookie,聊聊它在跨域情况下如何实现“共享”?大家都知道Cookie是需要遵守同源策略(SameSite)的,本文将以跨域Cookie信息共享为场景,进一步加深对Cors的了解。本文提纲 版本约定JDK:8Servlet:4.xTomcat:9.x正文Cookie是做web开发绕不过去的一个概念,即使随着JWT技术的出现它早已褪色不少,但依旧有其发光发热之地。譬如一些内网后台管理系统、Portal门户、SSO统一登录等场景…如若你是新时代的程序员朋友,可能从未使用过Cookie,但肯定听过它的“传说”。作为本文的主角,那么我们就来先认识下这位“老朋友”吧。重识Cookie Cookie中文名:曲奇饼干。当然,我们在与他人沟通时可不要使用中文名,还是使用Cookie本名吧~什么是Cookie一个看似简单,实则不好回答的一个问题。众所周知,Http是无状态协议(Tips:不要问我什么叫无状态哈),每次请求都是对等的(从0开始的),服务器不知道用户上一次做了什么,这严重阻碍了 交互式 Web应用程序的实现。有些场景服务端需要知道用户的访问状态(如登录状态),这个时候怎么办?针对这种场景其实很容想到解决办法:你来访问我服务端的时候,我给你一个“东西”,然后下次你再访问我(注意是访问我才携带哦)的时候把它带过来我就知道是你啦,简单交互图如下: 这里交互中所指的“东西”,在Web领域它就是Cookie。Cookie就是用来绕开HTTP的无状态性的手段,它是Web的标准技术(是web标准而不局限于只是Servlet),隶属于RFC6265,现今的所有的浏览器、服务器均实现了此规范。用一个20年前就用的比喻再补充解释下:你去银行卡里存钱,第一次去银行银行会给你办一张银行卡(里面存放着你的姓名、身份证、余额等信息)。下次你再去银行的时候,只需带着这张银行卡银行就可以“识别”你,从而就可以存/取钱了。这里的银行卡就类同于Http请求里的Cookie概念。基于此银行(卡)的比喻举一反三,类比解释同域Cookie、不同域Cookie、跨域Cookie共享的含义:同域Cookie:每次访问的是同一个域下的不同页面、API(每次去的是同一家银行的不同网点,带上这家银行卡即可识别身份)不同域Cookie:同一个浏览器窗口内可能同时访问A网站和B网站,它们均有各自的Cookie,但访问A时只会带上A的Cookie(你可能有不同银行的多张银行卡,而去某个银行时只有带着他们家的银行卡才去有用嘛)跨域Cookie共享:访问A站点时已经登录从而保存姓名、头像等基本信息,这时访问该公司的B站点时就自然而然的能显示出这些基本信息,也就是实现信息共享(在银联体系中A银行办理的卡也能在B银行能取出钱来,也就是实现余额“共享”)说明:Cookie实现跨域共享要求根域必须是一样才行,比如都是www.baidu.com和map.baidu.com的根域都是 baidu.com。这道理就相当于只有加入了银联的银行才能用银行卡去任意一家银联成员行取钱一样Cookie的交互机制下面这张图完整的说明了Cookie的交互机制,共四个步骤: 浏览器(客户端)发送一个请求到服务器服务器响应。并在HttpResponse里增加一个响应头:Set-Cookie浏览器保存此cookie在本地,然后以后每次请求都带着它,且请求头为:Cookie服务器收到请求便可读取到此Cookie,做相应逻辑后给出响应由此可见,Cookie用于保持请求状态,而这个状态依赖于浏览器端(客户端)的本地存储。代码示例概念聊了有一会了,写几句代码放松放松。下面演示一下这个交互过程:服务端代码:首次请求种植Cookie,以后(请求携带了)就只打印输出Cookie内容 /** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/cookie") public class CookieServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); // 读取Cookie List<Cookie> myCookies = new ArrayList<>(); if (req.getCookies() != null) { myCookies = Arrays.stream(req.getCookies()).filter(c -> c.getName().equals("name") || c.getName().equals("age")).collect(toList()); } if (myCookies.isEmpty()) { // 种植Cookie Cookie cookie = new Cookie("name", "YourBatman"); // cookie.setDomain("baidu.com"); cookie.setMaxAge(3600); resp.addCookie(cookie); cookie = new Cookie("age", "18"); cookie.setMaxAge(3600); resp.addCookie(cookie); } else { myCookies.stream().forEach(c -> { log.info("name:{} value:{} domain:{} path:{} maxAge:{} secure:{}", c.getName(), c.getValue(), c.getDomain(), c.getPath(), c.getMaxAge(), c.getVersion(), c.getSecure()); }); } resp.getWriter().write("hello cookie..."); } } 浏览器访问:http://localhost:8080/cookie,可以看到响应里带有Cookie头信息Set-Cookie告知浏览器要保存此Cookie,如下所示:浏览器收到响应,并且依照Set-Cookie这个响应头,在本地存储上此Cookie(至于存在内存还是硬盘上,请参照文下的生命周期部分分解):说明:除了name和age之外的cookie键值对不用关心,由于使用IDEA作为服务器交互的缘故才产生了它们再次发送本请求,它会将此域的Cookie全都都携带发给后端服务器,如下图所示:服务端打印输出:可以看到服务端收到浏览器发送过来的Cookie了INFO c.y.cors.java.servlet.CookieServlet - 收到请求:/cookie,方法:GET, Origin头:null INFO c.y.cors.java.servlet.CookieServlet - name:name value:YourBatman domain:null path:null maxAge:-1 secure:0 INFO c.y.cors.java.servlet.CookieServlet - name:age value:18 domain:null path:null maxAge:-1 secure:0 这就是Cookie一次完整的交互过程。这里有个细节需要特别注意:name和age的maxAge属性值均为-1,表示这套cookie是会话级别的。也就是说你若换一个会话(如:重新打开浏览器的一个无痕窗口(不是标签页)),发送一个同样的请求http://localhost:8080/cookie,请求头里将看不到Cookie的任何踪影,服务端会给其生成一套新Cookie。如下图所示: Cookie的生命周期缺省情况下,Cookie的生命周期是Session级别(会话级别)。若想用Cookie进行状态保存、资源共享,服务端一般都会给其设置一个过期时间maxAge,短则1小时、1天,长则1星期、1个月甚至永久,这就是Cookie的生命(周期)。Cookie的存储形式,根据其生命周期的不同而不同。这由maxAge属性决定,共有这三种情况:maxAge > 0:cookie不仅内存里有,还会持久化到硬盘,也叫持久Cookie。这样的话即使你关机重启(甚至过几天再访问),这个cookie依旧存在,请求时依旧会携带maxAge < 0:一般值为-1,也就临时Cookie。该Cookie只在内存中有(如session级别),一旦管理浏览器此Cookie将不复存在。值得注意的是:若使用无痕模式访问也是不会携带此Cookie的哟maxAge = 0:内存中没有,硬盘中也没有了,也就立即删除Cookie。此种case存在的唯一目的:服务浏览器可能的已存在的cookie,让其立马失效(消失)Tips:请注意maxAge<0(负数)和maxAge=0的区别。前者会存在于内存,只有关闭浏览器or重启才失效;后者是立即删除当然啦,Cookie的生命周期除了受到后端设置的Age值来决定外,还有两种方式可“改变”它:JavaScript操作Cookie // 取cookie: function getCookie(name) { var arr = document.cookie.split(';'); for (var i = 0; i < arr.length; i++) { var arr2 = arr[i].split('='); var arrTest = arr2[0].trim(); // 此处的trim一定要加 if (arrTest == name) { return arr2[1]; } } } // 删cookie: function delCookie(name) { var exp = new Date(); exp.setTime(exp.getTime() - 1); var cval = getCookie(name); if (cval != null) { document.cookie = name + "=" + cval + ";expires=" + exp.toGMTString(); } } 浏览器的开发者工具操作CookieCookie的安全性和劣势Cookie存储在客户端,正所谓客户端的所有东西都认为不是安全的,因此敏感的数据(比如密码)尽量不要放在Cookie里。Cookie能提高访问服务端的效率,但是安全性较差!Cookie虽然有不少优点,但它也有如下明显劣势:每次请求都会携带Cookie,这无形中增加了流量开销,这在移动端对流量敏感的场景下是不够友好的Http请求中Cookie均为明文传输,所以安全性成问题(除非用Https)Cookie有大小限制,一般最大为4kb,对于复杂的需求来讲就捉襟见肘由于Cookie有不安全性和众多劣势,所以现在JWT大行其道。当然喽,很多时候Cookie依旧是最好用的,比如内网的管理端、Portal门户、UUAP统一登录等。 Cookie的域和路径Cookie是不可以跨域的,隐私安全机制禁止网站非法获取其他网站(域)的Cookie。概念上咱不用长篇大论,举个例子你应该就懂了:淘宝有两个页面:A页面a.taotao.com/index.html和B页面b.taotao.com/index.html,默认情况下A页面和B页面的Cookie是互相独立不能共享的。若现在有需要共享(如单点登录共享token ),我们只需要这么做:将A/B页面创建的Cookie的path设置为“/”,domain设置为“.taobtao.com”,那么位于a.taotao.com和b.taotao.com域下的所有页面都可以访问到这个Cookie了。domain:创建此cookie的服务器主机名(or域名),服务端设置。但是不能将其设置为服务器所属域之外的域(若这都允许的话,你把Cookie的域都设置为baidu.com,那百度每次请求岂不要“累死”)注:端口和域无关,也就是说Cookie的域是不包括端口的path:域下的哪些目录可以访问此cookie,默认为/,表示所有目录均可访问此cookie 跨域Cookie共享三个关键词:跨域、Cookie、共享。Cookie是数据载体,跨域是场景,共享是需求。
什么是Cors跨域Cors(Cross-origin resource sharing):跨域资源共享,它是浏览器的一个技术规范,由W3C规定,规范的wiki地址在此:https://www.w3.org/wiki/CORS_Enabled#What_is_CORS_about.3F话外音:它是浏览器的一种(自我保护)行为,并且已形成规范。也就是说:backend请求backend是不存在此现象的喽若想实现Cors机制的跨域请求,是需要浏览器和服务器同时支持的。关于浏览器对CORS的支持情况:现在都2021年了,so可以认为100%的浏览器都是支持的,再加上CORS的整个过程都由浏览器自动完成,前端无需做任何设置,所以前端工程师的ajax原来怎么用现在还是怎么用,它对前段开发人员是完全透明的。为何需要Cors跨域访问?浏览器费尽心思的搞个同源策略来保护我们的安全,但为何又需要跨域来打破这种安全策略呢?其实啊,这一切都和互联网的快速发展有关~随着Web开放的程度越来越高,页面的内容也是越来越丰富。因此页面上出现的元素也就越来越多:图片、视频、各种文字内容等。为了分而治之,一个页面的内容可能来自不同地方,也就是不同的domain域,因此通过API跨域访问成了必然。浏览器作为进入Internet最大的入口,很长时间它是个大互联公司的必争之地,因此市面上并存的浏览器种类繁多且鱼龙混扎:IE 7、8、9、10,Chrome、Safari、火狐,每个浏览器对跨域的实现可能都不一样。因此对开发者而言亟待需要一个规范的、统一方案,它就是Cors。CORS(Cross-Origin Resource Sharing)由W3C组织于2009-03-17编写工作草案,直到2014-01-16才正式毕业成为行业规范,所有浏览器得以遵守。至此,程序员同学们在解决跨域问题上,只需按照Cors规范实施即可。Cors的工作原理Web资源涉及到两个角色:浏览器(消费者)和服务器(提供者),面向这两个角色来了解Cors的原理非常简单,如下图所示: 1.若浏览器发送的是个跨域请求,http请求中就会携带一个名为Origin的头表明自己的“位置”,如Origin: http://localhost:54322.服务端接到请求后,就可以根据传过来的Origin头做逻辑,决定是否要将资源共享给这个源喽。而这个决定通过响应头Access-Control-Allow-Origin来承载,它的value值可以是任意值,有如下情况: 无此头:不共享给此origin 有此头:值有如下可能情况值为*,通配符,允许所有的Origin共享此资源值为http://localhost:5432(也就是和Origin相同),共享给此Origin值为非http://localhost:5432(也就是和Origin不相同),不共享给此Origin3.浏览器接收到Response响应后,会去提取Access-Control-Allow-Origin这个头。然后根据上述规则来决定要接收此响应内容还是拒绝Tips:Access-Control-Allow-Origin响应头只能有1个,且value值就是个字符串。另外,value值即使写为http://aa.com,http://bb.com这种也属于一个而非两个值Cors细粒度控制:授权响应头在Cors规范中,除了可以通过Access-Control-Allow-Origin响应头来对主体资源(URL级别)进行授权外,还提供了针对于具体响应头更细粒度的控制,这个响应头就是:Access-Control-Expose-Headers。换句话讲,该头用于规定哪些响应头(们)可以暴露给前端,默认情况下这6个响应头无需特别的显示指定就支持:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma若不在此值里面的头将不会返回给前端(其实返回了,只是浏览器让其对前端不可见了而已,对JavaScript也不可见哦)。但是,但是,但是,这种细粒度控制header的机制对简单请求是无效的,只针对于非简单请求(也叫复杂请求)。由此可见,将哪些类型的跨域资源请求划分为简单请求的范畴就显得特备重要了。何为简单请求Cors规范定义简单请求的原则是:请求不是以更新(添加、修改和删除)资源为目的,服务端对请求的处理不会导致自身维护资源的改变。对于简单跨域资源请求来说,浏览器将两个步骤(取得授权和获取资源)合二为一,由于不涉及到资源的改变,所以不会带来任何副作用。对于一个请求,必须同时符合如下要求才被划为简单请求:1.Http Method只能为其一:GETPOSTHEAD2.请求头只能在如下范围: 1.Accept 2.Accept-Language 3.Content-Language 4.Content-Type,其中它的值必须如下其一:application/x-www-form-urlencodedmultipart/form-datatext/plain除此之外的请求都为非简单请求(也可称为复杂请求)。非简单请求可能对服务端资源改变,因此Cors规定浏览器在发出此类请求之前必须有一个“预检(Preflight)”机制,这也就是我们熟悉的OPTIONS请求。什么是Preflight预检机制顾名思义,它表示在浏览器发出真正请求之前,先发送一个预检请求,这个在Http里就是OPTIONS请求方式。这个请求很特殊,它不包含主体(无请求参数、请求体等),主要就是将一些凭证、授权相关的辅助信息放在请求头里交给服务器去做决策。因此它除了携带Origin请求头外,还会额外携带如下两个请求头:Access-Control-Request-Method:真正请求的方法Access-Control-Request-Headers:真正请求的自定义请求头(若没有自定义的就是空呗)服务端在接收到此类请求后,就可以根据其值做逻辑决策啦。如果允许预检请求通过,返回个200即可,否则返回400或者403呗。如果预检成功,在响应里应该包含上文提到的响应头Access-Control-Allow-Origin和Access-Control-Expose-Headers,除此之外,服务端还可以做更精细化的控制,这些精细化控制的响应头为:Access-Control-Allow-Methods:允许实际请求的Http方法(们)Access-Control-Allow-Headers:允许实际请求的请求头(们)Access-Control-Max-Age:允许浏览器缓存此结果多久,单位:秒。有了缓存,以后就不用每次请求都发送预检请求啦说明:以上响应头并不是必须的。若没有此响应头,代表接受所有 预检请求完成后,有个关键点,便是浏览器拿到预检请求的响应后的处理逻辑,这里描述如下:先通过自己的Origin匹配预检响应中的Access-Control-Allow-Origin的值,若不匹配就结束请求,若匹配就继续下一步验证关于Access-Control-Allow-Origin的验证逻辑,请参考文上描述拿到预检响应中的Access-Control-Allow-Methods头。若此头不存在,则进行下一步,若存在则校验预检请求头Access-Control-Request-Method的值是否在此列表中,在其内继续下一步,否则失败拿到预检响应中的Access-Control-Request-Headers头。同请求头中的Access-Control-Allow-Headers值记性比较,全部包含在内则匹配成功,否则失败以上全部匹配成功,就代表预检成功,可以开始发送正式请求了。值得一提的事,Access-Control-Max-Age控制预检结果的浏览器缓存,若缓存还生效的话,是不用单独再发送OPTIONS请求的,匹配成功直接发送目标真实即可。Access-Control-Max-Age使用细节Access-Control-Max-Age用于控制浏览器缓存预检请求结果的时间,这里存在一些使用细节你需要注意:1.若浏览器禁用了缓存,也就是勾选了Disable cache,那么此属性无效。也就说每次都还得发送OPTIONS请求2.判断此缓存结果的因素有两个:必须是同一URL(也就是Origin相同才会去找对应的缓存)header变化了,也会重新去发OPTIONS请求(当然若去掉一些header编程简单请求了,就另当别论喽)跨域请求代码示例正所谓说再多,也抵不上跑几个case,毕竟show me your code才是最重要。 下面就针对跨域情况的简单请求、非简单请求(预检通过、预检不通过)等case分别用代码(基于文首代码)说明。简单请求简单请求正如其名,是最简单的请求方式。 // 跨域请求 $.get("http://localhost:8080/cors", function (result) { $("#content").append(result).append("<br/>"); }); 服务端结果:INFO ...CorsServlet - 收到请求:/cors,方法:GET, Origin头:http://localhost:63342浏览器结果:若想让请求正常,只需在服务端响应头里“加点料”就成:... resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342"); resp.getWriter().write("hello cors..."); ... 再次请求,结果成功:对于简单请求来讲,服务端只需要设置Access-Control-Allow-Origin这个一个头即可,一个即可。非简单请求非简单请求的模拟非常简单,随便打破一个简单请求的约束即可。比如我们先在上面get请求的基础上自定义个请求头:$.ajax({ type: "get", url: "http://localhost:8080/cors", headers: {secret:"kkjtjnbgjlfrfgv",token: "abc123"} }); 服务端代码:/** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/cors") public class CorsServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342"); resp.setHeader("Access-Control-Expose-Headers","token,secret"); resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同) resp.getWriter().write("hello cors..."); } } 点击按钮,浏览器发送请求,结果为:服务端没有任何日志输出,也就是说浏览器并未把实际请求发出去。什么原因?查看OPTIONS请求的返回一看便知:根本原因为:OPTIONS的响应头里并未含有任何跨域相关信息,虽然预检通过(注意:这个预检是通过的哟,预检不通过的场景就不用额外演示了吧~),但预检的结果经浏览器判断此跨域实际请求不能发出,所以给拦下来了。从代码层面问题就出现在resp.setHeader(xxx,xxx)放在了处理实际方法的Get方法上,显然不对嘛,应该放在doOptions()方法里才行: @Override protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { super.doOptions(req, resp); resp.setHeader("Access-Control-Allow-Origin","http://localhost:63342"); resp.setHeader("Access-Control-Expose-Headers","token,secret"); resp.setHeader("Access-Control-Allow-Headers","token,secret"); // 一般来讲,让此头的值是上面那个的【子集】(或相同) } 在此运行,一切正常:值得特别注意的是:设置跨域的响应头这块代码,在处理真实请求的doGet里也必须得有,否则服务端处理了,浏览器“不认”也是会出跨域错误的。另外就是,Access-Control-Allow-Headers/Access-Control-Expose-Headers这两个头里必须包含你的请求的自定义的Header(标准的header不需要包含),否则依旧跨域失败哦~在实际生产场景中,Http请求的Content-type大都是application/json并非简单请求的头,所以有个现实情况是:实际的跨域请求中,几乎100%的情况下我们发的都是非简单请求。Cros跨域使用展望如上代码示例,处理简单请求尚且简单,但对于非简单请求来说,我们在doOptions和doGet都写了一段setHeader的代码,是否觉得麻烦呢?另外,对于Access-Control-Allow-Origin若我需要允许多个源怎么办呢?Tips:Access-Control-Allow-Origin头只允许一个,且Access-Control-Allow-Origin:a.com,b.com依旧算作一个源的,它没有逗号分隔的“特性”。从命名的艺术你也可看出,它并非是xxx-Origins而是xxx-Origin既然实际场景中几乎100%都是非简单请求,那么对于控制非简单请求的Access-Control-Allow-Methods、Access-Control-Allow-Headers、Access-Control-Max-Age这些都都改如何赋值?是否有最佳实践?现在我们大都在Spring Framework/Spring Boot场景下开发应用,框架层面是否提供一些优雅的解决方案?作为一名后端开发工程师(编程语言不限),也许你从未处理过跨域问题,那么到底是谁默默的帮你解决了这一切呢?是否想知其所以然?如果这些问题也是你在使用过程中的疑问,或者希望了解的知识点,那么请关注专栏吧。总结本文用很长的篇幅介绍了Cors跨域资源共享的相关知识,并且用代码做了示范,希望能助你通关Cors这个狗皮膏药一样粘着我们的硬核知识点。本文文字叙述较多,介绍了同源、跨域、Cors的几乎所有概念,虽然略显难啃,但这些是指导我们实践的说明书。革命尚未统一,带着👆🏻给到的问题,一起开启通过Cors跨域之旅吧~本文思考题本文已被https://yourbatman.cn收录。所属专栏:点拨-Cors跨域,后台回复“专栏列表”即可查看详情。看完了不一定懂,看懂了不一定会。来,3个思考题帮你复盘:试想一下,如果浏览器没有同源策略,将有多大的风险?Cors共涉及到哪些请求头?哪些响应头?你所知道的解决Cors跨域问题最佳实践是什么?
前言你好,我是YourBatman。做Web开发的小伙伴对“跨域”定并不陌生,像狗皮膏药一样粘着几乎每位同学,对它可谓既爱又恨。跨域请求之于创业、小型公司来讲是个头疼的问题,因为这类企业还未沉淀出一套行之有效的、统一的解决方案。让人担忧的是,据我了解不少程序员同学(不乏有高级开发)碰到跨域问题大都一头雾水: 然后很自然的 用谷歌去百度一下搜索答案,但相关文章可能参差不齐、鱼龙混杂。短则半天长则一天(包含改代码、部署等流程)此问题才得以解决,一个“小小跨域”问题成功偷走你的宝贵时间。既然跨域是个如此常见(特别是当下前后端分离的开发模式),因此深入理解CORS变得就异常的重要了(反倒前端工程师不用太了解),因此早在2019年我刚开始写博客那会就有过较为详细的系列文章: 本文提纲版本约定JDK:8Servlet:4.x正文文章遵循一贯的风格,本文将采用概念 + 代码示例的方式,层层递进的进行展开叙述。那么上菜,先来个示例预览,模拟一下跨域请求,后面的一些的概念示例将以此作为抓手。模拟跨域请求要模拟跨域请求的根本是需要两个源:让请求的来源和目标源不一样。这里我就使用IDEA作为静态Web服务器(63342),Tomcat作为后端动态Servlet服务器(8080)。说明:服务器都在本机,端口不一样即可前端代码index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>CORS跨域请求</title> <!--导入Jquery--> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script> </head> <body> <button id="btn">跨域从服务端获取内容</button> <div id="content"></div> <script> $("#btn").click(function () { // 跨域请求 $.get("http://localhost:8080/cors", function (result) { $("#content").append(result).append("<br/>"); }); // 同域请求 $.get("http://localhost:63342"); $.post("http://localhost:63342"); }); </script> </body> </html> 使用IDEA作为静态web服务器,浏览器输入地址即可访问(注:端口号为63342):后端代码后端写个Servlet来接收cors请求/** * 在此处添加备注信息 * * @author YourBatman. <a href=mailto:yourbatman@aliyun.com>Send email to me</a> * @site https://yourbatman.cn * @date 2021/6/9 10:36 * @since 0.0.1 */ @Slf4j @WebServlet(urlPatterns = "/cors") public class CorsServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String requestURI = req.getRequestURI(); String method = req.getMethod(); String originHeader = req.getHeader("Origin"); log.info("收到请求:{},方法:{}, Origin头:{}", requestURI, method, originHeader); resp.getWriter().write("hello cors..."); } } 启动后端服务器,点击页面上的按钮,结果如下:服务端控制台输出:... INFO c.y.cors.servlet.CorsServlet - 收到请求:/cors,方法:GET, Origin头:http://loc服务端输出日志,说明即使前端的Http Status是error,但服务端还是收到并处理了这个请求的下面以此代码示例为基础,普及一下和Cors跨域资源共享相关的概念。Host、Referer、Origin的区别这哥三看起来很是相似,下面对概念作出区分。Host:去哪里。域名+端口。值为客户端将要访问的远程主机,浏览器在发送Http请求时会带有此HeaderReferer:来自哪里。协议+域名+端口+路径+参数。当前请求的来源页面的地址,服务端一般使用 Referer 首部识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等常见应用场景:百度的搜索广告就会分析Referer来判断打开站点是从百度搜索跳转的,还是直接URL输入地址的一般情况下浏览器会带有此Header,但这些case不会带有Referer这个头来源页面协议为File或者Data URI(如页面从本地打开的)来源页面是Https,而目标URL是http浏览器地址栏直接输入网址访问,或者通过浏览器的书签直接访问使用JS的location.href跳转…Origin:来自哪里(跨域)。协议+域名+端口。它用于Cors请求和同域POST请求可以看到Referer与Origin功能相似,前者一般用于统计和阻止盗链,后者用于CORS请求。 但是还是有几点不同:只有跨域请求,或者同域时发送post请求,才会携带Origin请求头;而Referer只要浏览器能获取到都会携带(除了上面说明的几种case外) 2.若浏览器不能获取到请求源页面地址(如上面的几种case),Referer头不会发送,但Origin依旧会发送,只是值是null而已(注:虽然值为null,但此请求依旧属于Cors请求哦),如下图所示:Origin的值只包括协议、域名和端口,而Rerferer不但包括协议、域名、端口还包括路径,参数,注意不包括hash值浏览器的同源策略浏览器的职责是展示/渲染document、css、script脚本等,但是这些资源(将document、css、script统一称为资源)可能来自不同的地方,如本地、远程服务器、甚至黑客的服务器…浏览器作为万维网的入口,是我们接入互联网最重要的软件之一(甚至没有之一),因此它的安全性显得尤为重要,这就出现了浏览器的同源策略。同源策略是浏览器一个重要的安全策略,它用于限制一个origin源的document或者它加载的脚本如何能与另一个origin源的资源进行交互。它能帮助阻隔恶意文档,减少(并不是杜绝)可能被攻击的媒介。方便和安全往往是相悖的:安全性增高了,方便性就会有所降低那么问题来了,什么才算同源?同源的定义URL被称作:统一资源定位符,同源是针对URL而言的。一个完整的URL各部分如下图所示: Tips:域名和host是等同的概念,域名+端口号 = host+端口号(大部分情况下你看到域名并没有端口号,那是采用了默认端口号80而已)同源:只和上图的前两部分(protocol + domain)有关,规则为:全部相同则为同源。这个定义不难理解,但有几点需要再强调一下:两部分必须完全一样才算同源这里的domain包含port端口号,所以总共是两部分而非三部分当然也有说三部分的(协议+host+port),理解其含义就成下面通过举例来彻底了解下。譬如,我的源URL为:http://www.baidu.com/api/user,下面表格描述了不同URL的各类情况: 不同源的网络访问浏览器同源策略的存在,限制了不同源之间的交互,实为不便。但是浏览器也开了一些“绿灯”,让其不受同源策略的约束。此种情况一般可分为如下三类:1.跨域写操作(Cross-origin writes):一般是被允许的。如链接(如a标签)、重定向以及表单提交(如form表单的提交)2.跨域资源嵌入(Cross-origin embedding):一般是允许的。比如下面这些例子:<script src="..."></script>标签嵌入js脚本<link rel="stylesheet" href="...">标签嵌入CSS<img>展示的图片<video>和<audio>媒体资源<object>、 <embed> 、<applet>嵌入的插件CSS中使用@font-face引入字体通过<iframe>载入资源3.跨域读操作(Cross-origin reads):一般是不被允许的。比如我们的http接口请求等都属于此范畴,也是本专栏关注的焦点简单总结成一句话:浏览器自己是可以发起跨域请求的(比如a标签、img标签、form表单等),但是Javascript是不能去跨域获取资源(如ajax)。如何允许不同源的网络访问上面说到的第三种情况:跨域读操作一般是不允许跨域访问的,而这种情况是我们开发过程中最关心、最常见的case,因此必须解决。Tips:这里的读指的是广义上的读,指的是从服务器获取资源(有response)的都叫读操作,而和具体是什么Http Method无关。换句话讲,所有的Http API接口请求都在这里都指的是读操作可以使用 CORS 来允许跨源访问。CORS 是 HTTP 的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。
快速计算表达式都知道调试面板里的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把断点分为四大类型(截图中只有三类):Line breakpoint(行断点):图中红色小圆圈。顾名思义,在指定代码行设置断点Field watchpoint(属性断点):图中红色小眼睛。打在类的属性(static or 非static)上的断点,作用是在该属性读取和写入时激活Method breakpoint(方法断点):图中红色小菱形。标记在方法签名的那一行,在该方法执行的入口/出口处被激活Exception breakpoint(异常断点):红色小闪电。这是一个特殊但很好用的断点,当程序抛出指定异常时会激活异常断点。和其它断点不同,异常断点是项目全局的,它不需要打在具体某一行上下面就到了“啃硬骨”的时候了,来吧。行断点Line breakpoint使用得最最最广泛的断点类型,平时大部分情况下都使用此种断点。 从“教程”中可以看到该断点有很多的设置项,也就是有很多的断点参数可以配置,来了解下。断点参数因为这是第一个介绍断点参数的类型,因此会说得详细些,这样子后面相同功能的参数就不用再赘述了。对照这个截图页:Enabled:不解释。但需注意:若此项不勾选上,小红点并不会消失,而是由实心的变为空心的,当然喽,一般情况下并不会动此项Suspend:众所周知,断点激活时会阻塞程序的继续运行,从而阻塞当前线程。但是当你发现它是个复选框的时候,有没有被诧异到?并且,并且,并且你还可以根本就不勾选它,有何区别:若不勾选选中:此断点相关活动(如打日志等)依旧正常进行,只是不阻塞进程了若勾选中:All(默认):阻塞该程序内所有线程Thread:只阻塞当前断点所在线程 如上图,不勾选Suspend:线程14和线程15正常运行,“畅通无阻”如上图,勾选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比较小众,但并不代表不重要。在我理解它比较小众,可能大多数同学不知道如何打一个异常断点,因为它不是鼠标单击就能轻松搞定。上面介绍了异常断点它是一种全局断点类型,因此并不能在代码处直接单击,而是只能在管理窗口里统一添加: 和其它断点类型相比,至少有如下不一样:创建断点只能通过断点管理窗口创建,而不能通过鼠标点击方式创建完成后,代码栏处不会有任何显示(没有红色小图标),直到它被激活时才会出现红色小闪电异常断点作用于全局:本例中任何地方抛出了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个思考题帮你复盘:断点能打在类上吗?IDEA能设置哪几种类型的断点呢?各有什么场景?如何用IDEA debug调试测试环境的应用?
本文提纲版本约定IntelliJ IDEA:2020.3.2小插曲:IDEA刚发布了其2020.3.2这个小版本,启动图换成了20周年图,IntelliJ IDEA 20周岁啦,为期2天的周年庆活动对开发者免费开放,感受一下:正文Debug调试对IT从业者不是个陌生概念,工作中经常会用到它,这无关乎于初级、中级、高级程序员。调试程序的方式有多种,如:输出日志、增加辅助变量、拆分函数体、断点调试等等,本文将介绍的是断点调试 – 一种最行之有效的调试方法。准确的讲,本文讲述是使用IntelliJ IDEA断点调试。Debug用来追踪代码的运行流程,通常在程序运行过程中出现异常时,启用Debug模式可以分析定位异常发生的位置,以及在运行过程中参数的变化。除此之外,我们也可以使用Debug模式来跟踪代码的运行流程来学习优秀的开源框架。断点调试有多重要?俗话说编码5分钟,debug2小时,从这句话就能体现出调试的重要性,毕竟它占据你“大部分”的时间。为了真实的体现出它的重要性,我“引经据典”,找来了几个资深行业经验的大佬用引用他们的话来表述:调试技巧比编码技巧更为重要,因为花费在调试上的时间往往比编码还多,学到的东西比编码中学到的更丰富调试技能重要性甚⾄超过学习⼀门语⾔不会调试的程序员,肯定编制不出任何好的软件我把关键词都加粗划重点了,其重要性可见一斑。大佬尚且这么认为,何况是我等?所以,本文好好阅读O(∩_∩)O哈哈~什么是断点?突然被这么一问,是不是脑袋懵懵的? 一个天天都在用的“东西”,若是真要你对它下个定义说给别人听,估计一时半会还解释不清。当然喽,大道至简,领会其要义能熟练使用才是硬道理。本文作为一篇“严肃”的技术文章,自然需要先把断点这个概念用文字描述出来。断点:为了调试而故意让程序暂停的地方。它是一种附加在源代码上面的特殊标记,在debug模式下可以触发特定动作,如暂停执行、打印线程堆栈、计算表达式的值、变量跟踪等等。断点的设置和取消全人为手动管理,若不手动处理(删除)将会和项目一直存在。如果你看过前两篇文章,一定能解释为何它会一直存在项目里。建议你前往参阅,电梯直达可见,断点的核心要义是暂停程序,从而在暂停那时刻就可以看到上线文的变量情况、IO情况、线程情况等信息,进而更深入的了解程序,及时发现问题和追踪到错误的根源。断点参数断点并不是孤立存在的,它也可以有参数,从而定制出不同的断点行为,让其能在不同条件下生效,这个参数就叫断点参数。我们平时用得比较多的条件断点,它就是断点参数的最典型应用。当然除了条件断点,其它的断点类型也是可以定制化参数的。那到底有哪些断点类型可以使用和定制呢?那么接下来就步入到本文主体内容,开始进入更有意思的部分啦。断点的基本使用应该没人不会打断点吧,即使你是产品经理(产品经理莫名躺枪,手动狗头~)。打断点最简单最直接的方式就是在你想设置断点的哪一行代码的最左边窗栏鼠标左键单击一下,完成后能看到一个小红点,就表示断点设置成功啦,再点击一下就取消。形如这样: 因为我的IDEA界面简洁,尽可能的去掉了“按钮”,所以平时我自己是使用到大量的快捷键来操作IDEA,打断点也是如此经常用快捷键去完成。当然喽,很多时候也用鼠标的啦,毕竟鼠标处理还是有其很大优势的。说明:我的快捷键是Ctrl + Shift + B,仅供给你参考管理断点管理断点包括新增、删除断点。对于少量断点来讲,鼠标一个个的点击给它删除掉是可以的。但若打了“大量”的断点在代码里(比如看xxx源码的时候),这时让去一个个找来删除是不太现实的,毕竟你可能自己都忘了哪儿有断点。这个时候一个管理页面/窗口就显得格外的重要了,在IDEA中提供了这样的窗口,你有多种方式打开它:菜单栏方式:Run -> view breakpoints,缺点是路径太长太麻烦Actions方式:双击shift调出Actions窗口,输入view breakpoints即可打开任意断点处鼠标右键:选择more即可打开管理窗口。缺点是:你至少得找到一个断点作为抓手(当然喽你可以任意处随意打一个点进去也成)调试窗口:该打开方式下面会提到快捷键方式:毫无疑问,这是我最为推荐的方式喽 在这个管理页面,你可以对断点进行增删改。说明:我的快捷键是Ctrl + Shift + F8,仅供给你参考如何debug模式运行?额,这讲得是不是有点过于简单了点。启动Debug模式运行的方式有多种,比如工具栏的虫子小图标按钮、程序方法入口左键点击、菜单栏、右键菜单栏等等,下面简单演示下 :'据我了解,很多同学最常用的方式是点击上方工具栏右上角的虫子图标,因为我“没有”这个图标,所以“教程”中就不演示了。A哥平时99%情况下都是使用快捷键方式启动程序,因为我认为那是最迅速和便捷的(当然不一定适合你)。此功能我的快捷键分为两大类1/运行右上角当前选中的入口类。它有一组快捷键Ctrl + Shift + Alt + enter:Run运行Ctrl + Shift + Alt + \:Debug运行2.因为很多时候需要从新的入口启动程序,做Spring Boot工程开发可能体会不到(入口只有一个),但在做教程、Demo的时候程序入口是经常变化的,所以不可能每次都还人肉去改启动类,效率太低。为此我就新设置了这组快捷键Ctrl + Shift + Alt + [:Run运行,鼠标焦点所在作为入口Ctrl + Shift + Alt + ]:Debug运行,鼠标焦点所在入口另外,若要区分本次是Run运行还是Debug运行,除了看右上角小虫子图标外,更好的方式看底部控制台窗口激活的是哪个。这样看的优点是:即使同一份应用启动多次,也能快速看出来哪些debug哪些run。 -值得一提的是:debug模式运行,若没有任何断点被激活(比如你压根就没打断点),效果和run模式启动是一样(但控制台窗口不一样,因此日志输出的位置也就不一样)。调试窗口详解调试窗口是我们断点调试的操作面板,熟练的使用此面板推提高效率和掌握更多技巧非常重要。先来认识下它:此操作面板上按钮不少,对Debug调试有多熟练很大程度上是由操作此面板的熟练度决定的。调试按钮最常用的一排按钮,入门必备。一共9个按钮,从左往右依次解释下:1.Show Execution Point:回到当前激活的断点处。效果:若你鼠标现在在别的页面/别的类上面,点击它快速“归位”2.Step Over步过:也叫单步调试,一行一行往下走,若这一行是方法也不会进入里面去。这个应该是平时使用得最多的按钮了,没有之一。所以,建议记住你的快捷键来提高效率哈3.Step Into步入:进入方法体内部。这里的方法指的你自定义的方法or三方库的方法,不会进入到JDK官方的方法里(如上面的System.out.println()这种它是不会进去的)4.Force Step Into强制步入:能进入任何方法,包括JDK的。一般查看底层源码才会用到它5.Step Out步出:它是搭配(Force) Step Into一起使用的,当通过step into进入到方法体内部想出来时,一般有两种方案:单步调试慢慢出来,另一个就是step out(推荐)6.Drop frame:回到当前方法的调用处,同时上下文内所有的变量的值也回到那个时候。该按钮能够点击的前提条件是:当前所处的方法有上级方法,如果你是main方法里,那么按钮就是灰色喽7.Run to Cursor运行到光标处:你想要代码在哪里停一下,就把光标放在哪就成。这个功能实在太好用了,大大缓解了密密麻麻的断点,强烈推荐8.Evaluate Expression表达式计算器:看图标就是个计算器嘛,所以你可以在这里执行任何合法的表达式 9.Trace Current Stream Chain跟踪当前Stream流:只有代码停在Stream流语句上,此图标才点亮可以被点击。这是IDEA提供的由于调试Stream流的杀手锏级别的功能,放在文下详细解释这一排按钮非常重要,甚至是最重要,一定要熟练掌握,可以大大提高调试代码的效率,亲测有效。服务按钮把最左边一竖排定义为服务按钮,为调试过程提供服务。 一共10个,但都比较简单和好理解。同样的从上到下过一遍:Rerun xxx:关闭当前程序,重新运行Modify Run Configuration:顾名思义,修改运行的配置。点击此按钮的效果同点击右上角的框框:点击会弹出这个配置窗口:每份运行期配置都是具名且唯一的,互相隔离。运行配置可修改的项非常多,大概如下:说明:我截图的页面可能和你不一样,因为我用的是最新版的IDEA,此页面在2020.3版本做了改版4. Resume Program:恢复程序。当断点激活时程序“停止”了,点击这个按钮就是恢复的意思。它给到的效果是:跳到下一个断点(用这句话解释貌似更容易理解些),若后面没有断点就直接运行结束了。这个按钮非常常用。5. Pause Program:暂停程序。嗯,只要你现在“卡”在断点处,那么状态就是Pause的状态。这时候就有疑问了,难道这个按钮一直是灰色不可点状态?有啥用呢?我网络上看了看,几乎没人能够解释它的作用,这里A哥尝试给你解释下,用张图给你整得明明白白,服服帖帖: 6. Stop xxx:不解释7. View Breakpoints:打开断点管理窗口。文上已详细解释了此窗口的用法8. Mute Breakpoints:这个按钮挺有意思的,作用是让所有断点变为灰色,也就是说让它们失效。它是一个批量操作,操作对象是所有断点,而不可针对于某一个。若你现在不想把所有断点删除,但又不想它们阻拦你,那么可用这个按钮实现9. Get Thread Dump:拿到当前线程的dump,可以查看到当前线程的状态。如下图: 10. Settings:打开设置菜单。属于高级使用,每一项开启后有什么效果,放在文下解释11. 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()方法里面去探究其实现。为了模拟出这种场景进行调试,就对断点阻塞条件设置为这样:再次运行程序,线程情况如下:
新建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个思考题帮你复盘:Module模块如何单独设置JDK版本?IDEA如何打jar包?开个脑洞:Maven用pom管理项目结构,IDEA是如何识别它的呢?
前言你好,我是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的结构展示窗口,至少有如下三种办法,本文都例举给你。顶部菜单File -> Project Structure 2.点击右上角的快捷按钮3.快捷键方式(推荐)这是我本人最喜欢的方式,至于快捷键是哪个就看你是如何设定的喽,我的快捷键是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文件中记录着。知识点:Project创建时默认会创建一个同名的Module模块Module默认沿用Project的SDK、语言等级等设置,当然也可自己指定每个Module可自行管理依赖,可以是二方库、三方库…本模块的依赖情况默认存储在项目的{moduleName}.iml文件里新增依赖既然Module可以自行管理依赖,那么如何给该模块新增依赖呢?举个例子,现在需要向hello模块增加一个commons-io jar包依赖,可以点击Dependencies标签页左下角的+号,选择Library: 然后选择,如果没有就选择New Libarary...创建一个呗(有就直接用就成):下面分别演示选择Java和选择From Maven两种不同库的方式:
前言你好,我是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概念什么是ProjectEclipse中一个Workspace可以包括多个Project,而在IDEA里Project是顶级概念。Project(翻译为:项目)IntelliJ IDEA的顶级组织单元,它是个逻辑概念。一般来说一个Project代表一个完整的解决方案,如它可包含多个部分,如:源代码构建脚本配置文件文档SDK依赖库…也就是说Project是个完整体,是个资源的集合,扔到任何地方都是可以被解释的。说明:建议把Project翻译为项目,而非工程,不在一个维度。因为一个module其实也可以理解为一个工程,避免混淆什么是Module模块是是项目Project的一部分,必须隶属于Project而存在。它可以独立编译、测试、运行甚至部署。模块是分而治之思想的体现,是降低大型项目复杂度的一种有效手段。模块是可重用的,若需要,一个模块可以被多个模块引用,甚至多个Project项目引用(比如commons模块)。此处强烈不再建议你把Eclipse的Workspace引入进来做类比,那只会把你带跑偏了。细品这两个概念定义,总结一下:在IDEA中,Project项目是最顶级的结构单元,一个IDEA视窗有且只能代表一个Project现在知道为何把user、account、order扔到一个视窗里有多么的不合适了吧一个Project由一个or多个Module模块组成,对于大型项目来讲一般会有N多个module组成,如dubbo项目结构如下图所示: 3.一个module模块里,依旧可以有子模块,曾经可无限延伸(但不建议太多)4.Project是个逻辑概念,Module才是最终的存在形式错误使用优点:一个窗口,能看见全貌弊端:2. 视窗功能不单一。account、order、user属于不同项目,是为了解决不同问题而存在,没有理由放在一起3. 干扰性太强。比如他们三都有类叫ProcessService,那么在你查找的时候永远无法“精确定位”4. 额外性能开销。比如你只想开发user,但还得把其它的加载进来,完全没有必要嘛。1. 说明:idea不能像eclipse一样close project的,毕竟人家那是workspace的概念,而idea同一视窗属于同一项目,总不能说关闭某个模块吧,模块一般相关性很强,完全没必要单独开/关2. 想一想,若你一个人负责了20+个项目,每次打开是不是得花上个几分钟呢?5. 概念上混乱。这么放在一起,其实就不是user项目、order项目了,而是user模块、order模块,很明显概念上就不准确了正确使用这种使用方式界面清爽,运行流畅,解决了上面错误方式的所有弊端。新建项目Project万丈高楼平地起,使用IDEA的第一步一定是新建一个项目Project:或者你也可以在视窗内部新建,顶部菜单栏File -> New -> 三选一:三种创建方式:创建一个全新项目打开现有项目从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个思考题帮你复盘:IDEA的Project和eclipse的workspace的本质区别在哪里?如何给Project/module单独添加依赖库?IDEA模块的.iml文件有什么作用?
前言你好,我是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的得分,画成直方图直观的展示出来:好,排名出来了。从最烂 -> 最好的名次依次为:EJB 2.x,简直“遥遥领先”Date/Time/Calendar,今天的猪脚XML/DOMAWT/Swing…烂归烂,想一想什么样的烂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:日期时间处理库ArrowJavaScript:日期时间处理库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的官方版本,还是有些许诧异的,例举如下:首先当然是包名的差别,org.joda.time -> java.time标准日期时间包JSR 310不接受null值,Joda-Time把Null值当0处理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 310日期时间更多介绍此处就不展开了,毕竟前面文章啰嗦过好多次了。总之它是Java的新一代日期时间API,设计得非常好,几乎没有缺点可言,可用于100%替代老的日期时间API。如果你到现在2021年了还没拥抱它,那么请问你还在等啥呢?总结日期时间API因为过于常用,因此你可能都觉得它毫不起眼。坦白的说,如果你没有复杂的日期时间需求要处理,如涉及到时区、偏移量、跨时区转换、国际化显示等等,那么可能觉得Date也能将就。如果你不想做个将就的人,如果你想拥有更好的日期时间编程体验,弃用Date,拥抱JSR 310吧。本文思考题本文所属专栏:JDK日期时间,后台回复专栏名即可获取全部内容。本文已被https://www.yourbatman.cn收录。看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:偏移量Z代表什么含义?ZoneId和ZoneOffset是如何建立对应关系的?若某个城市不在ZoneId列表里面,想要获取其UTC偏移量该怎么破?
前言你好,我是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傻傻分不清,这里说说它俩的区别。OffsetDateTime = LocalDateTime + 偏移量ZoneOffset;ZonedDateTime = LocalDateTime + 时区ZoneIdOffsetDateTime可以随意设置偏移值,但ZonedDateTime无法自由设置偏移值,因为此值是由时区ZoneId控制的OffsetDateTime无法支持夏令时等规则,但ZonedDateTime可以很好的处理夏令时调整OffsetDateTime得益于不变性一般用于数据库存储、网络通信;而ZonedDateTime得益于其时区特性,一般在指定时区里显示时间非常方便,无需认为干预规则OffsetDateTime代表一个瞬时值,而ZonedDateTime的值是不稳定的,需要在某个瞬时根据当时的规则计算出来偏移量从而确定实际值总的来说,OffsetDateTime和ZonedDateTime的区别主要在于ZoneOffset和ZoneId的区别。如果你只是用来传递数据,请使用OffsetDateTime,若你想在特定时区里做时间显示那么请务必使用ZonedDateTime。总结本着拒绝浅尝辄止的态度,深度剖析了很多同学可能不太熟悉的OffsetDateTime、ZonedDateTime两个API。总而言之,想要真正掌握日期时间体系(不限于Java语言,而是所有语言,甚至日常生活),对时区、偏移量的了解是绕不过去的砍,这块知识有所欠缺的朋友可往前翻翻补补课。最后在使用它们三的过程中,有两个提醒给你:所有日期/时间都是不可变的类型,所以若需要比较的话,请不要使用==,而是用equals()方法。 2、任何时候,构造一个日期时间(包括它们三)请永远务必显示的指定时区,哪怕是默认时区。这么做的目的就是明确代码的意图,消除语义上的不确定性。比如若没指定时区,那到底是写代码的人欠考虑了呢,还是就是想用默认时区呢?总之显示指定绝大部分情况下比隐式“指定”语义上好得多。本文思考题看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:如何用LocalDateTime描述美国纽约本地时间?OffsetDateTime和ZonedDateTime你到底该使用谁?一个人的生日应该用什么Java类型存储呢?
ZoneId它代表一个时区的ID,如Europe/Paris。它规定了一些规则可用于将一个Instant时间戳转换为本地日期/时间LocalDateTime。上面说了时区ZoneId是包含有规则的,实际上描述偏移量何时以及如何变化的实际规则由java.time.zone.ZoneRules定义。ZoneId则只是一个用于获取底层规则的ID。之所以采用这种方法,是因为规则是由政府定义的,并且经常变化,而ID是稳定的。对于API调用者来说只需要使用这个ID(也就是ZoneId)即可,而需无关心更为底层的时区规则ZoneRules,和“政府”同步规则的事是它领域内的事就交给它喽。如:夏令时这条规则是由各国政府制定的,而且不同国家不同年一般都不一样,这个事就交由JDK底层的ZoneRules机制自行sync,使用者无需关心。ZoneId在系统内是唯一的,它共包含三种类型的ID:最简单的ID类型:ZoneOffset,它由’Z’和以’+‘或’-'开头的id组成。如:Z、+18:00、-18:00另一种类型的ID是带有某种前缀形式的偏移样式ID,例如’GMT+2’或’UTC+01:00’。可识别的(合法的)前缀是’UTC’, ‘GMT’和’UT’第三种类型是基于区域的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类型喽。遇到一个日期时间格式字符串,要解析它一般有这两种情况:不带时区/偏移量的字符串:要么不理它说转换不了,要么就约定一个时区(一般用系统默认时区),使用LocalDateTime来解析 @Test public void test11() { String dateTimeStrParam = "2021-05-05T18:00"; LocalDateTime localDateTime = LocalDateTime.parse(dateTimeStrParam); System.out.println("解析后:" + localDateTime); } 输出: 解析后:2021-05-05T18:00 带时区字/偏移量的符串:@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靠谱多了吧。另外它还内置了非常多的格式化模版实例供以使用,形如: @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个思考题帮你复盘:Date类型如何处理夏令时?ZoneId和ZoneOffset有什么区别?平时项目若遇到日期时间的处理,有哪些最佳实践?
SimpleDateFormat格式化Java中对Date类型的输入输出/格式化,推荐使用DateFormat而非用其toString()方法。DateFormat是一个时间格式化器抽象类,SimpleDateFormat是其具体实现类,用于以语言环境敏感的方式格式化和解析日期。它允许格式化(日期→文本)、解析(文本→日期)和规范化。划重点:对语言环境敏感,也就是说对环境Locale、时区TimeZone都是敏感的。既然敏感,那就是可定制的对于一个格式化器来讲,模式(模版)是其关键因素,了解一下:日期/时间模式:格式化的模式由指定的字符串组成,未加引号的大写/小写字母(A-Z a-z)代表特定模式,用来表示模式含义,若想原样输出可以用单引号’'包起来,除了英文字母其它均不解释原样输出/匹配。下面是它规定的模式字母(其它字母原样输出): 这个表格里出现了一些“特殊”的匹配类型,做如下解释: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解析时总结出两点结论:英文单词,不分区大小写。如SatUrday sunDay都是没问题,但是不能有拼写错误若有多个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:00RFC 822时区:遵循RFC 822格式,向下兼容通用时区(名称部分除外)ISO 8601时区:对于格式化,如果与GMT的偏移值为0(也就是格林威治时间喽),则生成“Z”;如果模式字母的数量为1,则忽略小时的任何分数。例如,如果模式是“X”,时区是“GMT+05:30”,则生成“+05”。在进行解析时,“Z”被解析为UTC时区指示符。一般时区不被接受。如果模式字母的数量是4个或更多,在构造SimpleDateFormat或应用模式时抛出IllegalArgumentException。这个规则理解起来还是比较费劲的,在开发中一般不太建议使用此种模式。若要使用请务必本地做好测试SimpleDateFormat的使用很简单,重点是了解其规则模式。最后关于SimpleDateFormat的使用再强调这两点哈:SimpleDateFormat并非线程安全类,使用时请务必注意并发安全问题若使用SimpleDateFormat去格式化成非本地区域(默认Locale)的话,那就必须在构造的时候就指定好,如Locale.US对于Date类型的任何格式化、解析请统一使用SimpleDateFormatJSR 310类型曾经有个人做了个很有意思的投票,统计对Java API的不满意程度。最终Java Date/Calendar API斩获第二烂(第一烂是Java XML/DOM),体现出它烂的点较多,这里给你例举几项:定义并不一致,在java.util和java.sql包中竟然都有Date类,而且呢对它进行格式化/解析类竟然又跑到java.text去了,精神分裂啊java.util.Date等类在建模日期的设计上行为不一致,缺陷明显。包括易变性、糟糕的偏移值、默认值、命名等等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的偏移量。值得提前强调,时区和偏移量在概念和实际作用上是有较大区别的,主要体现在:UTC偏移量仅仅记录了偏移的小时分钟而已,除此之外无任何其它信息。举个例子:+08:00的意思是比UTC时间早8小时,没有地理/时区含义,相应的-03:30代表的意思仅仅是比UTC时间晚3个半小时时区是特定于地区而言的,它和地理上的地区(包括规则)强绑定在一起。比如整个中国都叫东八区,纽约在西五区等等中国没有夏令时,所有东八区对应的偏移量永远是+8;纽约有夏令时,因此它的偏移量可能是-4也可能是-5哦综合来看,时区更好用。令人恼火的夏令时问题,若你使用UTC偏移量去表示那么就很麻烦,因为它可变:一年内的某些时期在原来基础上偏移量 +1,某些时期 -1;但若你使用ZoneId时区去表示就很方便喽,比如纽约是西五区,你在任何时候获取其当地时间都是能得到正确答案的,因为它内置了对夏令时规则的处理,也就是说啥时候+1啥时候-1时区自己门清,不需要API调用者关心。UTC偏移量更像是一种写死偏移量数值的做法,这在天朝这种没有时区规则(没有夏令时)的国家不会存在问题,东八区和UTC+08:00效果永远一样。但在一些夏令时国家(如美国、法国等等),就只能根据时区去获取当地时间喽。所以当你不了解当地规则时,最好是使用时区而非偏移量。
前言你好,我是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的安装路径。地址在.\jre\lib目录的为未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让我们有多种方式可以手动设置/修改默认时区:API方式: 强制将时区设为北京时区TimeZone.setDefault(TimeZone.getDefault().getTimeZone("GMT+8"));JVM参数方式:-Duser.timezone=GMT+8运维设置方式:将操作系统主机时区设置为北京时区,这是推荐方式,可以完全对开发者无感,也方便了运维统一管理据我了解,很多公司在阿里云、腾讯云、国内外的云主机上部署应用时,全部都是采用运维设置统一时区:中国时区,这种方式来管理的,这样对程序来说就消除了默认时区不一致的问题,对开发者友好。让人恼火的夏令时你知道吗,中国曾经也使用过夏令时。什么是夏令时?戳这里离现在最近是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设置时区(那就用默认时区呗)就可正常转换。但是作为高手的你必须清清楚楚,明明白白的知道这是由于交互双发默认有个相同时区的契约存在。
示例二:使用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; } 转换步骤分为两步:通过Parser将String转换为指定的类型结果result(若失败,则抛出异常)判断若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)完美。针对本例,有如下关注点:使用addFormatterForFieldType()方法注册了IntegerParser,并且明确指定了处理的类型,用于处理Person类型也就是说此IntegerParser专门用于转换目标类型为Person的属性因为IntegerParser 实际上 只能转换 String -> Integer,因此还必须注册一个转换器,用于Integer -> Person桥接一下,这样就串起来了String -> Integer -> Person。外面看起来这些都是IntegerParser做的一样,非常工整同样强调:addConverter()注册转换器时请务必不要使用lambda表达式代替输入,否则会失去泛型类型,导致出错二者均持有ConversionService带来哪些增强?说明:关于如此重要的ConversionService你懂的,遗忘了的可乘坐电梯到这复习对于PrinterConverter和ParserConverter来讲,它们的源目的是实现 String <-> Object,特点是:PrinterConverter:出口必须是String类型,入口类型也已确定,即Printer<T>的泛型类型,只能处理 T(或T的子类型) -> StringParserConverter:入口必须是String类型,出口类型也已确定,即Parser<T>的泛型类型,只能处理 String -> T(或T的子类型)按既定“规则”,它俩的能力范围还是蛮受限的。Spring厉害的地方就在于此,可以巧妙的通过组合的方式,扩大现有组件的能力边界。比如本利中它就在PrinterConverter/ParserConverter里分别放入了ConversionService引用,从而到这样的效果: 通过能力组合协作,起到串联作用,从而扩大输入/输出“范围”,感觉就像起到了放大镜的效果一样,这个设计还是很讨巧的。✍总结本文以介绍FormatterRegistry接口为中心,重点研究了此接口的实现方式,发现即使小小的一枚注册中心实现,也蕴藏有丰富亮点供以学习、CV。一般来说ConversionService 天生具备非常强悍的转换能力,因此实际情况是你若需要自定义一个Printer/Parser的话是大概率不需要自己再额外加个Converter转换器的,也就是说底层机制让你已然站在了“巨人”肩膀上。♨本文思考题♨看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:FormatterRegistry作为注册中心只有添加方法,why?示例中为何强调:addConverter()注册转换器时请务必不要使用lambda表达式代替输入,会有什么问题?这种功能组合/桥接的巧妙设计方式,你脑中还能想到其它案例吗?
本文提纲版本约定Spring Framework:5.3.xSpring 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接口的实现类,实现其所有接口方法。FormatterRegistry是ConverterRegistry的子接口,而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()); } 换步骤分为两步:若源类型(实际类型)不是该Printer类型的泛型类型的子类型的话,那就尝试使用conversionService转一趟例如:Printer处理的是Number类型,但是你传入的是Person类型,这个时候conversionService就会发挥作用了交由目标格式化器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的目的。
DateTimeFormatterFactoryBean顾名思义,DateTimeFormatterFactory用于生成一个DateTimeFormatter实例,而本类用于把生成的Bean放进IoC容器内,完成和Spring容器的整合。客气的是,它直接继承自DateTimeFormatterFactory,从而自己同时就具备这两项能力:生成DateTimeFormatter实例将该实例放进IoC容器多说一句:虽然这个工厂Bean非常简单,但是它释放的信号可以作为编程指导:一个应用内,对日期、时间的格式化尽量只存在1种模版规范。比如我们可以向IoC容器里扔进去一个模版,需要时注入进来使用即可 注意:这里指的应用内,一般不包含协议转换层使用的模版规范。如Http协议层可以使用自己单独的一套转换模版机制日期时间模版不要在每次使用时去临时创建,而是集中统一创建好管理起来(比如放IoC容器内),这样维护起来方便很多说明:DateTimeFormatterFactoryBean这个API在Spring内部并未使用,这是Spring专门给使用者用的,因为Spring也希望你这么去做从而把日期时间格式化模版管理起来代码示例 @Test public void test1() { // DateTimeFormatterFactory dateTimeFormatterFactory = new DateTimeFormatterFactory(); // dateTimeFormatterFactory.setPattern("yyyy-MM-dd HH:mm:ss"); // 执行格式化动作 System.out.println(new DateTimeFormatterFactory("yyyy-MM-dd HH:mm:ss").createDateTimeFormatter().format(LocalDateTime.now())); System.out.println(new DateTimeFormatterFactory("yyyy-MM-dd").createDateTimeFormatter().format(LocalDate.now())); System.out.println(new DateTimeFormatterFactory("HH:mm:ss").createDateTimeFormatter().format(LocalTime.now())); System.out.println(new DateTimeFormatterFactory("yyyy-MM-dd HH:mm:ss").createDateTimeFormatter().format(ZonedDateTime.now())); } 运行程序,输出:2020-12-26 22:44:44 2020-12-26 22:44:44 2020-12-26 22:44:44 说明:虽然你也可以直接使用DateTimeFormatter#ofPattern()静态方法得到一个实例,但是 若在Spring环境下使用它我还是建议使用Spring提供的工厂类来创建,这样能保证统一的编程体验,B格也稍微高点。使用建议:以后对日期时间类型(包括JSR310类型)就不要自己去写原生的SimpleDateFormat/DateTimeFormatter了,建议可以用Spring包装过的DateFormatter/DateTimeFormatterFactory,使用体验更佳。数字格式化通过了上篇文章的学习之后,对数字的格式化就一点也不陌生了,什么数字、百分数、钱币等都属于数字的范畴。Spring提供了AbstractNumberFormatter抽象来专门处理数字格式化议题:public abstract class AbstractNumberFormatter implements Formatter<Number> { ... @Override public String print(Number number, Locale locale) { return getNumberFormat(locale).format(number); } @Override public Number parse(String text, Locale locale) throws ParseException { // 伪代码,核心逻辑就这一句 return getNumberFormat.parse(text, new ParsePosition(0)); } // 得到一个NumberFormat实例 protected abstract NumberFormat getNumberFormat(Locale locale); ... } 这和DateFormatter的实现模式何其相似,简直一模一样:底层实现依赖于(委托给)java.text.NumberFormat去完成。此抽象类共有三个具体实现:NumberStyleFormatter:数字格式化,如小数,分组等PercentStyleFormatter:百分数格式化CurrencyStyleFormatter:钱币格式化数字格式化NumberStyleFormatter使用NumberFormat的数字样式的通用数字格式化程序。可定制化参数为:pattern。核心源码如下: NumberStyleFormatter: @Override public NumberFormat getNumberFormat(Locale locale) { NumberFormat format = NumberFormat.getInstance(locale); ... // 解析时,永远返回BigDecimal类型 decimalFormat.setParseBigDecimal(true); // 使用格式化模版 if (this.pattern != null) { decimalFormat.applyPattern(this.pattern); } return decimalFormat; } 代码示例:@Test public void test2() throws ParseException { NumberStyleFormatter formatter = new NumberStyleFormatter(); double myNum = 1220.0455; System.out.println(formatter.print(myNum, Locale.getDefault())); formatter.setPattern("#.##"); System.out.println(formatter.print(myNum, Locale.getDefault())); // 转换 // Number parsedResult = formatter.parse("1,220.045", Locale.getDefault()); // java.text.ParseException: 1,220.045 Number parsedResult = formatter.parse("1220.045", Locale.getDefault()); System.out.println(parsedResult.getClass() + "-->" + parsedResult); } 运行程序,输出:1,220.045 1220.05 class java.math.BigDecimal-->1220.045 可通过setPattern()指定数字格式化的模版(一般建议显示指定)parse()方法返回的是BigDecimal类型,从而保证了数字精度百分数格式化PercentStyleFormatter表示使用百分比样式去格式化数字。核心源码(其实是全部源码)如下:PercentStyleFormatter: @Override protected NumberFormat getNumberFormat(Locale locale) { NumberFormat format = NumberFormat.getPercentInstance(locale); if (format instanceof DecimalFormat) { ((DecimalFormat) format).setParseBigDecimal(true); } return format; } 这个就更简单啦,pattern模版都不需要指定。代码示例:@Test public void test3() throws ParseException { PercentStyleFormatter formatter = new PercentStyleFormatter(); double myNum = 1220.0455; System.out.println(formatter.print(myNum, Locale.getDefault())); // 转换 // Number parsedResult = formatter.parse("1,220.045", Locale.getDefault()); // java.text.ParseException: 1,220.045 Number parsedResult = formatter.parse("122,005%", Locale.getDefault()); System.out.println(parsedResult.getClass() + "-->" + parsedResult); } 运行程序,输出:122,005% class java.math.BigDecimal-->1220.05百分数的格式化不能指定pattern,差评。钱币格式化使用钱币样式格式化数字,使用java.util.Currency来描述货币。代码示例:@Test public void test3() throws ParseException { CurrencyStyleFormatter formatter = new CurrencyStyleFormatter(); double myNum = 1220.0455; System.out.println(formatter.print(myNum, Locale.getDefault())); System.out.println("--------------定制化--------------"); // 指定货币种类(如果你知道的话) // formatter.setCurrency(Currency.getInstance(Locale.getDefault())); // 指定所需的分数位数。默认是2 formatter.setFractionDigits(1); // 舍入模式。默认是RoundingMode#UNNECESSARY formatter.setRoundingMode(RoundingMode.CEILING); // 格式化数字的模版 formatter.setPattern("#.#¤¤"); System.out.println(formatter.print(myNum, Locale.getDefault())); // 转换 // Number parsedResult = formatter.parse("¥1220.05", Locale.getDefault()); Number parsedResult = formatter.parse("1220.1CNY", Locale.getDefault()); System.out.println(parsedResult.getClass() + "-->" + parsedResult); } 运行程序,输出:¥1,220.05 --------------定制化-------------- 1220.1CNY class java.math.BigDecimal-->1220.1 值得关注的是:这三个实现在Spring 4.2版本之前是“耦合”在一起。直到4.2才拆开,职责分离。✍总结本文介绍了Spring的Formatter抽象,让格式化器大一统。这就是Spring最强能力:API设计、抽象、大一统。Converter可以从任意源类型,转换为任意目标类型。而Formatter则是从String类型转换为任务目标类型,有点类似PropertyEditor。可以感觉出Converter是Formater的超集,实际上在Spring中Formatter是被拆解成PrinterConverter和ParserConverter,然后再注册到ConverterRegistry,供后续使用。关于格式化器的注册中心、注册员,这就是下篇文章内容喽,欢迎保持持续关注。♨本文思考题♨看完了不一定懂,看懂了不一定记住,记住了不一定掌握。来,文末3个思考题帮你复盘:Spring为何没有针对JSR310时间类型提供专用转换器实现?Spring内建众多Formatter实现,如何管理?格式化器Formatter和转换器Converter是如何整合到一起的?
你好,我是A哥(YourBatman)。上篇文章 介绍了java.text.Format格式化体系,作为JDK 1.0就提供的格式化器,除了设计上存在一定缺陷,过于底层无法标准化对使用者不够友好,这都是对格式化器提出的更高要求。Spring作为Java开发的标准基建,本文就来看看它做了哪些补充。本文提纲 版本约定Spring Framework:5.3.xSpring Boot:2.4.x✍正文在应用中(特别是web应用),我们经常需要将前端/Client端传入的字符串转换成指定格式/指定数据类型,同样的服务端也希望能把指定类型的数据按照指定格式 返回给前端/Client端,这种情况下Converter已经无法满足我们的需求了。为此,Spring提供了格式化模块专门用于解决此类问题。首先可以从宏观上先看看spring-context对format模块的目录结构安排: public interface Formatter<T> extends Printer<T>, Parser<T> { } 可以看到,该接口本身没有任何方法,而是聚合了另外两个接口Printer和Parser。Printer&Parser这两个接口是相反功能的接口。Printer:格式化显示(输出)接口。将T类型转为String形式,Locale用于控制国际化@FunctionalInterface public interface Printer<T> { // 将Object写为String类型 String print(T object, Locale locale); } Parser:解析接口。将String类型转到T类型,Locale用于控制国际化。@FunctionalInterface public interface Parser<T> { T parse(String text, Locale locale) throws ParseException; } Formatter格式化器接口,它的继承树如下:由图可见,格式化动作只需关心到两个领域:时间日期领域数字领域(其中包括货币)时间日期格式化Spring框架从4.0开始支持Java 8,针对JSR 310日期时间类型的格式化专门有个包org.springframework.format.datetime.standard: 值得一提的是:在Java 8出来之前,Joda-Time是Java日期时间处理最好的解决方案,使用广泛,甚至得到了Spring内置的支持。现在Java 8已然成为主流,JSR 310日期时间API 完全可以 代替Joda-Time(JSR 310的贡献者其实就是Joda-Time的作者们)。因此joda库也逐渐告别历史舞台,后续代码中不再推荐使用,本文也会选择性忽略。除了Joda-Time外,Java中对时间日期的格式化还需分为这两大阵营来处理: -Date类型虽然已经2020年了(Java 8于2014年发布),但谈到时间日期那必然还是得有java.util.Date,毕竟积重难返。所以呢,Spring提供了DateFormatter用于支持它的格式化。因为Date早就存在,所以DateFormatter是伴随着Formatter的出现而出现,@since 3.0 // @since 3.0 public class DateFormatter implements Formatter<Date> { private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); private static final Map<ISO, String> ISO_PATTERNS; static { Map<ISO, String> formats = new EnumMap<>(ISO.class); formats.put(ISO.DATE, "yyyy-MM-dd"); formats.put(ISO.TIME, "HH:mm:ss.SSSXXX"); formats.put(ISO.DATE_TIME, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); ISO_PATTERNS = Collections.unmodifiableMap(formats); } } 默认使用的TimeZone是UTC标准时区,ISO_PATTERNS代表ISO标准模版,这和@DateTimeFormat注解的iso属性是一一对应的。也就是说如果你不想指定pattern,可以快速通过指定ISO来实现。另外,对于格式化器来说有这些属性你都可以自由去定制:DateFormatter: @Nullable private String pattern; private int style = DateFormat.DEFAULT; @Nullable private String stylePattern; @Nullable private ISO iso; @Nullable private TimeZone timeZone; 它对Formatter接口方法的实现如下:DateFormatter: @Override public String print(Date date, Locale locale) { return getDateFormat(locale).format(date); } @Override public Date parse(String text, Locale locale) throws ParseException { return getDateFormat(locale).parse(text); } // 根据pattern、ISO等等得到一个DateFormat实例 protected DateFormat getDateFormat(Locale locale) { ... } 可以看到不管输入还是输出,底层依赖的都是JDK的java.text.DateFormat(实际为SimpleDateFormat),现在知道为毛上篇文章要先讲JDK的格式化体系做铺垫了吧,万变不离其宗。因此可以认为,Spring为此做的事情的核心,只不过是写了个根据Locale、pattern、IOS等参数生成DateFormat实例的逻辑而已,属于应用层面的封装。也就是需要知晓getDateFormat()方法的逻辑,此部分逻辑绘制成图如下:因此:pattern、iso、stylePattern它们的优先级谁先谁后,一看便知。代码示例@Test public void test1() { DateFormatter formatter = new DateFormatter(); Date currDate = new Date(); System.out.println("默认输出格式:" + formatter.print(currDate, Locale.CHINA)); formatter.setIso(DateTimeFormat.ISO.DATE_TIME); System.out.println("指定ISO输出格式:" + formatter.print(currDate, Locale.CHINA)); formatter.setPattern("yyyy-mm-dd HH:mm:ss"); System.out.println("指定pattern输出格式:" + formatter.print(currDate, Locale.CHINA)); } 运行程序,输出:默认输出格式:2020-12-26 指定ISO输出格式:2020-12-26T13:06:52.921Z 指定pattern输出格式:2020-06-26 21:06:52 注意:ISO格式输出的时间,是存在时差问题的,因为它使用的是UTC时间,请稍加注意。还记得本系列前面介绍的CustomDateEditor这个属性编辑器吗?它也是用于对String -> Date的转化,底层依赖也是JDK的DateFormat,但使用灵活度上没这个自由,已被抛弃/取代。关于java.util.Date类型的格式化,在此,语重心长的号召一句:如果你是新项目,请全项目禁用Date类型吧;如果你是新代码,也请不要再使用Date类型,太拖后腿了。JSR 310类型 JSR 310日期时间类型是Java8引入的一套全新的时间日期API。新的时间及日期API位于java.time中,此包中的是类是不可变且线程安全的。下面是一些关键类Instant——代表的是时间戳(另外可参考Clock类)LocalDate——不包含具体时间的日期,如2020-12-12。它可以用来存储生日,周年纪念日,入职日期等LocalTime——代表的是不含日期的时间,如18:00:00LocalDateTime——包含了日期及时间,不过没有偏移信息或者说时区ZonedDateTime——包含时区的完整的日期时间还有时区,偏移量是以UTC/格林威治时间为基准的Timezone——时区。在新API中时区使用ZoneId来表示。时区可以很方便的使用静态方法of来获取到同时还有一些辅助类,如:Year、Month、YearMonth、MonthDay、Duration、Period等等。从上图Formatter的继承树来看,Spring只提供了一些辅助类的格式化器实现,如MonthFormatter、PeriodFormatter、YearMonthFormatter等,且实现方式都是趋同的: class MonthFormatter implements Formatter<Month> { @Override public Month parse(String text, Locale locale) throws ParseException { return Month.valueOf(text.toUpperCase()); } @Override public String print(Month object, Locale locale) { return object.toString(); } } 这里以MonthFormatter为例,其它辅助类的格式化器实现其实基本一样:那么问题来了:Spring为毛没有给LocalDateTime、LocalDate、LocalTime这种更为常用的类型提供Formatter格式化器呢?其实是这样的:JDK 8提供的这套日期时间API是非常优秀的,自己就提供了非常好用的java.time.format.DateTimeFormatter格式化器,并且设计、功能上都已经非常完善了。既然如此,Spring并不需要再重复造轮子,而是仅需考虑如何整合此格式化器即可。整合DateTimeFormatter为了完成“整合”,把DateTimeFormatter融入到Spring自己的Formatter体系内,Spring准备了多个API用于衔接。DateTimeFormatterFactoryjava.time.format.DateTimeFormatter的工厂。和DateFormatter一样,它支持如下属性方便你直接定制: DateTimeFormatterFactory: @Nullable private String pattern; @Nullable private ISO iso; @Nullable private FormatStyle dateStyle; @Nullable private FormatStyle timeStyle; @Nullable private TimeZone timeZone; // 根据定制的参数,生成一个DateTimeFormatter实例 public DateTimeFormatter createDateTimeFormatter(DateTimeFormatter fallbackFormatter) { ... } 优先级关系二者是一致的:patternisodateStyle/timeStyle说明:一致的设计,可以给与开发者近乎一致的编程体验,毕竟JSR 310和Date表示的都是时间日期,尽量保持一致性是一种很人性化的设计考量。
✍前言你好,我是方同学(YourBatman)A哥 -> 方同学。是的,中文昵称改了。自知道行不深无以用“哥”字称呼,虽已毕业多年,同学二字寄寓心态一直积极、热情、年轻北京时间2020-12-22深夜,Spring Cloud 2020.0.0版本正式发布。2020.0.0是第一个使用新版本方案的Spring Cloud发行版本。关于版本号这里啰嗦几句:在这之前,Spring Cloud的Release Train名称采用的是伦敦地铁站命名方式,如:Hoxton、Greenwich等。说明:2020.0.0版本又名Ilford(地铁站名),因为此项目3月后才按照新规更名,估计是为了团队内沟通方便吧,你也可以理解为它仅是一个内部代号而已,方便沟通虽按照字母表顺序排列,但仍存在两个致命问题:对非英语母语国家(比如天朝)非常不友好,无法快速理清版本号关系A-Z,倘若版本号到Z了呢?如何继续发展?你品,你细品 Spring团队意识到了这的确是个问题,因此在今年3月份作出了改变。详情参考我前面写的一篇文章(强烈建议每个进来的你都了解下这次规则变更):Spring改变版本号命名规则:此举对非英语国家很友好说明:版本号规则变更适用于所有Spring技术栈,包含Spring Framework、Spring Boot、Spring Cloud、Spring Data…文归正传。Spring Cloud早在年初就启动了该版本的研发工作,并在今年4月份就已经发布了其2020.0.0-M1版本(第一个里程碑版本),直到离2020年结束不到10天了才“憋出”大招,正式RELEASE。Spring Cloud作为构建在Spring Boot之上的云计算框架,我觉得本次难产的原因主要有二:1.Spring Boot 2.4.0版本2020-11-12才正式RELEASE(Spirng Framework 5.3.0版本2020-10-27才RELEASE)Spring Framework 5.3.0正式发布,在云原生路上继续发力Spring Boot 2.4.0正式发布,全新的配置文件加载机制(不向下兼容)2.改动确实太大,研发、测试、文档编写工作量都是巨大的从Spring Framework、Spring Boot、Spring Cloud三者的发版线路图再一次验证了我的那句话:你对Spring Cloud多了解源自于你对Spring Boot有多了解,你对Spring Boot多了解源自于你对Spring Framework有多了解。这就是为何我文章花大量笔墨在Spring Framework上而非Spring Boot上的根本原因,底层通透了,上层运用自如。 版本约定Spring Framework:5.3.2Spring Boot:2.4.1Spring Cloud:2020.0.0以上版本为SC“携带”的版本✍正文有个有趣的现象,截止稿前(2020-12-23 22:00:00)官网还并未同步标注好当前最新版本为2020.0.0版(如图):其实早在24h之前官方博客就做出了发版宣告:并且Maven中央仓库也已存在最新Jar包(证明你正常引包、使用是没问题的了):其实,文档层面不止官网这一处没有sync最新版本,我就不一一例举,毕竟不太重要。针对此现象我yy一下,是不是Spring Cloud团队缺人人手不够用呢?请问社招吗?O(∩_∩)O哈哈~Spring Cloud版本管理版本管理对于软件开发来说太重要,在Spring Boot出现之前依赖关系、版本管理让人着实头大(即使有Spring BOM存在),特别是当出现版本不适配时很容易就偷走你一下午甚至一整天的时间。Spring Cloud作为上层应用框架,底层版本匹配了才能正常work,其中最主要就是和Spring Boot的版本号要对齐。与Spring Boot版本对应关系Spring Boot的出现和流行大大缓解了上述些情况,但使用起Spring Cloud时它和Spring Boot的版本对应关系依旧是需要特别关注的。为此我帮你总结出了这个表格: 说明:对于Spring Cloud内部组件、Spring Boot、Spirng Framework、Security等这个庞大体系的版本对照关系,文章已整理好,下篇发出,请记得搜藏哦特别提醒:spring-cloud-starter-loadbalancer是伴随着Spring Cloud Commons 2.2.0版本才开始商用的(Hoxton版本),这个版本节点请稍微关注下,因为它替代了Ribbon。当前支持的版本Spring Cloud遵循Pivotal OSS support policy 协议对主要版本提供3年的支持。此外,在Spring Cloud的主要或次要版本发布后,若存在严重的bug和安全问题,就会再维护一段时间(6-12个月不等)。特别注意:这里指的主要版本才是3年,主要版本可不常有的哦现在2020.0.0版本已发布,又到了淘汰的时候。现在Spring Cloud官方还会支持的版本有:2020.0版本:(支持Spring Boot 2.4.x)它是主要版本,按计划会支持到2023年12月份它是自Finchley后的又一主要版本Hoxton版本:(支持Spring Boot 2.2.x和2.3.x)作为Finchley发行系列的一个次要版本,它的常规维护将持续到2021年6月底。从2020-07开始进入到特殊维护期(不加新功能,只改紧急bug),2021-12月底就只会发布重大错误/安全补丁了Greenwich版本:(支持Spring Boot 2.1.x)2020-01就停止维护了,2020-12-31号也将终结它的特殊维护期Finchley版本:(支持Spring Boot 2.0.x)它是一个主要版本的开始,2018年发布更老版本:嗯,忘了吧 Spring官方建议:尽量使用最新版本。不过建议归建议,作为只使用晚期大众技术的我们,坐在第二排甚至第三排看戏才有安全感。但历史的巨浪总归会把前排淘汰,因此早点做足准备总是好的,不至于时至被推至前排时只能裸泳。Spring Cloud 2020.0作为一个主要版本,带来了众多显著的变化,其中进行了一些阻断式更新(不向下兼容)是本文最大看点,来吧上菜。阻断式升级(不向下兼容)差不多在去年(2019年)的这个时候,Spring Cloud在其Roadmap(之前文章有介绍过)里就宣布将要终结的一些库/版本,其中最重要的就是指Spring Cloud Netflix项目进入维护模式,然后计划在2020年完全移除。Spring Cloud做出这样的决定其实也是“被迫的”。我们知道Spring Cloud一直以来把Netflix OSS套件作为其官方默认的一站式解决方案,那时的Netflix OSS套件恨不得可以跟Spring Cloud划等号。奈何呀,Netflix公司在2018年前后宣布其核心组件Hystrix、Ribbon、Zuul、Archaius等均进入维护状态。虽然有Zuul 2.x,Archaius 2.x,但它们均不能向下兼容,无法平滑升级,因此几乎等于无法使用从2018年至今处于维护状态的模块有(包括其对应的starter,此处并未列出):spring-cloud-netflix-archaiusspring-cloud-netflix-hystrix-contractspring-cloud-netflix-hystrix-dashboardspring-cloud-netflix-hystrix-streamspring-cloud-netflix-hystrixspring-cloud-netflix-ribbonspring-cloud-netflix-turbine-streamspring-cloud-netflix-turbinespring-cloud-netflix-zuul1、再见了,Netflix时至今日,Spring Cloud 2020.0正式发布,在这个主要版本里,按既定计划终于对spring-cloud-netflix动刀了。我帮你画了幅spring-cloud-netflix-dependencies的xml文件前后版本主要差异的对比图,一目了然: spring-cloud-netflix-dependencies没有消失哦,它依旧存在,版本号跟随大部队升级为3.0.x版本旧版本的spring-cloud-netflix-dependencies管理着Netflix所有组件,包括Hystrix、Ribbon、Zuul、Eureka等。而自2020.0版本起,它有且只管理Eureka(包括Server和Client)解释说明:Feign虽然最初属Netflix公司,但从9.x版本开始就移交给OpenFeign组织管理了,因此不再划入Netflix管辖范畴简单一句话概括:Spring Cloud 2020.0.0版本彻底删除掉了Netflix除Eureka外的所有组件。至此,我们怀着感恩的心可以对Netflix OSS套件道一声谢谢,并可以对它说再见了。 说明:Netflix的Eureka项目仍旧是活跃状态,这个注册中心设计上还是蛮优秀的,综合表现尚可,市场上竞争力依旧可圈可点,因此Spring Cloud暂还未放弃它<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> Netflix组件替代方案Spring Cloud既然把Netflix OSS套件大刀阔斧的砍掉了,那总归得有替代方案吧。那是必然的,Spring Cloud团队给我们推荐了用于替代的产品:Spring Cloud LoadBalancer是什么?以上替代品中,你可能最陌生、最好奇的是Spring Cloud Loadbalancer,它一度只是Spring Cloud 孵化器里的一个小项目,并且一度搁浅。后再经过重启,发展,现行使其伟大使命,正式用于完全替换 Ribbon,成为Spring Cloud负载均衡器唯一实现。值得注意的是:Spring Cloud LoadBalancer首次引入是在Spring Cloud Commons 2.2.0时,也就是Hoxton发布时就引入了,只不过那会还只是备胎/备选,默认依旧是Ribbon挑大梁。下截图是在Hoxton版本的情况:如图,负载均衡抽象LoadBalancerClient接口有两个实现,而到了Spring Cloud 2020.0版本后,BlockingLoadBalancerClient就是唯一实现了。关于spring-cloud-loadbalancer负载均衡器的使用,官方有个极其建议教程:https://spring.io/guides/gs/spring-cloud-loadbalancer。有兴趣可自己玩玩,若没兴趣,那就关注我后面文章分析吧,我会专程介绍它的Spring Cloud Alibaba是否可作为替代方案?嗯,也可以。不过它目前来说并不是Spring Cloud官方的推荐的默认方案。期待国人一起努力,能早日送Spring Cloud Alibaba上去,让歪果仁用上咱天朝的框架,提issue必须用中文O(∩_∩)O哈哈~。显示导入Netflix包还能否正常work?既想升级到最新版本的Spring Cloud,又想保持向下兼容使用Netflix的技术。虽说spring-cloud-netflix-dependencies里不再包含netflix的核心组件,那我手动导包并指定版本号行不行?能否正常work呢?答:我拍脑袋就给你个答案,不行。既然我没论证过,但这么使用太畸形了,此方案应被枪毙在萌芽中,不应该有。另外,从此事也告诉我们:使用Spring Cloud时尽量面向它的抽象编程,这样即使Spirng Cloud换底层组件(如换熔断器、负载均衡器)等等,理论上对我们业务是无影响或者影响很小的,这都得益于它的Spring Cloud Commons抽象,那里是精华。2、Bootstrap上下文默认不再启动知晓原理的同学知道,Spring Cloud容器是靠Bootstrap Context引导上下文来启动的,对应的类是BootstrapApplicationListener。这在2020.0版本发生了改变,新版本的Spring Cloud不再依赖于此上下文而启动。因此默认情况下,将不再启动Bootstrap上下文。代码层面的改变发生在这里: BootstrapApplicationListener: @Override public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) { ConfigurableEnvironment environment = event.getEnvironment(); // 在方法开头加了这麽个判断 if (!bootstrapEnabled(environment) && !useLegacyProcessing(environment)) { return; } ... } PropertyUtils: // BOOTSTRAP_ENABLED_PROPERTY = spring.cloud.bootstrap.enabled public static boolean bootstrapEnabled(Environment environment) { return environment.getProperty(BOOTSTRAP_ENABLED_PROPERTY, Boolean.class, false) || MARKER_CLASS_EXISTS; } // USE_LEGACY_PROCESSING_PROPERTY = spring.config.use-legacy-processing public static boolean useLegacyProcessing(Environment environment) { return environment.getProperty(USE_LEGACY_PROCESSING_PROPERTY, Boolean.class, false); } 开启方式若你需要开启Bootstrap上下文,有两种办法可以实现:设置值spring.cloud.bootstrap.enabled=true或者 spring.config.use-legacy-processing=true即可。注意:这些个属性值必须确保其能放进环境里才能生效。比如靠谱的方式是:系统属性、环境变量、命令行等引入一个Jar:org.springframework.cloud:spring-cloud-starter-bootstrap,然后什么都不用做了说明:这个jar里面有且仅有一个Marker类,作用你懂的,此处不做过多解释说明:手动开启Bootstrap上下文,证明你fallback到老的方式去加载SC,那么一切请按照老方式做即可3、全新的配置方式得益于Spring Boot 2.4.x支持全新的配置文件书写方式,自此可以使用spring.config.import俩导入其它组建的配置。如:spring.config.import=configserver:xxxspring.config.import=zookeeper:…这么做更具模块化,更符合云原生环境的要求。4、其它之前若要禁用Spring Cloud Config Client端的健康指示用的是health.config.enabled=false,现改为management.health.config.enabled=false。保持了和Spring Boot控制端点风格一致带有无效字符(破折号)的端点id已经改为符合标准的了,自此启动时再也没有讨厌的警告了,拯救洁癖者。bus-env -> busenvbus-refresh -> busrefreshservice-registry -> serviceregistry // old @Endpoint(id = "service-registry") public class ServiceRegistryEndpoint { ... } // new @Endpoint(id = "serviceregistry") public class ServiceRegistryEndpoint { ... } 常规式升级常规升级这块关注点就没那么多了,主要对其组件如Spring Cloud Commons、Spring Cloud Kubernetes、Spring Cloud Openfeign...等做些常规升级,乏善可陈。值得关注的一点:Spirng Cloud所有的Module版本号均升级到了3.0.0(大版本号的升级),除Spring Cloud Circuitbreaker/Spring Cloud Kubernetes(2.0.0)和Spring Cloud Task(2.3.0)之外。仍旧存在的问题虽然2020.0已经RELEASE了,但是仍存在着未解决的问题,例举几个此版本现存的问题:若使用spring.config.import=configserver:来配置配置中心,此版本漏掉了支持retry参数解决方案:若你要使用的话,你只得fallback到传统方式喽(写在bootstrap.yaml里)spring-cloud-config-dependencies里出现了一个非release版本的jar(具体看下截图)解决方案:手动指定该jar的版本号 说明:M1属于里程碑版本,还属于较为早起阶段,可能存在bug,建议你使用时手动指定版本号替换掉这个jar看来即使强如Spring团队,也会出现各种各样的纰漏呀。这么一想的话,我又敢放心大胆的回去写bug去喽。✍总结Spring Cloud 2020.0.0是Spring Cloud的主要版本,是非常重要的存在,升级、改变也是巨大的。特别体现在Netflix模块的全部移除、Spring Cloud启动方式变了等等。伴随着Spring Boot 2.4.x以及Spirng Cloud 2020.0的发布,并且弃用Netflix OSS套件后,必将走入一个新的深度编程体验,满怀惊喜,很是期待。说明:因为此版本完全摈弃掉了Netflix的一套东西,为了跟上时代,我会使用一段时间后,尽快写出最新版本的系列教程,助你少踩坑文末有提到2020.0版本虽已发布,但仍旧存在些问题。不过话说回来,那些都属于很小的问题,可能在下个小版本里就得到修复。但尴尬的是,距离2020年结束只有不到10天了,倘若进入到了2021年,按照版本号命名新规,彼时发出的版本将不能再叫2020.x.x而只能是2021.x.x,显然这就属于大版本号的迭代了,需要谨慎啊。
三、分组分隔符,分组分隔符比较常用,它就是我们常看到的逗号,@Test public void test6() { double myNum = 1220.0455; System.out.println(new DecimalFormat(",###").format(myNum)); System.out.println(new DecimalFormat(",##").format(myNum)); System.out.println(new DecimalFormat(",##").format(123456789)); // 分隔符,左边是无效的 System.out.println(new DecimalFormat("###,##").format(myNum)); } 运行程序,输出:1,220 12,20 1,23,45,67,89 12,20 四、百分号%在展示层面也比较常用,用于把一个数字用%形式表示出来。@Test public void test42() { double myNum = 1220.0455; System.out.println("百分位表示:" + new DecimalFormat("#.##%").format(myNum)); System.out.println("千分位表示:" + new DecimalFormat("#.##\u2030").format(myNum)); } 运行程序,输出:百分位表示:122004.55% 千分位表示:1220045.5‰五、本地货币符号¤嗯,这个符号¤,键盘竟无法直接输出,得使用软键盘(建议使用copy大法)。@Test public void test7() { double myNum = 1220.0455; System.out.println(new DecimalFormat(",000.00¤").format(myNum)); System.out.println(new DecimalFormat(",000.¤00").format(myNum)); System.out.println(new DecimalFormat("¤,000.00").format(myNum)); System.out.println(new DecimalFormat("¤,000.¤00").format(myNum)); // 世界货币表达形式 System.out.println(new DecimalFormat(",000.00¤¤").format(myNum)); } 运行程序,输出:1,220.05¥ 1,220.05¥ ¥1,220.05 1,220.05¥¥ ¥1,220.05¥ 1,220.05CNY 注意最后一条结果:如果连续出现两次,代表货币符号的国际代号。说明:结果默认都做了Locale本地化处理的,若你在其它国家就不会再是¥人名币符号喽DecimalFormat就先介绍到这了,其实掌握了它就基本等于掌握了NumberFormat。接下来再简要看看它另外一个“儿子”:ChoiceFormat。ChoiceFormatChoice:精选的,仔细推敲的。这个格式化器非常有意思:相当于以数字为键,字符串为值的键值对。使用一组double类型的数组作为键,一组String类型的数组作为值,两数组相同(不一定必须是相同,见示例)索引值的元素作为一对。 @Test public void test8() { double[] limits = {1, 2, 3, 4, 5, 6, 7}; String[] formats = {"周一", "周二", "周三", "周四", "周五", "周六", "周天"}; NumberFormat numberFormat = new ChoiceFormat(limits, formats); System.out.println(numberFormat.format(1)); System.out.println(numberFormat.format(4.3)); System.out.println(numberFormat.format(5.8)); System.out.println(numberFormat.format(9.1)); System.out.println(numberFormat.format(11)); } 运行程序,输出:周一 周四 周五 周天 周天 结果解释:4.3位于4和5之间,取值4;5.8位于5和6之间,取值59.1和11均超过了数组最大值(或者说找不到匹配的),则取值最后一对键值对。可能你会想这有什么使用场景???是的,不得不承认它的使用场景较少,本文下面会介绍下它和MessageFormat的一个使用场景。如果说DateFormat和NumberFormat都用没什么花样,主要记住它的pattern语法格式就成,那么就下来这个格式化器就是本文的主菜了,使用场景非常的广泛,它就是MessageFormat。MessageFormat:字符串格式化MessageFormat提供了一种与语言无关(不管你在中国还是其它国家,效果一样)的方式生成拼接消息/拼接字符串的方法。使用它来构造显示给最终用户的消息。MessageFormat接受一组对象,对它们进行格式化,然后在模式的适当位置插入格式化的字符串。先来个最简单的使用示例体验一把: /** * {@link MessageFormat} */ @Test public void test9() { String sourceStrPattern = "Hello {0},my name is {1}"; Object[] args = new Object[]{"girl", "YourBatman"}; String formatedStr = MessageFormat.format(sourceStrPattern, args); System.out.println(formatedStr); } 运行程序,输出:Hello girl,my name is YourBatman有没有中似曾相似的感觉,是不是和String.format()的作用特别像?是的,它俩的用法区别,到底使用税文下也会讨论。要熟悉MessageFormat的使用,主要是要熟悉它的参数模式(你也可以理解为pattern)。参数模式MessageFormat采用{}来标记需要被替换/插入的部分,其中{}里面的参数结构具有一定模式: ArgumentIndex[,FormatType[,FormatStyle]] ArgumentIndex:非必须。从0开始的索引值FormatType:非必须。使用不同的java.text.Format实现类对入参进行格式化处理。它能有如下值:number:调用NumberFormat进行格式化date:调用DateFormat进行格式化time:调用DateFormat进行格式化choice:调用ChoiceFormat进行格式化FormatStyle:非必须。设置FormatType使用的样式。它能有如下值:short、medium、long、full、integer、currency、percent、SubformPattern(如日期格式、数字格式#.##等)说明:FormatType和FormatStyle只有在传入值为日期时间、数字、百分比等类型时才有可能需要设置,使用得并不多。毕竟:我在外部格式化好后再放进去不香吗? @Test public void test10() { MessageFormat messageFormat = new MessageFormat("Hello, my name is {0}. I’am {1,number,#.##} years old. Today is {2,date,yyyy-MM-dd HH:mm:ss}"); // 亦可通过编程式 显示指定某个位置要使用的格式化器 // messageFormat.setFormatByArgumentIndex(1, new DecimalFormat("#.###")); System.out.println(messageFormat.format(new Object[]{"YourBatman", 24.123456, new Date()})); } 运行程序,输出:Hello, my name is YourBatman. I’am 24.12 years old. Today is 2020-12-26 15:24:28它既可以直接在模版里指定格式化模式类型,也可以通过API方法set指定格式化器,当然你也可以再外部格式化好后再放进去,三种方式均可,任君选择。注意事项下面基于此示例,对MessageFormat的使用注意事项作出几点强调。@Test public void test11() { System.out.println(MessageFormat.format("{1} - {1}", new Object[]{1})); // {1} - {1} System.out.println(MessageFormat.format("{0} - {1}", new Object[]{1})); // 输出:1 - {1} System.out.println(MessageFormat.format("{0} - {1}", new Object[]{1, 2, 3})); // 输出:1 - 2 System.out.println("---------------------------------"); System.out.println(MessageFormat.format("'{0} - {1}", new Object[]{1, 2})); // 输出:{0} - {1} System.out.println(MessageFormat.format("''{0} - {1}", new Object[]{1, 2})); // 输出:'1 - 2 System.out.println(MessageFormat.format("'{0}' - {1}", new Object[]{1, 2})); // {0} - 2 // 若你数据库值两边都需要''包起来,请你这么写 System.out.println(MessageFormat.format("''{0}'' - {1}", new Object[]{1, 2})); // '1' - 2 System.out.println("---------------------------------"); System.out.println(MessageFormat.format("0} - {1}", new Object[]{1, 2})); // 0} - 2 System.out.println(MessageFormat.format("{0 - {1}", new Object[]{1, 2})); // java.lang.IllegalArgumentException: Unmatched braces in the pattern. } 1.参数模式的索引值必须从0开始,否则所有索引值无效2.实际传入的参数个数可以和索引个数不匹配,不报错(能匹配上几个算几个)3.两个单引号''才算作一个',若只写一个将被忽略甚至影响整个表达式 1.谨慎使用单引号' 2.关注'的匹配关系4.{}只写左边报错,只写右边正常输出(注意参数的对应关系)static方法的性能问题我们知道MessageFormat提供有一个static静态方法,非常方便的的使用: public static String format(String pattern, Object ... arguments) { MessageFormat temp = new MessageFormat(pattern); return temp.format(arguments); } 可以清晰看到,该静态方法本质上还是构造了一个MessageFormat实例去做格式化的。因此:若你要多次(如高并发场景)格式化同一个模版(参数可不一样)的话,那么提前创建好一个全局的(非static) MessageFormat实例再执行格式化是最好的,而非一直调用其静态方法。说明:若你的系统非高并发场景,此性能损耗基本无需考虑哈,怎么方便怎么来。毕竟朝生夕死的对象对JVM来说没啥压力和String.format选谁?二者都能用于字符串拼接(格式化)上,撇开MessageFormat支持各种模式不说,我们只需要考虑它俩的性能上差异。MeesageFormat:先分析(模版可提前分析,且可以只分析一次),再在指定位置上插入相应的值分析:遍历字符串,维护一个{}数组并记录位置填值String.format:该静态方法是采用运行时用正则表达式 匹配到占位符,然后执行替换的正则表达式为"%(\\d+\\$)?([-#+ 0,(\\<]*)?(\\d+)?(\\.\\d+)?([tT])?([a-zA-Z%])"根据正则匹配到占位符列表和位置,然后填值一说到正则表达式,我心里就发触,因为它对性能是不友好的,所以孰优孰劣,高下立判。说明:还是那句话,没有绝对的谁好谁坏,如果你的系统对性能不敏感,那就是方便第一经典使用场景这个就很多啦,最常见的有:HTML拼接、SQL拼接、异常信息拼接等等。比如下面这个SQL拼接: StringBuilder sb =new StringBuilder(); sb.append("insert into user ("); sb.append(" name,"); sb.append(" accountId,"); sb.append(" zhName,"); sb.append(" enname,"); sb.append(" status"); sb.append(") values ("); sb.append(" ''{0}'',"); sb.append(" {1},"); sb.append(" ''{2}'',"); sb.append(" ''{3}'',"); sb.append(" {4},"); sb.append(")"); Object[] args = {name, accountId, zhName, enname, status}; // 最终SQL String sql = MessageFormat.format(sb.toString(), arr); 你看,多工整。说明:如果值是字符串需要'包起来,那么请使用两边各两个包起来✍总结本文内容介绍了JDK原生的格式化器知识点,主要作用在这三个方面:DateFormat:日期时间格式化NumberFormat:数字格式化MessageFormat:字符串格式化Spring是直接面向使用者的框架产品,很显然这些是不够用的,并且JDK的格式化器在设计上存在一些弊端。比如经常被吐槽的:日期/时间类型格式化器SimpleDateFormat为毛在java.text包里,而它格式化的类型Date却在java.util包内,这实为不合适。有了JDK格式化器作为基础,下篇我们就可以浩浩荡荡的走进Spring格式化器的大门了,看看它是如何优于JDK进行设计和抽象的。
✍前言你好,我是A哥(YourBatman)。本文所属专栏:Spring类型转换,公号后台回复专栏名即可获取全部内容。在日常开发中,我们经常会有格式化的需求,如日期格式化、数字格式化、钱币格式化等等。格式化器的作用似乎跟转换器的作用类似,但是它们的关注点却不一样:转换器:将类型S转换为类型T,关注的是类型而非格式格式化器: String <-> Java类型。这么一看它似乎和PropertyEditor类似,但是它的关注点是字符串的格式Spring有自己的格式化器抽象org.springframework.format.Formatter,但是谈到格式化器,必然就会联想起来JDK自己的java.text.Format体系。为后文做好铺垫,本文就先介绍下JDK为我们提供了哪些格式化能力。版本约定JDK:8 ✍正文Java里从来都缺少不了字符串拼接的活,JDK也提供了多种“工具”供我们使用,如:StringBuffer、StringBuilder以及最直接的+号,相信这些大家都有用过。但这都不是本文的内容,本文将讲解格式化器,给你提供一个新的思路来拼接字符串,并且是推荐方案。JDK内置有格式化器,便是java.text.Format体系。它是个抽象类,提供了两个抽象方法: public abstract class Format implements Serializable, Cloneable { public abstract StringBuffer format(Object obj, StringBuffer toAppendTo, FieldPosition pos); public abstract Object parseObject (String source, ParsePosition pos); } format:将Object格式化为String,并将此String放到toAppendTo里面parseObject:讲String转换为Object,是format方法的逆向操作Java SE针对于Format抽象类对于常见的应用场景分别提供了三个子类实现:DateFormat:日期时间格式化抽象类。用于用于格式化日期/时间类型java.util.Date。虽然是抽象类,但它提供了几个静态方法用于获取它的实例:// 格式化日期 + 时间 public final static DateFormat getInstance() { return getDateTimeInstance(SHORT, SHORT); } public final static DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale){ return get(timeStyle, dateStyle, 3, aLocale); } // 格式化日期 public final static DateFormat getDateInstance(int style, Locale aLocale) { return get(0, style, 2, aLocale); } // 格式化时间 public final static DateFormat getTimeInstance(int style, Locale aLocale){ return get(style, 0, 1, aLocale); } 有了这些静态方法,你可在不必关心具体实现的情况下直接使用: /** * {@link DateFormat} */ @Test public void test1() { Date curr = new Date(); // 格式化日期 + 时间 System.out.println(DateFormat.getInstance().getClass() + "-->" + DateFormat.getInstance().format(curr)); System.out.println(DateFormat.getDateTimeInstance().getClass() + "-->" + DateFormat.getDateTimeInstance().format(curr)); // 格式化日期 System.out.println(DateFormat.getDateInstance().getClass() + "-->" + DateFormat.getDateInstance().format(curr)); // 格式化时间 System.out.println(DateFormat.getTimeInstance().getClass() + "-->" + DateFormat.getTimeInstance().format(curr)); } 运行程序,输出:class java.text.SimpleDateFormat-->20-12-25 上午7:19 class java.text.SimpleDateFormat-->2020-12-25 7:19:30 class java.text.SimpleDateFormat-->2020-12-25 class java.text.SimpleDateFormat-->7:19:30 嗯,可以看到底层实现其实是咱们熟悉的SimpleDateFormat。实话说,这种做法不常用,狠一点:基本不会用(框架开发者可能会用做兜底实现)。SimpleDateFormat一般来说,我们会直接使用SimpleDateFormat来对Date进行格式化,它可以自己指定Pattern,个性化十足。如: @Test public void test2() { DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); // yyyy-MM-dd HH:mm:ss System.out.println(dateFormat.format(new Date())); } 运行程序,输出:2020-12-25关于SimpleDateFormat的使用方式不再啰嗦,不会的就可走自行劝退手续了。此处只提醒一点:SimpleDateFormat线程不安全。说明:JDK 8以后不再建议使用Date类型,也就不会再使用到DateFormat。同时我个人建议:在项目中可强制严令禁用NumberFormat:数字格式化抽象类。用于格式化数字,它可以对数字进行任意格式化,如小数、百分数、十进制数等等。它有两个实现类 类结构和DateFormat类似,也提供了getXXXInstance静态方法给你直接使用,无需关心底层实现:@Test public void test41() { double myNum = 1220.0455; System.out.println(NumberFormat.getInstance().getClass() + "-->" + NumberFormat.getInstance().format(myNum)); System.out.println(NumberFormat.getCurrencyInstance().getClass() + "-->" + NumberFormat.getCurrencyInstance().format(myNum)); System.out.println(NumberFormat.getIntegerInstance().getClass() + "-->" + NumberFormat.getIntegerInstance().format(myNum)); System.out.println(NumberFormat.getNumberInstance().getClass() + "-->" + NumberFormat.getNumberInstance().format(myNum)); System.out.println(NumberFormat.getPercentInstance().getClass() + "-->" + NumberFormat.getPercentInstance().format(myNum)); } 运行程序,输出:class java.text.DecimalFormat-->1,220.045 class java.text.DecimalFormat-->¥1,220.05 class java.text.DecimalFormat-->1,220 class java.text.DecimalFormat-->1,220.045 class java.text.DecimalFormat-->122,005% 这一看就知道DecimalFormat是NumberFormat的主力了。DecimalFormatDecimal:小数,小数的,十进位的。用于格式化十进制数字。它具有各种特性,可以解析和格式化数字,包括:西方数字、阿拉伯数字和印度数字。它还支持不同种类的数字,包括:整数(123)、小数(123.4)、科学记数法(1.23E4)、百分数(12%)和货币金额($123)。所有这些都可以进行本地化。下面是它的构造器: 其中最为重要的就是这个pattern(不带参数的构造器一般不会用),它表示格式化的模式/模版。一般来说我们对DateFormat的pattern比较熟悉,但对数字格式化的模版符号了解甚少。这里我就帮你整理出这个表格(信息源自JDK官网),记得搜藏哦:说明:Number和Digit的区别:Number是个抽象概念,其表达形式可以是数字、手势、声音等等。如1024就是个numberDigit是用来表达的单独符号。如0-9这是个digit就可以用来表示number,如1024就是由1、0、2、4这四个digit组成的看了这个表格的符号规则,估计很多同学还是一脸懵逼。不啰嗦了,上干货一、0和#的使用(最常见使用场景)这是最经典、最常见的使用场景,甚至来说你有可能职业生涯只会用到此场景。 /** * {@link DecimalFormat} */ @Test public void test4() { double myNum = 1220.0455; System.out.println("===============0的使用==============="); System.out.println("只保留整数部分:" + new DecimalFormat("0").format(myNum)); System.out.println("保留3位小数:" + new DecimalFormat("0.000").format(myNum)); System.out.println("整数部分、小数部分都5位。不够的都用0补位(整数高位部,小数低位补):" + new DecimalFormat("00000.00000").format(myNum)); System.out.println("===============#的使用==============="); System.out.println("只保留整数部分:" + new DecimalFormat("#").format(myNum)); System.out.println("保留2为小数并以百分比输出:" + new DecimalFormat("#.##%").format(myNum)); // 非标准数字(不建议这么用) System.out.println("===============非标准数字的使用==============="); System.out.println(new DecimalFormat("666").format(myNum)); System.out.println(new DecimalFormat(".6666").format(myNum)); } 运行程序,输出:===============0的使用=============== 只保留整数部分:1220 保留3位小数:1220.045 整数部分、小数部分都5位。不够的都用0补位(整数高位部,小数低位补):01220.04550 ===============#的使用=============== 只保留整数部分:1220 保留2为小数并以百分比输出:122004.55% ===============非标准数字的使用=============== 661220 1220.666 通过此案例,大致可得出如下结论:整数部分:0和#都可用于取出全部整数部分0的个数决定整数部分长度,不够高位补0;#则无此约束,N多个#是一样的效果小数部分:可保留小数点后N位(0和#效果一样)若小数点后位数不够,若使用的0那就低位补0,若使用#就不补(该是几位就是几位)数字(1-9):并不建议模版里直接写1-9这样的数字,了解下即可 二、科学计数法E如果你不是在证券/银行行业,这个大概率是用不着的(即使在,你估计也不会用它)。来几个例子感受一把就成:@Test public void test5() { double myNum = 1220.0455; System.out.println(new DecimalFormat("0E0").format(myNum)); System.out.println(new DecimalFormat("0E00").format(myNum)); System.out.println(new DecimalFormat("00000E00000").format(myNum)); System.out.println(new DecimalFormat("#E0").format(myNum)); System.out.println(new DecimalFormat("#E00").format(myNum)); System.out.println(new DecimalFormat("#####E00000").format(myNum)); } 运行程序,输出:1E3 1E03 12200E-00001 .1E4 .1E04 1220E00000
3、转换功能(ConversionService)上半部分介绍完GenericConversionService对转换器管理部分的实现(对ConverterRegistry接口的实现),接下来就看看它是如何实现转换功能的(对ConversionService接口的实现)。判断 @Override public boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType) { return canConvert((sourceType != null ? TypeDescriptor.valueOf(sourceType) : null), TypeDescriptor.valueOf(targetType)); } @Override public boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType) { if (sourceType == null) { return true; } // 查找/匹配对应的转换器 GenericConverter converter = getConverter(sourceType, targetType); return (converter != null); } 能否执行转换判断的唯一标准:能否匹配到可用于转换的转换器。而这个查找匹配逻辑,稍稍抬头往上就能看到。转换@Override @SuppressWarnings("unchecked") @Nullable public <T> T convert(@Nullable Object source, Class<T> targetType) { return (T) convert(source, TypeDescriptor.forObject(source), TypeDescriptor.valueOf(targetType)); } @Override @Nullable public Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) { if (sourceType == null) { return handleResult(null, targetType, convertNullSource(null, targetType)); } // 校验:source必须是sourceType的实例 if (source != null && !sourceType.getObjectType().isInstance(source)) { throw new IllegalArgumentException("Source to convert from must be an instance of [" + sourceType + "]; instead it was a [" + source.getClass().getName() + "]"); } // ============拿到转换器,执行转换============ GenericConverter converter = getConverter(sourceType, targetType); if (converter != null) { Object result = ConversionUtils.invokeConverter(converter, source, sourceType, targetType); return handleResult(sourceType, targetType, result); } // 若没进行canConvert的判断直接调动,可能出现此种状况:一般抛出ConverterNotFoundException异常 return handleConverterNotFound(source, sourceType, targetType); } 同样的,执行转换的逻辑很简单,非常好理解的两个步骤:查找匹配到一个合适的转换器(查找匹配的逻辑同上)拿到此转换器执行转换converter.convert(...)说明:其余代码均为一些判断、校验、容错,并非核心,本文给与适当忽略。GenericConversionService实现了转换器管理、转换服务的所有功能,是可以直接面向开发者使用的。但是开发者使用时可能并不知道需要注册哪些转换器来保证程序正常运转,Spring并不能要求开发者知晓其内建实现。基于此,Spring在3.1又提供了一个默认实现DefaultConversionService,它对使用者更友好。DefaultConversionServiceSpirng容器默认使用的转换服务实现,继承自GenericConversionService,在其基础行只做了一件事:构造时添加内建的默认转换器们。从而天然具备有了基本的类型转换能力,适用于不同的环境。如:xml解析、@Value解析、http协议参数自动转换等等。小细节:它并非Spring 3.0就有,而是Spring 3.1新推出的API // @since 3.1 public class DefaultConversionService extends GenericConversionService { // 唯一构造器 public DefaultConversionService() { addDefaultConverters(this); } } 本类核心代码就这一个构造器,构造器内就这一句代码:addDefaultConverters(this)。接下来需要关注Spring默认情况下给我们“安装”了哪些转换器呢?也就是了解下addDefaultConverters(this)这个静态方法默认注册的转换器们// public的静态方法,注意是public的访问权限 public static void addDefaultConverters(ConverterRegistry converterRegistry) { addScalarConverters(converterRegistry); addCollectionConverters(converterRegistry); converterRegistry.addConverter(new ByteBufferConverter((ConversionService) converterRegistry)); converterRegistry.addConverter(new StringToTimeZoneConverter()); converterRegistry.addConverter(new ZoneIdToTimeZoneConverter()); converterRegistry.addConverter(new ZonedDateTimeToCalendarConverter()); converterRegistry.addConverter(new ObjectToObjectConverter()); converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry)); converterRegistry.addConverter(new FallbackObjectToStringConverter()); converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry)); } 该静态方法用于注册全局的、默认的转换器们,从而让Spring有了基础的转换能力,进而完成绝大部分转换工作。为了方便记忆这个注册流程,我把它绘制成图供以你保存:特别强调:转换器的注册顺序非常重要,这决定了通用转换器的匹配结果(谁在前,优先匹配谁,first win)。针对这幅图,你可能还会有如下疑问:JSR310转换器只看到TimeZone、ZoneId等转换,怎么没看见更为常用的LocalDate、LocalDateTime等这些类型转换呢?难道Spring默认是不支持的?答:当然不是。 这么常见的场景Spring怎能会不支持呢?不过与其说这是类型转换,倒不如说是格式化更合适。所以放在该系列后几篇关于格式化章节中再做讲述一般的Converter都见名之意,但StreamConverter有何作用呢?什么场景下会生效答:上文已讲述对于兜底的转换器,有何含义?这种极具通用性的转换器作用为何答:上文已讲述最后,需要特别强调的是:它是一个静态方法,并且还是public的访问权限,且不仅仅只有本类调用。实际上,DefaultConversionService仅仅只做了这一件事,所以任何地方只要调用了该静态方法都能达到前者相同的效果,使用上可谓给与了较大的灵活性。比如Spring Boot环境下不是使用DefaultConversionService而是ApplicationConversionService,后者是对FormattingConversionService扩展,这个话题放在后面详解。Spring Boot在web环境默认向容易注册了一个WebConversionService,因此你有需要可直接@Autowired使用ConversionServiceFactoryBean顾名思义,它是用于产生ConversionService类型转换服务的工厂Bean,为了方便和Spring容器整合而使用。 public class ConversionServiceFactoryBean implements FactoryBean<ConversionService>, InitializingBean { @Nullable private Set<?> converters; @Nullable private GenericConversionService conversionService; public void setConverters(Set<?> converters) { this.converters = converters; } @Override public void afterPropertiesSet() { // 使用的是默认实现哦 this.conversionService = new DefaultConversionService(); ConversionServiceFactory.registerConverters(this.converters, this.conversionService); } @Override @Nullable public ConversionService getObject() { return this.conversionService; } ... } 这里只有两个信息量需要关注:使用的是DefaultConversionService,因此那一大串的内建转换器们都会被添加进来的自定义转换器可以通过setConverters()方法添加进来值得注意的是方法入参是Set<?>并没有明确泛型类型,因此那三种转换器(1:1/1:N/N:N)你是都可以添加.、✍总结通读本文过后,相信能够给与你这个感觉:曾经望而却步的Spring类型转换服务ConversionService,其实也不过如此嘛。通篇我用了多个简单字眼来说明,因为拆开之后,无一高复杂度知识点。迎难而上是积攒涨薪底气和勇气的途径,况且某些知识点其实并不难,所以我觉得从性价比角度来看这类内容是非常划算的,你pick到了麽?正所谓类型转换和格式化属于两组近义词,在Spring体系中也经常交织在一起使用,有种傻傻分不清楚之感。从下篇文章起进入到本系列关于Formatter格式化器知识的梳理,什么日期格式化、@DateTimeFormat、@NumberFormat都将帮你捋清楚喽,有兴趣者可保持持续关注。
✍前言你好,我是YourBatman。通过前两篇文章的介绍已经非常熟悉Spirng 3.0全新一代的类型转换机制了,它提供的三种类型转换器(Converter、ConverterFactory、GenericConverter),分别可处理1:1、1:N、N:N的类型转换。按照Spring的设计习惯,必有一个注册中心来统一管理,负责它们的注册、删除等,它就是ConverterRegistry。对于ConverterRegistry在文首多说一句:我翻阅了很多博客文章介绍它时几乎无一例外的提到有查找的功能,但实际上是没有的。Spring设计此API接口并没有暴露其查找功能,选择把最为复杂的查找匹配逻辑私有化,目的是让开发者使可无需关心,细节之处充分体现了Spring团队API设计的卓越能力。另外,内建的绝大多数转换器访问权限都是default/private,那么如何使用它们,以及屏蔽各种转换器的差异化呢?为此,Spring提供了一个统一类型转换服务,它就是ConversionService。版本约定Spring Framework:5.3.1Spring Boot:2.4.0 ✍正文ConverterRegistry和ConversionService的关系密不可分,前者为后者提供转换器管理支撑,后者面向使用者提供服务。本文涉及到的接口/类有:ConverterRegistry:转换器注册中心。负责转换器的注册、删除ConversionService:统一的类型转换服务。属于面向开发者使用的门面接口ConfigurableConversionService:上两个接口的组合接口GenericConversionService:上个接口的实现,实现了注册管理、转换服务的几乎所有功能,是个实现类而非抽象类DefaultConversionService:继承自GenericConversionService,在其基础上注册了一批默认转换器(Spring内建),从而具备基础转换能力,能解决日常绝大部分场景 ConverterRegistrySpring 3.0引入的转换器注册中心,用于管理新一套的转换器们。public interface ConverterRegistry { void addConverter(Converter<?, ?> converter); <S, T> void addConverter(Class<S> sourceType, Class<T> targetType, Converter<? super S, ? extends T> converter); void addConverter(GenericConverter converter); void addConverterFactory(ConverterFactory<?, ?> factory); // 唯一移除方法:按照转换pair对来移除 void removeConvertible(Class<?> sourceType, Class<?> targetType); } 它的继承树如下:ConverterRegistry有子接口FormatterRegistry,它属于格式化器的范畴,故不放在本文讨论。但仍旧属于本系列专题内容,会在接下来的几篇内容里介入,敬请关注。ConversionService面向使用者的统一类型转换服务。换句话说:站在使用层面,你只需要知道ConversionService接口API的使用方式即可,并不需要关心其内部实现机制,可谓对使用者非常友好。 public interface ConversionService { boolean canConvert(Class<?> sourceType, Class<?> targetType); boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType); <T> T convert(Object source, Class<T> targetType); Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType); } 它的继承树如下:可以看到ConversionService和ConverterRegistry的继承树殊途同归,都直接指向了ConfigurableConversionService这个分支,下面就对它进行介绍。ConfigurableConversionServiceConversionService和ConverterRegistry的组合接口,自己并未新增任何接口方法。 public interface ConfigurableConversionService extends ConversionService, ConverterRegistry { } 它的继承树可参考上图。接下来就来到此接口的直接实现类GenericConversionService。GenericConversionService对ConfigurableConversionService接口提供了完整实现的实现类。换句话说:ConversionService和ConverterRegistry接口的功能均通过此类得到了实现,所以它是本文重点。该类很有些值得学习的地方,可以细品,在我们自己设计程序时加以借鉴。 public class GenericConversionService implements ConfigurableConversionService { private final Converters converters = new Converters(); private final Map<ConverterCacheKey, GenericConverter> converterCache = new ConcurrentReferenceHashMap<ConverterCacheKey, GenericConverter>(64); } 它用两个成员变量来管理转换器们,其中converterCache是缓存用于加速查找,因此更为重要的便是Converters喽。Converters是GenericConversionService的内部类,用于管理(添加、删除、查找)转换器们。也就说对ConverterRegistry接口的实现最终是委托给它去完成的,它是整个转换服务正常work的内核,下面我们对它展开详细叙述。1、内部类Converters它管理所有转换器,包括添加、删除、查找。 GenericConversionService: // 内部类 private static class Converters { private final Set<GenericConverter> globalConverters = new LinkedHashSet<GenericConverter>(); private final Map<ConvertiblePair, ConvertersForPair> converters = new LinkedHashMap<ConvertiblePair, ConvertersForPair>(36); } 说明:这里使用的集合/Map均为LinkedHashXXX,都是有序的(存入顺序和遍历取出顺序保持一致)用这两个集合/Map存储着注册进来的转换器们,他们的作用分别是:globalConverters:存取通用的转换器,并不限定转换类型,一般用于兜底converters:指定了类型对,对应的转换器们的映射关系。ConvertiblePair:表示一对,包含sourceType和targetTypeConvertersForPair:这一对对应的转换器们(因为能处理一对的可能存在多个转换器),内部使用一个双端队列Deque来存储,保证顺序小细节:Spring 5之前使用LinkedList,之后使用Deque(实际为ArrayDeque)存储 final class ConvertiblePair { private final Class<?> sourceType; private final Class<?> targetType; } private static class ConvertersForPair { private final Deque<GenericConverter> converters = new ArrayDeque<>(1); } 添加addpublic void add(GenericConverter converter) { Set<ConvertiblePair> convertibleTypes = converter.getConvertibleTypes(); if (convertibleTypes == null) { ... // 放进globalConverters里 } else { ... // 放进converters里(若支持多组pair就放多个key) } } 在此之前需要了解个前提:对于三种转换器Converter、ConverterFactory、GenericConverter在添加到Converters之前都统一被适配为了GenericConverter,这样做的目的是方便统一管理。对应的两个适配器是ConverterAdapter和ConverterFactoryAdapter,它俩都是ConditionalGenericConverter的内部类。添加的逻辑被我用伪代码简化后其实非常简单,无非就是一个非此即彼的关系而已:若转换器没有指定处理的类型对,就放进全局转换器列表里,用于兜底若转换器有指定处理的类型对(可能还是多个),就放进converters里,后面查找时使用删除remove public void remove(Class<?> sourceType, Class<?> targetType) { this.converters.remove(new ConvertiblePair(sourceType, targetType)); } 移除逻辑非常非常的简单,这得益于添加时候做了统一适配的抽象。查找find@Nullable public GenericConverter find(TypeDescriptor sourceType, TypeDescriptor targetType) { // 找到该类型的类层次接口(父类 + 接口),注意:结果是有序列表 List<Class<?>> sourceCandidates = getClassHierarchy(sourceType.getType()); List<Class<?>> targetCandidates = getClassHierarchy(targetType.getType()); // 双重遍历 for (Class<?> sourceCandidate : sourceCandidates) { for (Class<?> targetCandidate : targetCandidates) { ConvertiblePair convertiblePair = new ConvertiblePair(sourceCandidate, targetCandidate); ... // 从converters、globalConverters里匹配到一个合适转换器后立马返回 } } return null; } 查找逻辑也并不复杂,有两个关键点需要关注:getClassHierarchy(class):获取该类型的类层次(父类 + 接口),注意:结果List是有序的List也就是说转换器支持的类型若是父类/接口,那么也能够处理器子类根据convertiblePair匹配转换器:优先匹配专用的converters,然后才是globalConverters。若都没匹配上返回null2、管理转换器(ConverterRegistry)了解了Converters之后再来看GenericConversionService是如何管理转换器,就如鱼得水,一目了然了。添加为了方便使用者调用,ConverterRegistry接口提供了三个添加方法,这里一一给与实现。说明:暴露给调用者使用的API接口使用起来应尽量的方便,重载多个是个有效途径。内部做适配、归口即可,用户至上 @Override public void addConverter(Converter<?, ?> converter) { // 获取泛型类型 -> 转为ConvertiblePair ResolvableType[] typeInfo = getRequiredTypeInfo(converter.getClass(), Converter.class); ... // converter适配为GenericConverter添加 addConverter(new ConverterAdapter(converter, typeInfo[0], typeInfo[1])); } @Override public <S, T> void addConverter(Class<S> sourceType, Class<T> targetType, Converter<? super S, ? extends T> converter) { addConverter(new ConverterAdapter(converter, ResolvableType.forClass(sourceType), ResolvableType.forClass(targetType))); } @Override public void addConverter(GenericConverter converter) { this.converters.add(converter); invalidateCache(); } 前两个方法都会调用到第三个方法上,每调用一次addConverter()方法都会清空缓存,也就是converterCache.clear()。所以动态添加转换器对性能是有损的,因此使用时候需稍加注意一些。查找ConverterRegistry接口并未直接提供查找方法,而只是在实现类内部做了实现。提供一个钩子方法用于查找给定sourceType/targetType对的转换器。 @Nullable protected GenericConverter getConverter(TypeDescriptor sourceType, TypeDescriptor targetType) { ConverterCacheKey key = new ConverterCacheKey(sourceType, targetType); // 1、查缓存 GenericConverter converter = this.converterCache.get(key); if (converter != null) { ... // 返回结果 } // 2、去converters里查找 converter = this.converters.find(sourceType, targetType); if (converter == null) { // 若还没有匹配的,就返回默认结果 // 默认结果是NoOpConverter -> 什么都不做 converter = getDefaultConverter(sourceType, targetType); } ... // 把结果装进缓存converterCache里 return null; } 有了对Converters查找逻辑的分析,这个步骤就很简单了。绘制成图如下:
兜底转换器按照添加转换器的顺序,Spring在最后添加了4个通用的转换器用于兜底,你可能平时并不关注它,但它实时就在发挥着它的作用。ObjectToObjectConverter将源对象转换为目标类型,非常的通用:Object -> Object:@Override public Set<ConvertiblePair> getConvertibleTypes() { return Collections.singleton(new ConvertiblePair(Object.class, Object.class)); } 虽然它支持的是Object -> Object,看似没有限制但其实是有约定条件的:@Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { return (sourceType.getType() != targetType.getType() && hasConversionMethodOrConstructor(targetType.getType(), sourceType.getType())); } 是否能够处理的判断逻辑在于hasConversionMethodOrConstructor方法,直译为:是否有转换方法或者构造器。代码详细处理逻辑如下截图:此部分逻辑可分为两个part来看:part1:从缓存中拿到Member,直接判断Member的可用性,可用的话迅速返回part2:若part1没有返回,就执行三部曲,尝试找到一个合适的Member,然后放进缓存内(若没有就返回null)part1:快速返回流程当不是首次进入处理时,会走快速返回流程。也就是第0步isApplicable判断逻辑,有这几个关注点:Member包括Method或者ConstructorMethod:若是static静态方法,要求方法的第1个入参类型必须是源类型sourceType;若不是static方法,则要求源类型sourceType必须是method.getDeclaringClass()的子类型/相同类型Constructor:要求构造器的第1个入参类型必须是源类型sourceType 创建目标对象的实例,此转换器支持两种方式:通过工厂方法/实例方法创建实例(method.invoke(source))通过构造器创建实例(ctor.newInstance(source))以上case,在下面均会给出代码示例。part2:三部曲流程对于首次处理的转换,就会进入到详细的三部曲逻辑:通过反射尝试找到合适的Member用于创建目标实例,也就是上图的1、2、3步。step1:determineToMethod,从sourceClass里找实例方法,对方法有如下要求:方法名必须叫"to" + targetClass.getSimpleName(),如toPerson()方法的访问权限必须是public该方法的返回值必须是目标类型或其子类型step2:determineFactoryMethod,找静态工厂方法,对方法有如下要求:方法名必须为valueOf(sourceClass) 或者 of(sourceClass) 或者from(sourceClass)方法的访问权限必须是publicstep3:determineFactoryConstructor,找构造器,对构造器有如下要求:存在一个参数,且参数类型是sourceClass类型的构造器构造器的访问权限必须是public特别值得注意的是:此转换器不支持Object.toString()方法将sourceType转换为java.lang.String。对于toString()支持,请使用下面介绍的更为兜底的FallbackObjectToStringConverter。代码示例实例方法 // sourceClass @Data public class Customer { private Long id; private String address; public Person toPerson() { Person person = new Person(); person.setId(getId()); person.setName("YourBatman-".concat(getAddress())); return person; } } // tartgetClass @Data public class Person { private Long id; private String name; } 书写测试用例:@Test public void test4() { System.out.println("----------------ObjectToObjectConverter---------------"); ConditionalGenericConverter converter = new ObjectToObjectConverter(); Customer customer = new Customer(); customer.setId(1L); customer.setAddress("Peking"); Object convert = converter.convert(customer, TypeDescriptor.forObject(customer), TypeDescriptor.valueOf(Person.class)); System.out.println(convert); // ConversionService方式(实际使用方式) ConversionService conversionService = new DefaultConversionService(); Person person = conversionService.convert(customer, Person.class); System.out.println(person); } 运行程序,输出:----------------ObjectToObjectConverter--------------- Person(id=1, name=YourBatman-Peking) Person(id=1, name=YourBatman-Peking)静态工厂方法// sourceClass @Data public class Customer { private Long id; private String address; } // targetClass @Data public class Person { private Long id; private String name; /** * 方法名称可以是:valueOf、of、from */ public static Person valueOf(Customer customer) { Person person = new Person(); person.setId(customer.getId()); person.setName("YourBatman-".concat(customer.getAddress())); return person; } } 测试用例完全同上,再次运行输出:----------------ObjectToObjectConverter--------------- Person(id=1, name=YourBatman-Peking) Person(id=1, name=YourBatman-Peking)方法名可以为valueOf、of、from任意一种,这种命名方式几乎是业界不成文的规矩,所以遵守起来也会比较容易。但是:建议还是注释写好,防止别人重命名而导致转换生效。构造器基本同静态工厂方法示例,略使用场景基于本转换器可以完成任意对象 -> 任意对象的转换,只需要遵循方法名/构造器默认的一切约定即可,在我们平时开发书写转换层时是非常有帮助的,借助ConversionService可以解决这一类问题。对于Object -> Object的转换,另外一种方式是自定义Converter,然后注册到注册中心。至于到底选哪种合适,这就看具体应用场景喽,本文只是多给你一种选择 IdToEntityConverterId(S) --> Entity(T)。通过调用静态查找方法将实体ID兑换为实体对象。Entity里的该查找方法需要满足如下条件find[EntityName]([IdType]):必须是static静态方法方法名必须为find + entityName。如Person类的话,那么方法名叫findPerson方法参数列表必须为1个返回值类型必须是Entity类型说明:此方法可以不必是public,但建议用public。这样即使JVM的Security安全级别开启也能够正常访问支持的转换Pair如下:ID和Entity都可以是任意类型,能转换就成 @Override public Set<ConvertiblePair> getConvertibleTypes() { return Collections.singleton(new ConvertiblePair(Object.class, Object.class)); } 判断是否能执行准换的条件是:存在符合条件的find方法,且source可以转换为ID类型(注意source能转换成id类型就成,并非目标类型哦)@Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { Method finder = getFinder(targetType.getType()); return (finder != null && this.conversionService.canConvert(sourceType, TypeDescriptor.valueOf(finder.getParameterTypes()[0]))); } 根据ID定位到Entity实体对象简直太太太常用了,运用好此转换器的提供的能力,或许能让你事半功倍,大大减少重复代码,写出更优雅、更简洁、更易于维护的代码。代码示例Entity实体:准备好符合条件的findXXX方法@Data public class Person { private Long id; private String name; /** * 根据ID定位一个Person实例 */ public static Person findPerson(Long id) { // 一般根据id从数据库查,本处通过new来模拟 Person person = new Person(); person.setId(id); person.setName("YourBatman-byFindPerson"); return person; } } 应用IdToEntityConverter,书写示例代码:@Test public void test() { System.out.println("----------------IdToEntityConverter---------------"); ConditionalGenericConverter converter = new IdToEntityConverter(new DefaultConversionService()); TypeDescriptor sourceTypeDesp = TypeDescriptor.valueOf(String.class); TypeDescriptor targetTypeDesp = TypeDescriptor.valueOf(Person.class); boolean matches = converter.matches(sourceTypeDesp, targetTypeDesp); System.out.println("是否能够转换:" + matches); // 执行转换 Object convert = converter.convert("1", sourceTypeDesp, targetTypeDesp); System.out.println(convert); } 运行程序,正常输出:----------------IdToEntityConverter--------------- 是否能够转换:true Person(id=1, name=YourBatman-byFindPerson)示例效果为:传入字符串类型的“1”,就能返回得到一个Person实例。可以看到,我们传入的是字符串类型的的1,而方法入参id类型实际为Long类型,但因为它们能完成String -> Long转换,因此最终还是能够得到一个Entity实例的。使用场景这个使用场景就比较多了,需要使用到findById()的地方都可以通过它来代替掉。如:Controller层:@GetMapping("/ids/{id}") public Object getById(@PathVariable Person id) { return id; } @GetMapping("/ids") public Object getById(@RequestParam Person id) { return id; } Tips:在Controller层这么写我并不建议,因为语义上没有对齐,势必在代码书写过程中带来一定的麻烦。Service层:@Autowired private ConversionService conversionService; public Object findById(String id){ Person person = conversionService.convert(id, Person.class); return person; } Tips:在Service层这么写,我个人觉得还是OK的。用类型转换的领域设计思想代替了自上而下的过程编程思想。FallbackObjectToStringConverter通过简单的调用Object#toString()方法将任何支持的类型转换为String类型,它作为底层兜底。 @Override public Set<ConvertiblePair> getConvertibleTypes() { return Collections.singleton(new ConvertiblePair(Object.class, String.class)); } 该转换器支持CharSequence/StringWriter等类型,以及所有ObjectToObjectConverter.hasConversionMethodOrConstructor(sourceClass, String.class)的类型。说明:ObjectToObjectConverter不处理任何String类型的转换,原来都是交给它了代码示例略。ObjectToOptionalConverter将任意类型转换为一个Optional类型,它作为最最最最最底部的兜底,稍微了解下即可。代码示例 @Test public void test5() { System.out.println("----------------ObjectToOptionalConverter---------------"); ConversionService conversionService = new DefaultConversionService(); Optional<Integer> result = conversionService.convert(Arrays.asList(2), Optional.class); System.out.println(result); } 运行程序,输出:----------------ObjectToOptionalConverter--------------- Optional[[2]]使用场景一个典型的应用场景:在Controller中可传可不传的参数中,我们不仅可以通过@RequestParam(required = false) Long id来做,还是可以这么写:@RequestParam Optional id。✍总结本文是对上文介绍Spring全新一代类型转换机制的补充,因为关注得人较少,所以才有机会突破。针对于Spring注册转换器,需要特别注意如下几点:1.注册顺序很重要。先注册,先服务(若支持的话)2.默认情况下,Spring会注册大量的内建转换器,从而支持String/数字类型转换、集合类型转换,这能解决协议层面的大部分转换问题。如Controller层,输入的是JSON字符串,可用自动被封装为数字类型、集合类型等等如@Value注入的是String类型,但也可以用数字、集合类型接收对于复杂的对象 -> 对象类型的转换,一般需要你自定义转换器,或者参照本文的标准写法完成转换。总之:Spring提供的ConversionService专注于类型转换服务,是一个非常非常实用的API,特别是你正在做基于Spring二次开发的情况下。
✍前言你好,我是YourBatman。上篇文章 大篇幅把Spring全新一代类型转换器介绍完了,已经至少能够考个及格分。在介绍Spring众多内建的转换器里,我故意留下一个尾巴,放在本文专门撰文讲解。为了让自己能在“拥挤的人潮中”显得不(更)一(突)样(出),A哥特意准备了这几个特殊的转换器助你破局,穿越拥挤的人潮,踏上Spring已为你制作好的高级赛道。版本约定Spring Framework:5.3.1Spring Boot:2.4.0 ✍正文本文的焦点将集中在上文留下的4个类型转换器上。StreamConverter:将Stream流与集合/数组之间的转换,必要时转换元素类型这三个比较特殊,属于“最后的”“兜底类”类型转换器:ObjectToObjectConverter:通用的将原对象转换为目标对象(通过工厂方法or构造器)IdToEntityConverter:本文重点。给个ID自动帮你兑换成一个Entity对象FallbackObjectToStringConverter:将任何对象调用toString()转化为String类型。当匹配不到任何转换器时,它用于兜底默认转换器注册情况Spring新一代类型转换内建了非常多的实现,这些在初始化阶段大都被默认注册进去。注册点在DefaultConversionService提供的一个static静态工具方法里:static静态方法具有与实例无关性,我个人觉得把该static方法放在一个xxxUtils里统一管理会更好,放在具体某个组件类里反倒容易产生语义上的误导性 DefaultConversionService: public static void addDefaultConverters(ConverterRegistry converterRegistry) { // 1、添加标量转换器(和数字相关) addScalarConverters(converterRegistry); // 2、添加处理集合的转换器 addCollectionConverters(converterRegistry); // 3、添加对JSR310时间类型支持的转换器 converterRegistry.addConverter(new ByteBufferConverter((ConversionService) converterRegistry)); converterRegistry.addConverter(new StringToTimeZoneConverter()); converterRegistry.addConverter(new ZoneIdToTimeZoneConverter()); converterRegistry.addConverter(new ZonedDateTimeToCalendarConverter()); // 4、添加兜底转换器(上面处理不了的全交给这几个哥们处理) converterRegistry.addConverter(new ObjectToObjectConverter()); converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry)); converterRegistry.addConverter(new FallbackObjectToStringConverter()); converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry)); } } 该静态方法用于注册全局的、默认的转换器们,从而让Spring有了基础的转换能力,进而完成绝大部分转换工作。为了方便记忆这个注册流程,我把它绘制成图供以你保存:特别强调:转换器的注册顺序非常重要,这决定了通用转换器的匹配结果(谁在前,优先匹配谁)。针对这幅图,你可能还会有疑问:1.JSR310转换器只看到TimeZone、ZoneId等转换,怎么没看见更为常用的LocalDate、LocalDateTime等这些类型转换呢?难道Spring默认是不支持的?1.答:当然不是。 这么常见的场景Spring怎能会不支持呢?不过与其说这是类型转换,倒不如说是格式化更合适。所以会在后3篇文章格式化章节在作为重中之重讲述2.一般的Converter都见名之意,但StreamConverter有何作用呢?什么场景下会生效 1.答:本文讲述3.对于兜底的转换器,有何含义?这种极具通用性的转换器作用为何 1.答:本文讲述StreamConverter用于实现集合/数组类型到Stream类型的互转,这从它支持的Set<ConvertiblePair> 集合也能看出来: @Override public Set<ConvertiblePair> getConvertibleTypes() { Set<ConvertiblePair> convertiblePairs = new HashSet<ConvertiblePair>(); convertiblePairs.add(new ConvertiblePair(Stream.class, Collection.class)); convertiblePairs.add(new ConvertiblePair(Stream.class, Object[].class)); convertiblePairs.add(new ConvertiblePair(Collection.class, Stream.class)); convertiblePairs.add(new ConvertiblePair(Object[].class, Stream.class)); return convertiblePairs; } 它支持的是双向的匹配规则:代码示例/** * {@link StreamConverter} */ @Test public void test2() { System.out.println("----------------StreamConverter---------------"); ConditionalGenericConverter converter = new StreamConverter(new DefaultConversionService()); TypeDescriptor sourceTypeDesp = TypeDescriptor.valueOf(Set.class); TypeDescriptor targetTypeDesp = TypeDescriptor.valueOf(Stream.class); boolean matches = converter.matches(sourceTypeDesp, targetTypeDesp); System.out.println("是否能够转换:" + matches); // 执行转换 Object convert = converter.convert(Collections.singleton(1), sourceTypeDesp, targetTypeDesp); System.out.println(convert); System.out.println(Stream.class.isAssignableFrom(convert.getClass())); } 运行程序,输出:----------------StreamConverter--------------- 是否能够转换:true java.util.stream.ReferencePipeline$Head@5a01ccaa true 关注点:底层依旧依赖DefaultConversionService完成元素与元素之间的转换。譬如本例Set -> Stream的实际步骤为:也就是说任何集合/数组类型是先转换为中间状态的List,最终调用list.stream()转换为Stream流的;若是逆向转换先调用source.collect(Collectors.<Object>toList())把Stream转为List后,再转为具体的集合or数组类型。说明:若source是数组类型,那底层实际使用的就是ArrayToCollectionConverter,注意举一反三使用场景StreamConverter它的访问权限是default,我们并不能直接使用到它。通过上面介绍可知Spring默认把它注册进了注册中心里,因此面向使用者我们直接使用转换服务接口ConversionService便可。 @Test public void test3() { System.out.println("----------------StreamConverter使用场景---------------"); ConversionService conversionService = new DefaultConversionService(); Stream<Integer> result = conversionService.convert(Collections.singleton(1), Stream.class); // 消费 result.forEach(System.out::println); // result.forEach(System.out::println); //stream has already been operated upon or closed } 运行程序,输出:----------------StreamConverter使用场景--------------- 1再次特别强调:流只能被读(消费)一次。因为有了ConversionService提供的强大能力,我们就可以在基于Spring/Spring Boot做二次开发时使用它,提高系统的通用性和容错性。如:当方法入参是Stream类型时,你既可以传入Stream类型,也可以是Collection类型、数组类型,是不是瞬间逼格高了起来。
下面以CollectionToCollectionConverter为例分析此转换器的“复杂”之处:final class CollectionToCollectionConverter implements ConditionalGenericConverter { private final ConversionService conversionService; public CollectionToCollectionConverter(ConversionService conversionService) { this.conversionService = conversionService; } // 集合转集合:如String集合转为Integer集合 @Override public Set<ConvertiblePair> getConvertibleTypes() { return Collections.singleton(new ConvertiblePair(Collection.class, Collection.class)); } } 这是唯一构造器,必须传入ConversionService:元素与元素之间的转换是依赖于conversionService转换服务去完成的,最终完成集合到集合的转换。CollectionToCollectionConverter: @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { return ConversionUtils.canConvertElements(sourceType.getElementTypeDescriptor(), targetType.getElementTypeDescriptor(), this.conversionService); } 判断能否转换的依据:集合里的元素与元素之间是否能够转换,底层依赖于ConversionService#canConvert()这个API去完成判断。接下来再看最复杂的转换方法CollectionToCollectionConverter: @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } Collection<?> sourceCollection = (Collection<?>) source; // 判断:这些情况下,将不用执行后续转换动作了,直接返回即可 boolean copyRequired = !targetType.getType().isInstance(source); if (!copyRequired && sourceCollection.isEmpty()) { return source; } TypeDescriptor elementDesc = targetType.getElementTypeDescriptor(); if (elementDesc == null && !copyRequired) { return source; } Collection<Object> target = CollectionFactory.createCollection(targetType.getType(), (elementDesc != null ? elementDesc.getType() : null), sourceCollection.size()); // 若目标类型没有指定泛型(没指定就是Object),不用遍历直接添加全部即可 if (elementDesc == null) { target.addAll(sourceCollection); } else { // 遍历:一个一个元素的转,时间复杂度还是蛮高的 // 元素转元素委托给conversionService去完成 for (Object sourceElement : sourceCollection) { Object targetElement = this.conversionService.convert(sourceElement, sourceType.elementTypeDescriptor(sourceElement), elementDesc); target.add(targetElement); if (sourceElement != targetElement) { copyRequired = true; } } } return (copyRequired ? target : source); } 该转换步骤稍微有点复杂,我帮你屡清楚后有这几个关键步骤:1.快速返回:对于特殊情况,做快速返回处理 1.若目标元素类型是源元素类型的子类型(或相同),就没有转换的必要了(copyRequired = false) 2.若源集合为空,或者目标集合没指定泛型,也不需要做转换动作源集合为空,还转换个啥目标集合没指定泛型,那就是Object,因此可以接纳一切,还转换个啥2.若没有触发快速返回。给目标创建一个新集合,然后把source的元素一个一个的放进新集合里去,这里又分为两种处理case若新集合(目标集合)没有指定泛型类型(那就是Object),就直接putAll即可,并不需要做类型转换若新集合(目标集合指定了泛型类型),就遍历源集合委托conversionService.convert()对元素一个一个的转 代码示例以CollectionToCollectionConverter做示范:List<String> -> Set<Integer> @Test public void test3() { System.out.println("----------------CollectionToCollectionConverter---------------"); ConditionalGenericConverter conditionalGenericConverter = new CollectionToCollectionConverter(new DefaultConversionService()); // 将Collection转为Collection(注意:没有指定泛型类型哦) System.out.println(conditionalGenericConverter.getConvertibleTypes()); List<String> sourceList = Arrays.asList("1", "2", "2", "3", "4"); TypeDescriptor sourceTypeDesp = TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(String.class)); TypeDescriptor targetTypeDesp = TypeDescriptor.collection(Set.class, TypeDescriptor.valueOf(Integer.class)); System.out.println(conditionalGenericConverter.matches(sourceTypeDesp, targetTypeDesp)); Object convert = conditionalGenericConverter.convert(sourceList, sourceTypeDesp, targetTypeDesp); System.out.println(convert.getClass()); System.out.println(convert); } 运行程序,正常输出:[java.util.Collection -> java.util.Collection] true class java.util.LinkedHashSet [1, 2, 3, 4] 关注点:target最终使用的是LinkedHashSet来存储,这结果和CollectionFactory#createCollection该API的实现逻辑是相关(Set类型默认创建的是LinkedHashSet实例)。不足如果说它的优点是功能强大,能够处理复杂类型的转换(PropertyEditor和前2个接口都只能转换单元素类型),那么缺点就是使用、自定义实现起来比较复杂。这不官方也给出了使用指导意见:在Converter/ConverterFactory接口能够满足条件的情况下,可不使用此接口就不使用。ConditionalConverter条件接口,@since 3.2。它可以为Converter、GenericConverter、ConverterFactory转换增加一个前置判断条件。 public interface ConditionalConverter { boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType); } 该接口的实现,截图如下:可以看到,只有通用转换器GenericConverter和它进行了合体。这也很容易理解,作为通用的转换器,加个前置判断将更加严谨和更安全。对于专用的转换器如Converter,它已明确规定了转换的类型,自然就不需要做前置判断喽。✍总结本文详细介绍了Spring新一代的类型转换接口,类型转换作为Spring的基石,其重要性可见一斑。PropertyEditor作为Spring早期使用“转换器”,因存在众多设计缺陷自Spring 3.0起被新一代转换接口所取代,主要有:Converter<S, T>:Source -> Target类型转换接口,适用于1:1转换ConverterFactory<S, R>:Source -> R类型转换接口,适用于1:N转换GenericConverter:更为通用的类型转换接口,适用于N:N转换下篇文章将针对于GenericConverter的几个特殊实现撰专文为你讲解,你也知道做难事必有所得,做难事才有可能破局、破圈,欢迎保持关注。
ConverterFactory从名称上看它代表一个转换工厂:可以将对象S转换为R的所有子类型,从而形成1:N的关系。该接口描述为xxxFactory是非常合适的,很好的表达了1:N的关系 public interface ConverterFactory<S, R> { <T extends R> Converter<S, T> getConverter(Class<T> targetType); } 它同样也是个函数式接口。该接口的实现类并不多,Spring Framework共提供了5个内建实现(访问权限全部为default):以StringToNumberConverterFactory为例看看实现的套路:final class StringToNumberConverterFactory implements ConverterFactory<String, Number> { @Override public <T extends Number> Converter<String, T> getConverter(Class<T> targetType) { return new StringToNumber<T>(targetType); } // 私有内部类:实现Converter接口。用泛型边界约束一类类型 private static final class StringToNumber<T extends Number> implements Converter<String, T> { private final Class<T> targetType; public StringToNumber(Class<T> targetType) { this.targetType = targetType; } @Override public T convert(String source) { if (source.isEmpty()) { return null; } return NumberUtils.parseNumber(source, this.targetType); } } } 由点知面,ConverterFactory作为Converter的工厂,对Converter进行包装,从而达到屏蔽内部实现的目的,对使用者友好,这不正是工厂模式的优点么,符合xxxFactory的语义。但你需要清除的是,工厂内部实现其实也是通过众多if else之类的去完成的,本质上并无差异。代码示例 /** * ConverterFactory:1:N */ @Test public void test2() { System.out.println("----------------StringToNumberConverterFactory---------------"); ConverterFactory<String, Number> converterFactory = new StringToNumberConverterFactory(); // 注意:这里不能写基本数据类型。如int.class将抛错 System.out.println(converterFactory.getConverter(Integer.class).convert("1").getClass()); System.out.println(converterFactory.getConverter(Double.class).convert("1.1").getClass()); System.out.println(converterFactory.getConverter(Byte.class).convert("0x11").getClass()); } 运行程序,正常输出:----------------StringToNumberConverterFactory--------------- class java.lang.Integer class java.lang.Double class java.lang.Byte 关注点:数字类型的字符串,是可以被转换为任意Java中的数字类型的,String(1) -> Number(N)。这便就是ConverterFactory的功劳,它能处理这一类转换问题。不足既然有了1:1、1:N,自然就有N:N。比如集合转换、数组转换、Map到Map的转换等等,这些N:N的场景,就需要借助下一个接口GenericConverter来实现。GenericConverter它是一个通用的转换接口,用于在两个或多个类型之间进行转换。相较于前两个,这是最灵活的SPI转换器接口,但也是最复杂的。 public interface GenericConverter { Set<ConvertiblePair> getConvertibleTypes(); Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType); // 普通POJO final class ConvertiblePair { private final Class<?> sourceType; private final Class<?> targetType; } } 该接口并非函数式接口,虽然方法不多但稍显复杂。现对出现的几个类型做简单介绍:ConvertiblePair:维护sourceType和targetType的POJOgetConvertibleTypes()方法返回此Pair的Set集合。由此也能看出该转换器是可以支持N:N的(大多数情况下只写一对值而已,也有写多对的)TypeDescriptor:类型描述。该类专用于Spring的类型转换场景,用于描述from or to的类型比单独的Type类型强大,内部借助了ResolvableType来解决泛型议题GenericConverter的内置实现也比较多,部分截图如下: ConditionalGenericConverter是GenericConverter和条件接口ConditionalConverter的组合,作用是在执行GenericConverter转换时增加一个前置条件判断方法。说明:分割线下面的4个转换器比较特殊,字面上不好理解其实际作用,比较“高级”。它们如果能被运用在日常工作中可以事半功弎,因此放在在下篇文章专门给你介绍
✍前言你好,我是YourBatman。上篇文章 介绍完了Spring类型转换早期使用的PropertyEditor详细介绍,关于PropertyEditor现存的资料其实还蛮少的,希望这几篇文章能弥补这块空白,贡献一份微薄之力。如果你也吐槽过PropertyEditor不好用,那么本文将对会有帮助。Spring自3.0版本开始自建了一套全新类型转换接口,这就是本文的主要内容,接下来逐步展开。说明:Spring自3.0后笑傲群雄,进入大一统。Java从此步入Spring的时代版本约定Spring Framework:5.3.1Spring Boot:2.4.0 ✍正文在了解新一代的转换接口之前,先思考一个问题:Spring为何要自己造一套轮子呢? 一向秉承不重复造轮子原则的Spring,不是迫不得已的话是不会去动他人奶酪的,毕竟互利共生才能长久。类型转换,作为Spring框架的基石,扮演着异常重要的角色,因此对其可扩展性、可维护性、高效性均有很高要求。基于此,我们先来了解下PropertyEditor设计上到底有哪些缺陷/不足(不能满足现代化需求),让Spring“被迫”走上了自建道路。PropertyEditor设计缺陷前提说明:本文指出它的设计缺陷,只讨论把它当做类型转换器在转换场景下存在的一些缺陷。职责不单一:该接口有非常多的方法,但只用到2个而已类型不安全:setValue()方法入参是Object,getValue()返回值是Object,依赖于约定好的类型强转,不安全线程不安全:依赖于setValue()后getValue(),实例是线程不安全的语义不清晰:从语义上根本不能知道它是用于类型转换的组件只能用于String类型:它只能进行String <-> 其它类型的转换,而非更灵活的Object <-> ObjectPropertyEditor存在这五宗“罪”,让Spring决定自己设计一套全新API用于专门服务于类型转换,这就是本文标题所述:新一代类型转换Converter、ConverterFactory、GenericConverter。关于PropertyEditor在Spring中的详情介绍,请参见文章:3. 搞定收工,PropertyEditor就到这新一代类型转换为了解决PropertyEditor作为类型转换方式的设计缺陷,Spring 3.0版本重新设计了一套类型转换接口,有3个核心接口:Converter<S, T>:Source -> Target类型转换接口,适用于1:1转换ConverterFactory<S, R>:Source -> R类型转换接口,适用于1:N转换GenericConverter:更为通用的类型转换接口,适用于N:N转换 1.注意:就它没有泛型约束,因为是通用另外,还有一个条件接口ConditionalConverter,可跟上面3个接口搭配组合使用,提供前置条件判断验证。这套接口,解决了PropertyEditor做类型转换存在的所有缺陷,且具有非常高的灵活性和可扩展性。下面进入详细了解。Converter将源类型S转换为目标类型T。 @FunctionalInterface public interface Converter<S, T> { T convert(S source); } 它是个函数式接口,接口定义非常简单。适合1:1转换场景:可以将任意类型 转换为 任意类型。它的实现类非常多,部分截图如下:值得注意的是:几乎所有实现类的访问权限都是default/private,只有少数几个是public公开的,下面我用代码示例来“近距离”感受一下。代码示例/** * Converter:1:1 */ @Test public void test() { System.out.println("----------------StringToBooleanConverter---------------"); Converter<String, Boolean> converter = new StringToBooleanConverter(); // trueValues.add("true"); // trueValues.add("on"); // trueValues.add("yes"); // trueValues.add("1"); System.out.println(converter.convert("true")); System.out.println(converter.convert("1")); // falseValues.add("false"); // falseValues.add("off"); // falseValues.add("no"); // falseValues.add("0"); System.out.println(converter.convert("FalSe")); System.out.println(converter.convert("off")); // 注意:空串返回的是null System.out.println(converter.convert("")); System.out.println("----------------StringToCharsetConverter---------------"); Converter<String, Charset> converter2 = new StringToCharsetConverter(); // 中间横杠非必须,但强烈建议写上 不区分大小写 System.out.println(converter2.convert("uTf-8")); System.out.println(converter2.convert("utF8")); } 运行程序,正常输出:----------------StringToBooleanConverter--------------- true true false false null ----------------StringToCharsetConverter--------------- UTF-8 UTF-8 说明:StringToBooleanConverter/StringToCharsetConverter访问权限都是default,外部不可直接使用。此处为了做示例用到一个小技巧 -> 将Demo的报名调整为和转换器的一样,这样就可以直接访问。关注点:true/on/yes/1都能被正确转换为true的,且对于英文字母来说一般都不区分大小写,增加了容错性(包括Charset的转换)。不足Converter用于解决1:1的任意类型转换,因此它必然存在一个不足:解决1:N转换问题需要写N遍,造成重复冗余代码。譬如:输入是字符串,它可以转为任意数字类型,包括byte、short、int、long、double等等,如果用Converter来转换的话每个类型都得写个转换器,想想都麻烦有木有。Spring早早就考虑到了该场景,提供了相应的接口来处理,它就是ConverterFactory<S, R>。
customEditorsForPath作用解释上面说了,它是和customEditors互斥的。customEditorsForPath的作用是能够实现更精准匹配,针对属性级别精准处理。此Map的值通过此API注册进来:public void registerCustomEditor(Class<?> requiredType, String propertyPath, PropertyEditor propertyEditor); 说明:propertyPath不能为null才进此处,否则会注册进customEditors喽可能你会想,有了customEditors为何还需要customEditorsForPath呢?这里就不得不说两者的最大区别了:customEditors:粒度较粗,通用性强。key为类型,即该类型的转换全部交给此编辑器处理如:registerCustomEditor(UUID.class,new UUIDEditor()),那么此编辑器就能处理全天下所有的String <-> UUID 转换工作customEditorsForPath:粒度细精确到属性(字段)级别,有点专车专座的意思如:registerCustomEditor(Person.class, "cat.uuid" , new UUIDEditor()),那么此编辑器就有且仅能处理Person.cat.uuid属性,其它的一概不管有了这种区别,注册中心在findCustomEditor(requiredType,propertyPath)匹配的时候也是按照优先级顺序执行匹配的:若指定了propertyPath(不为null),就先去customEditorsForPath里找。否则就去customEditors里找若没有指定propertyPath(为null),就直接去customEditors里找为了加深理解,讲上场景用代码实现如下。代码示例创建一个Person类,关联Cat @Data public class Cat extends Animal { private UUID uuid; } @Data public class Person { private Long id; private String name; private Cat cat; } 现在的需求场景是:UUID类型统一交给UUIDEditor处理(当然包括Cat里面的UUID类型)Person类里面的Cat的UUID类型,需要单独特殊处理,因此格式不一样需要“特殊照顾”很明显这就需要两个不同的属性编辑器来实现,然后组织起来协同工作。Spring内置了UUIDEditor可以处理一般性的UUID类型(通用),而Person 专用的 UUID编辑器,自定义如下: public class PersonCatUUIDEditor extends UUIDEditor { private static final String SUFFIX = "_YourBatman"; @Override public String getAsText() { return super.getAsText().concat(SUFFIX); } @Override public void setAsText(String text) throws IllegalArgumentException { text = text.replace(SUFFIX, ""); super.setAsText(text); } } 向注册中心注册编辑器,并且书写测试代码如下:@Test public void test6() { PropertyEditorRegistry propertyEditorRegistry = new PropertyEditorRegistrySupport(); // 通用的 propertyEditorRegistry.registerCustomEditor(UUID.class, new UUIDEditor()); // 专用的 propertyEditorRegistry.registerCustomEditor(Person.class, "cat.uuid", new PersonCatUUIDEditor()); String uuidStr = "1-2-3-4-5"; String personCatUuidStr = "1-2-3-4-5_YourBatman"; PropertyEditor customEditor = propertyEditorRegistry.findCustomEditor(UUID.class, null); // customEditor.setAsText(personCatUuidStr); // 抛异常:java.lang.NumberFormatException: For input string: "5_YourBatman" customEditor.setAsText(uuidStr); System.out.println(customEditor.getAsText()); customEditor = propertyEditorRegistry.findCustomEditor(Person.class, "cat.uuid"); customEditor.setAsText(personCatUuidStr); System.out.println(customEditor.getAsText()); } 运行程序,打印输出:00000001-0002-0003-0004-000000000005 00000001-0002-0003-0004-000000000005_YourBatman 完美。customEditorsForPath相当于给你留了钩子,当你在某些特殊情况需要特殊照顾的时候,你可以借助它来搞定,十分的方便。此方式有必要记住并且尝试,在实际开发中使用得还是比较多的。特别在你不想全局定义,且要确保向下兼容性的时候,使用抽象接口类型 + 此种方式缩小影响范围将十分有用说明:propertyPath不仅支持Java Bean导航方式,还支持集合数组方式,如Person.cats[0].uuid这样格式也是ok的PropertyEditorRegistrarRegistrar:登记员。它一般和xxxRegistry配合使用,其实内核还是Registry,只是运用了倒排思想屏蔽一些内部实现而已 public interface PropertyEditorRegistrar { void registerCustomEditors(PropertyEditorRegistry registry); } 同样的,Spring内部也有很多类似实现模式:PropertyEditorRegistrar接口在Spring体系内唯一实现为:ResourceEditorRegistrar。它可值得我们絮叨絮叨。ResourceEditorRegistrar从命名上就知道它和Resource资源有关,实际上也确实如此:主要负责将ResourceEditor注册到注册中心里面去,用于处理形如Resource、File、URI等这些资源类型。你配置classpath:xxx.xml用来启动Spring容器的配置文件,String -> Resource转换就是它的功劳喽唯一构造器为: public ResourceEditorRegistrar(ResourceLoader resourceLoader, PropertyResolver propertyResolver) { this.resourceLoader = resourceLoader; this.propertyResolver = propertyResolver; } resourceLoader:一般传入ApplicationContextpropertyResolver:一般传入Environment很明显,它的设计就是服务于ApplicationContext上下文,在Bean创建过程中辅助BeanWrapper实现资源加载、转换。BeanFactory在初始化的准备过程中就将它实例化,从而具备资源处理能力: AbstractApplicationContext: protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) { ... beanFactory.setBeanExpressionResolver(new StandardBeanExpressionResolver(beanFactory.getBeanClassLoader())); beanFactory.addPropertyEditorRegistrar(new ResourceEditorRegistrar(this, getEnvironment())); ... } 这也是PropertyEditorRegistrar在Spring Framework的唯一使用处,值的关注。PropertyEditor自动发现机制最后介绍一个使用中的奇淫小技巧:PropertyEditor自动发现机制。一般来说,我们自定义一个PropertyEditor是为了实现自定义类型 <-> 字符串的自动转换,它一般需要有如下步骤:为自定义类型写好一个xxxPropertyEditor(实现PropertyEditor接口)将写好的编辑器注册到注册中心PropertyEditorRegistry显然步骤1属个性化行为无法替代,但步骤2属于标准行为,重复劳动是可以标准化的。自动发现机制就是用来解决此问题,对自定义的编辑器制定了如下标准:实现了PropertyEditor接口,具有空构造器与自定义类型同包(在同一个package内),名称必须为:targetType.getName() + "Editor"这样你就无需再手动注册到注册中心了(当然手动注册了也不碍事),Spring能够自动发现它,这在有大量自定义类型编辑器的需要的时候将很有用。说明:此段核心逻辑在BeanUtils#findEditorByConvention()里,有兴趣者可看看值得注意的是:此机制属Spring遵循Java Bean规范而单独提供,在单独使用PropertyEditorRegistry时并未开启,而是在使用Spring产品级能力TypeConverter时有提供,这在后文将有体现,欢迎保持关注。✍总结本文在了解PropertyEditor基础支持之上,主要介绍了其注册中心PropertyEditorRegistry的使用。PropertyEditorRegistrySupport作为其“唯一”实现,负责管理PropertyEditor,包括通用处理和专用处理。最后介绍了PropertyEditor的自动发现机制,其实在实际生产中我并不建议使用自动机制,因为对于可能发生改变的因素,显示指定优于隐式约定。关于Spring类型转换PropertyEditor相关内容就介绍到这了,虽然它很“古老”但并没有退出历史舞台,在排查问题,甚至日常扩展开发中还经常会碰到,因此强烈建议你掌握。下面起将介绍Spring类型转换的另外一个重点:新时代的类型转换服务ConversionService及其周边。
6、浏览项目新增预览模式提效神器,如下图(Project视窗):点击(Project视窗)右上角设置图标,把图中红框部门勾选上就开启预览模式浏览项目,就开启了预览模式。什么叫预览模式:之前打开文件你需要双击,现在光标定位在哪个文件就显示哪个其对应内容,所以就支持键盘操作啦(上下键、tab键),不用依赖鼠标真的十分方便,提效神器啊。7、在IDEA内部直接更新JDK在之前版本介绍中知道现在可以在IDEA内部直接下载JDK(非Oracle官方的),这次更狠:可以在IDEA内直接升级JDK了(若有更新的话)。我个人觉得此功能鸡肋,毕竟JDK谁会轻易去换呢。毕竟你发任你发,我用Java 8。8、拖拽标签页有些时候我们希望在处理一个项目时多打开几个选项卡(毕竟我们外接屏幕比较大嘛),有点分屏的意思。以前处理起来比较困难:先选中标签页右键,然后选择合适的split方式。现在很简单啦:可以通过拖放文件选项卡来水平或垂直地分割编辑器 如果想分屏的文件不在顶部选项卡里,IDEA也为你考虑到了,最近文件里也给你提供了入口:8、IDEA设为某些文件默认打开程序感觉IDEA的侵占欲越来越强了啊,它不仅仅只想做个开发用的IDE,我们的文本编辑工作它都想接管。这不你可以设置某些类型的文件默认打开程序为IDEA了:9、调试器 - 交互式提示当你在debug模式下运行时,可以直接点击变量后面的“小按钮”,弹出交互式窗口,从而可直接修改其值(做小家),大大方便了调试:老版这里是木有可点按钮的:若想要修改变量的值,得到底部的专门的窗口来,并且还得右键,选择set value才能达到目的,路径还是蛮长的10、调试器 - 内联watches这也是调试的时候经常用到的一个功能:watch观察一个表达式的值。这在新版本里操作方便到令人发指:点击这个按钮后就把i加入观察了,直接在程序里就可以观察到:所见即所得若是以前版本的IDEA:哎哟麻烦,技术真的具有不可逆性,用过就回不去喽。11、Profiler - 更强大的分析器工具窗口Profiler功能在2020.1就引入了,当时作为一个试验功能,但是Jetbrain承诺将来的方向会一直迭代它,然后它变得越来越强了。上个版本,我们要用它分析一个xxx.hprof需要如下步骤:通过jps命令找到java进程id通过jmap -dump xxx生成一个hprof文件将此快照文件放进IDEA的Profiler里来进行分析具体操作示例,参见这篇文章:IntelliJ IDEA 2020.2正式发布,诸多亮点总有几款能助你提效如果需要分析的项目就是本地项目,这顿操作还是非常麻烦的。既然是本地项目有木有更便捷的方法呢?IDEA在此版本给出了答案: 它允许你可以将分析器直接附加到正在运行的应用程序上,进而选择要执行的功能:分析快照、监控CPU内存等等。12、Profiler - 对.jfr和.hprof轻松访问在这之前,我们需要分析一个现成的.hprof文件,只能在Profiler视窗里把它导入进来。现在更加方便了,直接可以通过File - Open打开此文件,即自动调起Profiler视窗进行分析。13、主菜单栏VCS -> Git不说了,一切看图说话: 老版本是这样的:这侧面也说明了什么问题呢:Git现在是VCS(version control system)版本控制系统的事实标准,勇敢点说甚至是唯一标准。14、基于机器学习的代码完成现在啥都跟机器学习扯上关系,IDEA号称这个代码补全建议在新版本里是基于机器学习技术搞的,我竟然信了。要查看新的这套推荐系统是如何工作的,这么打开设置即可: 这样你就可以看到本次推荐的排名情况喽:总之IDEA的自动代码完成/补全是一绝,用得越多它就越懂你,好像成为好朋友一样,有温度了。15、其它对Kotlin、Scala等的(新特性)支持,支持http -> cURL转换,更好的url自动补全,对Spring更好的支持等等。当然还少不了对Javascript,以及当先很火的云原生Kubernetes的支持(主要体现在日志、脚本、配置上),这些因为不太普适,因此留给你在使用过程中慢慢发现吧。另外,值得关注的是此版本的IDEA已经支持到了Maven 3.7.0,虽然后者还未正式发布,但这是马上的事,所以IDEA就提前先给支持喽。升级建议升。毕竟我木有“正版”烦恼,O(∩_∩)O哈哈~: ✍总结作为2020年的封板之作,这个版本我个人觉得还是很讲武德的,符合定位。主要喜欢:某些外观UI的重新设计,缓解了审美疲劳阅读器模式,很赞调试器的增强,是真的爱了,调试效率再进一步虽然说坐在第二排看戏是最安全的,但每次我都安奈不住想尝试新功能的心,所以我也邀你一起吧,come on 最后不能忘了,依旧要向那些孜孜不倦为IT行业提供优秀工具的人们致敬!!!特别是捷克的那些创造者们,你们创造了非常好的工具提高了生产力,推动了社会某些产业的进步,功勋卓越!!!
✍前言你好,我是YourBatman。2020庚子年是载入史册的一年,但对Jetbrain公司来说却是它的20周年。上个较大版本的发布,要追溯到8月份了:时隔近4个月,北京时间2020-12-01深夜,IntelliJ IDEA再迎更新,这是2020年的第三个里程碑版本。踩着2020年的尾巴,Jetbrain团队完成了他们的“KPI”:交付了第三次里程碑版本。最近几个版本的介绍这里可电梯直达:IntelliJ IDEA 2020.2正式发布,诸多亮点总有几款能助你提效IntelliJ IDEA 2020.1正式发布,你要的Almost都在这!IntelliJ IDEA 2019.3正式发布,给我们带来哪些新特性?✍正文我们知道,Jetbrain公司它简直就是个IDE工厂,产出过各种语言使用IDE,并且每个IDEA都做得都非常优秀,傲视群雄。本次除了发布最受瞩目的IntelliJ IDEA外,其它兄弟也相继’out’了(红红绿绿的简直亮瞎眼): 说明:Go语言用的IDE Goland本次发布的是Release Candidate而非正式Release,还得过个几天(不会扣Goland团队年终奖吧)这么多兄弟能基于在几乎同一时间发布,有充分的理由相信它们使用的是同一基座,所以做到了一致的体验、一致的稳定、一致的优秀、一致的吃吃吃内存。IntelliJ IDEA作为最为出名的代表作,可以说是我们Java程序员的饭碗呀,接下来就来看看我们的新碗带来了哪些好玩的东西呢。what’s new(新特性)此版本在调试器中添加了交互式提示和嵌入式监视,改进了对Java 15的支持,并引入了对Git暂存的支持以及其它各种功能。然后呢,有些界面上也焕然一新了,做了重新设计。按照惯例,先来张启动图新老对比: 设计师领盒饭去吧,我觉得这个启动图很难再有进步了。1、全新的欢迎屏幕老的在这:这个不得不夸:进步了,更大气更方便了。此次IntelliJ IDEA欢迎屏幕经过重新设计,初始向导已替换为包含四个标签快速进入:项目管理项目自定义设置IDE插件安装了解IntelliJ IDEA的访问帮助和学习资源如果你是IDEA的初级使用者,或者想再学习一次IDEA,从这个页面点进去将会非常适合你2、IDE主题已与操作系统设置同步IntelliJ IDEA现在可以将其主题与您的OS主题同步。 勾选后,如果你的操作系统选用暗色主题,IDEA也会自动切换主题,保护双眼。注意,有一点官方并未做出说明:此功能windows 7不支持,此功能windows 7不支持,此功能windows 7不支持,亲测截图为证:可能你会说:为毛现在还有人用win 7?呃呃呃,我刚从XP升级上来…3、一键进入阅读器模式这个功能很赞,对阅读源码很有帮助。默认情况在Reader模式下打开只读文件(也可以是本项目的只读文件)和来自外部库(Jar内)的文件,注释就像被格式化过一样,方便阅读。如下图表示你当前正在以阅读模式看此文件: 开启阅读模式的效果:像看web页面关闭阅读模式的效果:像看html源代码简单的说:阅读器模式就是帮你翻译了一下javadoc,更适合源码阅读。默认情况下是关闭的,建议你全局开启(或者在页面右上角单独点击开启亦可):3、LightEdit模式优化还记得LightEdit模式吗?它是在2020.1版本(2020-04发布)引进的新功能:本次改进:现在要从命令行以LightEdit模式打开文件,只需idea -e xxx命令即可(若-e后不写文件名,那就打开上次刚打开的文件)。我个人觉得此功能鸡肋,至少对我来说很鸡肋,只玩过没实际用过,毕竟只打开一个文件的话我用普通编辑器更轻量些不香吗?4、改进的拼写检查一句话:就是帮你检查你的单词是不是拼写错了,并且给出建议(可自定义字典): 看起来挺智能,实际然并卵,毕竟咱们方法名起个都费劲,还用应为写注释?拼写检查可作用在doc注释上、字符串上。但不可检查变量名、方法名上~对于大多数程序员来说(如果你不做开源项目,个别拼错无所谓),建议关闭拼写检查,毕竟它还是耗性能的,特别是windows用户(手动o(╥﹏╥)o)。 5、更强的Serch Everywhere这个搜索更加强大了:新增了对git的支持,可以根据commit id等git相关元素进行搜索啦。老的:新的:增加了Git Refs选项(暂忽略Calc)现在如果你只知道一个commit id就可以拿去搜啦~
✍前言你好,我是YourBatman。Spring Framework是一个现代化的框架,俨然已发展成为Java开发的基石。随着高度封装、高度智能化的Spring Boot的普及,发现团队内越来越少的人知道其深层次机制,哪怕只有一点点。这是让Spirng团队开心,但却是让使用的团队比较担忧的现象。 若运行一个完全黑箱程序无疑像抱着一个定时炸弹,总是如履薄冰、战战兢兢。团队内需要这样的同学来为它保驾护航,惊爆之时方可泰然自诺。所以,你愿意pick吗?本系列将讨论Spring Framework里贯穿其上下文,具有举足轻重地位的一个模块:类型转换(也可叫数据转换)。✍正文Java是个多类型且强类型语言,类型转换这个概念对它来说并不陌生。比如:自动类型转换(隐式):小类型 -> 大类型。eg:int a = 10; double b = a;强制类型转换(显式):大类型 -> 小类型。eg:double a = 10.123; int b = (int)a;说明:强转有可能产生精度丢失调用API类型转换:常见的是字符串和其它类型的互转。eg:parseInt(String); parseBoolean(String); JSON.toJSONString(Obj); LocalDate.parse(String)说明:API可能来自于JDK提供、一方库、二方库、三方库提供 在企业级开发环境中,会遇到更为复杂的数据转换场景,譬如说:输入/传入一个规格字符串(如1,2,3,4),转换为一个数组输入/传入一个JSON串(如{"name":"YourBatman","age":18}),转换为一个Person对象输入/传入一个URL串(如:C:/myfile.txt、classpath:myfile.txt),转换为一个org.springframework.core.io.Resource对象虽说数据输入/传入绝大部分都会是字符串(如Http请求信息、XML配置信息),但结构可以千差万别,那么这就必然会涉及到大量的数据类型、结构转换的逻辑。倘若这都需要程序员自己手动编码做转换处理,那会让人望而生畏甚至怯步。还好我们有Spring。从本文起,A哥就帮你解密Spring Framework它是如何帮你接管类型转换,实现“自动化”的。有了此部分知识的储备,后续再讨论自动化数据绑定、自动化数据校验、Spring Boot松散绑定等,一切都变得容易接受得多。说明:类型转换其实每个框架都会存在,其中Java领域以Spring的实现最为经典,学会后便可举一反三Spring类型转换Spring的类型转换也并非一步到位。完全掌握Spring的类型转换并非易事,需要有一定的脉络按步骤进行。本文作为类型转换系列第一篇文章,将绘制目录大纲,将从以下几个方面逐步展开讨论。 早期类型转换之PropertyEditor早期的Spirng(3.0之前)类型转换是基于Java Beans接口java.beans.PropertyEditor来实现的(全部继承自PropertyEditorSupport):public interface PropertyEditor { ... // String -> Object void setAsText(String text) throws java.lang.IllegalArgumentException; // Object -> String String getAsText(); ... } 这类实现举例有:StringArrayPropertyEditor:,分隔的字符串和String[]类型互转PropertiesEditor:键值对字符串和Properties类型互转IntegerEditor:字符串和Integer类型互转…基于PropertyEditor的类型转换作为一种古老的、遗留下来的方式,是具有一些设计缺陷的,如:职责不单一,类型不安全,只能实现String类型的转换等。虽然自Spring 3.0起提供了现代化的类型转换接口,但是此部分机制一直得以保留,保证了向下兼容性。说明:Spring 3.0之前在Java领域还未完全站稳脚跟,因此良好的向下兼容显得尤为重要这块内容将在本系列后面具体篇章中得到专题详解,敬请关注。新一代类型转换接口Converter、GenericConverter为了解决PropertyEditor作为类型转换方式的设计缺陷,Spring 3.0版本重新设计了一套类型转换接口,其中主要包括:Converter<S, T>:Source -> Target类型转换接口,适用于1:1转换StringToPropertiesConverter:将String类型转换为PropertiesStringToBooleanConverter:将String类型转换为BooleanEnumToIntegerConverter:将Enum类型转换为IntegerConverterFactory<S, R>:Source -> R类型转换接口,适用于1:N转换StringToEnumConverterFactory:将String类型转任意EnumStringToNumberConverterFactory:将String类型转为任意数字(可以是int、long、double等等)NumberToNumberConverterFactory:数字类型转为数字类型(如int到long,long到double等等)GenericConverter:更为通用的类型转换接口,适用于N:N转换ObjectToCollectionConverter:任意集合类型转为任意集合类型(如List<String>转为List<Integer> / Set<Integer>都使用此转换器)CollectionToArrayConverter:解释基本同上MapToMapConverter:解释基本同上ConditionalConverter:条件转换接口。可跟上面3个接口组合使用,提供前置条件判断验证重新设计的这套接口,解决了PropertyEditor做类型转换存在的所有缺陷,且具有非常高的灵活性和可扩展性。但是,每个接口独立来看均具有一定的局限性,只有使用组合拳方才有最大威力。当然喽,这也造成学习曲线变得陡峭。据我了解,很少有同学搞得清楚新的这套类型转换机制,特别容易混淆。倘若你掌握了是不是自己价值又提升了呢?不信你细品?这块内容将在本系列后面具体篇章中得到专题详解,敬请关注。
Logback配置属性Logback一些配置项改名了,更加表名了它是logback的配置项。新增了配置类LogbackLoggingSystemProperties用于对应,它继承自之前的LoggingSystemProperties之前的配置项有些被废弃(此版本还未删除,后续版本肯定会删除的),对应关系如下: 一些属性是被放到system environment里面的:不再注册DefaultServlet从Spring Boot 2.4开始,默认将不会再注册DefaultServlet。因为在绝大多数的应用中,Spring MVC提供的DispatcherServlet是唯一需要被注册的Servlet。从源码处感受下这次改动:AbstractServletWebServerFactory: // 2.4.0之前版本,默认值是true private boolean registerDefaultServlet = true; // 2.4.0以及之后版本,默认值是false private boolean registerDefaultServlet = false; 当然喽,若你的工程强依赖于此Servelt,那么可以通过此配置项server.servlet.register-default-servlet = true把它注册上去。补课:什么是DefaultServlet?它是Java EE提供的标准技术,如Tomcat、Jetty等都提供了这个类。简而言之它的作用就是兜底(拦截/),当别的servlet都没匹配上时就交给它来处理,一般用于处理静态资源如.jpg,.html,.js这类的静态文件。DefaultServlet在传统web容器里,会被配置在tomcat目录(此处以tomcat为例)下的conf/web.xml里: <servlet> <servlet-name>default</servlet-name> <servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class> <init-param> <param-name>debug</param-name> <param-value>0</param-value> </init-param> <init-param> <param-name>listings</param-name> <param-value>false</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> 说明:tomcat下的web.xml对其加载的所有的Application都生效,并且最终和Application自己的web.xml内容合并,遇相同的话后者优先级更高在Spring Boot 嵌入式容器里配置是这样的(完全等价于xml配置):private void addDefaultServlet(Context context) { Wrapper defaultServlet = context.createWrapper(); defaultServlet.setName("default"); defaultServlet.setServletClass("org.apache.catalina.servlets.DefaultServlet"); defaultServlet.addInitParameter("debug", "0"); defaultServlet.addInitParameter("listings", "false"); defaultServlet.setLoadOnStartup(1); // Otherwise the default location of a Spring DispatcherServlet cannot be set defaultServlet.setOverridable(true); context.addChild(defaultServlet); context.addServletMappingDecoded("/", "default"); } 值得注意的是:Spring Boot注册的DispatcherServlet的path也是/(覆盖掉了DefaultServelt)。在Spring MVC环境下倘若是静态资源,也不用DefaultServelt费心,Spring MVC专门提供了一个DefaultServletHttpRequestHandler用于处理静态资源(虽然最终还是Dispatcher给defaultServlet去搞定)。现在的Spring Boot服务大都是REST服务,并无静态资源需要提供,因此就没有必要启用DefaultServletHttpRequestHandler和注册DefaultServlet来增加不必要的开销喽。HTTP traces不再包含cookie头Http traces默认将不再包含请求头Cookie以及响应头Set-Cookie。源码处感受一下: org.springframework.boot.actuate.trace.http.Include: // 2.4.0版本之前:包含COOKIE_HEADERS这个头 static { Set<Include> defaultIncludes = new LinkedHashSet<>(); defaultIncludes.add(Include.REQUEST_HEADERS); defaultIncludes.add(Include.RESPONSE_HEADERS); defaultIncludes.add(Include.COOKIE_HEADERS); defaultIncludes.add(Include.TIME_TAKEN); DEFAULT_INCLUDES = Collections.unmodifiableSet(defaultIncludes); } // 2.4.0版本以及之后:不包含COOKIE_HEADERS这个头 static { Set<Include> defaultIncludes = new LinkedHashSet<>(); defaultIncludes.add(Include.REQUEST_HEADERS); defaultIncludes.add(Include.RESPONSE_HEADERS); defaultIncludes.add(Include.TIME_TAKEN); DEFAULT_INCLUDES = Collections.unmodifiableSet(defaultIncludes); } 若你仍旧想保留老的习惯,那么请用配置项management.trace.http.include = cookies, errors, request-headers, response-headers自行控制。Neo4j这个版本对Neo4j的支持进行了重大调整。直接用源码来说明差异:Spring Boot 2.4.0之前版本: @ConfigurationProperties(prefix = "spring.data.neo4j") public class Neo4jProperties implements ApplicationContextAware { ... } // 无Neo4jDataProperties配置类 Spring Boot 2.4.0以及之后版本:@ConfigurationProperties(prefix = "spring.neo4j") public class Neo4jProperties { ... } @ConfigurationProperties(prefix = "spring.data.neo4j") public class Neo4jDataProperties { ... } 其它升级关注点Spring Framework 5.3:Spring Boot 2.4.0使用的是5.3.0主线分支(之前使用的5.2.x或更低)Spring Framework 5.3的新特性应该重点关注,请移步我上篇文章:Spring Framework 5.3.0正式发布,在云原生路上继续发力Spring Data 2020.0:Spring Boot 2.4.0使用的是最新发布的Spring Data 2020.0此版本的命名方式不同于之前,是因为使用了Spirng最新的release train命名方式。Spring在2020年4月份发布了最新的版本命名方式,可参考前面这篇文章:Spring改变版本号命名规则:此举对非英语国家很友好支持Java 15:此版本的Spring Boot完全支持Java 15,最小支持依旧是Java 8自定义属性名支持:当使用构造函数绑定时,属性的名称需要和参数名称保持一样。如果您想使用Java保留关键字,这可能是一个问题。如下例子: @ConfigurationProperties(prefix = "sample") public class SampleConfigurationProperties { private final String importValue; // import是Java关键字 public SampleConfigurationProperties(@Name("import") String importValue) { this.importValue = importValue; } } @Name注解是Spring Boot 2.4.0新增的注解,能标注在ElementType.PARAMETER上支持导入无扩展名的配置文件:如果您有这样的需求,现在就可以通过向Spring Boot引导提供关于内容类型的提示来导入这些文件此版本对Spring Boot的配置文件加载进行了完全重新改造,并且不向下兼容,具体参见下篇文章新增StartupEndpoint:显示有关应用程序启动的信息。此端点可以帮助您识别启动时间超过预期的bean此端点依赖于Spring Framework 5.3.0新提供的应用启动追踪新特性。具体可参考ApplicationStartup和StartupStep这个两个API是如何做追踪的新增RedisCacheMetrics:用于监控使用redis时的puts、gets、deletes以及缓存命中率等信息此指标信息默认不开启,需你增加配置spring.cache.redis.enable-statistics = true新增些Web配置项:spring.web.locale、spring.web.locale-resolver、spring.web.resources.*、management.server.base-path,这些属性既支持Servlet也支持WebFlux对应的只能用于 Spring MVC或servelt下配置项spring.mvc.locale/spring.mvc.locale-resolver/spring.resources.*/management.server.servlet.context-path均以标注为过期支持Flyway 7:这个版本升级到Flyway 7,带来了一些额外的属性。如:spring.flyway.url/user/password(开源版本);spring.flyway.cherry-pick/jdbc-properties...(团队版本)H2数据库控制台支持配置密码:可通过spring.h2.console.settings.web-admin-password属性配置通过密码访问H2控制台增强的错误分析器FailureAnalizers:现在即使你还没有创建ApplicationContext,FailureAnalizers都会生效来帮你定位错误位置处理/标注Spring Boot 2.2和2.3中过期项:按照Spring Boot的版本兼容性政策,在2.2版本已被标记为@Deprecated的在2.4.0版本会被删除,在2.3版本中被标记为@Deprecated的计划在2.5.0版本中将其移除✍总结这是A哥奉给大家的,对Spring Boot2.4.0版本新特性的介绍,希望对你有些帮助。Spring Boot 2.4.0版本的升级目标,基本和Spring Framework 5.3.0保持一致:为云原生做努力。表现在除了删除些无用类,禁止不需要的类的加载外,重点还会体现在它对配置文件加载机制的重构上,这将是下文的内容,也是本次升级的重头戏,敬请关注。Spring Boot重写了对配置文件的加载机制,并且新引入了近40个类来处理(老方式仅有区区几个类),可见其重视、重要程度。因此,为了适应未来的发展,你一定要掌握,并且越早越好,下篇将为你揭晓。
✍前言你好,我是YourBatman。北京时间2020-11-12,Spring Boot 2.4.0正式发布。2.4.0是第一个使用新版本方案的Spring Boot发行版本。注意:2.4.0版本号没有.RELEASE后缀,没有.RELEASE后缀,没有.RELEASE后缀。使用的是Spring最新的版本发布规则。此规则详解请参考上篇文章:Spring改变版本号命名规则:此举对非英语国家很友好还记得Spring Boot 2.3.0.RELEASE版本发布时那会麽?前后相差将好半年: 直达电梯:Spring Boot 2.3.0正式发布:优雅停机、配置文件位置通配符新特性一览一般来说,次版本号的升级会有点料,根据之前的爆料此次升级据说是做了大量的更新和改进。那么老规矩,作为小白鼠的我先代你玩一玩,初体验吧。也可参见官方的更新日志:Spring Boot 2.4.0 Release Notes✍正文除了刚发布的Spring Boot 2.4.0,Spring Boot 2.3.x/2.2.x仍旧是活跃的维护的版本。Spring Boot遵循的是Pivotal OSS支持策略,从发布日期起支持主要版本3年(注意:是主要版本)。下面是详情:2.3.x:支持的版本。2020.05发布,是现在的活跃的主干2.2.x:支持的版本。2019.10发布,是现在的活跃的主干2.1.x:2018.10发布,支持到2020.10月底,建议尽快升级EOL分支:2.0.x:2018.3发布,2019.4.3停止维护1.5.x:生命已终止的版本。2017.1发布,是最后一个1.x分支,2019.8.1停止维护 回忆2.3版本的新特性可能大部分小伙伴都还没用过2.3.x分支,没想到2.4.x就已发布。因此这里先对2.3.x版本的新特性,来波简单回忆:优雅停机。这是2.3.x主打的新特性:在关闭时,web服务器将不再允许新的请求,并将等待完成的请求给个宽限期让它完成。这个宽限期是可以设置的:可以使用spring.lifecycle.timeout-per-shutdown-phase=xxx来配置,默认值是30s。配置文件位置支持通配符。简单的说,如果你有MySql的配置和Redis配置的话,你就可以把他们分开来放置,这个新特性也是棒棒哒。隔离性更好目录也更加清晰 了(注意:此格式只支持放在classpath外部): 1.mysql:/config/mysql/application.properties 2.redis:/config/redis/application.properties 3.核心依赖升级。 1.Spring Data Neumann。备注:很明显这个还是旧的命名方式。在Spirng新的版本规则下,Spring Data最新版本为Spring Data 2020.0.0 2.Spring Session Dragonfruit(很明显这个也还是旧的命名方式)Spring Security 5.3Spring Framework 没有升级,使用的依旧是和Spring Boot 2.2相同的5.2.x版本 1.说明:小版本号的升级对于新特性来说一般选择性忽略关于Bean Validation:从此版本开始,spring-boot-starter-web不会再把validation带进来,所以若使用到,你需要自己添加这个spring-boot-starter-validation依赖 1. 一般来说建议你手动引入,毕竟Bean Validation的使用还是很广泛,并且真的非常非常好用做足功课后,就开始最新的Spring Boot 2.4.0之旅吧。2.4.0主要新特性全新的配置文件处理(properties/yaml)这个改变最为重磅,本次改变了配置文件的加载逻辑,旨在简化和合理化外部配置的加载方式,它可能具有不向下兼容性。Spring Boot 2.4改变了处理application.properties和application.yml文件的方式:若你只是简单的文件application.properties/yaml,那么升级对你是无缝的,你感受不到任何变化若你使用了比较复杂的文件,如application-profile.properties/yaml这种(或者使用了Spirng Cloud的配置中心、(带有分隔符----的)多yaml文件),那么默认是不向下兼容的,需要你显式的做出些更改因为配置文件隶属于程序的一部分,特别是我们现在几乎都会使用到配置中心。因此下面针对于老版本升级到Spring Boot 2.4.0做个简单的迁移指导。说明:因配置文件加载逻辑完全进行了重写,因此详细版本我放到了下文专文讲解,有兴趣可保持关注老版本版本配置属性迁移指南老版本:2.4.0之前的版本都叫老版本。Spring Boot 2.4对application.poperties/yaml的处理做了更新/升级。旨在简化和合理化外部配置的加载方式。它还提供了新功能:spring.config.import支持。所以呢,对于Spring Boot 2.4.0之前的版本(老版本)若升级到2.4.0需要做些修改,指导建议如下:方式一:恢复旧模式(不推荐)如果你还未准备好做配置迁移的修改,Spring Boot也帮你考虑到了,提供了一键切换到旧模式的“按钮”。具体做法是:只需要在Environment里增加一个属性spring.config.use-legacy-processing = true就搞定。最简的方式就是把这个属性放在application.poperties/yaml里即可。spring.config.use-legacy-processing = true 增加此配置后,Spring Boot对配置文件的解析恢复到原来模式:仍旧使用ConfigFileApplicationListener去解析。ConfigFileApplicationListener属于Spring Boot非常核心的底层代码,这次做了不向下兼容的改进,可见它对进击云原生的决心值得注意的是:此API在2.4.0已被标记为过期: // @since 1.0.0 // @deprecated since 2.4.0 in favor of {@link ConfigDataEnvironmentPostProcessor} @Deprecated public class ConfigFileApplicationListener implements EnvironmentPostProcessor, SmartApplicationListener, Ordered { ... } 按照Spring Boot的版本策略,此类将在Spring Boot 2.6.0版本被移除。因此:若不是迫不得已(时间紧急),并不建议你用兼容手法这么去做,因为这将成为技术债,迟早要还的。说明:很多RD其实只会看到当前的方便,获得利益(比如快速上线获奖),坑交给后人。我个人认为作为程序员应该有一定自我修养,自我追求,不为一时的爽而持续给团队积累债务,毕竟积重难返。方式二:按新规则迁移(推荐)若你对配置文件的使用有如下情行,那么你需要做迁移:多文档的yaml文件(带有----分隔符的文件)在Jar外使用配置文件,或者使用形如application-{xxx}.properties/yaml这种配置若在多文档yaml中使用到了spring.profiles配置项…Spring Boot 2.4.0升级对配置文件的改动是最大的,并且还不具备向下兼容性,简单的说就是从此版本开始要把Spring Boot的配置文件加载机制重学一遍(比如还增加了spring.config.import,增加了对kubernetes配置的支持等等),并且还要学会如何迁移。为了更好的描述好这个非常非常重要的知识点,下篇文章我会用专文来全面介绍 Spring Boot这套全新的配置文件加载机制,并且辅以原理,以及和过去方式的比较,帮助你更全面、更快速、更劳的掌握它,欢迎持续关注。说明:Spring Boot的配置文件加载机制非常非常重要,因为你也知道你平时开发中很大程度实际上是在跟它的配置项打交道。新的配置加载方式比老的更加优秀,适应发展,敬请期待 从spring-boot-starter-test中删除Vintage EngineSpring Boot 2.2.0版本开始就引入JUnit 5作为单元测试默认库,在此之前,spring-boot-starter-test包含的是JUnit 4的依赖,Spring Boot 2.2.0版本之后替换成了Junit Jupiter(Junit5)。Vintage Engine属于Junit5的一个模块,它的作用是:允许用JUnit 5运行用JUnit 4编写的测试,从而提供了向下兼容的能力。从2.2.0到现在经过了2个版本的迭代,到Spring Boot 2.4.0这个版本决定了把Vintage Engine从spring-boot-starter-test正式移除。因此:若你的工程仍需要对JUnit4支持,那么请手动引入依赖项(如果工程量不大,强烈建议使用JUnit5,比4好用太多): <dependency> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-core</artifactId> </exclusion> </exclusions> </dependency> 说明:其实在2.4.0之前,若你是从https://start.spring.io生成的项目其实也是不会带有vintage-engine的。只不过它是通过显式的在pom里通过exclusion标签来排除的嵌入式数据库检测改进嵌入式数据库检测机制:仅当数据库在内存中时才将其视为嵌入式数据库。所以如果使用H2、HSQL等产品,但是你是基于文件的持久性或使用的是服务器模式,那么将不会检测为内存数据库。而对于非内存数据库,你可能需要额外做如下动作:sa用户名将不会再被主动设置。所以如果你的数据库需要用户名,请增加配置项:spring.datasource.username = sa这种数据库将不会再被自动初始化,若要使用请根据需要更改spring.datasource.initialization-mode的值
✍前言你好,我是YourBatman。本文是上篇文章的续篇,个人建议可先花3分钟移步上篇文章浏览一下:5. Bean Validation声明式验证四大级别:字段、属性、容器元素、类很多人说Bean Validation只能验证单属性(单字段),但我却说它能完成99.99%的Bean验证,不信你可继续阅读本文,能否解你疑惑。版本约定Bean Validation版本:2.0.2Hibernate Validator版本:6.1.5.Final✍正文本文接上文叙述,继续介绍Bean Validation声明式验证四大级别中的:容器元素验证(自定义容器类型)以及类级别验证(也叫多字段联合验证)。据我了解,很多小伙伴对这部分内容并不熟悉,遇到类似场景往往被迫只能是一半BV验证 + 一半事务脚本验证的方式,显得洋不洋俗不俗。 本文将给出具体案例场景,然后统一使用BV来解决数据验证问题,希望可以帮助到你,给予参考之作用。自定义容器类型元素验证通过上文我们已经知道了Bean Validation是可以对形如List、Set、Map这样的容器类型里面的元素进行验证的,内置支持的容器虽然能cover大部分的使用场景,但不免有的场景依旧不能覆盖,而且这个可能还非常常用。譬如我们都不陌生的方法返回值容器Result<T>,结构形如这样(最简形式,仅供参考): @Data public final class Result<T> implements Serializable { private boolean success = true; private T data = null; private String errCode; private String errMsg; } Controller层用它包装(装载)数据data,形如这样:@GetMapping("/room") Result<Room> room() { ... } public class Room { @NotNull public String name; @AssertTrue public boolean finished; } 这个时候希望对Result<Room>里面的Room进行合法性验证:借助BV进行声明式验证而非硬编码。希望这么写就可以了:Result<@Notnull @Valid LoggedAccountResp>。显然,缺省情况下即使这样声明了约束注解也是无效的,毕竟Bean Validation根本就“不认识”Result这个“容器”,更别提验证其元素了。好在Bean Validation对此提供了扩展点。下面我将一步一步的来对此提供实现,让验证优雅再次起来。自定义一个可以从Result<T>里提取出T值的ValueExtractor值提取器Bean Validation允许我们对自定义容器元素类型进行支持。通过前面这篇文章:4. Validator校验器的五大核心组件,一个都不能少 知道要想支持自定义的容器类型,需要注册一个自定义的ValueExtractor用于值的提取。/** * 在此处添加备注信息 * * @author yourbatman * @site https://www.yourbatman.cn * @date 2020/10/25 10:01 * @see Result */ public class ResultValueExtractor implements ValueExtractor<Result<@ExtractedValue ?>> { @Override public void extractValues(Result<?> originalValue, ValueReceiver receiver) { receiver.value(null, originalValue.getData()); } } 将此自定义的值提取器注册进验证器Validator里,并提供测试代码:把Result作为一个Filed字段装进Java Bean里: public class ResultDemo { public Result<@Valid Room> roomResult; } 测试代码:public static void main(String[] args) { Room room = new Room(); room.name = "YourBatman"; Result<Room> result = new Result<>(); result.setData(room); // 把Result作为属性放进去 ResultDemo resultDemo = new ResultDemo(); resultDemo.roomResult = result; // 注册自定义的值提取器 Validator validator = ValidatorUtil.obtainValidatorFactory() .usingContext() .addValueExtractor(new ResultValueExtractor()) .getValidator(); ValidatorUtil.printViolations(validator.validate(resultDemo)); } 运行测试程序,输出:roomResult.finished只能为true,但你的值是: false完美的实现了对Result“容器”里的元素进行了验证。小贴士:本例是把Result作为Java Bean的属性进行试验的。实际上大多数情况下是把它作为方法返回值进行校验。方式类似,有兴趣的同学可自行举一反三哈在此弱弱补一句,若在Spring Boot场景下你想像这样对Result<T>提供支持,那么你需要自行提供一个验证器来覆盖掉自动装配进去的,可参考ValidationAutoConfiguration。类级别验证(多字段联合验证)约束也可以放在类级别上(也就说注解标注在类上)。在这种情况下,验证的主体不是单个属性,而是整个对象。如果验证依赖于对象的几个属性之间的相关性,那么类级别约束就能搞定这一切。这个需求场景在平时开发中也非常常见,比如此处我举个场景案例:Room表示一个教室,maxStuNum表示该教室允许的最大学生数,studentNames表示教室里面的学生们。很明显这里存在这么样一个规则:学生总数不能大于教室允许的最大值,即studentNames.size() <= maxStuNum。如果用事务脚本来实现这个验证规则,那么你的代码里肯定穿插着类似这样的代码: if (room.getStudentNames().size() > room.getMaxStuNum()) { throw new RuntimeException("..."); } 虽然这么做也能达到校验的效果,但很明显这不够优雅。期望这种case依旧能借助Bean Validation来优雅实现,下面我来走一把。相较于前面但字段/属性验证的使用case,这个需要验证的是整个对象(多个字段)。下面呀,我给出两种实现方式,供以参考。方式一:基于内置的@ScriptAssert实现虽说Bean Validation没有内置任何类级别的注解,但Hibernate-Validator却对此提供了增强,弥补了其不足。@ScriptAssert就是HV内置的一个非常强大的、可以用于类级别验证注解,它可以很容易的处理这种case:@ScriptAssert(lang = "javascript", alias = "_", script = "_.maxStuNum >= _.studentNames.length") @Data public class Room { @Positive private int maxStuNum; @NotNull private List<String> studentNames; } @ScriptAssert支持写脚本来完成验证逻辑,这里使用的是javascript(缺省情况下的唯一选择,也是默认选择)测试用例:public static void main(String[] args) { Room room = new Room(); ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room)); } 运行程序,抛错:Caused by: <eval>:1 TypeError: Cannot get property "length" of null at jdk.nashorn.internal.runtime.ECMAErrors.error(ECMAErrors.java:57) at jdk.nashorn.internal.runtime.ECMAErrors.typeError(ECMAErrors.java:213) ... 这个报错意思是_.studentNames值为null,也就是room.studentNames字段的值为null。what?它头上不明明标了@NotNull注解吗,怎么可能为null呢?这其实涉及到前面所讲到的一个小知识点,这里提一嘴:所有的约束注解都会执行,不存在短路效果(除非校验程序抛异常),只要你敢标,我就敢执行,所以这里为嘛报错你懂了吧。小贴士:@ScriptAssert对null值并不免疫,不管咋样它都会执行的,因此书写脚本时注意判空哦当然喽,多个约束之间的执行也是可以排序(有序的),这就涉及到多个约束的执行顺序(序列)问题,本文暂且绕过。例子种先给填上一个值,后续再专文详解多个约束注解执行序列问题和案例剖析。修改测试脚本(增加一个学生,让其不为null): public static void main(String[] args) { Room room = new Room(); room.setStudentNames(Collections.singletonList("YourBatman")); ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room)); } 再次运行,输出:执行脚本表达式"_.maxStuNum >= _.studentNames.length"没有返回期望结果,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman]) maxStuNum必须是正数,但你的值是: 0验证结果符合预期:0(maxStuNum) < 1(studentNames.length)。小贴士:若测试脚本中增加一句room.setMaxStuNum(1);,那么请问结果又如何呢?方式二:自定义注解方式实现虽说BV自定义注解前文还暂没提到,但这并不难,因此这里先混个脸熟,也可在阅读到后面文章后再杀个回马枪回来。自定义一个约束注解,并且提供约束逻辑的实现 @Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Constraint(validatedBy = {ValidStudentCountConstraintValidator.class}) public @interface ValidStudentCount { String message() default "学生人数超过最大限额"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } public class ValidStudentCountConstraintValidator implements ConstraintValidator<ValidStudentCount, Room> { @Override public void initialize(ValidStudentCount constraintAnnotation) { } @Override public boolean isValid(Room room, ConstraintValidatorContext context) { if (room == null) { return true; } boolean isValid = false; if (room.getStudentNames().size() <= room.getMaxStuNum()) { isValid = true; } // 自定义提示语(当然你也可以不自定义,那就使用注解里的message字段的值) if (!isValid) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("校验失败xxx") .addPropertyNode("studentNames") .addConstraintViolation(); } return isValid; } } 书写测试脚本public static void main(String[] args) { Room room = new Room(); room.setStudentNames(Collections.singletonList("YourBatman")); ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room)); } 运行程序,输出:maxStuNum必须是正数,但你的值是: 0 studentNames校验失败xxx,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman])完美,完全符合预期。这两种方式都可以实现类级别的验证,它俩可以说各有优劣,主要体现在如下方面:@ScriptAssert是内置就提供的,因此使用起来非常的方便和通用。但缺点也是因为过于通用,因此语义上不够明显,需要阅读脚本才知。推荐少量(非重复使用)、逻辑较为简单时使用自定义注解方式。缺点当然是“开箱使用”起来稍显麻烦,但它的优点就是语义明确,灵活且不易出错,即使是复杂的验证逻辑也能轻松搞定总之,若你的验证逻辑只用一次(只一个地方使用)且简单(比如只是简单判断而已),推荐使用@ScriptAssert更为轻巧。否则,你懂的~✍总结如果说能熟练使用Bean Validation进行字段、属性、容器元素级别的验证是及格60分的话,那么能够使用BV解决本文中几个场景问题的话就应该达到优秀级80分了。本文举例的两个场景:Result<T>和多字段联合验证均属于平时开发中比较常见的场景,如果能让Bean Validation介入帮解决此类问题,相信对提效是很有帮助的,说不定你还能成为团队中最靓的仔呢。
2022年05月