这篇文章主要涉及到以下一些内容:

  1. 有时候我们用点心,会发现客户的建议是很明智的。

  2. 将用户的输入标准化。

  3. 使用 excel 文件作为用户操作翻译对象的 UI。

  4. 使用 redis 作为翻译数据的存储后端。

  5. 特例: 处理邮件的翻译。

这篇文章的标题看起来有点唬人,这个大规模并不是说为了做这个 I18n 动用了很多机器,也不是说处理了大规模的并发请求,而是指 I18n 涉及到的国家和地区很多, 翻译的内容也很多,这两多已经多到不能用 Rails 默认的方法来处理 I18n 了,既然不能使用 Rails 默认的方法来处理了,那么我们就需要自己动脑筋设计新的方法和程序来处理 这个问题, 在这篇文章中将介绍我们是如何在实际开发中处理这个大规模的 I18n 问题。

本文代码在 https://github.com/baya/my-i18n-demo

问题概述与挑战

我们的项目是一个跨国电子商务网站, 下面的图表示的是我们支持的国家和语言,

locales

Rails 项目中默认以 yml 文件存储翻译内容,比如说 config/locales/en.yml,

# Files in the config/locales directory are used for internationalization
# and are automatically loaded by Rails. If you want to use locales other
# than English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
#     I18n.t 'hello'
#
# In views, this is aliased to just `t`:
#
#     <%= t('hello') %>
#
# To use a different locale, set it with `I18n.locale`:
#
#     I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# To learn more, please read the Rails Internationalization guide
# available at http://guides.rubyonrails.org/i18n.html.

en:
  hello: "Hello world"

这种存储方式有五大不足:

  1. 语言很多时需要添加很多翻译文件。

  2. 翻译内容很多时,文件会很大,存储效率不高,访问速度低下。

  3. 相同的语言在不同的国家其实是有差别的,比如英式英语和美式英语其实是有很多差别的,而这种存储方式不太好处理这种情况。

  4. 不能线上更新翻译内容,如果需要增加或者修改翻译需要更新 yml 文件,然后重新部署应用。

  5. yml 文件对翻译人员不友好,最终将翻译集成到项目的大部分工作都落在了我们开发人员身上。

针对上述不足,我们对症下药,实施下面的解决方法:

  1. 将翻译的存储由 yml 文件改为 redis。

  2. 使用 lang-country 取代 lang 作为 locale, 比如 en-US, en-UK 等。

  3. 使用 excel 文件作为用户提交翻译的 UI, 我们在后台写程序解析 excel 文件,然后自动将翻译内容导入到 redis 中。

在开始实践之前,我们首先建立自己的 demo 项目 my-i18n-demo:

$ rails new my-i18n-demo

1. 使用 redis 作为翻译的存储后端

1.1 建立 redis.yml 文件

# config/redis.yml

i18n:
  host: 127.0.0.1
  port: 6379
  db: 1
  driver: hiredis
  thread_safe: true
  timeout: 200

1.2 建立 locales.yml 文件

为了简单起见,我们在 demo 中只演示简体中文(zh-s), 繁体中文(zh-t), 西班牙语(es), 法语(fr), 日语(jp), 意大利语(it), 俄语(ru), 德语(de), 英语(en) 等几种语言。

# config/locales.yml

locales:
  zh-s:
    - CN
    - CA
    - US
  zh-t:
    - HK
    - TW
    - CA
    - US
  es:
    - ES
    - MX
    - US
    - CO
    - CR
    - DO
    - EC
    - PE
  de:
    - DE
  fr:
    - FR
    - CA
  it:
    - IT
  jp:
    - JP
  ru:
    - RU
  en:
    - US
    - GB
    - CA

locales.yml 文件的结构是: 语言下面是一组使用该语言的国家和地区。

1.3 配置 redis 为翻译的存储后端

首先我们需要在 Gemfile 里引入 3 个 gem:

# Gemfile

+ gem "redis"
+ gem "hiredis"
+ gem 'cached_key_value_store'

gem hiredis 是对用 c 写的 hiredis 做的一个 ruby 封装, c 写的 hiredis 是 redis 的一个 c 客户端库, gem hiredis 最主要的作用就是提高对大批量的 redis 回复的解析速度。

cached_key_value_store 这个 gem 可以缓存一些比较慢的翻译请求,提高系统的响应速度。

最后不要忘记 bundle install

然后创建 redis.rb 文件,

# config/initializers/redis.rb

require 'redis/connection/hiredis'

I18N_LOCALES = YAML.load_file(Rails.root.join('config', 'locales.yml'))['locales']

module I18n
  module Backend
    class KeyValue
	 # 设定了合法的 locales
      def available_locales
        a = []
        I18N_LOCALES.each do |lang, countries|
          countries.each do |c|
            a << "#{lang}-#{c}"
          end
        end
        a
      end
    end
  end
end

$i18n_redis = Redis.new(YAML.load_file("#{Rails.root}/config/redis.yml")['i18n'])
I18n.backend = I18n::Backend::CachedKeyValueStore.new($i18n_redis)

这样配置 redis 的工作就全部完成了,接下来我们调试下以 redis 为存储后端的 I18n 是否正常工作。

1.4 调试 I18n 是否正常工作

首先我们从网上找一份常用的国际化数据,比如 https://github.com/svenfuchs/rails-i18n, 我们使用 zh-CN.yml 作为调试数据。我们把 zh-CN.yml 改名为 zh-s-CN.yml, 并将 zh-s-CN.yml 里的 zh-CN 改为 zh-s-CN,

# config/locales/zh-s-CN.yml
+ zh-s-CN

调试的步骤如下:

  1. 将 zh-s-CN.yml 导入到 redis。

  2. 进入 rails console, 然后查看 zh-s-CN 的翻译是否正确。

1.4.1 将 zh-s-CN.yml 导入到 redis

我们先将 zh-CN.yml 文件下载到 config/locales

$ wget https://raw.githubusercontent.com/svenfuchs/rails-i18n/master/rails/locale/zh-CN.yml

接着我们启动 redis,启动 redis 之前,我们需要用到一个 redis 的配置文件 redis.conf, 可以从 https://raw.githubusercontent.com/baya/my-conf/master/redis.conf 下载, 我们将 redis.conf 放到 /usr/local/etc/ 目录下, 根据 redis.conf 里面 dir /usr/local/var/db/redis/ 的配置,如果没有 /usr/local/var/db/redis/ 这个目录,我们需要创建 /usr/local/var/db/redis/ 这个 目录, $ mkdir -p /usr/local/var/db/redis/, 现在可以启动 redis 了,

$ redis-server /usr/local/etc/redis.conf

我们写一个 rake task 用于将 zh-s-CN.yml 导入到 redis,

# lib/tasks/load_translations.rake

namespace :data do
  task :load_translations, [:locale] => [:environment] do |t, args|
    locale = args['locale']
    file = [locale, 'yml'].join('.')
    translations = YAML.load_file(Rails.root.join('config/locales', file))[locale]

    I18n.backend.store_translations(locale, translations, :escape => false)
  end
end

执行 data:load_translations task 导入翻译数据,

$ bundle exe rake data:load_translations[zh-s-CN]

1.4.2 进入 rails console 调试

> I18n.locale = 'zh-s-CN'
> I18n.t('date.abbr_day_names')
=> ["周日", "周一", "周二", "周三", "周四", "周五", "周六"] 

> I18n.t('date.day_names')
=> ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]

> I18n.t('datetime.distance_in_words.about_x_hours')
=> {:one=>"大约一小时", :other=>"大约 %{count} 小时"} 

> I18n.t('datetime.distance_in_words.about_x_hours.other', count: 1)
=> "大约 1 小时"

> I18n.t('errors.format')
=> "%{attribute}%{message}"

> I18n.t('errors.messages.greater_than', count: 0)
=> "必须大于 0" 

Cool, 工作正常。

2. 用户线上提交翻译

我们开发过一个功能很完备的 web 界面让用户去提交和管理翻译数据,但是用户经常抱怨不会使用这个界面,后来我们发现用户是使用 excel 文件来编辑 和保存翻译好的数据,于是我们想何不直接提供一个上传文件的界面,让用户直接上传 excel 文件,然后我们在后台将文件内容导入到 redis 里不就行了? 后来我们开发了这样一个上传 excel 文件到界面,果然很好用,用户不再抱怨了。现在我们在 my-i18n-demo 中实现这个功能。

2.1 标准化用户输入

在和用户讨论后,我们决定每条翻译记录的第 1 列为 locale, 第 2 列为翻译 key, 第 3 列为翻译 key 的英文内容, 第 4 列为翻译内容。样本文件如下图所示,

hello word

2.2 写代码

设置路由:

# config/routes.rb

Rails.application.routes.draw do
  
+  namespace :admin do
+    resources :translation_files
+  end
  
end

生成控制器:

$ bundle exe rails g controller admin/translation_files

我们建立 TranslationFile 模型来处理上传的翻译文件,

# app/models/translation_file.rb

# encoding: utf-8
class TranslationFile

  attr_reader :errors

  Spreadsheet.client_encoding = 'UTF-8'

  def self.load_to_backend(file)
    file = new(file)
    file.to_backend
    file.errors
  end

  def initialize(file)
    @file   = file
    @errors = []    
  end

  def to_backend
    dict.each {|locale, value|
      I18n.backend.store_translations(locale, value, escape: false)
    }
  rescue Exception => e
    Rails.logger.info(e.message)
    Rails.logger.info(e.backtrace.join("\n"))
    @errors << [e.class, e.message]
  end


  private
  
  def book
    @book ||= Spreadsheet.open(@file)
  end

  def sheet
    @sheet ||= book.worksheet(0)
  end

  def dict
    if @dict.nil?
      @dict = {}
      sheet.each do |row|
        locale, key, _, value = row
        locale = locale.gsub(/\s/, '')
        key = key.gsub(/\s/, '')

        @dict[locale] ||= {}
        @dict[locale][key] = value
      end
    end

    @dict
  end

end

因为我们需要用到 spreadsheet 这个 gem 来处理 xls 文件,所以我们需要在 Gemfile 增加 spreadsheet,

# Gemfile
+ gem 'spreadsheet'

TranslationFilesController 的代码,

# app/controllers/admin/translation_files_controller.rb

class Admin::TranslationFilesController < ApplicationController

  def index
    @locales = I18n.backend.available_locales
    @key = 'hello_world'
  end

  def new
  end

  def create
    file = params[:file].tempfile
    errors = TranslationFile.load_to_backend(file)
    if errors.blank?
      redirect_to action: 'index'
    else
      flash[:error] = errors.map {|e| e.join(":")}.join(";")
      redirect_to action: 'new'
    end
  end
  
end

当我们成功提交 hello_world.xls 文件后,我们可以看到 Hello World 的各个语言的翻译,

hello world translations

特例: 邮件的翻译

在邮件的翻译过程中,我们没有使用 I18n, 而是根据不同的 locale, 建立对应的邮件模版,比如 zh-s-CN 的欢迎邮件, 我们就建立一个 叫 _zh_s_cn_welcome_mail.html.erb 的邮件模版,这样做是因为邮件的翻译内容一般是大段的,并且这大段的内容又会因为不同的 locale 而产生 一些细微的差异, 最重要的原因是邮件是发给客户的,如果邮件内容出现错误我们是没有办法把已经发送的邮件撤销重新发送,所以我们需要尽最大 的努力确保邮件内容不会因为在线上修改翻译内容时而出现错误。