**了解Java時區的處理方式與應用**
開發應用系統顯示時間是常見的需求,大概不會有應用系統沒有「顯示時間」功能,前端頁面上顯示 “年/月/日 時:分:秒” ,大概的做法是取 DB中的值直接吐出來當字串顯示,或是以 long 加上 dateFormat 物件來以格式化字串,但是這個做法若扯上「時區」的話,問題就會變得再更複雜一些。以時區來說,通常會有這幾個需求:
1. UI上的時間固定以 zh_TW 時區顯示,User 身在美國跟在台灣要在 UI 上看到同樣的時間
2. UI 上的時間會根據使用者的瀏覽器時區設置不同,User 身在美國跟在台灣看到的時間會不一樣
上述只是顯示的部分而已,另外一個部分是與後端API 的溝通,要正確處理時間跟時區並不是一件簡單的事,前端、後端與SA流程及DB欄位定義都要搭配。
Timestamp
我們先來看看 java 中的timestamp寫法,印出當前時間這個數字怎麼解讀它呢?它表示「從 UTC+0 時區的 1970 年 1 月 1 號 0 時 0 分 0 秒開始,總共過了多少ms」
java code: |
System.out.println(System.currentTimeMillis()); //1658730994161 |
很明顯地,這個數字不人性化,所以一定要格式化,那麼直覺來想 ”SimpleDateFormat” 它就可以顯示yyyy-MM-dd hh:mm:ss了,只是這樣的字串我們把它帶到上述的情境中,我在美國看到 yyyy-MM-dd hh:mm:ss 和在台灣看到 yyyy-MM-dd hh:mm:ss 是一樣的意思嗎? 顯然不是,那有沒有一種全球統一的表示方法呢?有的...它就是 UTC 格式或是說它是 ISO 8601格式,它被定義在 “RFC 3339” 文件中,主要是上述的文字格式中加入了 “時區” 概念,基本的格式長這樣:
1. T 就代表 full date-time
2. Z 就代表 UTC +0,格林威治時間
3. 如果要其他時區可以這樣寫:2022-07-25T23:59:59+08:00,代表 +8 時區或是台北,格林威治時間+8小時
因此2022-07-25T23:59:59+08:00 等於 2022-07-25T15:59:59+00:00 雖然時間不一樣,但是加了時區這二組就表示同一個時間因為時區是相對概念,一切以UTC為基準。那麼我們來想一個實際的問題,例如:有一個 long 的時間值加上系統預設時區,那麼我們如何來做資料的呈現呢?
java code: |
long nowlong = System.currentTimeMillis(); // 我的電腦現是 2022-07-28 17:03:54
System.out.println("取出 long:\t" + nowlong);
System.out.println("......Long 轉 LocalDateTime");
System.out.println("系統預設時區 : " + ZoneId.systemDefault());
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(nowlong), ZoneId.systemDefault());
System.out.println("時區 - 日期時間格式如下:");
System.out.println(localDateTime.atZone(ZoneId.systemDefault())); //記得指定時區
System.out.println(localDateTime.atZone(ZoneId.of("UTC"))); //指定時區, ex: Asia/Taipei
System.out.println(localDateTime); //未指定 |
output: |
取出 long: 1658999034770
......Long 轉 LocalDateTime
系統預設時區 : Asia/Taipei
時區 - 日期時間格式如下:
2022-07-28T17:03:54.770+08:00[Asia/Taipei]
2022-07-28T17:03:54.770Z[UTC]
2022-07-28T17:03:54.770 |
由程式碼結果來看,加上時區似乎只是在字串中加入時區的字串標記,所以使用 localDateTime.atZone(...) ,只能是格式化字串,沒有辦法移到真正的時區時間,我們比較想要取「現在台北時間」後轉成「UTC時間」那要如何操作呢?答案是使用ZoneDateTime物件,顧名思義它已加上了Zone概念,所以並不是單純的Local時區,請看以下程式範例:
java code: |
…. 承上....
ZonedDateTime zonedSys = localDateTime.atZone(ZoneId.systemDefault()); // localDateTime 指定 ZoneId 後變成 ZoneDateTime 就可位移
ZonedDateTime utcZone = zonedSys.withZoneSameInstant(ZoneId.of("UTC")); //ZoneDateTime 位移 到 UTC
System.out.println("位移 到 UTC = " + utcZone);
System.out.println("位移 到 UTC, 轉為 long = " + utcZone.toInstant().toEpochMilli()); // long |
output: |
取出 long: 1659000584080
......Long 轉 LocalDateTime
系統預設時區 : Asia/Taipei
時區 - 日期時間格式如下:
2022-07-28T17:29:44.080+08:00[Asia/Taipei]
2022-07-28T17:29:44.080Z[UTC]
2022-07-28T17:29:44.080
位移 到 UTC = 2022-07-28T09:29:44.080Z[UTC]
位移 到 UTC, 轉為 long = 1659000584080 |
使用ZoneDateTime 位移 到 UTC 後,觀察它們的小時部分,發現UTC 真的少了8小時,且由 ZoneDateTime轉出的 long 值與 System.currentTimeMillis() 一模一樣,由此可知 System.currentTimeMillis() 就是 UTC 的 long 值,這點與 Ttimestamp 的定義相同。所以說若我們的系統想要記錄統一時間,可以直接記錄 long 這個長整數,之後再由格式轉換來顯示及呈現即可,那麼接下來就是「LocalDateTime、ZoneDateTime、Long、String 相互轉換」要處理的問題了。
java code: |
DateTimeFormatter dateTimeFormatter1 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
DateTimeFormatter dateTimeFormatter2 = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDateTime localDateTime = LocalDateTime.parse("2019-07-31 00:00:00",dateTimeFormatter1);
LocalDate localDate = LocalDate.parse("2019-07-31",dateTimeFormatter2);
Date date = Date.from(LocalDateTime.parse("2019-07-31 00:00:00",dateTimeFormatter1).atZone(ZoneId.systemDefault()).toInstant());
String strDateTime = "2019-07-31 00:00:00";
String strDate = "2019-07-31";
Long timestamp=1564502400000l;
/** LocalDateTime 轉 LocalDate */
System.out.println("LocalDateTime 轉 LocalDate: "+localDateTime.toLocalDate());
/** LocalDateTime 轉 Long */
System.out.println("LocalDateTime 轉 Long: "+localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
/** LocalDateTime 轉 Date */
System.out.println("LocalDateTime 轉 Date: "+Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()));
/** LocalDateTime 轉 String */
System.out.println("LocalDateTime 轉 String: "+localDateTime.format(dateTimeFormatter1));
System.out.println("-------------------------------");
/** LocalDate 轉 LocalDateTime */
System.out.println("LocalDate 轉 LocalDateTime: "+LocalDateTime.of(localDate,LocalTime.parse("00:00:00")));
/** LocalDate 轉 Long */
System.out.println("LocalDate 轉 Long: "+localDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
/** LocalDate 轉 Date */
System.out.println("LocalDate 轉 Date: "+Date.from(localDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()));
/** LocalDate 轉 String */
System.out.println("LocalDateTime 轉 String: "+localDateTime.format(dateTimeFormatter2));
System.out.println("-------------------------------");
/** Date 轉 LocalDateTime */
System.out.println("Date 轉 LocalDateTime: "+LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()));
/** Date 轉 Long */
System.out.println("Date 轉 Long: "+date.getTime());
/** Date 轉 LocalDate */
System.out.println("Date 轉 LocalDateTime: "+LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()).toLocalDate());
/** Date 轉 String */
SimpleDateFormat sdf =new SimpleDateFormat("yyyy-MM-dd HH:mm:ss SSS" );
System.out.println("Date 轉 String: "+sdf.format(date));
System.out.println("-------------------------------");
/** String 轉 LocalDateTime */
System.out.println("String 轉 LocalDateTime: "+LocalDateTime.parse(strDateTime,dateTimeFormatter1));
/** String 轉 LocalDate */
System.out.println("String 轉 LocalDate: "+LocalDateTime.parse(strDateTime,dateTimeFormatter1).toLocalDate());
System.out.println("String 轉 LocalDate: "+LocalDate.parse(strDate,dateTimeFormatter2));
/** String 轉 Date */
System.out.println("String 轉 Date: "+Date.from(LocalDateTime.parse(strDateTime,dateTimeFormatter1).atZone(ZoneId.systemDefault()).toInstant()));
System.out.println("-------------------------------");
/** Long 轉 LocalDateTime */
System.out.println("Long 轉 LocalDateTime:"+LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()));
/** Long 轉 LocalDate */
System.out.println("Long 轉 LocalDate:"+LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).toLocalDate());
/** Long 轉 ZoneDateTime */
localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
System.out.println("Long 轉 ZoneDateTime:"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用
System.out.println("Long 轉 ZoneDateTime:"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
ZonedDateTime utcZone = ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).withZoneSameInstant(ZoneId.of("UTC")); //ZoneDateTime 位移 到 UTC
System.out.println("Long 轉 ZoneDateTime:"+ utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用
System.out.println("Long 轉 ZoneDateTime:"+ utcZone.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
System.out.println("-------------------------------");
System.out.println("ms = 123" );
timestamp += 123;
localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
System.out.println("Long 轉 ZoneDateTime:"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用
System.out.println("Long 轉 ZoneDateTime:"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
utcZone = ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).withZoneSameInstant(ZoneId.of("UTC")); //ZoneDateTime 位移 到 UTC
System.out.println("Long 轉 ZoneDateTime:"+ utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用
System.out.println("Long 轉 ZoneDateTime:"+ utcZone.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
System.out.println("-------------------------------");
System.out.println("Long 轉 ZoneDateTime (JS可用) :"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用
System.out.println("Long 轉 ZoneDateTime (JS可用) :"+ utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));// 可給 JS 使用
System.out.println("-------------------------------");
/** ISO 8601 String 轉 ZoneDateTime */
strDateTime = "2022-07-28T05:59:59.000+08:00";
System.out.println("...標準格式解析【不用formatter】 : " + strDateTime);
System.out.println("String 轉 ZonedDate: "+ZonedDateTime.parse(strDateTime));
strDateTime = "2022-07-28T05:59:59.000Z";
System.out.println("...標準格式解析【不用formatter】 : " + strDateTime);
System.out.println("String 轉 ZonedDate: "+ZonedDateTime.parse(strDateTime));
strDateTime = "20220728T000000Z";
System.out.println("...自訂格式解析 'Z' : " + strDateTime);
DateTimeFormatter formatter1 = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmssz");
System.out.println("String 轉 ZonedDate: "+ZonedDateTime.parse(strDateTime, formatter1));
strDateTime = "20220728T000000+0000";
System.out.println("...自訂格式解析 +0800 : " + strDateTime);
DateTimeFormatter formatter2 = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmssZ");
System.out.println("String 轉 ZonedDate: "+ZonedDateTime.parse(strDateTime, formatter2));
|
output: |
LocalDateTime 轉 LocalDate: 2022-07-28
LocalDateTime 轉 Long: 1658959199000
LocalDateTime 轉 Date: Thu Jul 28 05:59:59 CST 2022
LocalDateTime 轉 String: 2022-07-28 05:59:59
-------------------------------
LocalDate 轉 LocalDateTime: 2022-07-28T00:00
LocalDate 轉 Long: 1658937600000
LocalDate 轉 Date: Thu Jul 28 00:00:00 CST 2022
LocalDateTime 轉 String: 2022-07-28
-------------------------------
Date 轉 LocalDateTime: 2022-07-28T05:59:59
Date 轉 Long: 1658959199000
Date 轉 LocalDateTime: 2022-07-28
Date 轉 String: 2022-07-28 05:59:59 000
-------------------------------
String 轉 LocalDateTime: 2022-07-28T05:59:59
String 轉 LocalDate: 2022-07-28
String 轉 LocalDate: 2022-07-28
String 轉 Date: Thu Jul 28 05:59:59 CST 2022
-------------------------------
Long 轉 LocalDateTime:2022-07-28T05:59:59
Long 轉 LocalDate:2022-07-28
Long 轉 ZoneDateTime:2022-07-28T05:59:59+08:00
Long 轉 ZoneDateTime:2022-07-28T05:59:59+08:00[Asia/Taipei]
Long 轉 ZoneDateTime:2022-07-27T21:59:59Z
Long 轉 ZoneDateTime:2022-07-27T21:59:59Z[UTC]
-------------------------------
ms = 123
Long 轉 ZoneDateTime:2022-07-28T05:59:59.123+08:00
Long 轉 ZoneDateTime:2022-07-28T05:59:59.123+08:00[Asia/Taipei]
Long 轉 ZoneDateTime:2022-07-27T21:59:59.123Z
Long 轉 ZoneDateTime:2022-07-27T21:59:59.123Z[UTC]
-------------------------------
Long 轉 ZoneDateTime (JS可用) :2022-07-28T05:59:59.123+08:00
Long 轉 ZoneDateTime (JS可用) :2022-07-27T21:59:59.123Z
-------------------------------
...標準格式解析【不用formatter】 : 2022-07-28T05:59:59.000+08:00
String 轉 ZonedDate: 2022-07-28T05:59:59+08:00
...標準格式解析【不用formatter】 : 2022-07-28T05:59:59.000Z
String 轉 ZonedDate: 2022-07-28T05:59:59Z
...自訂格式解析 'Z' : 20220728T000000Z
String 轉 ZonedDate: 2022-07-28T00:00Z
...自訂格式解析 +0800 : 20220728T000000+0000
String 轉 ZonedDate: 2022-07-28T00:00Z |
API 回傳顯示問題
假設前端單純只是顯示API時間,那麼前端只要在call API 時傳入locale值(Ex:zh_TW),那麼後端就可以利用 timestamp(long) + locale(zh_TW) 計算出前端頁面所需要的字串值嗎?答案是:不能。因為沒有“time zone to locale”之類的東西。那是因為有些個國家擁有許多個時區(例如美國)。
時區是一個相對UTC的概念。需要考慮實際的應用情況才能決定實作做方法,假如我是一個旅客,預計要搭台灣高鐵 2022-7-30,下午1:30前往左營的列車。那麼 API 回傳給 Browser 必需使用標準ISO格式 2022-07-30T13:30:00+08:00,而 Browser 必需轉換為人類習慣的格式,ex: '2022-07-30 13:30' ,這裡小弟推薦使用 JavaScript(https://day.js.org/) Day.js Timezone 外掛套件,此套件語法如下:dayjs('2022-07-30T13:30:00+08:00').format('HH:mm') 時,若我是台彎人那不會有問題,因為它預設會使用設備的 Timezone,可以用它來查詢 console.log('目前時區 => ' + dayjs.tz.guess()),所以時間會是正常的 '13:30',但若我是日本人那麼出現的時間就會是 '14:30',因為日本時區比台灣多了一小時,所以前端在撰寫時要採用顯式的指定時區 'Asia/Taipei' or '+08:00'。例如:
dayjs('2022-07-30T13:30:00+08:00')
.tz('Asia/Taipei')
.format('HH:mm:ss')
API to JavaScript 程式如下:
Java Code: |
long timestamp = 1659159000000L; //2022-07-30T13:30:00.000+08:00
System.out.println("API to JavaScript");
/** Long 轉 ZoneDateTime */
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault());
System.out.println("Long 轉 ZoneDateTime:"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
System.out.println();
System.out.println("...以下可傳給 JavaScript");
System.out.println("Long 轉 ZoneDateTime:"+ ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用,2022-07-30T13:30:00+08:00
System.out.println();
ZonedDateTime utcZone = ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).withZoneSameInstant(ZoneId.of("UTC")); //ZoneDateTime 位移 到 UTC
System.out.println("...以下可傳給 JavaScript");
System.out.println("Long 轉 ZoneDateTime:"+ utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); // 可給 JS 使用,2022-07-30T05:30:00Z
System.out.println();
System.out.println("系統預設時區 : " + ZoneId.systemDefault()); |
API to JavaScript
Long 轉 ZoneDateTime:2022-07-30T13:30:00+08:00[Asia/Taipei]
...以下可傳給 JavaScript
Long 轉 ZoneDateTime:2022-07-30T13:30:00+08:00
...以下可傳給 JavaScript
Long 轉 ZoneDateTime:2022-07-30T05:30:00Z
系統預設時區 : Asia/Taipei |
JavaScript Code 使用線上editor https://playcode.io/javascript/ => / index.html |
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.1/dayjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.1/plugin/utc.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.1/plugin/timezone.min.js"></script>
<script>
dayjs.extend(window.dayjs_plugin_utc);
dayjs.extend(window.dayjs_plugin_timezone);
</script>
<body>
<h1 id="header"></h1>
<script src="src/script.js"></script>
</body> |
https://playcode.io/javascript/ => / script.js |
console.log(
'收到 API 回傳UTC (2022-07-30T05:30:00Z) 一定要顯示設定 tz(), 才能保證訂票時間為台灣高鐵搭車時間 => '+
dayjs('2022-07-30T05:30:00Z')
.tz('Asia/Taipei')
.format('HH:mm:ss')
)
console.log(
'收到 API 回傳 (2022-07-30T13:30:00+08:00) 一定要顯示設定 tz(), 才能保證訂票時間為台灣高鐵搭車時間 => '+
dayjs('2022-07-30T13:30:00+08:00')
.tz('Asia/Taipei')
.format('HH:mm:ss')
) |
CONSONE: |
收到 API 回傳UTC (2022-07-30T05:30:00Z) 一定要顯示設定 tz(), 才能保證訂票時間為台灣高鐵搭車時間 => 13:30:00 收到 API 回傳 (2022-07-30T13:30:00+08:00) 一定要顯示設定 tz(), 才能保證訂票時間為台灣高鐵搭車時間 => 13:30:00 |
JS 傳送時間給後端API
承上搭台灣高鐵範例,前端收到 UTC 格式 (2022-07-30T05:30:00Z),而旅客看到 (13:30:00) 並且下了訂單,此時JavaScript 應回傳給 Java以下四種格式, Java 收到資料後轉換為 Long 的方法:
UTC |
2022-07-30T05:30:00Z |
String strDateTime = "2022-07-30T05:30:00Z"; ZonedDateTime.parse(strDateTime).toInstant().toEpochMilli(); //1659159000000 |
Asia/Taipei |
2022-07-30T13:30:00+08:00 |
ZonedDateTime.parse(strDateTime).toInstant().toEpochMilli(); //1659159000000 |
timestamp |
1659159000000 |
不用轉 |
無時區 |
2022-07-30 13:30:00 【千萬不要傳這種】 |
【千萬不要傳這種】 |
資料儲存方式
直接存入 long 值或是timestamp,這方式不會有任何 storage的限制,不論是RDB/NoSQL/File/JSON….之類的,都不會有問題,只是人類不好看,但效率高且可進行大小於的比較判斷。如果一定要有好看的格式建議使用位移法生成字串後再另存。
Java Code: |
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(1659159000000), ZoneId.systemDefault());
ZonedDateTime utcZone = ZonedDateTime.of(localDateTime, ZoneId.systemDefault()).withZoneSameInstant(ZoneId.of("UTC")); //ZoneDateTime 位移 到 UTC
System.out.println("Long 轉 ZoneDateTime:"+ utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME).toString()); // 可給 JS 使用,2022-07-30T05:30:00Z |
結論
不同的程式語言框架大部分都有提供轉換的方法,所以千萬不要自已人工的+8小時去動手做,那會是災難的開始,而言以上就是由前端到後端的時區統一處理方法,本文最後附上 Java 可用的 TimeZoneUtil.java 希望您會喜歡。
package tpi.dgrv4.codec.utils;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
public class TimeZoneUtil {
public static String long2UTCstring(long timestamp) {
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.of("UTC"));
ZonedDateTime utcZone = ZonedDateTime.of(localDateTime, ZoneId.of("UTC"));
String str = utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
return str;
}
public static String long2AnyISO8601string(long timestamp, ZoneId zoneid) {
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.of("UTC"));
ZonedDateTime utcZone = ZonedDateTime.of(localDateTime, ZoneId.of("UTC")).withZoneSameInstant(zoneid);
String str = utcZone.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
return str;
}
public static long utc2long(String UTCString) {
ZonedDateTime x = ZonedDateTime.parse(UTCString);
return x.toInstant().toEpochMilli();
}
public static long anyISO86012long(String ISOString) {
return utc2long(ISOString);
}
}
Reference:
1. https://blog.techbridge.cc/2020/12/26/javascript-date-time-and-timezone/
2. https://datatracker.ietf.org/doc/html/rfc3339
3. https://www.cnblogs.com/puke/p/11314431.html
4. https://lokalise.com/blog/date-time-localization/
5. https://playcode.io/javascript/
6. https://day.js.org/docs/en/timezone/timezone