Web开发中的时区问题
在国际化的业务场景中,时区问题是常见的。本文将就Web开发中的时区问题进行探索。
关于时区的概念,想必大家都有些了解。我们的地球被划分为24个时区,北京时间为东八区,而美国的太平洋时间为西八区,和我们差了16个小时。
下面我们从一个案例说起,我们的服务器和数据库部署在北京,而这时美国用户通过浏览器希望能查询北京时间下的“2020年7月1日8点-2020年7月1日18点”这10个小时的数据。
浏览器上选择时间区域查询数据
为了模拟浏览器在太平洋时间,只需将系统时间设置为太平洋时间即可。而系统时间的改变会影响到JVM的默认时区,所以为了让服务器程序仍处于北京时间,需要通过代码指定时区,如下:
TimeZone.setDefault(TimeZone.getTimeZone("GMT+8"));
而数据库MySQL的时区也设置为北京时间,SQL如下:
set global time_zone = '+8:00';
set time_zone = '+8:00';
flush privileges;
下面,让我们点击查询,先看下我们发送的内容:
发送数据的格式
可以看到开始时间和结束时间都比界面上显示的时间多了8小时。这是因为我使用的ElementUI组件的日期时间选择器,其默认时区为0时区,所以会将我们选择的时间根据浏览器的时区(西八区)转换成0时区的时间。最后传输的内容为时间+时区的字符串表示。
时间-时区的字符串表示
前端把数据成功发出来了,下面我们看下后端接收数据的情况。后端我使用的是SpringBoot,Controller的代码如下。
@PostMapping("/time")
public List<Data> test(@RequestBody TimeDto dto) {
Date startTime = dto.getStartTime();
Date endTime = dto.getEndTime();
System.out.println(startTime);
System.out.println(endTime);
// 格林时间(0)
String format = "yyyy-MM-dd HH:mm:ss";
SimpleDateFormat sdfGreen = new SimpleDateFormat(format);
sdfGreen.setTimeZone(TimeZone.getTimeZone("GMT+0"));
System.out.println("格林时间:" + sdfGreen.format(startTime) + "至" + sdfGreen.format(endTime));
// 北京时间(+8)
SimpleDateFormat sdfBeijing = new SimpleDateFormat(format);
sdfBeijing.setTimeZone(TimeZone.getTimeZone("GMT+8"));
System.out.println("北京时间:" + sdfBeijing.format(startTime) + "至" + sdfBeijing.format(endTime));
// 太平洋时间(-8)
SimpleDateFormat sdfPacific = new SimpleDateFormat(format);
sdfPacific.setTimeZone(TimeZone.getTimeZone("GMT-8"));
System.out.println("太平洋时间:" + sdfPacific.format(startTime) + "至" + sdfPacific.format(endTime));
List<Data> dataList = queryDate(dto);
return dataList;
}
/**
Thu Jul 02 00:00:00 GMT+08:00 2020
Thu Jul 02 10:00:00 GMT+08:00 2020
格林时间:2020-07-01 16:00:00至2020-07-02 02:00:00
北京时间:2020-07-02 00:00:00至2020-07-02 10:00:00
太平洋时间:2020-07-01 08:00:00至2020-07-01 18:00:00
**/
由于JVM时区为东八区,所以反序列化时得到的Date对象也是东八区的时间,即2号0点-2号10点。如果我们直接用startTime和endTime去查询,得到的将是北京时间2号0点到10点的数据,和预想的结果有差异。
时区问题导致的查询时间范围错误
那如何才能查询到北京时间1号8点-1号18点的数据呢。由于我们前端传输的太平洋时间在后台接收时发生时区转换,所以可以在前端直接传输需要查询的北京时间。也就是1号8点-1号18点。通过设置el-date-picker
的value-format
属性,指定选择的时间格式“yyyy-MM-dd HH:mm:ss”,这样传输的时间字符串将不具有时区属性。
<el-date-picker
v-model="dateTimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd HH:mm:ss"
>
</el-date-picker>
修正后的发送数据格式
而后端如果不修改,将报出以下错误,无法将该格式的时间转换成Date对象。
JSON parse error: Cannot deserialize value of type `java.util.Date` from String "2020-07-01 08:00:00": not a valid representation (error: Failed to parse Date value '2020-07-01 08:00:00': Cannot parse date "2020-07-01 08:00:00": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSZ', parsing fails (leniency? null)); nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.util.Date` from String "2020-07-01 08:00:00": not a valid representation (error: Failed to parse Date value '2020-07-01 08:00:00': Cannot parse date "2020-07-01 08:00:00": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSZ', parsing fails (leniency? null))↵ at [Source: (PushbackInputStream); line: 1, column: 14] (through reference chain: com.chaycao.timezone.TimeDto["startTime"])
所以为能正确反序列化,需要为jackjson做反序列化提供额外的信息。加上@JsonFormat
注解,指定时区和时间格式,便能达到期望的效果,得到的将是北京时间的1号8点和1号18点。所以,在前后端传输发生的时区问题,注意时间数据的序列化和反序列化方式就能解决。
public class TimeDto {
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
Date startTime;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
Date endTime;
//...
}
下面我们再看下数据库中会发生的时区问题。
我们将MySQL的时区改为太平洋时间。
set global time_zone = '-8:00';
set time_zone = '-8:00';
flush privileges;
看下查询的结果是否会发生变化,查询的程序如下:
private List<Data> queryDate(TimeDto dto) {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost/test?useSSL=false&useUnicode=true&characterEncoding=UTF8&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai");
dataSource.setUsername("root");
dataSource.setPassword("caoniezi");
Date startTime = dto.getStartTime();
Date endTime = dto.getEndTime();
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
String sql = "SELECT * FROM data WHERE create_time >= ? and create_time <= ?";
List<Map<String, Object>> maps = jdbcTemplate.queryForList(
sql,
new Object[]{startTime, endTime});
List<Data> dataList = new ArrayList<>();
for (Map<String, Object> map : maps) {
Data data = new Data();
data.setId((Integer) map.get("id"));
data.setContent((String) map.get("content"));
data.setCreateTime((Date) map.get("create_time"));
dataList.add(data);
}
return dataList;
}
查询的结果仍然是“D,E,F”,看来数据库时区的改变对于我们本次查询未产生影响。
修改MySQL时区后查询时间范围正确
这是因为在create_time
字段的类型为datetime,而datetime是没有时区概念的,存储的是格式为YYYYMMDDHHMMSS(年月日时分秒)的整数,不会受到时区的影响。
而如果我们先将时区改回东八区,将create_time
的类型改为timestamp,再把时区改为西八区。查询的结果是“H,I,J”。
set global time_zone = '+8:00';
set time_zone = '+8:00';
flush privileges;
ALTER TABLE `data` MODIFY COLUMN `create_time` TIMESTAMP DEFAULT NULL;
set global time_zone = '-8:00';
set time_zone = '-8:00';
flush privileges;
修改create_time字段类型为timestamp
这是因为timestamp是有时区概念,存入的是自时间纪元以来的秒数,在我们将类型改为timestamp时,create_time
的值也会由东八区计算为0时区的时间秒数存储。当我们以西八区查询时,会减少16小时。
修改为timestamp后查询
那如何才能在西八区的数据库中查出我们想要的数据。
jdbc连接url中的serverTimezone参数,其作用是为驱动指定MySQL的时区,在之前的操作中,我们修改了MySQL的时区,而serverTimezone未修改,仍然是东八区。
jdbc:mysql://localhost/test?useSSL=false&useUnicode=true&characterEncoding=UTF8&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
查询情况如下,MySQL驱动会根据指定的serverTimezone和JVM时区做转换,由于两者都是东八区,所以startTime和endTime的时间字符串不变,但是由于MySQL时区已变为西八区,查询结果就落到了H、I、J上。
serverTimezone为东八区的查询情况
下面我们把serverTimezone去掉,在未指定serverTimezone的情况下,驱动会根据MySQL的时区作为serverTimezone,然后做转换,这样得到的结果就是我们想要的。
serverTimezone不指定的查询情况
但是这样做有一个问题,就是在查询datetime类型的数据时,也会发生转换,查询的结果将是30号16点到1号2点的数据。那么如何才能保证datetime类型、timestamp类型的数据都正确。首先serverTimezone是需要指定Asia/Shanghai的,不然datetime的数据会发生转换。而由于serverTimezone和MySQL时区不一致,查询的timestampe数据存在时区问题,所以最后的办法就是修改MySQL时区为东八区。通过保证MySQL时区、serverTimezone和JVM时区三者一致,来保证时间数据读写的正确性。
文中的代码已上传至Github,感兴趣的同学可以自己试下: https://github.com/chaycao/Learn/tree/master/LearnTimeZone
- django之对FileField字段的upload_to的设定
- JAVA_HOME环境变量失效的解决办法
- JBOSS EAP 6.0+ Standalone模式安装成Windows服务
- Django 设置media static
- Django---Ajax
- 利用Spring MVC搭建REST Service
- ehcache2.8.3入门示例:hello world
- day4、Linux基础题目
- 命令行执行Django脚本的方法
- Spring Security笔记:Hello World
- day5、文件乱码怎么解决
- javascript计算对象的长度
- Spring 4.0.2 学习笔记(2) - 自动注入及properties文件的使用
- day6、Linux下如何找出7天以前的文件删除
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- linux中Centos7的LVM磁盘扩容问题
- 如何禁止网站内容被搜索引擎收录的几种方法讲解
- Apache由http自动跳转到https的多种方法
- CentOS服务器中安装FFmpeg的完整步骤
- linux中Centos7增加swap分区详解
- CentOS7如何重置root密码的方法
- Linux下RPM打包制作过程
- linux ssh端口转发的三种方式
- 学习Centos7软raid5的挂载
- linux中crw brw lrw等等文件属性是什么
- centos中文件与权限的基本操作教程
- 在Linux中使用tcpdump命令捕获与分析数据包详解
- easyswoole一键安装脚本及宝塔安装错误问题
- CentOS7系统增加swap的操作方法实例
- iOS逆向之OpenSSH登录iPhone