日期/时间的国际化,不仅涉及到地理位置(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的差异。比如:
select ...
from ...
where ...
and createTime>= #{start,jdbcType=TIMESTAMP} and createTime
]]>
小结
考虑到数据库日期/时间字段的精度问题,按时间段查询时,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框架中部分实现。