本人前段时间经历了一个全球化的报表项目(java+mysql),刚开始业务只在国内开展,所有报表用户都是中国人,涉及时间/日期的数据,统一用北京时间即可。后来业务逐渐扩大到海外市场,很多国外用户也会使用该系统,这样默认用北京时间来显示就不太友好了。
仔细分析一下,主要是几个关键点:
一、数据查询
当中国用户来查看报表时,通常是在国内,查询某张报表时,传入的查询日期参数 :比如 2020-04-06 00:00:00 ~ 2020-04-07 00:00:00,这2个字符串传到服务端,应该理解为北京时间(GMT+08:00)。
而当海外用户,比如"东京"的用户来查看时,同样还是 2020-04-06 00:00:00 ~ 2020-04-07 00:00:00,服务端收到这2个字符串时,应该理解为东京时间(GMT+09:00)时间。
所以,首先要改造的地方在于"查询参数",必须新增一个额外的时区参数,类似 timeZone:"GMT+08:00"之类,这样服务端才能知道用户所在时区。
二、数据存储
大多数公司的业务系统都是存储在mysql之类的关系型数据库中,通常在项目初期,全球化问题暂时不会考虑,部署在中国区的mysql实例,默认就是北京的东8区,即:GMT+08:00。
业务扩展到海外后,如果db性能还跟得上,仍然建议集中存储到原来的实例上,即数据存储仍然还是采用默认的GMT+08:00的北京时间存储。海外用户如果要访问加速,可以在当地部署数据副本,把主库的数据同步过去(方案有很多,大家可以自行网上查阅)。
这样的好处是,数据写入部分不用作任何修改。
三、时间的匹配及展示
有了前面2个前提,后面的事情就好做了,先来看日期字段的sql where 匹配:
3.1 根据查询参数中的timeZone,把传入的日期字符串,视为当地时间,统一转换成北京时间(在java层做转换即可,文章最后会给出转换代码),这样就跟db中的时区一致,原来的sql语句不用任何调整.
3.2 在数据展示时,把db中查出来的时间(默认北京时间),根据timeZone转换成当地时间显示,仍然只需要在java层输出数据时做转换 。
四、一些按天汇总的job调整
有些报表,是按“自然天”跑定时job汇总统计,比如每天统计 当地时间0点到23:59:59的订单总数。在只有中国业务的时期,这个统计的时间段范围就是北京时间的每天00:00:00 ~ 23:59:59,但是有海外业务后,当地的自然天,就不再是北京时间的00:00:00 ~ 23:59:59了,思路还是类似的:先将当地自然天的00:00:00 ~ 23:59:59,转换成北京时间对应的时间段.
比如:对于东京地区而言,2020-04-06 00:00:00 ~ 2020-04-06 23:59:59,其实对应北京时间的2020-04-05 23:00:00 ~ 2020-04-06 22:59:59. 仍然只需要在job计算的入口,统一换成北京时间的24小时区间段,再计算即可。
该方案理论上没问题,但实际落地时会有些复杂,比如:原来的job,每天0点后,算前1天的即可,只要跑一次,现在海外用户加进来后,比如有3个海外地区,job就要在额外的3个时间点,分别计算各个地区的自然天汇总数据。可能需要把原来的job部署多份(或配置多个启动的时间点),然后在每个不同的时间点,要有各自的逻辑,计算指定地区的数据。
所以,还有另一个思路:把按天计算的报表,汇总的时间颗粒度细化,变成按小时计算,每个小时汇总前1个小时的数据,1个小时一条记录,然后不同时区的用户在查看时,根据当地自然天,查询出对应匹配的24条记录,最后做个简单的sum即可。这样job就不用区别对待各个地区,逻辑是统一的,对所有地区,只算上1个小时数据。
最后贴一段时区转换的工具代码:
import java.time.*; import java.time.format.DateTimeFormatter; import java.util.Date; public class DateTest { public static void main(String[] args) { Date now = new Date();//中国部署的服务器,通常时间即为北京时间GMT+08:00 String pattern = "yyyy-MM-dd HH:mm:ss.SSS"; System.out.println("北京时间(GMT+08:00):"); System.out.println(now); System.out.println("转换成东京时间(GMT+09:00)字符串:"); System.out.println(toTargetDateTimeString(now, "GMT+9", pattern)); System.out.println("转换成东京时间(GMT+09:00):"); System.out.println(toTargetDate(now, "GMT+9")); System.out.println("\n东京时间(GMT+09:00)字符串:"); String gmt9DateTimeString = "2020-04-06 14:32:52.534"; System.out.println(gmt9DateTimeString); System.out.println("转换成北京时间(GMT+08:00)字符串:"); System.out.println(toTargetDateTimeString(gmt9DateTimeString, "GMT+9", pattern, "GMT+8")); System.out.println("转换成北京时间(GMT+08:00):"); System.out.println(toTargetDate(gmt9DateTimeString, "GMT+9", pattern, "GMT+8")); } /** * @param date * @param targetGMT * @return */ public static Date toTargetDate(Date date, String targetGMT) { return toDate(LocalDateTime.ofInstant(date.toInstant(), ZoneId.of(targetGMT))); } /** * date -> 目标GMT时区字符串 * * @param date * @param targetGMT * @param pattern * @return */ public static String toTargetDateTimeString(Date date, String targetGMT, String pattern) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern); return LocalDateTime.ofInstant(date.toInstant(), ZoneId.of(targetGMT)).format(formatter); } /** * 将原GMT时区的日期字符串->目标GMT时区的日期字符串 * * @param srcDateTimeString * @param srcGMT * @param pattern * @param targetGMT * @return */ public static String toTargetDateTimeString(String srcDateTimeString, String srcGMT, String pattern, String targetGMT) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern); LocalDateTime srcLocalDateTime = LocalDateTime.parse(srcDateTimeString, formatter); ZonedDateTime srcZonedDateTime = srcLocalDateTime.atZone(ZoneId.of(srcGMT)); LocalDateTime targetLocalDateTime = LocalDateTime.ofInstant(srcZonedDateTime.toInstant(), ZoneId.of(targetGMT)); return targetLocalDateTime.format(formatter); } /** * 将原GMT时区的日期字符串->目标GMT时区的Date * * @param srcDateTimeString * @param srcGMT * @param pattern * @param targetGMT * @return */ public static Date toTargetDate(String srcDateTimeString, String srcGMT, String pattern, String targetGMT) { DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern); LocalDateTime srcLocalDateTime = LocalDateTime.parse(srcDateTimeString, formatter); ZonedDateTime srcZonedDateTime = srcLocalDateTime.atZone(ZoneId.of(srcGMT)); LocalDateTime targetLocalDateTime = LocalDateTime.ofInstant(srcZonedDateTime.toInstant(), ZoneId.of(targetGMT)); return toDate(targetLocalDateTime); } /** * Date -> LocalDateTime * * @param date * @return */ public static LocalDateTime toLocalDateTime(Date date) { Instant instant = date.toInstant(); ZoneId zone = ZoneId.systemDefault(); return LocalDateTime.ofInstant(instant, zone); } /** * Date -> LocalDate * * @param date * @return */ public static LocalDate toLocalDate(Date date) { Instant instant = date.toInstant(); ZoneId zone = ZoneId.systemDefault(); LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, zone); return localDateTime.toLocalDate(); } /** * Date -> LocalTime * * @param date * @return */ public static LocalTime DateToLocalTime(Date date) { Instant instant = date.toInstant(); ZoneId zone = ZoneId.systemDefault(); LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, zone); return localDateTime.toLocalTime(); } /** * LocalDateTime -> Date * * @param localDateTime * @return */ public static Date toDate(LocalDateTime localDateTime) { ZoneId zone = ZoneId.systemDefault(); Instant instant = localDateTime.atZone(zone).toInstant(); return Date.from(instant); } /** * ZonedDateTime -> Date * * @param zonedDateTime * @return */ public static Date toDate(ZonedDateTime zonedDateTime) { Instant instant = zonedDateTime.toInstant(); return Date.from(instant); } /** * LocalDate -> Date * * @param localDate * @return */ public static Date toDate(LocalDate localDate) { ZoneId zone = ZoneId.systemDefault(); Instant instant = localDate.atStartOfDay().atZone(zone).toInstant(); return Date.from(instant); } /** * LocalDate,LocalTime -> LocalTimeToDate * * @param localDate * @param localTime */ public static Date toDate(LocalDate localDate, LocalTime localTime) { LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime); ZoneId zone = ZoneId.systemDefault(); Instant instant = localDateTime.atZone(zone).toInstant(); return Date.from(instant); } }
测试输出结果 :
北京时间(GMT+08:00):
Mon Apr 06 15:27:56 CST 2020
转换成东京时间(GMT+09:00)字符串:
2020-04-06 16:27:56.467
转换成东京时间(GMT+09:00):
Mon Apr 06 16:27:56 CST 2020
东京时间(GMT+09:00)字符串:
2020-04-06 14:32:52.534
转换成北京时间(GMT+08:00)字符串:
2020-04-06 13:32:52.534
转换成北京时间(GMT+08:00):
Mon Apr 06 13:32:52 CST 2020