こんにちは。
今日はPlayframeworkでガラケーサイトを作成した話をしてみたいと思います。
ちなみにガラケー対応をやったのは人生初です。(^^;;;
そんなに凝ったページではなかったんですが、ガラケー界のしきたり(?)とPlayの思想がそぐわない部分があってそこで多少面倒があったのでその部分の情報共有です。(読み直したら愚痴みたいになってますけど。。。(--)
★charsetはShift_JIS
知っている人からすれば当たり前の話なんでしょうがガラケー界では今もWebページはShift_JISで作成するのが常識のようです。
実際試してみると手元で試した端末はすべてUTF-8のページも表示できましたが、古い端末は化けると聞けばそこで冒険するのはまだ危険かと思います。
ちなみに世の慣習にならって「Shift_JIS」と言っていますが実際にはガラケーの文字コードはMS932+絵文字です。(多分)
絵文字はともかく丸数字のようなMS932固有の文字は対応しておかないとやっぱり文字化けしてると言われるんでしょう。。。
★PlayでShift_JISページを返す
Playframeworkは基本文字コードはUTF-8で統一というスタンスで作られています。
なので何も考えずに作るとすべてのレスポンスはUTF-8で返されます。
では別の文字コードでページを返すためにはどうすれば良いかというとこれはrenderメソッドでページを返す前にresponseにencodingをセットするだけです。
/** Webフォームを含むページを返す */ public static void form() { response.encoding = "Shift_JIS"; render(); }
ちなみにこの場合でもテンプレート自体はUTF-8で記述します。
一般的なブラウザは次のリクエストの送信をレスポンスページと同じ文字コードで実行するので理屈の上では対応はこれだけで良いはずです。
が、実際にはこのフォームから受けたパラメータの日本語は全部化けてしまいます。
★Httpリクエストではcharsetがつかないのが普通
ガラケーに限った話ではありませんが、ほとんどのブラウザはリクエスト送信時にContent-Typeのcharset属性を付加しません。
Playの実装ではContent-Typeのcharsetがある場合はそれを用いてリクエストをパースしますが、ない場合はUTF-8でパースします。
要するにShift_JISの文字をUTF-8としてパースしようとするから化けてるんです。
★どこでリクエストのエンコーディングを指定するのか?
さて、となるとどうにかしてリクエストのエンコーディングを明示しなければならないのですがAPI自体は単純です。
Requestオブジェクトのencodingに値を設定すれば良いだけなので。
ただし、いつ設定するかという問題はまだ残っています。当然ながらリクエストのパースが終わった後にエンコーディングを変更しても意味がないので、例えば以下のコードはアウトです。
/** リクエストから生成した結果ページを返す */ public static void result(String name) { requet.encoding = "Shift_JIS"; response.encoding = "Shift_JIS"; render(name); }
何故ならこのresultメソッドを呼び出すためにはリクエストをパースしてパラメータからnameの値を取り出さなければならないからです。
ちなみにPOSTの場合に限り以下のコードはOKです。
/** リクエストから生成した結果ページを返す */ public static void result() { requet.encoding = "Shift_JIS"; String name = params.get("name"); response.encoding = "Shift_JIS"; render(name); }
何故ならこの場合resultメソッドに引数がないため、リクエスト(のボディ)をメソッド実行前にパースする必要がないからです。この場合リクエストボディのパースはParams#getメソッドの実行まで遅延されます。
ただしGETのクエリ文字列のパースは常にコントローラのメソッド呼び出しの前に行われます。そのためこのアプローチではどうやってもGETパラメータは化けるので、フォームからデータを送信する際には常にPOSTで送信する必要があります。(PlayPluginを作れば対応できそうですけど未検証です。)
★@Beforeを使う
先の2つ目の方法で一応正しいエンコーディングでリクエストをパースすることはできるようになったわけですが、コントローラのメソッドで引数を使うことができないというのは結構いたい制約です。また、すべてのメソッドでrequestとresponseのエンコーディングを設定しなければならないのも面倒ですしバグのもとです。
なのでこれらの設定は@Beforeインターセプタに移動してしまう方がお勧めです。
/** エンコーディングをShift_JISに設定 */ @Before public static void convertEncoding() { requet.encoding = "Shift_JIS"; response.encoding = "Shift_JIS"; }
@Beforeはリクエストのパース前に実行されるのでこれでPOSTには対応できます。
(GETのクエリ文字列にはやはり対応できません。)
ここでは強制的にすべてのメソッドのエンコーディングをShift_JISに変更しているのでPC用のサイトなどエンコーディングをUTF-8で扱いたいページが混在する場合はコントローラ自体を分けてガラケー専用にします。
★「あめりか」は化けないけど「アメリカ」は化ける
さて、ここまでの設定で一見文字化けは解消されたかに見えます。が実はまだ文字化けは発生します。
それも「あめりか」は化けないけど「アメリカ」だと「ア」と「カ」だけが化けるという理不尽な化け方をします。
これは。。。実はPlayの問題というよりはJavaのURLDecoderの問題です。
昔から知ってましたが、もうとっくに直ったかと思ってました。。。(--
★「あめりか」と「アメリカ」はどこが違う?
前者はひらがなで後者はカタカナですね。(^^;
ちなみにShift_JISでのコードポイントは以下になります。
あめりか = 82 A0 - 82 DF - 82 E8 - 82 A9 アメリカ = 83 41 - 83 81 - 83 8A - 83 4A
ここまで書くともう気がついた人もいるかと思いますが、「ア」と「カ」はShift_JISの2バイト目がASC-IIの範囲と重なっています。
★JavaのURLDecoderとブラウザのURLエンコード
URLエンコードの仕様についてはとりあえずウィキペディアでも参照してください。
これを読むとShift_JISの2バイト目にASC-IIが表れた場合の扱いはエンコードしてもしなくても良さそうなので以下の二つは両方とも正しいURLエンコードです。
★全部エンコード %83%41%83%81%83%8A%83%8A%83%4A ★Shift_JISの2バイト目がASC-II範囲の場合はエンコードしない(0x41=A, 0x4A=J) %83A%83%81%83%8A%83%8A%83J
問題はブラウザのURLエンコードが後者の形式であるのに対し、Javaの標準のURLDecoderがその形式に対応していないことにあります。
ちなみにJavaのURLEncoderは前者の形式の文字列を返し、それはJavaのURLDecoderで正しくデコードできます。このことから、これはバグではなく仕様だという主張もあるようですが。。。個人的にはそれには納得できません。
その理由としては、まず第1に実際に後者のエンコードを行う処理系が世の中(少なくとも日本)には多数存在するということをあげますが、それ以上に思うのはこれを修正したところで何の不都合もないということです。
原理的にこの修正を行っても何らかの非互換の発生する文字コード体系は世界中のどこにも存在しないと思いますが、そんなことないんですかね???
実装を考えても素直に実装すれば対応する方が楽なはずで、今のJavaの実装の方が効率が悪いと思うので対応しない理由がわかりません。
★URLエンコードの形式を変換する
なんか激しく話が脱線しましたが、とにかくこれはどうにかしなければなりません。
対応方法は色々あると思いますが今回は時間も限られていたので安易にパース前のボディを一度自力でデコードしてJavaのURLEncoderでエンコードしなおすという方法を取りました。後者の形式に対応したURLDecoderとしてはcommons-codecのURLCodecがPlayのlibに最初から入っています。
@Before static void convertEncoding() { request.encoding = "Shift_JIS"; response.encoding = "Shift_JIS"; request.body = convertStream(request.body); } private static InputStream convertStream(InputStream is) { final String encoding = "Shift_JIS"; byte[] data = null; try { byte[] byteBuf = new byte[1024]; ByteArrayOutputStream bos = new ByteArrayOutputStream(); int n = is.read(byteBuf, 0, byteBuf.length); while (n > 0) { bos.write(byteBuf, 0, n); n = is.read(byteBuf, 0, byteBuf.length); } data = bos.toByteArray(); if (data.length > 0) { URLCodec codec = new URLCodec(); String str = new String(data, "us-ascii"); StringBuilder buf = new StringBuilder(); String[] values = str.split("&"); for (int i=0; i<values.length; i++) { String value = values[i]; int idx = value.indexOf("="); if (idx == -1) { value = codec.decode(value, encoding); buf.append(URLEncoder.encode(value, encoding)); } else { String key = value.substring(0, idx); value = value.substring(idx+1); key = codec.decode(key, encoding); value = codec.decode(value, encoding); buf.append(URLEncoder.encode(key, encoding)) .append("=") .append(URLEncoder.encode(value, encoding)); } if (i<values.length - 1) { buf.append("&"); } } data = buf.toString().getBytes("us-ascii"); } } catch (DecoderException e) { data = new byte[0]; } catch (IOException e) { data = new byte[0]; } return new ByteArrayInputStream(data); }
めっちゃ無駄ですけどね。(--
ちなみにPlay本体を修正するのであればUrlEncodedParserでURLDecoderを使用しているところをURLCodecに替えるだけで直るはずです。
★Shift_JISはMS932として扱う
最後にMS932対応について。
JavaではShift_JISとMS932は厳密に区別されているので今のままでは丸数字などのShift_JISにはなくてMS932にだけある文字は化けます。
かといってcharsetとして「MS932」とか「Windows-31J」とか書いて返してもガラケーが正しく解釈してくれるかどうかはまったくもって怪しいのでコードで対応するのはかなり苦しいです。
なのでこれは起動時の引数でcharsetのエイリアスを差し替えてしまうのが一番楽だと思います。
play run -Dsun.nio.cs.map=Windows-31J/Shift_JIS
こうすると「Shift_JIS」というcharset名が表れた場合にJavaの方でそれをMS932として扱ってくれます。
当然厳密な意味での「Shift_JIS」という文字コードは正しく扱えなくなるわけですが多分それで困ることはありません。(^^;;;
思った以上に長くなって最後若干投げやりでしたがちょっとは役に立つエントリになったでしょうか?(^^;
心の底からUTF-8だけでコンテンツ作れる世の中に早くなってほしいです。