跳轉到

多國語系

開始之前

任務目標

在這個任務中,你將學習:

  • 了解 Django 的國際化 (i18n) 機制
  • 設定專案的語系支援
  • 在 Python 程式碼中標記翻譯字串
  • 在模板中標記翻譯字串
  • 使用 makemessages 產生翻譯檔
  • 編輯 .po 翻譯檔案
  • 使用 compilemessages 編譯翻譯
  • 實作語系切換功能
  • Model 欄位的多語系處理

Django 國際化簡介

什麼是 i18n 和 l10n

在開發面向國際市場的網站時,你可能會聽到兩個常見的縮寫:i18nl10n

  • i18n (Internationalization,國際化):指的是設計軟體架構,使其能夠支援多種語言和地區,而無需對程式碼進行結構性修改。i18n 這個縮寫來自 "internationalization" 的首字母 i 和尾字母 n 之間有 18 個字母。
  • l10n (Localization,在地化):指的是將軟體調整為特定語言或地區的過程,包括翻譯文字、調整日期格式、貨幣符號等。l10n 來自 "localization" 首尾字母之間有 10 個字母。

簡單來說,i18n 是「讓軟體能夠被翻譯」,l10n 是「實際進行翻譯」。

Django 的多語系架構

Django 內建了完整的國際化框架,主要包含以下元件:

  1. 翻譯字串標記:使用 gettext() 函數標記需要翻譯的字串
  2. 翻譯檔案.po 檔案儲存原文與譯文的對應,.mo 檔案是編譯後的二進位格式
  3. 語系切換機制:透過 Middleware 和 Session 來偵測與切換使用者的語系偏好
  4. 格式化工具:自動根據語系格式化日期、時間、數字

Django 的翻譯機制基於 GNU gettext 工具集,這是一套廣泛使用的開源翻譯系統。

專案設定

settings.py 設定

要啟用 Django 的多語系功能,需要在 settings.py 中進行以下設定。

首先,在檔案開頭加入 gettext_lazy 的 import:

core/settings.py
"""
Django settings for core project.

Generated by 'django-admin startproject' using Django 5.2.8.

For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""

from pathlib import Path

from django.utils.translation import gettext_lazy as _

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

接著,找到檔案中的 Internationalization 區塊,將整個區塊替換為以下內容:

core/settings.py
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/

LANGUAGE_CODE = "zh-hant"

LANGUAGES = [
    ("zh-hant", _("繁體中文")),
    ("en", _("English")),
]

LOCALE_PATHS = [
    BASE_DIR / "locale",
]

TIME_ZONE = "Asia/Taipei"

USE_I18N = True

USE_TZ = True

各設定的說明:

  • LANGUAGE_CODE:網站的預設語系,使用 語言代碼格式(如 zh-hantenja
  • LANGUAGES:網站支援的語系列表,每個項目是 (語系代碼, 顯示名稱) 的 tuple
  • LOCALE_PATHS:翻譯檔案的目錄路徑
  • USE_I18N:是否啟用 Django 的翻譯系統(啟用時,日期、數字的在地化格式也會自動啟用)

為什麼用 gettext_lazy?

LANGUAGES 設定中使用 gettext_lazy() 而非 gettext(),是因為 settings.py 在 Django 啟動時就會被載入,此時翻譯系統可能尚未完全初始化。gettext_lazy() 會延遲翻譯的執行,直到字串真正需要被顯示時才進行翻譯。

Middleware 設定

Django 透過 LocaleMiddleware 來偵測使用者的語系偏好。找到 MIDDLEWARE 設定,在 SessionMiddleware 之後加入 LocaleMiddleware

core/settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

將這整個 MIDDLEWARE 設定替換掉原本的設定即可。

Middleware 順序很重要

LocaleMiddleware 必須放在 SessionMiddleware 之後(因為它會使用 session),並且放在 CommonMiddleware 之前。

LocaleMiddleware 會按照以下順序偵測使用者的語系偏好:

  1. URL 中的語系前綴(如果使用 i18n_patterns
  2. Session 中儲存的語系設定
  3. Cookie 中的 django_language
  4. 瀏覽器的 Accept-Language HTTP header
  5. 最後使用 LANGUAGE_CODE 作為預設值

建立語系檔案目錄

為了讓專案結構更清晰,我們採取「分層管理」的策略:

  1. 專案全域翻譯:放置在根目錄的 locale/,包含共用的模板(如 base.html)、settings.py 中的設定等。
  2. App 專屬翻譯:放置在各 App 下的 locale/(如 blog/locale/),包含該 App 的 Model、View 和專屬模板。

請建立這兩個目錄:

# 建立全域翻譯目錄
mkdir locale

# 建立 App 專屬翻譯目錄
mkdir -p blog/locale

目錄結構會像這樣:

django-playground/
├── locale/                  # 全域翻譯
│   └── en/
├── blog/
│   ├── locale/             # App 翻譯
│   │   └── en/
│   ├── templates/
│   └── ...
├── core/
└── ...

Django 會自動偵測 INSTALLED_APPS 中每個 App 底下的 locale 目錄,以及 settings.pyLOCALE_PATHS 指定的目錄。

日期、時間與數字格式

在進入翻譯字串標記之前,先了解 Django 如何處理日期、時間與數字的在地化格式。

時區設定

Django 支援時區感知的日期時間處理。在前面的 settings.py 設定中,我們已經設定了:

core/settings.py
TIME_ZONE = "Asia/Taipei"

USE_TZ = True
  • USE_TZ = True:Django 內部使用 UTC 儲存時間,顯示時轉換為指定時區
  • TIME_ZONE:預設顯示的時區

在模板中,可使用 timezone filter 來轉換時區:

{% load tz %}

{{ article.created_at|timezone:"Asia/Tokyo" }}

日期與數字格式在地化

USE_I18N = True 時,Django 會根據當前語系自動格式化日期和數字:

{{ article.created_at }}

不同語系的顯示範例:

類型 en zh-hant
日期 Jan. 15, 2024, 3:30 p.m. 2024年1月15日 下午3:30
數字 1234567.89 1,234,567.89 1,234,567.89

如果需要在特定位置關閉在地化格式,可以使用 unlocalize filter:

{% load l10n %}

{{ article.created_at|unlocalize }}

Python 程式碼中的翻譯

使用 gettext()

在 Python 程式碼中,我們使用 gettext 系列函數來標記翻譯字串。慣例上會將主要使用的翻譯函數 import 為 _ 以保持程式碼簡潔。

但在 Class-Based View (CBV) 的情境下,我們經常需要混用兩種模式:

  1. 類別屬性(如 success_message):必須使用 gettext_lazy,以免在伺服器啟動時就固定了翻譯結果。
  2. 方法邏輯(如 get_success_message):可以使用 gettext 進行即時翻譯。

因此在下方的範例中,我們採取以下 Import 策略,將 gettext_lazy 命名為 _,而即時翻譯則直接使用 gettext

from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _

請修改 blog/views.py,將整個檔案內容替換為以下程式碼:

blog/views.py
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
from django_filters.views import FilterView

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


class ArticleListView(FilterView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    filterset_class = ArticleFilter
    template_name = "blog/article_list.html"


class ArticleDetailView(DetailView):
    queryset = Article.objects.select_related("author").prefetch_related("tags")
    pk_url_kwarg = "article_id"


class ArticleCreateView(PermissionRequiredMixin, SuccessMessageMixin, CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_create.html"
    permission_required = "blog.add_article"
    raise_exception = True
    success_message = _("文章「%(title)s」已成功建立。")

    def form_valid(self, form):
        form.instance.created_by = self.request.user
        return super().form_valid(form)


class ArticleUpdateView(PermissionRequiredMixin, SuccessMessageMixin, UpdateView):
    model = Article
    form_class = ArticleForm
    template_name = "blog/article_edit.html"
    pk_url_kwarg = "article_id"
    permission_required = "blog.change_article"
    raise_exception = True
    success_message = _("文章「%(title)s」已成功更新。")


class ArticleDeleteView(PermissionRequiredMixin, SuccessMessageMixin, DeleteView):
    model = Article
    template_name = "blog/article_delete.html"
    pk_url_kwarg = "article_id"
    success_url = reverse_lazy("blog:article_list")
    permission_required = "blog.delete_article"
    raise_exception = True

    def get_success_message(self, cleaned_data):
        return gettext("文章「%(title)s」已成功刪除。") % {"title": self.object.title}

_() 函數會在執行時根據當前的語系設定,將字串替換為對應的翻譯。

使用 gettext_lazy()

在某些情況下,程式碼會在 Django 啟動時就被執行(例如 model 定義、form 欄位等或 CBV 的屬性),此時翻譯系統可能尚未準備好。這時需要使用 gettext_lazy()

請修改 blog/models.py,將整個檔案內容替換為以下程式碼:

blog/models.py
from django.conf import settings
from django.db import models
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from blog.validators import validate_image_dimensions, validate_image_extension, validate_image_size


class Author(models.Model):
    name = models.CharField(_("姓名"), max_length=100)
    email = models.EmailField(_("電子郵件"), unique=True)
    bio = models.TextField(_("個人簡介"), blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name = _("author")
        verbose_name_plural = _("authors")

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(_("名稱"), max_length=50, unique=True)

    class Meta:
        verbose_name = _("tag")
        verbose_name_plural = _("tags")

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(_("標題"), max_length=200)
    content = models.TextField(_("內容"))
    cover_image = models.ImageField(
        _("封面圖片"),
        upload_to="articles/covers/",
        blank=True,
        null=True,
        validators=[
            validate_image_size,
            validate_image_extension,
            validate_image_dimensions,
        ],
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(_("是否發布"), default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    class Meta:
        verbose_name = _("article")
        verbose_name_plural = _("articles")

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("blog:article_detail", kwargs={"article_id": self.pk})

    def get_cover_image_url(self):
        if self.cover_image:
            return self.cover_image.url

        return static("blog/images/default-cover.jpg")

修改完 Model 後別忘了更新資料庫

uv run manage.py makemigrations
uv run manage.py migrate

Message ID 的設定

要特別注意一下你打在 _() 中的文字會變成翻譯的 ID,像我們現在這邊要分辨單數複數名詞如果用中文那因為 message id 一樣會造成後續無法翻譯區分。所以我會建議你們 message id 都盡可能用英文或是用不一樣的名稱

class Meta:
    verbose_name = _("article")
    verbose_name_plural = _("articles")

這樣是好的,因為 message id 不同

class Meta:
    verbose_name = _("文章")
    verbose_name_plural = _("文章")

這樣是不好的,因為 message id 完全相同後續會無法區分翻譯

何時使用 gettext_lazy

使用 gettext_lazy() 的常見場景:

  • Model 欄位的 verbose_name
  • Model 的 Meta.verbose_nameverbose_name_plural
  • Form 欄位的 labelhelp_text
  • settings.py 中的設定值
  • 類別屬性

使用 gettext() 的場景:

  • View 函數中的字串
  • 即時需要翻譯結果的地方

複數形式處理

不同語言有不同的複數規則。例如英文只有單數和複數兩種形式,但有些語言(如阿拉伯語、俄語)有更多的複數形式。Django 提供 ngettext() 來處理這種情況。

例如在顯示文章列表的搜尋結果數量時:

from django.utils.translation import ngettext

count = 1
message = ngettext(
    "Found %(count)d article.",   # 單數形式
    "Found %(count)d articles.",  # 複數形式
    count
) % {"count": count}

print(message)

count 為 1 時,會使用第一個字串(單數形式);其他情況則使用第二個字串(複數形式)。

對應的 lazy 版本是 ngettext_lazy()

格式化字串的翻譯

當翻譯字串中需要包含變數時,建議使用命名參數而非位置參數。這在類別視圖的 success_message 中非常常見。

回顧前面的 blog/views.py 範例,我們在 ArticleCreateView 中使用了這種方式:

success_message = _("文章「%(title)s」已成功建立。")

這裡的 _gettext_lazy 的別名,因為 success_message 是類別屬性,需要在存取時才進行翻譯。

相對地,在 ArticleDeleteViewget_success_message 方法中,因為是在執行時呼叫,我們可以改用 gettext(即使用 gettext_lazy 版本通常也能運作,但區分清楚是好習慣且節省資源):

def get_success_message(self, cleaned_data):
    return gettext("文章「%(title)s」已成功刪除。") % {
        "title": self.object.title
    }

使用命名參數的好處是,翻譯人員可以根據目標語言的語法調整參數的順序。

模板中的翻譯

載入 i18n 標籤

在模板中使用翻譯功能前,需要先載入 i18n 標籤庫:

{% load i18n %}

使用 {% translate %} 標籤

{% translate %} 用於翻譯簡單的字串。例如導覽列中的連結文字:

<a class="nav-link" href="{% url 'blog:article_list' %}">{% translate "文章列表" %}</a>
<a class="nav-link" href="{% url 'admin:index' %}">{% translate "管理後台" %}</a>

如果需要將翻譯結果存入變數,可以使用 as 語法:

{% translate "Welcome" as welcome_message %}
<h1>{{ welcome_message }}</h1>

使用 {% blocktranslate %} 標籤

當翻譯字串中需要包含變數時,使用 {% blocktranslate %}。例如在登出按鈕中顯示使用者名稱:

{% blocktranslate with username=user.username %}
  登出 ({{ username }})
{% endblocktranslate %}

也可以同時使用多個變數:

{% blocktranslate with name=user.username count=notifications|length %}
  Hello, {{ name }}! You have {{ count }} new notifications.
{% endblocktranslate %}

{% trans %} 和 {% blocktrans %}

在較舊的程式碼或文件中,你可能會看到 {% trans %}{% blocktrans %} 標籤。它們與 {% translate %}{% blocktranslate %} 功能完全相同,只是名稱較短。Django 4.0 之後推薦使用較長的名稱,因為語意更加清晰。

處理複數形式

在模板中處理複數形式,使用 {% blocktranslate %} 搭配 count 參數:

{% blocktranslate count counter=article_list|length %}
  There is {{ counter }} article.
{% plural %}
  There are {{ counter }} articles.
{% endblocktranslate %}

透過 count 參數指定用於判斷複數形式的變數(count 關鍵字後方的變數是用來判斷單數複數的),{% plural %} 分隔單數和複數的翻譯。

如果要搭配其他變數

{% blocktranslate with amount=article.price count years=i.length %}
  That will cost $ {{ amount }} per year.
{% plural %}
  That will cost $ {{ amount }} per {{ years }} years.
{% endblocktranslate %}

修改現有模板

讓我們為現有模板加上翻譯標記。首先是 blog/templates/blog/article_list.html

blog/templates/blog/article_list.html
{% extends "blog/base.html" %}

{% load django_bootstrap5 i18n %}

{% block title %}
  {% translate "文章列表" %} - Django 大冒險
{% endblock title %}

{% block blog_content %}
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h2>
      {% translate "文章列表" %}
    </h2>
    {% if perms.blog.add_article %}
      <a href="{% url 'blog:article_create' %}" class="btn btn-primary">{% translate "建立文章" %}</a>
    {% endif %}
  </div>

  <div class="card mb-4">
    <div class="card-body">
      <h5 class="card-title">
        {% translate "篩選條件" %}
      </h5>
      <form method="get">
        {% bootstrap_form filter.form %}

        <div class="d-flex gap-2">
          {% bootstrap_button button_type="submit" content=_("搜尋") %}
          <a href="{% url 'blog:article_list' %}" class="btn btn-secondary">{% translate "清除" %}</a>
        </div>
      </form>
    </div>
  </div>

  <div class="row">
    <div class="col-12">
      <p>
        {% blocktranslate count counter=filter.qs|length %}
          There is {{ counter }} article.
        {% plural %}
          There are {{ counter }} articles.
        {% endblocktranslate %}
      </p>
    </div>

    {% for article in filter.qs %}
      {% include "blog/components/article_card.html" %}
    {% empty %}
      <div class="col-12">
        <div class="alert alert-info">
          {% translate "目前沒有符合條件的文章。" %}
        </div>
      </div>
    {% endfor %}
  </div>
{% endblock blog_content %}

接著修改 templates/base.html,為導覽列的文字加上翻譯標記,同時動態的調整 htmllang 屬性:

templates/base.html
{% load django_bootstrap5 i18n %}

{% get_current_language as LANGUAGE_CODE %}

<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Django Playground" />
    <meta name="keywords" content="Django, Playground" />

    <title>
      {% block title %}
        {% translate "Django 大冒險" %}
      {% endblock title %}
    </title>

    {% bootstrap_css %}

    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" />

    {% block extra_head %}
    {% endblock extra_head %}
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <span class="navbar-brand">Django 大冒險</span>
        <button class="navbar-toggler"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNav">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav ms-auto">
            <li class="nav-item">
              <a class="nav-link" href="{% url 'blog:article_list' %}">{% translate "文章列表" %}</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="{% url 'admin:index' %}">{% translate "管理後台" %}</a>
            </li>
            {% if user.is_authenticated %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:password_change' %}">{% translate "變更密碼" %}</a>
              </li>
              <li class="nav-item">
                <form method="post" action="{% url 'auth:logout' %}" class="d-inline">
                  {% csrf_token %}
                  <button type="submit" class="nav-link btn btn-link">
                    {% blocktranslate with username=user.username %}  <!-- (1)! -->
                      登出 ({{ username }})
                    {% endblocktranslate %}
                  </button>
                </form>
              </li>
            {% else %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:login' %}">登入</a>
              </li>
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:register' %}">註冊</a>
              </li>
            {% endif %}
          </ul>
        </div>
      </div>
    </nav>

    <main class="container my-4">
      {% bootstrap_messages %}

      {% block content %}
      {% endblock content %}
    </main>

    <footer class="bg-light py-4 mt-5">
      <div class="container text-center">
        <p class="text-muted mb-0">
          Django Playground
        </p>
      </div>
    </footer>

    {% bootstrap_javascript %}

    {% block extra_scripts %}
    {% endblock extra_scripts %}
  </body>
</html>
  1. 對於包含變數的文字,使用 {% blocktranslate %}

同樣地,更新 blog/templates/blog/base.html

blog/templates/blog/base.html
{% extends "base.html" %}

{% load i18n %}

{% block content %}
  <div class="row">
    <aside class="col-md-3 mb-4">
      <div class="card">
        <div class="card-body">
          <h5 class="card-title">
            {% translate "文章管理" %}
          </h5>
          <ul class="list-group list-group-flush">
            <li class="list-group-item">
              <a href="{% url 'blog:article_list' %}" class="text-decoration-none">
                {% translate "文章列表" %}
              </a>
            </li>
          </ul>
        </div>
      </div>
    </aside>

    <div class="col-md-9">
      {% block blog_content %}
      {% endblock blog_content %}
    </div>
  </div>
{% endblock content %}

產生與編輯翻譯檔

使用 makemessages

設定好翻譯字串並建立好 locale 目錄後,我們可以直接在專案根目錄執行 makemessages 指令搭配 -l 參數產生我們想要的語系:

uv run django-admin makemessages -l zh_Hant -l en

Django 的 makemessages 指令非常聰明,它會掃描整個專案,並根據 locale 目錄的存在與否,自動將翻譯字串分配到正確的位置:

  1. App 內的字串:如果該 App 有自己的 locale 目錄(如 blog/locale),字串會被存入該目錄下的翻譯檔。
  2. 其他字串:如果所在的 App 沒有 locale 目錄,或者字串位於共用區塊(如根目錄的 templates),字串會被存入根目錄的 locale

因此,你不需要特別切換目錄或使用 --ignore 參數,也能正確地分層管理翻譯檔案。

針對性更新與排除

雖然從根目錄執行很方便,但如果你只想更新特定 App 的翻譯,仍可以切換到該 App 目錄下執行:

cd blog
uv run ../manage.py makemessages -l en

此外,你也可以使用 --ignore 參數來手動排除不感興趣的目錄(如 venv 或其他特定的 App 目錄)。

uv run manage.py makemessages -l en --ignore=venv --ignore=node_modules

指定語系

使用 -l 參數指定要產生的語系:

# 產生英文翻譯檔
uv run manage.py makemessages -l en

# 產生日文翻譯檔
uv run manage.py makemessages -l ja

# 一次產生所有既有語系的翻譯檔,要先透過 -l 產生第一次
uv run manage.py makemessages --all

語系代碼格式

makemessages 使用的語系代碼格式與 LANGUAGES 設定中的略有不同。對於有地區變體的語系,使用底線而非連字號:

  • zh-hantzh_Hant
  • zh-hanszh_Hans
  • pt-brpt_BR

.po 檔案結構

產生的 .po 檔案是純文字格式,結構大致如下:

locale/en/LC_MESSAGES/django.po
# English translations for Django Playground.
#
msgid ""
msgstr ""
"Project-Id-Version: Django Playground 1.0\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

#: templates/base.html
msgid "文章列表"
msgstr ""

#: templates/base.html
msgid "管理後台"
msgstr ""

#: templates/base.html
msgid "登入"
msgstr ""

每個翻譯項目包含:

  • #: 開頭的註解,標示字串出現的檔案位置
  • msgid:原始字串(需要翻譯的文字)
  • msgstr:翻譯後的字串(由翻譯人員填寫)

編輯翻譯內容

編輯 .po 檔案,在每個 msgstr 中填入對應的英文翻譯。以下是翻譯項目的範例:

#: core/settings.py
msgid "繁體中文"
msgstr "Traditional Chinese"

#: core/settings.py
msgid "English"
msgstr "English"

#: templates/base.html
msgid "Django 大冒險"
msgstr "The Django Adventure"

#: templates/base.html
msgid "文章列表"
msgstr "Articles"

#: templates/base.html
#, python-format
msgid "登出 (%(username)s)"
msgstr "Logout (%(username)s)"

注意 #, python-format 這行是 Django 自動產生的標記,表示該字串包含 Python 格式化參數,翻譯時需要保留 %(username)s 這樣的佔位符。

翻譯工具

雖然可以直接用文字編輯器編輯 .po 檔案,但使用專門的翻譯工具會更有效率:

  • Poedit:跨平台的桌面應用程式
  • Weblate:開源的線上翻譯平台
  • Transifex:商業翻譯管理平台

編譯翻譯

使用 compilemessages

編輯完 .po 檔案後,需要編譯成 Django 可以讀取的 .mo 格式:

uv run manage.py compilemessages

這個指令會將 locale/ 目錄下所有的 .po 檔案編譯成對應的 .mo 檔案。

需要安裝 gettext

compilemessages 指令需要系統安裝 GNU gettext 工具。

macOS

brew install gettext
brew link gettext --force

Ubuntu/Debian

sudo apt-get install gettext

Windows: 可以從 GNU gettext for Windows 下載安裝。

.mo 檔案的作用

.mo (Machine Object) 檔案是 .po 檔案的編譯版本,它是一種二進位格式,Django 可以快速讀取。

  • .po 檔案:人類可讀的文字格式,用於編輯翻譯
  • .mo 檔案:機器可讀的二進位格式,用於程式執行

.mo 檔案與版本控制

關於是否將 .mo 檔案加入版本控制,有兩種常見做法:

  1. 加入版本控制:部署時不需要額外編譯步驟
  2. 不加入版本控制:在 CI/CD 流程中執行 compilemessages

無論選擇哪種方式,.po 檔案一定要加入版本控制。

語系切換

如前所述,LocaleMiddleware 會自動根據 URL 前綴、Session、Cookie、瀏覽器設定等來偵測使用者的語系偏好。如果你只需要根據瀏覽器語系自動切換,不需要額外的程式碼。

但如果希望讓使用者能主動切換語系,就需要實作語系切換功能。

使用 set_language view

Django 內建了 set_language view,可以處理語系切換的請求。在 core/urls.pyurlpatterns 中加入 i18n 路由:

core/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy
from django.views.generic import RedirectView
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token

from core import views
from core.ninja import api as ninja_api

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
    path(
        "password-reset/",
        auth_views.PasswordResetView.as_view(
            template_name="registration/password_reset.html",
            success_url=reverse_lazy("auth:password_reset_done"),
        ),
        name="password_reset",
    ),
    path(
        "password-reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            template_name="registration/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "password-reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            template_name="registration/password_reset_confirm.html",
            success_url=reverse_lazy("auth:password_reset_complete"),
        ),
        name="password_reset_confirm",
    ),
    path(
        "password-reset/complete/",
        auth_views.PasswordResetCompleteView.as_view(
            template_name="registration/password_reset_complete.html"
        ),
        name="password_reset_complete",
    ),
]

urlpatterns = [
    path("", RedirectView.as_view(pattern_name="blog:article_list"), name="root"),
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
    path("api-drf/blog/", include("blog.drf_urls")),
    path("api-drf/token", obtain_auth_token, name="api-token"),
    # API 文件
    path("api-drf/schema", SpectacularAPIView.as_view(), name="schema"),
    path(
        "api-drf/docs",
        SpectacularSwaggerView.as_view(url_name="schema"),
        name="swagger-ui",
    ),
    # Django Ninja API
    path("api-ninja/", ninja_api.urls),
    # i18n
    path("i18n/", include("django.conf.urls.i18n")),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [
        *urlpatterns,
        *debug_toolbar_urls(),
        *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
        *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
    ]

實作語系切換下拉選單

在前一節我們已經為 base.html 加上了翻譯標記,現在要在導覽列最右側加入語系切換下拉選單:

templates/base.html
{% load django_bootstrap5 i18n %}

{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}

<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Django Playground" />
    <meta name="keywords" content="Django, Playground" />

    <title>
      {% block title %}
        {% translate "Django 大冒險" %}
      {% endblock title %}
    </title>

    {% bootstrap_css %}

    <link rel="stylesheet"
          href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" />

    {% block extra_head %}
    {% endblock extra_head %}
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
      <div class="container">
        <span class="navbar-brand">Django 大冒險</span>
        <button class="navbar-toggler"
                type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNav">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
          <ul class="navbar-nav ms-auto">
            <li class="nav-item">
              <a class="nav-link" href="{% url 'blog:article_list' %}">{% translate "文章列表" %}</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="{% url 'admin:index' %}">{% translate "管理後台" %}</a>
            </li>
            {% if user.is_authenticated %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:password_change' %}">{% translate "變更密碼" %}</a>
              </li>
              <li class="nav-item">
                <form method="post" action="{% url 'auth:logout' %}" class="d-inline">
                  {% csrf_token %}
                  <button type="submit" class="nav-link btn btn-link">
                    {% blocktranslate with username=user.username %}
                      登出 ({{ username }})
                    {% endblocktranslate %}
                  </button>
                </form>
              </li>
            {% else %}
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:login' %}">登入</a>
              </li>
              <li class="nav-item">
                <a class="nav-link" href="{% url 'auth:register' %}">註冊</a>
              </li>
            {% endif %}
            <li class="nav-item dropdown">
              <a class="nav-link dropdown-toggle"
                 href="#"
                 id="language-navbar-dropdown"
                 role="button"
                 data-bs-toggle="dropdown"
                 aria-expanded="false">
                {{ LANGUAGE_CODE|language_name_local }}
              </a>
              <ul class="dropdown-menu dropdown-menu-end"
                  aria-labelledby="language-navbar-dropdown">
                <li>
                  <form id="language-form"
                        action="{% url 'set_language' %}"
                        method="post"
                        class="d-none">
                    {% csrf_token %}
                    <input name="next" type="hidden" value="{{ request.path }}">
                  </form>
                </li>
                {% for language in languages %}
                  <li>
                    <button type="submit"
                            form="language-form"
                            name="language"
                            value="{{ language.code }}"
                            class="dropdown-item {% if language.code == LANGUAGE_CODE %}active{% endif %}">
                      {{ language.name_local }}
                    </button>
                  </li>
                {% endfor %}
              </ul>
            </li>
          </ul>
        </div>
      </div>
    </nav>

    <main class="container my-4">
      {% bootstrap_messages %}

      {% block content %}
      {% endblock content %}
    </main>

    <footer class="bg-light py-4 mt-5">
      <div class="container text-center">
        <p class="text-muted mb-0">
          Django Playground
        </p>
      </div>
    </footer>

    {% bootstrap_javascript %}

    {% block extra_scripts %}
    {% endblock extra_scripts %}
  </body>
</html>

這段程式碼使用 {% get_available_languages %} 取得 settings.LANGUAGES 中定義的可用語系,在透過 {% get_language_info_list %} 取得語言的詳細資訊,並為每個語系產生一個表單按鈕。點擊後會 POST 到 set_language view,將語系偏好儲存在 session 和 cookie 中,並重新導向回當前頁面。

各 Templatetag 說明

  1. get_current_language 根據 Middleware 的判斷(如 URL 前綴、Session、Cookie 或瀏覽器提供的 Header)決定當前啟用的是哪種語言。
  2. get_available_languages 透過讀取專案設定檔 settings.py 中的 LANGUAGES 設定。
  3. get_language_info_list 取得語言的詳細資訊,轉換後的每個語言物件會包含以下重要屬性,方便前端渲染:
    • code: 語言代碼 (例如 zh-hant, en-us)。
    • name: 語言的英文名稱 (例如 Traditional Chinese)。
    • name_local: 語言的當地名稱 (例如 繁體中文)。這對使用者體驗至關重要,讓使用者能看到自己熟悉的文字。
    • bidi: 是否為右至左書寫 (Right-to-Left),如阿拉伯文。

URL 中的語系前綴

另一種常見的做法是在 URL 中加入語系前綴,例如 /en/articles//zh-hant/articles/。這對 SEO 很有幫助,因為搜尋引擎可以索引不同語言的頁面。

我們將 urlpatterns 分成兩部分不需多語系的 URL(如 API)和需要多語系的 URL,我們把需要多語系的 URL 放到 i18n_patterns() 中來為 URL 加上語系前綴。

請依照下方範例修改 core/urls.py

core/urls.py
from django.conf import settings
from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy
from django.views.generic import RedirectView
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token

from core import views
from core.ninja import api as ninja_api

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
    path(
        "password-reset/",
        auth_views.PasswordResetView.as_view(
            template_name="registration/password_reset.html",
            success_url=reverse_lazy("auth:password_reset_done"),
        ),
        name="password_reset",
    ),
    path(
        "password-reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            template_name="registration/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "password-reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            template_name="registration/password_reset_confirm.html",
            success_url=reverse_lazy("auth:password_reset_complete"),
        ),
        name="password_reset_confirm",
    ),
    path(
        "password-reset/complete/",
        auth_views.PasswordResetCompleteView.as_view(
            template_name="registration/password_reset_complete.html"
        ),
        name="password_reset_complete",
    ),
]

urlpatterns = [
    path("", RedirectView.as_view(pattern_name="blog:article_list"), name="root"),
    path("api-drf/blog/", include("blog.drf_urls")),
    path("api-drf/token", obtain_auth_token, name="api-token"),
    # API 文件
    path("api-drf/schema", SpectacularAPIView.as_view(), name="schema"),
    path(
        "api-drf/docs",
        SpectacularSwaggerView.as_view(url_name="schema"),
        name="swagger-ui",
    ),
    # Django Ninja API
    path("api-ninja/", ninja_api.urls),
    # i18n
    path("i18n/", include("django.conf.urls.i18n")),
    *i18n_patterns(
        path("admin/", admin.site.urls),
        path("practices/", include("practices.urls")),
        path("blog/", include("blog.urls")),
        path("auth/", include((auth_urlpatterns, "auth"))),
    ),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [
        *urlpatterns,
        *debug_toolbar_urls(),
        *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
        *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
    ]

使用 i18n_patterns 後,Django 會自動為其中的路由加上語系前綴:

  • 繁體中文:/zh-hant/blog/
  • 英文:/en/blog/

Model 的多語系

前面介紹的翻譯機制適用於「固定的」介面文字,但如果需要讓使用者輸入的資料(如文章標題、商品描述)也支援多語系,就需要不同的解決方案。

django-modeltranslation

django-modeltranslation 是一個透過「動態新增欄位」來實現 Model 多語系的套件。它會在資料庫原有的資料表中,為每個需要翻譯的欄位自動產生對應語系的欄位(例如 title 會變成 title_zh_hanttitle_en 等)。這種方式的優點是查詢效能極佳且對原有代碼侵入性低,但缺點是每增加一個語系都需要進行資料庫遷移(Migration)。

django-parler

django-parler 則是採用「獨立翻譯資料表」的策略。它會為每個 Model 建立一個額外的關聯資料表來儲存所有語系的翻譯內容。這種架構的優點是極具靈活性,新增語系不需要修改資料表結構,且欄位數量固定,適合語系數量較多或會頻繁變動的場景。

選擇適合的方案

特性 django-modeltranslation django-parler
資料儲存 同一資料表,新增欄位 獨立翻譯資料表
查詢效能 較快(不需 JOIN) 需要 JOIN
欄位數量 欄位數隨語系增加 欄位數固定
新增語系 需要 migration 不需 migration
Admin 整合 優秀 優秀
適用場景 語系數量固定、效能優先 語系可能動態增加

建議

如果你的網站只支援 2-3 種語系且不太會變動,django-modeltranslation 是較簡單直接的選擇。

如果語系數量較多或可能動態增加,django-parler 的架構會更有彈性。

任務結束

完成!

恭喜你完成了這個任務!現在你已經學會:

  • 了解 Django 的國際化 (i18n) 機制
  • 設定專案的語系支援
  • 在 Python 程式碼中標記翻譯字串
  • 在模板中標記翻譯字串
  • 使用 makemessages 產生翻譯檔
  • 編輯 .po 翻譯檔案
  • 使用 compilemessages 編譯翻譯
  • 實作語系切換功能
  • Model 欄位的多語系處理