Django 多語言教程 (i18n)

ocavue發表於2019-03-04

原文連結:ocavue.com/django_tran…

概覽

最近公司準備擴張海外業務,所以要給 Django 系統新增 國際化與本土化 支援。國際化一般簡稱 i18n,代表 Internationalization 中 i 和 n 有 18 個字母;本地化簡稱 L10n,表示 Localization 中 l 和 n 中有 10 個字母。有趣的一點是,一般會用小寫的 i 和大寫的 L 防止混淆。

簡單來說:i18n 是為國際化搭建框架,L10n 是針對不同地區的適配。舉個簡單的例子:

i18n:

datetime.now().strftime('%Y/%m/%d')  # before i18n
datetime.now().strftime(timeformat)  # after i18n
複製程式碼

L10n:

timeformat = {
    'cn': '%Y/%m/%d',
    'us': '%m/%d/%Y',
    'fr': '%d/%m/%Y',
    ...
}
複製程式碼

更加具體的定義可以看 W3C 的解釋。

i18n 的範圍非常廣,包括多語言、時區、貨幣單位、單複數、字元編碼甚至是文字閱讀順序(RTL)等等。這篇文章只關注 i18n 的多語言 方面。

使用阿拉伯語的 windows 系統,來源

↑ 阿拉伯語的 windows 系統,文字甚至介面的方向都與中文版的相反(圖片來源

基本步驟

Django 作為一個大而全的框架,已經提供了一套多語言的解決方案,我稍微對比了一下,並沒能找到在 Django 體系下比官方方案還好用的庫。Django 的方案可以簡單分為四步:

  1. 一些必要的配置
  2. 在程式碼中標記需要翻譯的文字
  3. 使用 makemessages 命令生成 po 檔案
  4. 編譯 compilemessages 命令編譯 mo 檔案

下面我們詳細來看看

第一步:配置

首先在 settings.py 中加入這幾個內容

LOCALE_PATHS = (
    os.path.join(__file__, 'language'),
)
MIDDLEWARE = (
    ...
    'django.middleware.locale.LocaleMiddleware',
    ...
)
LANGUAGES = (
    ('en', 'English'),
    ('zh', '中文'),
)
複製程式碼

LOCALE_PATHS:指定下面第三步和第四步生成檔案的位置。老版的 Django 需要手動新建好這個目錄。

LocaleMiddleware:可以讓 Django 識別並選擇合適的語言。

LANGUAGES:指定了這個工程能提供哪些語言。

第二步:標記文字

之前沒有多語言的需要,所以大家在 AJAX 相應程式碼中直接寫了中文,比如這樣:

return JsonResponse({"msg": "內容過長", "code": 1, "data": None})
複製程式碼

現在需要多語言了,就需要告訴 Django 哪些內容是需要翻譯的。對於上面的例子來說,就是寫成這樣:

from django.utils.translation import gettext as _

return JsonResponse({"msg": _("內容過長"), "code": 1, "data": None})
複製程式碼

這裡使用 gettext 函式將原本的字串包裹起來,這樣的話,Django 就可以根據當前語言返回合適的字串。一般會使用單個下劃線 _ 提高可讀性。

因為我司幾乎所有前後端通訊都使用 AJAX,所以並沒有怎麼用上 Django 的模板功能(順便一提,我司前端使用的多語言工具是 i18next)。不過在這裡也一併寫下 Django 模板的標記方法:

<title>{% trans "This is the title." %}</title>
<title>{% trans myvar %}</title>
複製程式碼

其中 trans 標籤告訴 Django 需要翻譯這個括號裡面的內容。更具體的用法可以參考官方文件

第三步:makemessages

在執行這一步之前,請先通過 xgettext --version 確認自己是否安裝了 GNU gettext。GNU gettext 是一個標準 i18n L10n 庫,Django 和很多其他語言和庫的多語言模組都呼叫了 GNU gettext,所以接下來講的一些 Django 特性實際上要歸功於 GNU gettext。如果沒有安裝的話可以通過下面的方法安裝:

ubuntu:

$ apt update
$ apt install gettext
複製程式碼

macOS:

$ brew install gettext
$ brew link --force gettext
複製程式碼

windows

安裝完 GNU gettext 後,對 Django 工程執行下面的命令

$ python3 manage.py makemessages --local en
複製程式碼

之後可以找到生成的檔案:language/en/LC_MESSAGES/django.po。把上面命令中的 en 替換成其他語言,就可以生成不同語言的 django.po 檔案。裡面的內容大概是這樣的:

#: path/file.py:397
msgid "訂單已刪除"
msgstr ""

...
複製程式碼

Django 會找到被 gettext 函式包裹的所有字串,以 msgid 的形式儲存在 django.po。每個 msgid 下面的 msgstr 就代表你要把這個 msgid 翻譯成什麼。通過修改這個檔案可以告訴 Django 翻譯的內容。同時通過註釋說明了這個 msgid 出現在哪個檔案的哪一行。

關於這個檔案,發現幾點有趣的特性:

  • Django 會把多個檔案中相同的 msgid 歸類在一起。「一次編輯,到處翻譯」
  • 如果以後原始碼中某個 msgid 被刪了,那麼再次執行 makemessages 命令後,這個 msgid 和它的 msgstr 會以註釋的形式繼續儲存在 django.po 中。
  • 既然原始碼中的字串只是一個所謂的 id,那麼我就可以在原始碼中寫沒有實際含義的字串,比如 _("ERROR_MSG42"),然後將 "ERROR_MSG42" 同時翻譯成中文和英文。
  • 這個檔案中會保留模板字串的佔位符,比如可以使用命名佔位符做到在不同語言中使用不同佔位符順序的功能,下面給出了一個例子:

py file:

_('Today is {month} {day}.').format(month=m, day=d)
_('Today is %(month)s %(day)s.') % {'month': m, 'day': d}
複製程式碼

po file:

msgid "Today is {month} {day}."
msgstr "Aujourd'hui est {day} {month}."

msgid "Today is %(month)s %(day)s."
msgstr "Aujourd'hui est %(day)s %(month)s."
複製程式碼

第四步:compilemessages

修改好 django.po 檔案後,執行下面的命令:

$ python3 manage.py compilemessages --local en
複製程式碼

Django 會呼叫程式,根據 django.po 編譯出一個名為 django.mo 的二進位制檔案,位置和 django.po 所在位置相同。這個檔案才是程式執行的時候會去讀取的檔案。

執行完上面四步後,修改瀏覽器的語言設定,就可以看到 Django 的不同輸出了。

Chrome 的語言設定

↑ Chrome 的語言設定

高階特性

i18n_patterns

有的時候,我們希望可以通過 URL 來選擇不同的語言。這樣做有很多好處,比如同一個 URL 返回的資料的語言一定是一致的。Django 的文件就使用了這種做法:

簡體中文:https://docs.djangoproject.com/zh-hans/2.0/

英文:https://docs.djangoproject.com/en/2.0/

具體的做法是在 URL 中新增 <slug:slug>

urlpatterns = ([
    path('category/<slug:slug>/', news_views.category),
    path('<slug:slug>/', news_views.details),
])
複製程式碼

詳細的做法可以參考 Django 的官方文件

Django 如何決定使用哪種語言

我們之前講過 LocaleMiddleware 可以決定使用何種語言。具體來說,LocaleMiddleware 是按照下面的順序(優先順序遞減):

  • i18n_patterns
  • request.session[settings.LANGUAGE_SESSION_KEY]
  • request.COOKIES[settings.LANGUAGE_COOKIE_NAME]
  • request.META['HTTP_ACCEPT_LANGUAGE'],即 HTTP 請求中的 Accept-Language header
  • settings.LANGUAGE_CODE

我司選擇把語言資訊放到 Cookies 中,當使用者手動選擇語言時,可以讓前端直接修改 Cookies,而不需要請求後臺的某個介面。沒有手動設定過語言的使用者就沒有這個 Cookies,跟隨瀏覽器設定。話說 settings.LANGUAGE_COOKIE_NAME 的預設值是 django_language,前端不想在他們的程式碼中出現 django,所以我在 settings.py 中新增了 LANGUAGE_COOKIE_NAME = app_language ?。

你也可以通過 request.LANGUAGE_CODE 在 View 中手動獲知 LocaleMiddleware 選用了哪種語言。你甚至可以通過 activate 函式手動指定當前執行緒使用的語言:

from django.utils.translation import activate

activate('en')
複製程式碼

ugettext

Python2 時代,為了區分 unicode strings 和 bytestrings,有 ugettextgettext 兩個函式。在 Python3 中,由於字串編碼的統一,ugettextgettext 是等價的。官方說未來可能會廢棄 ugettext,但是截止到現在(Django 2.0),ugettext 還沒廢棄。

gettext_lazy

這裡先用一個例子直觀地看一下 gettext_lazygettext 的區別

from django.utils.translation import gettext, gettext_lazy, activate, get_language

gettext_str = gettext("Hello World!")
gettext_lazy_str = gettext_lazy("Hello World!")

print(type(gettext_str))
# <class 'str'>
print(type(gettext_lazy_str))
# <class 'django.utils.functional.lazy.<locals>.__proxy__'>

print("current language:", get_language())
# current language: zh
print(gettext_str, gettext_lazy_str)
# 你好世界! 你好世界!

activate("en")

print("current language:", get_language())
# current language: en
print(gettext_str, gettext_lazy_str)
# 你好世界! Hello World!
複製程式碼

gettext 函式返回的是一個字串,但是 gettext_lazy 返回的是一個代理物件。這個物件會在被使用的時候,才根據當前執行緒中語言決定翻譯成什麼文字。

這個功能在 Django 的 models 中尤其的有用。因為 models 中定義字串的程式碼只會執行一次。在之後的請求中,根據語言的不同,這個所謂字串要有不同的表現。

from django.utils.translation import gettext_lazy as _

class MyThing(models.Model):
    name = models.CharField(help_text=_('This is the help text'))

class YourThing(models.Model):
    kind = models.ForeignKey(
        ThingKind,
        on_delete=models.CASCADE,
        related_name='kinds',
        verbose_name=_('kind'),
    )
複製程式碼

使用 AST / FST 修改原始碼

由於我司工程非常龐大,人力給每個字串新增 _( ... ) 過於繁瑣。所以我試圖尋找一種自動化的方式。

一開始選擇的是 Python 內建的 ast (Abstract syntax tree 語法抽象樹) 模組 。基本思路是通過 ast 找到工程中的所有字串,再給這些字串新增 _( ... )。最後把修改後的語法樹重新轉為程式碼。

但是由於 ast 對格式資訊的支援不佳,修改程式碼後容易造成格式混亂。所以找到了名為 FST (Full Syntax Tree 全面抽象樹) 的改進方式。我選擇的 FST 庫是 redbaron。核心的程式碼如下:

root = RedBaron(original_code)

for node in root.find_all("StringNode"):
    if (
        has_chinese_char(node)
        and not is_aleady_gettext(node)
        and not is_docstring(node)
    ):
        node.replace("_({})".format(node))

modified_code = root.dumps()
複製程式碼

我把完整的程式碼放到了 Gist 上,因為是一個一次性指令碼,寫的比較隨意,大家可以參考。

使用 redbaron 的過程中也發現了一些問題,一併記錄這裡:最大問題是 redbaron 已經停止維護了!所以不能支援一些新語法,比如 Python3.6 的 f-string。其次是這個庫和 ast 標準庫相比,執行速度很慢,每次跑這個指令碼我的電腦都發出了飛機引擎般的聲音。第三點是會產生一些奇怪的格式:

修改前:

OutStockSheet = {
    1: '未出庫',
    2: '已出庫',
    3: '已刪除'
}
複製程式碼

修改後('已刪除' 右邊的括號跑到了下一行):

OutStockSheet = {
    1: _('未出庫'),
    2: _('已出庫'),
    3: _('已刪除'
)}
複製程式碼

最後一點倒是可以通過格式化工具解決,問題不大。

utf8 vs utf-8

專案中有些 py 檔案比較老,在檔案開頭使用了 # coding: utf8 的標示。對於 Python 來說,utf8 是 utf-8 的別名,所以沒有任何問題。Django 在呼叫 GNU gettext 時,會使用引數指定編碼為 utf-8,但是 GNU 也會讀取檔案中的編碼標示,而且它的優先順序更高。不幸的是 utf8 對 GNU gettext 來說是一個未知編碼,於是 GNU gettext 會降級使用 ASCII 編碼,然後在遇到中文字元時報錯(真笨!):

$ python3 manage.py makemessages --local en
...
xgettext: ./path/filename.py:1: Unknown encoding "utf8". Proceeding with ASCII instead.
xgettext: Non-ASCII comment at or before ./path/filename.py:26.
複製程式碼

所以我需要把 # coding: utf8 改成 # coding: utf-8,或者乾脆刪掉這行,反正 Python3 已經預設使用 utf-8 編碼了。

總結

Django (和其背後的 GNU gettext) 的多語言功能非常全面,堪稱博大精深,比如處理單複數的 ngettext,處理多義詞的 pgettext。HTTP 響應中使用翻譯後的文字,但是在日誌中留下翻譯前文字的 gettext_noop

這篇文章主要講了我在實踐中用到的功能和遇到的坑,希望可以幫助大家瞭解 Django 多語言的基本用法。歡迎大家評論?。


知識共享協議

本文采用知識共享署名-非商業性使用-禁止演繹 2.5 中國大陸許可協議進行許可。

相關文章