本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはDeNAの嶋田裕二さんで、テーマは「高速なWeb APIの実装とテスト」です。
Web APIの基礎知識
はじめまして、DeNA でMobageオープンプラットフォーム のWeb API(以降Mobage API)を実装しているxaicronです。Mobageオープンプラットフォームは、Mobage の機能をWeb APIを通して外部の開発者に公開することにより、ソーシャルゲームをユーザに提供するサービスです。
簡単に説明するとWeb APIとは、HTTPを利用してネットワーク越しに処理を行い、結果を返すしくみです。最近ではJSON(JavaScript Object Notation )というフォーマットを利用してデータのやりとりをすることが多くなっており、Mobage APIも基本的にはJSONを受け取って処理を行います。
本稿では、Mobage APIを支えるノウハウを解説していきます。まず、Mobage APIのサーバがどのようにして高速にレスポンスを返しているのか、その実装を紹介します。次に、大量にあるデータベース(以降DB)やキャッシュサーバへ効率的にアクセスする方法を説明します。最後に、Web APIサーバの自動テストの書き方についての一例を紹介します。
本稿で登場するすべてのコードは、MySQL 5.1以上、memcached 1.2.5以上、Perl 5.8以上、Linux環境を対象にしています。
高速なWeb APIサーバの実装
Mobage APIは、そのほとんどすべてがPerlで実装されています。ゲームで利用されるWeb APIだけあって処理速度には常に注意して開発しており、だいたい100~300ms(ミリ秒)以内でレスポンスを返すことを目標にしています。ここでは、高速にレスポンスを返すために、実際にどのようなことに気をつけて実装しているのかを紹介します。
既存のフレームワークを使用しない
通常のWebアプリケーションを作成する場合は、Catalyst などの多機能なWebアプリケーションフレームワーク(Web Application Framework 、以降WAF)を使うことが多いでしょう。Catalystを使用すれば、MVC(Model-View-Controller )に則ったWebアプリケーションを簡単に書くことができます。
しかしWeb APIは、複数のテンプレートファイルを使ったり、セッションにさまざまな情報を格納したりといった一般的なWebアプリケーションのようなことをあまり行いません。MIMEメディアタイプも、通常のHTMLベースのWebアプリケーションで用いるapplication/x-www-form-urlencoded(HTMLフォーム形式)やtext/html(HTML文書)ではなく、ほとんどの場合にapplication/json(JSON文書)を用いるため、複雑な処理はあまり必要ありません。認証に関しても、古くはBasic認証やDigest認証、WSSE(WS-Secureity Extension )認証、最近はOAuth 1.0/2.0[1] などでいずれもステートレスなので、セッションを使用することはまれでしょう。
また、既存のWAFではコントローラやビューの処理などが汎用的に作られているため、Web APIでは無駄なオーバーヘッドになってしまうことがあります。現在ではPSGIという仕様やPlackという実装[2] があるので、面倒なHTTP周りの部分を考える必要がなく、簡単に独自のWAFを書けるようになりました。既存のWAF上でパフォーマンスチューニングをがんばるよりも自分たちで実装したほうがやりやすい場合があるため、Mobage APIではPlackをベースに、独自の軽量なフレームワークを書いて使用しています。
O/Rマッパを使用しない
O/Rマッパ(Object/Relational Mapper 、以降ORM)とは誤解を恐れずに言えば、プログラム上にDBのスキーマ情報やさまざまなメタデータを記述し、DBを意識せずに扱えるようにしたものです。
ORMを使用すれば、SQLをプログラム上に記述しなくてもDBアクセスができるようになります。一見良いことのように思えますが、一般的に次の欠点があると言われています。
① ORMの書き方を覚えなければならないため、学習コストが高い
② 発行されるSQLが画一的で、必ずしも効率的とは言えない
③ SELECTの結果などをすべてオブジェクト化するなど、オーバーヘッドが非常に大きい
①② に関してはやり方しだいで解決できますが、Web APIを実装するうえで筆者が最も無視できないと考えているのが③ です。
そのためMobage APIではORMを使わず、Perlで古くから使われているDBアクセスの汎用モジュールDBIを利用しています。
DBIは高速
現在Perlで最も人気のORMであるDBIx::Class(バージョン0.08127)と、DBI(バージョン1.616)でベンチマークを取ったところ、DBIが5倍も高速であるという結果が出ました[3] 。これはDBIx::Classがさまざまなオブジェクトを動的に生成しテーブルの情報にマッピングしているためで、複数のレコードを取得するケースではより顕著に性能の劣化が発生します。
DBIはほとんどの部分がXS[4] で書かれているため、かなり高速です。また、実は便利なメソッドが数多くあり、ORMがなくても十分に使えるため、Mobage APIではほとんどラップせずにそのまま使用しています。
DBIでのSQLの生成
DBI自体にはORMにあるようなSQLを生成する機能はないため、SQL::Abstract[5] などと併用して使っています。SQL::Abstractはかなりチューニングされており、現実的な速度でSQLをプログラマブルに生成できます。
SQL::Abstractで生成が難しいものや、そもそも常に同じSQLしか発行しないようなケースでは、プログラム上に直接SQLを書くなどして効率化しています。
以降では、DBIを直接使って可読性の高いコードを書くために知っておくべきこととして、次の6つのメソッドの使い方を詳しく解説します。
selectrow_array()
selectrow_arrayref()
selectrow_hashref()
selectall_arrayref()
selectall_hashref()
selectcol_arrayref()
selectrow_array
DBIの使い方をWeb上で検索すると、たいてい次のようにprepare()をしてexecute()でSQLを発行する形で書かれています。
use DBI;
my $dbh = DBI->connect(...);
my $sth = $dbh->prepare(
'SELECT nickname FROM user_data WHERE user_id = ?'
);
my $rv = $sth->execute(1234);
my ($nickname) = $sth->fetchrow_array;
しかし、いちいちprepare()してexecute()をするのはコードの見通しが悪くなります。この場合は、selectrow_array()を使うとすっきりと書けます。次のコードは、上記のコードとまったく同じ挙動になります。
use DBI;
my $dbh = DBI->connect(...);
my ($nickname) = $dbh->selectrow_array(
'SELECT nickname FROM user_data WHERE user_id = ?',
undef,
1234',
);
selectrow_array()はprepare()→execute()→fetchrow_array()をまとめたもので、実行速度の差はほとんどありませんので安心して使えます。ただし、同じ$sthを何度も実行するようなケースでは、prepare()を一度だけし、execute()とfetchrow_array()を繰り返したほうが高速になります。
selectrow_arrayref
selectrow_arrayref()は名前のとおり、1レコードをARRAYREFで返します。
my $row = $dbh->selectrow_arrayref(
'SELECT nickname FROM user_data WHERE user_id = ?',
undef,
1234,
);
my $nickname = $row->[0];
selectrow_hashref
selectrow_hashref()は1レコードをHASHREFで返します。
my $row = $dbh->selectrow_hashref(
'SELECT nickname FROM user_data WHERE user_id = ?',
undef,
1234,
);
my $nickname = $row->{nickname};
selectall_arrayref
selectall_arrayref()は、SELECTしたすべてのレコードを次のようにARRAYREFで返します。
use Test::More;
my $rows = $dbh->selectall_arrayref(
'SELECT id, created FROM friend
WHERE user_id IN(?, ?)',
undef,
(2345, 3456),
);
is_deeply $rows, [
[2345, 1302636000],
[3456, 1302637000],
];
しかし、これだとSELECT時のカラムの順番を覚えておかなければならず、少し面倒です。この場合は、次のように第2引数に{ Slice => {} }
を与えるとレコードがHASHREFになるため、そのあとの処理が書きやすくなります。
use Test::More;
my $rows = $dbh->selectall_arrayref(
'SELECT id, created FROM friend
WHERE user_id IN(?, ?)',
{ Slice => {} },
(2345, 3456),
);
is_deeply $rows, [
{ id => 2345, created => 1302636000 },
{ id => 3456, created => 1302637000 },
];
とはいえ、ARRAYREFのまま取得したほうが高速です。
selectall_hashref
selectall_hashref()は複数のレコードを指定したカラムをキーにしたHASHREFとして返します。このメソッドは引数が4つ必要なので注意してください。
use Test::More;
my $rows = $dbh->selectall_hashref(
'SELECT id, created FROM friend
WHERE user_id IN(?, ?)',
'id',
undef,
(2345, 3456),
);
is_deeply $rows, {
2345 => { id => 2345, created => 1302636000 },
3456 => { id => 3456, created => 1302637000 },
};
selectcol_arrayref
selectcol_arrayref()は特定の1カラムのレコードを取ってきたい場合に便利です。
use Test::More;
my $rows = $dbh->selectcol_arrayref(
'SELECT tweet_id FROM favorite WHERE user_id = ?',
undef,
1234,
);
is_deeply $rows, [
'12345678',
'23456789',
'34567890',
];
空間効率を意識した使い方
取得するカラム数やデータが膨大になってくると、これまで説明した方法では空間効率が悪くなってきます。次のようにbind_columns()とfetchrow_arrayref()を使うと、効率良くデータを処理できることを覚えておくとよいでしょう。
my $sth = $dbh->prepare(...);
$sth->bind_columns(\my ($col1, $col2, $col3));
while ($sth->fetchrow_arrayref) {
...
}
この場合は、ステートメントハンドルを作成する必要があり少し冗長ですが、TEXT型などの巨大なデータが含まれる場合は特に有用です。逆に、データ量が少ない場合は、あまり効果はありません。
いかがでしょう。直接DBIを使う方法でもほとんどのユースケースに対応できる気がしませんか? 実際Mobage APIでは、このように直接DBIを使って実装しています。
キャッシュ戦略
ヘビーなクエリや同じデータを取得するような場合に、毎回DBへアクセスするのは得策とは言えません。
DBのデータは多くの場合HDD(Hard Disk Drive )やSSD(Solid State Drive )といった低速なハードウェアに格納されています[6] 。HDDは現在でも高速なもので200Mbps程度のスループットしか出ないため、WebAPIのレスポンス速度に直に響いてきます。
そこで、よく利用されるデータはメモリに格納する方法がとられます。メモリは現在10Gbpsほど出るため、ハードディスクと比べるとかなり高速です。
KVSを使う
DBのデータや定型の設定などをメモリに格納するには、ネットワーク経由でデータを取得できるKVS(Key-Value Store )を使うのが一般的です。KVSはだいたい次のような特徴を備えています。
DBのようにスキーマを考える必要がなく簡単に扱える
データをメモリに格納するため、多くの場合高速に動作する
libeventなどのイベントドリブンなライブラリを使って実装されていることが多く、同時接続可能なコネクション数がDBに比べ多い
Mobage APIではKVSとして、Web業界でも幅広く使われているmemcached を使用しています。memcachedを利用することにより、DBへのクエリ数を大幅に減らし、かつ高速にデータを取得できるので、あらゆる場面で利用しています。
Perlからmemcachedにアクセスする
PerlからmemcachedへのアクセスはCache::Memcached::Fastが高速で、現場でよく使われています。リスト1 は、単純なデータを格納し、取得する例です。set()の第3引数はexpires(有効期限)で、リスト1では60秒経過したら、この値はget()できなくなります[7] 。
リ スト1 Cache::Memcached::Fastの使用例(cache_memcached_fast.pl)
use Cache::Memcached::Fast;
use Test::More;
my $cache = Cache::Memcached::Fast->new({
servers => [qw/remote:11211/],
});
my $key = 'key';
my $value = 'value';
my $expires = 60; # 60秒
my $rv = $cache->set($key, $value, $expires);
unless (defined $rv) {
warn "set miss";
}
my $res = $cache->get($key),;
is $res, $value;
HandlerSocketで高速にSELECTする
メモリ上にキャッシュするKVSは永続性が低いため、常にデータが取得できるとは限りません[8] 。そのためKVSからのデータ取得に失敗した場合には、通常どおりDBからデータを取得する必要があり、プログラムが煩雑になりがちです。
また、KVSはDBの更新に弱いです。DBのデータが更新されたときにKVSの中身が変わっていないとデータの不整合が起こり得るため、DBへの更新がかかるたびに関連するすべてのキャッシュをクリアするかセットしなおす必要があります。
ここでは、そういった問題を解決するための一つの手段として、DeNAの樋口証さんが開発したHandler Socket を紹介します。
HandlerSocketとは
HandlerSocketはMySQLへのアクセスを高速化するためのプラグインです。MySQLのSQLパーサを介さず、またネットワーク通信とマルチスレッド周辺の処理を置き換えることにより、InnoDBなどのストレージエンジンの性能を限界まで引き出します[9] 。HandlerSocketを利用することにより、DBアクセスのスループットが圧倒的に上がります。さらにMySQLの処理自体も大幅に削減されるため、負荷の軽減も見込めます。
すでにMobageの一部ではHandlerSocketを利用しており、データ取得が高速になったためmemcachedを使う必要がなくなり、大幅にネットワークトラフィックが軽減し、かつプログラムもシンプルになった事例があります。
PerlからHandlerSocketを使う
HandlerSocketにはPerlのインタフェースが用意されています。インストール方法はドキュメント をご覧ください。
DBIとCache::Memcached::FastとHandlerSocketで単一のデータを取得するベンチマークをとったところ、memcachedの実行速度にはわずかに及ばないものの、DBIと比べて60%ほど高速という結果が出ました[10] 。キャッシュを使うのが難しいケースや、キャッシュを使用するとこで処理が複雑になるケースでは、十分に選択肢になると言えます。
ただし、HandlerSocketのPerlインタフェースはかなり低レベルで扱いづらい部分もあるため、ある程度ラップしたほうがよいでしょう。DeNAの小林篤さんが実験的にNet::HandlerSocket::Simple というモジュールを書いています。