级别: 初级 童春杰 (tongcj@cn.ibm.com), 软件工程师,
IBM
2009 年 5 月 14 日
本书是介绍全球化基本体系结构、技术和方法的经典力作。全书共18章,介绍了全球化的背景、Java开发中的国际化技术、全球化测试、常见问题的解决方法、DB2数据库等知识,并以一个完整的全球化开发实例,指导读者利用该用例中的方法和实现,自行实施一个精巧、完整的全球化开发项目。
本章介绍了 Java 编程语言和 Java 核心类库对国际化编程的支持,以及使用 Java 开发桌面应用程序所采用的技术,并简要介绍了 ICU4J 对 Java 标准库国际化支持的增强。
在此我们推出了本书的 前言 和第 4、5、6 章供大家在线浏览。更多推荐书籍请访问 developerWorks 图书频道。
 | |
|
|
书名:全球化软件开发最佳实践
作者:童春杰 周皓峰 杨普 舒芳蕊 等编著
出版社:电子工业出版社
出版日期:2008 年 6 月
ISBN:978-7-121-06315-2
购买:
中国互动出版网、卓越网
| |
推荐章节:
更多推荐书籍,请访问 developerWorks 图书频道。
欢迎您对本书提出宝贵的反馈意见。您可以通过本页面最下方的 建议 栏目为本文打分,并反馈您的建议和意见。
如果您对 developerWorks 图书频道有什么好的建议,欢迎您将建议发给我们。
|
|
|
Java语言是由Sun公司在1995年创建的,最初的设计目的是用于嵌入式设备的应用开发,但是在其诞生的十多年后,Java在很多软件开发领域获得了巨大的成功,成为最流行的编程语言之一。Java语言最大的特点是其平台无关性及“一次编写,到处运行”,此外它成功的原因还有纯粹的面向对象、强调开发的高效和安全、丰富的类库等,也包括对国际化编程的良好支持。
Java的设计者在很早的时候就意识到在语言级别支持国际化编程的重要性,因此从Java1.0开始,就决定将Java中的字符基本类型(char)用16位的Unicode来存储,而Java类库中直接处理单个字符的类也都支持Unicode,比如正则表达式工具包java.util.regex等。Java标准版的类库也对国际化有非常好的支持,比如区域和本地化、日期时间处理及字符编码转换等。这些都使得Java成为一种非常适合用来开发国际化应用的编程语言。
Java丰富的平台无关人机界面组件也吸引了桌面应用程序的开发人员。桌面应用程序通常指主要运行于个人计算机上、为个人用户提供服务、具备可独立执行代码的应用程序。桌面应用程序的最大特点是:它是完全服务于个人的。这主要体现在以下两个方面:首先,桌面应用程序是直接和使用者交互的;其次,在同一时间,桌面应用程序不会存在多人访问的情况。因此,在使用Java语言开发国际化的桌面应用程序时,其中用到的类库和技术也与其他类型的应用不尽相同。
另一方面,尽管Java对国际化编程的支持已经非常好,还是有些特殊的要求难以仅从Java语言内建支持和Java标准库中得到满足,于是一些专门致力于国际化编程支持的第三方类库便应运而生。ICU4J(Internatioal Components for Unicode for Java)就是其中很优秀的一个开放源代码工具包。
本章将首先介绍Java编程语言和Java核心类库对国际化编程的支持,然后讨论使用Java语言开发桌面应用程序时的技术,最后简要介绍第三方Java国际化工具包ICU4J等。本章主要针对Java标准版5.0,对2006年发布的Java标准版6.0的一些新特性也会略有提及。
请注意,本书所推荐的所有解决方法仅供参考,我们不确保它是最佳的解决方案,也不能保证它在所有环境下都会正确无误地工作。
5.1 区域(Locale)和本地化信息
区域(Locale)是区分不同文化习惯的标识。软件国际化的一个根本目标,就是同一套应用程序的可执行代码能够为不同区域的使用者提供不同的交互体验,以满足其特定的文化习惯。一般来说,不同的使用者可能会来自不同的区域,但是同一个使用者只会属于某一个特定的区域。桌面应用程序的最大特点是服务于个人,它响应使用者的输入并提供输出信息,而且不存在多人同时访问一个桌面应用程序运行实例的情况。因此,对于一个国际化桌面应用程序而言,在任何时候都只需要关心一种区域,即当前使用者所属的区域。
一般情况下,国际化的应用程序中,所有需要“人”输入的信息和输出给“人”看的信息都必须是经过本地化处理的信息,即本地化信息。这里的“人”指自然人,相对于其他程序或设备而言。作为使用者的人都会具备区域的属性,使用者乐意使用自己所熟悉的区域的文化习惯和应用程序进行交互,而不是使用陌生的方式。本地化信息一般都是通过区域来分层次管理的。
本节将介绍在Java语言中进行管理区域和本地化信息的技术。
5.1.1 区域管理
1.区域标识
区域标识唯一表示了某一个范围的区域。一个区域标识通常包含以下三个部分:
语言
国家或地区
变体
其中,语言部分一般来说是必需的(根区域除外),而国家或地区和文化变体部分是可选的。语言是指人类的自然语言,如英语(en)、法语(fr)和中文(zh)等,一般使用ISO 639标准规定的语言标识,由两个小写英文字母组成。国家或地区则代表了一个地理上的位置,一般使用ISO 3166标准规定的国家或地区标识,由两个大写的英文字母组成。变体是在语言和国家或地区给定的基础之上更细化的部分,例如中文简体(Hans)和中文繁体(Hant),一般可由程序自行规定。
这三个部分是逐级细化的关系,例如,对于zh、zh_CN、zh_CN_Hans、zh_TW和zh_TW_Hant这5个区域来说,zh代表了一个最大的范围——中文区域,但它所包含的信息量却是最少的,因为它包含的信息必须是其他4个同属zh的区域都包含的共同的信息。zh_CN和zh_TW则更进一步,它们除了蕴含所有zh所含的信息之外,还各自含有分别针对中国大陆和中国台湾的特定信息。zh_CN_Hans和zh_TW_Hant目前在Unicode组织制定的区域数据的国际标准中作为zh_CN和zh_TW的别名存在,但是它们本身也暗示其可能会在未来得到进一步的细化,例如,如果以后中国台湾也同时使用简体字,那么就可以用zh_TW_Hans和zh_TW_Hant来区分中国台湾简体中文和中国台湾繁体中文了。
还有一种特殊的区域——根区域,它的区域标识中的三个部分都是被省略的。根区域包含了所有和语言无关的数据,也可以看做是和国际化无关的数据。在实际的软件开发中,根区域往往包含了默认的英文区域(如en_US)的内容,这样,即使在不指定任何区域的情况下,程序仍然能够获得所需的数据并正常运行。
2.Locale类
Java语言通过java.util.Locale类来表示区域,一个Locale实例就代表了一个特定的区域。Locale类中包含三个属于String类的实例成员:language, country和variant,它们分别代表了一个区域的语言、国家或地区和文化变体。当某一个部分在区域中被省略时,使用空字符串来标识(不能为null值)。当两个Locale实例用equals方法比较时,实际上就是这三个部分在作比较,只要这三个部分的字符串值都对应相同,那么两个Locale实例也相同,即代表了同一个区域。
Locale实例通常是很多Java提供的区域相关的功能中的重要参数,但是所有的区域相关的功能一般都会提供两个调用界面,一个是带区域参数的,另一个是不带区域参数的,例如字符串的大小写转换方法就有两个版本:String.toLowerCase()和String.toLowerCase (Locale)。不带区域参数的版本并不是不考虑区域,而是隐含使用了系统默认的区域标识。可以使用Locale.getDefault()来得到这个系统默认区域。这里推荐在任何情况下,使用Java中的区域相关功能时,都用不带区域参数的调用界面,然后通过设置系统默认区域的方法来改变程序的行为。因为这种做法既可以集中控制程序中的区域设置,又简化了程序的编写,即不需要处处都写上区域参数。注意,这里只推荐桌面应用程序使用这种方法,本书后续章节中将会讨论,对于需要同时处理多用户多区域的服务器程序而言,这种做法恰恰是应该极力避免的。与桌面应用程序相反,服务器程序需要在任何情况下都必须调用含有区域参数的版本,否则,不同区域的用户都只能得到一种区域的信息,即服务器程序本身所在系统的默认区域,这是完全不符合国际化要求的。
设置系统默认区域一般有以下三种方法:
设置操作系统的区域。
启动Java程序时对JVM使用-Duser.language参数。
在程序内部调用Locale.setDefault(Locale)静态方法。
第一种方法即不特别针对Java程序进行区域设置,而直接默认采用操作系统的区域设置,因为JVM本身针对不同的操作系统实现了读取其区域设置的功能。例如简体中文操作系统中,在默认情况下运行Java程序,则由Locale.getDefault()返回的默认区域是zh_CN。第二种方法是使用JVM启动参数,比如:
java com.example.Test -Duser.lanuage=fr_CA
|
则不管操作系统的默认区域是什么,此Java程序运行时的默认区域都是fr_CA(加拿大法语)。第三种方法用于在程序运行过程中动态改变默认区域设置,在Locale.setDefault (Locale)执行之后的所有区域相关功能都将会采用新的默认区域设置。
这里还需要特别注意的一点是如何表示一个区域,即如何获得一个代表特定区域的Locale对象。Locale对象本身是普通的Java对象,新建的即使是代表同一个区域的Locale实例也将占用不同的内存空间,因此应当尽量减少不必要的创建新Locale对象。Locale类本身就提供了不少常用区域的区域对象,它们都是Locale类的静态常量。如果程序需要表示的区域不在这些常量中,则应该自行定义常量,而不应处处新建Locale对象。
此外,新建Locale对象时需要注意的是,Locale的构造函数并不会自动解析或者拆分一个完整的区域标识,解析的工作仍然应该由程序自行完成。例如:
System.out.println(Locale.US + " " + Locale.US.getLangauge() + " " +
Locale.US.getCountry());
|
将打印出:
但是new Locale("en_US")并不能创建一个和Locale.US相同的区域对象:
Locale loc = new Locale("en_US");
System.out.println(loc.getLanguage() +" "+ loc.getCountry());
|
将打印出:
而不是:
原因在于Locale的构造函数将传入的“en_US”仅看做是语言标识,并做了字母小写化的处理。正确的做法是在程序中先自行解析“en_US”,然后再调用:
Local loc = new Locale("en", "US");
|
3.切换区域
应用程序中最基本的国际化功能就是能够切换不同的区域。前文提到,在桌面应用程序中,应该遵循在使用一切区域相关的功能时都基于默认区域设置的原则,这里的切换区域就是指切换默认区域设置。程序中切换区域通常会有以下三种方式:
无独立的区域切换设置。
有独立的区域切换设置,但需要重新启动操作系统或者重新启动应用程序。
有独立的区域切换设置,无须重新启动应用程序,区域切换即时生效。
第一种无区域切换设置是最简单的做法,区域设置完全依赖于用户的操作系统设置或者JVM启动参数。第二种方法最为常用,通常应用程序提供的区域设置只是简单地帮助用户修改了JVM的启动参数,新区域的生效依赖于重新启动应用程序。而对于一些带有系统后台服务程序的应用而言,虽然其区域设置是独立于操作系统的,但仍然必须重新启动操作系统才能完全完成区域的切换。第三种方法最为用户喜欢,但是最难实现且易于出错,因为动态切换区域将带来必须刷新所有已存在的界面信息的麻烦。
因此,如果程序相对简单,可以采用第三种做法。而一般来说,推荐采用第二种做法,即默认使用系统的区域设置,同时提供应用程序自己的区域配置界面,保存下来的用户自定义的区域将在下一次程序启动时作为JVM的启动参数。
5.1.2 本地化的信息
本地化的信息通常包括以下几种类型:
文本。
图形和记号。
视频和音频。
本节主要说明使用这几种本地化信息时可能会遇到的一些问题,针对这些问题的具体解决方案将会在后续章节中详细讨论。
1.文本
无论是采用命令行界面还是图形用户界面,文本是用户和应用程序交互的不可缺少的媒介,很难找到一个完全不包含任何文本输入和输出的应用程序。文本在桌面应用程序中更是无处不在,比如提示、消息、名称、标签等。
对于文本来说,第一,最常被考虑的本地化工作就是文本的翻译,这里经常出现的一个问题是翻译所带来的文本长度的变化。不同语言在表达相同的意思时所需要的文字长度往往是不一样的,例如中文大都短于英文。如果程序的界面在设计时严重依赖于某一种语言文字的长度,那么在文本被翻译到另一种语言时就会带来不小的问题。较轻的问题是文本长度变短,例如英文到中文,此时界面上可能会出现多处空白,或许只是影响美观。较为严重的问题则是文字变长,例如从英文到西班牙文,那么翻译之后多出的文字很有可能无法在原有的界面上显示出来。
显然,为不同语言的文字编写不同的界面并不是一个好的解决方案,一个办法是不要将界面元素的大小硬编在程序代码中,而应该尽量使得界面元素能够自适应其包含的文字,即根据其包含的文字来自动调整大小。
第二,需要考虑的是数据的文本化,即格式化,以及反向解析,即从用户输入的字符串中解析出数据。程序中常用的数据包含以下几种:
数字:如1234567.89。
货币:如123.45元人民币。
日期和时间:如2005年7月18日9点10分20秒。
每一种数据在不同的区域下必须被显示为不同的格式。例如,对于数字而言,通常会有千分位和小数点的区别,1 234 567.89在美国英语下显示为“1,234,578.9”,而在法语中则被显示为“1 234 567,89”;对于货币来说,则是货币符号、符号的位置、小数点位数(比如日元是没有小数的)都有可能不同。
所有被显示出来的格式还必须能够被程序的输入所接受,例如,程序在法语区域下将数字显示为“1 234 567,89”的样子,那么程序中负责接收用户输入数字的输入框也应该能够接受类似“89 765,4321”的输入,并正确解析出数字89765.4321。当然,有一些数据在约定俗成中是和区域无关的,就没有必要按照区域的要求被格式化,例如,表示程序行号的数字通常都是被直接显示为不带千分位符号的。
第三,文本的连接。除了非常简单的标签文本,程序中绝大多数的文本几乎都是经过几段文本的连接而产生的,因为很多需要显示的文本信息不可能在程序运行之前就全部准备好。例如,当数个文件被复制成功后,程序可能会显示诸如“已经复制了10个文件”这样的消息,而其中的“10”就是程序运行时才产生的内容,它需要和前后的文本进行接合才能变成完整的信息。
处理文本的接合时,最重要的是绝对不可以直接使用字符串“加”这样的操作来将文本串接起来,必须使用文本模板然后填空的方法来处理。这是因为在不同的语言中,语言成分和元素的位置是完全不一样的。如果用字符串“加”的方法,则可能完全没有办法在不修改程序的情况下将文本翻译成另一种语言。例如,前一个例子中如果一开始使用英文,则可能是“10 files have been copied”,写在程序中如果写成:
String message = fileCount + FILES_HAVE_BEEN_COPIED;
|
那么无论怎么翻译FILES_HAVE_BEEN_COPIED的值,也不可能达到在中文环境中显示成“已经复制了10个文件”的效果。
第四,对文本进行的处理必须考虑一些特殊的Unicode字符的情况,否则可能会产生错误甚至导致程序崩溃。这些特殊字符主要包括以下几类。
增补字符:通常的Unicode字符就是一个Java中的char类型数据,但是存在着这样的一些增补字符,它们是由2个char类型数据组成的,代表从U+10000~U+10FFFF。这2个char类型数据是有范围规定的,前一个char的范围只能是从\uD800~\uDBFF,后一个则只能是\uDC00~\uDFFF。单独出现的、或者违反了前后关系出现的任何一个从\uD800~\uDFFF中的char类型数据都被视为不合法的Unicode数据。如果一个字符串中包含了这样的不合法数据,那么在做编码转换等操作时就会有无法预料的异常抛出,可能引起程序的崩溃。
组合字符:西方拉丁语系的很多字符都带有音调符号,这些带有音调符号的字符可以通过组合字符的方式由多个字符组合而成,即前一个是普通的不带音调的字符,而后一个是音调字符。系统会自动把组合字符渲染在一起,组成一个逻辑上的单个字符。组合字符的处理相对比较复杂,涉及合并、比较、分界等很多操作。
特殊属性的字符:一些字符含有特殊的属性,如空白字符。在做一些文字操作的时候可能会遇到,例如,去除字符串前后的空白字符时,就需要不仅仅去掉空格、制表符等通常的空白字符,还需要考虑其他的具有空白字符属性的字符。
第五,需要考虑的是字体的问题。不论是从实用角度还是从美观角度而言,几乎没有一种字体可以包含世界上的所有文字(这里的所有文字指的是Unicode标准中定义的文字)。一个良好的国际化应用程序应当做好以下两点:可以美观地显示当前界面上的文字;能够接受和显示(不一定要美观)非当前区域的其他语言的字符。
对于第一点要求而言,不同区域下应该使用不同的字体(包括字形和字体大小)来进行界面文字渲染,或者如果程序的区域设置和操作系统一致的话,程序也应该使用和操作系统一致的字体。为了满足第二点要求,应用程序需要正确的配置逻辑字体,虽然可能一种字体不能显示所有的字符,但是通过不同字体组合成新的逻辑字体,就能达成显示绝大多数字符的目的。
最后,文本的排版对于一般的桌面应用程序来说可能并不需要太多的关注,即使排版有差错也是比较细微的问题,但是对于字处理程序来说则是必须要考虑的。文本的排版涉及很多问题,其中最常见的问题是处理文本的自然换行。当文本超出容器的宽度时,需要进行换行,但不同的语言中文本换行的规则也是大不相同的,例如英文等拼音文字是以词为单位来换行的,中文则是以字符为单位来换行。当涉及标点符号时,规则更是多样,例如,什么标点可以放置在行尾或者行首、什么标点不可以、多个标点放在一起时如何处理等。
2.图形和记号
图形和记号包括图标、标记、旗帜、背景图案等,它们是和文化习惯密切相关的。一般的桌面应用程序中的图形都是静态的,其目的是为了更直接、更形象化地表达某种含义,因此图形的本地化并没有文本那么复杂,但是很多时候图形也不可避免地需要进行翻译。需要针对不同的区域来使用不同的图形往往是由于以下几种因素导致的。
第一种情况是图形中包含文字。比如,字处理软件中表示“拼写检查”的按钮上的图标在中文下可能含有“文”的字样,而英文区域下则显示为含有“A”。通常图形中尽量不要含有文字,尤其是较长的文本,这样不便于翻译和理解。一个很普遍的反面示例是很多字处理软件在所有区域中,表现“粗体”、“斜体”和“下划线”这三种文字格式的按钮图标中都使用了英文字母“B”、“I”和“U”,但实际上,一个不熟悉英文的使用者很难知道这些图标所代表的含义。正确的做法是在不同的区域中使用不同的图标,例如在中文环境下,使用“粗”、“斜”和“划”这样的汉字代替前述的英文字母。
第二种情况是图形的颜色。同一种颜色在不同的文化中可能含有不同、甚至是完全相反的意义,例如中国人很喜欢的代表高贵和喜庆的大红色,在西方的一些文化中代表着警告和恐怖(血的颜色)。不仅仅是图形的颜色,界面其他元素的颜色也是需要注意的,尤其是背景颜色,尽量采用系统颜色是一个比较保险的方法。
第三种情况是图示中的物体。一个物体可能在一种文化中具有很强烈的某种意义的暗示,但是在另一种文化中则可能完全不带有这种意义。比如,单手握拳后伸出食指和中指,且手心面向对方,这一手势在大多数使用英语的国家中表示胜利和成功的意思,因为两个手指正好形成V字(Victor),但在中文环境中只是表示数字2,而如果手心不是面向对方而是面向自己的话,这在英国会被视为一个具有很强烈挑衅意味的侮辱的姿势。
第四种情况是图示的方向。因为世界上的文字并非都是从左往右写的,阿拉伯语和希伯来语就是从右往左写的,而日本的很多出版物还保留着文字从上往下、从左往右的排版习惯。这样一来,含有表示文字方向的图示就必须在这些区域中作出相应的变化,例如,在字处理软件中表示“项目符号”的图标,在阿拉伯语的区域下应该将项目符号显示在代表文本的线段的右边。
第五种情况是旗帜带来的麻烦。因为一些政治的原因,不当的使用国旗或者地区的旗帜都可能会导致软件产品在某些地区被禁止销售。因此,在任何情况下,绝对不要在软件产品中使用任何国家或者地区的旗帜来作为此国家或者地区的标识。
3.视频和音频
视频和音频包含了动画、警告声响和解说等。与图形类似,视频和音频一般也是事先准备好的静态内容,也存在可能需要被翻译的情况,其考虑的方面也是一致的。
5.1.3 资源束
资源通常是一个完整的应用程序必须具备的,它可以部分地解决信息本地化的问题,主要是静态信息的本地化,包括前面列举的文本、图形记号和视频音频等。即使一个最简单的“Hello, world!”程序也包含了一条字符串资源——“Hello, world!”:
public class HelloWorld {
public static void main(String[] argv) {
System.out.println("Hello, world!");
}
}
|
用Java编译执行后,这段程序将在屏幕上打印出一行:
显然,无论说任何语言、在任何国家或者地区的人运行这段程序,它都会在屏幕上打印出相同的句子。人们通常希望程序能够以自己熟悉的语言显示信息,比如,一个说中文的人希望看到“世界,你好!”,而一个说西班牙语的人则想看到“¡Hola, mundo!”。所以,一个国际化的应用程序必须能够提供让资源本地化的可能性和手段。
Java语言本身自带的运行库已经提供了用于方便应用程序实现资源本地化的类——ResourceBundle,即资源束。可以使用这个类将程序中的资源抽取到本地化的属性文件中,从而实现资源的本地化。以上程序可以改写为:
import java.util.ResourceBundle;
public class HelloWorld {
public static void main(String[] argv) {
ResourceBundle bundle = ResourceBundle.getBundle("messages")
System.out.println(bundle.getString("hello.world"));
}
}
|
不同的本地化属性文件包含了同一资源的不同的本地化版本。例如,如果提供以下三个属性文件,则默认情况下,在英文、中文和西班牙文操作系统中就可以分别看见不同文字的输出结果(注意:因为Java规定属性文件必须是ISO-8859-1编码,因此这里不在该编码字符集里的字符,如中文字符,必须使用转义字符写成UTF-16编码的形式)。
messages_en.properties文件内容:
hello.world = Hello, world!
|
messages_zh.properties文件内容:
hello.world = \u4E16\u754C\u4F60\u597D\uFF01
|
messages_sp.properties文件内容:
hello.world = \u00A1Hola, mundo!
|
属性文件中的转义字符并不需要开发者手工输入,可以使用JDK中自带的小工具native2ascii.exe来将原本包含非ISO-8859-1字符集字符的文件转成使用转义字符的形式。这个小工具的使用方法很简单,两个参数分别是需要转换的源文件和转换完成后的目标文件。如果不加任何参数,这个小工具将提供一个简单的交互界面输出用户输入的字符的转义字符形式。
但是,如果一个具备相当规模的应用程序也仅仅使用以上例子中的方法来使用本地化资源,就会显得过于烦琐而难以维护。试想,一个混乱的应用程序可能到处都分散着不同的属性文件,而这些属性文件可能包含着互相重复的资源;程序中的代码纷纷创建和调用着各自的资源束对象,随处可见重复的getString方法的调用……因此,在着手开发一个国际化应用程序之前,必须首先布置该应用程序使用本地化资源的策略和方法。
一个良好、易维护的本地化资源布置方案应该满足以下基本条件:
(1)相同的资源只在一个地方定义一次;
(2)开发者能够方便地访问和引用所需资源;
(3)资源属性文件在程序打包发布时应当与程序文件分离。
1.传统的本地化资源方案
一般使用本地化资源时,会在本地化资源所在的同一个包中声明一个Messages类,用来封装对资源束的访问。传统的Messages类通常会提供一个静态的getString方法来获取本地化资源,并且以常数方式提供资源的键名,例如:
package my.test;
import java.util.ResourceBundle;
import java.util.MissingResourceException;
public class Messages {
private static final String BUNDLE_NAME = "my.test.messages"; //$NON-NLS-1$
private static final ResourceBundle bundle =ResourceBundle.getBundle(BUNDLE_ NAME);
public static final String ERR_FILE_NOT_FOUND ="ERR_FILE_NOT_FOUND"; //$NON-NLS-1$
public static final String ERR_FILE_NOT_EXIST ="ERR_FILE_NOT_EXIST"; //$NON-NLS-1$
public static final String ERR_IO_ERROR = "ERR_IO_ERROR"; //$NON-NLS-1$
...
public static String getString(String key) {
try {
return bundle.getString(key);
} catch (MissingResourceException e) {
return key;
}
}
}
|
相应的本地化属性文件my/test/messages_zh.properties的内容可以是:
ERR_FILE_NOT_FOUND = 文件{0}未找到
ERR_FILE_NOT_EXIST = 文件{0}不存在
ERR_IO_ERROR = 输入输出错误
...
|
在程序中使用此本地化资源时就可以调用Messages.getString方法来获得相应的资源,例如:
Logger.err(Messages.getString(Messages.ERR_IO_ERROR));
|
这种传统方案的一个较为明显的缺点就是使用上比较烦琐,开发者必须确保Messages类中的资源键名常量和属性文件中的键值是一一对应的,而且在使用时还必须不厌其烦地调用Messages.getString方法。
2.Eclipse风格的本地化资源方案
Eclipse作为图形界面的集成开发环境,本身就需要用到大量的本地化资源。为了更加有效地使用本地化资源,Eclipse团队在开发Eclipse时使用了一种不同于传统的方案。这种方案利用了Java语言自身的反射机制,其思路不仅可以用于Eclipse平台应用的开发,还可以在其他任何的Java桌面程序中被简单地实现。
Eclipse风格的方案仍然需要一个类似的Messages类,不过要简洁很多,例如:
package my.test
import java.util.ResourceBundle;
import java.util.MissingResourceException;
public class Messages extends NLS {
private static final String BUNDLE_NAME = "my.test.messages"; //$NON-NLS-1$
public static String ERR_FILE_NOT_FOUND;
public static String ERR_FILE_NOT_EXIST;
public static String ERR_IO_ERROR;
...
static {
NLS.initializeMessages(Messages.class, BUNDLE_NAME);
}
}
|
与传统的Messages类相比,新的Messages类中原本存放键名的常数域去掉了常数属性(final),它们将被用来直接存放资源的值。这些值会在类初始化的时候通过执行NLS.initializeMessages方法被赋值。这里的NLS类可以被简单实现为:
package com.ibm.dojo.js2xlf.util;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ResourceBundle;
public class NLS {
protected static void initializeMessages(String bundleName, Class bundleClass)
{
Field[] allFields = bundleClass.getFields();
ResourceBundle bundle = ResourceBundle.getBundle(bundleName);
for (int i = 0; i < allFields.length; i++) {
Field field = allFields[i];
int modifier = field.getModifiers();
if (field.getType().equals(String.class)
&& Modifier.isStatic(modifier)
&& !Modifier.isFinal(modifier)) {
try {
field.set(null, bundle.getString(field.getName()));
} catch (IllegalArgumentException e) {
// TODO log errors and load default value here
// ...
} catch (IllegalAccessException e) {
// TODO log errors and load default value here
// ...
}
}
}
}
}
|
NLS.initializeMessages方法的作用就是读取本地化属性文件,并利用Java运行时态的反射特性,将该属性文件中的资源值一一对应复制到名称和与其键名相同的Messages类的公共静态域上。这样在使用Messages类时,就可以直接引用资源对应的域,而无须再调用任何方法:
Logger.err(Messages.ERR_IO_ERROR);
|
Eclipse风格的本地化资源方案比传统的方案更加简洁和高效,且可以在资源被读取时一次性检查本地化属性文件是否提供了所需的所有资源,有利于错误的发现和调试。其缺点是,存放资源值的域不是只读的,有可能被程序误改。
3.使用本地化资源
前面只是以文本为例列举了两种本地化资源的方案,对于其他的静态信息,如图形记号,实际上只需要把不同区域的图形文件进行不同的命名,或者放置在不同的目录下,然后将这些文件的路径作为文本写在对应的资源文件中即可,这样,程序就可以根据不同的区域载入不同的静态资源了。
通常情况下,应用程序并不需要将所有语言的本地化资源都打包一起发布。一般的做法是选择一种主要语言(如英语)的本地化资源,将其加入到应用程序的主发布包中,然后再根据用户的需要,将其他语言的本地化资源另外打包,并随同程序的主发布包一起发布。
此外,前面在示例程序中所有出现字符串字面常量的地方都出现了$NON-NLS-1$这样的NON-NLS注释。从国际化的角度来看,任何可以被本地化的文本都不允许直接出现在程序代码中,而应该以资源文件的形式存放。而程序代码中出现的字符串字面常量应该都是不需要本地化的文本,即Non-National Language String。一些集成开发环境(如Eclipse)为了能够在编译时检查这种情况,约定使用NON-NLS注释来标识不需要本地化的文本。NON-NLS的用法是:$NON-NLS-<n>$,其中的<n>是指当前代码行中的第n个字符串字面常量,例如:
foo("No.1 NLS", bar, "No.2 NLS"); // $NON-NLS-1$ $NON-NLS-2$
|
5.2 字符和编码转换
Java语言从最初的版本开始就内建支持Unicode字符集。例如,Java语言中固定长度为16位的基本类型char,就是被用来存储较早的Unicode标准中的码点的(0x0~0xFFFF)。随着Unicode标准的不断发展,其码点范围也在不断扩充。Unicode 2.0版本将码点范围扩充为0x0~0x10FFFF,之后Unicode 3.1版本定义了第一批增补字符,这些扩充导致Java中原有的char类型不再足够表示所有的Unicode码点。因此,Java也从5.0版本开始,在其运行库中加入了对增补字符的支持。
本节将详细介绍如何在Java程序中正确使用Unicode字符及字符编码的相关功能。如无特殊说明,本节中给出的示例程序均是基于Java 5.0版本的。此外,示例程序源文件应该使用UTF-8编码,否则,可能有一些源代码中的Unicode字符无法正确显示。
5.2.1 字符
Java语言中的“字符”有三层含义。
第一层是存储层面。存储层面的“字符”指的是Java的char类型数据,每个char类型数据都是16位的,其数据范围是0x0~0xFFFF。为方便起见,下文称之为“存储字符”。
第二层是Unicode码点层面。目前Unicode的码点范围是从0x0~0x10FFFF,每个Unicode字符都可以标识为一个唯一的码点,下文称之为“码点字符”。
第三层是语言逻辑层面。这一层面的“字符”是指从人类语言逻辑上来说的一个独立的文字。并非每一个独立的文字都能够表示为一个Unicode字符,存在多个Unicode字符组合起来表示一个独立文字的情况,这些字符组合也会被显示成一个独立的文字。前面章节中介绍的组合字符即属于此类,下文称此层面的字符为“逻辑字符”。
Java 1.4及其之前的版本并不支持增补字符,从Java 5.0版本开始,Java标准的制定组织JCP(Java Community Process)制定了名为“Unicode增补字符支持”的Java技术规范请求(编号JSR-204),其参考实现也被引入了Java SE 5.0规范。
为了保证与原有Java虚拟机和应用程序的兼容,Java的Unicode增补字符支持实现没有引入新的基本类型,依然保留原有的16位基本类型char。此时,可以将其表示的概念看做一个UTF-16编码单位,原有的char序列则相应的被解释为UTF-16编码序列。Unicode码点使用32位的基本类型int来表示,并且提供API来支持char类型(UTF-16编码单位)和int类型(码点)之间的转换。
实际上,这种转换和Unicode标准中规定的基于代理(Surrogate)的码点表示方法是一致的,即:码点0x0~0xFFFF仍然使用一个char类型数据表示,然后从中抽出两段0xD800~0xDBFF和0xDC00~0xDFFF分别作为下位代理符(Lower Surrogate)和上位代理符(Upper Surrogate),增补字符的码点0x10000~0x10FFFF就各取一个下位代理符和一个下位代理符组成的序列来表示。例如,0x20000在Java中表示为两个char:0xD840 0xDC00。注意,从0x0~0xDFFF中抽出的上位代理符和下位代理符不再被当做是合法的单个Unicode字符码点,只有当它们组合使用时才是合法。
为了统一Unicode字符的操作,Java类库还增加了将Unicode代码点(int类型)作为完整的单元来处理的方法。可以看到,Java SE 5.0以后的类库中存在很多同名的重载方法,它们的唯一区别就是接受的参数分别是char和int,例如Character.toUpperCase(char)和Character.toUpperCase(int)。
对于Java开发人员来说,如何在应用程序中支持增补字符取决于应用处理的文本类型和处理方式。如果处理的是字符串(String)等字符序列类(CharSequence)对象,那么,由于Java SE 5平台已经可以自动处理增补字符,因此应用程序不需要做任何特殊处理;如果处理的是char数组,而且其包含的码点字符可能是增补字符时,则需要对这些应用进行更改。
一般的解决方案是:从char数组创建一个字符串对象,然后提取单个的代码点,再使用基于代码点的API处理,将处理结果使用一个StringBuffer或者StringBuilder对象连接起来,最后从这个对象中获得处理后的char数组作为结果。
下面介绍具体的单个码点字符操作方法。
1.java.lang.Character类
Character类是基本类型char的对象封装类,供只能操纵对象类型的类(如Collections)使用。同时,Character也包含了大量的用于Unicode字符处理的静态方法。从Java SE 5.0版本开始,这些方法的实现均基于Unicode 4.0标准,主要可分为以下几类。
(1)判断字符的属性:Java SE 5.0的Character共提供了40余个以“is”开头的静态方法,以及两个getDirectionality方法,用于判断给定字符的属性。典型的,如判断字符是否是数字的isDigit及是否大写的isUpperCase等,这些属性是由Unicode标准规定的。
// dz是印地语(Hindi)中的数字。打印结果:true。
System.out.println(Character.isDigit('\u0967')); // dz
// Ǣ是大写字符,ǣ是其小写形式。打印结果:true。
System.out.println(Character.isUpperCase('\u01E2')); // Ǣ
|
(2)判断字符的Unicode类别:Character为所有支持的Unicode类别定义了一个byte类型的常量,并且提供了getType()方法判断给定字符的类别。
// 打印结果:2 (START_PUNCTUATION)
System.out.println(Character.getType('('));
|
(3)字符的大小写转换:除了普通的小写和大写之外,Unicode还定义了首字母大写,分别对应于toUpperCase, toLowerCase和toTitleCase方法。注意,这里的大小写转换是与语言文化无关的大小写转换,可以被用来处理文件路径等,但是不要用它们处理自然语言的文本。
// 打印结果:DZ
System.out.println(Character.toUpperCase('\u01F3')); // dZ
// 打印结果:dz
System.out.println(Character.toLowerCase('\u01F1')); // DZ
// 打印结果:Dz
System.out.println(Character.toTitleCase('\u01F3')); // dz
|
(4)字符与其他类型的转换:主要是Character,String与char和int的相互转换,如digit, forDigit,charValue,getNumericValue等,需要特别注意的是digit和getNumericValue的区别。digit方法用于得到某个进制基数下的数值,而getNumericValue的可应用范围则广泛得多。
// 打印结果:-1
System.out.println(Character.digit('f', 10));
// 打印结果:15
System.out.println(Character.getNumericValue('f'));
// 打印结果:8
System.out.println(Character.getNumericValue('\u2167')); // Ⅷ
|
(5)char类型数据与Unicode代码点的相互转换,以及以代码点为单位访问char数组和CharSequence接口。另外需要注意的是,代码单元与代码点的转换方法并不会检查给定参数是否在有效范围,因此,需要先用相应的属性判断方法来检查。
此外,Character.UnicodeBlock封装了Unicode标准中的字符块概念,提供了判断字符所属的字符块集合的静态方法。字符块是Unicode标准定义的一系列子集,通常用来区分用于特定目的的字符,典型的如现金符号“€”等。注意,这里的字符块是指将整个Unicode字符空间分为连续的块,一般并不能完全表示该字符的属性。例如,“¥”虽然也是现金符号,但是其码点位于LATIN_1_SUPPLEMENT块内,而不在CURRENCY_ SYMBOL块内。
// 打印结果:CURRENCY_SYMBOL
System.out.println(Character.UnicodeBlock.of('\u20A0')); // €
|
这里需要注意的是,Character类中的所有方法都是和区域无关的,通过它们得到的某个字符的属性或者变换都是唯一的。因此,这些方法只能被用于和区域无关的上下文中,如数字字符串的解析、文件路径、程序源代码等,不能将它们应用到自然语言的处理上。对自然语言,必须使用字符序列相关的类来处理。此外,Character中还有一些和字符序列相关的静态方法在相应的字符序列类中也有对应的体现,如offsetByCodePoints方法,在String类中也有此方法,此时,可以尽量使用字符序列类中的方法,以符合面向对象的程序习惯。
2.char序列
在Java类库中,除了主要处理单个字符的Character,还有一些类或接口是char的容器,即char序列。char序列分为两类,一类是char数组(数组是Java的一种特殊的类),另一类是CharSequence接口及其子类,包括String, StringBuffer, StringBuilder和CharBuffer等。char数组是最基本的字符串存储方式,其他所有的char序列都是使用char数组实现的。CharSequence提供了一个最基本的char序列的抽象接口,仅包含4个基本方法,用于枚举和复制char数据。其他char序列类在CharSequence的基础上扩充了大量的用于字符串操作的方法,这些方法在Java 5.0版本以后又都提供了针对扩充字符的扩展功能。对char序列的详细讨论参见5.3.1“字符及字符串操作”一节。
5.2.2 字符编码转换
Java语言内建支持Unicode是Java语言国际化支持的基础。但是,Java语言的Unicode支持仅限于在Java进程内用Unicode表示字符,而Java进程之外的很多文本内容都不是以Unicode存储的。比如说,操作系统的默认文件编码可能是因其默认地域的不同而不同的,Internet上网页的编码也是多种多样,所以,要用Java语言正确地处理这些文本,就必须进行字符编码转换。所幸的是,Java SE通过类库提供了强大的字符编码转换功能。本节将详细介绍Java SE类库的字符编码转换功能。
Java对字符编码转换的支持可以分为两个层次,较高级的接口已经融入了java.io, java.lang等其他基础类库之中,较底层的则是一个单独的包java.nio.charset,是在J2SE 1.4时作为NIO的一部分引入的。在Java 1.4以前的版本里,有一个实现相关的sun.io包也包括了一些字符编码转换的类,但是这个包并非Java标准API,既不能保证在其他Java SE实现下运行也不能保证在将来的版本里兼容,因此不推荐使用。
本节将首先介绍Java中字符编码的基本接口,然后详细分析应该如何在应用程序中使用这些接口。
1.字符编码的接口
一般情况下,应用程序开发人员接触到的字符编码问题大多是与IO相关的。因此,Java的IO标准库从设计之初就考虑到了这个问题。标准库java.io中包含了一般IO操作所需的类,而最重要的大概就是InputStream/OutputStream和Reader/Writer两组抽象类了。这两组抽象类分别提供了对二进制字节流和文本字符流的读写的基本操作,然后通过子类及装饰器、适配器等设计模式扩展成为支持各种形式IO操作的类库。
InputStream/OutputStream提供更为一般的操作,其处理的对象只是一个个字节而已,因此,应用程序需要自己去解析这些字节的意义,如果读写的是文本字符流,就需要自己处理编码解码。因此,如果应用程序希望处理的是文本字符流,那么,Reader/Writer就是更好的选择,在使用Reader/Writer读写时,Java已经将底层的字节流转换成了Unicode字符序列,应用程序只要专注在逻辑上统一处理Unicode字符就可以了。Java提供了从InputStream/OutputSteam到Reader/Writer的转换,分别是InputStreamReader和OutputStreamWriter适配器,它们分别继承自Reader和Writer,其构造函数分别接受一个InputStream/OuputStream实例,并负责进行字符编码转换,其实际的IO操作都是传递给内嵌的InputStream/OutputStream实例来做的。它们详细的构造函数如下:
InputStreamReader(InputStream in)
InputStreamReader(InputStream in, Charset cs)
InputStreamReader(InputStream in, CharsetDecoder dec)
InputStreamReader(InputStream in, String charsetName)
OutputStreamWriter(OutputStream out)
OutputStreamWriter(OutputStream out, Charset cs)
OutputStreamWriter(OutputStream out, CharsetEncoder enc)
OutputStreamWriter(OutputStream out, String charsetName)
需要注意的是,InputStreamReader和OutputStreamWriter的构造函数大多需要指定字符编码以用来转换,但也各自有一个单参数的构造函数,并未指定编码,在这种情况下,Java环境默认的字符编码将被使用。在java.io中还有一些类也有类似的情况,比如FileReader的构造函数FileReader(String fileName),是从一个给定的文件路径构造一个Reader实例,在这种未指定使用何种字符编码的情况下,均使用默认编码。默认编码可以由静态方法java.nio.charset.Charset.defaultCharset()获得。
另外一个经常用到的字符编码转换高级API由java.lang.String类提供——从编码字节流转化为Unicode字符串可以使用String的构造函数String(byte[] bytes, String charsetName),从Unicode字符串转化为编码字节流可以使用String.getBytes(String charsetName)。这两个方法分别对应的API也不指定编码,这种情况下均使用默认编码来转换。
以上介绍的Java字符转换高级接口实际上都是借助底层接口实现的,该底层接口在J2SE 1.4中也作为标准库API的一部分公开了出来,这就是java.nio.charset和java.nio.charset.spi包。这个包的公开类很少,更多的实现,也就是实际对各种编码进行转换的部分,都被藏在这些接口下面。
首先需要认识的是Charset类,它的每个实例都代表了一种Java支持的字符编码。Charset类的构造函数访问权限是protected的,一般不会被直接使用。相应的是它提供了一个工厂方法forName(String charsetName),也是应用程序获取Charset实例最常用的方法,该方法接受一个字符编码的名字或者别名,如果该编码在当前Java SE平台上被支持,则相应实例会被返回,反之,则抛出UnsupportedCharsetException。不同的Java SE平台支持的字符编码集合可能不同,而以下6种编码则是每种实现都要支持的:
US-ASCII
ISO-8859-1
UTF-8
UTF-16BE
UTF-16LE
UTF-16
另外两个重要的静态方法分时是返回当前平台默认字符编码的defaultCharset()和所有支持的字符编码的availableCharsets(),它们与其他的一些静态方法一样,实际上都起到了类似Charset管理器的作用。
另一方面,Charset的实例方法则是一些真正的Charset相关功能实现,比如,直接在Unicode字符串和该实例代表的编码字节流之间做转换的相关方法如下:
public ByteBuffer encode(CharBuffer cb)
public ByteBuffer encode(String str)
public CharBuffer decode(ByteBuffer bb)
其他的实例方法则分别负责返回该编码相关的一些信息,如别名集合、显示名称、在当前平台上的支持情况等。
但是在一些特殊情况下,编码字节流可能非常长或者不能一次全部得到,如从一个长文件或者网络套接字中读取等,因此需要分步骤解码。但是在读取字节时,应用程序并不能保证正好读到了本次解码所需要的所有字节,比如一个字符可能被UTF-8编码成为3个字节,如果应用程序某一次只读到了前两个,该怎么处理呢?这种情况下,应用程序需要对字符的编码解码有进一步的控制,CharsetEncoder和CharsetDecoder就是为这个目的而设计的。CharsetEncoder和CharsetDecoder分别支持所谓的流式字符编码和解码,所谓编码是指将Unicode字符串按照某种Charset转化为二进制字节流,解码则是相反的过程。CharsetEncoder和CharsetDecoder实例可以通过相应Charset实例的newEncoder()及newDecoder()方法得到。
为了支持流式编码,应用程序必须按照一定的步骤来使用CharsetEncoder:
(1)如果不是第一次使用该CharsetEncoder实例,那么首先调用reset()方法重置该实例的内部状态。
(2)如果可能有更多的字符串需要被编码,则反复调用encode(CharBuffer in, ByteBuffer out, boolean endOfInput)方法,并且为endOfInput参数传入false。注意:相应的输入CharBuffer和输出ByteBuffer都要处在合适的状态,即CharBuffer仍有字符可读,而ByteBuffer仍有空间可写。
(3)对于最后一部分输入字符串,则调用encode(CharBuffer in, ByteBuffer out, boolean endOfInput)方法,但是为endOfInput参数传入true。
(4)反复调用flaush(ByteBuffer)方法,直到CharsetEncoder将所有可能的缓存都输出。
一个典型的例子如下所示:
import java.nio.*;
import java.nio.charset.*;
public class EncoderTest{
public static void main(String[] args) throws Exception{
String unistr = "this is a string to be encoded\n";
ByteBuffer out = ByteBuffer.allocate(200);
CharBuffer in = CharBuffer.wrap(unistr);
CharBuffer in2 = CharBuffer.wrap(unistr);
in.rewind();
in2.rewind();
CharsetEncoder encoder = Charset.forName("utf-8").newEncoder();
encoder.encode(in, out, false);
encoder.encode(in2, out, true);
encoder.flush(out);
out.rewind();
}
}
|
流式解码的步骤与此类似,只将其中的CharsetEncoder方法变为对应的CharsetDecoder方法即可。
Java SE平台还允许对字符编码支持进行扩展,一种方法就是通过服务提供接口SPI java.nio.charset.spi.CharsetSPI抽象类。要扩展Charset支持,可以遵循以下步骤。
(1)将所要支持的字符编码实现为Charset,CharsetEncoder及CharsetDecoder的子类。
(2)实现一个或多个CharsetSPI类。
(3)编辑一个名为java.nio.charset.spi.CharsetProvider的配置文件,分行列出所有CharsetSPI实现类的全限定名。
(4)生成jar包所需的META-INF目录,在其中添加一个services目录,把java.nio. charset.spi.CharsetProvider配置文件放在services目录里。
(5)将以上文件打包成为一个单独的jar,加入类路径即可。
实际上,无论是配置文件还是CharsetSPI实现类,或者Charset实现类,都不需要在同一个jar里,也不需要在类路径中,但是,必须能够被当前线程的Context ClassLoader装载,尤其是CharsetSPI实现类要能够被接受配置文件装载请求的类装载器装载。
2.处理输入文本
处理文本输入时可能遇到的问题比处理输出复杂得多。因为毕竟输出文本时只要按照一定的规则(即字符编码)将文本数据转成二进制的8位字节数据即可,此时的文本和字符编码是应用程序已知的。而在输入文本时,应用程序可能面对将字符编码未知的字节数据转化为正确的文本的问题,这就要求应用程序对字节数据进行一定的分析。即使在给出编码的情况下,应用程序仍然可能需要分析字节数据,才能得到正确的输入文本。
因此,为了能方便地在程序中使用文本的输入和输出功能,应用程序应当事先规划一个专门用于处理输出和输出的工具类。这个工具类封装了程序中可能会用到的各种输入和输出功能。下面就以开发这样的一个工具类中的方法为例。
假设需要开发这样一个工具类FileUtil来处理文件的输入和输出,其中有一个简单而常用的方法getStringFromFile,这个方法的目的是从一个文本文件中读取其所有文本并返回为一个字符串,如果读取失败,返回一个null。
public static String getStringFromFile(File file) {
FileReader fileReader;
try {
fileReader = new FileReader(file);
try {
StringBuffer sb = new StringBuffer();
char[] buf = new char[1000];
int read;
while ((read = fileReader.read(buf)) >= 0) {
sb.append(buf, 0, read);
}
return sb.toString();
} catch (IOException e) {
return null;
} finally {
fileReader.close();
}
} catch (IOException e) {
return null;
}
}
|
如果不考虑性能等其他因素,这段实现该方法的代码似乎并没有什么问题:异常被处理了,循环读遍了文件的所有内容,输入资源最后也被回收了。但实际上,这段代码隐藏着非常大的缺陷,如果应用程序使用这段代码,那么它将很有可能完全不支持国际化。
必须明确的是,任何输入的源设备(包括文件)提供的都是8位的字节数据,而文本是由逻辑字符组成的。一个8位的字节只能表示256种符号,而逻辑字符理论上是无止境的,所以,从字节数据中读取文本必须要知道字符编码。
上述程序的一个最大的问题是使用了FileReader来读取文本。FileReader的所有构造函数都不包含字符编码,它会隐含使用当前操作系统默认的字符编码来转换字节数据到文本。其带来的问题是显而易见的:中文Windows系统默认字符编码是GB2312或者GB 18030,如果用这个方法读取一个UTF-8编码的文件,其结果必定是一堆无意义的杂乱字符。因此,应用程序中永远不要使用FileReader来读取文本,可以使用能够指定字符编码的类来代替,比如InputStreamReader。在getStringFromFile方法中还应该添加一个encoding参数以允许指定字符编码。
public static String getStringFromFile(File file, String encoding)
throws IOException {
try {
InputStream inStream = new FileInputStream(file);
InputStreamReader inReader = new InputStreamReader(inStream, encoding);
try {
StringBuffer sb = new StringBuffer();
char[] buf = new char[1000];
int read;
while ((read = inReader.read(buf)) >= 0) {
sb.append(buf, 0, read);
}
return sb.toString();
} catch (IOException e) {
return null;
} finally {
inReader.close();
}
} catch (IOException e) {
return null;
}
}
|
增加了字符编码的设定并非就解决了所有的问题,因为对于几个Unicode字符编码而言,还存在BOM头字节的识别和处理问题。BOM,即Byte Order Mark,是位于文件(或者数据流)最前面的2至4个字节,用于标记Unicode字符编码及其字节序,但是BOM头字节并非是必需的。对于不同的Unicode字符编码,有以下可能的BOM头字节与其对应。
UTF-8:可能的BOM头字节是EF BB BF。
UTF-16:可能的BOM头字节是FF FE和FE FF,前者表示字节是小头对齐序,后者表示字节是大头对齐序。
UTF-16BE:可能的BOM头字节是FE FF。
UTF-16LE:可能的BOM头字节是FF FE。
UTF-32:可能的BOM头字节是FF FE 00 00和00 00 FE FF,前者表示字节是小头对齐序,后者表示字节是大头对齐序。
UTF-32BE:可能的BOM头字节是00 00 FE FF。
UTF-32LE:可能的BOM头字节是FF FE 00 00。
使用InputStreamReader时需要注意的问题主要有以下两点。
首先,InputStreamReader会在处理UTF-16和UTF-32编码时自动识别BOM头字节并以正确的字节序读取字节数据。然而,如果没有BOM头字节,则InputStreamReader在处理UTF-16和UTF-32时将默认采用大头对齐序,而一般的操作系统如Windows、UNIX和Linux等均是采用小头对齐序的。比如,61 00 62 00 63 00若以UTF-16读入,那么文本将是“\u6100\u6200\u6300”而并非“abc”。
其次,除UTF-8编码外,InputStreamReader会自动除去识别正确的BOM头字节,而如果BOM头字节和制定的字符编码不一致,BOM头字节将会作为文本的一部分而不会被识别。比如,FF FE 61 00若以UTF-16编码读入,则文本只有一个字符“a”,而以UTF-16BE编码读入时,文本将会包含两个字“\uFFFE”和“\u6100”。这里需要注意的是,UTF-8编码的BOM头字节是一个例外,InputStreamReader不能自动识别并除去这一编码,BOM头字节EF BB BF将被识别为普通字符“\uFEFF”,应用程序必须自己除去它。这原本是Java 1.4中的一个缺陷,但是Sun公司最终决定:出于兼容性的考虑并不会在以后的Java版本中修正它,否则将会导致大量的应用程序为此改写代码,尤其是以前所有的J2EE的容器服务器。
因此,这里的getStringFromFile方法中还必须为除去可能的UTF-8编码的BOM头字节增加处理的代码,且在BOM头字节表明的编码和方法调用时给出的编码不一致时,应该优先使用BOM头字节表明的编码来读取文件。
到此为止,getStringFromFile方法已经可以完全胜任处理已经给定字符编码文件。但是,用户很有可能并不希望在使用一个应用程序读取文本文件时,还要费劲地去了解和指定这个文本文件的字符编码。理想的应用程序应该能自动猜测需要读取的文本文件的字符编码,而让用户指定字符编码只能是所有猜测措施都失效或者猜测出现错误时的最后的办法。
一个合理的编码猜测方案是:
(1)读入BOM头字节,确定其是否属于众多Unicode编码中的一个,若有符合,则使用匹配的字符编码;
(2)若用户已指定了字符编码,则使用之;
(3)使用系统默认编码或者使用应用程序设置的默认编码。
当然,如果想提供更加智能的自动字符编码判断功能,可以在第2步BOM头字节匹配失败后加入新的识别步骤,例如,通过抽样计算字节数据中00的比例确定是否可能是UTF-16或者UTF-32编码。对这些更加复杂的检验方式的讨论已经超出了本书的范围,通常情况下,上述简单的编码猜测方案已经能够满足应用程序国际化的需要了。
3.选择输出和存储的字符编码
文本的输出相对文本的输入是一个较为简单的过程,一般来说,只需要按照用户选择的字符编码输出为字节数据就可以了。当用户没有选择,或者输出的文本仅仅会用于应用程序内部的数据交换时,一个支持国际化的应用程序应该优先使用Unicode或者完全兼容Unicode的字符编码。这是因为目前Unicode已经成为世界通用的编码标准,包含世界上几乎所有的字符,而且还在不断增加和更新中,这样就不会发生世界上的某些字符无法被应用程序支持的问题。
UTF-8是目前使用最为广泛的Unicode字符编码。如果应用程序主要处理以罗马字母为主的语言,那么UTF-8编码是非常理想的选择。因为UTF-8编码设计的初衷就是兼容7位ASCII,它可以用1个8位字节表示大部分的罗马字母,比其他的Unicode编码占用的平均字节数要少得多。
如果一个应用程序主要处理达到海量的中文数据,且可能包含相当多的英文数据内容,那么,内部使用GB 18030编码将是一个比较好的选择。GB 18030编码的字符集包含了完整的Unicode字符集,所以,不必担心Unicode字符无法用GB 18030编码表示。
和UTF-8一样,GB 18030编码也兼容7位ASCII编码,使得英文数据可以以最少的1个8位字节表示。更为重要的是,GB 18030还兼容GB2312和GBK这样的双字节编码,让绝大部分中文的汉字都能够以2个8位字节表示,而用UTF-8编码表示1个汉字则基本上需要3个或者3个以上的8位字节。
可以看到,GB 18030在这种应用环境中既有表示全部的Unicode字符的能力,又能节省近三分之一的存储成本。不过需要注意的是,输出给用户的文本数据应当仍然转化为UTF-8编码,因为用户使用的系统未必支持GB 18030编码。
本章小结
本章介绍了 Java 编程语言和 Java 核心类库对国际化编程的支持,以及使用 Java 开发桌面应用程序所采用的技术,并简要介绍了 ICU4J 对 Java 标准库国际化支持的增强。
读者反馈
欢迎您对本书提出宝贵的反馈意见。您可以通过本页面最下方的 建议 栏目为本文打分,并反馈您的建议和意见。
如果您对 developerWorks 图书频道有什么好的建议,欢迎您将建议发给我们。
参考资料
关于作者  | |  | 童春杰,毕业于浙江大学,部门经理,在多个全球化项目中担任项目管理和具体开发工作,对 J2EE 和 Web 应用开发有浓厚的兴趣。 |
对本文的评价
|