ラベル web の投稿を表示しています。 すべての投稿を表示
ラベル web の投稿を表示しています。 すべての投稿を表示

2013/05/05

RaspberryPiでLVS+keepalivedを使ってHA構成にしてみた

RaspberryPiは手軽なLinux実行環境ですが、定格で5V/700mAが必要です。 古いPCやUSBハブに接続した場合には、うまく動かない可能性があります。

iPadやタブレットで充電に1000mA(==1A)が必要な状況なので、それほどUSBの電源周りも余裕のある設計になっているものも多くあるはずです。

とはいえ、USB2の定格は5V/500mAなので古めのPCや電源付きUSBハブにいろいろな機器を接続した場合には動作が不安定になる可能性があります。

またカーネルに3.6-trunk-rpiを選択した時には、2013年5月5日時点では、ファイルシステムに負荷をかけるようなタイミングでBUG: scheduling while atomic: swapper/...のエラーが出てまともに動きませんでした。

対応linuxカーネルの有効化

基本的な環境はarmelhfアーキテクチャのRaspbian "wheezy" をインストールした状態です。

Raspbianのカーネルは3.6.11#371版ですが、ip_vsモジュールなどは導入されていません。

このためパッケージからip_vsモジュールの含まれているイメージを導入します。

$ sudo apt-get install linux-image-3.2.0-4-rpi
起動時のカーネルイメージの変更

linux-image-*パッケージを導入しただけでは、再起動をしても引き続きRaspbianの付属カーネルが使われます。 ポイントは次のとおりです。

  • zImageを/boot/kernel.imgのファイル名でコピーする
  • initrdイメージがある場合は/boot/config.txtに記入する
/boot/kernel.imgファイルの置き換え

既存のkernel.imgファイルは退避して、vmlinuz-3.6-trunk-rpiファイルをコピーします。

$ cd /boot
$ sudo mv kernel.img kernel.3.6.11#371.img
$ sudo cp vmlinuz-3.2.0-4-rpi kernel.img

ちなみに/bootはvfatなので、ハードリンクなどを使う事はできません。 基本的にはcopyで対応することになります。

/boot/config.txtファイルの書き換え

initrdを使用している場合には、対象のファイル名をconfig.txtに書き換えます。

...
ramfsfile=initrd.img-3.2.0-4-rpi
ramfsaddr=0x00a00000
initramfs initrd.img-3.2.0-4-rpi 0x00a00000
...

ちゃんと確認していませんが、/bootにあるものとしてファイル名だけを記述しています。

再起動でカーネルの確認

とりあえず再起動して動作を確認します。

うまく行かない場合には別のPCに接続して編集内容を確認します。 /boot以下はVFAT領域なので、問題なく扱えるはずです。

ramfsaddrなどを指定しないと、initrdのイメージが展開されない事になります。 うまくいけばdmesgに次のようなメッセージが表示されます。

$ dmesg | grep init
...
[    1.049760] Unpacking initramfs...
[    1.710015] Freeing initrd memory: 4476K
...

パッケージの導入

ipvsadmとkeepalivedを使いますが、ipvsadmはkeepalivedの導入時に自動的にインストールされます。

keepalivedの導入

パッケージを導入します。

$ sudo apt-get install keepalived

設定

パッケージを導入しても設定ファイルは一切導入されないので、過去のログをみながら設定ファイルを構成します。

/etc/keepalived/keepalived.confの編集

今回はWebサーバではなくて、ESXi上に構成しているLDAPサーバに対してHA構成を取っています。

振り先の設定

LDAPサーバ側にいくつか設定が必要です。

/etc/sysctl.confの編集

対象サーバが1台しかない場合には、問題になりませんが、複数のslave LDAPサーバに処理を分散させるような場合にはサービス(仮想)IPとMACアドレスが強力に結びついてしまうと、ルータからダウンしたLDAPサーバに常に処理が振られてしまうのでARPリクエストに反応しないように変更する必要があります。

/etc/sysctl.confへの追加内容

net.ipv4.conf.eth0.arp_ignore=1
net.ipv4.conf.eth0.arp_announce=2

設定を反映させます。

$ sudo sysctl -p

sysctlコマンド実行後の画面出力

net.ipv4.conf.eth0.arp_ignore = 1
net.ipv4.conf.eth0.arp_announce = 2
サービス(仮想)IPの設定

IPレベルではリクエストは2台のRaspberryPiで共有されるサービス用の(仮想)IP宛てのパケットがLDAPサーバに到着します。

自分宛てのパケットだと認識しないといけないので、loopback(lo)にIPを設定します。 まだ未使用のlo:0デバイスを使います。他に使用している場合には、lo:1,lo:2など適当な番号に変更してください。

/etc/network/interfacesファイルへの追加内容

auto lo:0
iface lo:0 inet static
  address 192.168.1.182
  netmask 255.255.255.255

さいごに

RaspberryPiを入手して、いろいろ試していますが、電源の品質とカーネルの安定性が一番の問題でした。

電源はUSBハブから取っていますが、マウス、キーボードなどは本体に直接つないで他の機器はつないでいません。

PCなどに接続する場合には、2つ口の片側に接続する事で500mA 2つ分の1000mAまで電流が取れるものが多いようです。 最近ではiPadやタブレットに対応した急速充電可能なタイプであれば、1000mAまで取れるので問題ないでしょう。

SDカードはTranscendのClass10 UHS-I対応16GBと、PanasonicのClass 10 SDHC 8GBのカードを使っています。 他のARM系ボードでも使っていますが、これまでのところ問題は起きていません。

この他にも$ sudo raspi-configで変更できるパラメータは少し変更していて、安定して動くようになってから900MHzへのオーバークロックやVRAMを16Mに変更したり、

tail -5 /boot/config.txtの実行結果

# for more options see http://elinux.org/RPi_config.txt
gpu_mem=16
core_freq=250
sdram_freq=450
over_voltage=2

この記事で取り上げた品々

2012/12/09

RacktablesでLDAP認証をかける時の注意点

Racktablesは簡単に導入ができて、ビジュアル的にもそこそこ見栄えが良く、ネットワーク屋さんがexcelやvisioを使って書くであろう絵の基本的な部分はカバーできるツールではないでしょうか。

PHPベースなので、あいかわらず拡張性が良いとは思えませんが、とりあえず動いているので使ってみる事にしました。

今回は不特定多数で使用する前提で、Ubuntu上で稼働するopenldapを使ったLDAP認証と連携させる事にしました。

racktablesのadminユーザーで、承認するユーザー名を個別に設定する場合には難しい点はないのですが、グループで認証しようとする場合には、Active Directory(AD)が想定されているので、Openldap用に少しだけ変更してあげる必要があります。

基本的なopenldapの設定

今回は手間をかけないために、ldap-account-manager (LAM)を併用しています。

LAMをUbuntuで使う場合は、openldapがデフォルトでドメイン名をみて基本的なディレクトリを作成しています。 sudo slapcatなどで確認して、変更したい場合には、$ sudo dpkg-reconfigure slapdなどで自分の管理するドメイン(dc=example,dc=com)を設定します。

racktablesの導入

ポイントはだいたい次のようなところでしょうか。

  • mysqlをあらかじめ導入しておく
  • wwwrootディレクトリをScriptAliasに設定するなどし、Webサーバーの実行ユーザーには読取権限だけを与えておく
  • inc/secret.phpファイルをWebサーバーの実行ユーザーに書き換えられるよう権限を変更する ($ sudo chown www-data:www-data wwwroot)
  • http://localhost/racktables/など配置した場所に応じたパスの/install.phpにアクセスし、インストール作業を終える

PHPが参照するファイルはWebサーバーのアクセス制限には縛られないので、secert.phpはシンボリックリンクなどにして、Webサーバーの設定としてはシンボリックリンク参照を無効にしておくのがお勧めです。

racktablesにLDAP認証を設定する

inc/secret.phpファイルに既に入っている雛型だけで、LDAP認証は十分です。 domain行は使わないのでコメントのまま無効にしておきます。

だいたい次のようなコードになっています。

secret.phpファイルの内容抜粋


$user_auth_src = 'ldap';
$require_local_account = FALSE;
$LDAP_options = array
(
        'server' => 'localhost',
#       'domain' => 'example.com',
        'search_attr' => 'uid',
        'search_dn' => 'ou=People,dc=x200,dc=example,dc=com',
# // The following credentials will be used when searching for the user's DN:
#       'search_bind_rdn' => NULL,
#       'search_bind_password' => NULL,
        'displayname_attrs' => 'sn',
        'group_attr' => 'gidnumber',
        'group_filter' => '/^([0-9]+)$/',
        'options' => array (LDAP_OPT_PROTOCOL_VERSION => 3),
#       'use_tls' => 2,         // 0 == don't attempt, 1 == attempt, 2 == require
);

group_attrは基本がAD認証なので、posixAccountで定義されているgidNumberをキーにすると、group_attrに対応するgroup_filterも書き直す必要があります。

PHPのldapsearchのマニュアルをみると、ldapsearchは大文字、小文字を意識しないけれど、戻り値として返される配列のキーは小文字になる記述があります。

そのためgroup_attrに指定する文字列は小文字でなければいけません。

また、group_filterの設定は数字が期待されているので、ここでは全ての数字を許可していますが、ドメインのポリシーに従って、10000番台だけを許可するような設定がここで可能です。

racktablesでのauthorization設定

認証されたユーザーがracktablesにアクセスできるかどうかは、Configurationのpermissions設定で行ないます。

Configuration -> Permissions設定


allow {$userid_1}
allow {lgcn_10000}

デバッグの方法について

secret.phpを編集してもうまく動かない場合、内部動作を確認する方法がないので、printfデバッグの手法を使います。 auth.phpファイルを編集する場合には、認証失敗画面に出力されるよう、$ret['memberof'][]に代入するのがお勧めです。

さいごに

ネットワーク管理用のアプリはいろんな形態が考えられますが、物理的なラックに着目した点で、ユニークなアプリなのかなと思っています。

認証済みユーザーに閲覧権限だけ付与するのに、default tabだけへのアクセスを許可するようなアプローチもできそうですが、それでは閲覧できない情報がいろいろあるので良い方法がないか探しています。

この手のアプリケーションをいろいろみていて、拡張性なんかが気になってしまいます。 mediawikiのWeb API的なアプローチが内部構造として採れないか、ちょっといろいろ考えています。

2012/07/29

Linux環境でのbin/neo4j installコマンドの挙動

グラフDBの一つであるNeo4Jをいろいろ試す中で、 サーバースクリプトの引数にinstallオプションがある事に気がつきました。

いろいろ勝手にやってくれるのは便利なのですが、 それはそれで動きが分からないと不便なので、調べたことをメモしておきます。

環境はUbuntu 12.04 LTS 64bit版、JVMはOracle版のJava 1.6.0_33です。

マニュアルの説明

セクション18.1 Server InstallationではWindows, Linux, MacOSX毎の説明が書かれています。 ここではLinux用の記述について説明しています。

まず、導入から起動までの基本的なステップとして次のように書かれています。

  • $ bin/neo4j install
  • $ service neo4j-service status
  • $ service neo4j-service start

そして、導入ステップの中で次のような情報を入力や作業が行なわれるとされています。

  • ユーザー名 (デフォルトは"neo4j") の入力
  • ユーザーが存在しない場合の作成をスクリプトが行なう
  • dataディレクトリ以下のファイル所有者をユーザー名に変更

さらにセクション 22.9 にあるLinux用のパフォーマンスチューニングを実施するよう勧めています。

スクリプトの挙動

サービスとして起動させるために、何をするのかの説明はないので、スクリプトを普通に眺めていきます。

bin/neo4jスクリプトの中では、installservice()シェル関数の中で処理が行なわれています。

1. OS、JVMの判別

bin/utilsで定義されているdetectos, findjavaシェル関数でそれぞれOSとJVMの判別が行なわれています。

detectosの中では$ uname -sコマンドの出力をベースにDIST_OS変数に"linux"(solaris,cygwinなどの場合には正規化した値)が入るようになっています。

findjava関数はいろいろ泥くさい処理をしていて、例えばgentoo linuxの場合には$ java-config --jre-homeの出力をJAVA_HOME変数に設定するなどの処理をしています。 最終的にはJAVACMD変数にjavaコマンドのパスを格納して、それを後続の処理で使うようになっています。

2. /etc/init.d/neo4j-serviceファイルの配置

/etc/init.dディレクトリがある場合には、/etc/init.d/neo4j-serviceファイルはbin/neo4jへのシンボリックリンクとして作成されます。

その後で、$ update-rc.d neo4j-service defaults$ chown -R neo4j: data confのコマンドが実行されています。ユーザー名neo4jは適宜入力された名前に変更されて、ドキュメントにある通りの設定がされています。

グループ名の指定がないのがポイントと、いくつかドキュメントにない暗黙の設定があります。

インストール処理の自動化について

falseに設定されているHEADLESS変数は質問があるところで参照されて、もしtrueだと全ての設問にy(yes)を選択した挙動になります。 これを使う場合、外部から変数にtrueを設定する仕組みはないので、スクリプト自体を上書きする必要があります。

またwrapper_user変数は、作成するユーザー名の指定があって、通常は$ id -unから実行時のUIDの値が入りますが、$ sudo env wrapper_user=neo4jsrvr bin/neo4j installのようにするとコマンドラインからデフォルトのユーザーIDが指定できます。

この他にenvコマンドで同様に操作できるパラメータには次の変数がありました。

  • org_neo4j_server_webserver_port - ポート番号 (既存プロセスのステータス確認に使用)
  • wrapper_user - サーバーを実行するユーザーID
  • wrapper_ntservice_name - /etc/init.d/直下に作成するシンボリックリンクのファイル名 (serviceコマンドの引数に指定する名前)

ユーザーIDと/etc/init.d以下に配置されるファイル名の変更であれば、手動でHEADLESS=falseをtrueに変更して、envコマンドの引数でパラメータを指定すれば、対話的な処理なしにインストール作業を進めることができます。

org_neo4j_server_webserver_port は導入時のプロセスチェックにのみ使われて、confファイルは変更されません。 conf/neo4j-server.propertiesファイルを直接変更する必要があります。

上書きされるファイルについて

変更されたユーザー名については、conf/neo4j-wrapper.propertiesファイルが変更されます。 これによって$ sudo /etc/init.d/neo4j-service startのように実行すると、自動的に指定したユーザーIDでneo4jプロセスが起動します。

これ以外のパラメータは、confファイルを変更する必要があります。

さいごに

installオプションを実行すると、ファイルが展開されているディレクトリがそのまま使われます。 マニュアルにもありますが、/opt以下などにtar.gzを展開する必要があるでしょう。

その反面 data, conf ディレクトリは展開されたディレクトリに含まれているので、本番で運用するのであれば、これらデータ本体の配置は考慮する必要があります。

アップグレード時は、初回起動時にデータベース構造の更新処理が走る事になっているので、tar.gzを展開して、conf, dataを入れ替えるか、シンボリックリンクで適当なディレクトリを参照するようにしてあげるぐらいで良いようです。

その他に本番運用にはクラスタリングやオンラインバックアップなど、まだ考慮しなければいけない要素があるので、これから検証していく予定です。

2012/07/11

Google Chrome拡張を{ "manifest_version": 2 }に対応させてみる

気がついたらGoogleから「マニフェストのバージョンが2になったから対応よろしくね」、という旨のメールが届いていた。 まぁ8月中旬から新規の登録受付を中止して、年内には更新の受け付けができなくなるスケジュールが引かれていたので、 使うだけのユーザーなら影響がでるのは来年以降ですね。

今回はmanifest.jsonファイルを変更して気になった点をまとめていきます。

ちなみに変更は動作確認が終り次第、gitoriousにあるコード(Japan Postal Code Search, Open PinnedTab Link, etc...)に反映させます。

参考にしたページ

Googleのガイドはとてもまとまっているので、あまり他のサイトを調べる必要性を感じないのですが、 さすがに今回はStackOverflowなどのサイトにお世話になりました。

とはいえ、まず抑えておかなければいけないのはGoogleのmanifest.jsonの"manifest_version"の説明ページです。

ここで、全体のスケジュールと全体の変更の概要が書かれています。 しかし、ここだけでは具体的にアプリケーションにどんな変更が必要か把握するのは難しかったです。

具体的な変更作業

とりあえず作成しているOpen PinnedTab LinkJapan Postal Code Searchの2つは、比較的簡単なアプリケーションです。

このアプリケーションを修正した際に必要な修正は以下のようになりました。 まだ稼働確認が終っていないので、追加で必要なものもありそうですが、とりあえずまとめます。

HTML中にonclick, onload属性は書けなくなった

scriptタグを使ったjQueryの$(function() {...});表記などは、そのまま使えますが、(x)HTML上のタグにあるonclick="..."やonload="..."の表記を埋め込む事ができません。

基本的なコードはGoogle ChromeガイドのContent Security Policy (CSP)ページに記載されています。

onloadについは書かれていませんが、onclickと同様にdocument.addEventListener('DOMContentLoaded', function () { ... };の...部分にonloadで行なっている関数呼び出しなどをコピーすれば動きます。

ここで問題になったのはJavaScriptのコード中で、明示的に文字列としてonclick属性を追加している場合でした。

変更前のコード:文字列としてonclick属性を追加している例

...
td4_span.setAttribute("onclick","showMapImg('" + center + "')");
...

変更後のコード:addEventListenerに書き換えた例

...
td4_span.addEventListener('click', function(){showMapImg(center);});
...

元々変更後のように組むべきだったとは思います。 とはいえ文字列で解決するのは単純で簡単だったので、使っている方もいそうです。

動的なアクションはすべからくaddEventListener関数で指定する事になると思われます。

background処理をhtmlからjsファイルへ移動

manifestのbackgroundで指定する対象がhtml("page":文字列)とjavascript("scripts":文字列配列)の2つが準備されています。

これまではhtmlで記述していたので、manifest.jsonを書き換えて、そのまま流用すれば良いと思っていたのですが、どういう分けか、うまく動きませんでした。

変更前:version 1のmanifest.jsonから抜粋

...
  "background_page": "background.html",
...

元々background.htmlは本文のない、scriptタグの中に処理が記述されているだけだったので、その中身をjsスクリプトファイルに分割して、manifest.jsonの表記を書き換えました。

変更後:version 2のmanifest.jsonから抜粋

...
  "background": { 
     "scripts": ["scripts/background.js"]
  },
...

jsファイルを指定した場合でも、内部では空のhtmlファイルが生成されて組み込まれているだけのようなので、今回の挙動はおかしいと思います。 おそらくhtmlファイルのまま移行できるように作られているはずなので、早晩このワークアラウンドは不要になるでしょう。

まとめ

いろいろ書こうと思ったのですが、うまく動かない処理を発見したりして別の記事にまとめる事にして、とりあえず今回はここまでで終りです。

今回取り上げなかったのですが、外部サイトからのJavaScriptファイルのロードなどもデフォルトではできなくなっています。 オプションページのためにjQueryのコードをCDNからダウンロードしたいような場合にも対応が必要そうです。

単純なmanifest.jsonの書き換えで対応できるアプリケーションは少ないのではないでしょうか。

とはいえ、変更内容を確認する限り、機能が制限されているものはないので、既存の"manifest_verison":1アプリは全て、ちゃんと書き換えれば動くはずです。

この「ちゃんと」という部分が曲者ですが、今回の経験からは、新ルールに矯正された事によってアプリケーションのコード全体は良くなっていると思います。

Chrome拡張のAPIについてはいろいろ疑問に思うところもあったので、これから始める方々には、面倒は増えるでしょうが、よりよいコードになっているとは思います。

これがどれくらい面倒かは…、なんともいえませんが、少なくとも既存の開発者は、どこに問題があるか調べるだけで面倒そうです。

2012/04/17

お名前.comのVPS(KVM)でのIPv6接続

お名前.comのサーバーを少し使ってみて、パフォーマンスには驚くばかりです。 これが1ヶ月単位の契約でも1300円ちょっとというのは、言葉になりません。

さて自宅の中も外もIPv6環境を整えているので、サーバーとして使うにはなにかしらIPv6リーチャブルでないと困ってしまいます。

今回はTokyo6to4を使って、IPv6環境を設定してみました。

6to4を使う前提

Ipv6に対応する手段として6to4はお手軽で強力な方法ですが、Ipv4の固定アドレスが必要になります。 いわゆるVPSと呼ばれるものは、固定IPv4アドレスを持っているので、問題になることはないでしょう。

自宅のサーバーなんかを6to4に接続する場合には、固定IPv4アドレスの契約をしている事と、ホームルーター(ブロードバンドルーター)を自作するのがお勧めです。

Tokyo6to4設定方法

設定情報はTokyo6to4が「6to4の利用方法」としてまとめています。

何も難しくないというか、固定IPv4アドレスからグローバルアドレスを計算する方法ぐらいしか間違えそうな要素がありません。

手動でIPv6アドレスを計算していたので、この部分については、次のようなコマンドでも自動化できると思います。

 $ env LANG=C ip -4 -o addr | awk '!/127.0./ {sub("...$","",$4);split($4,list,"."); printf "2002:%02x%02x:%02x%02x::1\n",list[1],list[2],list[3],list[4];}'

さすがに、これはない。これなら固定IPv4を素直に人間が分解して、ガイドの通りprintfコマンドに渡した方が簡単ですね。

これでガイドの通りの設定を加えるとinterfacesファイル全体は次のようになります。

$ cat /etc/network/interfaces の出力結果


# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
allow-hotplug eth0
iface eth0 inet dhcp

auto tun6to4
iface tun6to4 inet6 v4tunnel
        address 2002:cbbd:60af::1
        netmask 16
        gateway ::192.88.99.1
        local 203.189.96.175
        endpoint any
        ttl 64
設定の有効化

サービス開始前ならちゃんと再起動して確認した方がいいと思いますが、とりあえず設定を確認するのに再起動を繰り返すのは時間が無駄なので、次のコマンドで確認します。

$ sudo ifup tun6to4

設定を再度試すには$ sudo ifdown tun6to4で一度設定を戻してから試します。

うまくいけば再起動時にも判定されるか、reboot(shutdown -r now)して試しましょう。

各種アプリケーションからのIPv6アドレス指定方法

DNSにAAAAレコードを追加するまではIPアドレスを直接してして試すしかありません。

Tokyo6to4の設定確認方法にも載っているping6コマンドは、直接2002から始まるIPv6アドレスを渡します。

$ ping6 2002:cbbd:60af::1

しかし、firefoxなどのURLでは[]で括って渡してあげます。

http://[2002:cbbd:60af::1]/
$ wget 'http://[2002:cbbd:60af::1]/'

sshクライアントではping6と同じように2002で始まるアドレスを渡します。

$ ssh 2002:cbbd:60af::1

ここら辺がちょっと判りづらいところですよね。

本番運用する時の考慮点

とりあえずIPv4によるバックアップがあるので、IPv6へのサービス提供、サービスアクセスが常に必要でなければ6to4で十分な場合が多いと思います。固有なサービスを作りたいのであれば、避けるべきだと思います。

レイテンシについて6to4は早いとはいえず、IPv6に対応しているServersman@VPSが良いのは間違いありません。 とはいえIPv6に対応しているServersman@VPSのサーバー自身の可用性がいまいち信頼できないので、安定して稼働して欲しいなぁとは思っています。最近は調子が良いですけどね。

6to4サービスのレイテンシ

ping6の結果はだいたい15倍ぐらいの開きがあって、これが6to4サービスをサーバー側で使うことのネックになるでしょう。TCPのセッションを確立するだけで、だいたい1秒くらいかかる事になります。

$ ping6 2001:2e8:601:0:3:1:0:bc


PING 2001:2e8:601:0:3:1:0:bc(2001:2e8:601:0:3:1:0:bc) 56 data bytes
64 bytes from 2001:2e8:601:0:3:1:0:bc: icmp_seq=1 ttl=56 time=20.2 ms
64 bytes from 2001:2e8:601:0:3:1:0:bc: icmp_seq=2 ttl=56 time=20.2 ms

$ ping6 2002:cbbd:60af::1

$ ping6 2002:cbbd:60af::1
PING 2002:cbbd:60af::1(2002:cbbd:60af::1) 56 data bytes
64 bytes from 2002:cbbd:60af::1: icmp_seq=1 ttl=52 time=323 ms
64 bytes from 2002:cbbd:60af::1: icmp_seq=2 ttl=52 time=319 ms

ただ2002で始まるアドレスを使う6to4には逆引きDNSサービスが受けられます。 AAAAレコードを登録できるDNSサーバを持つ事が前提ですけれど、とりあえずはセカンダリDNSサービスに紛れる事にします。

データベースに格納されている画像データをWebページに表示する

これまでWebアプリケーションを作る際のサムネイルデータなど、サイズのごく限られた画像ファイルをBLOBデータとしてデータベースに格納してきました。こういったケースは比較的よくあると思います。 最近では比較的大きなサイズの画像ファイルでもBLOBとして格納してしまうかもしれません。

今回は比較的サイズの小さな画像データをWebページに大量に貼り付けたい場合のお話しです。

背景

これまでは、DB上にあってファイルとしてアクセスできない画像を表示するために、画像データのストリームを返す小さなWebアプリケーションを準備してきました。

これだと画像ファイルの数が多くなった場合には、画像のデータ分だけのコネクションが必要になります。 keep-aliveが使えて実際のコネクション数が参照数と同じではないとしても、同一ページに小さな画像を大量に表示する事 は全体のパフォーマンス上の懸念材料になってきました。

いままでのベストプラクティスであれば、1ページは30kbyte以下に抑えるとか、画像データの取り扱いについてもいろいろありましたが、デスクトップアプリケーションの代りとしてWebアプリを作る場合には、これまでのルールが当てはまらない場合もでてきました。

HTMLに画像データを埋め込む技術的な話し

これまでの手法だとimgタグのsrc属性に画像表示用のURLを指定してidなどで画像を指定するようにしてきましたが、直接に画像データを指定します。

データを埋め込む例

<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAABLCAIAAAA06NSGAAAACXBIWXMAAABI
AAAASABGyWs+AAAACXZwQWcAAAB4AAAASwDhLqRrAAAki0lEQVR42u18a5Bc
..." />

いままで試したプログラミング言語毎の例を載せておきます。

RubyでBase64を扱う方法

いま手元にあるのはRuby 1.9.3-p125ですが、方法は共通のはずで、だいたい次のようなコードになっています。

Base64の文字列を得るのにpackを使っています。

rubyでのイメージファイルのbase64化


## この例ではオリジナルデータをファイルにしています
@filepath = "/somewhere/foo.jpg"

@prefix = "data:$MIME_TYPE;base64,"

## この戻り値をimgタグのsrc属性に指定します。
def getSrcData(data)
  ret = @prefix.sub("$MIME_TYPE", getMimeType())
  ret += [IO.read(@filepath)].pack("m")
  return ret
end

def getMimeType()
  ext = File.extname(@filepath).downcase.sub("\.","")
  ext = "jpeg" if ext =~ /jpg/i
  return format "image/%s", ext
end
JavascriptでBase64を扱う方法

以前、Google Chromeの機能拡張でExifの情報を扱うアプリケーションを作成しました。 Chromeでは同じ機能拡張の中でもメインのタブとポップアップウィンドウの間ではデータ参照が制限されているので、メインコンテンツの画像を機能拡張のポップアップウィンドウの中で表示するには、外部参照のURLを渡すか、データ列を渡すかの方法をとることになります。

その時はExifを解析するためにメインコンテンツ側でダウンロードしたデータをBase64で渡して、ポップアップウィンドウ側に表示させました。これはダウンロード回数を最小にするべきだろうという理由での判断でした。

Javascript自身にはbase64化の手段がないので、Masanao Izumoさんが公開されているbase64.jsを利用しました。

Javascriptでのbase64化


  imgtag_data_list.push("data:image/jpeg;base64,"+base64encode(EXIF.getRawData(node)));

この時はいくつかbase64化のライブラリを試しましたがテキストではなく、バイナリに対応しているのはIzumoさんのコードしか見つけられませんでした。コード中のEXIF.getRawData(node)で参照しているデータはXMLHttpRequestのresponseBodyです。

考慮点

同じ画像を大量に表示するような場合は、テキストファイルに埋め込むよりも、アプリケーション側でLast-ModifiedCache-Controlヘッダーをちゃんと扱うようにするのが最も経済的です。

安易にCGIやらで組むと動的アプリケーションの特性としてCache-Control: no-cache付く事になるので、キャッシュが有効になりませんが、これに気をつけるだけでブラウザが適切に繰り返しの読み込みを効率化してくれます。

他には画像データのサイズが比較的大きい場合は、HTMLファイルのダウンロードに時間がかかる事になるので、最初の画面描画が遅くなる欠点があります。ここ最近のブラウザはレンダリング途中から表示が始まるので、問題にならないかもしれませんが、いずれにしろサイズの大きなファイルを大量に表示するのは、もともとの設計に問題があります。

画像データの埋め込みはあくまでもサムネイルや、HTMLメールなどの用途に限定するべきでしょう。 よくよく考えて使わないと、策に溺れる事になります。

2012/04/15

pubimg: 画像ファイルを外部に公開するためのユーティリティ

このブログのコンテンツ自身はテキストファイルで、独自にXMLスキーマをRelaxNGを使って定義して、Emacsのnxmlモードでバリデーションしながら書いています。

外部参照用のURLは外部のXML定義ファイルにまとめていて、コアの機能は固まっていますが、ラベル一覧やURL一覧に効率良くアクセスする手段は模索中だったりします。

ブログだけでなく外部に公開する画像ファイルを対象に、効率良くアップロードするためのユーティティを作成しました。

pubimg本体の動き

外部に画像ファイルを公開する時に必要な作業の流れは次のようになっています。

  • サイズなどを整えた画像ファイルを作成
  • 手元のPCのあるディレクトリにファイルをコピー
  • Apache AntのFtpTaskでバッチ的にファイルをアップロード

この時にブログなどの記事を書く方では、最終的なURLの形式が必要になるので、ファイルを登録するとURLを表示するようにしています。 ここら辺はホームディレクトリの~/.pubimg.confプロパティファイルを参照して、コピー先やURLのprefixの情報等をApache AntとRubyの両方で共有しています。

このプログラムで実行している様子は次の通りです。

端末でpubimgを実行した様子

pubimgスクリプトの特徴

このスクリプトは単純ですが、次のような機能を持っています。

  • コピー先のファイル名を別に指定
  • 日付情報をprefixに付与
  • ファイルサフィックス(いわゆる拡張子)をコピー元から自動的に判別
  • sha256チェックサムで同じファイルが存在した場合には、コピーせずに既にアップロードされているURLを表示

たとえば gnome-screenshot を使った場合は、ファイル名が "Screenshot-1.png"のような名前がデフォルトになります。公開用に作成した分けではない画像ファイルであれば、オリジナルから名前を変更することが必要になります。

そこでpubimgは、第2引数にコピー先のファイル名を指定して、次のような使い方ができます。

$ pubimg Screenshot-4.png terminal_exec_pubimg
http://www.yasundial.org/images/pubimg/20120415.0.terminal_exec_pubimg.png

内容が異なるファイルを既にあるURLにコピーしようとした場合は、日付後のindexがインクリメントします。

$ pubimg Screenshot-5.png terminal_exec_pubimg
http://www.yasundial.org/images/pubimg/20120415.1.terminal_exec_pubimg.png

うっかりヒストリにあるコマンドを実行したり、別の名前で登録しようとしても、重複して登録される事はありません。

$ pubimg Screenshot-4.png hogehgoe
http://www.yasundial.org/images/pubimg/20120415.0.terminal_exec_pubimg.png

プロパティファイル

Apache antとRubyで共有しているプロパティファイルの内容は次のようになります。

プロパティファイル

local.dir = ${user.home}/.pubimg/files
remote.dir = /images/pubimg/.

ant.home = ${user.home}/java/tools/ant
java.home = /usr/java/1.6.0

url.prefix = www.yasundial.org/images/pubimg/

Apache AntのFtpTask用の設定ファイルは別の場所にあり、プロパティで指定する事もできるようになっています。

もし複数のftpアップロード先が必要になれば、読み込むプロパティファイルを切り替える仕組みをpubimg側に入れるだけです。

さいごに

これだけだとライブラリ管理はできていないので、ファイルのリストや削除は別のアプリケーションを作成する事にします。また将来的にはWindowsからファイルをアップロードしたいので、pubimgの機能はRESTアプリケーションとしても動く事になるでしょう。

pubimg自身は複数の小さいrubyクラスから出来ているのでREST側からその機能を呼ぶのは難しくないはずです。どちらかというと権限管理とか、RESTサーバーアプリからファイルを直接コピーさせるわけにもいかないので、そういう手間が増えそうな予感はします。

自宅用のアプリだし、セキュリティの懸念を考慮しなくていいならすぐに動くかな…。

2012/04/01

AndroidでJSONICライブラリを使ってみる

このブログを作成するシステムはgdataライブラリを利用して、いくつかの手製スクリプトで構成されています。

Subversionで履歴を管理していたのですが、実際にはほとんど使えないデータだったので履歴を切り離してGitに移行してしまいました。 まぁcommitしてきたログがあるので移行しても良かったのですが、気分的にはすっきりしていい感じです。

さて、新しい仕事を始めてからは.Net FrameworkやらAndroidやらiPhoneなんかのプログラミングを始めていますが、今回はJSONICというJavaでJSONデータを扱うライブラリについてです。

AndroidでのJSONデータの扱い

さて、Androidではorg.jsonパッケージがついてきて最低限のJSONデータを取り扱う環境があります。

送信用にJavaのデータ構造をJSONにする際には、特に問題なく扱えると思います。 反対に外部から受け取ったデータを扱う際にはローレベル過ぎて扱いが少しだけ面倒です。

今回はorg.jsonパッケージの上にライブラリを構築するのではなく、JSONICというJava言語用のJSONライブラリを利用する事にしました。

作成した成果物はjsonic4androidとしてGithubで公開しています。

JSONICを扱うメリット

基本的には無理に使う必要はありません。

自作の郵便番号検索システムから受け取ったJSONデータをJavaBeansとして扱うために使用しています。

この部分をorg.jsonパッケージを利用して取り出そうとして、面倒だったのは次のような点です。

  • UTF-8にエンコードするため、BufferedReaderを使い全データを変換する必要があった
  • 配列の中に各データのレコードを持っているため、要素名を指定してアクセスする処理が冗長で、JavaBeansにマッピングする必要があった
  • org.jsonパッケージを利用する自作変換クラスが安定して動作しなかった (これは自分の技量の問題です)

JSONのデータ構造は単純ですが項目数が多いため、各要素にアクセスするコードは繰り返しが多く、JavaBeansに変換してから扱うのがベストに思えたのですが、その変換コードを書くのがさらに面倒だったというのが実情です。

エントリ数が10以下で配列の中にさらにJSONデータ構造を持つような場合でなければ、org.jsonパッケージをそのまま使うのがお勧めです。

Android端末向けのプログラミングではライブラリを利用する事も作業量を減らす上では大切ですが、アプリ全体のパッケージサイズの圧縮が必要だったり、インタフェースを使わないなどのいくつかのプログラミング上のテクニックが存在します。

そういった事との兼ね合いで常にベストな選択をしてください。

パフォーマンス

jsonicでBufferedInputStreamからJavaBeansオブジェクト(JPostalBeanインスタンス)を生成した場合と、自前でInputStreamを開いてUTF-8に変換した文字列からJavaBeansオブジェクトを返すメソッドを作成して変換時間を計測して比較してみました。

jsonicを使ったコード


is = urlconn.getInputStream();
bis = new BufferedInputStream(is);
long beginTime = new Date().getTime();
ret = JSON.decode(bis, JPostalBean.class);
long endTime = new Date().getTime();

自前の変換メソッドを使ったコード (reqTextの生成時間は計測していません)


is = urlconn.getInputStream();
br = new BufferedReader(new java.io.InputStreamReader(is,"UTF-8"));
StringBuilder reqText = new StringBuilder();
...
long beginTime = new Date().getTime();
ret = parseJsonStream(reqText.toString());
long endTime = new Date().getTime();

合計で170件のデータを含むデータを処理しましたが、jsonicを利用した場合は約10%ほど高速でした。 自前ライブラリの場合には、BufferedReaderを使いUTF-8文字列を取得するところは省いていますが、それを含めると2倍近く処理速度の差が確認できました。

jsonicを使ったから特に処理が遅くなるという事ではなさそうなので、全体のバランスの中で選択すれば良いのかなと思います。

パッケージサイズの増加量

郵便番号検索アプリで作成したAPKファイルで比較するために、jsonicに依存するコードをコメントアウトしてjsonic4androidライブラリプロジェクトへのリンクを削除したAPKファイルを作成してみました。

$ ls -l JPostalSearch.*.apk
-rw-r--r-- 1 yasu yasu 469461 2012-04-01 10:38 /home/yasu/JPostalSearch.16e.without_jsonic.apk

jsonic4androidを含む、通常のAPKファイルは次のようになります。

$ ls -l JPostalSearch.*.apk
-rw-r--r-- 1 yasu yasu 499148 2012-04-01 10:45 /home/yasu/JPostalSearch.16e.apk

およそ30KB程度の増加量になり、配布について大きな障害ではなくなります。

jsonic4androidが対応するSDK(API)バージョン

作成しているアプリはAndroid 1.6 から(minSdkVersion="4")対応するようにエミュレータで動作を確認しています。

実機で確認しているのはAndroid 2.3.3 (SdkVersion="8")、及び、Android 3.2 (SdkVersion="13") です。

変更内容は次のセクションを参照してください。

jsonic4android - Eclipseプロジェクト形式での配布

今回扱ったJSONICのバージョンは1.2.10です。当初は1.2.9でしたがバージョンアップしたため、Githubのjsonic4androidも追随しました。

Githubに登録してあるのはEclipseに組み込む事を前提としたインポート可能なパッケージ形式です。

GithubのプロジェクトページのWikiにアクセスすると、画像で使い方が並んでいます。 gitでcloneしてから「Existing Projects into Workspace」を選ぶだけで、Eclipseの中で利用する事ができます。

jsonic4androidとして変更点

Eclipseパッケージとして組み込めるような形式になっている他の変更は次のような点です。

SDKのバージョンでいうと4、いわゆるAndroid 1.6以降で扱えるように、主にString::isEmptyメソッドを使わないように修正しています。

jsonicとproguardとの関係

リリースする際には署名をしますが、ここで通常はProguardを使う事になります。

開発環境ではうまく動くのに、署名したAPKファイルを配布すると問題になる場合がある原因の一つにはProguardがあります。

Proguardでの課題はテンプレートプログラミングを行なうためのGenericsとの相性です。

Proguardはパッケージ名やメソッドなどを変更してしまいますが、Java Reflection APIを扱うJSONICからはメソッドの名称が変更されてしまうと変数にアクセスする事ができなくなってしまいます。

鉤括弧でクラスを指定するGenericsの形式を使う場合には、明示的にproguard.cfgでそのオブジェクトを対象外にする必要があります。

今回はJPostalBeanクラスをjsonicのdecodeメソッドで処理しているため、proguard.cfgに次のような記述を追加しました。

proguard.cfgに追加したJSONICで処理するJavaBeans用の記述

-keep class net.yadiary.android.jpostal.beans.JPostalBean {
  <init>(...);
  *;
}

さいごに

JSONはクライアントとサーバーが1対1に対応するような軽量Webサービスを利用する場合の選択肢としては最有力だと思います。

SOAPでなければいけない場合は、ステートフルなセッションを行ない、メッセージボディを途中で分割して別々のサーバーに送信するSOPA対応Proxyを使用しなければいけない、限られた環境に限定されるでしょう。

Androidのようなスマートフォンで外部とのメッセージをやりとりする場合には自然に使う事になるJSONですが、アクセスするためにJSONICのようなデコーダとJavaBeansのデータ構造を利用しないと、JSONのデータ構造が変化する度にコードを追加、修正する手間が発生する事になります。

選択肢は沢山あるので、うまくライブラリを利用して見通しの良いコードを書くようにしたいものです。

2012/03/22

EXIF Geotag Checker Chrome機能拡張を作った時の変更点まとめ

画像ファイルにGPSの位置情報が入っていたら気持ち悪いなぁと思って、いくつかChrome機能拡張を探してみました。 ちゃんとExif情報を表示するものはいろいろあったのですが、その中からプライバシー情報を見つけるのは面倒で、汎用的なViewerはあったのですが、適当なものがみつかりませんでした。

Exif Geotag Checker Window

EXIF情報が常に悪いかというと、写真なんかを扱う人であれば、管理上は間違いなく重要な情報で、画像ビューアーがいつの間には画像を上書き保存していてEXIF情報が失われたと知れば怒る人もいるでしょう。

iPhoneであれば画像をメーラーで添付する時にEXIF情報は削除してくれますが、iPhoto経由なんかで普段とちょっと違う方法で画像をアップロードしたらGPS情報がばっちり残っている可能性もあります。

今回は思い切ってEXIFの数あるエントリの中から、GPSだけ、それも時刻、緯度、経度情報の3点だけを表示するツールを作成してみました。 EXIFの時刻情報のDateTimeOriginalもみるべきなのかもしれませんが、それはツールが勝手に改竄したとかいう主張も通りそうに思えるので今回は省きました。

興味のある方は、Gitoriousでコードをforkして機能を追加してみてください。

今回作成した機能拡張の機能そのものもはライブラリに頼っていますが、それ以外の基本的なところでいろいろ壁にぶつかったのでまとめておくことにしました。

作成したChrome機能拡張について

DateTimeOriginalについてのところで書きましたが、今回はGPS情報の3点だけを表示するようにしました。 EXIFのコメント欄にメールアドレスなんかが入っている可能性もあります。
とはいえ、全部をチェックするのは時間がかかる上に、現実的には問題が発生する可能性は低そうです。

それが必要であればEXIFのViewerを使ってください。

制限事項

今回作成したアプリケーションは、Ajaxなどでブラウザに動的に表示している画像には対応できません。

その他にもいくつか制限はあるものの、本格的なExifViewerはむしろ余計に混乱するので、いくつかの技術的なポイントの解決策の模索と合わせて、ニッチな需要を探してみる事にしました。

技術的なポイント

機能的の中心になるEXIF情報の取得には、既に公開されている外部ライブラリを使っています。 README.mdにも含めていますが、中心となる機能部分にはJacob Seidelinさんのexif.js(+binaryajax.js)を使用しました。

この他にもContent Scripts側での非同期処理を扱うために、jsDeferredライブラリ(jsdeferred.nodoc.js)も使用しています。

いろいろライブラリは使用しましたが、最終的にはexif.jsとbinaryajax.jsには、次のような変更を加えています。

  • IEに特化したコードをロードする個所のコメントアウト
  • 起動時の自動ロード(loadAllImages)の停止
  • exif属性のあるimgタグだけを対象にしていた処理のスキップ
  • 画像データを入手するためにメッセージボディ全体を入手するコードの追加

作業の中心はEXIFについてではなく、できるだけドキュメントのDOMの影響を受けないようにしたり、非同期呼び出しの内部構造作成に時間を取るようにしました。

取り組んだいくつかの課題

開発の前の調査段階と開発初期の試行錯誤の時期では次のような、課題を感じました。

Chrome機能拡張に固有な課題

画面に表示されているデータはDOMとして入手が可能ですが、Chromeで直接にDOMを操作するためにはContent Scriptsと呼ばれるコードインジェクションの手段を使う必要があります。 BackgroundタイプやBrowser Actionタイプからは非同期(コールバック)通信の仕組みを利用して、その結果だけを受け取る事になります。

またContent Scriptsは、画面に表示されているドキュメントの構造(DOM)の影響を受けます。 例えば、Content Scriptsを使って画面上にjQuery UIのポップアップウィンドウを表示させようとすると、コンテンツが既にロードしていたCSSの影響などを受ける可能性があります。

他のExifViewerを使って感じた課題

EXIF Viewerと呼ばれるようなアプリケーションをいくつか触ってみた時に、画像を解析するために外部のCGIを呼んでいるものがありました。

Browser Actionであるポップアップウィンドウから画像データにアクセスするのは面倒なのでリーズナブルな方法ではありますが、データを送信したCGIから画像データにアクセスするため、外部に公開されているサイトでないと正常に動かない課題があります。

このため、NATの中にいる自宅やイントラネットなどからはうまく動かない事になります。 また場合によっては画像が外部サイト上にキャッシュされる事を問題視する事もあるでしょう。

解決するべき課題

ざっと感じた点をまとめて、以下のようなポイントに注意して作業を進めました。

  • 外部サイトのCGIなどに頼らず、Chromeブラウザで完結した処理を実現する
  • アドレスバーの横にアイコンを出し、ポップアップした画面に結果を表示する
  • 問題があるか、ないか、ひと目で判断できるようにする

とはいえ画像データを2重にダウンロードする課題はあります。そのためJPEG画像のみをロードするようにPNGやGIFは無視する仕組みを入れています。そのためにexif.jsにもともとあった、読み込む対象をexif属性で指定する処理と、全ての画像データにアクセスする処理は動かないようにしています。

その他にも、ポップアップ画面で画像を表示させる時にもBasic認証が必要なページや相対URLから絶対URLへの変換といった面倒な処理への対応が必要になります。

こんな感じで、解決するべき課題はありました。 しかしなんとか、荒削りですが、一通り動かすことができるものを作る事ができました。

作成したコードはGitoriousから入手する事が可能です。

また機能拡張自体はChrome Webストアから入手する事ができます。 [ EXIFジオタグチェッカー ]

Browser Actionとしてポップアップ画面からコンテンツの画像を表示する方法

Chrome機能拡張についていえば、アクセスしているタブページとBrowser Actionでアドレスバー横のボタンを押して表示されるポップアップ画面とは完全に独立しています。

この環境でポップアップ画面に画像データを表示するためには、imgタグのsrc=...に次の2つの方法で画像ファイルを指定する必要があります。

  • httpから始まるURLで画像ファイルの場所を指定する
  • Base64でエンコードされたデータそのものを指定する

データそのものを指定する方法は、情報量が増えるので普通は使いません。

どちらでも良さそうだと思ったのですが、認証が必要なページの画像をURL指定でポップアップ画面からアクセスした時には認証ダイアログは出ずにアクセスが拒絶されました。 そんな分けでBase64を使って、タブページから画像データの情報そのものを入手できるようにContent Scriptsを作成しました。

扱うデータの量が増えたので、少し負荷テストらしき事をしてみました。 この段階ではSpeed Tracerのために開発版のChromeを使う必要があって準備していないので、細かい時間の測定は後回しにして、印象だけで書いています。

負荷テスト1

バックグラウンドでexif情報をチェックするスクリプトを動かして画面のレンダリングに異常がないか、確認しました。

テストのためにWebサイトの画像作成用に持っているイメージ素材集のJPEGイメージをいくつか使い、一つのindex.htmlの中にimgタグを2600以上配置させる負荷テストを行ないました。

この中にgeotag情報を持ったJPEG画像を配置しましましたが、時間がかかったものの、ローカルのWebサーバを経由させた事を考えても、思ったよりも早いスピードで処理を行なう事ができました。

負荷テスト2

次にGPS情報を持ったJPEGファイルのコピーを100件作成し、index.htmlに100件のimgタグを書き込んで、このファイルをロードするテストを行ないました。

この記事を書きながらテストしている今は、結果を表示するまでに1、2秒程度のタイムラグがあります。 デフォルトのページの内容が更新されないと、読み込み中なのかどうか、そのステータスを表示することができていません。

機能拡張を入れることでJPEGデータのロードは2倍発生するので、その分の処理時間は単純に倍かかります。 その予測とほぼ同じ変化がある事はわかりました。

ブラウザが表示した画像データのキャッシュにアクセスできれば良いのですが、そうするとCaptchaや乱数表のようなアクセスする度に変化する事でセキュリティを保っているようなアプリケーションの脅威になってしまうので、永遠にそんな事はできないでしょう。

そんな分けで、JPEGファイルについて再読み込みを許可、あるいは予測していない場合には、JPEGファイルに2回アクセスする事で問題が発生する可能性はあります。

今後Chrome機能拡張を作成する時に気をつけること

今後も気が向けば機能拡張を作ったり、現状のアプリケーションに変更を加える事もあるでしょう。

タブに開いているコンテンツ(のDOM)にアクセスするタイプのアプリケーションは、その解析にContent Scriptsを使い、そのデータの取得に非同期(コールバック)通信の仕組みを使う事になります。

そういった全体の構造を考えて、どういうコードをどこで使うか、そういう判断というか、全体の構成と楽をするための手段について検討することが必要でしょう。

JavaScriptを使う時にはライブラリを使わなくとも似たような処理はいろいろな方法で可能になります。 しかしコードが長くなって読み難くなってしまうところが難点だと思っています。

苦労すれば最終的に同じ(ような)機能の実装はできるけれど、それを高いメンテナンス性を保って作る事は、相当な知識と面倒な作業が発生するでしょう。JavaScriptの手軽さと難しさはそういうところにあると思います。

余談ですが、JavaScriptを学校で教えるべき言語に上げる人は多いそうですが、かなり特定の問題領域(ドメイン)に特化した知識+経験になりそうな点が心配です。

情報系の大学であれば、同じものを別の角度でみるためにも、JavaとScalaを合わせて教えて、同じ機能を別々の方法で実装させたりするのが、時間はかかっても最終的には良いJavaScriptプログラマを生み出すような気がします。

創造性っていうのは応用力の積み重ねでしかないので、思ったものが作れれば何でもいいんですけどね。

JavaScriptの需要が多いのは確かですが、最初からJavaScriptだと、苦労をしてもJavaScriptしか使えない人間になりそうに思えるのが怖いところです。

2011/03/31

chrome.tabsイベントの動きを追って、graphvizで図にしてみた

Open PinnedTab LinkというGoogle chromeの機能拡張を作成したので、 Google Chrome Extensionsのデベロッパーガイドを読む必要がありました。

特に Browser Interaction/Tabs (chrome.tabs package)にあるイベントハンドラーの動きがちょっと分かりづらかったので、備忘録的にどういう風に呼ばれるのか図にしてみました。

各ハンドラーの先頭にconsole.log()を入れるローレベルな方法で動きを追ったので、事前条件やテストの方法がまずかったりして違う動きになるかもしれません。

まずは、Graphviz(dot)で図にしてみた

タブを開くたびに上からNormal stateまでのevent handlerメソッド(chrome.tabs.on*())が呼ばれます。

"Normal state"は通常のWebブラウジングをしている状態です。

Statechart diagram of chrome.tabs event handlers

遷移するアクションの説明

説明のない矢印は自動で次の状態に遷移する事を示していて、Chromeのイベントは楕円で示しています。

  • create_tab(): Chrome上で新しいタブを開いたり、"Open Link in New Tab"を選択した場合のアクション
  • (un)pin_tab(): Pinを固定したり、外したりを選択した場合のアクション
  • reloase(): C-rや"Reload"などで明示的にページを更新した場合のアクション
  • close_tab(): C-wや"Close Tab"を選択した場合のアクション
  • (un)dock_tab(): タブをドラッグして、別ウィンドウに開いたり、別ウィンドウに統合した場合のアクション
  • change_focus(): 単純にタブを選択したり、別のタブを閉じたりした事でフォーカスが当った場合のアクション

chrome.tabs.OnRemovedが呼ばれた時には、そのタブは閉じますが、別のタブにフォーカスが当たるので、矢印が2つ出ている事になります。

明示的にTabIDやガード条件のようなものは書いていませんが、適当に読み取れると思います。

楕円で示したところは Events を示していますが、念のため対応する完全修飾のイベント名を列挙しておきます。

  • onCreated(): chrome.tabs.onCreated イベント
  • onUpdated(): chrome.tabs.onUpdated イベント
  • onSelectionChanged(): chrome.tabs.onSelectionChanged イベント
  • onDetached(): chrome.tabs.onDetached イベント
  • onAttached(): chrome.tabs.onAttached イベント
  • onMoved(): chrome.tabs.onMoved イベント
  • onRemoved(): chrome.tabs.onRemoved イベント

図を生成する元のdotファイル

図を生成するためのa.dotファイル



// Statechart diagram of chrome.tabs.on*()

digraph G {

  start [shape=circle, label="", style=filled];
  normal [shape=rect label="Normal state"];
  closed [shape=doublecircle, label="", style=filled];

  onCreated [label="onCreated()"];
  onSelectionChanged [label="onSelectionChanged()"];
  onSelectionChanged_ [label="onSelectionChanged()"];
  onUpdated [label="onUpdated()"];
  onRemoved [label="onRemoved()"];
  onMoved [label="onMoved()"];
  onDetached [label="onDetached()"];
  onAttached [label="onAttached()"];

  start -> onCreated [label="create_tab()"];
  onCreated -> onSelectionChanged_;
  onSelectionChanged_ -> onUpdated;
  onUpdated -> normal;

  // normal state
  normal -> normal;
  normal -> onSelectionChanged [label="change_focus()"];
  onSelectionChanged -> normal;

  // change tab position
  normal -> onMoved;
  onMoved -> normal;

  // pin or unpin tab
  normal -> onUpdated [label="(un)pin_tab() /\nreload()"];

  // dock or undock tab
  normal -> onDetached [label="(un)dock_tab()"];
  onDetached -> onAttached;
  onAttached -> onSelectionChanged [label="change_focus()"];

  // close tab
  normal -> onRemoved [label="close_tab()"];
  onRemoved -> onSelectionChanged [label="change_focus()"];
  onRemoved -> closed;
}

これを図にするには、dotコマンドを使って次のようなコマンドラインを使っています。

$ dot -Tpng -o a.png a.dot

困ったこと

複数のタブを保存して開いた時に、chrome.tabs.onUpdated が呼ばれずに、いきなり chrome.tabs.onSelectionChanged が呼ばれる場合がありました。

さらに悪いことに、この場合には Pin Tab かどうか確実に判別することができませんでした。

そのためタブを保存した状態のChromeを起動して、偶然この問題に遭遇するとPin/Unpinを判別するような機能拡張がうまく動作しない場合があります。

Open PinnedTab Linkでは、この他にもUnpinの場合にContext Menuを無効にするようにしたかったのですが、いまのところ良い手がなく全てのタブの中にメニューを表示しています。

問題はいろいろありますが、タブをブックマーク的に使う初期の目標は達成できたので良しとしましょう。 でも当然ユーザーは不満に思いますよね、「無駄なら消してよ」って。うーん、困ったなぁ。

2011/03/29

Google Chrome用の機能拡張 "Open PinnedTab Link" を作ってみた

結論からいうと、この記事ではGoogle Chrome用に作成したOpen PinnedTab Linkという拡張機能を紹介しています。

ブックマーク代りのピン化したタブ

いまのところLinux 64bit版のGoogle Chrome 10.0.648.204をメインに使っています。

Ubuntu 10.04ではmozilla-daily-ppaを経由してFirefox 4を導入していますが、ピンにしたタブから開くリンクは新しいタブで開くところがとても便利だと思っています。

Google Chromeでもタブをピンにすることはできますが、標準ではピンになったタブの中でリンクが開いてしまい、ブックマーク代りに使うには適していません。

TabLinkは全部のリンクを書き換えてしまう…

似たようなことを考える人はいて、Pin Tab Links should open new windowというHelp forumのトピックからTabLinkという機能拡張を試してみました。

TabLinkは問題なく動いたのですが、全部のリンクが書き変わってしまうので、自分の目的には合いませんでした。 そのため今回はピン化したタブからのリンクだけは新しいタブで開くというOpen PinnedTab Linkを作りました。

特別なことはしていません。内部的にはピン化したタブかどうかを判断する処理の後はTabLinkと同じことをしています。

ただし、ピンを外した後は全リンクの"target"属性を空にすることをしているので、元々新しいタブを開くリンクは違う動きをするかもしれません。

さいごに

ピンタブを使うと、アイコンでその機能を見分ける必要がでてくるので、サイトがfavicon.icoを設定していないと微妙に不便になるでしょう。

2011/03/24

RubyでFastCGIとGetTextモジュールを組み合せる

Rubyで多言語化を行なうにはMutohさんのRuby-GetText-Packageが便利そうです。

通常の po → mo の変換を行なえばメッセージを m17n できるところが良いので、作業の内容は一般的なものです。

単純な置換なら手でも作れますが、将来的に複数の言語に対応するなら、自分でロジックを組む手間が削減できます。

処理速度が速くなるかどうかは微妙で、ステータスをThread毎に格納して、全体のメッセージ文はRuntimeでキャッシュされるので手作業で置換ロジックを書くよりも多少はスピードアップが期待できそうです。ただメモリを消費してもよいならシンプルに正規表現のリストと置換する文字列の対を作った方が早いんじゃなかろうかと想像しています。

ERBだと<%= _("message") %>のように埋め込む手間がかかり、可読性は多少低下するデメリットもありそうです。

それでもGetTextモジュールを使うことで作業手順が標準化できるので、置換対象の文字列を管理する後々のメンテナンスを考えると総合的に利点が上回ると思います。

GetText自体はcgiクラスとの組み合せを想定しているので、FastCGIについてはドキュメントがなさそうでした。 そのためGetTextとFastCGIについてまとめておきます。

開発環境について

いつものようにUbuntu 10.04 LTS x86_64版を使用しています。

  • OS: Ubuntu 10.04 LTS x86_64
  • Ruby 1.9.2-p136 (/usr/local/bin/ruby) + gettext + fcgi
  • Apache 2.2.14 + libapache2-mod-fcgid

FastCGIとCGIの違い

FastCGIは標準添付ライブラリにはないため、fcgiモジュールをgemなどでインストールする必要があります。

CGIとの違いは起動済みのプロセスにWebブラウザからアクセスする仕組みなので、プロセスの起動にかかる時間が短縮される分、メモリを常に使いますが処理スピードは速いです。

またプロセス自体は終了せずに(Rubyの場合)スレッドが各ブラウザからのリクエストを処理するため、インスタンス変数やクラス変数を適切に使うことで、キャッシュの効果により全体的なレスポンスを向上させる事もできます。

その反面、処理する単位はオブジェクト単位にしてシンプルに切り分けないと、キャッシュしたくない内容が残ったりしてプライバシー上の問題を引き起す可能性もあります。

一般的なWebコンテンツのホスティングサービスでは、FastCGIのようにプロセスが常駐するとメモリを消費するため、必要に応じてリソースを消費するCGIやPHPが一般的です。

FastCGIもCGIもWebの初期からある仕組みなので古典的に扱われますが、Web以前から起動済みのプロセスにオンライントランザクションを処理させる仕組みは、決済やら問い合せ処理やらを高速に処理する仕組みとしてよく知られていました。

起動時間とその起動処理を無視できることは、アクセスが増えていく中で安定的なレスポンスを得るための重要な要件の一つです。

PHPはApacheのモジュールとしてサーバのプロセスイメージの中で処理が完結するため速いはずですが、基本的にはあらかじめ処理内容をキャッシュするわけではないので、時間は短縮できますが、接続毎にいろいろな初期化処理が必要となる点が独特です。

さて「FastCGIは古くない!」という主張は十分したので、GetTextとの組み合せについてまとめます。

FastCGIとGetTextの組み合せ

GetText付属のサンプルをみると、CGIモジュールに対応したスクリプトがありますが、そのままではFastCGIに適用できません。

その理由はFastCGIのスレッドをプールして使い回すという性質にあります。

違いを説明する前に雛型になりそうなスクリプトは次のようになります。

完全に動作するFastCGIスクリプト全体

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-

$:.unshift "/app/lib"
ENV['GEM_HOME'] = "/app/gems"

require 'rubygems'
require 'fcgi'
require 'cgi'
require 'erb'
require 'gettext/cgi'
require 'gettext/tools/parser/erb'

## Render
class SimpleRender

  def initialize(query, env)
    @query = query
    @env = env
    Locale::clear
    Locale::set_request([query["lang"]], [], env["HTTP_ACCEPT_LANGUAGE"], env["HTTP_ACCEPT_CHARSET"])
    GetText::set_output_charset("UTF-8")
    GetText::bindtextdomain("sample", "/app/data/locale")
  end
  
  def render
    ret = ""
    content = "/app/sample.erb"

    ret += ERB.new(open(content, "r:utf-8").read).result(binding)
    return ret
  end

  def _(msgid)
    GetText::_(msgid)
  end
end

## Controller
class Main
  def initialize(request)
    @env = request.env
    @query = check_query_string(CGI.parse(@env['QUERY_STRING']))
  end
  
  def run
    ret = "Content-Type: text/html; charset=UTF-8\r\n\r\n"
    case @query["mode"]
    when "search"
      sr = SimpleRender.new(@query, @env)
      ret += sr.render()
    end
    return ret
  end

  private
  def check_query_string(q)
    ret = {}
    label = "mode"
    ret[label] = (q.has_key?(label) and not q[label][0].empty?) ? q[label][0] : "search"
    label = "lang"
    ret[label] = (q.has_key?(label) and not q[label][0].empty?) ? q[label][0] : "ja"
    return ret
  end
end

###############
## main loop ##
###############

FCGI.each {|request|
  main = Main.new(request)
  request.out.print main.run
  request.finish
}

スクリプトが参照している外部ファイルについて

gemsを使うかどうかは問題ではないのですが、requireしているモジュール・ライブラリにアクセスできるように、先頭の6行は適切なパスに書き直す必要があります。

/app/sample.erbはコンテンツ本体で、次のような内容になっています。

sample.erb本体

<html>
<body>
<h1><%= _("Hello World") %></h1>
</body>
</html>

GetTextモジュールの処理の中心となる翻訳データを保存するためには /app/data/localeディレクトリを作成しています。 作成方法はmoファイルを生成するところで行なっています。

結果として、ライブラリを除いた/appディレクトリの構造は次のようになっています。

$ find . -type f
./po/en/sample.po
./po/ja/sample.po
./po/sample.pot
./Rakefile
./data/locale/en/LC_MESSAGES/sample.mo
./data/locale/ja/LC_MESSAGES/sample.mo
./sample.erb
解説:Thread毎のキャッシュの破棄

LocaleはThread.current[:current_request]に必要なデータ構造をキャッシュします。

スレッドが消滅しないかもしれないので、接続毎に処理が終った段階でデータを破棄する必要があります。

処理が終った段階」を徹底するために、処理を始める前にデータを破棄しています。 このLocale::clearはnilを代入するだけの処理なので、スレッドが立ち上がった最初に実行しても問題ない内容になっています。

処理開始時にLocale::clearを呼び出しているところ

    Locale::clear
    Locale::set_request([query["lang"]], [], env["HTTP_ACCEPT_LANGUAGE"], env["HTTP_ACCEPT_CHARSET"])
    GetText::set_output_charset("UTF-8")
    GetText::bindtextdomain("sample", "/app/data/locale")
解説:クライアントの言語情報の登録

前項にあるコードの2行目 Locale::set_request()メソッド が、クライアントが利用する言語を登録しているところです。

CGIモジュールを使うサンプルでは、Locale::set_cgi()を使用していますが、今回はCGIオブジェクトは使えないため直接その内部で使用している Locale::set_requestメソッド を呼び出しています。

第一引数に指定した言語から優先度が高く、第二引数にはCookieに保存した言語情報を格納する想定ですが、cookieを使っていないので空にしています。

GetText関連ファイル(po,moファイル)の作成

今回の例では、moファイルにアクセスするために、/app/data/localeを指定しています。

必要なファイルを作成するために、まず次のような内容の/app/Rakefileファイルを作成しました。 これはMutohさんのページの説明ほぼそのままです。

/app/Rakefileの全体

$:.unshift "/app/lib"

desc "Update pot/po files."
task :updatepo do
  require 'gettext/tools'
  GetText.update_pofiles("sample", Dir.glob("*.erb"), "sample 1.0.0")
end

desc "Create mo-files"
task :makemo do
  require 'gettext/tools'
  GetText.create_mofiles
end

先頭の $:.unshift は、FastCGIスクリプトと同じで、GetTextモジュール(gettext.rb)へのパスです。

デフォルトのパスにインストールしていれば不要ですが、今回は特別な場所にモジュールを配置しているので追加しています。

ファイルを配置した後は、poファイルを作成します。

$ cd /app
$ rake updatepo

"/app/po/sample.pot"が作成されるので、それを言語毎のディレクトリにコピーをして翻訳を行ないます。

$ cd /app
$ mkdir po/ja po/en
$ cp po/sample.pot po/ja/sample.po
$ cp po/sample.pot po/en/sample.po

2つのsample.poの翻訳が終ったら moファイル を作成します。

$ rake makemo

これが無事に終ると /app/data/locale/ja/LC_MESSAGES/sample.mo/app/data/locale/en/LC_MESSAGES/sample.mo のファイルが作成されているはずです。

po/ja/sample.poファイルの内容

# Copyright (C) 2011 Yasuhiro ABE yasu@yasundial.org
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: sample 1.0.0\n"
"POT-Creation-Date: 2011-03-24 09:25+0900\n"
"PO-Revision-Date: 2011-03-24 09:25+0900\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"

#: sample.erb:3
msgid "Hello World"
msgstr "こんにちは"

FastCGIスクリプトの実行

Apacheを適切に設定して、Options ExecCGIが設定された場所に配置してWebブラウザから呼び出します。

URLの最後に ?lang=ja?lang=enをつけて呼び出して内容が変化することを確認します。

GetTextモジュールを使っていて気がついたこと

rmsgmergeコマンドが使えない

たぶん一斉置換か何かの影響か、gettext/tools/rmsgmerge.rb の中がおかしくなっています。

rmsgmergeの修正個所

#      refpot = parser.parse_file(config.refstrrefstr, PoData.new, false)
      refpot = parser.parse_file(config.refpot, PoData.new, false)

これで終りかと思いきや、-hで表示される第一引数と第二引数は逆にしないといけない模様です。

いまのところRakefileの方が便利そうなので困ってはいませんが、コマンドを使って更新をpoファイルに反映したい場合には注意が必要です。

困った事にdebパッケージでruby 1.8.x用に入っているrmsgmergeも動きません。

rgettextコマンド内部で参照しているrgettext.rbへのパスがおかしい

require 'gettext/rgettext'require 'gettext/tools/rgettext'に変更しました。

rgettextコマンド内部にあるrubyコマンドへのパスを変更する

rmsgmergeでも同じでしたが、コマンドの先頭にある#! /usr/bin/rubyのパスを#!/usr/local/bin/rubyに変更しました。

$:変数にライブラリディレクトリを加える

またRuby 1.9.2では$:はカレントディレクトリを指さないので、gettext.rbのあるディレクトリへのパスを$:に登録しています。

いろいろ修正したrgettextスクリプト全体

#! /usr/local/bin/ruby
# -*- coding: utf-8 -*-
=begin
  rgettext - ruby version of xgettext

  Copyright (C) 2005-2009  Masao Mutoh
  
  You may redistribute it and/or modify it under the same
  license terms as Ruby.

=end

$:.unshift File::join([File::dirname($0),"..","lib"])

begin
  require 'gettext/tools/rgettext'
rescue LoadError
  begin
    require 'rubygems'
    require 'gettext/tools/rgettext'
  rescue LoadError
    raise 'Ruby-GetText-Package are not installed.'
  end
end

GetText.rgettext

ドキュメントが微妙に古くて通用しない記述もいろいろありましたが、ちゃんと動いています。

m17n のためには、gettextの標準的な作法が使えるというのは強力だと思います。

2011/02/25

RDFaを理解する際に便利かもしれないCheatSheetの作成

手持ちのWebサイトではしばらく前からXHTML+RDFa-1.0をDTD宣言に使用していましたが、十分に対応できている状況ではありませんでした。

今は個人的使うCMSというか静的HTML/RSS生成ツールを作成していて、RDFの構造を意識しようとしています。

RDFaをちゃんと使うために神崎さんの「セマンティックHTML/XHTML」を読み直していて、RDFグラフの典型的な型をちゃんと理解しないといけないなぁと思ったところです。

CMSみたいなツールを考えてみると、文書自体の構造はある程度自動化できる可能性がありそうなのですが、文書に含める画像、アンカーなどのリソースは手動でrel, propertyなどの属性を付与する必要があると強く思っています。

コンテンツを書くために毎回本を開くのも大変なので、自分の理解の範囲でまとめた図を作ってみました。

RDFグラフの基本形

RDFa関連の資料を読むためには、リソースとリテラルを区別することが必要です。

Turtle構文が理解できる事は必要ですが、それは神崎さんの本を呼んでもらう事にして、次のような図に対応するTurtle構文RDF/XML構文XHTML+RDFa構文を理解できれば一つの目安になるかなと思います。

RDFaグラフの基本形

最後の形は閉じたRDFの形ですが、RDFaではまだ記述する事ができません。

詳細はRDFa Working Group WikiのContainersAndCollectionsに詳しく載っています。

XHTML+RDFaからのグラフへの変換

太字になっているのは属性に指定されている値のことです。 ここではrdf:typeだけが小文字で理解を補助するために付与しています。

XHTMLからRDFaグラフへの変換

relはリソース用、propertyはリテラル用

ここには書いていませんが、XHTMLのlink, metaタグも、指定する対象がリソースかリテラルかで使い分けています。

さいごに

これで網羅できているわけではないですが、リソース、リテラルの区別と、空白ノードが出現するタイミングを理解することがとりあえずの目標です。

おそらくこれぐらいは覚えておかないと、RDFaを意識したタグ付けはストレスな作業になるでしょう。

自動化できそうなのは、ダブリンコアやFOAFで定義されているタグの種類を提示したり、それがリソース用なのかリテラル用なのか判別するところぐらいでしょうか。

この記事で取り上げた品々

2011/02/03

OpenOffice Calcでダブルクォートが特殊文字に変換される現象について

CSVファイルを扱っていて、OpenOfficeのデフォルトの挙動に困った事がありました。

それはデータとしてセルに"name"のように単純にダブルクォートで囲んだ場合に、特殊文字に展開されてしまうところです。

これを回避する方法は簡単にでメニューバーの「ツール」から「オートコレクトオプション…」を選択して、「ユーザ定義引用符」置換のチェックを外すだけです。

オートコレクトメニューの変更が必要な個所を赤丸で表示しています

Excelを使った場合のダブルクォートの扱い

CSVについてはrfc4180が一応、後出しですがまとまった情報という事になっています。

これによれば、以前も書きましたが、ダブルクォートは""のように2つを続けて出力することでエスケープできます。

最近はCouchDBにPUTするDocumentをCSVから生成できる方法を模索しているので、Map関数を定義してみました。

Excel2007でCouchDBのMap関数を定義してみた

language,views,all,map
javascript,,,"function(doc) {
  if(doc.doctype && doctype == ""log"")
    emit(doc.src);
}"

注目するのは、== ""log""のエスケープされたところです。

OpenOfficeを使った場合

すでに説明していますが、オートコレクトの挙動によって入力したダブルクォートは\u201c(0x28809c), \u201d(0xe2809d)に変換されてしまいます。

"_id";"language";"views";"all";"map";"views";"all";"reduce"
"_design/all";"javascript";;;"function(doc) {
  if (doc.doctype && doc.doctype == “log”) {
    emit(doc,null);
  }
}";;;"_count"

Unicodeの"Left Double Quotation Mark"や"Right ..."はかなり見分けるには厳しい違いなので、できればデフォルトの挙動としては勘弁して欲しいんですけどね。

とりあえず先頭に書いたようにオートコレクトのオプションを操作して、CSVファイルを生成しています。

2011/02/02

CouchDB: 1.0.1から1.0.2のリリースアップ手順 (xstow対応版)

家にあるalixやらworkstationやらhttp://www.yadiary.net/にあるソフトウェアのバージョン管理にはxstowを利用しています。

最初にコンパイルしたapache couchdb version 1.0.1は/usr/local/stow/couchdb-1.0.1をconfigureの'--prefix'に指定しています。

今回は始めてのバージョンアップになります。

まぁstowのマニュアルには/usr/localをprefixに指定して、make install時に変数を上書きするような方法をとるわけですが、そうすると間違って/usr/local/stow以下でコマンドを実行しても、それなりに動いてしまうのが痛いところなわけです。

さて、今回はapache-couchdb-1.0.2がリリースされてしばらく経ち、いろいろテストしてみてもそのまま移行して問題なさそうな感じなので、その作業をまとめたログです。

現行環境の確認

インストールする環境はDTIのVPSで使っているDebian squeezeです。

stowディレクトリの構造

local.iniなどはバージョンに対して不変なので、stowディレクトリには含めていません。

深さ2ぐらいまでのディレクトリ構造は次のようになっています。

$ find /usr/local/stow/couchdb-1.0.1 -maxdepth 2
/usr/local/stow/couchdb-1.0.1/etc
/usr/local/stow/couchdb-1.0.1/etc/init.d
/usr/local/stow/couchdb-1.0.1/etc/logrotate.d
/usr/local/stow/couchdb-1.0.1/share
/usr/local/stow/couchdb-1.0.1/share/man
/usr/local/stow/couchdb-1.0.1/share/couchdb
/usr/local/stow/couchdb-1.0.1/share/doc
/usr/local/stow/couchdb-1.0.1/bin
/usr/local/stow/couchdb-1.0.1/bin/couchdb
/usr/local/stow/couchdb-1.0.1/bin/couchjs
/usr/local/stow/couchdb-1.0.1/lib
/usr/local/stow/couchdb-1.0.1/lib/couchdb
テスト環境で発見した不具合

このままstow側にないディレクトリ(例: var/, etc/couchdb)を同じように削除して、バージョンアップすると、ちょっとした問題が発生しました。

stowに含めないファイルはバージョンに依存しない情報を持っている事が前提だったのですが、default.iniファイルにはいろいろとパス名を含めた情報が記述されています。

$code_cap

...
util_driver_dir = /usr/local/lib/couchdb/erlang/lib/couch-1.0.1/priv/lib
...

このため$ sudo xstow -D couchdb-1.0.1を実行してから、/usr/local/etc/couchdb/default* ファイルたちを /usr/local/stow/couchdb-1.0.1/etc/couchdb 以下に退避してから、$ sudo xstow couchdb-1.0.2を実行する手間がかかっています。

置き換え手前までの作業

今回はノーマルなcouchdb-1.0.2のパッケージに加えて、自作のパッチを適用しています。

準備したファイルは次の通りです。

gropu_numrows関係のファイルは差分ではないので、直接置き換えます。 差分はGitHub上で確認できます。

コンパイルとインストール
$ tar xvzf apache-couchdb-1.0.2.tar.gz
$ mkdir apache-couchdb-1.0.2.extras
$ cd apache-couchdb-1.0.2.extras
$ curl -o couch_db.hrl https://github.com/YasuhiroABE/CouchDB-Group_NumRows/raw/couchdb-1.0.2/couch_db.hrl
$ curl -o couch_httpd_view.erl https://github.com/YasuhiroABE/CouchDB-Group_NumRows/raw/couchdb-1.0.2/couch_httpd_view.erl
$ cp couch_db.hrl couch_httpd_view.erl ../apache-couchdb-1.0.2/src/couchdb/
$ cd ../apache-couchdb-1.0.2
$ ./configure --prefix=/usr/local/stow/couchdb-1.0.2
$ make
$ sudo mkdir /usr/local/stow/couchdb-1.0.2
$ sudo chown $(id -un) /usr/local/stow/couchdb-1.0.2
$ make install
$ sudo chown -R root:couchdb /usr/local/stow/couchdb-1.0.2
stowで管理しない不要なファイル・ディレクトリの削除

インストールまでは、これで終りで次にvarディレクトリなど/usr/local/以下に直接配置するファイルやディレクトリを削除していきます。

新規に導入する場合には、削除ではなくて、対応する/usr/localの場所にmvする事になります。

$ sudo rm -rf /usr/local/stow/couchdb-1.0.2/var
$ sudo rm -rf /usr/local/stow/couchdb-1.0.2/etc/couchdb/local.*
$ sudo rm -rf /usr/local/stow/couchdb-1.0.2/etc/default
埋め込まれた"stow/couchdb-1.0.2/"パスの削除

スクリプトファイルなどの中からstow/を含むパスを通じて参照している部分を書き換えて/usr/local/stowを参照するようにします。

候補は次のコマンドラインで表示されますが、lib/couchdb/以下のファイルは設定ファイルで指定するので作業を行ないません。

ファイルを開いて書き換えますが、viを使った場合のコマンドラインは次のようになります → :%s!stow/couchdb-1.0.2/!!

 $ find /usr/local/stow/couchdb-1.0.2/ -type f | while read file ; do grep stow/ $file > /dev/null 2>&1 && echo $file ;  done
/usr/local/stow/couchdb-1.0.2/etc/init.d/couchdb
/usr/local/stow/couchdb-1.0.2/etc/logrotate.d/couchdb
/usr/local/stow/couchdb-1.0.2/etc/couchdb/default.ini
/usr/local/stow/couchdb-1.0.2/lib/couchdb/erlang/lib/couch-1.0.2/ebin/couch.app
/usr/local/stow/couchdb-1.0.2/lib/couchdb/erlang/lib/couch-1.0.2/priv/lib/couch_icu_driver.la
/usr/local/stow/couchdb-1.0.2/bin/couchjs
/usr/local/stow/couchdb-1.0.2/bin/couchdb

これで準備作業は全て完了しました。

couchdb-1.0.1からのバージョンアップ

最初に説明したように、通常であればcouchdbで停止してxstowコマンドで切り替えるだけなのですが、今回はstowで管理するファイルにdefault.iniとdefault.dを追加したのでxstowでcouchdb-1.0.1のリンクを削除した後で、このファイルを手動で移動する必要があります。

たぶんコマンドラインは次のようになるはずです。

$ cd /usr/local/stow
$ sudo /etc/init.d/couchdb stop
$ ps auxwww|grep couchdb          ## ← eralangプロセス(beam)の停止を確認
$ sudo xstow -D couchdb-1.0.1
$ sudo mkdir couchdb-1.0.1/etc/couchdb
$ sudo mv ../etc/couchdb/default.* couchdb-1.0.1/etc/couchdb
$ sudo xstow couchdb-1.0.2
$ sudo /etc/init.d/couchdb start

とりあえずこの手順通りで無事にhttp://www.yadiary.net/で動いているCouchDBを1.0.1から.1.0.2に移行する事ができました。

2011/01/29

重複起動を防止するRuby用のdaemonクラスライブラリ

SourceForge.jpにできたPersonalForgeのGitリポジトリ機能を使ってみました。

登録したのはCouchDB周りでDBのメンテナンス用Daemonを作るために作成したRuby用Daemonライブラリyadaemon.rbとサンプルコードです。

有名なdaemonライブラリは便利そうにみえますが、今回は使わない機能が豊富で、欲しかった機能は重複起動を防ぐ機能だったので自分で作成する事にしました。

今回はRuby 1.9.2用に作成しました。

雛型コード

#!/usr/local/bin/ruby
#-*- coding: utf-8 -*-
require 'yadaemon'
opts = { "daemon"=>true,"euid"=>1000 }
daemon = YaDaemon.new("testapp","test.pid","/tmp",opts)
daemon.run do |pid|
  i = 0
  while daemon.running
    open("/tmp/testapp/test.log","w") do |f|
      f.write(format("updated: %d\n", i))
      f.flush
    end
    i += 1
    sleep 5
  end
end

PersonalForge

Gitリポジトリ

よくあるGitリポジトリのビューがPersonalForge Summaryページから提供されています。

コードのチェックアウト

gitを使ってチェックアウトする事ができます。

$ git clone git://git.pf.sourceforge.jp/gitroot/y/ya/yasundial/MyDaemonWrapper4Ruby.git

基本的な挙動

よくあるdaemonと同じように、pidファイルを使います。

runメソッドに指定したブロックを実効する前に、いくつかのチェック作業を行ないます。

  • シナリオ# - メインラインシナリオ
  • A1 - ユーザがプロセスを起動する
  • A2 - 既に実行しているプロセスがないかpidファイルの存在をチェックする
  • A3a - pidファイルが存在する場合、中に記述されている番号のプロセスが存在するか確認する
  • A3b - pidファイルが存在しない場合、pidファイルが作成できる事を確認する
  • A4 - piddirが書き込み可能か確認する
  • A5 - forkする場合は、forkする
  • A6 - 既存pidファイルにpidを保存する
  • A7 - rootで起動して、かつ、指定がある場合、eUID, eGIDにswitchする
  • A8 - runメソッドがyieldを実行し、ユーザが指定したジョブを実行する
  • A9 - ユーザが指定したジョブを実行した後は特に何もせず後続の処理を続ける

オプション

インスタンス化の手順

daemon = YaDaemon.new(appname, pidfile, pidpdir, options)

YaDaemonクラスのインスタンスを作成する際に指定できるパラメータは次の通りです。

  • appname: appname used for sub-directory name of the pidpdir.
  • pidfile: pid filename
  • pidpdir: pid parent dir
  • options: Hash object
    • debug => true/false
    • daemon => true/false
    • euid => number
    • egid => number
    • perms => number

意味と使い方を順番に解説していきます。

appname

パスを含まないディレクトリ名にとることができる文字列を記述します。

名前はアプリケーションの名前の意味ですが、実際には"pidpdir"直下にサブディレクトリを作るためのディレクトリ名として使われます。

euidが指定されている場合には、所有者がeuidで指定したユーザになります。

pidfile

パスを含まないファイル名を記述します。

指定したファイルにPID番号が書き出されます。

ファイルオーナはプログラムを実行したユーザのUIDになります。

pidpdir

ディレクトリへの絶対パスを記述します。

pidpdir自体のowner/groupなどのパーミッションは一切変更されません。

ディレクトリが存在していれば、このディレクトリappnameのディレクトリが作成されます。

options: debug (default: false)

オプションが指定されている場合にはデバッグメッセージがpidfileと同じディレクトリ(pidpdir/appname/)に"debug.log"の名前でログファイルが出力されます。

options: daemon (default: false)

trueの場合には、プロセスをforkしてstderr/stdout/stdinを切り離します。

d.j.b.のdaemontoolsと一緒に使う事を想定しているため、標準ではfalseに設定されています。

options: euid (default: Process::euid)

実効UIDを整数値で指定。文字列でグループ名を指定する事はできません。

プロセス特定のユーザで実効する場合には、ファイルの権限変更などに供えて、予防的にこのオプションを指定する事をお勧めします。

過ってrootユーザで実効した場合でも、指定したeuidに遷移した後にrunメソッドが呼ばれます。

options: egid (default: Process::egid)

実効GIDを整数値で指定。文字列でグループ名を指定する事はできません。

options: perms (default: 0711)

整数値で指定する必要がありますが、0711は711とは異なります。

0711,02711のような8進数をベースに指定します。

2011/1/31追記:stop/restartオプションの追加

pidファイルの中にPIDを埋め込んでいるので、これを利用してstop()force_stop()の2つのメソッドを持っています。

使い方の例はsf.jpのGitリポジトリの中に入れてありますが、簡単にサンプルコードだけを載せておきます。

トップにあるコードで、daemon.run()の呼び出しの前に、次のようなコードを書いておきます。

stop()メソッドを使った例

if ARGV[0] == "restart"
  begin
    daemon.stop
    while daemon.check_proc
      sleep 1
    end
    puts "running process was terminated."
  rescue
    puts $!
    puts "failed to restart process."
  end
elsif ARGV[0] == "stop"
  begin
    daemon.stop
    while daemon.check_proc
      sleep 1
    end
    puts "running process was terminated."
  rescue
    puts $!
  end
  exit
end

これを呼ぶと @piddirの直下に"stop.txt"ファイルが作成され、whileの条件式にしているdaemon.running()がfalseを返すようになります。

長期間sleepするような作りになっていると、daemon.running()メソッドが呼ばれるまで何も反応しませんが、ちょっとコードを工夫すれば問題なく安全に停止することができるでしょう。

2011/01/25

WebSVNに"lost+found"を無視させる

自作のコードのいくつかはSubversionで管理していて、Alix上にApacheを入れてWebDAVとWebSVNで管理しています。

今回USBメモリ上にリポジトリを作成したのですが、そこをWebSVN 2.0で表示させるとlost+foundをリポジトリとして扱おうとしてエラーが表示されてしまいます。

リポジトリの表示を自動化せずに、全て手動で$config->addRepository()を/etc/websvn/config.php内で実行するのは、どんどんリポジトリを追加していて、あまりにも面倒なので削除パターンを追加することにしました。

環境

WebSVNは次の環境で稼働しています。

  • HW: Alix 2c3
  • OS: Debian lenny (5.0.8)
  • Apache: 2.2.9-10+lenny9
  • WebSVN: 2.0-4+lenny1

WebSVNの最新版は2.3系列で、それについては最後に少しふれています。

Ubuntu 10.10で確認した範囲ではWebSVN 2.3.1では、apacheプロセスが読み取れない、SVNリポジトリではないディレクトリが存在していても問題なく動いています。

挙動の変更内容

設定ファイルの中でリポジトリが格納されているディレクトリを$config->setParentPath()で指定していると想定しています。

PHPの内部ではopendir()で展開されていますが、lost+foundなどのシステム上必要なディレクトリを省くためにignoreRepoPathPattern変数を追加しました。

既存コードに倣って直接変数への代入ではなく、addIgnoreRepoPathPattern関数を使って追加する仕様にしています。

修正内容

変更を加えたのは設定ファイルのconfig.phpと、それを元にリポジトリをリストアップするconfigclass.phpファイルです。

config.phpへの設定追加

--- /etc/websvn/config.php.orig	2011-01-25 10:45:15.000000000 +0900
+++ /etc/websvn/config.php	2011-01-25 11:02:04.000000000 +0900
@@ -1,4 +1,8 @@
 <?php
+
+// $config->addIgnoreRepoPathPattern("/my/"); // test purpose only
+$config->addIgnoreRepoPathPattern("/lost\+found/");
+
 // WebSVN - Subversion repository viewing via the web using PHP
 // Copyright (C) 2004-2006 Tim Armes
 //

configclass.phpの修正内容

--- /usr/share/websvn/include/configclass.php.orig	2011-01-25 10:10:19.000000000 +0900
+++ /usr/share/websvn/include/configclass.php	2011-01-25 11:00:23.000000000 +0900
@@ -133,6 +133,8 @@
    var $contentEnc;
    var $templatePath;
 
+   var $ignoreRepoPathPattern = array();
+
    // }}}
 
    // {{{ __construct($name, $svnName, $path, [$group, [$username, [$password]]])
@@ -1012,6 +1014,15 @@
 
    // }}}
 
+   // {{{ addIgnoreRepoPathPattern
+   //
+   // Set the ignore path pattern which works with the ParentPath function.
+   function addIgnoreRepoPathPattern($pattern)
+   {
+     $this->ignoreRepoPathPattern[] = $pattern;
+   }
+   // }}}
+
    // {{{ parentPath
    //
    // Automatically set up the repositories based on a parent path
@@ -1023,6 +1034,19 @@
          // For each file...
          while (false !== ($file = readdir($handle)))
          { 
+
+/* ignore some paths */
+$flag = false;
+foreach($this->ignoreRepoPathPattern as $reg) {
+  if ( preg_match($reg, $file) ) {
+    $flag = true;
+    break;
+  }
+}
+if ($flag) {
+  continue;
+}
+
             // That's also a non hidden directory
             if (is_dir($path.DIRECTORY_SEPARATOR.$file) && $file{0} != ".")
             {

さいごに

最新版の2.3系列のconfigclass.phpをみると、リポジトリの同一名での重複登録を省くための処理と、パターンにマッチした場合にのみリポジトリに加える処理も追加されていました。

addExcludedPath()を呼べばよさそうですが、指定するパスには絶対パスで指定する必要がありそうですが、現在では、Ubuntu 10.10の例にあるように、SVNディレクトリではないディレクトリがネガティブな影響を与える心配はないようです。

一括登録からみれば省くものは限られているケースが想定できて、想定外のパターンを含めてしまう可能性もあるので、バランスですが、一般的にはinclude, excludeは正規表現のパターンでも指定できたほうが便利だとは思いますが、とりあえずは古いWebSVNを動かしているが故のワークアラウンドでした。

2011/01/20

W3CのValidatorを通す方法 - Twitter/Facebookボタン編

W3CのMarkup Validation Service (validator.w3.org)はWebサイトの文書が規格に従っているかどうかを確認する事ができます。

有名なサイトであっても規格そのものに準拠していることは、このサービスを使えばあまり真剣に考えていないようすがみてとれます。

でもルールは守ってこそのルールですから、ここはどうにかしてValidatorサービスを通すようにしてみました。

今回使っている定義はXMTML/RDFaですが、XHTML系列であれば同じだと思います。

お題: Twitter/FacebookのボタンをWebサイトに追加する

作成した郵便番号の検索システムやらにTwitter/Facebookのボタンを追加しようとして、公式ガイドをみたところ見たことのないタグの使い方をしていました。

ちなみにボタンを追加するためのTwitterの公式ガイドは「Resources → Tweet Button」で、Facebookの方は「Like Button」にガイドがあります。

XHTMLではiframeタグが使えずにObjectタグを使うなどの変更が必要だったりすることは知られていますが、今回はその範囲を越えています。

スマートではないですが、規格に準拠しないところはJavaScriptを使って動的に作成することにしました。

Twitterボタンを設置する

オリジナルのコードは次のようになっています。

twitter.comに指示されたボタンを埋め込むためのHTML断片

<a 
  href="http://twitter.com/share"
  class="twitter-share-button" 
  data-count="vertical"
  data-via="YasuhiroABE">Tweet</a>
  <script type="text/javascript" src="http://platform.twitter.com/widgets.js"></script>
課題

問題はdata-countなどの属性が標準では定義されていないところです。

この部分をJavaScriptを使って追加してあげることにしました。

変更したコード

..
<script src="/js/jquery.min.js" type="text/javascript"></script>
<script src="http://platform.twitter.com/widgets.js" type="text/javascript" charset="utf-8"></script>
..
<script type="text/javascript"><!--
  jQuery(document).ready(function($){
    $("a.twitter-share-button").attr("data-url","http://www.yadiary.net/postal/");
    $("a.twitter-share-button").attr("data-text", "CouchDBを使用した郵便番号検索 @VPS");
    $("a.twitter-share-button").attr("data-via", "YasuhiroABE");
    $("a.twitter-share-button").attr("data-count", "horizontal");
    $("a.twitter-share-button").attr("data-lang", "ja");
  }
--></script>
...
<!-- twitter button --> 
<a href="http://twitter.com/share" class="twitter-share-button">Tweet</a> 
...

Facebookボタンを追加する

ボタンを追加する方法にはiframeタグを使う方法と、FBMLを使う方法が選択可能です。

TwitterボタンではjQueryを使用したので、JavaScriptを使ってみます。

facebookから提示されたボタンを埋め込むためのHTML断片

<script src="http://connect.facebook.net/en_US/all.js#xfbml=1"></script>
<fb:like href="http://www.yadiary.net/postal" show_faces="true" width="60"></fb:like>
課題

fbタグが未定義なので、xmlnsでnamespaceを追加しています。 これは実際に定義されている必要はないので、他にも使われているらしい"http://www.facebook.com/2008/fbml"を使いました。

修正した

<html xml:lang="ja"
  ...
  xmlns:fb="http://www.facebook.com/2008/fbml"
> 
<head>
...
  <script src="/js/jquery.min.js" type="text/javascript"></script> 
  <script type="text/javascript"><!--
  jQuery(document).ready(function($){
    /* for facebook button */
    var fbtag = document.createElement('fb:like');
    fbtag.setAttribute("href","http://www.yadiary.net/postal/");
    fbtag.setAttribute("layout", "button_count");
    fbtag.setAttribute("show_faces","true");
    fbtag.setAttribute("width", "80");
    document.getElementById("facebook-button").appendChild(fbtag);
  });
  --></script>
...
</head>
<body>
...
  <script type="text/javascript" src="http://connect.facebook.net/en_US/all.js#xfbml=1"></script> 
  <span id="facebook-button"></span> 
...
</body>
...

今回jQueryを使っているのは、「既に使っているから」という以外の理由はありません。 コード自体はjQueryには依存していないはずです。

まとめ

はたしてこの方法が妥当なのか微妙ですが、どちらのボタンもJavaScriptを有効にしていないと動かないので、タグをJavaScriptでレンダリングしても良いのかなぁと自分を納得させています。

なんにしても、こんな方法で標準に準拠しないようなタグを使うことも可能です。

結果としてValidatorサービスでエラーなしにする事ができて満足しています。

2010/12/15

CouchDB: group_numrows拡張のパフォーマンス

修正したコードを使ってVMWare上のCouchDBサーバに対して、118,924件の郵便番号を数えた場合の速度を比較してみました。

元々のDBの構造はCSVファイルを1文書に変換したもので、詳細は 以前の記事にあります。

その他のmap/reduce関数の定義、データ取得用スクリプトは次のとおりです。

viewを作成するためのmap,reduce関数

json['views']['code']['map'] = <<-MAP
function(doc) {
  if(doc._id.length == 40) {
    emit(doc.code, 1)
  }
}
MAP
json['views'][label]['reduce'] = "_sum"

使用したRubyスクリプト

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-

require 'csv'
require 'couchdb'
require 'json'
require 'uri'

couch = Couch::Server.new("localhost","5984")

def show(couch, uri)
begin
  res = couch.get(URI.escape(uri))
  json = JSON.parse(res.body)
p json['group_numrows'] if json.has_key?('group_numrows')
p json['rows'].length if json.has_key?('rows')
rescue
  p $!
end
end
#uri = YaCouch::DBname + '/_design/all/_view/code?group=true'
#show(couch, uri)
uri = YaCouch::DBname + '/_design/all/_view/code?group=true&group_numrows=true'
show(couch, uri)

これにtimeコマンドを使って実行速度を測ってみました。

VMWareを使うとディスクアクセスは特にホストOSのファイルキャッシュの影響を強く受ける傾向があると感じているので、何回か両方のスクリプトを実行した後に計測を始め、各2回目の結果を載せています。

Array.lengthを使って重複を取り除いたキーの数を数える方法

配列の数を数え上げるわけですが、各要素は、 {"key"=>"9998524", "value"=>1}、のような形式になっています。

118924

real	0m8.183s
user	0m4.188s
sys	0m3.812s

group_numrowsパラメータでCouchDBから取得する方法

この戻り値は単純で、クライアントが受け取るのは、 {"group_numrows":"118924"}の一行だけです。 スクリプトの最後の4行のコメントを変更して、同じように実行しています。

"118924"

real	0m0.952s
user	0m0.132s
sys	0m0.052s

Array.lengthを計算しない、JSON.parseを実行しない場合の速度

スクリプトをちょっと変更して、res = couch.get(URI.escape(uri))行の次にreturnを挿入して、すぐに戻るようにしてみました。

real	0m7.796s
user	0m3.784s
sys	0m3.836s

今回の環境では、ほとんどネットワークトラフィックの影響が時間に大きな影響を与えていて、JSON.parse()自体が時間を取っているわけではない事がわかります。

まとめ

この後に実環境でも試しましたが、おおむね同じような結果になりました。

ただし、group_numrowsを使った場合の実時間が最大1.4[s]程度、配列を取得する場合の時間が最小5.7[s]程度となり、その差は縮まっています。

erlangの軽量プロセスについて、もう少し勉強して何か不備はないか確認しようと思います。

2010/12/14

CouchDBでSELECT COUNT(DISTINCT ...)をするために必要なこと

CouchDBを使うためのネタとして 郵便番号検索 (http://www.yadiary.net/postal/)を作成しています

ここで入力をサポートするためにjQueryプラグインのFlexBoxから、候補を検索するためのQueryを投げて、CouchDBから候補となるkeyを取り出しています。

このFlexBoxは必要に応じてリクエストを投げていて、全件数を内部的に持つことはしていません。

町村名、その他地名をすべて数え上げた時に9万5千件の先頭10個が表示されている画面イメージ

この画像ではFlexBoxに表示されるべき候補が9万5千件あって、その先頭10件が表示されている様子を示しています。 この数字は、各ページの先頭に編集中の文字列が追加されているので、実数からページ数分だけ水増しされています。

この9万5千件という数字は、FlexBoxからの10件毎のQueryへの返信に加える必要があります。

つまり通常のCouchDBで普通に作成すると、毎回の問い合わせの返信内容は10件の内容なのに、全件数を追加するために、内部的にはこの9万5千件のデータを取得して件数の数字だけを作る事になります。

もちろんサーバ側で9万5千という数字をキャッシュする事は可能で、今回も9万5千件という数字もFastCGIの起動時に計算して保持しています。

他の数字についてもキャッシュする事は可能です。 しかし、今回はホスティング環境(DTIのVPS - Entry)でのメモリの制約があります。

RubyスクリプトでHashオブジェクトによる試作では、FastCGI RubyスクリプトのResident Memoryのサイズ上昇が許容範囲を越えていました。

そこでCouchDBだけに負荷を押し付けて、件数を返す機能を追加することにしました。

前書きの後の前書き 〜 技術的背景の説明

CouchDBに関連するドキュメントでは、いわゆる重複を取り除いた結果を取り出すためのSQLでいうところの SELECT DISTINCT に相当する方法として、VIEWについてmap/reduceの両関数を定義して、 group=trueを引数に加える方法を紹介しています。

この時のreduce関数は組み込みの"_sum"や"_count"にするのが一般的でしょう。

大抵はこれで問題ありませんが、SELECT DISTINCTした結果が、それでも比較的大きかった場合に、その全数を数え上げるための方法が問題になります。

ここでは一度JSONをHashオブジェクト等に変換して、その長さを求めることを考えます。

クライアント例:/examples/_design/allに、View名allを設定している場合に、allの結果を数え上げる


@couth = Couch::Server.new("localhost", "5984")
uri = '/examples/_design/all/_view/all?group=true'
json = JSON.parse(@couch.get(URI.escape(uri)).body)
printf "num of distinct rows: %d\n", json['rows'].length
num of distinct rows: 1039

この時にクライアント側のHashオブジェクト(json)は大量のメモリを消費します。 戻り値(@couch.get(...).body)をテキストとしてパースする事もできますが、それでも、ネットワーク帯域とレイテンシの観点からみて、大量のStringオブジェクトを経由する処理は効率的とはいえません。

今回はSQLでいうところの SELECT COUNT(DISTINCT field) FROM tableに相当する処理をCouchDBにさせてみる事を考えました。

まず結論から

今回の方法は軽量プロセスを生成して、Group化された結果(e.x. {"key"=>"三重県", "value"=>2473})を表示する部分で回数だけを軽量プロセスにカウントさせて、最後にその結果だけを受け取って軽量プロセスを終了させる方法をとりました。

タイムアウトやらのエラー処理は十分ではないですし、linkしていないためにプロセスが滞留する可能性もあるかもしれません。

結果はおもしろくなりましたが、かなりad-hocなパッチになっている事はご理解ください。

また軽量プロセスのプログラミングについては、 Erlang Worldを参考にさせて頂き、ほぼその内容に依っています。

基本事項の確認 - group=trueな時のCouchDBの内部動作

CouchDBへViewを設定すると、保持しているドキュメントの一部をキーとしてまとめて表示したり(map関数)、そのまとめたドキュメントの数を数えたり(reduce関数)することができます。

さらにgroup=trueを追加してリクエストを投げると、そのキーの重複を排除して表示することができます。

しかし、この重複を排除したキーの合計数を取り出す仕組みがないために、今回のようにメモリが潤沢でなかったり、reduceしてもまだ結果が大きい場合に問題が起こります。

CouchDBがgroup=true時にやっていること、と解決へのアプローチ

内部での処理は、ほぼ apache-couchdb-1.0.1/src/couchdb/couch_httpd_view.erl で完結しています。

先頭からいくつかの関数の連鎖を通って、send_json_reduce_row(Resp, {Key, Value}, RowFront)関数が出力する文字列(e.x. {"key"=>"三重県", "value"=>2473})を作っています。

send_json_reduce_row関数

send_json_reduce_row(Resp, {Key, Value}, RowFront) ->
    send_chunk(Resp, RowFront ++ ?JSON_ENCODE({[{key, Key}, {value, Value}]})),
    {ok, ",\r\n"}.

本質的にはこの関数を呼び出しているDatabase Engine部分に手を入れ、件数をカウントさせて、関数の戻り値に入れれば良さそうですが、いまのsend_json_reduce_rowの引数をみると、戻り値を受けられるような余地はありません。

内部的には出力を素早く行なうための機能に特化しているようにみえて、今回は、このsend_json_reduce_row関数が呼び出された回数を数えることにしました。

内部的にグローバル変数のようなものを持たせるわけにはいかないので、処理の前半で軽量プロセスを作成して、その後に呼ばれる関数引数を増やしてそのPidを適当な処理まで渡しています。

変数"Pid"に注目すればコードを追うのは簡単だと思います。パッチ本体とファイルへのリンクはこの記事の最後に載せました。

使い方 - 追加したオプションパラメータ

今回はgroup=trueと併用する group_numrowsというパラメータを増やしました。

例えば 郵便番号検索Databaseに入っている都道府県(pref)について、group=trueをした場合の件数(47)を数えると次のようになります。

$ curl -u reader:xxxxxx 'http://localhost:5984/postal/_design/all/_view/pref?group=true&group_numrows=true'

この出力は次の通りです。

{"group_numrows":"47"}

group_numrows=falseとした時の出力(の一部)は次の通りです。 "value"の数字は郵便番号DBの全レコード12万件中の何件分かを表していることになります。

{"rows":[
{"key":"\u4e09\u91cd\u770c","value":2473},
{"key":"\u4eac\u90fd\u5e9c","value":6658},
... ## 47都道府県分のデータが続く 

さらに郵便番号的に、第二フィールドの市区郡(city)の全数はいくつあるのか数えると…

$ curl -u reader:xxxxxx 'http://localhost:5984/postal/_design/all/_view/city?group=true&group_numrows=true'

この出力は次の通りです。

{"group_numrows":"1899"}

これが役に立つのはgroup=trueで返される文字列が、その環境では大き過ぎる場合です。

CouchDB内部で節約できている処理は出力用文字列を生成する部分だけですから、何かB-Treeをトリッキーな方法でtraverseしているわけではありません。

この他のアプロートとしては、RDBMSが中間結果を保持する一時テーブルのように、あらかじめオリジナルの文書群から、中間処理用の文書群を生成しておくこともできるはずです。

まとめ

SQLでいうところのSELECT COUNT(DISTINCT field)が使えないのは、CouchDBのskip, limitパラメータの威力を弱めてしまっていると思います。

jQueryプラグインのFlexBoxとCouchDBは組み合せると、かなり大きなデータも扱う事ができそうだという事が実感できました。

次はFlexBoxのcache更新のタイミングが問題でしょうか…。

Appendix. CouchDB 1.0.1用パッチ

diff -ur apache-couchdb-1.0.1.orig/src/couchdb/couch_db.hrl apache-couchdb-1.0.1/src/couchdb/couch_db.hrl

2010


--- apache-couchdb-1.0.1.orig/src/couchdb/couch_db.hrl	2010-07-20 07:59:53.000000000 +0900
+++ apache-couchdb-1.0.1/src/couchdb/couch_db.hrl	2010-12-14 09:54:37.000000000 +0900
@@ -190,6 +190,7 @@
     skip = 0,
 
     group_level = 0,
+    group_numrows = false,
 
     view_type = nil,
     include_docs = false,
diff -ur apache-couchdb-1.0.1.orig/src/couchdb/couch_httpd_view.erl apache-couchdb-1.0.1/src/couchdb/couch_httpd_view.erl
--- apache-couchdb-1.0.1.orig/src/couchdb/couch_httpd_view.erl	2010-08-08 11:25:40.000000000 +0900
+++ apache-couchdb-1.0.1/src/couchdb/couch_httpd_view.erl	2010-12-14 10:08:16.000000000 +0900
@@ -155,15 +155,22 @@
         group_level = GroupLevel
     } = QueryArgs,
     CurrentEtag = view_group_etag(Group, Db),
+    Pid = case get_group_numrows_type(Req) of 
+            true -> spawn(fun() -> group_numrows_server() end);
+              _  -> false
+          end,
     couch_httpd:etag_respond(Req, CurrentEtag, fun() ->
         {ok, GroupRowsFun, RespFun} = make_reduce_fold_funs(Req, GroupLevel,
                 QueryArgs, CurrentEtag, Group#group.current_seq,
-                #reduce_fold_helper_funs{}),
+                #reduce_fold_helper_funs{}, Pid),
         FoldAccInit = {Limit, Skip, undefined, []},
         {ok, {_, _, Resp, _}} = couch_view:fold_reduce(View,
                 RespFun, FoldAccInit, [{key_group_fun, GroupRowsFun} |
                 make_key_options(QueryArgs)]),
-        finish_reduce_fold(Req, Resp)
+        case get_group_numrows_type(Req) of
+          true -> finish_reduce_fold(Req, Resp, [], Pid);
+             _ -> finish_reduce_fold(Req, Resp)
+        end
     end);
 
 output_reduce_view(Req, Db, View, Group, QueryArgs, Keys) ->
@@ -173,10 +180,14 @@
         group_level = GroupLevel
     } = QueryArgs,
     CurrentEtag = view_group_etag(Group, Db, Keys),
+    Pid = case get_group_numrows_type(Req) of 
+            true -> spawn(fun() -> group_numrows_server() end);
+              _  -> false
+          end,
     couch_httpd:etag_respond(Req, CurrentEtag, fun() ->
         {ok, GroupRowsFun, RespFun} = make_reduce_fold_funs(Req, GroupLevel,
                 QueryArgs, CurrentEtag, Group#group.current_seq,
-                #reduce_fold_helper_funs{}),
+                #reduce_fold_helper_funs{}, Pid),
         {Resp, _RedAcc3} = lists:foldl(
             fun(Key, {Resp, RedAcc}) ->
                 % run the reduce once for each key in keys, with limit etc
@@ -190,7 +201,10 @@
                 {Resp2, RedAcc2}
             end,
         {undefined, []}, Keys), % Start with no comma
-        finish_reduce_fold(Req, Resp, [{update_seq,Group#group.current_seq}])
+        case get_group_numrows_type(Req) of
+          true -> finish_reduce_fold(Req, Resp, [{update_seq,Group#group.current_seq}], Pid);
+             _ -> finish_reduce_fold(Req, Resp, [{update_seq,Group#group.current_seq}])
+          end
     end).
 
 reverse_key_default(?MIN_STR) -> ?MAX_STR;
@@ -203,6 +217,9 @@
 get_reduce_type(Req) ->
     list_to_existing_atom(couch_httpd:qs_value(Req, "reduce", "true")).
 
+get_group_numrows_type(Req) ->
+    list_to_existing_atom(couch_httpd:qs_value(Req, "group_numrows", "false")).
+
 load_view(Req, Db, {ViewDesignId, ViewName}, Keys) ->
     Stale = get_stale_type(Req),
     Reduce = get_reduce_type(Req),
@@ -303,6 +320,8 @@
     [{reduce, parse_bool_param(Value)}];
 parse_view_param("include_docs", Value) ->
     [{include_docs, parse_bool_param(Value)}];
+parse_view_param("group_numrows", Value) ->
+    [{group_numrows, parse_bool_param(Value)}];
 parse_view_param("list", Value) ->
     [{list, ?l2b(Value)}];
 parse_view_param("callback", _) ->
@@ -385,6 +404,8 @@
 % Use the view_query_args record's default value
 validate_view_query(include_docs, _Value, Args) ->
     Args;
+validate_view_query(group_numrows, _Value, Args) ->
+    Args;
 validate_view_query(extra, _Value, Args) ->
     Args.
 
@@ -393,7 +414,7 @@
         start_response = StartRespFun,
         send_row = SendRowFun,
         reduce_count = ReduceCountFun
-    } = apply_default_helper_funs(HelperFuns),
+    } = apply_default_helper_funs(HelperFuns, Req, Req),
 
     #view_query_args{
         include_docs = IncludeDocs
@@ -425,10 +446,13 @@
     end.
 
 make_reduce_fold_funs(Req, GroupLevel, _QueryArgs, Etag, UpdateSeq, HelperFuns) ->
+  make_reduce_fold_funs(Req, GroupLevel, _QueryArgs, Etag, UpdateSeq, HelperFuns, nil).
+
+make_reduce_fold_funs(Req, GroupLevel, _QueryArgs, Etag, UpdateSeq, HelperFuns, Pid) ->
     #reduce_fold_helper_funs{
         start_response = StartRespFun,
         send_row = SendRowFun
-    } = apply_default_helper_funs(HelperFuns),
+    } = apply_default_helper_funs(HelperFuns, Req, Pid),
 
     GroupRowsFun =
         fun({_Key1,_}, {_Key2,_}) when GroupLevel == 0 ->
@@ -488,7 +512,7 @@
         #view_fold_helper_funs{
             start_response = StartResp,
             send_row = SendRow
-        }=Helpers) ->
+        }=Helpers, _Req, _Pid) ->
     StartResp2 = case StartResp of
     undefined -> fun json_view_start_resp/6;
     _ -> StartResp
@@ -504,19 +528,21 @@
         send_row = SendRow2
     };
 
-
 apply_default_helper_funs(
         #reduce_fold_helper_funs{
             start_response = StartResp,
             send_row = SendRow
-        }=Helpers) ->
+        }=Helpers, Req, Pid) ->
     StartResp2 = case StartResp of
     undefined -> fun json_reduce_start_resp/4;
     _ -> StartResp
     end,
 
     SendRow2 = case SendRow of
-    undefined -> fun send_json_reduce_row/3;
+    undefined -> case get_group_numrows_type(Req) of
+                   true -> gen_send_json_reduce_row(Pid);
+                      _ -> fun send_json_reduce_row/3
+                 end;
     _ -> SendRow
     end,
 
@@ -586,6 +612,24 @@
     send_chunk(Resp, RowFront ++ ?JSON_ENCODE({[{key, Key}, {value, Value}]})),
     {ok, ",\r\n"}.
 
+gen_send_json_reduce_row(Pid) ->
+  fun(_Req, _KV, _RowFront) ->
+    Pid ! {countup},
+    {ok, ",\r\n"}
+  end.
+
+group_numrows_server() ->
+  group_numrows_server(0).
+group_numrows_server(X) ->
+  receive
+    {status, From} ->
+      From ! X, group_numrows_server(X);
+    {countup} ->
+      group_numrows_server(X+1);
+    {stop, From} ->
+      From ! X
+  end.
+
 view_group_etag(Group, Db) ->
     view_group_etag(Group, Db, nil).
 
@@ -651,6 +695,25 @@
 finish_reduce_fold(Req, Resp) ->
     finish_reduce_fold(Req, Resp, []).
 
+get_group_numrows_final_results(Pid) ->
+  Pid ! {stop, self()},
+  receive
+    X -> X
+  end.
+
+finish_reduce_fold(Req, Resp, Fields, Pid) ->
+    case Resp of
+    undefined ->
+        send_json(Req, 200, {[
+            {rows, []},
+            {group_numrows, 0}
+        ] ++ Fields});
+    Resp ->
+        X = get_group_numrows_final_results(Pid),
+        send_chunk(Resp, "{\"group_numrows\":\"" ++ integer_to_list(X) ++ "\"}"),
+        end_json_response(Resp)
+    end.
+ 
 finish_reduce_fold(Req, Resp, Fields) ->
     case Resp of
     undefined ->