在PHP应用程序中实施国际化(语言字符串)

19
我想建立一个能够处理获取本地化字符串以支持国际化的CMS。我计划将字符串存储在数据库中,然后在数据库和应用程序之间放置一个键/值缓存,如memcache,以防止每个页面都访问数据库导致性能下降。
与使用包含字符串数组的PHP文件相比,这种方法更复杂,但是当你有2000个翻译行时,那种方法效率极低。
我考虑过使用gettext,但我不确定CMS的用户是否习惯使用gettext文件。如果字符串存储在数据库中,那么可以设置一个漂亮的管理系统,让他们随时进行更改,并且RAM中的缓存将确保获取这些字符串的速度与gettext一样快,甚至更快。我也不觉得使用PHP扩展安全,甚至Zend框架都不使用它
这种方法有什么问题吗?

更新

我想也许我可以提供更多的思考材料。字符串翻译的一个问题是它们不支持日期、货币或条件语句。然而,多亏了intl PHP现在有了MessageFormatter,这才是真正需要使用的工具。
// Load string from gettext file
$string = _("{0} resulted in {1,choice,0#no errors|1#single error|1<{1, number} errors}");

// Format using the current locale
msgfmt_format_message(setlocale(LC_ALL, 0), $string, array('Update', 3));

另外一件我不喜欢gettext的事情是,文本嵌入到应用程序的各个地方。这意味着负责主要翻译(通常是英文)的团队必须能够访问项目源代码,以便在所有默认语句的位置进行更改。这几乎和在应用程序中到处存在SQL乱码一样糟糕。
因此,使用像_('error.404_not_found')这样的键是有道理的,这样内容编写者和翻译者只需要关心PO/MO文件,而不用在代码中乱搞。
然而,如果给定的键没有gettext翻译,那么就没有办法回退到默认值(就像你可以使用自定义处理程序一样)。这意味着你要么让编写者在你的代码中胡乱搞,要么让没有区域翻译的用户看到"error.404_not_found"!
此外,我不知道有哪些大型项目使用PHP的gettext。如果有任何关于实际依赖于原生PHP gettext扩展的、被广泛使用(因此经过测试)的系统的链接,我将不胜感激。

1
新的ICU库似乎很有前途,但我还没有使用过。但正如您已经注意到的那样,gettext已安装在几乎所有PHP安装中,而ICU库需要PHP 5.3+和启用扩展(读取共享主机将显示“error.404_not_found”字符串)。我现在会坚持使用gettext。 - xmarcos
2
不仅是 ICU 库看起来很有前途,而且没有它就无法进行正确的翻译。无论使用 gettext 还是不使用,intl 类都是构建带有时间、货币或复数短语选择的正确短语所必需的。 - Xeoncross
如果你在谈论WordPress的_n()函数,那么它只适用于一个单数/复数形式。有些语言不止两种形式。只有MessageFormatter支持这些(如上面粗略示例所示)。 - Xeoncross
我在说这是可以做到的,_n()只是一个示例,说明你如何使用工具。Gettext是一款成熟、强大、经过广泛测试和得到很好支持的工具。你来这里寻求建议,我推荐它。 - xmarcos
1
您可以使用复数形式编写消息,如下所示: "{0} 导致 {1,plural,=0 {没有错误} one{一个错误} other{#个错误}}" 每种语言的翻译人员可以使用以下链接中显示的复数规则,并且它们将适用于相应的语言,请参考 http://unicode.org/repos/cldr-tmp/trunk/diff/supplemental/language_plural_rules.html - Steven R. Loomis
显示剩余6条评论
10个回答

6
Gettext使用一种相当快速的二进制协议。此外,它的实现通常更简单,只需要echo _('Text to translate');。它还有现有的翻译工具可供使用,并且它们被证明可以很好地工作。
你可以将它们存储在数据库中,但我觉得这会更慢而且有点过度设计,特别是因为你必须自己构建编辑翻译的系统。
如果你可以将查找缓存到APC的专用内存部分中,那就太好了。不幸的是,我不知道如何做到这一点。

我更新了我的问题,不知道你是否能够回答我提出的问题? - Xeoncross
WordPress使用gettext。就网站数量和用户基数而言,这是我所能想到的最大项目。至于处理不存在的翻译...没有规定必须使用内置的_()函数(及其类似函数)。您可以编写自己的函数来包装它们,测试翻译是否存在(如果返回的翻译与键相同),甚至可以在memcache中进行缓存。 - John Watson

5

对于那些感兴趣的人来说,PHP中有关本地化和国际化(i18n)的完全支持似乎终于开始实现了。详情请参考setlocale()函数。

// Set the current locale to the one the user agent wants
$locale = Locale::acceptFromHttp(getenv('HTTP_ACCEPT_LANGUAGE'));

// Default Locale
Locale::setDefault($locale);
setlocale(LC_ALL, $locale . '.UTF-8');

// Default timezone of server
date_default_timezone_set('UTC');

// iconv encoding
iconv_set_encoding("internal_encoding", "UTF-8");

// multibyte encoding
mb_internal_encoding('UTF-8');

有几个问题需要考虑,检测时区/语言环境并将其用于正确解析和显示输入/输出非常重要。最近发布了一个PHP I18N库,其中包含大量此类信息的查找表,您可以使用它。
处理用户输入很重要,以确保应用程序从用户输入中获得干净、格式良好的UTF-8字符串。可以使用iconv实现这一目标。
/**
 * Convert a string from one encoding to another encoding
 * and remove invalid bytes sequences.
 *
 * @param string $string to convert
 * @param string $to encoding you want the string in
 * @param string $from encoding that string is in
 * @return string
 */
function encode($string, $to = 'UTF-8', $from = 'UTF-8')
{
    // ASCII is already valid UTF-8
    if($to == 'UTF-8' AND is_ascii($string))
    {
        return $string;
    }

    // Convert the string
    return @iconv($from, $to . '//TRANSLIT//IGNORE', $string);
}


/**
 * Tests whether a string contains only 7bit ASCII characters.
 *
 * @param string $string to check
 * @return bool
 */
function is_ascii($string)
{
    return ! preg_match('/[^\x00-\x7F]/S', $string);
}

那么只需将输入传递给这些函数即可。
$utf8_string = normalizer_normalize(encode($_POST['text']), Normalizer::FORM_C);

翻译

正如Andre所言,使用gettext似乎是编写可翻译应用程序的明智默认选择。

  1. Gettext使用一个相当快速的二进制协议。
  2. Gettext实现通常更简单,因为它只需要_('Text to translate')
  3. 已经有了翻译者使用的工具,并且它们已被证明能很好地工作。

当你达到Facebook的规模时,可以开始实现RAM缓存和其他替代方法,比如我在问题中提到的方法。然而,对于大多数项目来说,没有什么能超过“简单、快速、有效”的做法。

然而,gettext还无法处理一些额外的内容,例如显示日期、货币和数字。对于这些内容,您需要使用INTL扩展

/**
 * Return an IntlDateFormatter object using the current system locale
 *
 * @param string $locale string
 * @param integer $datetype IntlDateFormatter constant
 * @param integer $timetype IntlDateFormatter constant
 * @param string $timezone Time zone ID, default is system default
 * @return IntlDateFormatter
 */
function __date($locale = NULL, $datetype = IntlDateFormatter::MEDIUM, $timetype = IntlDateFormatter::SHORT, $timezone = NULL)
{
    return new IntlDateFormatter($locale ?: setlocale(LC_ALL, 0), $datetype, $timetype, $timezone);
}

$now = new DateTime();
print __date()->format($now);
$time = __date()->parse($string);

此外,您可以使用strftime来解析日期,考虑当前的语言环境。
有时您需要将数字和日期的值正确插入到本地化消息中。
/**
 * Format the given string using the current system locale
 * Basically, it's sprintf on i18n steroids.
 *
 * @param string $string to parse
 * @param array $params to insert
 * @return string
 */
function __($string, array $params = NULL)
{
    return msgfmt_format_message(setlocale(LC_ALL, 0), $string, $params);
}

// Multiple choices (can also just use ngettext)
print __(_("{1,choice,0#no errors|1#single error|1<{1, number} errors}"), array(4));

// Show time in the correct way
print __(_("It is now {0,time,medium}), time());

请查看ICU格式详细信息

数据库

确保与数据库的连接使用正确的字符集,以便在存储过程中不会出现任何损坏。

字符串函数

您需要了解string, mb_string, 和 grapheme函数之间的区别。

// 'LATIN SMALL LETTER A WITH RING ABOVE' (U+00E5) normalization form "D"
$char_a_ring_nfd = "a\xCC\x8A";

var_dump(grapheme_strlen($char_a_ring_nfd));
var_dump(mb_strlen($char_a_ring_nfd));
var_dump(strlen($char_a_ring_nfd));

// 'LATIN CAPITAL LETTER A WITH RING ABOVE' (U+00C5)
$char_A_ring = "\xC3\x85";

var_dump(grapheme_strlen($char_A_ring));
var_dump(mb_strlen($char_A_ring));
var_dump(strlen($char_A_ring));

域名顶级域名

INTL库中的IDN函数对处理非ASCII编码的域名有很大帮助。


请看我上面的注释^^ ChoiceFormat已过时,应使用PluralFormat。 - Steven R. Loomis
Locale::setDefault($locale);setlocale(LC_ALL, $locale . '.UTF-8');有什么区别?另外,我在哪里可以找到语言标签的列表? - CMCDragonkai

3
我正在我的框架中使用ICU的东西,发现它非常简单易用。我的系统是基于XML的,使用XPath查询,而不是像您建议使用数据库。我没有发现这种方法低效。在研究技术时,我也尝试了资源束,但发现它们实现起来相当复杂。 Locale功能真是太好了。你可以更轻松地完成很多事情:
// Available translations
$languages = array('en', 'fr', 'de');

// The language the user wants
$preference = (isset($_COOKIE['lang'])) ?
    $_COOKIE['lang'] : ((isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) ?
        Locale::acceptFromHttp($_SERVER['HTTP_ACCEPT_LANGUAGE']) : '');

// Match preferred language to those available, defaulting to generic English
$locale = Locale::lookup($languages, $preference, false, 'en');

// Construct path to dictionary file
$file = $dir . '/' . $locale . '.xsl';

// Check that dictionary file is readable
if (!file_exists($file) || !is_readable($file)) {
    throw new RuntimeException('Dictionary could not be loaded');
}

// Load and return dictionary file
$dictionary = simplexml_load_file($file);

我会进行单词查询,使用的方法如下:

我接着使用这种方式:

$selector = '/i18n/text[@label="' . $word . '"]';
$result = $dictionary->xpath($selector);
$text = array_shift($result);

if ($formatted && isset($text)) {
    return new MessageFormatter($locale, $text);
 }

我的系统的优势是,模板系统基于XSL,这意味着我可以直接在模板中使用相同的翻译XML文件来处理不需要任何i18n格式化的简单消息。

1
关于根据用户偏好选择正确区域设置的第一个代码片段非常完美。我希望更多的人能看到这个代码块并在他们的应用程序中实现它。然而,我无法想象XML对象比数组更节省资源。该对象包含需要额外内存的附加属性。不过,使用XPath是一个有趣的想法。 - Xeoncross

3

有许多与此类似的其他SO问题和答案。建议您也搜索并阅读它们。

建议?使用现有解决方案,例如gettext或xliff,因为当您遇到所有翻译边缘情况时(如从右到左的文本、日期格式、不同的文本数量等),这将节省您很多麻烦。更好的建议是:不要这样做。如果用户想要翻译,他们会制作一个克隆并进行翻译。因为本地化更多关于外观和使用口语化语言,通常就会发生这种情况。再次举个例子,盎格鲁-撒克逊文化喜欢酷炫的网页颜色和无衬线字体,而西班牙裔文化则喜欢明亮的颜色和衬线/草书字体。为了迎合您,每种语言都需要不同的布局。

Zend实际上为Zend_Translate提供了以下适配器列表,并且这是一个有用的列表。

  • Array:对于小页面使用PHP数组;最简单的用法;仅供程序员使用
  • Csv:使用逗号分隔的(.csv/ .txt)文件表示简单的文本文件格式;快速;可能会出现Unicode字符问题
  • Gettext:使用二进制gettext(* .mo)文件表示GNU标准的Linux;线程安全;需要翻译工具
  • Ini:使用简单的INI(* .ini)文件表示简单的文本文件格式;快速;可能会出现Unicode字符问题
  • Tbx:使用术语库交换(.tbx/ .xml)文件表示行业标准的应用程序间术语字符串;XML格式
  • Tmx:使用tmx(.tmx/.xml)文件表示行业标准的应用程序间翻译;XML格式;人类可读
  • Qt:使用qt linguist(* .ts)文件表示跨平台应用程序框架;XML格式;人类可读
  • Xliff:使用xliff(.xliff/.xml)文件表示与TMX相关但更简单的格式;XML格式;人类可读
  • XmlTm:使用xmltm(* .xml)文件表示XML文档翻译记忆的行业标准;XML格式;人类可读
  • 其他:*.sql等,未来可能会实现不同的适配器

在过去的几年中,我已经阅读了这里关于gettext的许多问题。然而,它们中没有一个像我提供的那样提出了一个合理的替代方案 - 通常都是一些无意义的东西,比如将CSV/INI/JSON文件解析成一个巨大的数组进行查找。无论如何,在你的第二段中,我不确定你在谈论什么 - 我不明白gettext或数组如何处理rtl或ltr语言的问题。此外,它们都不支持日期格式或演示更改 - 这就是intl PHP模块与MessageFormatter的作用。 - Xeoncross

1
csv文件怎么办(可以在许多应用程序中轻松编辑),以及将缓存保存到memcache (wincache等)?这种方法在Magento中效果很好。代码中的所有语言短语都被包装在__() 函数中,例如。
<?php echo $this->__('Some text') ?>

例如,在新版本发布之前,您可以运行一个简单的脚本来解析源文件,找到所有包含在__()中的文本,并将其放入.csv文件中。您加载csv文件并将它们缓存到memcache中。在__()函数中,您查看memcache中缓存的翻译。

1
Magento已经如此庞大,我怀疑任何人都不会注意到使用CSV所带来的影响。我的意思是,对于美国14万亿的赤字来说,又多了10亿算什么呢? - Xeoncross
使用CSV没有任何问题,除了第一次将CSV加载到内存缓存时。无论您如何存储语言文件,都将将其加载到内存缓存中。任何其他方法都比直接从内存缓存中读取慢,您知道的。 CSV文件只是存储翻译的最方便格式,因为任何办公室女孩都可以编辑它。 - Dmytro Zavalkin
非常抱歉@Zyava,我误读了您的评论。是的,加载文件(CSV或其他文件)并解析各个行,并将它们存储在memcache中,这正是我提出的建议。 - Xeoncross

1

坚持使用gettext,你在PHP中找不到更快的替代品。

关于如何,您可以使用数据库存储您的目录,并允许其他用户使用友好的GUI翻译字符串。当新更改经过审核/批准后,点击一个按钮,编译一个新的.mo文件并部署。

一些资源可以帮助您入门:


@Zyava,数组可能更快,因为它只是在RAM中等待-但事实上,它在RAM中等待也是问题所在。加载CSV或INI文件具有相同的问题,因为它们最终都会成为一个数组,占用内存浪费资源。 - Xeoncross
@Zyava,更快是相对于您的项目大小而言的。此外,gettext提供了比普通数组更多的功能,经过了广泛的测试和支持,并在几乎所有PHP安装中启用。 - xmarcos
这个扩展功能非常好,甚至zf都不使用它?抱歉,但你难道不觉得这里有些不对劲吗? - Dmytro Zavalkin
@Zyava 我猜你所说的zf是指Zend_Translate,它是一个标准化的接口,在许多适配器之上提供一致的API。毫不奇怪,其中一个适配器(Zend推荐用于大型应用程序的适配器)就是gettext。 - xmarcos
1
OP发布了这个链接,请阅读。简而言之,zf gettext适配器不使用php gettext扩展,正如你所说的那样,“在几乎所有PHP安装中都经过了广泛测试、支持和启用”。 - Dmytro Zavalkin
这是一个用户空间实现的解析器,但它仍然是gettext,并且是推荐的一种解决方案。他们捆绑它以确保框架的可移植性,但您可以轻松编写一个Zend_Translate_Adapter_Gettext_Native适配器来使用php扩展。 - xmarcos

0
另外,关于gettext,我不喜欢的一件事情是文本嵌入到应用程序的各个位置。这意味着负责主要翻译(通常是英语)的团队必须能够访问项目源代码,在所有默认语句的位置进行更改。这几乎和在整个应用程序中都有SQL混乱代码的应用程序一样糟糕。
实际上并非如此。你可以有一个头文件(抱歉,我是C程序员),例如:
<?php
define(MSG_404_NOT_FOUND, 'error.404_not_found')
?>

那么每当您需要一条消息时,请使用_(MSG_404_NOT_FOUND)。这比要求开发人员每次想要输出本地化版本的非本地化消息的确切语法更加灵活。

您可以进一步进行一步,从CSV或数据库中生成头文件,并与翻译进行交叉引用以检测缺少的字符串。


这是一个不错的想法。它解决了上面提到的问题,即允许“键入”的字符串名称,并在找不到时提供默认翻译。不幸的是,想象将2,100个定义存储在文件中并不现实。那将浪费太多资源。 - Xeoncross

0

有一个非常适合这个工作的Zend插件。

<?php
/** dependencies **/
require 'Zend/Loader/Autoloader.php';
require 'Zag/Filter/CharConvert.php';

Zend_Loader_Autoloader::getInstance()->setFallbackAutoloader(true);

//filter
$filter = new Zag_Filter_CharConvert(array(
    'replaceWhiteSpace' => '-',
    'locale' => 'en_US',
    'charset'=> 'UTF-8'
));

echo $filter->filter('ééé ááá 90');//eee-aaa-90
echo $filter->filter('óóó 10aáééé');//ooo-10aaeee

如果您不想使用Zend框架,只能使用插件。

拥抱!


0
在最近的一个项目中,我们考虑使用gettext,但事实证明编写自己的功能更容易。这真的很简单:为每个语言环境创建一个JSON文件(例如strings.en.json,strings.es.json等),并在某个地方创建一个名为“translate()”或类似名称的函数,然后只需调用该函数。该函数将确定当前语言环境(从URI或会话变量等),并返回本地化字符串。
唯一需要记住的是确保输出的任何HTML都以UTF-8编码,并在标记中标记为此类编码(例如在doctype中等)。

显然,你的翻译不是很大 - 或者你没有监控内存使用情况。在PHP中,数组并不是免费的。 - Xeoncross
@Xeoncross:很少有PHP开发者进行基准测试,它通常不是资源敏感型工作的首选。 - Orbling

0

看起来和Zend差不多,但似乎不支持像Zend那样的gettext。 - Xeoncross
1
也许你可以调查一下为什么他们不支持gettext(),我只能猜想他们有自己的原因。这个知识对你自己的决定可能是相关的。 - Jan-Henk

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接