Perl Hackers Hub

第9回高速なWeb APIの実装とテスト―Mobage APIを支えるノウハウ(2)

大量にあるサーバへのアクセスを効率的に扱う

Mobage APIでは、接続するDBサーバやmemcachedサーバなどが大量にあります。サーバが増えても、アプリケーションの変更は最低限にしたいものです。ここでは、複数のサーバへのアクセスを簡単に記述する方法を紹介します。

DBへのアクセスを隠蔽する

DBは一般的な、マスタ/スレーブ構成を採用しています。INSERT/UPDATE/DELETEのような更新系のクエリはマスタへ、SELECTなどの参照系のクエリはスレーブへいくようにしています。また、Sharding[11]をしているDBの系統もあります。

このように大量にあるDBへのアクセスを簡単に行うために、Mobage APIではDeNAの有澤高介さんが開発したDBIx::DBHResolver(現在のメンテナは同じくDeNAの山口徹さん)を利用してDBへのアクセス情報を隠蔽(いんぺい)しています。DBIx::DBHResolverを使うと、DBへのアクセス情報を設定ファイルから読み込み、データベースハンドルを自分で決めた名前で取得できますリスト2~3⁠。

リスト2 DBIx::DBHResolverの使用例(dbh_resolver.pl)
use DBIx::DBHResolver;
my $resolver = DBIx::DBHResolver->new;
$resolver->load('db_config.yaml'); …①
my $dbh1 = $resolver->connect('MASTER002'); …②
my $dbh2 = $resolver->connect('MASTER', 1234);…③
my $dbh3 = $resolver->connect('SLAVE', 2345); …④
...
リスト3 リスト2で読み込んでいる設定ファイル(db_cong.yaml)
---
clusters:
  MASTER:
    - MASTER001
    - MASTER002
  SLAVE:
    - SLAVE001
    - SLAVE002
connect_info:
  MASTER001:
    attrs:
      AutoCommit: 0
      RaiseError: 1
    dsn: dbi:mysql:dbname=test;host=master001;
    password: ~
    user: root
  SLAVE001:
    attrs:
      AutoCommit: 1
      RaiseError: 1
    dsn: dbi:mysql:dbname=test;host=slave001;
    password: ~
    user: root
...

リスト2 $resolver->load()はConfig::Anyを使っているのでYAMLYAML Ain't Markup Language以外でも読み込むことができます。リスト2 はMASTER002のデータベースハンドルを取得します。

リスト2 はShardingの例です。リスト3のclustersの部分がShardingの設定になります。MASTERがこのクラスタの名前で、MASTER001とMASTER002の2台が属しているという設定です。デフォルトでは、$resolver->connect()の第2引数の値で剰余演算をし、どのデータベースハンドルを使用するかを決めます[12]⁠。リスト2 では、第2引数の1234をMASTERに属しているDBの台数である2で割ると余りが0なため、MASTER001が選択されます。リスト2 では、第2引数の2345をSLAVEに属しているDBの台数である2で割ると余りが1となるため、SLAVE002が選択されます。

DBIx::DBHResolverを使うと、このように複数あるDBサーバへのアクセスを直感的に行えるようになります。

memcachedへのアクセスを隠蔽する

memcachedも用途によって複数のサーバ/クラスタに分かれています。こちらもDBと同じように、プログラムからはクラスタに名前を付けてアクセスできるしくみにしています。運用上、少し特殊な要件があるため、汎用的なモジュールにはしていませんが、基本的にはDBIx::DBHResolverと同じような感覚で設定を書けるようにしています。

自動テストのノウハウ

Web APIは入出力の形式がほぼ決まっているので、比較的、自動テストが書きやすいプログラムです。ここでは、特に重要なDBとmemcachedなどの外部装置を使ったテスト方法と、効率良くテストを走らせる方法について述べます。

MySQLを使ったテストの書き方

Mobage APIではDBにMySQLを使用しています。少しでもパフォーマンスを稼ぐためにMySQLの独自SQLを使用している個所も少なくないため、正しい挙動をするかどうか、自動テストでも実際にMySQLに接続して処理を走らせています。

開発環境にアクセスして、テストを行ってもよいのですが、DBに更新がかかるような処理が走ると、正確なテストデータの整合性がとれなくなります。そのため、毎回クリーンなMySQLを立ち上げて、そこに固定のテストデータを入れてテストを行うようにしています。

テストが起動するたびにクリーンなMySQLを立ち上げるには、Test::mysqldを使用します。Test::mysqldを使用すると次のようにテスト用のMySQLを簡単に起動できます。

use DBI;
use Test::mysqld;
use Test::More;

my $mysqld = Test::mysqld->new(my_cnf => {
    'skip-networking' => '', # no TCP socket
}) or plan skip_all => $Test::mysqld::errstr;

my $dbh = DBI->connect($mysqld->dsn(dbname => 'test'));

ここで起動したMySQLは$mysqldのスコープが抜けた段階で自動的に停止します。

当然、この状態ではDBやテーブルなどもなく、データも入っていません。そこでTest::Fixture::DBIを使用しMySQLに特定のデータをセットします。Test::Fixture::DBIを使うと、既存のDBからDB情報やデータを簡単に取得できます。DB情報の取得はmake_database_yaml.pl、テーブルのデータの取得はmake_database_yaml.plコマンドを使用します。

DBの情報はすべてのテストで共通で使えるため、t/schema配下にdb_name.yamlという名前で保存します。

$ make_database_yaml.pl \
    -d 'dbi:mysql:dbname=user;host=localhost' \
    -u root -o t/schema/user.yaml

また、テーブルのデータは、テスト対象のファイル名でディレクトリを作成し、その配下にtablename_fixture.yamlという名前で保存するようにしています。先ほどのt/model/user/get_friends.tというファイルに対するデータであればt/user/get_friends/friend_data_fixture.yamlなどです。以下は、userデータベースのfriendテーブルをYAMLファイルに落とし込む例です。

$ make_fixture_yaml.pl \
    -d 'dbi:mysql:dbname=user;host=localhost' \
    -u root -t friend_data -n user_id -n friend_id \
    -o t/user/get_friends/friend_data_fixture.yaml

詳しいオプションを知りたい方はそれぞれのコマンドで-hを付けて実行してください。

このように作成したデータを実際のテストコードでMySQLに読み込むために、リスト4のようなモジュールを書くと便利です。テスト専用のモジュールは慣例に従ってt/lib配下に置くとよいでしょう。これを使うと、リスト5のようにMySQLの立ち上げからデータの登録までを簡単に行えます。

リスト4テスト用のMySQLをセットアップするモジュール(t/lib/Test/MyApp/Fixture/DBI.pm)
package Test::MyApp::Fixture::DBI;
use strict;
use warnings;
use DBI;
use Test::mysqld;
use Test::Fixture::DBI
    qw(construct_database construct_fixture);
use DBIx::DBHResolver;
use Exporter 'import';

our @EXPORT = qw(start_mysql setup_database dbh);

sub start_mysql {
    my %config = @_;
    return Test::mysqld->new(my_cnf => +{
        'skip-networking' => '', %config,
    });
}

sub dbh {
    my ($dbname, $opts) = @_;
    DBI->connect(
        $mysqld->dsn(dbname => $dbname),
        'root',
        '',
        {
            AutoCommit => 1,
            RaiseError => 1,
            %$opts,
        },
    );
}

sub setup_database {
    my ($dbname, $fixtures) = @_;
    my $db_yaml = "t/schema/$dbname.yaml";

    my $dbh = dbh('mysql');
    $dbh->do("CREATE DATABASE IF NOT EXISTS $dbname");
    $dbh->do("USE $dbname");

    # データベースをsetup
    construct_database(
        dbh => $dbh,
        database => $db_yaml,
    );

    $dbh->{AutoCommit} = 0;

    # テーブルのデータを入れる
    for my $fixture (@$fixtures) {
        construct_fixture(
            dbh => $dbh,
            fixture => "$fixture",
        );
    }
}

1;
リスト5 Test::MyApp::Fixture::DBIの使用例(t/user/get_friends.t)
use lib 't/lib';
use Test::More;
use Path::Class qw/dir/;
use Test::MyApp::Fixture::DBI qw(
    start_mysql setup_database dbh
);
use MyApp::Model::User;
use MyApp::DB;

# $test_dirにはt/user/get_friendsが入る
my $test_dir = dir(__FILE__)->subdir('get_friends');

# MySQLを起動する
my $mysqld = start_mysql();

# fixtureを適用する
setup_database('friend', [
    $test_dir->file('friend_data_fixture.yaml'),
]);

# データベースハンドルを登録する
MyApp::DB->register(
    FRIEND_MASTER => dbh('friend', {AutoCommit => 0}),
    FRIEND_SLAVE => dbh('friend', {AutoCommit => 1}),
);

my $model = MyApp::Model::User->new;

sub test_get_friends {
    my %specs = @_;
    my ($input, $expects, $desc) = @_;

    subtest $desc => sub {
        # get_friendsは内部でfriend.friend_data
        # を参照している
        my $got = $model->get_friends($input);
        is_deeply $got, $expects;
    };
}

test_get_friends(...);
...
done_testing;

このように、Test::mysqldとTest::Fixture::DBIを利用すれば、常に固定のデータで自動テストを行うことができるようになります。だたし、スキーマの変更があった場合はすべてのデータを作りなおす必要があるため、変更に弱いテスト手法であるということは覚えておいたほうがよいでしょう。

memcachedを使ったテストの書き方

DBと同様にmemcachedもテストのたびに立ち上げて、データがある状態とない状態での挙動などをテストしたほうがよいでしょう。

memcachedをテストごとに立ち上げるためにはProc::GuardとTest::TCPを使用します。リスト6のようにmemcachedを立ち上げるモジュールを書いておくと便利でしょう。これを使うと、ローカル環境でmemcachedが起動し、$procがスコープを抜けた段階で自動的に停止します。テストでは、この起動したmemcachedにアクセスするしくみを用意しておくとよいでしょう。

リスト6 テ スト用のmemcachedを立ち上げるモジュール(t/lib/Test/MyApp/Memcached.pm)
package Test::MyApp::Memcached;
use Proc::Guard qw/proc_gurad/;
use Test::TCP qw/empty_port/;
use File::Which qw/which/;

sub start_memd {
    my $port = empty_port();
    my $proc = proc_guard(
        scalar(which 'memcached'), '-p', $port);
    wait_port($port);
    return $proc, $port;
}

このようにテストごとに新しいmemcachedを立ち上げることで、いつでもクリーンな状態でテストが行えます。もちろんこの方法を使えば、memcached以外のあらゆる外部プログラムの立ち上げが可能になります。

make testを高速化!

テストが増えると、すべてのテストを走らせるのに時間がかかるようになります。ここでは、make testを高速に処理させる方法を紹介します。

MySQLをあらかじめ立ち上げておく

Test::mysqldを使ったテストの場合、1つのテストごとにMySQLを立ち上げて終了させるため、DBのテストが増えてくると極端に遅くなります。そこでMobage APIでは、

  • 1つずつテストを走らせるときは通常どおりMySQLを立ち上げる
  • make testでまとめて実行する場合は、はじめの処理でMySQLを立ち上げておいて、すべてのテストが終わったら終了する

というようにしています。まず、リスト4で書いたTest::MyApp::Fixture::DBIのstart_mysql()を、リスト7のように、すでにMySQLが立ち上がっている場合は使い回すように書き換えます。

リスト7  Test::MyApp::Fixture::DBIの修正(t/lib/Test/MyApp/Fixture/DBI.pm)
package Test::MyApp::Fixture::DBI;
use JSON;
use DBI;
use Test::mysql;

our $SKIP_DROP_DB_MAP = {
    information_schema => 1,
    mysql => 1,
    test => 1,
};

sub start_mysql {
    my %config = @_;

    my $mysqld;
    if (my $json = $ENV{TEST_MYSQLD}) {…①
        my $obj = decode_json $json;
        $mysqld = bless $obj, 'Test::mysqld';…②
        cleanup($mysqld);…③
    }
    else {
        $mysqld = Test::mysqld->new(my_cnf => +{ ―┐
            'skip-networking' => '', %config,      …④
        });                              ―――――――――┘
    }

    return $mysqld;

}

sub cleanup {
    my ($mysqld) = @_;
    my $dbh = DBI->connect($mysqld->dsn, '', '' {
        AutoCommit => 1, RaiseError => 1,
    });

    my $rs = $dbh->selectall_hashref(
        'SHOW DATABASES', 'Database');
    for my $dbname (keys %$rs) {
        next if $SKIP_DROP_DB_MAP->{$dbname};
        $dbh->do("DROP DATABASE $dbname");
    }
}
...

の$ENV{TEST_MYSQLD}にはTest::mysqldのオブジェクトをJSON形式にした値が入っています。もしその値があればすでにMySQLが立ち上がっているものとして、でJSONの値からTest::mysqldのオブジェクトを復元します。そのあとで、DBの中身をすべて削除し、クリーンな状態に戻します。$ENV{TEST_MYSQLD}に値がなければ、で今までどおり新規にMySQLを立ち上げます。

make testのときにMySQLを立ち上げるには、拙作のModule::Install::TestTargetを使うと簡単に書くことができます。リスト8のようにdefault_test_target()という関数のrun_on_prepareにMySQLを起動するPerlスクリプトを指定すると、make testを走らせたときにリスト9が実行され、MySQLが起動します。

リスト8  Module::Install::TestTargetを使用し、make testをフックする(Makele.PL)
use inc::Module::Install;
use Module::Install::TestTarget;
...
default_test_target(
    includes => ['t/lib'],
    run_on_prepare => ['t/script/setup_mysqld.pl'],
);
...
リスト9 MySQLを起動し、$ENV{TEST_MYSQLD}を設定する(t/script/setup_mysqld.pl)
use Test::MyApp::Fixture::DBI;
use JSON;

$SIG{INT} = sub { CORE::exit 1 };
$mysqld = setup_mysqld();
$ENV{TEST_MYSQLD} = encode_json +{ %$mysqld };

これで、make testを実行したとき、$ENV{TEST_MYSQLD}にTest::mysqldのオブジェクトをJSONにしたものが入るので、テストコードを修正することなく起動しているMySQLを使うようになるため、高速にテストを走らせることができます。

できるだけテストを並列で実行する

DBのテストは速くなりましたが、ほかのテストはどうでしょうか? 実は、あまり知られていないのですが、$ENV{HARNESS_OPTIONS}にj数字という文字列を入れると、テストが並列で実行されるようになります。たとえばj4とすれば4つのテストが並列に実行されるので、現実に4倍とはいかないまでも、かなり高速にテストが終わるようになります[13]⁠。

この設定を有効にするには、リスト8をリスト10のように修正します。

リスト10 make testでテストを並列実行させる(Makele.PL)
use inc::Module::Install;
use Module::Install::TestTarget;
...
default_test_target(
    includes => ['t/lib'],
    run_on_prepare => ['t/script/setup_mysqld.pl'],
    env => { HARNESS_OPTIONS => 'j4' }, ←追加
);
...

しかし、このままだと1つのMySQLに対して複数のテストが同時に走ってしまうため、テストデータが混ざってしまい、正常なテストができなくなってしまいます。

そこで、テストの並列実行を抑止するためのモジュールTest::Synchronizedを、次のようにTest::MyApp::Fixture::DBIの冒頭でuseするようにします。

package Test::MyApp::Fixture::DBI;
use Test::Synchronized;
...

Test::Synchronizedはuseするだけでそのテストが並列実行されなくなりますので、結果としてTest::MyApp::Fixture::DBIを使用しているすべてのテストが並列実行されなくなります。当然、並列実行による高速化の恩恵は受けられなくなりますが、それでも個別にTest::mysqldを立ち上げるよりは多少速くテストが走る印象です。

このようにすると、Test::Synchronizedを使っているものは並列実行されなくなり、それ以外のテストはすべて並列で実行されるようになります。

また、Module::Install::TestTargetを使うと、maketestの実行段階でいろいろとフックすることができるので、より良いテスト方法を模索してみてはいかがでしょうか。

おわりに

Mobage APIの実装を例に、どういったことに気をつけたら高速かつスケールしやすいプログラムを書けるかの一例を紹介しました。また、DBやmemcachedを使ったテストについても解説しました。より堅牢なアプリケーションを開発する手助けとなれば幸いです。

実際にはWeb APIを提供するうえで、OAuthなどの認証が必須となりますし、そもそもどういったAPIを提供するかが一番難しいところだったりします。もし今回の記事でWeb APIに少しでも興味が湧いたら、ご自身で実装してみると、いろいろと発見があっておもしろいかもしれません。

次回の執筆者は日ごろから筆者の隣に座って仕事をしている小林篤(nekokak)さんで、テーマは「ジョブキュー」です。お楽しみに!

おすすめ記事

記事・ニュース一覧