在国际化应用程序中处理日期/时间比您想象的要困难得多,尤其是在涉及时区时。为什么这么难?我们如何解决它?让我为你分解一下。几乎所有的系统都离不开“时间”的概念,以至于大多数语言(及其默认库)都定义了日期/时间等类型。然而,我们日常使用的“时间”这个词其实包含了几个相似的概念,又有着细微的差别。如果分不清它们,会给你的开发工作带来很大的麻烦。一、基本概念1、时区(Timezone)在应用系统中,对时间的混淆往往与时区有关。这是许多系统从本地化应用程序发展到全球应用程序的主要障碍。由于各地日出日落时间不同,世界分为24个时区,每个时区跨度360/24=15个经度。比如伦敦位于北京的西边,那么当北京的太阳升起时,伦敦还要再过8个小时才能迎来黎明。也就是说,伦敦比北京晚8小时。而东京位于北京的东边,所以东京的日出比北京早1个小时。如果我们想知道北京时间中午12:00时东京时间是几点,可以先用12:00减去当前时区+08:00,换算成伦敦时间04:00,加上目标时间zone+09:00,我们得到东京时间13:00。2.地球在零时区是圆的。北京比伦敦早8小时,实际上比伦敦晚16小时。谁更早?这个时间差我们可以表示为+8,也可以表示为-16,怎么写呢?我们需要先确定一个标准。首先,确定零时区。虽然任何地方都可以作为零时区,但拥有世界第一座航海钟的英国格林威治天文台获得了这一荣誉,而经过那里的子午线(经度)被称为本初子午线。左右7.5度范围称为零时区,向西一时区称为西一时区。当我们从东到西旅行时,我们是在追逐太阳,所以每经过一个时区,我们都要将手表拨回一个小时,以与当地时间保持一致。我们称此“减速”动作为-01:00,否则为+01:00。如果从伦敦到北京,需要从西到东走八个时区,所以北京的时区记录为+08:00。3.日界线(国际日界线)在球面上,与它相对的子午线,恰好是+12:00区和-12:00区的分界线。这条线很特别,因为当您从西向东穿过它时,您比伦敦早13小时,而从另一个方向比伦敦晚11小时。就像数学中的进位一样,它们的日期应该不同。当自西向东越过日期变更线时(迎着朝阳),日期应减一,反之(追夕阳)则应加一。假设一个人在中午12:00乘飞机从伦敦出发,从东到西(跟随太阳)环游世界。以这架飞机的速度,它每小时正好飞过一个时区,所以他必须每小时将手表拨回一小时(-01:00)。也就是说,我们的主角一直生活在中午12:00,太阳确实一直在头顶。他感知到的时间与手表上的时间是一致的,这让他不会说出“现在是午夜”的荒谬感。但是当他到达日期变更线时,还有另一件事要做。由于他是从东向西越过日期变更线,因此他也将日期加一。他继续前行,回到伦敦下飞机时,手表上的时间是第二天中午12:00,而当地时间恰好是第二天中午12:00,而他恰好整天在天空中飞翔。这样,所有的时间都是对齐的。但是我们仔细看时区表,会发现有的时区被标记为+13:00,+14:00,这是怎么回事?或者因为日期变更线。因为日期变更线虽然大部分在海上,但还是要经过有人居住的陆地。如果同一个地方的人分在不同的日期,会带来很多不便,所以日期线在中间转了几个角,这些角自然就出现了像+13:00,+14:00这样的诡异时区。4、夏令时一到夏天,白天就变得很长,尤其是在高纬度地区。在北极或南极,太阳终日不落,为极日。为了充分利用大自然的馈赠,有些地方会实行夏令时,也就是说在夏天,手表会人为拨快一小时,让人们早起早睡早,可以节省一些照明用电费。中国曾短暂实施过几年的夏令时,后来认为利大于弊,因而废止。但是世界上还有很多地方实行夏令时,在设计全局应用时必须考虑到这一点。5.瞬间(Instant)可能你意识到,当伦敦是中午十二点(太阳在天上)时,位于伦敦西部的巴黎应该是下午一点(太阳升起)略偏西)。但实际上它们必须指的是同一时间。想象一下,如果我在中午12:00从伦敦给我在巴黎的朋友打电话,当他接听电话时,他的手机上应该显示下午1:00。但无论是伦敦的中午十二点还是巴黎的下午一点,它们都只是同一客观时间的两种不同表现。这个与时区无关的客观时间,我们称之为“时刻”。事实上,在大多数情况下,我们应该关注这个时刻,其他所有时间都是它的衍生物或等价物。将这个客观时刻作为记录的唯一时间可以避免很多概念上的混淆。6.GMT——格林威治标准时间由于确定了时区,格林威治标准时间在国际上被记录为GMT+0。对于同一时刻,可以有多个等价表示,如12:00GMT+00:00、13:00GMT+01:00等。7.UTC—CoordinatedUniversalTime现代技术对时间精度的要求越来越高,而GMT依靠天文观测(地球自转)获得的时间远远不能满足现代科技的精度要求。于是人们改用原子钟来实现高精度计时,但是GMT有很多历史应用,直接换成原子钟计时会带来一些不兼容的问题。因此,人们在新的应用程序中创建了UTC时间来替代GMT。由于UTC不再依赖天文观测获得,地球自转一天的时间不再必然等于86400秒。如果地球的自转稍微慢一点会怎么样?一天的最后一分钟可能有61秒,称为闰秒。事实上,由于潮汐效应,地球的自转确实在不知不觉中减慢了。因此,如果你在某些系统中看到了23:59:60这样的表示,请不要急于调用BUG,先查看一下当时新闻上是否有闰秒公告。当然,为了减少不必要的转换,UTC在设计时有意与GMT对齐。在大多数情况下,两者之间没有显着差异。8.日历我们经常提到日期,但实际上并没有一个独立的概念叫日期。所有日期实际上都是某个日历系统中的日期。例如,我们可以用“1911年10月10日”来表示辛亥革命的日期,或者用“宣统三年8月19日”来表示。两者都是真实的。因此,当我们要向用户显示时间时,日期部分必须指定日历才能正确格式化。我们日常使用的默认日历系统是指公历系统,因为被大多数国家采用,所以也被称为公历。中国的传统历法称为农历或农历。同样,还有伊斯兰历、佛历等历法系统。年、月、日、星期等也是与特定历法系统密切相关的概念。因此,一旦遇到“下个月”、“第二周”等概念,首先要明白它们指的是公历系统。一些语言或者它们的默认库将日期的概念绑定到公历系统,比如Java的Date类,这使得在国际化时很难适应不同的日历系统,容易造成混淆。所以Date类的一些方法和属性被弃用,并且在Java8中引入了一些新的时间/日期类。是时候了。这个区别非常重要。如果混淆它们,在设计国际化应用程序时就会陷入歧义。1、Unix时间戳(Timestamp)Unix系统诞生的时候,需要一个数据结构来表示时间。在计算机系统资源非常有限的情况下,系统的设计者选择用一个32位的整数来表示时间,并以1970年1月1日0:00:00UTC时间为起点。随着Unix和Linux系统越来越流行,这种表示法的使用也越来越多。但是,由于是32位整数,只能表示到2038年初。但是,在新系统中,已经使用64位整数来表示时间戳,可以表示2900亿年后,也就是说没有最长时间限制。但考虑到遗留系统较多,此次迁移将是一项浩大的工程。除了兼容性问题,Unix时间戳在调试和跟踪方面也非常不友好。你很难一眼看出现在几点了。因此,尽量不要使用这种格式在API和日志中传输或存储时间数据。2、RFC2822是在Internet协议中传输的字符串,通常采用RFC2822格式。例如,格林威治标准时间2020年12月10日星期四13:49:45。这种形式虽然冗长,但没有精度限制,适用于对存储空间不是很敏感但注重可读性的场合。不过这种格式涉及到一点英语,对于非英语国家的人来说不是很友好。因此,虽然对开发调试影响不大,但在国际化应用中最好不要直接展示给最终用户。3.ISO8601/RFC3339另一种常用的字符串表示形式是ISO8601格式,如2020-12-01T00:49:45.001Z。ISO8601包含许多种子格式。其实国内使用的日期格式标准是ISO8601,只是我们日常使用的主要是它的“年月日”部分。顾名思义,它是一个ISO标准,几乎所有现代语言和库都很好地支持它,不会造成歧义。而且,它只会使用阿拉伯数字和两个字母,以及一些可选的分隔符,这对非英语用户更友好。在互联网领域,定义了另一个基本兼容ISO8601的标准RFC3339,即“{year}-{month}-{day}T{hour}:{minute}:{second}.{millisecond}{timezone}”格式,年份补零至4位,月日时分秒补至2位。毫秒部分是可选的。最后一部分是时区。前面例子中的Z其实是Zulu的缩写,也就是零时区。也可能是+08:00或-08:00等。这两个标准非常相似,但并不完全兼容。编程上下文中常用的ISO8601是指与RFC3339一样完整的子版本。即前面提到的2020-12-01T00:49:45.001Z的形式。4.人类可读格式(Human-readable)虽然我们已经有了很多存储格式,但是人类用户的需求是多种多样的。例如,有时用户只想查看“月-日”或时间的其他部分。甚至还有“刚才、五分钟前、上个月”等“人性化的格式”,显然是不完整、不规则的,不能作为存储格式。它们存在的意义,是要被人类解读的。还有另一种容易混淆的人类可读格式,例如2020-12-0100:49:45.001。为什么说它是人类可读的格式而不是ISO8601?问题的关键不在于它缺少一个T,而是它丢失了时区信息!这样,当我向伦敦同学显示这个时间时,我们将默认为当地时间。看似一样,实际时间却相差了整整八个小时。怎么回事都耽误了!与时间相关的编程要点1.只存储时间Unix时间戳,RFC2822和ISO8601存储时间,人类可读格式不是这样,因为它通常缺少关键的时区信息。因此,不要在数据库中存储人类可读的格式,而是存储时刻,否则信息将会丢失。仅在向人类显示时间时才应临时转换为人类可读格式。2.只传输时间在API中,我们应该只传输时间。由于API的提供者和消费者可能不在同一时区,如果传输的人类可读格式缺少时区,则会被解释为各自时区的时间,从而造成歧义。3、正确设置服务器时间在服务器内部,时间通常使用Unix时间戳存储,也就是UTC时间。设置服务器时间时,一般输入本地时间,服务器内部转换成时间后生效。这就要求在服务器上必须正确设置你输入的本地时间对应的时区,否则转换时会出错,服务器理解的时间和你期望的时间不一样,导致出错。如果您使用远程登录管理服务器,您可以暂时将当前会话的时区设置为您当地的时区,这样您就可以随意输入当地时间,服务器会自动为您转换。当然,如果想以其他时区的用户身份在服务器上查询,也可以将当前会话的时区设置为用户的时区,这样就可以自由使用用户想要的时间了。另一种解决方案也是可以的:将服务器设置为零时区,不再为每个会话设置时区。这样可以防止忘记,但是在服务器上输入之前,必须先将本地时间转换为零时区时间。比如今天想查询北京时间00:0012:00的日志,在服务器维护的时候,需要转换成服务器时间(零时区),也就是昨天16:00和今天4:00。两种选择各有利弊,但无论选择哪一种,请记住时区只是表象,实时才是根本。需要保证所有服务器上的真实时刻是一致的,从而记录下唯一的“真相”,以保持数据的一致性。比如服务器设置为零时区,输入的时间是你的本地时间,显然会报错。保持每个节点的真实时刻一致并不容易。幸运的是,互联网之初就设计了一个协议:NetworkTimeProtocolNTP。它可以帮助每个网络节点自动同步其实时时间。在互联网的大部分领域,其同步精度可达1~50毫秒。4.上下游服务器的时区最好保持一致。无论采用哪种方案,最好保证上下游服务器的时区一致,尤其是应用服务器和对应的数据库服务器。例如,应用服务器和数据库服务器分别设置本地时区和零时区,在应用服务器上发送SQL查询2020-01-01到2020-01-02之间的数据,那么这个时间指的是什么时间呢?它是什么?应用服务器认为它在检查本地时区,而数据库服务器认为它在检查零时区,这显然是错误的。这个问题在保存数据时更加严重。如果表中的某些时间字段由应用程序服务器填充而其他时间字段由数据库服务器填充,则时区设置的这种差异可能会导致灾难性错误。防止此类问题最简单的方法是使这些服务器的时区保持一致。如果你不能保持一致怎么办?这就涉及到接下来的几点。5.不要用“日期”。刚才说的问题,表面的问题在时区,本质的问题在“日期”。这两个日期有什么问题?问题是它没有自己的时区信息!因此,应用服务器和数据库服务器将无法在时区上达成一致!各种信息丢失问题是很多BUG的根源,这里也是一样。更重要的是,它还丢失了时间信息。既然我要传递的是“日期”,为什么还要包含时间信息呢?很简单,因为没有所谓的“约会”!我们所说的今天其实是一个时间段,指的是这个时区的今天00:00:00到明天00:00:00。如果换个时区,今天可能不是今天,而是从昨天的16:00:00到今天的16:00:00。你说今天是什么日子?所以,虽然我们在与用户交互时会用到日期的概念,但在实际程序中,我们应该始终使用时间来保持概念上的一致性。6、保存时使用应用服务器的时间可以使数据库服务器和应用服务器保持一致,但是为了简化逻辑,在保存数据时尽量提供应用服务器的时间,而不是数据库服务器的时间,这样可以简化时间。源,更容易保持一致性。我们不能相信客户端提供的时间,因为客户端节点通常不在我们的控制范围内,使用客户端数据会导致数据错误甚至安全漏洞。所以,对于需要保存的数据,以应用服务器上的时刻作为真实来源通常是最好的选择。7.查询时使用用户的时间。查询通常是从用户的角度出发。比如用户查询北京今天的数据,一般想查询北京时间今天00:00:00到明天00:00:00之间的数据。数据,无论服务器位于何处。因此,如果我们要设计一个查询今天数据的API,我们不能向应用服务器传递一个日期,因为客户端和服务器的时区可能不同,服务器无法准确理解客户端的意图。我们应该给它传递两个参数:今天这个时区的开始时间和结束时间。8.用“closed-open”间隔来表示时间段。我们在使用时间段来表示日期的时候,需要注意区间右边的开区间。也就是说,要查询今天的数据,需要查询今天的午夜到午夜。午夜至明天午夜之间的数据,但不包括明天午夜。否则即使我们用11:59:59.999来查询,这个时间点之后可能还会出现今天的一条数据。使用SQL查询数据库有个坑:BETWEEN是一个闭区间,也就是说它的结束时间是包含在统计范围内的。因此,我们应该用今晚零点>=时间AND时间<明晚零点来准确找出今天的数据。9.强制指定时区有时,用户希望使用的时区并不是用户所在的时区。例如,当用户旅行到另一个时区时,他可能仍然关心他原来的时区。除了允许用户强制修改客户端的时区外,还可以允许当前用户指定一个时区,用于在应用服务器上进行转换。但是,在这种情况下,客户端需要对日期选择器进行特殊处理,使用户感知到的日期与实际使用的日期保持一致。10.指定数据库会话的时区。我们经常需要按照年、月、日、周等标准进行统计。这个时候只指定区间就不太容易统计了。我们可以修改数据库会话的时区为用户想要的时区。例如altersessionsettime_zone='+08:00';。这样我们在SQL中使用的函数就可以得到正确的年、月、日、周等时区相关的结果。总结时间包含许多相关但令人困惑的概念。尤其是我们的日常用语往往不是很准确,这就留下了很多隐患。仔细区分这些概念,在思考的时候有意识地使用这些精确的概念,可以避免很多时间相关的bug。【本文为专栏作家《ThoughtWorks》原创稿件,微信公众号:Thinkworker,转载请联系原作者】点此查看该作者更多好文
