2.4 编码国际化
前面在提到字符串编码时展示了一些不同文化下需要特殊对待的场景。今后越来越多的中国企业可能出海,因此我们不可避免地要考虑全球可用的编码场景。国际化不仅仅包含下面这些要求。
1)同一套代码适应全球所有书面语言。
2)使用Unicode编码存储和处理字符。
3)将用户界面上需要翻译的文字与代码分离,便于翻译人员翻译。
不同国家在技术、文化等方面的差异如下。
1)不同语言表达同一个概念的文字长度可能不同,例如中国汉族人的名字通常很短,在界面上可能只有三四个字符宽度,而俄罗斯、东欧国家人的名字很长,界面需要预留足够的空间或者考虑其他方案来处理这种情形。另外不同的书写习惯,如汉语是从左向右书写,而阿拉伯文等语言是从右向左书写。
2)不一样的数据和时间计量法,世界大部分区域使用公制,如米、千克等,而美国习惯英制,如英尺、磅等。在纪年方面,日本有按年号纪年的方式,如“平成22年12月5日”。
3)时间和数字的不同表示方法,“1/9/2019”在英国文化里表示2019年9月1日,在美国文化里表示2019年1月9日。如在美国使用句点“.”作为小数分隔符,在德国则使用逗号“,”作为小数分隔符。
4)还有文化和政治上也有考量,例如软件要同时在有边界争议的国家销售,且要显示地图的话,则要慎重考虑对争议边界的处理。
5)键盘布局也存在不小的差异,除了国内常见的QWERTY键盘布局(见图2-17)以外,还有很多其他键盘布局,如法语世界使用的AZERTY布局(见图2-18)等。微软官网提供了世界上所知的键盘布局,有兴趣的读者可参阅:https://docs.microsoft.com/en-us/globalization/windows-keyboard-layouts。
图2-17 QWERTY键盘布局
图2-18 AZERTY键盘布局
即使操作系统对键盘布局的差异尽量做了隐藏,在一些场景中还是会有键盘布局导致软件不能使用的情况,例如在大多数浏览器的JavaScript程序中,同样是按键事件,用户在QWERTY键盘上按下Q键,event.code返回的是KeyQ,event.key的值是q。而在AZERTY键盘上按下A键,event.code的值也是KeyQ,event.key的值却是a。
关于编码国际化和本地化内容,本书无法做到穷尽,有兴趣的读者可以参阅微软官网的系列文章:https://msdn.microsoft.com/zh-cn/goglobal/bb688110和https://docs.microsoft.com/en-us/globalization/。在.NET中,所有与区域性相关信息的类都定义在System.Globalization命名空间。最核心的类是CultureInfo,它包括书写系统、日历、字符串排序规则、日期和数字的格式化设置等信息,是大部分国际化编程的入口类型。
- CultureInfo.CompareInfo属性返回的是一个CompareInfo对象,包含比较和排列字符串的规则。
- CultureInfo.DateTimeFormat属性返回的是一个DateTimeFormatInfo对象,包含格式化日期和时间的设置。
- CultureInfo.NumberFormat属性返回的是一个NumberFormatInfo对象,包含格式化数字的设置。
- CultureInfo.TextInfo属性返回的是一个TextInfo对象,包含区域设置的书写系统信息。
代码清单2-22演示了不同国家对日期的处理。可以看到,即使都是使用英语的国家,英国(文化代码:en-GB)和美国(文化代码:en-US)表达日期的习惯大相径庭。
代码清单2-22 不同文化下对日期的表达方式
// 源码位置:第2章\GlobalizationDemo.cs // 编译命令:csc GlobalizationDemo.cs 01 var culture = CultureInfo.CreateSpecificCulture("en-GB"); 02 var date = DateTime.Parse("1/9/2019", culture); 03 // 输出: 2019年9月1日 星期日 04 Console.WriteLine(date.ToLongDateString()); 05 culture = CultureInfo.CreateSpecificCulture("en-US"); 06 date = DateTime.Parse("1/9/2019", culture); 07 // 输出: 2019年1月9日 星期三 08 Console.WriteLine(date.ToLongDateString()); 09 // 输出:1/9/19 12:00:00 AM 10 Console.WriteLine(date.ToString(culture)); 11 // 输出:1/9/19 12:00:00 AM 12 Console.WriteLine(date.ToString(culture.DateTimeFormat)); 13 // 模式: yyyy'-'MM'-'dd'T'HH':'mm':'ss,输出:2019-01-09T00:00:00 14 Console.WriteLine(date.ToString( 15 culture.DateTimeFormat.SortableDateTimePattern)); 16 // 模式: dddd, MMMM d, yyyy,输出: 星期三, 一月 9, 2019 17 Console.WriteLine(date.ToString(culture.DateTimeFormat.LongDatePattern)); 18 Console.WriteLine($"进程的区域设置: {CultureInfo.CurrentCulture.Name}," + 19 $"UI界面的区域设置:{CultureInfo.CurrentUICulture.Name}");
代码清单2-22第1行演示了指定区域名创建CultureInfo的方法,这里创建的是英式英语的区域设置,GB表示Great Britain;第3行用en-US创建美式英语的区域设置。由于时间、数字这些表现形式都需要考虑文化差异,因此这些类型的ToString和Parse方法有一个接收IFormatProvider参数的重载。之所以是IFormatProvider接口,而不是CultureInfo类,是因为CultureInfo、NumberFormatInfo等类型都实现了IFormatProvider接口,所以可以混用。
程序启动后,自动从操作系统里获取当前的区域设置。CultureInfo.CurrentCulture静态字段用来提供这种信息,以控制数字、时间、货币等的区域设置。CultureInfo.CurrentUICulture字段用来控制UI界面的本地化和翻译方面的设置。在Widnows操作系统里,控制面板的区域和语言设置用来控制系统级别的文化区域设置,如图2-19所示。通过Windows系统的区域设置,我们可以了解程序本地化需要考虑的事项。
图2-19 Windows系统下的区域和语言设置
- 时间、日期的展现方式。
- 日历的差异,如中国的农历和阳历。
- 货币符号和金额的展现方式。
- 数字的展现方式。
- 地址和电话号码的展现方式。
- 度量衡系统,包括尺寸的差异。
- 字符串的对比和排序规则。
.NET几乎为世界各个国家的区域设置实现了对应的CultureInfo。通过代码清单2-23,可以获取.NET中所有区域设置的信息。注意,第3行在遍历.NET支持的所有区域设置时,使用位操作将中性的区域设置忽略掉了。
代码清单2-23 获取.NET中所有区域设置信息
01 // 获取.NET支持的所有区域以及名称 02 var cinfo = CultureInfo.GetCultures( 03 CultureTypes.AllCultures & ~CultureTypes.NeutralCultures); 04 foreach (var c in cinfo) 05 Console.WriteLine($"Name:{c.DisplayName},Code:{c.Name},LCID:{c.LCID}");
还有一些人造区域设置,例如为电影《星球大战》的克林贡语言、《阿凡达》的外星语言定制区域设置。要实现定制的CultureInfo,我们也可以采用类似代码清单2-24的办法实现IFormatProvider接口。它只有一个GetFormat方法需要实现。代码清单2-24中的第6行和第8行分别使用typeof关键字判断调用方需要获取的区域设置,比如时间设置、数字设置等,并根据判断结果返回适合该文化的区域设置。
代码清单2-24 定制CultureInfo
// 源码位置:第2章\GlobalizationDemo.cs // 编译命令:csc GlobalizationDemo.cs 01 class DemoCultureInfo : IFormatProvider 02 { 03 public Object GetFormat(Type formatType) 04 { 05 Console.WriteLine($"Type: {formatType}"); 06 if (formatType == typeof(NumberFormatInfo)) 07 return NumberFormatInfo.CurrentInfo; 08 else if (formatType == typeof(DateTimeFormatInfo)) 09 return DateTimeFormatInfo.CurrentInfo; 10 else 11 return null; 12 } 13 } 14 15 date = DateTime.Now; 16 // 演示IFormatProvider的实现 17 str = date.ToString(new DemoCultureInfo()); 18 Console.WriteLine(str);
既然有很多信息因区域而异,在跨区域保存和传输这些信息时,我们就不应该依赖程序当前运行环境的区域设置,这是一种很常见的编程错误。对于日期和时间处理,我们可以参考下列几种做法。
1)将日期和时间使用二进制格式保存,如保存在DateTime.Ticks字段中。
2)使用CultureInfo.InvariantCulture或者自定义的格式字符串获取日期的字符串展现方式,并使用它从字符串中解析日期。
3)如果日期字符串要在不同时区的进程上处理,建议先将日期转化成UTC日期,再使用UniversalSortableDateTimePattern和RFC1123Pattern来格式化和解析日期字符串。
代码清单2-25演示了这几种做法的编码方式,如第2~3行先将日期转换成UTC日期,再使用UniversalSortableDateTimePattern保存,这样即使使用其他区域设置还是可以正确地还原日期数据。第12行演示InvariantCulture的格式化和解析方法。它的缺点是只能处理固定格式的区域设置,如果采用其他区域设置解析,只能看运气,如第19行被注释的代码。第22行是以二进制格式保存时间和日期。这种方式实现方式最简单,但由于是二进制格式,需要传输数据的两端进程都事先约定好通信协议。对于同一个组织来说,二进制格式比较方便,但对于跨组织来说,由于沟通和文档传输难度大,字符串格式的数据容易理解。
代码清单2-25 在多区域设置间传输日期数据的方法
// 源码位置:第2章\GlobalizationDemo.cs // 编译命令:csc GlobalizationDemo.cs 01 date = DateTime.Now; 02 var str = date.ToUniversalTime().ToString( 03 CultureInfo.CurrentCulture.DateTimeFormat.UniversalSortableDateTimePattern); 04 Console.WriteLine(str); 05 var parsed = DateTime.Parse(str, new CultureInfo("en-US")); 06 Console.WriteLine($"1. {date} == {parsed}"); 07 parsed = DateTime.Parse(str, CultureInfo.CreateSpecificCulture("en-GB")); 08 Console.WriteLine($"2. {date} == {parsed}"); 09 parsed = DateTime.Parse(str, CultureInfo.CreateSpecificCulture("zh-CN")); 10 Console.WriteLine($"3. {date} == {parsed}"); 11 12 str = date.ToString(CultureInfo.InvariantCulture); 13 parsed = DateTime.Parse(str, CultureInfo.InvariantCulture); 14 Console.WriteLine($"4. {date} == {parsed}"); 15 // 下面的代码可以解析,是因为zh-CN刚好和InvariantCulture兼容 16 parsed = DateTime.Parse(str, CultureInfo.CreateSpecificCulture("zh-CN")); 17 Console.WriteLine($"5. {date} == {parsed}"); 18 // 下面的代码会报告解析异常,是因为en-GB刚好与InvariantCulture不一致 19 // parsed = DateTime.Parse(str, CultureInfo.CreateSpecificCulture("en-GB")); 20 21 // 使用二进制格式保存时间和日期 22 var ticks = date.Ticks; 23 parsed = new DateTime(ticks); 24 Console.WriteLine($"6. {date} == {parsed}");