Sunday, December 16, 2012

mixer2とSpringMVCで作るマルチリンガルサイト

こんばんは。この記事は Spring Framework Advent Calendar 2012の??日目です。参加者少なくて途切れてしまってるみたいますが ^^; 遅ればせながら参戦。

さて、11月にJJUG CCC 2012 Fall 日本Javaユーザーグループっていうイベントがありました。twitter4jというライブラリを作っている山本さんの講演で、ライブラリの配布や使い方のマニュアルなどを載せているWebサイトを、日本語、英語両方で作っているという話がありました。 自作のタグライブラリやjspでちょっとしたCMS(コンテンツマネジメントシステム)を自作しているそうです。

それにからんで、twitterでこんなやりとりをさせていただきました。

実際、ドキュメントを日本語と英語の両方で書かなければならない場面は結構あって、mixer2もいちおう英語日本語の両方で最低限の情報を書いています。

mavenに限らず、Java言語の世界では、hogehoge.propertiesには英語、hogehoge_ja.propertiesには日本語で書いておくと、JavaのLocaleという標準的な多言語対応(i18n)が使えるようになりますよ、みたいな実装が普通です。

ところが、案外これが面倒です。mixer2の場合はmavenのsite機能を使っていますが、たとえば英語のページのソース日本語のページのソースを、別々のファイルに書かなければなりません。 結果、日本語には書いてあるけど英語のほうで翻訳漏れ!といったことが起きます。やったことある人ならわかると思いますが、翻訳の場合、同じファイルに英語と日本語を交互に書くようにすると結構便利です。

山本さんもそこを目指して、同じjsp(拡張子はhtmlにしてるみたいですが)に日本語と英語の両方を特殊なカスタムタグで書いているのでしょう。(実際そんなスライドが講演資料にありました)

そこで、mixer2とSpringMVCを使って、同じリソースに英語と日本語を交互に書けるが、結果は英語サイトと、日本語サイトのように別々に表示できる簡易CMSを作ってみました。

サンプルの説明に入る前に先にお詫びです。山本さんが言っていた「カスタムタグもまだ使い続けたい」という要望には下記のサンプルでは対応できていません。html全体をmxier2で出力する方式をとったためです。ただ、mixer2は部分マーシャル機能もありますので、画面の一部をmixer2に作らせて、それ以外を普通のjspとカスタムタグで作る、みたいなことで対応可能でしょう。

さて、サンプルのソースはここです。mixer2-sampleというリポジトリにまとめて入っています。

作業環境はmavenが動けばどんなIDEでもかまいませんが、ここでは「 SpringToolSuiteでSAStrutsとSpringMVCのHelloWorldを作るまで」で紹介したSTSを想定しています。

先日の「nabedge blog: mixer2でJSPレスなSpringMVCアプリケーションを作ってみた」と同じ方法でmixer2-multilingual-websiteプロジェクトをIDE上に持ちこんでください。そのままTomcat等で起動してみましょう。

シャーロックホームズの原著と日本語訳を載せるサイトです。適当にクリックしてみるとわかるとおり、en/index.htmlでは英語が、ja/index.htmlでは日本語が表示されます。ところが、index.htmlファイルはソースツリー上に一つしかありません。ここがポイントです。

index.html(トップページのテンプレート)はこんな感じです。

  

Sherlock Holmes

シャーロック ホームズ

Sherlock Holmes is a fictional detective created by author and physician Sir Arthur Conan Doyle.

シャーロック・ホームズ(Sherlock Holmes)は、アーサー・コナン・ドイルの推理小説 に登場する架空の探偵。

日本語のタグにはlang-jaというclass属性を、英語のタグにはlang-enをあてています。SpringMVCのコントローラクラスがmixer2実行エンジンを通じてこのテンプレートをロードし、URLの先頭が「en」の場合はlang-ja属性を持つタグを全て削除、「ja」の場合はその逆をやって、出力させるという方法です。

全てのhttpリクエストはPageControllerクラスが処理するようにします。 下の @RequestMapping(value = "/{langStr}/**/*.html") によって、この形式に当てはまるurlへのリクエストは全てこのメソッドに集まります。

    @RequestMapping(value = "/{langStr}/**/*.html")
    public ModelAndView show(@PathVariable String langStr,
            HttpServletRequest request) throws TagTypeUnmatchException,
            IOException {

        logger.debug("# request processing...");
        ModelAndView modelAndView = new ModelAndView();

        // set Lang
        Lang lang;
        try {
            lang = Lang.valueOf(langStr.toUpperCase());
        } catch (IllegalArgumentException e) {
            // set "en" if langStr unmatch Lang enum.
            modelAndView.setViewName("redirect:/"
                    + defaultLang.toString().toLowerCase() + "/index.html");
            return modelAndView;
        }
        logger.debug("# lang = " + lang.toString());

        // build template file path from URI
        String path = StringUtils.substringAfter(request.getRequestURI(),
                request.getContextPath() + "/" + lang.toString().toLowerCase());
        logger.debug("# path = " + path);
        String templatePath = "classpath:m2mockup/m2template" + path;
        logger.debug("# templatePath = " + templatePath);

        // load template
        Html html = mixer2Engine.loadHtmlTemplate(ResourceUtils
                .getFile(templatePath));

        // replace side menu (except for index.html)
        if (!templatePath.equals(indexTemplatePath)) {
            Html indexHtml = mixer2Engine.loadHtmlTemplate(ResourceUtils
                    .getFile(indexTemplatePath));
            Div sideBarDiv = indexHtml.getById("sidebar", Div.class);
            html.replaceById("sidebar", sideBarDiv);
        }

        // remove other language tags
        PageHelper.removeOtherLangTags(html, lang);

        // replace anchor to top page
        for (A a : html.getDescendants("topPageAnchor", A.class)) {
            a.setHref(request.getContextPath() + "/");
        }

        // remake lang list at page top
        PageHelper.remakeLangList(html, lang, path);

        // replace static file path
        M2staticHelper.replaceM2staticPath(html);

        modelAndView.setViewName("mixer2view");
        modelAndView.addObject("htmlString", mixer2Engine.saveToString(html));
        return modelAndView;
    }

たとえば、/en/foo/bar.htmlというURLへのリクエストから、まず言語が英語であることを認識します。次にclasspath:m2mockup/m2template/foo/bar.html というテンプレートファイルを探してロードします。 PageHelper.removeOtherLangTags(html, lang); によって、英語以外のタグを全て削除しています。

PageHelperクラスの説明をする前に、Langというenum型クラスについて説明します。

public enum Lang {
    EN("English")
    ,JA("日本語")
    ;

    private String name;

    private Lang(String name){
        this.setName(name);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

コンストラクタつきのenumクラスです。HashMapよりも使い勝手がいいです。

さて、PageHelperクラスはこんな感じです。

public class PageHelper {

    public static void removeOtherLangTags(Html html, Lang lang) {
        for (Lang l : Lang.values()) {
            if (l.equals(lang)) {
                continue;
            }
            String target = "lang-" + l.toString().toLowerCase();
            html.removeDescendants(target);
        }
    }

html.removeDescendants(target); がポイントです。要するに、指定されたLang型引数以外の言語がclass属性で指定されているタグを、削除しています。要するに en/foo/bar.html であればLang型変数に英語が指定され、その情報をもとにhtml.removeDescendants("lang-ja") しているわけです。こうすることで、表示したい言語以外の言語で書かれたhtmlタグを全て削除しています。

最後にSpringMVCでのLocaleの使い方の話。このサンプルアプリでは、http://.../[contextPath]/ にアクセスすると、そのブラウザのlocaleにあわせて自動的に /en/index.html または /ja/index.html にリダイレクトされるようになっています。やり方は簡単です。

    @RequestMapping(value = "/")
    public String top(Locale locale) {
        Lang lang = defaultLang;
        try {
            lang = Lang.valueOf(locale.getLanguage().toUpperCase());
        } catch (IllegalArgumentException e) {
            return "redirect:/" + defaultLang.toString().toLowerCase()
                    + "/index.html";
        }
        String redirect = "redirect:/" + lang.toString().toLowerCase()
                + "/index.html";
        logger.debug("# " + redirect);
        return redirect;
    }

@RequestMapping(value = "/") というアノテーションがついているメソッドが http://.../[contextPath]/ へのアクセスを処理します。SpringMVCの便利なところは、この処理メソッドの引数にいろいろなものを指定できるところです。 jaav.util.Locale型引数をあてればそこにはブラウザからのリクエストに含まれるロケール情報が自動的にはいります。あとはその言語情報をこっちのLang型変数にあてるだけです。

いかがでしたでしょうか。mixer2はhtml専用のテンプレートエンジンにすぎませんが、他のフレームワークと組み合わせることで結構便利に使えます。それではHappy hacking !

No comments:

Post a Comment