Google 日本語入力 Cloud API を利用してiPadのSafariでも動作する日本語ソフトウェアキーボードを作ってみた

最近になってjQueryの使い方を覚えたので、忘れないうちに何かを作りたいなぁと思っていたところ、ちょうどGoogle CGI API for Japanese Inputというのが公開されたので、これを使ったソフトウェアキーボードを作ってみました。

f:id:mdgw:20101012070651p:image:w384

実用性は低いですが、所要時間1〜1.5日くらいでとりあえず動くようになりました。実物はこちら

最初はiPadでフリック入力ができると面白いんじゃないかと思って作り始めたのですが、フリック入力だとUIを作るのが面倒技術的に悩ましい点があったので、単純な五十音キーから入力になっています。*1これはこれで、少しだけ新鮮な感覚が味わえるのではないかなと思っています。(追記:結局フリック入力を実現しました。)

技術的に特別なことは何もないのですが、1点だけ、iPadに対応させるための工夫として、テキストフィールドにフォーカスされた際にblurを呼び出してフォーカスを外すということをしています。テキストフィールドにフォーカスされた際、本来はデフォルトのキーボードが起動するのですが、即座にフォーカスを外すことでデフォルトのキーボードを消すことができます。*2

ただし、フォーカスを外すことで、テキストの範囲選択等の編集操作が出来なくなるという問題が発生します。このままでは実用性に難があるのですが、今のところこの問題は未解決です。恐らくテキストフィールド内の文字列の編集操作も実装することで解決できるような気がしていますが、そこまでは実装していません。また現在のものはキーボードの表示位置が変になったり、スペースが入力できなかったり、文節区切りの変更が出来なかったりしますが、とりあえずのコンセプトモデルなのでご了承下さい。

ちなみにGoogle CGI API for Japanese Input (日本語入力 Cloud API?)JSONPに未対応、Access-Control-Allow-Originヘッダーが付与されていないため、クロスドメインでの利用ができません。そのため、Google CGI for Japanese Input Proxyというサービスを利用させて頂いています。クロスドメインでの利用への対応については、公式フォーラムの方で Google CGI API for Japanese Input」のクロスドメイン対応要望 として挙がっているのですが、今のところ反応はないようです*3

*1:後で知りましたがiOS4.2で実際に50音キーが導入されるようです。でもフリックは… http://d.hatena.ne.jp/white-apple/20100930/1285838269

*2:iPhone ではダメだったので、iPadもiOSのアップデートでダメになったりして…と思ったけど単なるバグでiPhoneでも動きました。

*3:発表時の詳細を知らないのですが、AJAX APIじゃなくてCGI APIという名称なのは、JavaScriptからの直接利用はサポートしないという方針だったりするのでしょうか?

Java (.javaファイル) のみで利用可能なDB用ライブラリを作ってみた

以前Wicketを使っていた際は、データベースの操作にHibernate(JPA)を利用していたのですが、今一つ気に入らない部分がありました。下記のような部分です。

  • 設定をXMLファイルで書かないといけない。
  • テーブル定義とマッピング定義を分離できない。
  • カラム名等をテキストリテラルで指定する必要がある。

別のO/R Mappingライブラリも探してみたのですが、プリプロセッサでの処理が必要となったり、機能的にいろいろと制限があったり、今一つしっくりとくるものがありませんでした。

JDBCを直接使ったりDbUtils等を利用する方法も考えられるのですが、SQLを書かなければいけない、DBごとの差違の吸収が大変になる等の別の問題が出てきてしまいます。

そこで、これらの不満点を解消するために、データベース操作用のライブラリを自作してみました。ライブラリ開発の方針は下記のようなものです。

  • Java (.javaファイル)のみで利用可能とする。
  • テーブルの作成・更新もJavaから操作可能とする。(プリプロセッサによるDDLの生成、事前のテーブル作成を不要にする)
  • 文字リテラルをなるべく使わなくても良くする。
  • SQLで表現できることはなるべく出来るようにする。

開発力のなさにより、今のペースだと永遠に完成しそうにないので、ひとまず動く状態になったものをProof of conceptとして公開してみました。構成としては、DbUtils + Criteria + Javaクラスでのスキーマ定義という感じになっています。

plaintable - Simple database definition and manipulation library for Java - Google Project Hosting

簡単な使い方を以下に説明します。

テーブル定義

テーブル定義はJavaクラスにより行います。テーブル定義用クラスはSerializableを実装する必要があります。また@Tableアノテーションを付与する必要があります。@Tableのname属性はデータベース上に作成される実際のテーブル名となります。

テーブルのカラムは public static なフィールドとして定義します。各行には自動生成の主キーが必ず付与され、それに対応するSyntheticKeyは必須となります。それ以外に、実装上の都合でSchemaも必須となります。

@Table(name="User")    // required
public class UserTable implements Serializable {

        public static Schema schema;    // required

        public static SyntheticKey id;  // required

        @Attribute(indexed=true, unique=true)
        public static CharAttribute username;

        public static CharAttribute password;

        @Attribute(length=128, nullable=true)
        public static CharAttribute email;

        @Attribute(nullable=true)
        public static IntegerAttribute age;
}

初期化

データベース操作に利用するDatabaseManagerをDatabaseManagerFactory経由で生成します。DatabaseManagerFactoryの内部では、データベースの種類を自動判別します。何種類かのRDBMSを判別するようになっていますが、現在のところMySQLでしか動作を試していません。

        DataSource dataSource = getDataSource();    // RDBMS 固有の方法
        DatabaseManagerFactory factory = new DatabaseManagerFactory(dataSource);
        DatabaseManager databaseManager = factory.getDatabaseManager();

テーブルの登録と作成

ライブラリの利用を開始する前に、テーブル定義をSchemaManagerに登録する必要があります。DatabaseManagerからSchemaManagerを取得し、manageメソッドでテーブル定義クラスを指定します。

その後、syncメソッドでデータベース上に実際のテーブルを作成します。この時、前回からテーブル定義に変更がない場合は、何も実行されません。もしテーブル定義の変更有無に関わらず、テーブルの再作成を行いたい場合は、2番目の引数にtrueを指定します。

SynchronizeModeにALL_DROP_AND_CREATEを指定していると、テーブル定義の変更があった場合は、変更がないテーブルを含めて、全てのテーブルが一旦DROPされます。

syncメソッドの戻り値は、テーブルの更新が行われた場合にtrueとなります。そこで、この値がtrueの場合だけプログラムの初期データを挿入する等の用途に利用することが可能です。

        SchemaManager schemaManager = databaseManager.getSchemaManager();
        schemaManager.manage(UserTable.class);
        schemaManager.manage(MessageTable.class);

        boolean updated = schemaManager.sync(SynchronizeMode.ALL_DROP_AND_CREATE);
        if (updated) {  // テーブルが更新された場合は true
                // 初期データの挿入など…
        }

Select

データの取得・更新は、Sessionオブジェクトを利用して行います。SessionはDatabaseManagerのnewSessionメソッドにより生成します。Sessionの生成後、openメソッドによりトランザクションを開始します。AutoCommitはされませんので、更新等の場合は明示的なcommit, rollbackが必要です。

Selectの条件等はQueryオブジェクトを利用して指定します。Bools, Condsにある条件指定用のメソッドで生成したオブジェクトを、Sessionのaddメソッドで追加していきます。addメソッドで追加された条件は、自動的に AND 指定されたものとして扱われます。この例では、UserTableのusernameが"user1"と完全一致する条件を指定しています。ちなみに、カラム名の部分にスキーマ定義クラスのフィールド名を利用していますので、フィールド名の変更を行っても自動的に反映されます。

Selectの結果取得等は、RowHandlerインターフェイスにより行います。この例では、結果をListにして返すListHandlerを利用しています。ListHandlerはコンストラクタに渡されたMapperを利用して、Select結果の各行をオブジェクトに変換します。BeanMapperは、行をコンストラクタで指定されたJava Beanに変換するMapperです。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                Query query = new Query(UserTable.schema);
                query.add(Bools.exact(UserTable.username, "user1"));  // SQL: WHERE username LIKE 'user1'

                Mapper<User> mapper = new BeanMapper<User>(User.class);
                ListHandler<User> handler = new ListHandler<User>(mapper);

                session.select(query, handler);
                List<User> users = handler.getList();

        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

BeanMapperで利用するJava Bean には、@Mappedアノテーションを付与する必要があります。@Mappedのschema属性には、対応するテーブル定義クラスを指定します。現在のところ、カラム名と同じ名前のプロパティに対してのみ代入が行われます。

@Mapped(schema=UserTable.class)  // specify Table Definition class
public class User {

        private Long id;

        private String username;

        private String password;

        public Long getId() {
                return id;
        }

        public void setId(Long id) {
                this.id = id;
        }

        public String getUsername() {
                return username;
        }

        public void setUsername(String username) {
                this.username = username;
        }

        public String getPassword() {
                return password;
        }

        public void setPassword(String password) {
                this.password = password;
        }

}

Insert と Update

InsertとUpdateでは、RowProvider により行データを指定します。この例では、Java Beanから行データを取り出すBeanRowProviderを利用しています。
Insertでは、挿入された行に対応した主キーが自動生成され、その値が返されます。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                User user = new User();
                user.setUsername("user1");
                user.setPassword("passwd");

                BeanRowProvider<User> provider = new BeanRowProvider<User>(user);
                long id = session.insert(provider);  // 自動生成された主キー

                session.commit();
        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

Updateでは、更新する行の条件をRestrictionにより指定します。Restrictionに指定できる条件はQueryと同じです。idが分かっている行を1行だけ更新する場合は、Restrictionの代わりに、idを直接指定することも可能です。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                User user = new User();
                user.setUsername("user");
                user.setPassword("passwd");

                Restriction restriction = new Restriction();
                restriction.add(Bools.eq(UserTable.id, 2));  // SQL: WHERE id = 2

                BeanRowProvider<User> provider = new BeanRowProvider<User>(user);
                long count = session.update(provider, restriction);  // 更新された行の数
                //   OR
                // long count = session.update(provider, 2);  // simple way

                session.commit();
        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

BeanRowProviderで利用するBeanには、@Providedアノテーションを付与する必要があります。それ以外の部分は、@Mappedで利用したものと同一です。

@Mapped(schema=UserTable.class)
@Provided(schema=UserTable.class)  // mark as provider
public class User {

        private Long id;

        private String username;

        private String password;

        public Long getId() {
                return id;
        }

        public void setId(Long id) {
                this.id = id;
        }

        public String getUsername() {
                return username;
        }

        public void setUsername(String username) {
                this.username = username;
        }

        public String getPassword() {
                return password;
        }

        public void setPassword(String password) {
                this.password = password;
        }

}

Delete

Deleteでは、対象テーブルのschemaオブジェクトと、削除する行の条件をRestrictionで指定します。

        Session session = null;
        try {
                session = databaseManager.newSession();
                session.open();

                Restriction restriction = new Restriction();
                restriction.add(Bools.contain(UserTable.username, "user"));  // SQL: username LIKE '%user%'

                long count = session.delete(UserTable.schema, restriction);  // 削除された行の数

                session.commit();
        } catch (PlainTableException e) {
                // error handling
        } finally {
                if (session != null) {
                        session.close();
                }
        }

XenのdomUを別のホストにssh経由で移動する

今回はdomUをネットワーク経由で別ホストにお引越しする方法のメモです。イメージファイルを利用している場合は単にファイルをコピーするだけですが、LVMを利用している場合は少しだけ面倒になります。

Xenとしては共有ディスクを利用するのが正しい姿な気がしますが、Xenを使うような環境ではそんな豪華な装備がないことが多いので、今回はネットワーク経由でディスクをコピーしてみます。また二つのホストは遠隔地にある可能性もありますので、そのような場合も考慮してssh経由でコピーを行います。

下記の例では移動元ホストをxen00とし、移動先ホストをxen01としています。

まず下準備として、移動先ホスト上でパスフレーズなしのssh用カギを作成します。

[xen01]# ssh-keygen -t rsa -b 4096

次に移動元ホスト上でsshログイン用のユーザを作成します。わざわざユーザを作成しないでrootユーザを利用しても良いとは思いますが、念のために別ユーザにしています。

[xen00]# groupadd xenmgr
[xen00]# useradd -m -g xenmgr xenmgr

先ほど作成したssh用カギの公開鍵の方を作成したユーザの authorized_keys に設定します。
この時authorized_keysでは、安全のためcommandを指定して実行可能なコマンドを制限しておきます。

ここではxenmgrというスクリプトを実行するようにしました。またLVMのLVを読み込むためにはroot権限が必要になりますのでsudoを利用しています。

command="sudo /usr/local/sbin/xenmgr",from="192.168.1.2",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAA....  root@xen01

スクリプトをsudo経由で利用するために、visudoで設定を行います。

[xen00]# visudo

一つ目はrequirettyの設定をコメントアウトします。
これでssh経由で実行する際にsshが端末を割り当てなくても大丈夫になります。

...
#Defaults    requiretty
...

二つ目はssh経由で実行するコマンドをパスワードなしで実行可能にします。

Cmnd_Alias XENMGR_CMD = /usr/local/sbin/xenmgr
Host_Alias XENMGR_HOST = ALL
User_Alias XENMGR_USER = xenmgr

Defaults:XENMGR_USER env_keep = "SSH_ORIGINAL_COMMAND"
XENMGR_USER	XENMGR_HOST=(root)	NOPASSWD:XENMGR_CMD
...

xenmgrの中身は下記のような感じです。

#!/usr/bin/python

import re
import os
import sys

VGName = 'VolGroup00'
VMPrefix = 'vm'
XenConfigDir = '/etc/xen'
BlockSize = 8192

def main():
        if os.environ.has_key('SSH_ORIGINAL_COMMAND'):
                params = os.environ['SSH_ORIGINAL_COMMAND']
        else:
                params = ' '.join(sys.argv[1:len(sys.argv)])

        p = re.compile('^([a-zA-Z0-9_\-]+)\s+([a-zA-Z0-9_\-]+)$')
        m = p.match(params)
        if not m:
                sys.stderr.write("invalid parameters: " + params + "\n")
                return 1

        cmd = m.group(1)
        vmname = m.group(2)

        if cmd == 'dump':
                ret = command_dump(vmname)
        elif cmd == 'config':
                ret = command_config(vmname)
        else:
                sys.stderr.write("unknown command: " + cmd + "\n")
                return 2

        return ret

def command_dump(vmname):
        lvname = '/dev/%s/%s%s' % (VGName, VMPrefix, vmname)
        if not os.path.exists(lvname):
                sys.stderr.write("lv not found: " + lvname + "\n")
                return 3

        dd = 'dd if=%s bs=%d' % (lvname, BlockSize)
        os.system(dd)

def command_config(vmname):
        cfgfile = '%s/%s%s' % (XenConfigDir, VMPrefix, vmname)
        if not os.path.exists(cfgfile):
                sys.stderr.write("cfgfile not found: " + cfgfile + "\n")
                return 4

        cat = 'cat %s' % (cfgfile)
        os.system(cat)

if __name__ == '__main__':
        sys.exit(main())

commandで指定されたスクリプトには、環境変数SSH_ORIGINAL_COMMAND経由で、sshの実行時に指定された文字列が渡されます。スクリプトは指定された文字列をLV名として利用し、該当するLVの内容をddで標準出力に書き出すだけの単純なものです。

これで下準備は環境です。

実際にdomUを移動するには、最初に対象のdomUを停止します。

[xen00]# xm shutdown vm00

次に移動先にディスク領域を確保して、ssh経由でデータを書き込みます。

[xen01]# lvcreate -L 16G -n vm00 /dev/VolGroup00
[xen01]# ssh xenmgr@192.168.1.1 dump 00 > /dev/VolGroup00/vm00
[xen01]# ssh xenmgr@192.168.1.1 config 00 > /etc/xen/vm00

手元の環境(CPU:Celeron420, Mem:8GB, NIC:e1000e)で試したところ、16GBのLVに対してddのBlockSizeが4096の時に20分くらい、BlockSizeが8192の時に12分くらいかかりました。ディスク容量が大きくなると辛そうですが、そんなに頻繁に利用するものでもないので使えないこともなさそうです。

最後に移動先でdomUを起動させて無事に動作すれば完了です。ちなみに各ホストごとにVG名を別にしている場合は、事前に/etc/xen/vm00のdisk部分等を修正しておく必要があります。

[xen01]# xm create -c vm00

domUが送受信するパケットのIPアドレスを制限する

前回に引き続きCentOS 5でのXenに関するネタです。今回実現したいのは、例えばdomUIPアドレスが192.168.1.2だった場合、それ以外のIPを利用して送受信するのを防ぐことです。これはdomUのroot権限を持ったユーザが、IPアドレスを詐称して通信する場合などを想定しています。そのためdom0側で通信を制限する方法を考えます。

以下の例では、domU側のeth0をvif1.0としてブリッジに接続している場合を想定しています。またdomUIPアドレスは192.168.1.2とします。

この設定を実現する方法として、ここではブリッジの入出力部分であるvif1.0を出入りするパケットについて、IPアドレスが許可されたものかどうかを確認するようにします。

iptablesでブリッジのフィルタリングをする場合、physdevモジュールを利用することができます。そこでphysdevを利用して、vif1.0から出てくるパケットの送信元が192.168.1.2以外だった場合は破棄するようにします。

# iptables -I FORWARD 1 -m physdev --physdev-in vif1.0 --src ! 192.168.1.2/32 -j DROP

また念のため、vif1.0に入っていく方もフィルタリングしておきます。ただしこの設定ではブロードキャストやマルチキャストのパケットも破棄してしまいますので、必要な場合はそれらに対する許可設定も必要です。

# iptables -I FORWARD 1 -m physdev --physdev-out vif1.0 --dest ! 192.168.1.2/32 -j DROP

このままだと通信できない理由が後で分からなくなる可能性がありますので、実際に利用する場合はリミット付きでログを出力するように設定した方が良いかもしれません。

さてフィルタリングの設定自体はこれで完了です。しかしCentOS 5.2では、残念ながらこのままでは設定が反映されないようです。そこでいつものごとくGoogleしてみたところ、同じ症状の人を見つけることが出来ました(http://bugs.centos.org/view.php?id=2900)。

このページによると、Xenの起動時にnet.bridge.bridge-nf-call-iptablesという設定がOFFにされていることが原因のようです。そこでこの設定をONにしてみたところ無事に動作するようになりました。

# sysctl -w net.bridge.bridge-nf-call-iptables=1

このままでは再起動時に設定が消えてしまいますので、/etc/xen/scripts/xen-network-common.shの該当部分を書き換えておくと便利かもしれません。

そもそもなぜXenの起動時にこの設定をするようになったのか調べてみたところ、それらしきやり取りがメーリングリストにありました(http://lists.xensource.com/archives/html/xen-devel/2006-07/msg00240.html)。

流れとしては、domUから通信できない問題がある、実はiptablesの設定が影響していた、dom0を想定して設定しているiptablesの処理がブリッジにまで適用されるのは紛らわしい、ブリッジにはiptablesの設定が適用されないようにしよう、ということのようです。

本来はXen用のiptables設定をしてくれると良いのですが、複雑になるので根本的にOFFにしてしまったのですね。確かにこのままでは紛らわしいので、OFFにした場合と同じ感じになるように設定を追加してみます。環境により異なると思いますが、CentOS5.2のデフォルト設定の場合は下記を追加しておけば大丈夫なようです。

# iptables -I FORWARD 3 -i xenbr0 -o xenbr0 -j ACCEPT
# iptables -L
...
Chain FORWARD (policy ACCEPT)
target     prot opt source               destination
DROP       all  --  anywhere            !192.168.1.2      PHYSDEV match --physdev-out vif1.0
DROP       all  -- !192.168.1.2          anywhere         PHYSDEV match --physdev-in vif1.0
ACCEPT     all  --  anywhere             anywhere
RH-Firewall-1-INPUT  all  --  anywhere             anywhere
...

デフォルトではRH-Firewall-1-INPUTに飛ぶようになっていますので、今回の設定とRH-Firewall-1-INPUTに飛ぶ設定の間に挿入してやる必要があります。

CentOS 5 (Xen)でdomUのVG名をdom0から変更する

virt-installCentOS 5をテキストモードでインストールすると、LVMのVG名がVolGroup00に固定されてしまいます。VGが同じだと後で悲しい状況になることが多いので、インストール後に変更しておいた方が無難です。変更する方法はいくつかあると思うのですが、今回は全てをdom0側から実施する方法を調べてみました。

最初にdomUがインストールされているパーティションを kpartx で認識させ、vgrenameでVG名を変更します。

# kpartx -a /dev/VG_xen00_00/vm00
# vgrename VolGroup00 new_VG_name

VG名の変更自体はこれで完了です。ただこのままでは、VolGroup00を参照している部分があるためdomUを起動することができません。そこでdomUの領域をdom0側でマウントしてVolGroup00を参照している部分を書き変えます。

# vgchange -ay new_VG_name
# mount /dev/mapper/VG_xen00_00-root /mnt
# mount /dev/mapper/vm00p1 /mnt/boot

設定ファイルでVolGroup00となっている部分を new_VG_name に書き変えます。私の手元の環境では下記の3か所を書きかえれば大丈夫でした。

# sed -i 's/VolGroup00/new_VG_name/' /mnt/boot/grub/grub.conf
# sed -i 's/VolGroup00/new_VG_name/' /mnt/etc/fstab
# sed -i 's/VolGroup00/new_VG_name/' /mnt/etc/mtab

もう一か所、initrdの内部でもVolGroup00を参照している部分がありますのでそれを変更します。一度initrdの中身を展開し、initファイルの修正を行ってから固めます。

# mkdir /var/tmp/initrd
# cd /var/tmp/initrd
# zcat /mnt/boot/initrd-2.6.18-92.1.22.el5xen.img | cpio -i -c
# sed -i 's/VolGroup00/new_VG_name/' init
# find . | cpio --quiet -c -o | gzip -c > /mnt/boot/initrd-2.6.18-92.1.22.el5xen.img
# cd ..
# rm -rf initrd

後片付けをして終了です。

# umount /mnt/boot
# umount /mnt
# vgchange -an new_VG_name

本来はこれだけで動くはずなのですが、残念ながらこのままでは変更が反映されずdomUが起動しません。原因は良く分かっていないのですが、どうやらXenが起動時に使っているpygrubが古い情報を見てしまっているようです。

マシン自体を再起動すれば変更後の情報が反映されるのですが、せっかくの仮想マシンでその方法はちょっと悲しいものがあります。そこで色々と検索してみたところ、同じ症状の人を見つけることが出来ました(http://cormander.com/blog/2008/09/weird-pygrub-fs-cache-issue/)。

ここに書かれていたように、下記のようなコマンドを実行するとキャッシュが破棄されて変更後の情報がpygrubからも参照できるようです。

# sync && echo 1 > /proc/sys/vm/drop_caches

これでマシンを再起動することなく、domUのVG名変更が可能になります。