$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

WEB+DB PRESS vol.94で「Perl開発への動的な型制約の導入」について執筆しました

 WEB+DB PRESSPerl Hackers Hubで執筆しませんかとお声がけいただいたので、「Perl開発への動的な型制約の導入」について執筆しました。本日発売です。

 今回は、動的な型制約とは何かや、なぜPerlに導入したいのか、Smart::Argsを使った型制約の導入方法、型制約の応用など、動的な型制約にまつわる内容について書きました。Perlでもっと安全に開発したいと思っている方には参考になると思うので、是非見ていただければと思います。見出しは次のとおりです。

  • なぜ動的な型制約を導入したいのか
  • Smart::Argsで引数に型制約を導入する
  • Mouse::Util::TypeConstraintsで独自の型を定義する
  • 動的な型制約の応用例
  • 動的な型制約を導入してみて


 他にも実践スケーラブルAWSや作って学ぶElectronなど、非常に面白そうな特集があるので、購入して見ていただけるとありがたいです!


 WEB+DBへの執筆は初めてで、また8ページという長さの文章はこれまで書いたことがなかったので、想像していたよりも苦労しました。しかし、それにより新しく執筆という経験が出来て非常に良かったです。また機会があったら何か執筆をしてみたいです。

PerlでHarrietを使ってElasticsearchのテストをする & 並列でもテストできるように

 Elasticsearchを使った開発をしていると、Elasticsearchを使った機能でも簡単にテストしたいという気持ちになってくる。またproveの-jオプションを使って並列に動かしていても変にコンフリクトせずにいい感じになってほしい。

 この課題を解決するために、Harrietを用いてテスト実行前にElasticsearchを起動し、テストを行うというのをやってみた。

作戦

  • Harrietを用いて、テスト実行前にElasticsearchのnodeを一つ起動する
  • 並列数分、Elasticsearchのインデックスを作成し、ついでにロック用のインデックスも作っておく
  • 環境変数で情報をテストファイルに渡す
  • テストファイルでは環境変数から情報を読み込んで、ロックを取りつつ、立ち上がったportやインデックスにアクセスする

HarrietでElasticsearchの起動 & インデックスの作成

 Harrietについてはtokuhirom blog を参照。

 t/harriet/elasticsearch.plを用意して、以下のように書く。

use strict;
use warnings;
use utf8;

use JSON::XS qw(encode_json);
use Test::TCP;
use App::Prove;

use Search::Elasticsearch;

# ctrl-cした時とかにguardが開放されないのでここで明示的に開放してkillしている
# See also: http://perl-users.jp/articles/advent-calendar/2012/hacker/9
sub install_signal_handler ($) {
    my ($sig) = @_;

    my $original_handler = $SIG{$sig};
    $SIG{$sig} = sub {
        $HARRIET_GUARDS::ELASTICSEARCH = undef;
        $SIG{$sig} = $original_handler || 'DEFAULT';
        kill $sig => $$;
    };
}

# テスト用にElasticsearchを一台立ち上げる
my $now = time;
my $test_process_pid = $$;
$HARRIET_GUARDS::ELASTICSEARCH = Test::TCP->new(
    code => sub {
        my $port = shift;

        # elasticsearchのtcp側は大体100増やしたportを使うので、それに合わせる
        my $tcp_port = $port + 100;
        exec(
            'elasticsearch/bin/elasticsearch',
            # テストが同時に実行された時にそれらで勝手にクラスタを作られると困るので
            # ジョブのプロセスIDをクラスタ名に入れる。またpidだけではいつか
            # 被る恐れがあるので、現在時刻もクラスタ名に入れる
            sprintf("--cluster.name=sample-test-%s-%s", $now, $test_process_pid),
            "--http.port=$port",
            "--transport.tcp.port=$tcp_port",
        );
    },
);
my $http_port = $HARRIET_GUARDS::ELASTICSEARCH->port;

# インデックスの作成
my $es = Search::Elasticsearch->new(nodes => ["localhost:$http_port"]);
my $elasticsearch_index_names = [];

my $setup = sub {
    my $j = shift;

    my $index_name = "sample-$j";

    # テストで利用するインデックスを作成。これは別ファイルで
    # Mappingsを定義しておいて流しこむのが良いと思う
    $es->indices->create(
        index => $index_name,
        body => {
            mappings => {
                hoge => {
                    properties => {
                        foo => { type => 'string' },
                    },
                },
            },
        },
    );

    # 並列時にロックを取るためのMappingを用意
    $es->indices->put_mapping(
        index => $index_name,
        type  => 'lock',
        body  => {
            lock => {},
        },
    );

    push @$elasticsearch_index_names, $index_name;
};

# テストの並列数分インデックスを作る
my $prove = App::Prove->new;
$prove->process_args(@ARGV);
my $jobs_n = $prove->jobs || 1;
$setup->($_) for (1..$jobs_n);

# INTとTERMのときに正しくguardが開放されるようにする
install_signal_handler($_) for qw(INT TERM);

# 環境変数を経由して、portと作成したインデックス名をテストに渡す
$ENV{TEST_HARRIET_ELASTICSEARCH_JSON} = encode_json({
    http_port   => $http_port,
    index_names => $elasticsearch_index_names,
});

 すごく長いし難しいのだが、それぞれやっていることはコメントに書いた。ポイントとしては

  • 並列に実行するとして、その台数分Elasticsearchを起動するのは重いので、インデックスを複数作るという方向で考える
  • Elasticsearchはクラスタを勝手に作るので、クラスタ名が被らないように工夫する
  • $HARRIET_GUARDSというところに入れておけば、テスト終了時にオブジェクトが破棄され、Elasticsearchを終了できる

テストファイルから起動したElasticsearch及び作成したインデックスにアクセスする

 環境変数で情報は渡ってきているので、それをテストファイルから利用したら良い。

 例えば以下のようにt/elasticsearch.tを作る。

use strict;
use warnings;
use utf8;

use lib 't/lib';

use Test::More;
use Search::Elasticsearch;
use JSON::XS qw(decode_json);
use Guard qw(guard);

my $http_port;
my $free_index_name;
my $elasticsearch_unlock;

if (my $elasticsearch = eval { decode_json($ENV{TEST_HARRIET_ELASTICSEARCH_JSON}) }) {
    $http_port = $elasticsearch->{http_port};

    # ロック用のドキュメントID。どんな値でも良いので1にしている。
    my $LOCK_ID = 1;

    # Harrietで作ったインデックスに対してロックが取れるまで待つ
    # 並列数分インデックスは作成しているので、一瞬で取れる想定
    my $es = Search::Elasticsearch->new(nodes => ["localhost:$http_port"]);
    GET_INDEX: for my $count (1..100) {
        for my $index_name (@{ $elasticsearch->{index_names} }) {
            eval {
                $es->create(
                    index => $index_name,
                    type  => 'lock',
                    id    => $LOCK_ID,
                    body  => {},
                );
            };
            unless ($@) {
                $free_index_name = $index_name;
                last GET_INDEX;
            }
        }

        # 取れなかったら1秒待つ
        sleep(1);
    }

    die 'No elasticsearch index acquired' unless $free_index_name;

    # オブジェクトが破棄されたらロックが解除されるようにしておく
    $elasticsearch_unlock = guard {
        my $es = Search::Elasticsearch->new(
            nodes  => ["localhost:$http_port"],
        );

        $es->delete(
            index => $free_index_name,
            type  => 'lock',
            id    => $LOCK_ID,
        );
    };
}

my $es = Search::Elasticsearch->new(nodes => ["localhost:$http_port"]);
ok $es->indices->exists(index => $free_index_name), 'index exists';

undef $elasticsearch_unlock;

done_testing();

 これはベタッと書いてみたけど、あとはこのロックを取る部分をメソッド化してあげて、返り値でguardオブジェクトやportや取得したインデックス名を受け取れば、いろんなテストで利用できるはず。

ElasticsearchのAnalyzerを理解するため全文検索の仕組みをシンプルに考える

 Elasticsearchを使おうとしているとAnalyzerという概念が出てくるが、このAnalyzerという概念は最初理解することが難しかった。全文検索の仕組みを理解すれば分かるだろうと思い、https://speakerdeck.com/johtani/elasticsearchru-men?slide=5http://www.atmarkit.co.jp/ait/articles/1111/18/news148.html の記事などを読んで勉強してみたものの、こちらもいろんな言葉が出てきて非常に混乱した。例えば転置インデックス、tf-idf、トークナイズ、ストップワードN-Gram、正規化などといった言葉が出てくる。

 いろいろ調べてみて整理すると、全文検索の技術には、なぜ検索ができるかの話以外に、類似度の話、検索を高速に行うための話、あいまいな検索に対応する話など、いろんな話題を含んでいるために理解が難しいのではと感じた。そこで、今回はなぜ検索ができるかをシンプルに自分の言葉でまとめることによって、理解を深め、Analyzerの概念も理解してみたい。

 ただし、僕自身は全文検索について専門ではないので、間違っていることがあれば指摘していただけると嬉しい。

なぜ検索できるかシンプルに考える

 自分で全文検索の仕組みをシンプルに言語化してみると、以下の3行にまとめられるのではと感じた。

  • まず文章を、前処理 -> トークン分割 -> トークンごとの後処理という3段階の処理にかけて、文章に含まれるトークン群を作る
  • 検索クエリを同じように、前処理 -> トークン分割 -> トークンごとの後処理という3段階の処理にかけて、検索クエリに含まれるトークン群を作る
  • あとは検索クエリに含まれるトークン群が、文章のトークン群に含まれているなら検索にヒットする


 これだけだとわかりづらいので例を使って考えてみる。例えば、「DVD版秒速5㌢メートルの感想ブログ」という文章があったとする。この時、全文検索をするなら、大文字小文字は関係なく検索にヒットさせたいし、半角全角も無視したい。また、助詞の「を」とかでヒットしても意味がなさそうなので省きたい。これらを満たすためには文章に対して次の処理を行いたいと思うだろう。

  • 前処理として、アルファベットは小文字化、全角文字は半角に、「㌢」などのワードも「センチ」に変換したい
  • 日本語検索ができるように、品詞単位でトークン分割したい
  • トークン分割した中から、助詞は意味なさそうなので省きたい

 文章のトークン群を作るイメージは以下のとおり。最終的に「dvd」「版」「秒速」「5」「センチメートル」「感想」「ブログ」というトークン群が得られた。

f:id:shiba_yu36:20160814121152j:plain


 次に検索クエリ側である。「秒速5センチメートル」という検索クエリも同じような処理にかけられる。これによって「秒速」「5」「センチメートル」というトークン群が得られる。この時、検索クエリの「秒速」「5」「センチメートル」は文章のトークン群の中に全て含まれる。そのため、検索にヒットする。

 別の例として、「感想 秒速5センチメートル」と検索したとする。この時ホワイトスペースの処理は別で行う必要はあるのだが、それを行うとすると「感想」「秒速」「5」「センチメートル」というトークン群が得られる。これも文章のトークン群に含まれるので、検索にヒットする。

 さらに「秒速5センチ」と検索したとする。この時「秒速」「5」「センチ」というトークン群が作られる。しかし、この時文章には「センチメートル」というトークンはあるものの、「センチ」というトークンは含まれないので検索にヒットしない。


 ここまで理解できれば、あとは前処理とトークン分割とトークンごとの後処理の組み合わせで自分の好きにトークン群を作れるということが分かる。それぞれの処理には例えば以下のようなものがある。

  • 前処理 : 辞書を使ったワード変換、HTMLタグの除去など
  • トークン分割 : 日本語の品詞での分割、N文字ごとの分割(N-gram)、ホワイトスペースでの分割など
  • トークンごとの後処理 : 必要ないトークンの除去、トークンをさらにN文字ごとに分割、漢字の読みがなをさらにトークンに追加など


 以上から、文章と検索クエリの両方共からトークン群を作り、検索クエリのトークン群が文章のトークン群に含まれていたら検索にヒットするということが分かった。もちろん応用として検索クエリのトークン群のうち一部が含まれていたらOKとか、トークンを変形させてあいまい検索にも対応するとかそのようなことも可能だろう。最後の例で「秒速5センチ」ではヒットしないと書いたが、トークン群の作り方によってはそれでもヒットさせるとかもできるだろう。

Anaylzerについて考える

 ここでElasticsearchのAnalyzerに戻って考えてみる。すると、以上で説明したものに完全に対応していることが分かる。

 Analysis and Analyzers | Elasticsearch: The Definitive Guide [2.x] | Elastic を見ると、Analyzerの中にはCharacter filters、Tokenizer、Token filtersというものがある。この説明を見ると、前処理 = Character filters、トークン分割 = Tokenizer、トークンごとの後処理 = Token filtersとなっている。図で表すと先ほどとほぼ変わらず以下のとおりである。

f:id:shiba_yu36:20160815101616j:plain

 これにより、ElasticsearchのAnalyzerは文章からトークン群を作る処理を定義するものであり、それは3つのプロセスに分かれ、プロセスごとにやることを組み合わせることでいろんなトークン群を作ることができる、と理解することが出来た。

まとめ

 今回は全文検索の仕組みについて、自分の言葉でシンプルに考えてみるということをしてみた。またまとめてみたことでAnalyzerをさらに理解することが出来た。

 僕は全文検索について専門ではないので、間違っている部分もあるかもしれない。もし間違っていたら指摘してもらえると助かります。