国际化的问题——分享下最近做支持的经验

企业级的系统和我们平常桌面、手机上运行的软件有着很重要的区别,其中比较重要的一点就是环境(包括第三方的系统的不同接口以及各系统的不同版本、安全性、数据等)比较复杂,所以不论在产品维护还是部署过程中要考虑的因素都有很多。

偶们的系统的最新版本最近在南非上线,遇到了不少问题。昨天晚上本屌和同事解决了一个比较“严重”的问题(因为影响到系统的核心功能),发现是由“国际化”的问题所引起。虽然找到问题的原因并不困难,但是要在客户的环境解决还是费了不少精力(因为不能从产品中去修复这个问题,即使给一个补丁按照公司的流程至少也需要半个月)。之前我也帮运营团队解决过一部分这类问题,在工作中也看到过不少,那么这里一并记录一下吧。希望大家在以后的开发过程中多加注意。


一、数据库的日期时间格式问题

这应该是几乎所有的系统在部署到不同国家的客户环境中都会遇到的问题,卡死不少程序,耗费无数人力。因此这是在数据存储时一定要格外注意的地方。这里以SQL Server为例。

(1)数据表中的时间格式

对于日期和时间,SQL Server中提供了datetime、datetime2、datetimeoffset、date、time这几种类型。我个人推荐只使用datetime和datetime2,因为这两个类型与.NET的程序兼容性最好(都可以直接对应成DateTime)。在.NET中也提供了DateTimeOffset类型,但是在一些比较老的语言中并不支持,为了兼容使用datetime就足够了。使用其他类型会牵扯到转型的问题,应尽量避免以免产生额外的问题。

需要注意的是,datetime支持的最小时间是1753年,因此要避免在程序中直接使用DateTime.MinValue传到数据库中,否则会产生out-of-range的错误。

在一些古老的系统中,会使用int类型来存储日期,因为int会占4个字节,所以两者互相转型是支持的,使用convert进行转型即可,一般不会有什么问题。

(2)时区的问题

建议在数据库中所有数据都以UTC时间保存,在显示的时候客户端根据本地的时区进行处理,因此在存储过程中一定要注意把时间转换成UTC时间。如果不保存UTC时间,以后在显示和迁移的过程中难免会发生混乱,所以尽量不要使用当地的时间来保存,即使系统只在一个地区使用。

在设计SSRS报表时,也要注意时区的问题。由于数据库中存储的是UTC时间,所以在调用Reporting Service服务的时候应把用户输入的时间转换成UTC时间后传给报表服务。在显示的使用,使用如下代码格式化单元格:

=System.TimeZone.CurrentTimeZone.ToLocalTime(Fields!DateTime.Value)

不要在报表的存储过程中对时区进行处理。虽然很方便且直观,但是当你的系统有几十张报表并且由不同的人开发的时候就会发现混乱的局面。

(3)报表的时间格式

这是微软考虑不周导致的问题。由于报表服务是一个Web Service,所以在实际的运行环境中无论是日期控件还是报表中输出日期的单元格都是使用IE的语言设置,而非Windows的地区。但是在设计报表的时候它又是使用的Windows的日期格式。如果客户有这类需求,那么我们至少修改IE的设置。

但是最坑人的地方是要修改的是数据库所在的服务器的IE设置,这样做会影响到所有的客户端,那么这也没办法。有时候这样做了也不见得好使,似乎又与数据库的连接用户的默认语言有关,所以这类问题我们一般不会花太多时间去修正。

好在这只是一个显示的问题,并不影响系统的正常功能,也不会出错,所以很少会听到客户抱怨这类问题。

(4)应用程序与数据库之间的日期时间传递

我们首先来看下面一段代码:

            DateTime creation_time = DateTime.Now.AddDays(-1);

            string sql = string.Format("SELECT * FROM [User] WHERE [CreationTime] < '{0}'", creation_time);

在数据库中CreationTime是一个datetime类型,而我们单引号之间的内容是一个字符串,此时SQL Server会自动对字符串进行转型,因此相当于隐式实现了一个convert语句,把{0}处的内容转型成datetime类型。{0}处实际上是一个字符串,在string.Format方法里会隐式调用creation_time的ToString方法,这个方法所返回的日期时间的格式会依据当前线程所处的语言环境来确定。

在调试的过程中似乎不会发现问题,但是当产品给一个国外的客户时,就很有可能发现SQL Server抛出out-of-range的错误,大意是将varchar转型成datetime类型时越界。导致这个问题的原因简单来说就是因为客户端的日期格式与数据库的设置不一致(更详细的下面会说明),此时系统理解的年、月、日这几个字段所处的位置不在正确的地方上,因此有时会越出月或者日的边界。

避免这个问题的根本解决办法是在访问数据库的时候只使用参数形式,根本不应该出现非参数的拼接形式(同时可以避免SQL注入以及其他bug),如果你在做代码审查,看到上面这种代码应该果断reject回去。使用参数形式的代码大概是这样:

            SqlCommand command = new SqlCommand("SELECT * FROM [User] WHERE [CreationTime] < @creationTime", connection);

            command.Parameters.AddWithValue("creationTime",  creation_time);

这样,就会避免将DateTime类型转型成字符串,直接使用SQL Server兼容的类型访问,就避免了格式不一致导致的转型失败(或者转换成错误的结果)。

我们在系统日志中发现某个模块频繁抛出越界的错误,就是因为没有使用参数的形式进行访问。与此对应的系统其他模块都没有这个类型的错误。

(5)SQL语句中的日期格式转型问题

这就是昨天做支持过程中遇到的一个非常诡异的问题。在我们的产品的SQL语句中有一段类似下面这样的代码:

DECLARE @startTime VARCHAR(100)

SELECT @startTime = CONVERT(VARCHAR, timeIn, 23), @endTime = CONVERT(VARCHAR, timeIn + 1, 23) FROM...

我们不谈论这段代码写得怎样,我们只关心为什么会出错。在客户现场中,客户的描述是:“在前一天系统还是好的,但是在换班后(我们的系统执行换班操作后日期会往后加一天),系统就无法操作。”

分析日志发现抛出的错是日期转型越界,出错的代码片段应该就是在这个地方,那么为什么会出错呢?

经过一番折腾,发现了SQL Server的一个隐藏炸弹(还好有测试的同事在其他系统见过这个问题,否则也不会往这个方向去想):对于每一个有权限访问数据库的用户(包括Windows登录方式和SQL Server登录方式),在用户的属性中都有一个称之为“默认语言”的属性。在我们公司的产品中,基本只会在British English和English这两者之间进行选择。

这个属性会影响到SQL语句中牵涉到日期与字符串转型时的处理。因此,在SQL语句中转型是与这个“默认语言”有关系的。同时,还会与客户端(访问数据库的服务所在的机器)的本地时间格式有关。

客户的数据库登录用户的默认语言选择是British English,而数据库服务器和客户端的时间格式配置都是南非的时间格式。这种不一致可能会导致一些不可预知的问题。我们要用户把默认语言给成English(注意:1. 改了之后要重启SQL Server的服务使之生效; 2. 特别注意修改的是访问数据库的用户,每个用户都有这个属性),然后问题莫名其妙地就解决了。

但是后来我们在实验环境中按照这种配置都无法重现这个问题,我觉得有可能是客户修改了一些日期格式的原因所导致。

今天早上我做了一个实验,把默认语言设置成British English,而客户端的时间格式是中国的格式,此时上面的timeIn + 1得到的结果竟然是月份加1!这样就使我们恍然大悟:难怪客户在前一天没有问题,而过了一天问题才出现。因为出现问题的时间是6月12日,由于某种特殊的配置(我们至今还是没尝试出是何种格式),系统把12当成是月份+1,得到13就越界了,而使用6月11日就不会出越界的问题,但结果是错误的。

这显而易见是微软的一个bug,无论何种配置,都不应该转型出错,即使是月份+1,应该会进到年的字段去。

上面这段SQL语句的正确写法应该是使用DATEDIFF进行日期运算,而且更不应该出现第三个参数“23”这种直接指定格式的输出。

(6)结论

通过上面的分析,有一点可以肯定的是:在编码过程中,除非仅用于显示,否则必须要避免日期和字符串的相互转换

日期转型成字符串应该只在客户端显示的最后一步进行转型,而字符串转换成时间应该只在应用程序中使用DateTime提供的与时间格式相关的方法进行完成。一定要避免使用字符串进行日期和时间的运算,即使是在SQL语句中也是如此

这样应该就能避免由时间格式所导致的问题,而且也不会碰到(5)里面那种诡异的问题:即使知道问题出在哪,也不知道怎么样设置才不会出这种问题。

上面是着重阐述的与数据库相关的日期格式问题,下面再提两种我遇到的小问题。


、Web Server的日期格式问题

微软的.NET与JSON格式兼容得不是很好,我碰到了两个问题。

(1)JSON解析DateTime格式

JSON的时间格式和.NET完全不一样。DateTime说白了就是一个8字节的二进制位,而JSON是以字符串来表示日期的。JSON的日期类似于“/Date(1242357713797+0800)/”。

一种比较懒的处理方式是直接使用eval语句让系统自动解析,这种方法的效果比较好(性能就不知道了),转换后可以按照当地的日期格式进行输出,比较方便。

(2).NET解析JSON的日期格式

从JSON的字符串进行解析时,除了把中间的数字进行运算外(1970年1月1日+数字*10000作为DateTime类型的Ticks),还要考虑后面的时区。处理比较麻烦。

当然可以想一个偷懒的办法,那就是直接传字符串而不通过JSON的日期格式:既然客户端显示的是本地时间格式,那么如果服务器的时间格式也是一样的就可以直接用DateTime的Parse就完事了。这种方式适合偷懒人士(比如我),暂时也没有发现什么问题,除了接口类型不太好看以外。

(3)微软的bug

又是时间格式引发的bug!将Web Server的格式设置为南非的以后,发现所有将DateTime序列化成JSON格式的代码全都报错:日期时间转换越界。在调用堆栈中全部都是系统的代码,将时间格式设置成美国后问题消失,因此可以肯定这是微软的一个bug(在4.0版本的.NET Framework中,不知道是否修正了)。

那么只能让客户使用美国的时间格式凑合着用了,我们也只能在下一个版本解决这个问题。方案很简单,直接转换成字符串传给客户端就行了,这样客户端也不必写代码去解析DateTime格式。

由于.NET本身就和JSON的兼容有缺陷,所以使用上述办法也并无不妥。假如客户有国际化的需求,其实可以定义一种中间格式(比如传输的过程中都以中国的格式为准),客户端和服务器按照这个中间格式进行解析,就能避免由格式不配套产生的转型错误。


三、文本的国际化处理

这个说起来很简单,只要改变编码习惯就可以了。在一个资源文件(比如XML格式)中首先以默认语言(比如中文)存储所有需要显示出来的字符串以及对应的名称(名称最好按功能+内容来命名,不要用数字编号,这样可以避免在有10000个记录的资源文件中不确定是否有某个需要的字符串的尴尬),然后依葫芦画瓢制作其他的语言包。

在应用程序中,推荐使用MVVM或者MVP模式进行数据绑定,令其作为静态资源定位到一个中间的静态类,由这个类决定使用哪种语言包。这样做的好处是我们不需要写代码去初始化界面上各种文本标签(试想我们在窗体加载的使用写一大堆语句去设置Label或者Menu的Text?)。使用数据绑定,系统会在需要显示这个资源的时候访问静态类去取文本内容。

微软已经考虑到这个问题并把它做得很完善(除了一个小bug外我们暂时没发现其他的bug,被上面的两个微软的bug搞怕了吧?这个小bug影响不大在此不做说明),你可以参考这里:http://msdn.microsoft.com/en-us/kb/ms745650(v=vs.80)

这样做的好处是:在程序编译后会生成几个文件夹,这些文件夹按语言的名字来命名(比如zh-cn表示简体中文),文件夹中有资源文件的dll。在系统启动的时候可以让用户选一种语言,然后设置CultureInfo,这样系统就会自动去相应的文件夹取这个语言包里的字符串。还有一个很人性化的功能,就是如果在对应语言的资源文件中没有找到这个字符串,就会从默认的语言包中取出并显示。(比如我们产品有繁体中文的语言包,如果有些地方没有进行翻译,在繁体中文的语言包中就不会有这个字符串,此时系统会显示出英文语言包中的字符串。)

以上是我遇到的处理国际化问题的一些典型例子,如果你遇到了一些这样的问题,欢迎指出并共同探讨。

✏️ 有任何想法?欢迎发邮件告诉老夫:daozhihun@outlook.com