2011/03/24

RubyでFastCGIとGetTextモジュールを組み合せる

Rubyで多言語化を行なうにはMutohさんのRuby-GetText-Packageが便利そうです。

通常の po → mo の変換を行なえばメッセージを m17n できるところが良いので、作業の内容は一般的なものです。

単純な置換なら手でも作れますが、将来的に複数の言語に対応するなら、自分でロジックを組む手間が削減できます。

処理速度が速くなるかどうかは微妙で、ステータスをThread毎に格納して、全体のメッセージ文はRuntimeでキャッシュされるので手作業で置換ロジックを書くよりも多少はスピードアップが期待できそうです。ただメモリを消費してもよいならシンプルに正規表現のリストと置換する文字列の対を作った方が早いんじゃなかろうかと想像しています。

ERBだと<%= _("message") %>のように埋め込む手間がかかり、可読性は多少低下するデメリットもありそうです。

それでもGetTextモジュールを使うことで作業手順が標準化できるので、置換対象の文字列を管理する後々のメンテナンスを考えると総合的に利点が上回ると思います。

GetText自体はcgiクラスとの組み合せを想定しているので、FastCGIについてはドキュメントがなさそうでした。 そのためGetTextとFastCGIについてまとめておきます。

開発環境について

いつものようにUbuntu 10.04 LTS x86_64版を使用しています。

  • OS: Ubuntu 10.04 LTS x86_64
  • Ruby 1.9.2-p136 (/usr/local/bin/ruby) + gettext + fcgi
  • Apache 2.2.14 + libapache2-mod-fcgid

FastCGIとCGIの違い

FastCGIは標準添付ライブラリにはないため、fcgiモジュールをgemなどでインストールする必要があります。

CGIとの違いは起動済みのプロセスにWebブラウザからアクセスする仕組みなので、プロセスの起動にかかる時間が短縮される分、メモリを常に使いますが処理スピードは速いです。

またプロセス自体は終了せずに(Rubyの場合)スレッドが各ブラウザからのリクエストを処理するため、インスタンス変数やクラス変数を適切に使うことで、キャッシュの効果により全体的なレスポンスを向上させる事もできます。

その反面、処理する単位はオブジェクト単位にしてシンプルに切り分けないと、キャッシュしたくない内容が残ったりしてプライバシー上の問題を引き起す可能性もあります。

一般的なWebコンテンツのホスティングサービスでは、FastCGIのようにプロセスが常駐するとメモリを消費するため、必要に応じてリソースを消費するCGIやPHPが一般的です。

FastCGIもCGIもWebの初期からある仕組みなので古典的に扱われますが、Web以前から起動済みのプロセスにオンライントランザクションを処理させる仕組みは、決済やら問い合せ処理やらを高速に処理する仕組みとしてよく知られていました。

起動時間とその起動処理を無視できることは、アクセスが増えていく中で安定的なレスポンスを得るための重要な要件の一つです。

PHPはApacheのモジュールとしてサーバのプロセスイメージの中で処理が完結するため速いはずですが、基本的にはあらかじめ処理内容をキャッシュするわけではないので、時間は短縮できますが、接続毎にいろいろな初期化処理が必要となる点が独特です。

さて「FastCGIは古くない!」という主張は十分したので、GetTextとの組み合せについてまとめます。

FastCGIとGetTextの組み合せ

GetText付属のサンプルをみると、CGIモジュールに対応したスクリプトがありますが、そのままではFastCGIに適用できません。

その理由はFastCGIのスレッドをプールして使い回すという性質にあります。

違いを説明する前に雛型になりそうなスクリプトは次のようになります。

完全に動作するFastCGIスクリプト全体

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-

$:.unshift "/app/lib"
ENV['GEM_HOME'] = "/app/gems"

require 'rubygems'
require 'fcgi'
require 'cgi'
require 'erb'
require 'gettext/cgi'
require 'gettext/tools/parser/erb'

## Render
class SimpleRender

  def initialize(query, env)
    @query = query
    @env = env
    Locale::clear
    Locale::set_request([query["lang"]], [], env["HTTP_ACCEPT_LANGUAGE"], env["HTTP_ACCEPT_CHARSET"])
    GetText::set_output_charset("UTF-8")
    GetText::bindtextdomain("sample", "/app/data/locale")
  end
  
  def render
    ret = ""
    content = "/app/sample.erb"

    ret += ERB.new(open(content, "r:utf-8").read).result(binding)
    return ret
  end

  def _(msgid)
    GetText::_(msgid)
  end
end

## Controller
class Main
  def initialize(request)
    @env = request.env
    @query = check_query_string(CGI.parse(@env['QUERY_STRING']))
  end
  
  def run
    ret = "Content-Type: text/html; charset=UTF-8\r\n\r\n"
    case @query["mode"]
    when "search"
      sr = SimpleRender.new(@query, @env)
      ret += sr.render()
    end
    return ret
  end

  private
  def check_query_string(q)
    ret = {}
    label = "mode"
    ret[label] = (q.has_key?(label) and not q[label][0].empty?) ? q[label][0] : "search"
    label = "lang"
    ret[label] = (q.has_key?(label) and not q[label][0].empty?) ? q[label][0] : "ja"
    return ret
  end
end

###############
## main loop ##
###############

FCGI.each {|request|
  main = Main.new(request)
  request.out.print main.run
  request.finish
}

スクリプトが参照している外部ファイルについて

gemsを使うかどうかは問題ではないのですが、requireしているモジュール・ライブラリにアクセスできるように、先頭の6行は適切なパスに書き直す必要があります。

/app/sample.erbはコンテンツ本体で、次のような内容になっています。

sample.erb本体

<html>
<body>
<h1><%= _("Hello World") %></h1>
</body>
</html>

GetTextモジュールの処理の中心となる翻訳データを保存するためには /app/data/localeディレクトリを作成しています。 作成方法はmoファイルを生成するところで行なっています。

結果として、ライブラリを除いた/appディレクトリの構造は次のようになっています。

$ find . -type f
./po/en/sample.po
./po/ja/sample.po
./po/sample.pot
./Rakefile
./data/locale/en/LC_MESSAGES/sample.mo
./data/locale/ja/LC_MESSAGES/sample.mo
./sample.erb
解説:Thread毎のキャッシュの破棄

LocaleはThread.current[:current_request]に必要なデータ構造をキャッシュします。

スレッドが消滅しないかもしれないので、接続毎に処理が終った段階でデータを破棄する必要があります。

処理が終った段階」を徹底するために、処理を始める前にデータを破棄しています。 このLocale::clearはnilを代入するだけの処理なので、スレッドが立ち上がった最初に実行しても問題ない内容になっています。

処理開始時にLocale::clearを呼び出しているところ

    Locale::clear
    Locale::set_request([query["lang"]], [], env["HTTP_ACCEPT_LANGUAGE"], env["HTTP_ACCEPT_CHARSET"])
    GetText::set_output_charset("UTF-8")
    GetText::bindtextdomain("sample", "/app/data/locale")
解説:クライアントの言語情報の登録

前項にあるコードの2行目 Locale::set_request()メソッド が、クライアントが利用する言語を登録しているところです。

CGIモジュールを使うサンプルでは、Locale::set_cgi()を使用していますが、今回はCGIオブジェクトは使えないため直接その内部で使用している Locale::set_requestメソッド を呼び出しています。

第一引数に指定した言語から優先度が高く、第二引数にはCookieに保存した言語情報を格納する想定ですが、cookieを使っていないので空にしています。

GetText関連ファイル(po,moファイル)の作成

今回の例では、moファイルにアクセスするために、/app/data/localeを指定しています。

必要なファイルを作成するために、まず次のような内容の/app/Rakefileファイルを作成しました。 これはMutohさんのページの説明ほぼそのままです。

/app/Rakefileの全体

$:.unshift "/app/lib"

desc "Update pot/po files."
task :updatepo do
  require 'gettext/tools'
  GetText.update_pofiles("sample", Dir.glob("*.erb"), "sample 1.0.0")
end

desc "Create mo-files"
task :makemo do
  require 'gettext/tools'
  GetText.create_mofiles
end

先頭の $:.unshift は、FastCGIスクリプトと同じで、GetTextモジュール(gettext.rb)へのパスです。

デフォルトのパスにインストールしていれば不要ですが、今回は特別な場所にモジュールを配置しているので追加しています。

ファイルを配置した後は、poファイルを作成します。

$ cd /app
$ rake updatepo

"/app/po/sample.pot"が作成されるので、それを言語毎のディレクトリにコピーをして翻訳を行ないます。

$ cd /app
$ mkdir po/ja po/en
$ cp po/sample.pot po/ja/sample.po
$ cp po/sample.pot po/en/sample.po

2つのsample.poの翻訳が終ったら moファイル を作成します。

$ rake makemo

これが無事に終ると /app/data/locale/ja/LC_MESSAGES/sample.mo/app/data/locale/en/LC_MESSAGES/sample.mo のファイルが作成されているはずです。

po/ja/sample.poファイルの内容

# Copyright (C) 2011 Yasuhiro ABE yasu@yasundial.org
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: sample 1.0.0\n"
"POT-Creation-Date: 2011-03-24 09:25+0900\n"
"PO-Revision-Date: 2011-03-24 09:25+0900\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"

#: sample.erb:3
msgid "Hello World"
msgstr "こんにちは"

FastCGIスクリプトの実行

Apacheを適切に設定して、Options ExecCGIが設定された場所に配置してWebブラウザから呼び出します。

URLの最後に ?lang=ja?lang=enをつけて呼び出して内容が変化することを確認します。

GetTextモジュールを使っていて気がついたこと

rmsgmergeコマンドが使えない

たぶん一斉置換か何かの影響か、gettext/tools/rmsgmerge.rb の中がおかしくなっています。

rmsgmergeの修正個所

#      refpot = parser.parse_file(config.refstrrefstr, PoData.new, false)
      refpot = parser.parse_file(config.refpot, PoData.new, false)

これで終りかと思いきや、-hで表示される第一引数と第二引数は逆にしないといけない模様です。

いまのところRakefileの方が便利そうなので困ってはいませんが、コマンドを使って更新をpoファイルに反映したい場合には注意が必要です。

困った事にdebパッケージでruby 1.8.x用に入っているrmsgmergeも動きません。

rgettextコマンド内部で参照しているrgettext.rbへのパスがおかしい

require 'gettext/rgettext'require 'gettext/tools/rgettext'に変更しました。

rgettextコマンド内部にあるrubyコマンドへのパスを変更する

rmsgmergeでも同じでしたが、コマンドの先頭にある#! /usr/bin/rubyのパスを#!/usr/local/bin/rubyに変更しました。

$:変数にライブラリディレクトリを加える

またRuby 1.9.2では$:はカレントディレクトリを指さないので、gettext.rbのあるディレクトリへのパスを$:に登録しています。

いろいろ修正したrgettextスクリプト全体

#! /usr/local/bin/ruby
# -*- coding: utf-8 -*-
=begin
  rgettext - ruby version of xgettext

  Copyright (C) 2005-2009  Masao Mutoh
  
  You may redistribute it and/or modify it under the same
  license terms as Ruby.

=end

$:.unshift File::join([File::dirname($0),"..","lib"])

begin
  require 'gettext/tools/rgettext'
rescue LoadError
  begin
    require 'rubygems'
    require 'gettext/tools/rgettext'
  rescue LoadError
    raise 'Ruby-GetText-Package are not installed.'
  end
end

GetText.rgettext

ドキュメントが微妙に古くて通用しない記述もいろいろありましたが、ちゃんと動いています。

m17n のためには、gettextの標準的な作法が使えるというのは強力だと思います。

0 件のコメント: