多國語系¶
開始之前¶
任務目標
在這個任務中,你將學習:
- 了解 Django 的國際化 (i18n) 機制
- 設定專案的語系支援
- 在 Python 程式碼中標記翻譯字串
- 在模板中標記翻譯字串
- 使用 makemessages 產生翻譯檔
- 編輯 .po 翻譯檔案
- 使用 compilemessages 編譯翻譯
- 實作語系切換功能
- Model 欄位的多語系處理
Django 國際化簡介¶
什麼是 i18n 和 l10n¶
在開發面向國際市場的網站時,你可能會聽到兩個常見的縮寫:i18n 和 l10n。
- i18n (Internationalization,國際化):指的是設計軟體架構,使其能夠支援多種語言和地區,而無需對程式碼進行結構性修改。i18n 這個縮寫來自 "internationalization" 的首字母 i 和尾字母 n 之間有 18 個字母。
- l10n (Localization,在地化):指的是將軟體調整為特定語言或地區的過程,包括翻譯文字、調整日期格式、貨幣符號等。l10n 來自 "localization" 首尾字母之間有 10 個字母。
簡單來說,i18n 是「讓軟體能夠被翻譯」,l10n 是「實際進行翻譯」。
Django 的多語系架構¶
Django 內建了完整的國際化框架,主要包含以下元件:
- 翻譯字串標記:使用
gettext()函數標記需要翻譯的字串 - 翻譯檔案:
.po檔案儲存原文與譯文的對應,.mo檔案是編譯後的二進位格式 - 語系切換機制:透過 Middleware 和 Session 來偵測與切換使用者的語系偏好
- 格式化工具:自動根據語系格式化日期、時間、數字
Django 的翻譯機制基於 GNU gettext 工具集,這是一套廣泛使用的開源翻譯系統。
專案設定¶
settings.py 設定¶
要啟用 Django 的多語系功能,需要在 settings.py 中進行以下設定。
首先,在檔案開頭加入 gettext_lazy 的 import:
"""
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 區塊,將整個區塊替換為以下內容:
# 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-hant、en、ja)LANGUAGES:網站支援的語系列表,每個項目是(語系代碼, 顯示名稱)的 tupleLOCALE_PATHS:翻譯檔案的目錄路徑USE_I18N:是否啟用 Django 的翻譯系統(啟用時,日期、數字的在地化格式也會自動啟用)
為什麼用 gettext_lazy?
在 LANGUAGES 設定中使用 gettext_lazy() 而非 gettext(),是因為 settings.py 在 Django 啟動時就會被載入,此時翻譯系統可能尚未完全初始化。gettext_lazy() 會延遲翻譯的執行,直到字串真正需要被顯示時才進行翻譯。
Middleware 設定¶
Django 透過 LocaleMiddleware 來偵測使用者的語系偏好。找到 MIDDLEWARE 設定,在 SessionMiddleware 之後加入 LocaleMiddleware:
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 會按照以下順序偵測使用者的語系偏好:
- URL 中的語系前綴(如果使用
i18n_patterns) - Session 中儲存的語系設定
- Cookie 中的
django_language值 - 瀏覽器的
Accept-LanguageHTTP header - 最後使用
LANGUAGE_CODE作為預設值
建立語系檔案目錄¶
為了讓專案結構更清晰,我們採取「分層管理」的策略:
- 專案全域翻譯:放置在根目錄的
locale/,包含共用的模板(如base.html)、settings.py中的設定等。 - App 專屬翻譯:放置在各 App 下的
locale/(如blog/locale/),包含該 App 的 Model、View 和專屬模板。
請建立這兩個目錄:
目錄結構會像這樣:
django-playground/
├── locale/ # 全域翻譯
│ └── en/
├── blog/
│ ├── locale/ # App 翻譯
│ │ └── en/
│ ├── templates/
│ └── ...
├── core/
└── ...
Django 會自動偵測 INSTALLED_APPS 中每個 App 底下的 locale 目錄,以及 settings.py 中 LOCALE_PATHS 指定的目錄。
日期、時間與數字格式¶
在進入翻譯字串標記之前,先了解 Django 如何處理日期、時間與數字的在地化格式。
時區設定¶
Django 支援時區感知的日期時間處理。在前面的 settings.py 設定中,我們已經設定了:
USE_TZ = True:Django 內部使用 UTC 儲存時間,顯示時轉換為指定時區TIME_ZONE:預設顯示的時區
在模板中,可使用 timezone filter 來轉換時區:
日期與數字格式在地化¶
當 USE_I18N = True 時,Django 會根據當前語系自動格式化日期和數字:
不同語系的顯示範例:
| 類型 | 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:
Python 程式碼中的翻譯¶
使用 gettext()¶
在 Python 程式碼中,我們使用 gettext 系列函數來標記翻譯字串。慣例上會將主要使用的翻譯函數 import 為 _ 以保持程式碼簡潔。
但在 Class-Based View (CBV) 的情境下,我們經常需要混用兩種模式:
- 類別屬性(如
success_message):必須使用gettext_lazy,以免在伺服器啟動時就固定了翻譯結果。 - 方法邏輯(如
get_success_message):可以使用gettext進行即時翻譯。
因此在下方的範例中,我們採取以下 Import 策略,將 gettext_lazy 命名為 _,而即時翻譯則直接使用 gettext:
請修改 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,將整個檔案內容替換為以下程式碼:
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 後別忘了更新資料庫
Message ID 的設定
要特別注意一下你打在 _() 中的文字會變成翻譯的 ID,像我們現在這邊要分辨單數複數名詞如果用中文那因為 message id 一樣會造成後續無法翻譯區分。所以我會建議你們 message id 都盡可能用英文或是用不一樣的名稱
這樣是好的,因為 message id 不同
這樣是不好的,因為 message id 完全相同後續會無法區分翻譯
何時使用 gettext_lazy
使用 gettext_lazy() 的常見場景:
- Model 欄位的
verbose_name - Model 的
Meta.verbose_name和verbose_name_plural - Form 欄位的
label和help_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 中使用了這種方式:
這裡的 _ 是 gettext_lazy 的別名,因為 success_message 是類別屬性,需要在存取時才進行翻譯。
相對地,在 ArticleDeleteView 的 get_success_message 方法中,因為是在執行時呼叫,我們可以改用 gettext(即使用 gettext_lazy 版本通常也能運作,但區分清楚是好習慣且節省資源):
def get_success_message(self, cleaned_data):
return gettext("文章「%(title)s」已成功刪除。") % {
"title": self.object.title
}
使用命名參數的好處是,翻譯人員可以根據目標語言的語法調整參數的順序。
模板中的翻譯¶
載入 i18n 標籤¶
在模板中使用翻譯功能前,需要先載入 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 語法:
使用 {% blocktranslate %} 標籤¶
當翻譯字串中需要包含變數時,使用 {% blocktranslate %}。例如在登出按鈕中顯示使用者名稱:
也可以同時使用多個變數:
{% 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:
{% 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,為導覽列的文字加上翻譯標記,同時動態的調整 html 的 lang 屬性:
{% 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>
- 對於包含變數的文字,使用
{% blocktranslate %}:
同樣地,更新 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 參數產生我們想要的語系:
Django 的 makemessages 指令非常聰明,它會掃描整個專案,並根據 locale 目錄的存在與否,自動將翻譯字串分配到正確的位置:
- App 內的字串:如果該 App 有自己的
locale目錄(如blog/locale),字串會被存入該目錄下的翻譯檔。 - 其他字串:如果所在的 App 沒有
locale目錄,或者字串位於共用區塊(如根目錄的templates),字串會被存入根目錄的locale。
因此,你不需要特別切換目錄或使用 --ignore 參數,也能正確地分層管理翻譯檔案。
針對性更新與排除
雖然從根目錄執行很方便,但如果你只想更新特定 App 的翻譯,仍可以切換到該 App 目錄下執行:
此外,你也可以使用 --ignore 參數來手動排除不感興趣的目錄(如 venv 或其他特定的 App 目錄)。
指定語系¶
使用 -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-hant→zh_Hantzh-hans→zh_Hanspt-br→pt_BR
.po 檔案結構¶
產生的 .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 這樣的佔位符。
編譯翻譯¶
使用 compilemessages¶
編輯完 .po 檔案後,需要編譯成 Django 可以讀取的 .mo 格式:
這個指令會將 locale/ 目錄下所有的 .po 檔案編譯成對應的 .mo 檔案。
需要安裝 gettext
compilemessages 指令需要系統安裝 GNU gettext 工具。
macOS:
Ubuntu/Debian:
Windows: 可以從 GNU gettext for Windows 下載安裝。
.mo 檔案的作用¶
.mo (Machine Object) 檔案是 .po 檔案的編譯版本,它是一種二進位格式,Django 可以快速讀取。
.po檔案:人類可讀的文字格式,用於編輯翻譯.mo檔案:機器可讀的二進位格式,用於程式執行
.mo 檔案與版本控制
關於是否將 .mo 檔案加入版本控制,有兩種常見做法:
- 加入版本控制:部署時不需要額外編譯步驟
- 不加入版本控制:在 CI/CD 流程中執行
compilemessages
無論選擇哪種方式,.po 檔案一定要加入版本控制。
語系切換¶
如前所述,LocaleMiddleware 會自動根據 URL 前綴、Session、Cookie、瀏覽器設定等來偵測使用者的語系偏好。如果你只需要根據瀏覽器語系自動切換,不需要額外的程式碼。
但如果希望讓使用者能主動切換語系,就需要實作語系切換功能。
使用 set_language view¶
Django 內建了 set_language view,可以處理語系切換的請求。在 core/urls.py 的 urlpatterns 中加入 i18n 路由:
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 加上了翻譯標記,現在要在導覽列最右側加入語系切換下拉選單:
{% 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 說明
get_current_language根據 Middleware 的判斷(如 URL 前綴、Session、Cookie 或瀏覽器提供的 Header)決定當前啟用的是哪種語言。get_available_languages透過讀取專案設定檔 settings.py 中的 LANGUAGES 設定。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
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_hant、title_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 欄位的多語系處理