原文对 ISO 8601 时间格式中 T 和 Z 的表述有一些错误,我已经对原文进行了一些修订,抱歉给你们形成误解。javascript
最近使用 sequelize
过程当中发现一个“奇怪”的问题,将某个时间插入到表中后,经过 sequelize
查询出来的时间和经过 mysql
命令行工具查询出来的时间不同。很是困惑,因而研究了下,下面是学习成果。html
咱们先来介绍一些可能当年在地理课上学习过的基本概念。java
提及来,时间真是一个神奇的东西。之前人们经过观察太阳的位置来决定时间(好比:使用日晷),这就使得不一样经纬度的地区时间是不同的。后来人们进一步规定以子午线为中心,向东西两侧延伸,每 15 度划分一个时区,恰好是 24 个时区。而后由于一天有 24 小时,地球自转一圈是 360 度,360 度 / 24 小时 = 15 度/小时,因此每差一个时区,时间就差一个小时。node
最开始的标准时间(子午线中心处的时间)是英国伦敦的皇家格林威治天文台的标准时间(由于它恰好在本初子午线通过的地方),这就是咱们常说的 GMT
(Greenwich Mean Time)。而后其余各个时区根据标准时间肯定本身的时间,往东的时区时间晚(表示为 GMT+hh:mm)、往西的时区时间早(表示为 GMT-hh:mm)。好比,中国标准时间是东八区,咱们的时间就老是比 GMT
时间晚 8 小时,他们在凌晨 1 点,咱们已是早晨 9 点了。mysql
可是 GMT
实际上是根据地球自转、公转计算的(太阳天天通过英国伦敦皇家格林威治天文台的时间为中午 12 点),不是很是准确,因而后面提出了根据原子钟计算的标准时间 UTC
(Coordinated Universal Time)。git
通常状况下,GMT
和 UTC
能够互换,可是实际上,GMT
是一个时区,而 UTC
是一个时间标准。github
能够在这里看到全部的时区:http://www.timeanddate.com/ti...sql
因此,当咱们“展现”某个时间时,明确时区就变得很是重要了。否则你只说如今是 2016-01-11 19:30:00
,而后不告诉我时区,我实际上是无法准确知道时间的(固然,我能够认为这个时间是我所在时区的当地时间)。若是你说如今是 2016-01-11 19:30:00 GMT+0800
,那我就知道这个时间是东八区的时间了。若是我在东八区,那时间就是 19:30,若是我在 GMT
时区,那时间就是 11:30(减掉 8 小时)。shell
咱们如今来介绍下 JavaScript 中的“时间”,包括:Date
、Date.parse
、Date.UTC
、Date.now
。数据库
注:下面的代码示例能够在 node shell 里面运行,若是你运行的时候结果和下面的不一致,那可能我们不在一个时区:)
构造时间的方法有下面几种:
new Date(); // 当前时间 new Date(value); // 自 1970-01-01 00:00:00 UTC 通过的毫秒数 new Date(dateString); // 时间字符串 new Date(year, month[, day[, hour[, minutes[, seconds[, milliseconds]]]]]);
须要注意的是:构造出的日期用来显示时,会被转换为本地时间(调用 toString
方法):
> new Date() Mon Jan 11 2016 20:15:18 GMT+0800 (CST)
打印出我写这篇文章时的本地时间。后面的 GMT+0800
表示是“东八区”,CST
表示是“中国标准时间(China Standard Time)”。
有一个很“诡异”的地方是若是咱们直接使用 Date
,而不是 new Date
,获得的将会是字符串,而不是 Date
类型的对象:
> typeof Date() 'string' > typeof new Date() 'object'
咱们先说最复杂的时间字符串形式。它实际上支持两种格式:一种是 RFC-2822 的标准;另外一种是 ISO 8601 的标准。咱们主要介绍后一种。
ISO 8601的标准格式是:YYYY-MM-DDTHH:mm:ss.sssZ
,分别表示:
YYYY
:年份,0000 ~ 9999MM
:月份,01 ~ 12DD
:日,01 ~ 31T
:分隔日期和时间HH
:小时,00 ~ 24mm
:分钟,00 ~ 59ss
:秒,00 ~ 59.sss
:毫秒Z
:时区,能够是:Z
(UFC)、+HH:mm
、-HH:mm
这里咱们主要来讲下 T
、以及 Z
。
。这里的表述是错误的,T
也能够用空格表示,可是这两种表示有点不同,T
其实表示 UTC
,而空格会被认为是本地时区(前提是不经过 Z
指定时区)T
仅仅是分隔日期和时间的符号,没有其余含义。因此下面的例子其实结果是同样的。
> new Date('1970-01-01 00:00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST) > new Date('1970-01-01T00:00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
这里补充一点须要注意的,时间字符串这种形式有一个特殊的逻辑:若是你不提供“时间”(也就是 T
分隔后的内容),获得的实际上是 UTC
时间。好比:
> new Date('1970-01-01') Thu Jan 01 1970 08:00:00 GMT+0800 (CST) > new Date('1970-01-01T00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
Z
用来表示传入时间的时区(zone),不指定而且没有使用 这个说法也不严谨,指定 T
分隔而是使用空格分隔时,就按本地时区处理。Z
时表示 UTC
时间,不指定时表示的是本地时间。
> new Date('1970-01-01T00:00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST) > new Date('1970-01-01T00:00:00Z') Thu Jan 01 1970 08:00:00 GMT+0800 (CST)
示例 1 是东八区时间,显示的时间和传入的时间一致(由于我本地时区是东八区)。
示例 2 指定了 Z(也就是 UTC 零时区),显示的时间会加上本地时区的偏移(8 小时)。
RFC-2822 的标准格式大概是这样:Wed Mar 25 2015 09:56:24 GMT+0100
。其实就是上面显示时间时使用的形式:
> new Date('Thu Jan 01 1970 00:00:00 GMT+0800 (CST)') Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
除了能表示基本信息,还能够表示星期,可是一点也不容易读,不建议使用。完整的规范能够在这里查看:http://tools.ietf.org/html/rf...
Date
构造器还能够接受整数,表示想要构造的时间自 UTC
时间 1970-01-01 00:00:00
通过的毫秒数。好比下面的代码:
> new Date(1000 * 1) Thu Jan 01 1970 08:00:01 GMT+0800 (CST)
传人 1 秒,等价于:1970-01-01 00:00:01Z
,显示的时间加上了本地时区的偏移(8 小时)。
最后,Date
构造器还支持传递多个参数,这种方法就没办法指定时区了,都当作本地时间处理。好比下面的代码:
> new Date(1970, 0, 1, 0, 0, 0) Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
显示时间和传入时间一致,均是本地时间。注意:月份是从 0 开始的。
Date.parse
接受一个时间字符串,若是字符串能正确解析就返回自 UTC
时间 1970-01-01 00:00:00
通过的毫秒数,不然返回 NaN
:
> Date.parse('1970-01-01 00:00:00') -28800000 > new Date(Date.parse('1970-01-01 00:00:00')) Thu Jan 01 1970 00:00:00 GMT+0800 (CST) > Date.parse('1970-01-01T00:00:00') 0 > new Date(Date.parse('1970-01-01T00:00:00')) Thu Jan 01 1970 08:00:00 GMT+0800 (CST)
示例 1,-28800000 换算后恰好是 8 小时表示的毫秒数,28800000 / (1000 * 60 * 60)
,咱们传入的是本地时区时间,等于 UTC
时间的 1969-12-31 16:00:00
,和 UTC
时间 1970-01-01 00:00:00
相差恰好 -8 小时。
示例 2,将 parse 后的毫秒数传递给构造器,最后显示的时间加上了本地时区的偏移(8 小时),因此结果恰好是 1970-01-01 00:00:00
。
示例 3,传入的是 UTC
时区时间,因此结果为 0。
示例 4,将 parse 后的毫秒数传递给构造器,最后显示的时间加上了本地时区的偏移(8 小时),因此结果恰好是 1970-01-01 08:00:00
。
Date.UTC
接受的参数和 Date
构造器多参数形式同样,而后返回时间自 UTC
时间 1970-01-01 00:00:00
通过的毫秒数:
> Date.UTC(1970,0,1,0,0,0) 0 > Date.parse('1970-01-01T00:00:00') 0 > Date.parse('1970-01-01 00:00:00Z') 0
能够看出,Date.UTC
进行的是一种“绝对运算”,传入的时间就是 UTC
时间,不会转换为当地时间。
Date.now
返回当前时间距 UTC
时间 1970-01-01 00:00:00
通过的毫秒数:
> Date.now() 1452520484343 > new Date(Date.now()) Mon Jan 11 2016 21:54:55 GMT+0800 (CST) > new Date() Mon Jan 11 2016 21:55:00 GMT+0800 (CST)
MySQL 中和时间相关的数据类型主要包括:YEAR
、TIME
、DATE
、DATETIME
、TIMESTAMP
。
DATE
、YEAR
、TIME
比较简单,大概总结以下:
名称 | 占用字节 | 取值 |
---|---|---|
DATE | 3 字节 | 1000-01-01 ~ 9999-12-31 |
YEAR | 1 字节 | 1901 ~ 2155 |
TIME | 3 字节 | -838:59:59 ~ 838:59:59 |
注:TIME
的小时范围能够这么大(超过 24 小时),是由于它还能够用来表示两个时间点之差。
咱们主要来讲明下 DATETIME
和 TIMESTAMP
,能够作下面的总结:
名称 | 占用字节 | 取值 | 受 time_zone 设置影响 |
---|---|---|---|
DATETIME | 8 字节 | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | 否 |
TIMESTAMP | 4 字节 | 1970-01-01 00:00:00 ~ 2038-01-19 03:14:07 | 是 |
第一个区别是占用字节不一样,致使能表示的时间范围也不同。
第二个区别是 DATETIME
是“常量”,保存时就是保存时的值,检索时是同样的值,不会改变;而 TIMESTAMP
则是“变量”,保存时数据库服务器将其从time_zone
时区转换为 UTC
时间后保存,检索时将其转换从 UTC
时间转换为 time_zone
时区时间后返回。
好比,咱们有下面这样一张表:
CREATE TABLE `tests` ( `id` INTEGER NOT NULL auto_increment , `datetime` DATETIME, `timestamp` TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB;
链接到数据库服务器后,能够执行 SHOW VARIABLES LIKE '%time_zone%'
查看当前时区设置。相似下面这样的结果:
Variable_name | Value |
---|---|
system_time_zone | CST |
time_zone | SYSTEM |
说明我目前时区是 CST
(China Standard Time),也就是东八区。
咱们尝试插入下面的数据:
INSERT INTO `tests` (`id`, `datetime`, `timestamp`) VALUES (DEFAULT, '1970-01-01 00:00:00', '1970-01-01 00:00:00');
会发现有一个报错:Error Code: 1292. Incorrect datetime value: '1970-01-01 00:00:00' for column 'timestamp'
。给 timestamp 这一列提供的值不对,由于咱们尝试插入 1970-01-01 00:00:00
时,数据库服务器会根据 time_zone
的设置将其转换为 UTC
时间,也就是 1969-12-31 16:00:00
,而这个值明显超过了 TIMESTAMP
类型的范围。
咱们换个大一点的值:
INSERT INTO `tests` (`id`, `datetime`, `timestamp`) VALUES (DEFAULT, '2000-01-01 00:00:00', '2000-01-01 00:00:00');
此次就成功插入了。
再次检索时结果也是正确的(数据库服务器将值从 UTC
时间转换为 time_zone
设置的时区时间):
SELECT * FROM sample.tests;
返回:
id | datetime | timestamp |
---|---|---|
1 | 2000-01-01 00:00:00 | 2000-01-01 00:00:00 |
若是咱们先将 time_zone
设置为一个不一样的值后再进行检索就会发现不一样的结果:
SET time_zone = '+00:00'; SELECT * FROM sample.tests;
返回:
id | datetime | timestamp |
---|---|---|
1 | 2000-01-01 00:00:00 | 1999-12-31 16:00:00 |
能够看到 datetime 列值没有受 time_zone
设置的影响,而 timestamp 列值却改变了。数据库服务器将其从 UTC
时区转换为 time_zone
时区的时间(首先 2000-01-01 00:00:00
在上面进行插入时根据 time_zone
被转换为了 1999-12-31 16:00:00
,这次检索时 time_zone
被设置为 +00:00
,转换回来恰好就是 1999-12-31 16:00:00
)。
那这两种类型怎么选择呢?建议优先使用 DATETIME
,表示范围大、不容易受服务器的设置影响。
分别说明了 JavaScript 和 MySQL 中的“时间”后,咱们来聊聊 ORM 框架通常都是怎么样在二者间进行正确、合适的转换来避免混乱的。下面的说明将基于 sequelize 框架来解释,主要是一种思路,其余的框架能够阅读框架提供的文档或是源码。
sequelize 实际上有一个 timezone
的配置,默认是 +00:00
(http://sequelize.readthedocs....)。这个 timezone
有下面的用途:
SET time_zone = opts.timezone
第一个用途很简单,体如今源码里就是执行一个 SQL
语句:
connection.query("SET time_zone = '" + self.sequelize.options.timezone + "'"); /* jshint ignore: line */
第二个用途主要体如今两个地方:1)在 JavaScript 中调用 ORM 方法进行插入、更新时,须要将 Date
类型转为正确的 SQL 语句;2)从 MySQL 服务器查询数据时,须要将数据库查询到的值转换为 JavaScript 中的 Date 类型。下面咱们分别来看一看。
这个转换的核心代码以下:
SqlString.dateToString = function(date, timeZone, dialect) { if (moment.tz.zone(timeZone)) { date = moment(date).tz(timeZone); } else { date = moment(date).utcOffset(timeZone); } if (dialect === 'mysql' || dialect === 'mariadb') { return date.format('YYYY-MM-DD HH:mm:ss'); } else { // ZZ here means current timezone, _not_ UTC return date.format('YYYY-MM-DD HH:mm:ss.SSS Z'); } };
代码逻辑以下:
timeZone
是否存在,若是存在(存在指的是相似 America/New_York
这样的表示法),调用 tz
设置 date
的时区。+00:00
、-07:00
这样的表示法),调用 utcOffset
设置 date
的相对 UTC
的时区偏移。format
成 MySQL 须要的 YYYY-MM-DD HH:mm:ss
格式。举两个例子。
若是 timeZone
等于 +00:00
,date 等于 new Date('2016-01-12 09:46:00')
,到 UTC
的偏移等于 (timeZone - 本地时区) + timeZone:(00:00 - 08:00) + 00:00 = -08:00
,即 2016-01-12 09:46:00-08:00
,因而 format
后的结果是 2016-01-12 01:46:00
。
若是 timeZone
等于 +08:00
,date 等于 new Date('2016-01-12 09:46:00')
,到 UTC
的偏移等于 (timeZone - 本地时区) + timeZone:(08:00 - 08:00) + 08:00 = 08:00
,即 2016-01-12 09:46:00+08:00
。因而 format
后的结果是 2016-01-12 09:46:00
。
若是 timeZone
等于 Asia/Shanghai
,结果也会是 2016-01-12 09:46:00
,和 +08:00
等价。
sequelize 的 timezone
默认是 +00:00
,因此,咱们在 JavaScript 中的时间最后应用到数据库中都会被转换成 UTC
的时间(比实际的时间早 8 小时)。
这个转换过程其实是更底层的 node-mysql 库来实现的。核心代码以下:
switch (field.type) { case Types.TIMESTAMP: case Types.DATE: case Types.DATETIME: case Types.NEWDATE: var dateString = parser.parseLengthCodedString(); if (dateStrings) { return dateString; } var dt; if (dateString === null) { return null; } var originalString = dateString; if (field.type === Types.DATE) { dateString += ' 00:00:00'; } if (timeZone !== 'local') { dateString += ' ' + timeZone; } dt = new Date(dateString); if (isNaN(dt.getTime())) { return originalString; } return dt; // 更多代码... }
处理过程大概是这样:
parser
将服务器返回的二进制数据解析为时间字符串dateStrings
而不是转换回 Date
类型,直接返回 dateString
DATE
,时间字符串的时间部分统一为 00:00:00
timeZone
不是 local
(本地时区),时间字符串加上时区信息Date
构造器,若是构造出的时间不合法,返回原始时间字符串,不然返回时间对象默认状况下,sequelize 在进行链接时传递给 node-mysql 的 timeZone
是 +00:00
,因此,第 4 步的时间字符串会是相似这样的值 2016-01-12 01:46:00+00:00
,而这个值传递给 Date
构造器,在显示时转换回本地时区时间,就变成了 2016-01-12 09:46:00
(比数据库中的时间晚 8 小时)。
在使用 sequelize 定义模型时,实际上是没有 TIMESTAMP
类型的,sequelize 只提供了一个 Sequelize.DATE
类型,生成建表语句时被转换为 DATETIME
。
若是是在旧表上定义模型,而这张旧表恰好有 TIMESTAMP
类型的列,对 TIMESTAMP
类型的列定义模型时仍是可使用 Sequelize.DATE
,对操做没有任何影响。可是 TIMESTAMP
是受 time_zone
设置影响的,这会引发一些困惑。下面咱们来看一个例子。
sequelize 默认将 time_zone
设置为 +00:00
,当咱们执行下面代码时:
Test.create({ 'datetime': new Date('2016-01-10 20:07:00'), 'timestamp': new Date('2016-01-10 20:07:00') });
会进行上面提到的 JavaScript 时间到 MySQL 时间字符串的转换,生成的 SQL 实际上是(时间被转换为了 UTC
时间,比本地时间早了 8 小时):
INSERT INTO `tests` (`id`,`datetime`,`timestamp`) VALUES (DEFAULT,'2016-01-10 12:07:00','2016-01-10 12:07:00');
当咱们执行 Test.findAll()
来查询数据时,会进行上面提到的 MySQL 时间到 JavaScript 时间的转换,其实就是返回这样的结果(显示时时间从 UTC
时间转换回了本地时间):
> new Date('2016-01-10 12:07:00+00:00') Sun Jan 10 2016 20:07:00 GMT+0800 (CST)
和咱们插入时的时间是一致的。
若是咱们经过 MySQL 命令行来查询数据时,发现实际上是这样的结果:
id | datetime | timestamp |
---|---|---|
1 | 2016-01-10 12:07:00 | 2016-01-10 20:07:00 |
这很好理解,由于咱们数据库服务器的 time_zone
默认是东八区,TIMESTAMP
是受时区影响的,查询时被数据库服务器从 UTC
时间转换回了 time_zone
时区时间;DATETIME
不受影响,仍是 UTC
时间。
若是咱们先执行 SET time_zone = '+00:00'
,再进行查询,那结果就都会是 UTC
时间了。因此,不要觉得数据出错了哦。
总结下就是,sequelize 会将本地时间转换为 UTC
时间后入库,查询时再将 UTC
时间转换为本地时间。这能达到最好的兼容性,存储老是使用 UTC
时间,展现时应用端本身转换为本地时区时间后显示。固然这个的前提是数据类型选用 DATETIME
。
这里要说的最后一个问题是基于旧表定义 sequelize 模型,而且表中时间值插入时没有转换为 UTC
时间(所有是东八区时间),并且 DATETIME
和 TIMESTAMP
混用,该怎么办?
在默认配置下,状况以下:
查询 DATETIME
类型数据时,时间老是会晚 8 小时。好比,数据库中某条老数据的时间是 2012-01-01 01:00:00
(已是本地时间了,由于没转换),查询时被 sequelize 转换为 new Date('2012-01-01 01:00:00+00:00')
,显示时转换为本地时间 2012-01-01 09:00:00
,结果显然不对。
查询 TIMESTAMP
类型数据时,时间是正确的。这是由于 TIMESTAMP
受 time_zone
影响,sequelize 默认将其设置为 +00:00
,查询时数据库服务器先将时间转换到 time_zone
设置的时区时间,因为没有时区偏移,恰好查出来的就是数据库中的值。好比:2012-01-01 00:00:00
(注意这个值是 UTC 时间),sequelize 将其转换为 new Date('2012-01-01 00:00:00+00:00')
,显示时转换为本地时间 2012-01-01 08:00:00
,恰好“侥幸”正确。
新插入的数据 sequelize 会进行上一部分说的双向转换来保证结果的正确。
维持默认配置显然致使查询 DATETIME
不许确,解决方法就是将 sequelize 的 timezone
配置为 +08:00
。这样一来,状况变成下面这样:
查询 DATETIME
类型数据时,时间 2012-01-01 01:00:00
被转换为 new Date('2012-01-01 01:00:00+08:00')
,显示时转换为本地时间 2012-01-01 01:00:00
,结果正确。
查询 TIMESTAMP
类型数据时,因为 time_zone
被设置为了 +08:00
,数据库服务器先将库中 UTC
时间 2011-01-01 00:00:00
转换到 time_zone
时区时间(加上 8 小时偏移)为 2011-01-01 08:00:00
,sequelize 将其转换为 new Date('2011-01-01 08:00:00+08:00')
,显示时转换为本地时间 2011-01-01 08:00:00
,结果正确。
插入、更新数据时,全部 JavaScript 时间会转换为东八区时间入库。
这样带来的问题是,全部入库时间都是东八区时间,若是有其余应用的时区不是东八区,那就须要本身基于东八区时间计算偏移并转换时间后显示了。
一不当心写的有点长了,下面列出参考资料供你们进一步学习: