tháng 6 4, 2021

Cài đặt đa ngôn ngữ trong Phoenix bằng Gettext (Phần 2)

Cài đặt đa ngôn ngữ trong Phoenix bằng Gettext (Phần 2)

Ở phần 1, mình đã giới thiệu về Gettext, cấu trúc file của Gettext trong Phoenix cùng một số tính năng nổi bật của nó. Sang phần 2, mình xin trình bày các cách cài đặt Gettext trong Phoenix.

Mỗi module có use Gettext được gọi là một Gettext backend. Và một process có thể có nhiều Gettext backend. Khi các hàm hay macro *gettext (như gettext, dgettext, pgettext, ngettext đã giới thiệu ở phần 1) của một Gettext backend được thực thi, để xác định bản dịch của ngôn ngữ nào sẽ được sử dụng, chúng sẽ xác định giá trị locale theo thứ tự ưu tiên sau.

  • Locale được cài đặt cho backend đó trong process (bằng put_locale/2), nếu có
  • Locale được cài đặt cho process (bằng put_locale/1), nếu có
  • Default locale của backend đó, nếu có
  • Default locale của Gettext, nếu có.
  • Locale mặc định của Gettext là tiếng Anh - "en"

Bước 1 - Cài đặt default locale

Default locale cho Gettext

Default locale cho Gettext được cài đặt thông qua atom :default_locale trong config.

config :gettext, :default_locale, "vi"

Default locale cho Gettext backend

Như đã trình bày ở trên một module khi sử dụng use Gettext. Một project mới tạo, trong file gettext.ex phần web có định nghĩa sẵn một backend.

defmodule LocalizationDemoWeb.Gettext do
  use Gettext, otp_app: :localization_demo
end

Nếu cần cài đặt riêng locale cho một backend thay vì sử dụng locale chung của Gettext, ta có thể cài đặt bằng 2 cách

Cách 1: Sử dụng option default_locale trong use Gettext
defmodule LocalizationDemoWeb.Gettext do
  use Gettext,
    otp_app: :localization_demo,
    default_locale: "vi",
    other_options
end

Ngoài default_locale, có một số option khác như

  • :priv: cài đặt thư mục lưu bản dịch. Mặc định là "priv/gettext"
  • :allowed_locales: mảng các locale được cho phép. Mặc định là tất cả các locale trong thư mục priv/gettext
Cách 2: Sử dụng option default_locale trong config
config :localization_demo, LocalizationDemoWeb.Gettext,
  default_locale: "vi",
  locales: ~w(en vi)
    
# Option locales tương tự như :allowed_locales trong cách 1

Như vậy với file priv/gettext/vi/LC_MESSAGE/default.po sau khi thêm bản dịch

#, elixir-format
#: lib/localization_demo_web/templates/page/index.html.eex:2
msgid "Welcome to %{name}!"
msgstr "Chào mừng tới %{name}"

#, elixir-format
#: lib/localization_demo_web/templates/page/index.html.eex:3
msgid "Peace of mind from prototype to production"
msgstr "Yên tâm từ nguyên mẫu tới thành phẩm"

#, elixir-format
#: lib/localization_demo_web/templates/layout/app.html.eex:18
msgid "LiveDashboard"
msgstr "Bảng điều khiển"

#, elixir-format
#: lib/localization_demo_web/templates/layout/app.html.eex:16
msgid "Get Started"
msgstr "Bắt đầu!"

Sẽ hiển thị giao diện tiếng Việt như sau.

Dịch hơi chuối :D

Nếu app chỉ cần hỗ trợ một ngôn ngữ duy nhất, ta có thể dừng lại ở bước này. Còn nếu hỗ trợ từ hai ngôn ngữ trở lên, sẽ cần tới cơ chế cho phép ta chuyển đổi giữa các ngôn ngữ với nhau.

Bước 2: Lựa chọn locale cho app

Có 2 hướng cho vấn đề này: 1) sử dụng thư viện đã có sẵn hoặc 2) tự làm từ đầu. Ta sẽ lần lượt đi theo từng hướng để giải quyết.

Hướng 1: Sử dụng thư viện có sẵn.

Ta chọn plug set_locale, hiện tại hỗ trợ lấy locale theo thứ tự ưu tiên từ URL, cookie, tham số accept-language của request header và default locale trong config.

Bước 1: Cài đặt set_locale

Thêm set_locale vào mix.exs như sau và chạy lệnh mix deps.get

def deps do
  [
    # ...
    {:set_locale, "~> 0.2.1"}
  ]
end
def application do
  [
    mod: {LocalizationDemo.Application, []},
    extra_applications: [
      :logger,
      :runtime_tools,
      :set_locale
    ]
  ]
end
Bước 2: Thêm plug vào pipeline :browser
pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug(SetLocale,
      gettext: LocalizationDemoWeb.Gettext,
      default_locale: "vi",
      cookie_key: "_localization_demo_locale",
      additional_locales: []
    )
end
Bước 3: Thêm /:locale vào routes
scope "/", LocalizationDemoWeb do
  pipe_through :browser

  get "/", PageController, :dummy
end

scope "/:locale", LocalizationDemoWeb do
  pipe_through :browser
  get "/", PageController, :index
end

Hướng 2: Thực hiện từ đầu

Bước 1: Tạo một module có thể plug vào pipeline :browser

Một plug-able module cần cài đặt hai callback:

  • init/1: khởi tạo các options truyền cho call/2. Ta có thể truyền default_locale để sử dụng trong trường hợp không tìm thấy locale tại các nơi chỉ định. Tuy nhiên, để tránh việc định nghĩa default_locale ở nhiều nơi gây ra tình trạng không đồng nhất, ta chỉ trả về nil.
  • call/2: nhận conn làm đầu vào, thêm locale vào conn, trả về conn.
defmodule LocalizationDemoWeb.Plugs.SetLocale do
  import Plug.Conn
  
  @supported_locales Gettext.known_locales(LocalizationDemoWeb.Gettext)
  
  @max_age 60*60*24*30 # 30 ngày

  def init(_opts), do: nil

  def call(conn, _options) do
    case fetch_locale_from(conn) do
      nil ->
        conn
      locale -> 
        Gettext.put_locale(LocalizationDemoWeb.Gettext, locale)
        conn |> put_resp_cookie("locale", locale, max_age: @max_age)
    end
  end
  
  defp fetch_locale_from(conn) do
    (conn.params["locale"] || conn.cookies["locale"]) |> check_locale
  end

  defp check_locale(locale) when locale in @supported_locales, do: locale
  defp check_locale(_), do: nil
end
  • Gettext.known_locales: trả về danh sách các locale được cho phép. Nếu danh sách này chưa được định nghĩa trong config, mặc định sẽ lấy các locale trong thư mục priv/gettext.
  • Hàm fetch_locale_from tìm locale trong URL, nếu không có nó sẽ tìm trong cookie. Sau đó kiểm tra locale được tìm thấy có hợp lệ (nằm trong danh sách được cho phép) hay không.
  • Nếu tìm thấy locale thì sử dụng hàm Gettext.put_locale để cài đặt locale cho backend LocalizationDemoWeb.Gettext và sử dụng hàm put_resp_cookie để lưu locale vào cookie.
Bước 2: Plug module SetLocale vào pipeline :browser
# lib/router.ex
# ...  
pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug LocalizationDemoWeb.Plugs.SetLocale
end
Bước 3 (tuỳ chọn): Thêm tuỳ chọn ngôn ngữ vào giao diện

Ta sẽ thêm 2 đường dẫn lựa chọn ngôn ngữ vào header trong layout.

Đầu tiên, ta thêm vào module LayoutView, hàm new_locale. Hàm helper này sẽ render đường dẫn tới "/?locale=LOCALE_CODE"

defmodule LocalizationDemoWeb.LayoutView do
  use LocalizationDemoWeb, :view

  def new_locale(conn, locale, language_title) do
    "<a href='#{Routes.page_path(conn, :index, locale: locale)}'>#{language_title}</a>" |> raw
  end
end

Sau đó, sử dụng new_locale trong file layout app.html.eex để tạo 2 đường dẫn tới 2 ngôn ngữ tiếng Anh và tiếng Việt

...
<nav role="navigation">
  <ul>
    <li><a href="https://hexdocs.pm/phoenix/overview.html"><%= gettext "Get Started" %></a></li>
    <%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
    <li><%= link gettext("LiveDashboard"), to: Routes.live_dashboard_path(@conn, :home) %></li>
    <li><%= new_locale @conn, :en, gettext("English") %></li>
    <li><%= new_locale @conn, :vi, gettext("Vietnamese") %></li>
    <% end %>
  </ul>
</nav>

Cuối cùng chạy lệnh mix gettext.extract --merge để cập nhật lại file default.pot, default.po, thêm bản dịch tiếng Việt.

#, elixir-format
#: lib/localization_demo_web/templates/layout/app.html.eex:19
msgid "English"
msgstr "Tiếng Anh"

#, elixir-format
#: lib/localization_demo_web/templates/layout/app.html.eex:20
msgid "Vietnamese"
msgstr "Tiếng Việt"

Kết quả

Từ các kiến thức lượm lặt trên web, mình hệ thống lại thành hai bài viết giới thiệu về Gettext, trước hết là giúp mình hệ thống lại kiến thức, sau đó hy vọng giúp ích được gì đó cho ai đó vô tình đọc được bài viết này.

Code của app LocalizationDemo, mình đẩy lên Github để có thể tham khảo nếu cần.

Neit.