Category Archives: Java

アンドロイドは猫さんとは仲がわるいのか

こんにちは。またまたはまりましたので報告します。このところ連投だなあ(笑)

問題概要

Android端末で毎画面セッションIDが変わるという現象が開発用のローカル環境で発生し、延々と調べてしまいました。
原因は、http://d.hatena.ne.jp/s-ishigami/20110916/p1と同じで、セキュア属性付きのCookieをHTTPで変更できない件によるもので、それが、JSESSIONIDで発生していました。

<

div class="section">

原因

こういうことのようです。

「JSESSIONIDを保持したCookieをsecure属性にする方法」
http://www.atmarkit.co.jp/bbs/phpBB/viewtopic.php?topic=5722&forum=12

hreq(HttpServletRequest) がSSLであれば、Secure属性を付けているようです。

セッションが初回(または無効)で、HTTPSでアクセスされたと判断すると、TomcatはSet-Cookieレスポンスヘッダをsecure属性付きで返します。この仕様は変えられないようです。これはこれで望ましいのですが困ってしまうことがあります。

以下は検証コードです。

public class MyServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
        try {
            String path = request.getRequestURI().substring(request.getContextPath().length());
            if (path.equals("/cookie")) {
                request.getSession(true); // session start
                response.setContentType("text/plain; charset=UTF-8");
                PrintWriter writer = response.getWriter();
                writer.println("request cookies: ");
                if (request.getCookies() != null) {
                    for (Cookie reqCookie : request.getCookies()) {
                        writer.println("\\t" + cookieToString(reqCookie));
                    }
                }
            }
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }
    prcted String cookieToString(Cookie cookie) {
    return cookie.getName() + "=" + cookie.getValue() + "\\n\\t\\t" +
    "d: " + cookie.getDomain() +
    ", p: " + cookie.getPath() +
    ", v: " + cookie.getVersion() +
    ", a: " + cookie.getMaxAge() +
    ", c: " + cookie.getComment() +
    ", s: " + cookie.getSecure();
    }
}


PCやiPhoneのブラウザでは、セキュア属性付きでセッションがスタートした直後にHTTPページへ遷移した場合、そのセッションが切れますが、新しいセッションが開始し、ブラウザもそのセッションIDをCookieで受け取ります。が、AndroidはこのCookieを拒否してしまいます。

解決方法

ロードバランサにSSLを処理させている場合は、TomcatがHTTPSかどうかを判断することが出来ず、常にHTTPだと認識して動作しているので、問題ありません。(リバースプロキシを使用していない場合)

しかし、もし、

  • LBやSSLアクセラレータを使っていない
  • AndroidでアクセスされるWebサービス
  • HTTPとHTTPSを行ったり来たりする
  • 「初回は必ずHTTPである」ことを保証できない(HTTPS操作中にセッション切れになる場合も含めて)

このような性格のサービスの場合は注意が必要です。

取れる対策としては

  • 全てSSLにする
  • セッションが無効で、HTTPSアクセスの場合は、HTTPにリダイレクトする
  • Cookieを使わない

などになると思います。

結論

AndroidとTomcatは仲が悪い(・へ・)

2012/03/04 追記

セッションハイジャック保護のため、SSLを使用したページではセキュアなセッションIDを発行すること自体は望ましいことです。IPAも推奨しています。しかし、httpとhttpsを行き来するケースでは、jsessionidを直接secureにして欲しくなく、別にセキュアなsessionidを投げて、httpで使用するjsessionidと紐付けを行うという実装方法が多く取られていると思います。

自分のアプリケーションはそのような構成になっていたのですが、まさかjsessionid自体をsecureにされるとは思っていませんでした。

実際はLBの後ろ側にAPサーバを配置するケースが多いと思います。開発環境でオレオレ証明書でのテストが「何故か動かない」と、納期直前に泣きそうになった開発者の記録です。。。

MayaaなどRhinoを使っていてハマること。It is not a function, it is String

またまたドハマリをしたので報告します。シチュエーション的にはまれだと思いますが、同じ罠にはまると、解決するのに時間がかかると思うので報告します。

現象としては、以下の2記事の組み合わせです。

TreeSetのComparatorではまったのでメモ(初心者向け)
http://d.hatena.ne.jp/s-ishigami/20110615/p1

噛み砕いていうと「compareの結果が0と、equalsが同値でないと、TreeSetは異常な動きをするよ」というところです。

そろそろ2年間Mayaa使ってわかったことを書く
http://d.hatena.ne.jp/s-ishigami/20110708

mayaaでは、Javaオブジェクトとテンプレートとの橋渡しにJavaScript(Rhino)を使用します。〜略〜「Javaのように見えて少し違う」書き方をしなければなりません。Javaだと思って書いていると、うっかり思いもよらないバグを作ってしまってハマります。

今回はこの具体例の好例だと思います。

コード例

実務では書かないと思いますが、実験のため以下の様なコードを書いたとします。

public class Example {
    public static SortedMap<String, String> newNumberSortedMap() {
        return new TreeMap<String, String>(new Comparator<String>() {
        @Override
        public int compare(String o1, String o2) {
            int i1 = 0, i2 = 0;
            try { i1 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
            try { i2 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
                return i2 - i1;
            }
        });
    }
}

ここでやっていることは、"1", "2"のように、数字だけで構成された文字を入れたとすると、"1", "10", "2"...のようなアスキー順にならず、"1", "2", "10", ...のように数値順に列挙できるマップをつくろうとしています。数字以外をキーするケースはビジネス上考えなくてよく(バリデートされているか、DBで検査されているとする)、万が一設定された場合は数値"0"として判断されます。

mayaaファイルをこのように実装します。実務ではないかもしれませんが、mapに値を格納して、エントリーセットを変数に格納しています。

<?xml version="1.0" encoding="UTF-8"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org">
<m:beforeRender>
<![CDATA[
      var map = Packages.com.example.Example.newNumberSortedMap();
      map.put(\'0\', "zero");
      map.put(\'1\', "one");
      map.put(\'2\', "two");
  ]]>

これを実行するすると、以下のようなエラーが発生します。

TypeError: Cannot call property put in object {0=zero}. It is not a function, it is "string".

原因

これはどういう事でしょうか?直訳すると、「プロパティー"put"をオブジェクト{0=zero}にcallできません。それは関数ではなく、それはstringです。」です。JavaScriptのことがわからないとこのエラーは意味不明です。

JavaScriptには、以下のようなシンタックスシュガーがあります。

var hoge = { a: 'a' };

このとき、

hoge.a

hoge['a']

は同等です。したがって、先程の

map.put('1', 'one')

は、

map['put']('1', 'one')

のように変化します。

このままなら、map['put']が、Javaのmap#put(String,String)を呼び出す関数オブジェクトになって、それにパラメータ('1', 'one')が渡されるだけなので、問題ありません。

ところが、さらに、今後はスクリプトエンジンRhino特有のシンタックスシュガーが悪さをします。RhinoはJSPのEL式のような書式をサポートしています。例えば、

hoge.fuga

と書くと、

hoge.getFuga()

へのショートカットになったりします。Map型に対してはさらに、

map['hoge']

が、

map.get("hoge")

に割り当てられます。すると、先ほどの式は、

map.get("put")

へと変化します。

続いて、このMapオブジェクトのgetの実装に処理は移譲されます。このmapは上記では、独自Comparatorを搭載したTreeMapです。このTreeMapはアプリケーションの制約により、数字以外の文字列を格納しないルールでした。数字以外の文字列を格納したときの動作は保証されません。具体的には、最初にリンクをした記事にあるように、TreeMapのgetはComparatorで0になるキーの値を返してしまいます。そのため、

map.get("put")

map.get("0")

と同じ結果を返してしまいます。運が悪いことに、直前に

map.put("0", "zero")

が呼び出されていました。そのため、この式は"zero"というString値を返してしまいます。このままJavaScript側に処理を戻すと、今このようになっています。

'zero'('1', 'one')

まとめ

上記をまとめると以下のようになります。

map.put('1', 'one');
map['put']('1', 'one');        // JavaScriptのシンタックスシュガー
map.get("put")('1', 'one'); // Rhinoのシンタックスシュガー
map.get("0")('1', 'one');    // Comparatorの実装ミス
"zero"('1', 'one');

はい、

It is not a function, it is "string"

というわけです(笑)

修正方法

今回の問題の根本原因はComparatorの実装にバグがあることでした。正しい実装は以下のとおりです。(キーにnullを許容しない場合、許容する場合は上記の記事の最後に正解があります)

@Override
public int compare(String o1, String o2) {
    int i1 = 0, i2 = 0;
    try { i1 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
    try { i2 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
    if (i1 == i2) {
        return o1.compareTo(o2);
    }
    return i2 - i1;
}

最初に引用したとおり、TreeMapのComparatorを実装するときは、o1とo2が完全に一致する場合を除いて0を返してはいけません。そのようなコードを書いたときの動作はAPIによって保証されていません。

今回は、SunVMで検証したので、もしかしたらVMによっては動作が異なるかもしれません。

考察

今回は、TreeMapで陥りやすい罠、にMayaaあるいはテンプレート系DSL全般で陥りやすい罠が組み合わさって非常に発見しない不具合が発生してしまいました。まだおかしな動作をしてデータを破壊することはなく、例外で落ちていたからましかも知れません(前回TreeMapで問題を起こしたときは最悪データを壊す可能性がありました)

言えることは、他言語間(JavaとJavaScriptなど)のコラボレーションはシームレスには行かないということです。境界がある以上どこかにほつれは必ず存在します。そのための方針としては、両者の役割をできるだけ分離し、境界でコードが動くことを少なくすることです。JavaとJavaScriptを行ったり来たりするケースでは、プリミティブ型とString、配列など典型的な型のみを使用し、やむなくListやMap、その他複雑なオブジェクトを使う場合は十分に注意するべしというところです。

それは分かっていても、今回のような問題は発生します。そこでできるのは情報共有だと思います。少しでも誰かの役に立てるよう、今回のように問題にはまったときは、これからも情報を発信し続けようと思います。

2年間Mayaaを使ってわかったこと その2

前回のエントリは、ここ最近の自分の取り組みの集大成(大げさw)のつもりで気合を入れて書きました。結果、このブログとしては多めアクセス数、ブックマークをいただいているようで、誠にありがとうございます。*1しかし読み返してみると書き忘れたことが幾つかありました。すでに前エントリは十分長いので、ここに続きを書こうと思います。

ちなみに、仕事のことを公に書いて大丈夫かと時々言われますが、弊社代表が「オープンにして失うものはない」ということをおっしゃっているので、それに従っています。もちろんこのブログは上司も社長も読んでいるので、万が一なにか差し支えがあって怒られたら、その時は全力で修正するでしょう。

それでは書きます。

Mayaaを効果的に使うためのノウハウ

iframeを使ってサーバサイドインクルードを偽装する

Mayaaを使ったテンプレートで妥協はよくありません。例えばWebサイトでは当たり前のように使われる「ヘッダー」「フッター」「サイドバー」などの部品は、テンプレートエンジン関連で重大な悩みだと思います。

「Webページにヘッダーなどの共通部分を埋め込みたい!」…‥古くはFrameタグやSSIからあるニーズですね。

しかし、このことをうまく解決している現場はなかなかないと思います。例えば、FrameやIFrameを使った方法では、JavaScriptでの制御が難しく、ユーザに対してもどことなく違和感を感じさせてしまいます。一方、SSIのようなサーバサイドのインクルードは(Mayaaではinsertプロセッサーで実現できますが)、「サーバ上で実行しなければ結果がわからない」というデメリットがあります。

私たちは、Mayaaのinsertプロセッサと、IFrameを組み合わせてこの問題を解決しました。

まず、こんな感じのm:idを定義します。

<!-- 常に非表示にします。 -->
<m:null m:id=\'DUMMY\' />
<!-- ヘッダーを出力します。 -->
<m:insert m:id=\'common.HEADER_HERE\' path=\'${"common/header.xhtml"}\' />

次に、次のようなクライアントサイドJavaScript(js/iframe.js)を作成します。

function resize_iframe(element) {
    if (element.contentWindow.document.documentElement) {
        element.style.height = element.contentWindow.document.documentElement.scrollHeight + "px" ;
    } else {
        element.style.height = element.contentWindow.document.body.scrollHeight + "px";
    }
}

テンプレートには次のように書きます。

<script type="text/javascript" charset="utf-8" src="js/iframe.js" m:id=\'DUMMY\'></script>         
<iframe m:id=\'common.HEADER_HERE\' src="common/header.xhtml" frameborder="0" scrolling="no" width="100%" onload="resize_iframe(this)"></iframe>

このようにすると、ローカルでhtmlファイルを直接開いたときは、iframeとしてヘッダ部分が表示され、サーバ上で実行したときは、insertプロセッサの働きでHTML内にインクルードされます。

ただ、残念なことにchrome4(いつのことやら)あたりで、セキュリティが厳しくなってしまって、上記コードをローカルの実行するとJavaScriptエラーになってしまいました。この場合、chrome起動オプション

-allow-file-access-from-files

を付与すると、エラーが出なくなり正しく動作するようになります。

IFやLOOPのタグの定型文

いろいろ試したのですが、ifプロセッサやforプロセッサ使用時のテンプレートやmayaaファイルの定義は次の書き方に統一するのが一番良いように思います。

<m:if m:id="IF_MEMBER" test="${条件}">
    <m:echo><m:doBody /></m:echo> <!-- この一行が大事! -->
</m:if>
....
<div m:id="IF_MEMBER">
こんにちは....さん。
</div>
...

このようにすると、testの条件を満たすときは、divタグがまるごと表示され、条件を満たさないときはdivタグごと消えてくれます。これを、

<m:if m:id="IF_MEMBER" test="${条件}" />

のように書いてしまうと、test条件を満たすときのhtml出力が

....
こんにちは....さん。
...

のようになってしまい、divタグが消えてしまいます。そうすると、デザイナーさんがもし、divタグにstyle属性を付けている場合、それらが適用されなくなってしまい、デザインと本番との整合性が取れなくなってしまいます。

しかし、どうせいつも同じように書くなら、

<my:if m:id="IF_MEMBER" test="${条件}" />

のように書けたほうが楽です。Mayaaは独自のプロセッサーを定義することができるので、これも実現できます。これについては、いずれ紹介しようと思います。

エスケープを解除するときは全部解除する

writeプロセッサーなどで出力した文字は自動でエスケープされます、デフォルトでXSS対策上望ましい挙動をしてくれるのは安心です。もし、HTMLタグを出力したい場合など個別にエスケープを解除したい場合は、

escapeXml="false"

という属性をつければOKです。

http://mayaa.seasar.org/documentation/processor_reference.html#write

ドキュメントにもあるとおり、エスケープ系の属性は、他にもescapeEol、escapeWhiteSpaceがあります。それぞれ、改行をBRに、空白を&nbspに変換するものです。これについて、僕は初めのうち、統一性なくごちゃ混ぜ(ある程度は利便性を意識したが)に使ってしまった結果、ユーザに混乱を与えてしまいました。

それ以来、エスケープを解除するときは、

escapeXml="false" escapeEol="false" escapeWhiteSpace="false"

というふうに全部falseにすることに統一しました。また属性の書き方も順番も統一し、何かあったときにgrepしやすいようにしました。ならば、これについても、

<my:write value="データ" escape="false" />

のようにまとめて書けると尚更シンプルです。これも自分たちは独自プロセッサーで実現しています。

テーブルレイアウトは良くない。divレイアウトが良いわけ

Mayaaを使ったテンプレートで画面を実装するとき、出来ればいわゆるtableレイアウトは望ましくありません。その具体例を紹介します。例えば、「table2行を1かたまりとして繰り返したい」というニーズがあった場合、

<table>
    <span m:id="LOOP_DATA">
        <tr>...1行目...</tr>
        <tr>...2行目...</tr>
    </span>
</table>

と書くのが良いでしょう。でもこれはhtmlの文法違反です。確か、htmlには文法上、子要素にできるタグの種類に制限がありますが、tableタグ関連はその制限が厳しめです。本番実行時はspanタグごと消してしまうようにmayaaファイルを書けば良いですが、そうすると、本番とローカルとでデザインのズレが出てしまいます。

一方divタグならばいくら入れ子にしても正常に動きます。ですので、可能ならばtableレイアウトは避けてdivレイアウトを採用しましょう。

<div>
    <div m:id="LOOP_DATA">
        <div>...1行目...</div>
        <div>...2行目...</div>
    </div>
</div>

iPhoneなどのスマートフォン対応に必要なこと

ここにまとめてました。

http://d.hatena.ne.jp/s-ishigami/20110331/p2

多言語対応

ひとつのサイトを複数の言語で運用する場合、言語ごとにテンプレートを分けるのが無難でしょう。公式サイトでは以下の手法が紹介されています。

http://mayaa.seasar.org/documentation/template_suffix.html

私たちは別の所でtemplateSuffixを使ってしまいましたので、SourceDescriptorによる振り分けで対応しましたが、本質的には同じことだと思います。

ここでも、前回書いた「プログラムは文言を吐き出さない」「テンプレートに文言を委譲する」方針が生きてきます。プログラムで文言を言語切り替えする場合、一般的にはリソースバンドルを使うことになるでしょう。しかし、経験者はわかると思いますが、リソースバンドル+プロパティファイルでの多言語化は、結構しんどいです。1ファイルで何千行もの文言を管理しなければなりません。。。(私の所属する会社ではコードジェネレータでそのめんどくささを回避していますが、それは別の話ですね)。なので、なるべくテンプレート側に文言を持ちましょう。

その他

書き忘れたことがあったら、さらに追って書いていきます。

情報源

日本発のオープンソースらしく、日本語の情報、コミュニティが充実しています。まずは

  • 本家サイトの説明を熟読する
  • MLを購読する。過去ログを読みあさる

これだけでかなり深い情報が得られます。

また、

  • SeasarConferenceなどで発表があったら聴く

というのもおすすめします。

2010年のSeasar Conferernce×JCMT2010では、ヌーラボさんの「Cacoo」発表の中にMayaaの話題がありました。
http://www.slideshare.net/nulab/seasarwebcacoo
実運用事例なので大変参考になりました。

2006年のSC2006Autumnでは、現プロジェクトリーダーの須賀さんの発表があったようです。 http://mayaa.seasar.org/documentation/slide/SC2006Autumn_B1_Mayaa.pdf こちらも、参考にさせていただいております。

<

p>日本には、Mayaaユーザが結構いるようですね。派手な存在ではないですが、話をすると使ったことある方がポロポロ出てくる印象です。

*1:と、同時にプレッシャを感じます(笑)