日期/时间的国际化,不仅涉及到地理位置(Locale,比如星期、月份等日历本地化表示),还涉及到时区(TimeZone,针对UTC/GMT的偏移量)。时区不仅是地理位置规定,更是政治规定,比如中国从地理位置上跨5个时区,但只使用一个统一时区(id=Shanghai/Asia)。
用户locale/timezone的获取
- 猜测:根据IP、HTTP Header(Accept-Language),js脚本等方式猜测,不准确。
- 客户请求参数。有一个入口让用户选择locale/timezone,同时也可让用户选择date/time format偏好。常见于后台管理系统。
用户locale/timezone的存储
- cookie
- 用户profile管理系统
客户端输出
优先使用用户确认的locale/timezone/format。否则使用应用系统默认,必须显示时区,比如amazon的限时销售显示为PST时区。
客户端输入
- 相对时间:比如一周内,1天前等。直接转换为应用系统相对时间。
- 绝对时间:允许客户端输入具体日期/时间的系统,有用户确认timezone的,需转换为应用系统默认timezone再处理。比如:
DateFormat df = new SimpleDateFormat(pattern, userLocale); df.setTimeZone(userTimeZone); Date userInputDate = df.parse(inputDate);
服务系统时区
同一服务系统内所有主机的操作系统、数据库、JVM,原则上应该使用相同时区。
系统交互
同一服务系统跨时区服务的,日期/时间数据必须带有时区信息。服务系统之间交换日期/时间数据的,必须带有时区信息。
操作系统时区设定
同一服务系统内,数据库服务器按照其服务的地理位置和时区设置,应用服务器参照数据库服务器设置。数据库软件系统和JVM默认时区不做调整,均采用主机操作系统时区。
所有主机开启NTP,应用服务器向数据库服务器请求时间同步。
数据库日期/时间字段类型存储
mysql(5.5)
字段类型 |
字节 |
精度 |
范围 |
说明 |
---|---|---|---|---|
date | 3 |
天 |
'1000-01-01' to '9999-12-31' | 日期 |
datetime | 8 |
秒 |
'1000-01-01 00:00:00' to '9999-12-31 23:59:59' | 日期和时间混合 |
timestamp | 4 |
秒 |
'1970-01-01 00:00:01' UTC to '2038-01-19 03:14:07' UTC. | 日期和时间混合,转换为UTC时区的时间后存储;insert/update时,由数据库自动更新。 |
year | 1 |
年 |
1901 to 2155, or 0000 | 年份 |
time | 3 |
秒 |
'-838:59:59' to '838:59:59' | 时间值或持续时间 |
说明:
- datetime/timestamp可接受微妙级的时间,但是存储时只保留到秒级别。需要存储毫秒级别的,可以使用bigint类型字段,在应用程序级别进行long型的epoch毫秒和Date类型转换。
- 只有timestamp类型在存储时,会将值从数据库时区转换成UTC时区存储;检索时,从UTC时区转换为数据库时区。中途改变数据库时区,timestamp类型的值将不会正确转换。
- timestamp使用条件限制:一个表最多只有一个timestamp类型字段会被正确处理。
oracle(10g)
字段类型 | 字节 |
精度 |
范围 |
说明 |
---|---|---|---|---|
date |
7 |
秒 |
-4712-01-01 00:00:00 to 9999-12-31 23:59:59 | 日期和时间混合 |
timestamp(n) | 7-11 | 秒~纳秒 |
秒范围同上 | 日期和时间混合,小数秒位数(0-9,相当于秒-纳秒)可设置 |
timestamp WITH TIME ZONE | 9-13 |
秒~纳秒 | 秒范围同上 | 日期和时间混合,同时存储时区。若输入不指定时区,则使用数据库时区 |
timestamp WITH LOCAL TIME ZONE | 7-11 | 秒~纳秒 | 秒范围同上 | 转换为数据库时区后存储,不存储时区 |
interval year to month | 5 | N/A | N/A | 存储年或月指定的时间段 |
interval day to second | 11 |
N/A | N/A |
存储天,小时,分钟,秒指定的时间段 |
说明:
- timestamp WITH LOCAL TIME ZONE不存储时区,因此不能中途改变数据库时区。
sqlserver(2008)
字段类型 | 字节 | 精度 |
范围 |
说明 |
---|---|---|---|---|
smalldatetime | 4 |
分 |
1900-01-1 00:00:00 to 2079-06-06 23:59:00 | 日期和时间混合 |
datetime | 8 | 3.33毫秒 |
1753-01-01 00:00:00.000 to 9999-12-31 23:59:59.998 | 日期和时间混合 |
datetime2(n) | 6-8 | 100ns | 0001-01-01 00:00:00.0000000 to 9999-12-31 23:59:59.9999999 | 日期和时间混合。n为小数秒精度,取值范围为0-7,表示1秒-100ns |
date | 3 | 天 |
001-01-01 to 9999-12-31 | 日期 |
time(n) | 3-5 | 100ns | 00:00:00.0000000 to 23:59:59.9999999 | 时间。n为小数秒精度,取值范围为0-7,表示1秒-100ns |
datetimeoffset | 8-10 |
100ns | 0001-01-01 00:00:00.0000000 to 9999-12-31 23:59:59.9999999 | 日期和时间混合。n为小数秒精度,取值范围为0-7,表示1秒-100ns。 数据库时区与UTC的时区偏移量(精确到分钟)被存储。 |
小结
- 数据库系统时区默认取自操作系统的时区设置。因此必须正确设定操作系统的时区。
- 字段中存储时区信息的只有sqlserver2008(datetimeoffset)和oracle(timestamp WITH TIME ZONE),可以安全的跨时区进行数据库级别导出导入/复制。当然相应的存储空间也会加大。
- mysql和oracle均有一种字段类型支持存储前进行时区转换。数据库时区一旦设定,均不应该更改。
- 大多数日期/时间类型字段都不进行时区转换存储。因此应用程序应该使用与数据库一致的时区。相对使用数据库sysdate(),now()等时间函数而言,优先使用应用程序传入时间。
- 对于精确到秒的日期/时间类型字段,不应该作为乐观锁和版本管理用途,而应优先使用int4类型。
JDK6日期时间处理相关类
- java.util.Date的fastTime域和Calendar的time域,存储特定的瞬间,精确到毫秒。初始值是系统时间距1970-01-01 00:00:00.000 UTC(epoch time)以来的毫秒数。因此可以认为这两个类本身是带有时区信息的。
- Calendar通过改变timezone,将时间在不同时区转换表示。相对java.util.Date,Calendar提供了人可读的日历字段:年月日小时星期等,还提供了通过修改这些日历字段来改变时间值的方法。
- TimeZone主要提供距UTC时区的偏移毫秒数、夏令时规定。
- DateFormat及其子类,用来格式化calendar域存储的时间,或将字符串表示解析成java.util.Date。pattern格式化字串和locale属性提供更灵活的本地化表示能力。也可通过设置timezone,格式化成不同时区的时间表示。
- 通过-Duser.language、-Duser.country、-Duser.timezone设定与操作系统不一致的地理位置和时区,不推荐。
小结
- java.util.Date是目前JDK中存储时间的标准。Calendar提供了获取和修改时间值的方便方式。
- Locale和TimeZone是日期/时间的国际化处理的核心,分别起着不同的作用。
- Calendar默认为宽松模式(lenient=true),使用DateFormat解析字串时,可设置为严格模式,避免将"2012-01-32"解析成2012-02-01。
- 除Locale是线程安全的不可变类外,其他都是可变类,非线程安全。
JDBC
jdbc使用java.util.Date的三个子类(见上图),负责与数据库系统交互。java.sql.Date只包含日期,java.sql.Time只包含时间,java.sql.Timestamp包含日期和时间混合,精确到纳秒。java type, jdbc sql type和数据库字段类型对应关系如下:
java.sql |
java.sql.Types |
mysql |
sqlserver |
oracle |
---|---|---|---|---|
Date |
DATE |
date |
date |
date |
Time |
TIME |
time |
time |
date |
Timestamp |
TIMESTAMP |
datetime timestamp |
datetime datetime2 datetimeoffset |
date timestamp [with ....] |
String |
VARCHAR | timestamp WITH TIME ZONE |
- timestamp WITH LOCAL TIME ZONE:
当检索列时,返回给用户的值被转换成 TIME_ZONE 会话参数指定的时区(JVM报告的当前时区:TimeZone.getDefault(),来自-Duser.timezone的设定或主机操作系统时区,或设置TimeZone.setDefault(timezone))。
当设置列时(PreparedStatement.setTimestamp):设置的值将被转换成 TIME_ZONE 会话参数指定的时区。
- 对于timestamp WITH TIME ZONE来说,默认映射为JDBC VARCHAR,时区信息将以字符串返回。
- jdbc规范中的ps.setXXX(index, datetime, calendar)和ps.getXXX(index, calendar)不是所有的jdbc驱动都正确实现。见:http://wenku.baidu.com/view/6a06501ffc4ffe473368ab6b.html
小结
- java应用程序应该始终使用java.util.Date作为领域对象的日期/时间属性类型。
- 大部分的数据库日期/时间字段不保留时区信息,传递给数据库的值依赖于JVM时区。
- 注意jdbc sql type和数据库字段类型的映射关系。
- JVM时区尽量保持与数据库系统时区一致。当应用系统JVM与数据库时区不同时,读写时需要参照数据库时区转换。这个工作应该由框架来完成,对开发透明。
mybatis
mybatis关于日期/时间处理的地方,主要是使用TypeHandler:
register(Date.class, new DateTypeHandler());
register(Date.class, JdbcType.DATE, new DateOnlyTypeHandler());
register(Date.class, JdbcType.TIME, new TimeOnlyTypeHandler());
register(JdbcType.TIMESTAMP, new DateTypeHandler());
register(JdbcType.DATE, new DateOnlyTypeHandler());
register(JdbcType.TIME, new TimeOnlyTypeHandler());
register(java.sql.Date.class, new SqlDateTypeHandler());
register(java.sql.Time.class, new SqlTimeTypeHandler());
register(java.sql.Timestamp.class, new SqlTimestampTypeHandler());
各个TypeHandler主要处理java.util.Date类型和jdbc sql type的映射关系和转换,屏蔽java.sql.Date/Time/Timestamp的差异。比如:
"createTime" property="createTime" jdbcType="TIMESTAMP" />
小结
- 考虑到数据库日期/时间字段的精度问题,按时间段查询时,start(含)、end(不含)应该剔除时间信息,只保留到日期。
- 可以编写具备时区转换的,或者将Date映射成int8的TypeHandler,覆盖mybatis的默认处理器。
MVC框架
struts2/spring3 mvc针对日期/时间处理和国际化方面,主要涉及到拦截器设置locale上下文环境、日期/时间字符串输入解析、验证、国际化显示这几个方面。
设置locale context
- struts2:I18nInterceptor。支持从请求参数、session中获取用户设置的locale。
- spring3 mvc:LocaleResolver的几个实现。支持从请求参数、session、http header中获取。
两者均将获取到的locale暴露到request scope context中,供解析、国际化使用。两者均缺乏TimeZone相关的拦截器。如果单点登录系统增加了用户profile管理,则还可以增加基于profile的拦截器实现。
输入解析
两个框架都利用DateFormat的parse方法进行解析,支持locale/timezone转换。
- struts2:Converter接口和XWorkBasicConverter,将字符串按照一些上下文locale相关的规范格式、rfc3399等进行fallback解析,使用严格模式(lenient=false)。当用户输入不符合这些规范格式时,将出现错误。
- spring3 mvc:spring3新的Converter框架众多转换器中,唯独缺少日期/时间转换相关实现,而是迫使开发者在DateBinder中注册自定义的CustomDateEditor(提供特定的DateFormat,设置宽松模式或严格模式)。
在无法获知用户locale/timezone的情况下,或者用户输入格式随意,通过猜测来解析用户的时间含义,是不够准确的。spring框架把这个决定权交给开发者,很明智。因此,在涉及到用户输入时,必须明确规范用户的输入格式、协商用户时区。
验证
在输入解析时进行了日期/时间格式合法性验证后,两者的验证框架,均有针对日期/时间的可否为空、范围验证。
国际化显示
均可以在两者支持的模板系统中添加相关的宏或tag。实际格式化由暴露在view context中的DateFormat帮助类,根据request scope context中的locale/timezone或缺省设置来处理。
小结
- 两种框架针对日期/时间的输入、输出都有成熟和规范的解决方案,正确使用即可,缺乏的功能可扩展。
- 必须明确用户的输入格式,使用严格模式解析。协商用户时区。
xml/json针对日期/时间的处理
xml/json也被MVC框架用来作为视图层。更多的时候,用作两个系统进行信息交互(比如webservice和rest)的一种中间格式。
XML
JAXB规范中的日期/时间格式,遵循XML中的日期/时间规范(ISO8601),JDK内部实现中格式如下:
- 日期格式:2012-03-08+08:00
- 时间格式:22:18:13.453+08:00
- 日期时间格式:2012-03-08T22:18:13.453+08:00
JSON
JSON规范并没有定义如何序列化日期时间。json框架主要处理方式如下:
- long型:自1970-01-01 00:00:00以来的毫秒数。java和javascript中的Date类型内部都这样表示时间。jackson json框架考虑到性能,默认以这种方式序列化日期类型。
- String型:常见的是ISO8601标准的表示,如上。
小结
- 遵循标准规范。在系统交互时,必须传递时区信息;在无法确定用户时区时,显示时必须带有时区信息。
总结
规范
- 所有主机开启NTP。同一服务系统,原则上使用同一时区。数据库系统、JVM,使用主机默认时区。
- 如果应用系统与数据库系统时区不一致,读写时应参照数据库时区转换。
- 系统间交互,日期/时间信息必须带有时区信息。
- 能确定用户locale/timezone的,使用用户时区进行显示。否则必须显示应用系统的时区。
- 有用户输入的,必须明确规范用户输入格式,使用严格模式。
- 格式化字串以常量定义,避免typo。
- 理解并遵循各层规范标准和成熟实现(JDBC/MVC/XML/JSON等)。
公共类库开发原则
- 不重复造轮子,尽量使用广泛成熟的工具类,比如apache.commons.lang,jodatime等,最多加一个门面。
- 尽量使用mybatis/mvc等框架的标准实现,整理最佳实践文档;适当扩展mybatis/mvc框架中部分实现。