Tuesday, December 4, 2012

mixer2でJSPレスなSAStrutsアプリケーションを作ってみた (JavaEE Advent Calendar 2012)

サーバーサイドJavaが好きだ!(お約束)

こんにちは。このエントリはJavaEE Advent Calendar 2012の4日目です!

プロローグ

(……きこえますか…きこえますか…mixer2のコミッターさん… デザイナーに作ってもらったhtmlを… jspに書き換える作業の繰り返しが… つらくなってきた… プログラマです… 今… あなたの…心に…直接… 呼びかけています…mixer2の公式サイトにあるSAStrutsのサンプルは… しょぼすぎです… もっとまともな… サンプルアプリを作るのです… サンプルを… とにかくサンプルを…)

そんな啓示があったような気がしたので、とりあえずよくあるECサイトっぽい感じで作ってみました。題してFruit Shop(くだもの屋さん)サンプルアプリケーション です。

もちろんソースコードも公開しています。nabedge/mixer2-sampleです。

このエントリでは、mixer2というテンプレートエンジンとSAStruts(Super Agile Struts)というMVCフレームワークとでWebアプリを作るとしたらどんな勘所があるのか? そして何よりmixer2の便利さを、サンプルアプリを通じて紹介します。

Seasar2+SAStrutsではなく、SpringMVCフレームワークを使ったバージョンもあります。 詳しくはこちら!→mixer2でJSPレスなSpringMVCアプリケーションを作ってみた (Java Advent Calendar 2012)

目次
  1. mixer2について
  2. Fruit Shopアプリケーションのチェックアウト(クローン)と起動
  3. まずテンプレートファイルを見てみよう
  4. item.htmlとItemActionクラス
  5. 静的リソースを出力するM2StaticActionとM2StaticHelper
  6. 参考書籍


1. mixer2について

ではソースコードをgithubからチェックアウトして、、、とその前に、あらためてmixer2についてお話しておきます。

mixer2はJavaアプリケーション用テンプレートエンジンです。たとえばApache VelocityFreeMarkerMayaaといったソフトと同じジャンルです。

mixer2の最大の特徴は、テンプレートを100%pureなXHTMLで書けることです。 それはつまり、Webデザイナーが作ったモックアップHTMLファイルにプログラマが手を加えることはほとんどなしでWebアプリ化できるということです。 実際、サンプルアプリのトップページのテンプレートには<c:out value="${data}" />のようなカスタムタグは一切ありません。ごく一般的なhtmlマークアップだけです。逆にjspファイルはたったの1個しかありません。

その代償として、サーバサイドのJavaコードが少し膨れ上がります。そりゃそうです。JSPファイルに書いていたことを、後述するViewHelperクラスの形で書いているからです。しかし、JSPのカスタムタグやVelocityのVTLやmayaaの特殊XML記法ではなく、普通のJavaコードでMVCのView開発できるということは、普通のJavaの知識だけでいいということです。学習の容易さは段違いです。

カスタムタグとhtmlタグとがゴチャゴチャにスパゲティ化したJSPよりは、普通のJavaコードのほうがメンテナンスもデバッグも容易です。そして従来ではSelenium等を使うしかなかったViewに対する自動テストを、普段いつも使っているJUnitで書けます。このサンプルアプリには、ViewのレベルまでをもJUnitでテストするテストケースも入っています。

なお、jspと違って、mixer2のテンプレートはどこにおいてあってもかまいません。サンプルでは通常のリソースファイルとしてsrc/main/resources配下に置いてありますが、たとえばデータベースのテーブルの中に <html>...</html> という文字列でテンプレートが置いてあっても使えます。機能は同じでも多数のデザインを使い分けたいWebアプリ、たとえばマルチテナント型のECサイトや、ブログサービスサイト等では効果的なテンプレートエンジンです。

2. Fruit Shopアプリケーションのチェックアウト(クローン)と起動

ではそろそろ、サンプルアプリのソースをgithubからcloneして実際に動かしてみましょう。mavenとeclipseの環境を用意してください。maven3を使ってますが、maven2.2.xでも動くと思います。よくわからない方は、この前書いたSpringToolSuiteでSAStrutsとSpringMVCのHelloWorldを作るまでというエントリをごらんの上、PCに開発環境を構築しておいてください。

  1. eclipseのEGitでcloneする方法です。まず Window→Open Perspective→OtherでGitRepositoryエクスプローラを開き、新しいリポジトリのcloneボタンをクリックします。

  2. サンプルアプリのリポジトリURLは https://github.com/nabedge/mixer2-sample.git です。ダイアログボックスのURI欄に入力すればそれで他の欄にも入力されます。

  3. git cloneが完了するとこんな感じで見えるはずです。WorkingDirectory内のmixer2-fruitshop-sastrutsを右クリックして Import Projects してください。

  4. とりあえず Import as General Project(一般プロジェクトとしてインポートする)を選択してください。

  5. 表示をJavaパースペクティブに戻して、インポートしたmixer2-fruitshop-sastrutsプロジェクトを右クリック→Configure→Convert to Maven Projectしてください。これでプロジェクトがmavenプロジェクトとして認識され、ソースがコンパイルされます。

  6. あとはサーバで起動するだけです。

  7. トップページが見えたら起動成功です!

3. まずテンプレートファイルを見てみよう

テンプレートファイルが src/main/resources/m2mockup/m2templates にあります。m2staticは画像やcssなどの静的リソース置き場です。まずはindex.htmlをエディタではなくWebブラウザで開いてみてください。

トップページが見れましたね? 適当にリンクやボタンをクリックしてみてください。画面遷移できますね。ただしこれはただの静的ファイルであり、いわゆるHTMLモックアップと呼ばれるものです。買い物カゴに何を入れても入れたように見えるだけ、決済処理をしてもしたように見えるだけの、紙芝居です。Webアプリケーション開発では、まずワイヤーフレームと呼ばれる紙で画面を検討し、最終的にこうしたHTMLモックアップによる仕様の確認が行われます。

このサンプルアプリでは、紙芝居状態のこのHTMLモックアップをそのままテンプレートとして使います。JSPに書きかえる必要はありません。mixer2エンジンがHTML文字列をHTMLオブジェクトとしてインスタンス化し、すべてのタグはそれぞれの型のJavaオブジェクトに変換することで、MVCで言うViewを普通のjavaコードで処理します。

4. item.htmlとItemActionクラス

いちばん構造が単純な商品詳細画面を使って細かい解説に移りましょう。仕様は次のとおりです。

  • http://.../[contextPath]/item/999 というURLのアクセスを受けて、商品番号999の商品データをデータベースから取り出し、商品名(itemName),商品説明(itemDescription),価格(itemPrice)を画面に表示する。(画像を準備するのが面倒だったので商品画像の差し替えは省略 ^^;)
  • add cartボタンを押したらその商品をカートに追加しカート画面に遷移する。
  • 画面上部のロゴ画像を押したらトップページへ遷移する。
  • 画面左のカテゴリ画面へのリンク、カート画面へのリンクはそれぞれの画面へ遷移する。

SAStrutsでは、/item/ というURLに対するアクセスはItemActionクラスのitem()メソッドに割り当てられます。各メソッドはアクションフォームクラス(ここではItemForm)を経由してリクエストパラメータを受け取ってビジネスロジック(サービスクラス)を処理し、結果をアクションフォームクラスまたはActionクラス上のpublicなプロパティに格納しておきます。ViewたるJSPは、それらを使って画面を形成します。

ItemActionクラスのソースコードです。

public class ItemAction {

    @SuppressWarnings("unused")
    private static Logger logger = Logger.getLogger(ItemAction.class);

    public String htmlString;

    @Resource
    protected Mixer2Engine mixer2Engine;

    @Resource
    protected JdbcManager jdbcManager;

    @Resource
    protected CategoryService categoryService;

    @Resource
    protected ItemService itemService;

    @ActionForm
    @Resource
    protected ItemForm itemForm;

    private String mainTemplate = "m2mockup/m2template/item.html";

itemFormがアクションフォームのインスタンスで、ここではitemId(商品番号)をStringで持ってるだけです。URLパラメータとして受け取ったid値がこのitemIdプロパティに自動的に格納されます。

htmlStringが、最終的にmixer2view.jspに渡すためのプロパティです。文字通り<html>...</html>までの全ての画面情報をhtmlStringにつっこんでitem()メソッドは完了です。

では、テンプレートhtmlファイルをどうやって読み込んで、どうやって中身を差し替え、どうやってhtml文字列に戻してhtmlStringにつっこんでいるのでしょうか? まずはテンプレートの読み込みです。

        // load html template
        File file = ResourceUtil.getResourceAsFile(mainTemplate);
        Html html = mixer2Engine.loadHtmlTemplate(file);

これだけです。Seasar2のResourceUtilはクラスパス内の任意のリソースファイルを取得できます。これで得たテンプレートファイルをmixer2Engineに渡すと<html>から</html>までの文字列がHtml型のオブジェクトに変換されます。

        // get item data from database
        ItemDto item = itemService.getItem(Long
                .valueOf(itemForm.itemId));
        
        // embed item boxes
        ItemHelper.replaceItemBox(html, item);

http://.../contextPath/item/999 の999の部分を SAStrutsのリクエストプロセッサが自動的にitemFormのitemIdプロパティに格納してくれますので、あとはそれをItemServiceクラスのgetItemメソッドに渡すだけです。内部ではjdbcManagerを使ってSQLを発行し、SELECT文で得られた結果をItemDto型のオブジェクトに変換して返してくれます。詳しくはItemServiceクラスのソースを見てください。

さていよいよ、itemオブジェクトの内容をhtmlに埋め込んでいきます。公式サイトのHelloWorldのように、Actionクラスの中に埋め込み操作を直接書いてもかまいません。しかしよほど単純な画面でもない限り、それをやるとActionクラスが肥大化しています。

そこで、ItemHelperというヘルパークラスに切り出してそいつにhtmlとitemの両方を渡し、埋め込み操作をやらせます。

ItemHelperのソースをご覧ください。単純なオブジェクト操作だけのユーティリティ的なクラスです。

        // contet div
        Div itemBox = html.getBody().getById("itemBox", Div.class);

        // item information
        itemBox.getById("itemName", H1.class).getContent().clear();
        itemBox.getById("itemName", H1.class).getContent().add(item.name);

mixer2のHtml型オブジェクトは、内部にBody型、Head型、P型、Div型、Span型など、すべてのHTMLタグにあわせたオブジェクトを内包しています。と同時にそれらを操るユーティリティメソッドがついています。

Div itemBox = html.getBody().getById("itemBox", Div.class);
これはhtmlからbody要素を取り出し、さらにその中からitemBoxというid属性を持つdiv要素(<div id="itemBox">...</div>)を取得しています。JavaScriptで言うgetElementById()メソッドと似た感じだと思ってください。 なお、ここではgetBody()をはさんでいますが、
Div itemBox = html.getById("itemBox", Div.class);
でも結果は同じです。

こうして取得したタグ要素の中の商品名、説明、価格などの情報を、itemService経由でデータベースから取得した商品情報(itemオブジェクト)の値で置換していきます。

        itemBox.getById("itemName", H1.class).getContent().clear();
        itemBox.getById("itemName", H1.class).getContent().add(item.name);

商品名はH1タグ内に埋め込むのですが、読み込んだテンプレートには <h1 id="itemName">item name</h1> と言う形で、item nameというダミーの商品名が既に入っています。そこで、h1タグの中身をいったんすべて消してから、改めて商品名を追加しています。価格や説明もそれぞれspan要素やdiv要素の中身を同じ方法で置換しています。

なお、divやspanなどの要素は中に複数のタグや文字列を持つことが出来る要素です。そうした子要素はgetContent()メソッドによってjava.util.List型で返されますので、clear()で全要素の削除、add()で新要素の追加、となります。

次に、カートに追加するボタンまわりの操作です。

        // addCart form
        String ctx = RequestUtil.getRequest().getContextPath();
        Form addCartForm = itemBox.getById("addCartForm", Form.class);
        addCartForm.setAction(ctx + "/cart/add");

テンプレート上では <form id="addCartForm" action="cart.html"> のようにカート画面へ遷移するだけの紙芝居になっています。それを <form id="addCartForm" action="/コンテキストパス/cart/add"> に置換しています。

htmlタグはさまざまな属性を持ちますが、mixer2ではそれらを操作するメソッドも各タグ型に用意されています。Form型であればgetAction()でaction属性を取得、setAction()でaction属性をセットできます。

次に、カートに入れるときに、商品番号もhiddenで送信する必要があります。デザイン決めの段階では必要ないのでテンプレートには含まれていません。そこで、hiddenのinput要素をformに追加しておく必要があります。

        Input input = new Input();
        input.setType(InputType.HIDDEN);
        input.setName("itemId");
        input.setValue(Long.toString(item.id));
        addCartForm.getContent().add(input);

これで<input type="hidden" name="itemId" value="999">というタグがフォームの最後に追加されます。

最後に、いろいろ操作を加えたhtmlオブジェクトをhtml文字列に戻して、ActionクラスのhtmlStringプロパティに格納します。

        // output
        htmlString = mixer2Engine.saveToString(html);
        return "/mixer2view.jsp";

mixer2veiw.jspではこのhtmlStringをそのまま表示するだけです。

5. 静的リソースを出力するM2StaticActionとM2StaticHelper

ところで、各画面の左上のロゴ画像や、cssファイルは、どうやって出力しましょう?

実は意外に難しい問題です。htmlモックアップは*.htmlだけでなく*.css,*.pngでひとまとめで構成されます。今回はこれをJavaアプリのリソースファイルの一部としてm2mockupというフォルダの配下に置いています。しかし通常のServlet/JSP仕様のアプリでは、静的リソースファイルはWebアプリルートフォルダ、つまり今回で言えばsrc/main/webappの配下(WEB-INF除く)に置かないとクライアントからアクセスできません。

ところが、*.htmlは静的リソースではなくアプリケーションコードが使うテンプレートなので、リソース置き場(つまりsrc/main/resources)に置くほうが都合が良いです。だからといってテンプレートをsrc/main/resources, 画像をsrc/main/webappにそれぞれ分けて格納してしまうと、htmlモックアッップの一括管理が難しくなります。

そこで、画像やcssへのリクエストも専用のActionクラスでレスポンスするようにすれば、やはり全てまとめてリソース(src/main/resources配下)に置くことができます。

まず、htmlモックアップ上で <img src="../../m2static/img/logo.png"/> のようになっている画像のパスを、専用Actionへ誘導するヘルパーを書きます。それがM2StaticHelperです。

        for (Img img : ab.getDescendants(Img.class)) {
            if (img.isSetSrc()) {
                String src = img.getSrc();
                img.setSrc(convertPath(src));
            }
        }

上のコードは、与えられたタグ(ここではAbstractJaxb型にキャストされている)の中を走査して全てのimgタグを抜き取り、そのsrc属性をconvertPath()メソッドにあてて差し替えています。

convertPath()メソッドは何をするのかというと、

    private static String convertPath(String str) {
        String ctx = RequestUtil.getRequest().getContextPath();
        if (!str.startsWith("http") && str.matches(".*/m2static/.*")) {
            str = ctx + "/m2static/?path="
                    + URLUtil.encode(str.split("/m2static", 2)[1], encoding);
        }
        return str;
    }

要するに、 "../../m2static/img/logo.png" のような文字列を、"/コンテキストパス/m2static/?path=%2Fimg%2Flogo.png" に差し替えています。これによって、imgタグやstyleタグなどのsrc属性はM2Actionクラスを経由するURIに誘導されます。

M2StaticActionクラスでは、pathというパラメータで受け取った文字列でm2staticフォルダ配下のリソースファイルを探し、それに適切なContent-Typeヘッダ属性をつけてレスポンスします。

    @Execute(validator = false, urlPattern = "m2static")
    public String outputImage() throws IOException {

        // create static file path
        String path = "m2mockup/m2static/" + m2staticForm.path;
        log.debug("static file path = " + path);

        // set contentType
        HttpServletResponse response = ResponseUtil.getResponse();
        String contentType = MimeTypeUtil.guessContentType(path);
        if (contentType == null
                && "css".equals(ResourceUtil.getExtension(path))) {
            response.setContentType("text/css");
        } else {
            response.setContentType(contentType);
        }

        // set cache-control header
        response.addHeader("Cache-Control", "max-age=60");

        // output image
        InputStream inputStream = ResourceUtil
                .getResourceAsStreamNoException(path);
        OutputStream outputStream = response.getOutputStream();
        byte[] buf = new byte[1024];
        if (inputStream == null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND,
                    "File Not Found: " + path);
        } else {
            while (inputStream.read(buf) > 0) {
                outputStream.write(buf);
            }
            inputStream.close();
        }

        // need not view
        return null;
    }

静的リソースまでをもActionクラスで返すのはちょっと贅沢なので、Cache-Controlヘッダをつけて無駄な再リクエストを減らすようにしてあります。

さて次はいよいよActionクラスのテストケースです。MVCのVIEWをもJUnitでテストしてしまう方法...なんですが、長くなりそうです。テストの話は次の機会にしましょう。 引き続きJavaEE Advent Calendar 2012をお楽しみください。次は@sugarlifeさん!

参考書籍

Seasar 2 徹底入門 SAStruts/S2JDBC 対応
竹添 直樹
翔泳社
売り上げランキング: 15271

No comments:

Post a Comment