HumHubの検索にApacheSolrを使わせてみる（後編）

#はじめに
後編では、[前編](https://qiita.com/tomofu74/items/1ad13936f4c125c3ee31)の後がまぁどうったか書いておく。読者諸氏は、初めに右側にある目次を確認のうえ、参考になる部分は参考に、読むのをやめるなら今のうちにご判断をいただきたい。

#（前編のつづき）
[前編](https://qiita.com/tomofu74/items/1ad13936f4c125c3ee31)ではSolariumというPHPのライブラリだかなんだかをインストールして、PHPからSolrにアクセスすることができるようにしたあと、Searchモジュールをベースにカスタムモジュールを書き始めた。その後、データの追加のためのAdd、削除のためのdelete、書き換えのためのupdateまでは割とすんなりと作れて・・・。いよいよ、本丸、findの記述だ。

# 検索機能の本丸、find関数
HumHubの　Search class （/humhub/protected/modules/search/engine/Search.php）のabstract function find() は、次ぐのとおり解説がある。

    /**
     * Retrieves results from search
     *
     * Available options:
     *      page
     *      pageSize
     *
     *      sortField           Mixed String/Array
     *      model               Mixed String/Array
     *      type                Mixed String/Array
     *      checkPermissions    boolean (TRUE/false)
     *      limitSpaces         Array (Limit Content to given Spaces(
     *      filters             Array (Additional filter Field=>Value)
     *
     * @param type $query
     * @param array $options
     * @return SearchResultSet
     */
    abstract public function find($query, Array $options);

これと、デフォルトで存在しているZendLuceneSearch Classのコードから、実装を模索してみた。最終ゴールは、 find では、キーワードからOR検索とフレーズ検索を作成したいところでもある。そのあたりのクエリの解析・構築の仕組みはあとで作るとして・・・。まず、単純に１キーワードをsolariumで処理させてみよう。

### 全件抽出

こちらの solarium の公式ドキュメントに記載がある。
https://solarium.readthedocs.io/en/stable/queries/select-query/building-a-select-query/building-a-select-query/

ここにExampleがあるのだが・・・これでわかれというのだ・・・。
まず、solariumでクライアントインスタンスを作成する。前編のfluash関数の中でもやったので、そのコードはここでは説明を省略する。そして、そこにセレクトクエリをクリエイトするのだ（←横文字ばっかり。たいがい、ソフトウェア開発者が横文字を連発するときは、自分でもよくわかっていない何かをごまかそうとするときだ（笑。）。

    $query = $client->createQuery($client::QUERY_SELECT);
    $query->setQuery('*:*');
    $query->setStart($options['pageSize'] * ($options['page'] - 1));
    $query->setRows($options['pageSize']);
    $resultset = $client->select($query);

これで、\$resultset に結果が登録される。例えば、 \$resultset->getNumFound() すれば、取得したドキュメント数がわかることは確認できた。

ここまではよい。Solrからちゃんとデータが取得できてるようだ。

ただ、この \$resultsetデータは、HumHubのFind関数で return する SearchResultSetとは型が異なる。そこで、変換する必要があるのだが・・・まずは、SearchResultSet (humhub\modules\search\libs\SearchResultSet.php) クラスのメンバ変数を確認しよう。

    
    /**
     * @var SearchResult[] the search rsults
     */
    public $results = [];

    /**
     * @var int number of total results
     */
    public $total = 0;

    /**
     * @var int the current page
     */
    public $page = 1;

    /**
     * @var int page size
     */
    public $pageSize;

    /**
     * Returns active record instances of the search results
     *
     * @return ActiveRecord[]
     */
    public function getResultInstances()
    {
      ....
    }

results[], total, page, pagesize　をパブリック変数として持つようなので、これらは検索結果から引用、入力しておくべきだろう。ちなみに results は　SearchResult[] なので　humhub\modules\search\libs\SearchResult.php を見てみた。

    class SearchResult
    {

        /**
         * Type
         *
         * @var string
         */
        public $type;

        /**
         * Model of found object
         *
         * @var string
         */
        public $model;

        /**
         * Primary Key of found object
         *
         * @var string
         */
        public $pk;

    }

type, model, pk を登録する。model と pk の組み合わせでデータを識別するのは HumHub の標準的思想（？）であって、HumHubでコードを書くことになるとよく出くわす。よってここに入れるのは、例えば Postモデルのclass と その id だろうと予想。type というのがよくわからなかったが・・・。これらは、結局、humhub\modules\search\engine\ZendLuceneSearch.php の find関数の記述部分を見て真似ることにした。先に得られた検索結果が \$resultset で、ここで記載する \$resultSet と一文字case違いなので要注意になってしまうが・・・

        $resultSet = new SearchResultSet();
        $resultSet->total = $resultset->getNumFound() ;
        $resultSet->pageSize = $options['pageSize'];
        $resultSet->page = $options['page'];

        foreach ($resultset as $document)
        {
            $result = new SearchResult();
            $result->model = $this->getDocumentField($document, 'model');
            $result->pk = $this->getDocumentField($document, 'pk');
            $result->type = $this->getDocumentField($document, 'type');
            $resultSet->results[] = $result;
        }

よし、これで \$resultSet をreturn させれば、 "*:*" クエリの回答を画面表示できるはずだ。テストでHumHubで動かしてみよう。

・・・　うん、表示できた。全件表示、件数の計上は問題なさそうだ・・・。

### キーワード検索

さて、攻城戦に例えると、外堀が埋まったところ、次は大門か・・・。
「$query->setQuery('\*:\*');」をどうにかすれば、うまく行くはずだ、と着想。

ここは意外にストレートな攻防で対応してみることにした。つまりは、「ユーザーにクエリ文を書いてもらう欄をもつUIを用意して、そのクエリ文を、適切なfieldに宛に渡してしまおう」というわけだ。前編でも述べたとおり、今回はSolr側の設定については説明を略する。利用する環境では、_text_ja_shigleというフィールドを使って検索クエリ（\$keyword）を実行させることにしてあるので、例えば、setQueryを次のとおり記述した。

    $query->setQuery("_text_ja_shingle:".$keyword);

これでは、検索ワードを一つ入力したときにはうまくいく。しかし、単語を入力しないときには、エラーになる。また、ORと半角スペースで区切った単語や、””でかこったフレーズの検索もできない。そこで、次のように修正した。

        if ($keyword == "") {
            $query->setQuery("*:*");            
        } else {
            $query->setQuery("_text_ja_shingle:(".$keyword.")");
        }

\$keywordが空だったら、if文で全件抽出の処理を行わせる。$keywordが空でなかったら、クエリ文を()半角カッコではさんだカタチで_text_ja_shigleフィールドにわたすと、Solr でうまく解釈してくれるようだ。Luceneバンザイ。

### 日付フィルタ

今回あつかうSolr側では、日付データ（pdate型）のフィールドがあるので、その部分にフィルタをかけてしまいたい。日付は、「開始日：何年何月何日（'from_date'）から終了日：何年何月何日まで（'to_date'）」という風に期間を指定する。期間指定には、[]角カッコを使うことと、日付レンジはフィルタ扱いと考えれば、例えば$options[]のデータとして'from_date'と'to_date'を使っているならば、次のようにSolariumuに書ける。

    $query->createFilterQuery('daterange')
           ->setQuery('created_at:['
                       . $options['from_date']
                       . ' TO '
                       . $options['to_date']
                       . ']');

### パーミッション

HumHubでの扱いで慎重にならざるをえないのが、記事の閲覧権限（パーミッション）だ。これについては、地道に書いてセットするしかないのだろうが・・・。HumHub元来の検索エンジンである、humhub/protected/humhub/modules/search/engine/ZendLuceneSearch.php の find() 関数では、その処理のなかで呼び出す buildquery() 関数のなかで パーミッションの設定処理を行っている。その考え方は、”閲覧してよいスペース、ユーザーのcontentcontainerのIDのリストを作成し、そのリストをAND列記したフィルタをかける”というもののようだ。さて、その考え方を真似して、頑張ってフィルタを手書きする・・・。力技（ちからわざ）で乗り切るしかなさそう・・・。
あとは、作成した　クエリの文字列を、 $query->setQuery() の引数に連結すればいいはず。

## フロントエンド

キーワードのほか、日付や検索対象とする情報の入力できるフロントエンドは、formで作って \$_get なり、\$_post なりで処理すればよい。これは、その他のHumHubプラグインモジュールと同じように作ればよいが・・・。いつ、どこで表示するか、が気になるだろう。

HumHub 本体、オリジナルの検索機能があるので、その動作をフックしたいことだろう。HumHubオリジナルの検索機能は、humhub\modules\search\controlers\SearchControler クラス でアクションが定義されている。Contoler クラスのイベントの ハンドリングについては、公式ドキュメントの次の部分にサンプルがある。

https://docs.humhub.org/docs/develop/modules-event-handler#controller-events

[（前編)](https://qiita.com/tomofu74/items/1ad13936f4c125c3ee31)で、モジュールのファイル配置をした際、”フックするイベントがない”と記述したが、フロントエンドを設定するとなると別だ。config.php と Event.php を次のように記述してみる。namespaceは、各自の環境で読み替えてくだされ。

< config.php （例）>

    <?php

    use OSINTech\humhub\modules\customsearch\Events;

    return [
        'id' => 'searchfilter',
        'class' => OSINTech\humhub\modules\customsearch\Module::class,
        'namespace' => 'OSINTech\humhub\modules\customsearch',
        'events' => [
          [
            'class' => humhub\modules\search\controllers\SearchController::class,
            'event' => humhub\modules\search\controllers\SearchController::EVENT_BEFORE_ACTION,
            'callback' => [Events::class, 'onBeforeControllerAction']
          ],
        ],
    ];
    ?>

< Events.php （例） >

    <?php

    namespace OSINTech\humhub\modules\Customsearch;

    use yii\base\ActionEvent;
    use Yii;
    use humhub\modules\search\models\forms\SearchForm;

    class Events extends ActionEvent
    {
      public static function onBeforeControllerAction(ActionEvent $event)
      {
        // var_dump($event->sender->route);
        if ($event->sender->route === 'search/search/index') {


            $o_model = new SearchForm();
            $o_model->load(Yii::$app->request->get());

            // Do not continue running the action.
            $event->isValid = false;
            // Manipulate action result
            $event->result = Yii::$app->response->redirect(['/customsearch/search/index', 'SearchForm' => $o_model]);
        }
      }
    }

Events.php の終盤、$event->result で受けている redirect に記載したURL に合わせて、モジュールにコントローラを追加する。

    customsearch
      |
      |-controllers
      |     |
      |     |-SearchController.php
      |
      |-engine
      |   |
      |   |-SolrSearch.php
      |
      |-config.php
      |-Events.php
      |-module.json
      |-Module.php

あとは、コントローラからViewを呼び出すなり何なりして画面表示部を作ればよい。

ここまでつくれば、あとは動作確認だ（やっと目で見れる動きが・・・）・・・、と思ってもすぐには表示結果はでないのよ。

# 検索に使う”素材”をHumHubの各モデルから提出させる方法

そもそも検索の対象となる情報をSolrに書き込む（インデクシングという）を行わなければならない。そのための動作もHumHubには用意されている。

### データベース上のフィールド

各モデルを、humhub\modules\search\interfaces\Searchable クラスの implements として設定し、public function getSearchAttributes() をもたせると、サーチインデックスのビルド時に検索対象素材としてシステムにデータ提出する仕組みになっているようだ。公式による仕組みについての説明は無いが、次のドキュメントから推察できる。

https://docs.humhub.org/docs/develop/search

### アップロードしたファイルの内容

公式ドキュメントによると、ファイル内容もインデクシング対象にできるらしい。このドキュメントは、Ubuntu上でpdftotext を使ったり、java で Apache Tika を使う方法になっている。 pdftext や　java, tika のインストール、利用方法についてはここでは説明しない。各自で調査対応願いたい。

https://docs.humhub.org/docs/admin/search/

# Solr の設定

必要なスキーマの設定は各自の研究次第。HumHubから提出された検索素材を field に設定して、あとはanalyzer やらなんやらの fieldType の設定をいろいろと行って、検索結果が返ってくるように作ってください。この辺は、Solr職人の世界かもしれないけど、Google先生、Qiita先輩からいろいろ聞いて調べるのもよいし、情報が古くはなるが、書籍で調べるのもいいかも。Solr8の解説書は、日本語では一般に販売されてないんじゃなかろうか。

# ・・・とりあえず、うごくぞ！

以上のようにして、なんとか Solr を検索エンジンに使う カスタムの検索機能が出来上がり。詳細は端折ってしまっているけれども、動作させる方法は一通り記述できた（ハズだ）。

動かしながら、動作ログをみると・・・確かにSolr動作部分（検索機能）は早い。ただし、HumHubおよびYii2フレームワークのレンダリングスピードがそんなに早くないようで、体感できるほどの速度向上が見られるかというとまだちょっとわからない。検索のキーワードやフィルタが増え、全文検索の負荷が高い処理になるほど、オリジナルとの速度差はわかりやすくなるのだろうか。

注意してほしいのは、Solrを検索機能に使ったが、その検索結果の返却は、ContentのIDであって、文章データではない。Solrが検索して返却したIDで、HumHub側でコンテンツをDBから取り出し表示しているのだ。”検索”が早くても表示のための機能が遅ければ、しょっぱい結果になってしまうのだ。

# その他


多様なフィルタをつけるためには、各モジュールからデータを提出させるよう、モデルごとにgetSearchAttributes() を記述しなければならない。また、そのデータ提出の処理、検索Indexの構築も提出データ量に応じて長時間化することになるので、これもサーバーへの負荷などに注意が必要だ。特に初回のIndex構築は相応の所要時間を見込んだほうがいい。field数が30程度、百万件なら構築に12時間を見込んでおくことをおすすめする。あわせて、必要業務直前の、Index Rebuildもおすすめしない。

HumHub の母体となっている Yii2フレームワークは、Elasticsearchを動かすエクステンションもあるとのドキュメントがある。・・・ただ、クエリの扱いについての説明が（内容未定）なのだ。居眠りおじさんにもわかるよう、目の覚めるような機能紹介を期待したい・・・。

https://www.yiiframework.com/extension/yiisoft/yii2-elasticsearch/doc/guide/2.0/ja

#おわりに（まとめ）

SNSのアプリケーションであっても、全文検索機能は欲しくなるものだ。ましてCMSならなおさらだろう。今回はHumHub上で動作するよう、そのフレームワークに従った全文検索機能のカスタマイズをApache Solr を使っておこなった。 Solr にしても Elasticsearch にしても、HumHubオリジナルの機能よりも高速な情報検索、フィルタ機能を期待できる。