PowerCMSのブログに登録したデータをAlgoliaで検索する

公開
  • フロントサーバーとCMSサーバーが分かれている
  • 特定のコンテンツのみを検索対象としたい

上記のような状況で例えば「よくあるご質問」を検索したいとなり、どう対応するか考えました。製品サイトで「PowerCMS機能を公開サーバーで利用する際の要件定義・インフラ設計上の考慮点について | PowerCMS ブログ」や「PowerCMS を運用するサーバと公開するサーバを別にすることは可能でしょうか?(ステージングサーバを経て公開したい) | よくあるご質問」のような記事で対処法が紹介されており、早い段階でお伝えするようにしています。

そして、内部サーバーに接続しない・データベースをコピーしないとなった時(大幅に話を端折っています)は外部の検索サービスを使う、もしくはJSONにデータを全て書き出して検索する、ということになるかと思います。JSONに全て書き出す方法だと内容によってはサイズの大きなJSONを読むことになるなど、僕はどうもあまり好みではなく…。そんな時、たまたま別件(Vue.jsですね)でCodeGridを眺めていると、「Algoliaでリアルタイム検索を実装する」という記事で高速さと信頼性の高さが特長の検索をAPI経由で提供するサービス「Algolia」を知りました。Pricingページを見ると検索数にもよりますが低価格で導入できそうでひとまず試してみることにしました。(日本語で出てくる記事を見ると過去の情報なのか高そうに見えるのですが…)
画面キャプチャ:Algoliaのダッシュボード

※信頼性の高い検索サービスを選ぶことが重要だと考えます。フロントサーバーからCMSサーバーに接続した方が良かった…ということが起こらないようにすることが肝要です。

検索データの登録

Algoliaへの登録と最小限の設定を済ませた後、JSONを用いてデータをインデックスに登録します。PHPで実現したいのでcomposer require algolia/algoliasearch-client-phpでAPIクライアントを導入した後、「ending Records in Batches | How to | Sending and Managing Data | Guide | Algolia Documentation」を参考に以下のコードを書きました。.envを使いたかったので少しコードを足しています。

<?php
require_once '../vendor/autoload.php';
define('PROJECT_ROOT_DIR', __DIR__ . '/../');
define('JSON_PATH', 'entries.json');

$dotenv = Dotenv\Dotenv::createImmutable(PROJECT_ROOT_DIR);
$dotenv->load();

$client = Algolia\AlgoliaSearch\SearchClient::create(
    $_ENV['APP_ID'],
    $_ENV['ADMIN_API_KEY']
);

$index = $client->initIndex($_ENV['INDEX_NAME']);
$index->clearObjects();  // 既存データを更新せずクリアして再登録する(とりあえず)
$records = json_decode(file_get_contents(JSON_PATH), true);
$index->saveObjects($records, ['autoGenerateObjectIDIfNotExist' => true]);  // Batching is done automatically by the API client

上記をターミナルで実行して登録しましたが、PowerCMS Xのプラグインにすることもできそうだなと感じました。今回はPowerCMS 5だったのでPerlを使うことを考えたのですが、Perlは書けなくはないけれど苦手、モジュールがあったけど古い、などであきらめました。

フロント(検索フォーム・検索結果の表示)

フロントはReactで書きました。CodeGridの記事にも解説がありますし、「Getting Started | Building Search UI | Guide | Algolia Documentation」でも紹介されています。React InstantSearchを使うと簡単に検索が実装できました。

ただ、フォームのHTMLや検索結果のHTMLをコーディングデータに合わせたい、となりドキュメントを読み進めるとカスタマイズ方法がありました。「React InstantSearch Widgets | React InstantSearch | API Reference | Algolia Documentation」のSearchBoxHitsを確認します。

その他、検索条件にマッチする結果がない時の表示、検索中の表示をしたく、下記に記載がありました。

検索語の入力に応じてクエリストリングを変化させたかったのですが、React Routerが必要になるために後回しにしました。

結果、下記のようなコードになりました。(私がベースのHTMLを書いていないので適当に書き換えています。)

import React, { useRef, useEffect } from 'react';
import algoliasearch from 'algoliasearch';
import {connectHits, connectSearchBox, connectStateResults, InstantSearch} from 'react-instantsearch-dom'

const algoliaClient = algoliasearch(process.env.APP_ID, process.env.API_KEY);

// 検索フォーム(submitを押したときに検索するようにカスタマイズ)
const SearchBox = ({ refine }) => (
  <form noValidate action="" onSubmit={e => {
      e.preventDefault();
      const value = new FormData(e.target).get('search_query');
      refine(value);
    }}
  >
    <input type="text" placeholder="キーワードを入力" name="search_query" />
    <input type="submit" value="検索" />
    <input type="reset" onClick={() => refine('')} />
  </form>
);

// 検索結果
const Hits = ({ hits }) => {
  const titleElem = useRef(null);
  useEffect(() => {
    titleElem.current.focus();
  })
  return (
    <>
      <h2 tabIndex="-1" ref={titleElem}>検索結果</h2>
      <dl>
      { hits.map(hit => (
        <div>
          <dt>{hit.question}</dt>
          <dd>{hit.answer}</dd>
        </div>
      ))}
      </dl>
    </>
  )
};

const CustomResults = connectStateResults(
  ({ searchState, searchResults, children, isSearchStalled }) => {
    if (searchResults && searchResults.hits.length > 0) {
      return children;
    } else if (searchState.query && !isSearchStalled) {
      return (
        <>
          <p>該当する質問と回答がありませんでした。</p>
        <>
      );
    } else {
      return null;
    }
  }
);

// ローディング中表示
const CustomLoadingIndicator = connectStateResults(
  ({ isSearchStalled }) =>
    isSearchStalled ? <div><img src="/assets/img/common/loading.gif" alt="読み込み中..." /></div> : null
);

const CustomSearchBox = connectSearchBox(SearchBox);
const CustomHits = connectHits(Hits);
const searchClient = {
  search(requests) {
    if (requests.every(({ params }) => !params.query)) {
      return Promise.resolve({
        results: requests.map(() => ({
          hits: [],
        })),
      });
    }
    return algoliaClient.search(requests);
  },
};

function App() {
  return (
    <InstantSearch
      indexName={process.env.INDEX_NAME}
      searchClient={searchClient}
    >
      <CustomSearchBox />
      <CustomLoadingIndicator />
      <CustomResults>
        <CustomHits />
      </CustomResults>
    </InstantSearch>
  );
}

export default App;

まとめ

以上がAlgoliaとReactでシンプルな検索を実現する方法でした。Algoliaにはいろいろな機能があるようで非常に興味を持ちましたので、今後もいろいろ調べてみたいと思いますし、もしかしたらデータをインデックスに追加・更新するのとインデックスのObjectIDを取得するPowerCMS Xのプラグインを書いてみるかもしれません。(→書きました:Algolia + PowerCMSシリーズ