Elasticsearchのクエリビルダをコンポジットパターンで書いた


訳あってElasticsearchのクエリビルダを書いたので、コードを載せます。
タイトルには「コンポジットパターンで書いた」と書きましたが、
正しくは「実装したらコンポジットパターンぽくなってた」です。

背景

  • PHPで書いているWebアプリケーションで全文検索エンジン使いたい。
  • Elasticsearchの検索クエリは文字列を連結すれば簡単に作れるが、複数の条件を組み合わせたクエリになると複雑になってつらい。
  • クエリビルダ探したけどちょうどいいOSSがない。1
  • そんなに複雑そうじゃないので自前で実装しよう。

Elasticsearchの検索クエリ

Elasticsearchの検索には、Query DSLを用います。 Query DSLはElasticsearch用の検索問い合わせのための専用言語です。
関係データベースにおけるSQLと同じ役割になります。

ElasticsearchのQuery DSLにはいくつか種類がありますが、今回は自由度の高いQuery string queryを使用しました.

参考)
- https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
- https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html

コンポジットパターンとは?

コンポジットパターン(Compositeパターン)とは、入れ子構造をシンプルに表現できるデザインパターンです。
ファイルシステムにおけるディレクトリファイルを例に挙げると、
ディレクトリファイルも、両方ともディレクトリの中に入ることができるという共通の性質があります。
これをクラス化することで、ファイルとディレクトリを同一に扱うことができるので、
入れ子構造をシンプルに実装できます。


図 ディレクトリとファイルのツリー


図 コンポジットパターンのクラス図Wikipediaより引用

コンポジットパターンの登場人物

コンポジットパターンの登場人物は、次の通りです。
- Leaf (葉): 中身を表す役。
- Composite(複合体): 容器を表す役。Leaf役またはComposite役を格納できる。
- Component : Leaf役とComposite役を同一視するための役。

Query string queryとコンポジットパターン

ElasitcSearchのクエリ DSLには2種類の句が存在します。
- Leaf query clauses(リーフクエリ句) : 完全一致や部分一致、数値範囲の一致といった絞り込みを行う句
- Compound query clauses(複合クエリ句) : Leaf Query clausesまたはCompound query clausesをブール演算で繋ぐ句

この2種類のクエリの関係は、ツリー構造。 コンポジットパターンのLeafCompositeの関係になっています。

参考) https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html

コード


図 これから実装するクラスのクラス図

(この図を書いて思ったが、リーフクエリ句と複合クエリ句をそれぞれインターフェースにしておけばもっとシンプルだった)

Component役 Expression

Leaf query clauses, Compound query clausesの両方を同一視するための役をExpressionというインターフェースで実装しました。
訳あってExpressionという名前を採用してますが、
Clausesという名前の方がしっくりくるので、読み替えてください。

Expression.php
interface Expression
{
    public function toStringExpression(): String;
}

toStringExpression()は、その句を文字列に変換するメソッドです。

Leaf役 TermExpression

検索条件を指定する句は、数値の一致に使われるTermや、範囲一致のRangeなど色々ありますが、
今回は抜粋してTerm句のみ紹介します。

TeamExpression.php
class TermExpression implements Expression
{
    /**
     * @var string
     */
    private $field;
    /**
     * @var int|null
     */
    private $boost;
    /**
     * @var StringOrValueCondition
     */
    private $condition;

    public function __construct(string $field, ?int $boost, StringOrValueCondition $condition)
    {
        $this->field = $field;
        $this->boost = $boost;
        $this->condition = $condition;
    }


    /**
     * 構文: (FIELD^BOOST: CONDITION)
     * FIELD: FieldName, N: Boost, CONDITION: String or Value Condition
     * @return String
     */
    public function toStringExpression(): String
    {
        $field = $this->field;
        $boostOption = isset($this->boost) ? "^$this->boost" : '';
        $expressionString = $this->condition->toStringCondition();
        return "($field$boostOption: $expressionString)";
    }
}

StringOrValueCondition型は、文字列か数値を表現するための独自のValueObjectです。

Composite役 AndExpression

Compound query clausesにはANDのほかにOR, NOTがあります。

AndExpression.php
class AndExpression implements Expression
{
    /**
     * @var ExpressionList
     */
    private $expressions;
    /**
     * @var int|null
     */
    private $boost;

    public function __construct(?int $boost, ExpressionList $expressions)
    {
        if ($expressions->count() <= 1) {
            throw new InvalidArgumentException();
        }

        $this->expressions = $expressions;
        $this->boost = $boost;
    }

    /**
     * 構文: (EXPRESSION1^BOOST AND EXPRESSION2^BOOST AND ... AND EXPRESSIONN^BOOST)
     * EXPRESSION : Expressions BOOST: Boost
     * @return String
     */
    public function toStringExpression(): String
    {
        $expressionsString = $this->expressions->toStringWithGlue('AND');
        $boostOption = isset($this->boost) ? "^$this->boost" : '';

        return "($expressionsString" . "$boostOption)";
    }
}

注目すべきは、$this->expressions->toStringWithGlue('AND');の部分です。
toStringWithGlue()メソッドでは、Expressionリスト内のそれぞれのExpressionに対してtoStringExpression()を実行して、それを文字列連結で1つの文字列にしています。
たとえば、AndExpressionの中にAndExpressionがあれば、またtoStringWithGlue()が呼ばれ、そのAndExpressionの中に、またAndExpressionがあれば、さらにtoStringWithGlue()が呼ばれることになります。
つまり、ツリーを辿って再帰的に文字列変換が行われることになります。

ExpressionList.php

class ExpressionList extends BaseListValue
{
    public function toStringArray(): array
    {
        return $this->map(function ($expression) {
            /** @var Expression $expression */
            return $expression->toStringExpression();
        });
    }

    public function toStringWithGlue(string $glue): string
    {
        return implode(" $glue ", $this->toStringArray());
    }
}

使い方の例

3つの式をORとANDを使って合体して、一つのQueryStringを作ってみます。

$expressionA = new TermExpression('fieldA', null /* boost */, new IntCondition(123)); //式A
$expressionB = new TermExpression('fieldB', 2 /* boost */, new StringCondition("Hello")); //式B
$expressionC = new TermExpression('fieldC', 2 /* boost */, new StringCondition("World")); //式C

$orExpression = new OrExpression(
    new ExpressionList([$expressionA, $expressionB]));
 // (式A OR 式B)を作る
$andExpression = new AndExpression(new ExpressionList([$orExpression, $expressionC])); // ((式A OR 式B) AND 式C) を作る

$queryString = $andExpression->toStringExpression();
// (((fieldA: 123) OR (fieldB^2: 'Hello')) AND (fieldC^2: 'World'))

こんな感じになります。

おわりに

  • クエリ句の階層構造に着目することでシンプルにクエリビルダを書くことができた。
  • 今回は訳あって、クエリ文字列を自前で作成したが、基本的には、車輪の再発明を避けてライブラリを使うべきなので、よいこのみんなはライブラリを使いましょう。


  1. いい感じのクエリビルダがなかったと書いたが、実は、最初はCloudSearchを導入する予定だったのでCloudSearchのクエリビルダを探していた。「Elasticsearch Query Builder」で検索するといっぱい見つかったので、きっとその中にちょうどいいものがあるだろう。