都什么年代了你還在用 Date
前言
上篇文章搞清楚了時(shí)區(qū),這篇文章就主要來(lái)談一談 Java 中處理日期時(shí)間用什么 API 比較好。我本來(lái)不準(zhǔn)備寫(xiě)這篇文章的,因?yàn)槲矣X(jué)得 Java17 都特么出來(lái)了,大家對(duì) Java8 提供的時(shí)間日期 API 都很熟悉了。但是經(jīng)過(guò)我調(diào)研,很多中小公司還在用老版本的 Date 來(lái)處理時(shí)間日期,視 Java8 提供的時(shí)間日期 API 于無(wú)物,所以還是想來(lái)推薦一下新一代的時(shí)間日期 API,希望對(duì)大家有幫助。
傳統(tǒng)的 Date
老版本的 Date 相信大家都很熟悉了,這里就簡(jiǎn)單介紹幾個(gè)點(diǎn)
可觀不可觸的時(shí)區(qū)
對(duì)于老版本的 Date、SimpleDateFormat 相信大家都很熟悉,值得注意的是,你是無(wú)法直接設(shè)置 Date 的時(shí)區(qū)信息的,但與之矛盾的是我們?cè)诖a中從數(shù)據(jù)庫(kù)讀取一個(gè)帶時(shí)區(qū)的時(shí)間,例如:2021-11-01 13:50:47.138494+00 ,它卻能夠自動(dòng)解析成當(dāng)前服務(wù)器所在時(shí)區(qū)的時(shí)間封裝在 Date 對(duì)象中。其實(shí)這是它的一個(gè)成員變量 private transient BaseCalendar.Date cdate 去做的事情,由于 Unix 時(shí)間戳是和時(shí)區(qū)無(wú)關(guān)的,所以在從數(shù)據(jù)庫(kù)讀取時(shí)它會(huì)將數(shù)據(jù)庫(kù)帶有時(shí)區(qū)時(shí)間轉(zhuǎn)換為 Unix 時(shí)間戳,然后在用這個(gè)時(shí)間戳轉(zhuǎn)換為當(dāng)前服務(wù)器所在時(shí)區(qū)的時(shí)間,并且攜帶著時(shí)區(qū)信息。
其實(shí)我們可以看一下 Date 構(gòu)造方法源碼,他就是獲取的當(dāng)前 Unix 時(shí)間戳。
public?Date()?{
????this(System.currentTimeMillis());
}
復(fù)雜的時(shí)區(qū)轉(zhuǎn)換計(jì)算
如果我們需要顯示一個(gè) Date 對(duì)象在不同時(shí)區(qū)的時(shí)間,那么我們需要通過(guò) SimpleDateFormat 來(lái)實(shí)現(xiàn)
SimpleDateFormat?sdf?=?new?SimpleDateFormat("yyyy-MM-dd?HH:mm:ss");
Date?date?=?new?Date();//默認(rèn)北京時(shí)區(qū)的時(shí)間
System.out.println("北京時(shí)區(qū):"?+?sdf.getTimeZone());
System.out.println("北京時(shí)區(qū)時(shí)間:"?+?sdf.format(date));
sdf.setTimeZone(TimeZone.getTimeZone(ZoneId.of("Asia/Jakarta")));
System.out.println("雅加達(dá)時(shí)區(qū):"?+?sdf.getTimeZone());
System.out.println("雅加達(dá)時(shí)區(qū)時(shí)間:"?+?sdf.format(date));
如果你想得到的不是字符串而是轉(zhuǎn)換為另一個(gè)時(shí)區(qū)的 Date 對(duì)象,那么你還需要再?gòu)淖址崔D(zhuǎn)到 Date ......這無(wú)疑是非常麻煩的
復(fù)雜時(shí)間間隔計(jì)算
要說(shuō) Date 最難受的地方之一就是兩個(gè)日期的時(shí)間差計(jì)算,之前 JDK 并沒(méi)有提供直接的 API 來(lái)計(jì)算兩個(gè) Date 的間隔。通常我們是把 Date 轉(zhuǎn)成時(shí)間戳之后進(jìn)行操作
Date?date1?=?new?Date();
TimeUnit.SECONDS.sleep(10);
Date?date2?=?new?Date();
long?time1?=?date1.getTime();
long?time2?=?date2.getTime();
System.out.println("間隔秒數(shù):"?+?(time2?-?time1)?/?1000);
System.out.println("間隔小時(shí)數(shù):"?+?(time2?-?time1)?/?(1000?*?3600));
//...
是不是很麻煩?如果你并不覺(jué)得麻煩,那么你只是沒(méi)有見(jiàn)過(guò)更好的方式。我們來(lái)看看新版 API 怎么來(lái)做(后面會(huì)詳細(xì)介紹)
Duration?duration?=?Duration.between(time2,?time1);
long?days?=?duration.toDays();//獲取天數(shù)間隔
long?hours?=?duration.toHours();//獲取小時(shí)間隔
//...
這樣是不是很簡(jiǎn)單!
數(shù)據(jù)庫(kù)到底要不要存儲(chǔ)時(shí)區(qū)信息
在談新版的時(shí)間 API 之前,我們先要搞清楚一個(gè)問(wèn)題,那就是你真的有必要把時(shí)區(qū)信息存儲(chǔ)到數(shù)據(jù)庫(kù)嗎?其實(shí)我覺(jué)得對(duì)于絕大多數(shù)的公司應(yīng)該都是不需要的,下我以我們公司印度尼西亞業(yè)務(wù)為例來(lái)分析這個(gè)問(wèn)題。
存儲(chǔ)時(shí)區(qū)
我們目前是先在代碼中獲取當(dāng)前服務(wù)器(我們服務(wù)器在亞馬遜 UTC-3 )所在時(shí)區(qū)的 Date 對(duì)象,在存入數(shù)據(jù)庫(kù)時(shí),在數(shù)據(jù)庫(kù)層面將其轉(zhuǎn)換為 UTC+8 時(shí)區(qū)的時(shí)間存儲(chǔ)到數(shù)據(jù)庫(kù)中,例如 2021-10-24 15:47:47.138494-03 存到數(shù)據(jù)庫(kù)中是 2021-10-25 02:47:47.138494+08,然后在前端頁(yè)面可以直接調(diào)用 API 根據(jù)帶時(shí)區(qū)的時(shí)間計(jì)算出前端設(shè)備當(dāng)?shù)貢r(shí)區(qū)的時(shí)間。
xxx//假如設(shè)備在印度尼西亞,那么前端?API?獲得的時(shí)間就是?2021-10-25?01:47:47.138494
不過(guò)不是很明白,我們既然存時(shí)區(qū),那么應(yīng)該也要存印度尼西亞的時(shí)區(qū)啊......畢竟我們的業(yè)務(wù)在印尼,也不在北京......
不存儲(chǔ)時(shí)區(qū)
正常來(lái)說(shuō)服務(wù)器所在地一般是唯一的,即使有 100 個(gè)服務(wù)實(shí)例來(lái)做負(fù)載均衡,總不可能出現(xiàn)一半服務(wù)器在美國(guó),一半在中國(guó)吧?那么代碼中的時(shí)間日期操作都是默認(rèn)使用服務(wù)器所在地時(shí)區(qū)(UTC-3),既然如此那么我們數(shù)據(jù)庫(kù)就可以不存儲(chǔ)時(shí)區(qū)信息,只存儲(chǔ)一個(gè)時(shí)間描述例如 2021-10-24 13:50:47.138494,它本身沒(méi)有時(shí)區(qū),但是我們都知道它的默認(rèn)時(shí)區(qū)就是 UTC-3 。
這樣在前端代碼中只需要調(diào)用 API 的時(shí)候帶上帶上該時(shí)間的所屬時(shí)區(qū) UTC-3 即可算出前端設(shè)備當(dāng)?shù)貢r(shí)區(qū)的時(shí)間。
xxx//假如設(shè)備在印度尼西亞,那么前端?API?獲得的時(shí)間就是?2021-10-25?01:47:47.138494
而且這個(gè)不帶時(shí)區(qū)的時(shí)間不會(huì)受數(shù)據(jù)庫(kù)時(shí)區(qū)的影響,無(wú)論你把數(shù)據(jù)庫(kù)設(shè)置成哪個(gè)時(shí)區(qū),它都不會(huì)變化。這種方式就是整個(gè)業(yè)務(wù)里我們把所有時(shí)區(qū)都干掉,只留一個(gè)服務(wù)器時(shí)區(qū),當(dāng)有任何涉及時(shí)區(qū)的業(yè)務(wù)功能時(shí),只需要把源時(shí)間換算成服務(wù)器所在時(shí)區(qū)的時(shí)間即可。
總結(jié)
不存儲(chǔ)時(shí)區(qū)還有一種情況就是有些程序員喜歡存時(shí)間戳,不得不吐槽一下我覺(jué)得這是最 low 的方式之一(也許你能說(shuō)出它僅有的個(gè)別優(yōu)點(diǎn),但我不接受反駁)。雖然時(shí)間戳是和時(shí)區(qū)無(wú)關(guān)的,但是它的可讀性真的太差了......而且代碼中也不好進(jìn)行操作
其實(shí)對(duì)比一下就能發(fā)現(xiàn)數(shù)據(jù)庫(kù)存不存儲(chǔ)時(shí)區(qū)信息,對(duì)于前后端的操作區(qū)別不大的。而且我覺(jué)得絕大多數(shù)業(yè)務(wù)場(chǎng)景,不存儲(chǔ)時(shí)區(qū)相對(duì)更簡(jiǎn)單!
Java8 的時(shí)間日期 API
LocalDate、LocalTime、LocalDateTime
看源碼是一個(gè)好習(xí)慣,看源碼注釋更是一個(gè)好習(xí)慣。這三個(gè)類(lèi)的注釋說(shuō)的很清楚,首先這些類(lèi)是線程安全的,對(duì)于它的任何操作都會(huì)產(chǎn)生一個(gè)新的實(shí)例,這和 String 類(lèi)是一樣的。其次它不存儲(chǔ)或表示時(shí)區(qū)。相反,它是對(duì)日期時(shí)間的描述。
值得注意的是,它不存儲(chǔ)時(shí)區(qū),但不代表它沒(méi)有時(shí)區(qū),細(xì)品這句話(huà)!通過(guò)一張圖來(lái)理解三者的關(guān)系

下面以 LocalDateTime 為例簡(jiǎn)單介紹下用法
LocalDateTime?time1?=?LocalDateTime.now();
LocalDateTime?time2?=?LocalDateTime.now();
time1.isAfter(time2);?time1.isBefore(time2);//比較時(shí)間
time1.plusDays(1L);?time1.minusHours(1L);//加減時(shí)間日期
LocalDateTime.parse("2021-11-19T15:16:17");//解析時(shí)間
LocalDateTime.of(2019,?11,?30,?15,?16,?17);//指定日期時(shí)間
LocalDateTime.now(ZoneId.of("Asia/Jakarta"));//其他時(shí)區(qū)相對(duì)此服務(wù)器時(shí)區(qū)的時(shí)間
//...
如果你可以不在數(shù)據(jù)庫(kù)中存儲(chǔ)時(shí)區(qū)信息的話(huà),那么請(qǐng)使用這個(gè)類(lèi)。如果你一定要存儲(chǔ),那么也請(qǐng)使用下面的 OffsetDateTime 而不要使用 Date 。
OffsetDateTime
帶有時(shí)區(qū)偏移量的日期時(shí)間類(lèi),相當(dāng)于 OffsetDateTime = LocalDateTime + ZoneOffset

大多數(shù)情況下我們使用帶有偏移量的日期時(shí)間已經(jīng)能夠滿(mǎn)足需求。
ZonedDateTime
真正帶有完整時(shí)區(qū)信息的日期時(shí)間類(lèi)

Duration、Period
這兩個(gè)類(lèi)是代表一段時(shí)間或者說(shuō)是兩個(gè)時(shí)間的間隔,以 Duration 為例,試想在 Java8 之前你有一個(gè)業(yè)務(wù)要表示一個(gè)令牌的有效期為 7 天,那么通常的做法可能是存儲(chǔ)令牌的創(chuàng)建時(shí)間,然后在代碼中用系統(tǒng)當(dāng)前時(shí)間減去令牌創(chuàng)建時(shí)間和 7 天做比較,例如
long?duration?=?System.currentTimeMillis()?-?token.getCreateTime().getTime();
if?(duration?7?*?24?*?60?*?60?*?1000)?{?//令牌合法}
但是在使用 Duration 之后,
Duration?duration?=?Duration.between(token.getCreateTime(),?LocalDateTime.now());
boolean?negative?=?duration.minus(effective).isNegative();//是否過(guò)期
其次 Duration 在 SpringBoot 項(xiàng)目中,配置也很方便
token:
??effective-time:?7d??#?d:天?,?h:小時(shí)?,?m:分鐘?,?s:秒
在實(shí)體類(lèi)中,可以使用 @ConfigurationProperties 或者 @Value 將它直接映射成 Duration 對(duì)象,當(dāng)然這依賴(lài)于 SpringBoot 中提供的豐富的類(lèi)型轉(zhuǎn)換器。下一篇文章會(huì)介紹
TemporalAdjuster 和 TemporalAdjusters
第一眼看到這兩個(gè)類(lèi)是不是想起了熟悉的 Collection 和 Collections ,與之類(lèi)似這兩個(gè)類(lèi)是時(shí)間矯正器接口和時(shí)間矯正器的工具類(lèi)。新版日期時(shí)間類(lèi)幾乎都實(shí)現(xiàn)了 TemporalAdjuster ,以便于針對(duì)所有日期時(shí)間都可以對(duì)其進(jìn)行計(jì)算得到另外一個(gè)時(shí)間,例如
LocalDateTime.now().with(TemporalAdjusters.firstDayOfMonth());//當(dāng)月第一天LocalDateTime.now().with(TemporalAdjusters.firstDayOfNextMonth());//下個(gè)月第一天LocalDateTime.now().with(TemporalAdjusters.dayOfWeekInMonth(2,DayOfWeek.MONDAY));//第N個(gè)星期幾LocalDateTime.now().with(TemporalAdjusters.next(DayOfWeek.MONDAY));//下個(gè)星期幾//...
有這么豐富的 API ,你還需要寫(xiě)一堆日期時(shí)間的工具類(lèi)嗎?
Date 和 LocalDateTime 互轉(zhuǎn)
不可否認(rèn)存在一種現(xiàn)象就是你的項(xiàng)目一直用的都是 Date,而 leader 又不愿意花費(fèi)時(shí)間精力去升級(jí),或者老的業(yè)務(wù)限制的情況,那么某些場(chǎng)景下你可以使用 Java8 提供的 Instant 將兩者互轉(zhuǎn)來(lái)簡(jiǎn)化一些業(yè)務(wù)代碼操作
LocalDateTime.ofInstant(new?Date().toInstant(),?ZoneId.systemDefault());//Date?轉(zhuǎn)?LocalDateTime
Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant());//?LocalDateTime?轉(zhuǎn)?Date
總結(jié)
Java8 的時(shí)間日期工具還有很多用法,這里就不一一介紹了,總之 Date 有的功能 LocalDateTime 都有,Date 沒(méi)有的功能,LocalDateTime 還有很多。使用新版的日期時(shí)間,幾乎是不存在原來(lái)的 DateUtil 的。所以還需要我告訴你選誰(shuí)嗎~~
結(jié)語(yǔ)
人們總是對(duì)于自己熟悉的東西持有傾向,對(duì)于不熟悉的新事物往往會(huì)抵觸,曾經(jīng)我也不止一次的抵觸我親愛(ài)的架構(gòu)師讓我們更換新的技術(shù)組件,但后來(lái)我都愛(ài)上了這些新的技術(shù)。

抵觸新技術(shù)不是一個(gè)優(yōu)秀程序員該有,這會(huì)阻礙你的成長(zhǎng)。新技術(shù)的出現(xiàn)往往是彌補(bǔ)老技術(shù)的缺陷,沒(méi)有哪個(gè)組織會(huì)花費(fèi)人力物力出一個(gè)廢物組件......所以每當(dāng)有新技術(shù)組件出現(xiàn)時(shí),請(qǐng)嘗試它,也許會(huì)有意想不到的收獲!
