はてなでコードを書くときに気をつけていること ― 2011年12月18日 14時37分
こんにちは、はてなでアプリケーションエンジニアをしている nanto_vi です。この記事は Hatena::Staff Advent Calendar 2011 の一環として書いています。Advent Calendar の会場ははてなブログとなっていますが、主催の antipop さんに尋ねたところ何でもよいというような返答があったのでここで書きます。
はじめに謝っておきますが、この記事ははてなとほとんど関係がありません。タイトルに「はてな」と入っているのは Advent Calendar の要件を満たすための目くらましあり、内容はといえばはてなのノウハウでもなんでもない、私が最近個人的に心がけていることです。ごめんなさい。
できれば明示的に
業務で書くコードは複数人により読み書きされメンテナンスされるので、わかりやすさが重要になってきます。わかりやすさを保つためには暗黙的に行われる操作を減らし、明示的にコードに表した方がよいと感じます。たとえば、Perl では、
use Encode;
# encode_utf8 関数は Encode モジュールで定義されている
my $octets = encode_utf8 $string;
のようにモジュール読み込み時に自動的に関数をインポートすることができますが、それよりも、
use Encode qw/encode_utf8/;
my $octets = encode_utf8 $string;
のようにインポートする関数を明示的に指定したほうが、その関数がどこで定義されているのかわかりやすいでしょう。
個人的には、関数や変数の名前で grep をかけてその定義(外部で定義されている場合は宣言)を探し出せることが、「明示的」であるための最低線ではないかと思います。
入力には正規化を、出力には符号化を
アプリケーションを作るうえで入出力の扱いは避けて通れません。入力元はユーザーだったりバッチ処理スクリプトの引数だったり、出力先はユーザーだったりデータベースサーバーだったりしますが、いずれにおいても入力には正規化処理を、出力には符号化処理を、それぞれ施すことになります。
正規化は一般に不可逆です。空白文字類をすべてスペースに置換した後はもともとスペース以外の空白文字が含まれていたのかわかりませんし、数値「42」を生み出した入力は "42" でも "42.0" でも、はたまた "0x2A" でもありえます。
符号化は一般に可逆です。あるデータをユーザーのもとへ送り届けるのに、そのデータを HTML のテキストとして扱えるよう一部文字を文字参照の形に符号化し、その HTML ソース全体を文字符号化方式 UTF-8 をもって符号化し、さらには HTTP メッセージに、TCP のセッションに、IP のパケットにと符号化を重ねていきますが、それらすべては符号化規則を逆に適用することで元の形式へ復元できます。
たとえば HTML として解析され、その結果が JavaScript の文字列として、HTML として、URI としてと順に解析されるデータを Template-Toolkit で出力する場合、解析の逆順でそれぞの形式に応じた符号化を施す必要があります。
<script type="text/javascript">
document.write('<a href="/search?q=[% word | uri | html | js %]">検索<\/a>');
</script>
script 要素の内容は CDATA 型であり文字参照は解決されないので、最後に html フィルタをかけることはしません。また、そもそもこのような多重にフィルタをかける事態は避けたほうが賢明でしょう。
入出力いずれにおいても、データがどういう形式で扱われるのかを意識し、それに応じた正規化処理または符号化処理を施すことで、意図しない入力が致命的な挙動を引き起こすこともある程度は防げるのではないかと考えます。
IE 6/7 で文書間通信を実現するための一案 ― 2011年12月08日 23時32分
HTML5-WEST.jp 飲み会 UST というのがあるそうで、「参戦希望者募集」とお誘いを受けたのですが、「参戦」というからには何かしら戦の準備を整えねばなるまいと、以前から考えていたことを夜なべして実装しました。
いわゆる HTML5 の文書間通信、window.postMessage() を IE 6/7 でどう再現するかという話で、about:blank を指す隠しフレームを二つ用い、window.name を介することで双方向の通信を実現しています。
どうも IE での about:blank は、その空白ページを読み込ませた文書の生成元を継承するらしく、たとえば http://example.org/ から location.href = 'about:blank'; を実行すれば http://example.org を生成元とする about:blank になります (少なくともそのように見えます)。これを利用し、一つのフレームに二つの文書から交互に about:blank を読み込ませあうことで、そのフレームの window.name を両文書で共有できるという仕組みです。
about:blank を読み込ませるのに location.reload() を使えば履歴に余分な項目を残すこともありませんし、フラグメント識別子を使った場合と違ってデータ量にもだいぶ余裕が出ます。
Template::Plugin::JSON::Escape 公開 ― 2011年06月15日 20時57分
Template::Plugin::JSON::Escape という Perl モジュールを公開しました。
Template Toolkit を使って HTML を出力するとき、HTML 中に JSON を埋め込みたいことがあります。そのためのモジュールとして Template::Plugin::JSON が存在するのですが、これは小なり記号 (<) などの文字を素通しするため、JSON の内容によっては不正な HTML が出力されてしまいます。
Template::Plugin::JSON::Escape は一部記号を \uxxxx の形式にエスケープするので、JSON の内容にかかわらず出力する HTML を妥当に保てます (もちろん、JSON 出力部以外が妥当な HTML を出力するのであれば、です。なお、SGML のコメント内に JSON を埋め込む場合はこの限りではありません)。副次作用として、XSS の防止にも一定の効果を期待できます。
また、Template::Plugin::JSON 0.06 は Moose を使っていますが、私が普段 Perl を使っている環境には Moose が入っていません。そのためだけに Moose を導入するのは難しいので、Moose を使用しないモジュールが求められていたというのもあります。
関連する話題
Perl の URI モジュールにおける文字列とオクテット列の扱いに関して ― 2011年05月27日 17時28分
Perl の URI モジュールには ASCII 外の文字の扱いに関して欠陥がある。ここでは Perl における URI の扱いについて述べ、URI モジュールの修正方針を提案する。
この記事で取り上げた問題に関して主たる部分は URI.pm 1.59 で (この提案とは違った形で) 修正済みであり、この提案は obsolete です。
用語の定義
- URI
- RFC 3986 (日本語訳) で定義される識別子。
- URI.pm
- Perl の URI モジュール。ここでは、ファイルとしての URI.pm だけでなく、URI パッケージ下に含まれるコード全般を指す。断りがない限りバージョン 1.58 に基づく。
- 文字列
- UTF-8 文字列と Latin-1 文字列のいずれか。
- UTF-8 文字列
- Perl の文字列値で、UTF8 フラグが立っているもの。
- Latin-1 文字列
- Perl の文字列値で、UTF8 フラグが立っておらず、文字の並びとして扱われることを期待されているもの。
- オクテット列
- Perl の文字列値で、UTF8 フラグが立っておらず、バイトの並びとして扱われることを期待されているもの。Latin-1 文字列とオクテット列の機械的な判別は不可能。
発端となった問題
URI.pm のメソッドに UTF-8 文字列とオクテット列を同時に渡した場合、期待した出力が得られないことがある。特に Perl 5.10 以下では、use utf8 環境下で bareword に UTF8 フラグが立つため、この問題が発生しやすい。
#!/usr/bin/perl5.8.8
use strict;
use warnings;
use utf8;
use URI;
use Encode qw/encode_utf8/;
my $uri = URI->new('http://example.org/');
$uri->query_form( foo => encode_utf8('字') );
print $uri->query, "\n";
# actual: "foo=%C3%A5%C2%AD%C2%97"
# expected: "foo=%E5%AD%97"
- Bug #53681 for URI: A bad utf8-related trap on query_form()
- use utf8 環境下で => オペレータの左辺が UTF8 flag on になってしまう - daily dayflower
Perl において URI はどう扱われるべきか
URI は US-ASCII の範囲内の文字で構成されるが、特定の文字符号化方式を仮定しない。URI には任意の文字符号化方式で符号化されたオクテットの並びが (パーセントエンコードされて) 含まれうる。たとえば、二つの URI http://ja.wikipedia.org/wiki/%E3%81%AF%E3%81%A6%E3%81%AA と http://d.hatena.ne.jp/keyword/%A4%CF%A4%C6%A4%CA において、パーセントエンコードされた部分はいずれも「はてな」という文字の並びを表すが、前者は UTF-8、後者は EUC-JP で符号化され、パーセントエンコードされたものである。
それゆえ、Perl において URI はオクテット列として扱われるべきである。そのオクテット列がどの文字符号化方式を用いて解釈されるかは、URI とは別の層の問題である。
URI.pm をどう修正すべきか
現在、URI.pm では入力を文字列として扱うものとオクテット列として扱うものが混在している。たとえば、host メソッドの引数に渡した値は UTF-8 文字列または Latin-1 文字列として解釈され、query メソッドの引数に渡した値はオクテット列であるかのように扱われる。
ここでは、前節の主張に従い、URI.pm に関する入出力はすべてオクテット列として扱うことを提案する。IRI に関するものを除き、URI.pm の各メソッドに渡された引数はすべてオクテット列とみなされ、返り値はオクテット列となるべきである。
引数に UTF-8 文字列が渡された場合は、それを UTF-8 で符号化されたオクテット列として扱うのがよいと考える。HTTP::Message の content メソッドのように UTF-8 文字列を渡されたらエラーとするのもひとつの手ではあるが、現在の URI.pm との互換性を大きく損ねないためにはエラーにしないほうがよいと判断する。
国際化ドメイン名の扱い
URI.pm の入出力をオクテット列とみなすとき、問題となるのは国際化ドメイン名の扱いである。本来なら ASCII 外の文字を含めるのに URI::IRI のような専用のモジュールを使うべきだろうが、現在の URI.pm との互換性を損ねないためには、URI->new(...) や $uri->host(...) で ASCII 外の文字を指定できるのが好ましい。
あくまで入力はオクテット列とみなすという方針を貫きつつ国際化ドメイン名を受け付けるため、URI のホスト部に関してはオクテット列を UTF-8 で符号化されたものとみなし、UTF-8 でデコードした文字列を Punycode 符号化して出力のホスト部とする。
URI.pm の互換性の確保
ここで提案する修正により、URI.pm は後方互換性を失う。特にホスト部に関しては、これまで Latin-1 文字列として扱われてきたものが、UTF-8 で符号化されたオクテット列として扱われ、まったく違った出力を得ることになる。
そこで、過去の挙動に戻せるよう、$URI::COERCE_OCTETS 変数を設ける。この変数のデフォルト値は真であるが、値が偽なら以前と同様に入力をオクテット列とみなさず処理を行う。
修正の段取り
以上の提案を実装すべく、現在 github の nanto/uri レポジトリの coerce_octets ブランチで作業中である。さしあたってどのような出力を期待しているのか明らかにするため、ASCII 外の文字を含む入出力に関するテストを書いた。実装が一段落したら URI.pm に対するパッチとして提出するつもりである。
#43859: should be _utf8_off -ed raw data before URI encoding に修正の URL を添えて返信した。
chrome URL からファイル一覧を取得する ― 2011年01月12日 01時41分
Firefox にて、ディレクトリを指す chrome URL から、そのディレクトリ以下の全ファイルの URL を返すサンプルコードを書きました。
ChromeFiles.get("chrome://browser/content/");
/* => [ "chrome://browser/content/NetworkPanel.xhtml",
* "chrome://browser/content/aboutDialog.css",
* ...,
* "chrome://browser/content/browser.css",
* "chrome://browser/content/browser.js",
* "chrome://browser/content/browser.xul",
* ... ]
*/
特徴として、実際のファイルが (.jar または .xpi に) パッケージ化されているかどうかに関わらず、ファイル一覧を取得できることが挙げられます。ソースコード全体は上記 Gist へのリンクを参照してもらうとして、以下は各関数の解説です。
var ChromeFiles = {
get: function CF_get(spec) {
const ios = Cc['@mozilla.org/network/io-service;1'].
getService(Ci.nsIIOService);
let uri = ios.newURI(spec, null, null);
return this.getByURI(uri);
},
...
};
単に、文字列として受け取った URL を nsIURI のインスタンスにして、getByURI() へ処理を委譲しているだけです。
getByURI: function CF_getByURI(uri) {
// 1. ディレクトリを指す URL にする
let baseURI = uri.clone().QueryInterface(Ci.nsIURL);
baseURI.path = baseURI.directory;
// 2. chrome URL からローカルファイルシステムでの URL へ変換
const registry = Cc['@mozilla.org/chrome/chrome-registry;1'].
getService(Ci.nsIChromeRegistry);
let localURI = registry.convertChromeURL(baseURI);
// 3. ディレクトリ中のファイル名を取得
let leafNames = null;
if (localURI instanceof Ci.nsIFileURL) {
leafNames = this.getLeafNamesByDirectory(localURI.file);
} else if (localURI instanceof Ci.nsIJARURI) {
leafNames = this.getLeafNamesByJARURI(localURI);
} else {
throw new Error('Unknown URI: ' + localURI.spec);
}
// 4. ディレクトリのパスとファイル名を結合
let baseSpec = baseURI.spec;
return leafNames.sort().map(function (leafName) baseSpec + leafName);
},
-
chrome URL において、
chrome://{package}/content/とchrome://{package}/content/{package}.xulは同じリソースを表し、nsIIOService#newURI()の引数に前者を渡しても返ってくる URI オブジェクトのspecは後者になります。確実にディレクトリを指す URI オブジェクトを得るためには、自分で URI オブジェクトのプロパティを変更しなければなりません。ところが、
newURI()で作られたオブジェクトは可変でない (immutable な) ことがあり、このときプロパティに値を設定しようとすると例外が発生します。chrome URL の場合clone()で生成した URI オブジェクトは可変になるので、まずは URI オブジェクトを複製します。パスからディレクトリ部分だけを抜き出すのは、
nsIURLインターフェースのdirectoryプロパティを使うのが簡単です。nsIURLインターフェースを経由すれば、ディレクトリ以外にもファイル名や拡張子などをすぐに取得できます。 -
chrome URL からローカルファイルシステム上でのファイル位置をあらわす URL への変換は、
nsIChromeRegistry#convertChromeURL()で一発です。これにより得られる URL は大抵の場合 file URL か jar URL かのいずれかです。 -
ディレクトリ中のファイル名一覧を配列として取得します。処理本体は file URL の場合と jar URL の場合で別になります。
なお、
QueryInterface()を使わなくとも、instanceof演算子でnsIFileURLインターフェースを実装していることを確認できたなら、それ以降はnsIFileURLのfileプロパティからnsIFileオブジェクトを取得できます。 -
得られたファイル名一覧の順序はわからないので、辞書順で並べ替えます。その後にディレクトリ部分を表す chrome URL と結合すれば、ディレクトリ直下のファイルを指す chrome URL の一覧が得られます。
getLeafNamesByDirectory: function CF_getLeafNamesByDirectory(dir) {
let files = dir.directoryEntries;
let leafNames = [];
while (files.hasMoreElements()) {
let file = files.getNext().QueryInterface(Ci.nsIFile);
if (file.isFile())
leafNames.push(file.leafName);
}
return leafNames;
},
ディレクトリを表す nsIFile オブジェクトから、その子ファイルの名前一覧を取得します。単に子ファイルを列挙していき、それがディレクトリなどでないときにファイル名を取得するだけです。
getLeafNamesByJARURI: function CF_getLeafNamesByJARURI(jarURI) {
// 1. ZipReader を作成
let zip = this.openZipReader(jarURI.JARFile);
try {
// 2. ディレクトリ直下のファイルのパスを取得
let baseEntry = jarURI.JAREntry;
let pattern = baseEntry + '?*~' + baseEntry + '?*/*';
let entries = zip.findEntries(pattern);
// 3. ファイル名部分だけを抜き出し、返す
let leafNames = [];
while (entries.hasMore())
leafNames.push(entries.getNext().substring(baseEntry.length));
return leafNames;
} finally {
zip.close();
}
},
jar URI は jar:file://path/to/file.jar!/path/to/entry/ のような形で表されます。nsIJARURI インターフェースは、file://path/to/file.jar の部分を示す JARFile プロパティ (返ってくるのは nsIURI オブジェクト) と、/path/to/entry/ の部分を指す JAREntry プロパティ (返ってくるのは文字列) を持っています。
-
JAR ファイル (または XPI ファイル) は ZIP 書庫なので、内部のファイル情報を読み取るためには
nsIZipReaderのインスタンスを作成し書庫を開く必要があります。 -
書庫内部のファイル名一覧を取得するには
findEntries()を使います。ここで指定するファイル名のパターンにおいて、"?"は任意の1文字を、"*"は任意の文字列を、"pattern1~pattern2"はpattern1にマッチするがpattern2にはマッチしないものを表します。"/path/to/entry/*"というパターンでは/path/to/entry/自信も含まれてしまうので、ディレクトリではないファイルだけを抽出するために"/path/to/entry/?*"と指定します。また、それだけだと子孫ディレクトリ中のファイルも含まれるので、"/path/to/entry/?*/*"を除外してやります。 -
findEntries()で得られた値にはディレクトリ部分も含まれるので、その部分は切り取ってファイル名だけにします。
openZipReader: function CF_openZipReader(uri) {
let zip = Cc['@mozilla.org/libjar/zip-reader;1'].
createInstance(Ci.nsIZipReader);
if (uri instanceof Ci.nsIFileURL) {
// 1. file URL なら単にそのファイルを開く
zip.open(uri.file);
} else if (uri instanceof Ci.nsIJARURI) {
// 2. jar URL なら JAR ファイル内部のファイルを開く
let innerZip = this.openZipReader(uri.JARFile);
zip.openInner(innerZip, uri.JAREntry);
} else {
throw new Error('Unknown URI: ' + uri.spec);
}
return zip;
},
-
JAR ファイルがローカルファイルシステム上に直接存在するなら、単に
open()メソッドにファイルオブジェクトを渡して開くだけです。 -
開こうとする JAR ファイルが別の書庫内に存在することもあります。その場合は
openInner()に、JAR ファイルが含まれる書庫と、その書庫内での JAR ファイルのパスを指定してやります。nsIZipReader#openInner()は Firfox 4 で追加されたものですが、Firefox 4 より前では jar URL がネストすることはないといっていいので、ここで使っても問題ないでしょう。
たとえば Firefox 4 Beta でツリー型タブを使うと、chrome://treestyletab/content/ の実体は jar:jar:file://{profile}/extensions/treestyletab@piro.sakura.ne.jp.xpi!/chrome/treestyletab.jar!/content/treestyletab/ といった URL になりますが、上記のようにすればその内部のファイル構成を知ることができます。
また、resource://gre/modules/XPCOMUtils.jsm といった resource URL に関しても、nsIResProtocolHandler#resolveURI() を使えばローカルファイルシステム上の URL へ変換でき、上と同様にファイル一覧の取得などが可能になります。
最近のコメント