検索機能を実装する

Kurocoでサイト内検索を実装する方法として、下記2つの方法があります。

  • filter機能を利用する
  • 商用サービス(Algolia、Syncsearch等)を利用する

本チュートリアルでは、filter機能を利用したコンテンツ検索機能の実装方法を記載します。

filter機能を利用して検索を実装する方法

APIのfilter機能を利用することで、コンテンツの条件検索やキーワード検索を実装できます。

Image (fetched from Gyazo)

検索機能を実装するにあたって、まずはfilter機能の概要を説明します。

filterは、APIエンドポイントの取得対象データを絞り込むための機能です。エンドポイントの「filter」パラメータに次のようなクエリを指定すると、条件に合致するデータのみを取得できます。

// topics_idが1のコンテンツを指定
topics_id = 1

完全一致/部分一致、数値や日付の比較、AND/ORなど、条件を柔軟に指定できるため、複雑な検索の実現も可能です。

// タイトルが「WORD」を含むコンテンツ、または2021-01-01 ~ 2021-12-31の間に追加されたコンテンツを指定
subject contains "WORD" OR (inst_ymdhi >= "2021-01-01" AND inst_ymdhi <= "2021-12-31")

filterパラメータは下記の2箇所から指定でき、それぞれ役割が異なります。

指定箇所説明
エンドポイント設定Kurocoの管理画面(API画面)で設定するものです。
常に付与される固定の検索条件を設定します。
例えば、ここで inst_ymdhi >=:relatively "-1 year" を設定した場合、現在日時から1年以内に追加されたコンテンツを常に取得します。
GETパラメータフロントエンドからAPIに動的に指定するものです。
エンドポイント設定でもfilterを設定済みの場合は、以下のように両方のクエリをAND条件で結合した状態で検索を行い、結果を返します。
filterクエリ(エンドポイント設定) AND filterクエリ(GETパラメータ)

今回の検索機能のように、ユーザーの入力内容に応じて取得結果を変えたい場合は、GETパラメータでfilterクエリを指定します。
固定の検索条件を追加で指定したい場合は、必要に応じてエンドポイント設定にもクエリを入力してください。

filterは、対象のAPIモデルが機能をサポートしている場合のみ利用できます。エンドポイントの設定画面、またはSwagger UIの画面を確認し、対象のエンドポイントに「filter」パラメータが存在するかを確認してください。

それではfilterを利用した検索の実装方法を説明していきます。今回は下記2パターンでの実装方法を説明します。

  • 条件検索
  • キーワード検索

事前準備

APIを追加する

API

下記のAPIを作成します。

項目
タイトル検索機能のAPI
1.0
ディスクリプション検索機能のAPI

Image from Gyazo

追加するをクリックすると、追加したAPIに遷移しますので、続いて、セキュリティの設定をします。
[セキュリティ]をクリックします。
Image from Gyazo

[Cookie]を選択して[保存する]をクリックします。
fetched from Gyazo

CORS

次にCORSの設定をします。[CORSを設定する] をクリックします。

Image from Gyazo

CORS_ALLOW_ORIGINSの [Add Origin] をクリックし、下記を追加します。

  • http://localhost:3000
  • フロントエンドドメイン

CORS_ALLOW_METHODSの [Add Method] をクリックし、下記を追加します。

  • GET
  • POST
  • OPTIONS

Image from Gyazo

設定できたら[保存する]をクリックし、CORSの設定が完了です。

条件検索の実装

ここからは先ほどの機能概要を踏まえて、条件検索機能を実装する方法を説明していきます。

1. コンテンツ定義とエンドポイントを作成する

下記のコンテンツ定義とエンドポイントを作成します。

コンテンツ定義

コンテンツ定義は下記の設定で作成します。

  • グループ名:Search
  • グループID:9(自動採番されます)

Image from Gyazo

また、拡張項目を使用し下記フィールド追加しています。

ID項目名設定項目オプション
1Textテキスト-
2Select選択形式01::option1
02::option2
03::option3
3Checkbox複数選択(チェックボックス)01::option1
02::option2
03::option3

Image from Gyazo

識別子欄は空白にしておいて下さい。

また、作成したコンテンツ定義「Search」にて、下記のようにテストデータを3件登録しました。

Image from Gyazo

エンドポイント

エンドポイントは下記の設定で作成します。

項目
パスcontent
カテゴリーコンテンツ
モデルTopics (v1)
オペレーションlist
topics_group_id9

topics_group_idには、ご自身のコンテンツ定義のIDを記入してください。

Image from Gyazo

Image from Gyazo

2. エンドポイントを設定する

[API] -> API LIST画面から先ほど作成したエンドポイントを選択し、「更新」 をクリックします。

Image from Gyazo

次に、filter_request_allow_listの設定項目に移動し、検索対象の項目名を指定します。

Image from Gyazo

filter_request_allow_listは、GETパラメータでの検索を許可する項目を指定するための設定です。

初期状態では、GETパラメータによるfilterクエリの指定は無効化されています。ここで個別に項目名を指定することで初めて、対象項目への検索が有効になります。

今回は下記を追加します。

  • subject
  • inst_ymdhi
  • ext_1
  • ext_2
  • ext_3

Kurocoをお申込みいただいたタイミングによっては、各拡張項目がext_1ではなく、ext_col_01となる場合があります。
うまくいかない場合は Swagger UIでレスポンスをご確認ください。

Image from Gyazo

ここで :ALL を指定した場合、全ての項目に対する検索が許可されます。便利な機能ですが、これは時にAPIのセキュリティを弱める原因にもなり得ます。(例えば、特定の項目をレスポンスデータに返さないよう設定している場合、その項目も含めて検索対象となります)
そのため、基本的には対象の項目を個別に設定することを推奨します。
:ALLを設定する場合は、対象のエンドポイントが返すデータの内容を確認し、本当に問題がないかどうかを確認してください。

設定が完了したら、[更新] ボタンをクリックし保存してください。

Image from Gyazo

3. エンドポイントの動作を確認する

API LIST画面より「Swagger UI」をクリックし、Swagger UI画面に移動します。

Image from Gyazo

設定したエンドポイントをクリックし、動作確認を行います。

Image from Gyazo

[Try it out] をクリックします。

Image from Gyazo

「filter」パラメータに検索条件となるクエリを入力します。

今回は「Test」という名前の記事を作っている前提で、「subject contains "Test"」と記入し、この記事が取得できることを確認します。

記述可能なクエリの形式については、下記ドキュメントを参照してください。
リファレンス:検索機能の使い方

Image (fetched from Gyazo)

入力が完了したら [Execute] ボタンをクリックします。

Image (fetched from Gyazo)

結果が表示されるので、期待通りに検索が行えているかを確認してください。

Image from Gyazo

以上でエンドポイントの動作が確認できました。

4. 検索機能を実装する

では、先ほど設定したエンドポイントを利用して、実際に条件検索画面を実装していきましょう。

今回はNuxt.jsを使い、以下のようにシンプルな検索フォーム・検索結果テーブルを表示するコンポーネントを作成します。

Image from Gyazo

まずは下記のコンポーネントを、pages/search/index.vueとして用意します。

<template>
  <div>
    <div class="search-form">
      <p>
        <label for="subject">Title</label>
        <input v-model="searchInput.subject" type="text">
      </p>
      <p>
        <label for="inst_ymdhi">Created at</label>
        <input v-model="searchInput.inst_ymdhi.from" type="date"> 
        ~
        <input v-model="searchInput.inst_ymdhi.to" type="date"> 
      </p>
      <p>
        <label for="ext_1">Text</label>
        <input v-model="searchInput.ext_1" type="text">
      </p>
      <p>
        <label for="ext_2">Select</label>
        <select v-model="searchInput.ext_2">
          <option value="">Not selected</option>
          <option value="01">option1</option>
          <option value="02">option2</option>
          <option value="03">option3</option>
        </select>
      </p>
      <p>
        <label for="ext_3">Checkbox</label>
        <input v-model="searchInput.ext_3" type="checkbox" value="01">option1
        <input v-model="searchInput.ext_3" type="checkbox" value="02">option2
        <input v-model="searchInput.ext_3" type="checkbox" value="03">option3
      </p>  
      <button type="button">Search</button>
    </div>
    <div v-if="Object.keys(searchResult).length > 0" class="search-result">
      <template v-if="(searchResult.errors || []).length === 0">
        <table>
          <tr>
            <th>ID</th>
            <th>Title</th>
            <th>Created at</th>
            <th>Text</th>
            <th>Select</th>
            <th>Checkbox</th>
          </tr>
          <tr v-for="content in searchResult.list" :key="content.topics_id">
            <td>{{ content.topics_id }}</td>
            <td>{{ content.subject }}</td>
            <td>{{ content.inst_ymdhi }}</td>
            <td>{{ content.ext_1 }}</td>
            <td>{{ content.ext_2 }}</td>
            <td>{{ content.ext_3 }}</td>
          </tr>
        </table>
      </template>
      <template v-else>
        {{ searchResult.errors }}
      </template>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      searchInput: {
        subject: '',
        inst_ymdhi: {
          from: '',
          to: '',
        },
        ext_1: '',
        ext_2: '',
        ext_3: []
      },
      searchResult: {},
    }
  },
  mounted() {},
  methods: {}
}
</script>

<style scoped>
.search-form {
  border: 1px solid;
  padding: 10px;
}
.search-form label {
  display: block;
  float: left;
  width: 100px;
}
.search-result {
  width: 100%;
  margin-top: 20px;
}
.search-result table, th, td {
  border: solid 1px;
  border-collapse: collapse;
}
.search-result th, td {
  padding: 5px;
}
.search-result table {
  width: 100%;
}
</style>

.search-formは検索フォーム、.search-resultは検索結果を表示するための要素です。

dataオブジェクトのsearchInputにはユーザーの入力内容を、searchResultには検索結果のレスポンスを格納します。

data() {
  return {
    // ユーザーの入力内容 (.search-form)
    searchInput: {
      subject: '',
      inst_ymdhi: {
        from: '',
        to: '',
      },
      ext_1: '',
      ext_2: '',
      ext_3: []
    },
    // 検索結果のレスポンス (.search-result)
    searchResult: {},
  }
},

npm run devコマンドを実行してローカル環境を立ち上げ、http://localhost:3000/search にアクセスすると、下記の画面が表示されます。
画面上には以下の入力フォームが表示されます。

Image (fetched from Gyazo)

しかしながら、まだエンドポイントの呼び出し処理を実装していないため「Search」ボタンをクリックしても何も起こらない状態です。ここからはmethodsを定義し、実際にエンドポイントを呼び出して検索処理を実装していきます。

まずは、searchメソッドを作成します。ここには、「1. エンドポイントを設定する」で設定したエンドポイントを呼び出す処理を記述します。

filterクエリの生成処理については、buildFilterQueryメソッドへ移譲するようにしています。(このメソッドの具体的な処理については、後ほど記述します。)

methods: {
  // エンドポイントへのリクエストを行い、取得結果をsearchResultに格納
  async search() {
    let searchResult;
    try {
      // 自分の環境で設定したエンドポイントのURLに置き換えてください
      const response = await this.$axios.get("/rcms-api/14/content", {
        params: {
          filter: this.buildFilterQuery()
        }
      })
      searchResult = response?.data || {};
    } catch(errorResponse) {
      searchResult = { errors: errorResponse?.data?.errors || ['Unexpected error'] };
    }
    this.searchResult = searchResult;
  },
  // filterクエリの生成
  buildFilterQuery() {
    return '';
  }
}

/rcms-api/14/content、の箇所は、Kuroco管理画面に記載のパスをご記入ください。

作成が完了したら、searchメソッドを[Search] ボタンのクリックイベントとして定義します。 [Search] ボタンは .search-form 要素の最下部に存在します。

<!--
    <button type="button">Search</button> <= @click="search" を追記
-->
<button type="button" @click="search">Search</button>

続いては、buildFilterQueryの具体的な処理を記述していきます。data属性 searchInput に格納されている値を、filterクエリに変換します。

今回は、以下のような条件でクエリを生成します。

  • 日付: 範囲指定
  • テキスト: 部分一致
  • 選択(select): 完全一致
  • 複数選択(checkbox): 部分一致

値が未入力の場合は、対象の項目は検索条件として指定しないものとします。

生成すべきクエリはinputの形式、対象の項目、機能要件などによって異なります。必要に応じて適した処理を実装してください。

methods: {
  // ...
  buildFilterQuery() {
    const filterQuery = Object.entries(this.searchInput).reduce((queries, [col, value]) => {
      switch (col) {
        // 日付: 範囲指定
        case 'inst_ymdhi':
          if (value.from !== '') {
            queries.push(`${col} >= "${value.from}"`);
          }
          if (value.to !== '') {
            queries.push(`${col} <= "${value.to}"`);
          }
          break;
        // テキスト: 部分一致
        case 'subject':
        case 'ext_1':
          if (value !== '') {
            queries.push(`${col} contains "${value}"`);
          }
          break;
        // 選択(select): 完全一致
        case 'ext_2':
          if (value !== '') {
            queries.push(`${col} = "${value}"`);
          }
          break;
        // 複数選択(checkbox): 部分一致
        case 'ext_3':
          if (value.length > 0) {
            queries.push('(' + value.map(v => `${col} contains "${v}"`).join(' OR ') + ')');
          }
          break;
        default:
          break;
      }
      return queries;
    }, []).join(' AND ');

    return filterQuery;
  }
}

最後に、mountedに以下のコードを追加し、初期遷移時には条件指定のないコンテンツ一覧を表示するようにします。

mounted() {
  this.search();
},

以上で、条件検索コンポーネントの実装は完了です。 localhostにアクセスしてフォームを操作し、期待通りに結果を取得できているかを確認してください。

Image from Gyazo

以上で条件検索の実装を終わります。

キーワード検索の実装

続いては、キーワード検索を実装する方法を説明します。

キーワード検索を実装するにあたって、条件検索の例を踏まえると、どのような実装方法が考えられるでしょうか。まずは以下のように、対象のカラムに対しての部分一致検索クエリを OR で連結する方法も考えられます。

subject contains "KEYWORD" OR ext_1 contains "KEYWORD"

確かにこの方法を利用すれば、キーワード検索の機能自体は実現が可能です。ですが、検索対象のカラムが増えた場合に少し問題があります。 以下のように、非常に冗長で読みづらいクエリを渡す必要があるためです。

subject contains "KEYWORD" OR ext_1 contains "KEYWORD" OR ext_4 contains "KEYWORD" OR ext_5 contains "KEYWORD" OR ext_6 contains "KEYWORD" OR ...

そのためfilter機能では次のように、よりシンプルなクエリでキーワード検索を実装できる仕組みを用意しています。

search_keyword contains "KEYWORD"

今回はこの search_keyword 機能を利用して、キーワード検索を実装していきます。

Kurocoを申し込んだタイミングによってsearch_keywordではなくkeywordで動作するサイトがあります。
うまく動かない場合はkeywordでもお試しください。

1. コンテンツ定義とエンドポイントを作成する

今回は、下記のコンテンツ定義とエンドポイントを利用します。

コンテンツ定義

コンテンツ定義は条件検索の実装で定義した「Search」を利用します。

エンドポイント

エンドポイントは下記の設定で作成します。

項目
パスcontent_keyword
カテゴリーコンテンツ
モデルTopics (v1)
オペレーションlist
topics_group_id9

topics_group_idには、ご自身のコンテンツ定義のIDを記入してください。

Image from Gyazo

Image from Gyazo

2. エンドポイントを設定する

API LIST画面から、検索機能を実装したいエンドポイントを選択し、[更新] をクリックします。

Image from Gyazo

次に、filter_request_allow_listの設定項目に移動し、検索対象の項目名を指定します。

Image from Gyazo

キーワード検索の許可リストを設定する場合には、以下のフォーマットを利用できます。

形式説明
search_keyword全項目に対するキーワード検索を許可します。
例) search_keyword
search_keyword:[項目名1,項目名2,...]対象の項目名をカンマ区切りで入力し、指定した項目名に対するキーワード検索を許可します。
例) search_keyword:[subject]
例) search_keyword:[ext_1,ext_2]

:ALL を指定した場合は他の項目と同様に、search_keywordも対象の項目として含まれます。

今回は以下のように、search_keyword:[subject,ext_1]を指定し、タイトルとテキスト項目をキーワード検索の対象とします。

Image from Gyazo

設定が完了したら、[更新] ボタンをクリックし保存してください。

Image from Gyazo

3. エンドポイントの動作を確認する

API LIST画面より「Swagger UI」をクリックし、Swagger UI画面に移動します。

Image from Gyazo

設定したエンドポイントをクリックし、動作確認を行います。

Image from Gyazo

[Try it out] をクリックします。

Image from Gyazo

「filter」パラメータにクエリを入力します。部分一致で検索させるため、 contains を指定します。 今回は「search_keyword contains "1"」と記入します。

Image from Gyazo

入力が完了したら [Execute] ボタンをクリックします。

Image (fetched from Gyazo)

結果が表示されるので、期待通りに検索が行えているかを確認してください。

Image (fetched from Gyazo)

以上でエンドポイントの動作が確認できました。

4. 検索機能を実装する

では、先ほど設定したエンドポイントを利用して、実際にキーワード検索画面を実装していきましょう。

条件検索と同様に、今回もNuxt.jsを使って、以下のようなコンポーネントを作成します。

Image (fetched from Gyazo)

まずは下記のコンポーネントを、pages/search_keyword/index.vueとして用意します。

<template>
  <div>
    <div class="search-form">
      <p>
        <label for="keyword">Keyword</label>
        <input v-model="searchInput.search_keyword" type="text">
      </p>
      <button type="button" @click="search">Search</button>
    </div>
    <div v-if="Object.keys(searchResult).length > 0" class="search-result">
      <template v-if="(searchResult.errors || []).length === 0">
        <table>
          <tr>
            <th>ID</th>
            <th>Title</th>
            <th>Created at</th>
            <th>Text</th>
            <th>Select</th>
            <th>Checkbox</th>
          </tr>
          <tr v-for="content in searchResult.list" :key="content.topics_id">
            <td>{{ content.topics_id }}</td>
            <td>{{ content.subject }}</td>
            <td>{{ content.inst_ymdhi }}</td>
            <td>{{ content.ext_1 }}</td>
            <td>{{ content.ext_2 }}</td>
            <td>{{ content.ext_3 }}</td>
          </tr>
        </table>
      </template>
      <template v-else>
        {{ searchResult.errors }}
      </template>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      searchInput: {
        search_keyword: '',
      },
      searchResult: {},
    }
  },
  mounted() {
    this.search();
  },
  methods: {
  async search() {
      let searchResult;
      try {
        // 自分の環境で設定したエンドポイントのURLに置き換えてください
        const response = await this.$axios.get("/rcms-api/5/content_keyword", {
          params: {
            filter: this.buildFilterQuery()
          }
        })
        searchResult = response?.data || {};
      } catch(errorResponse) {
        searchResult = { errors: errorResponse?.data?.errors || ['Unexpected error'] };
      }
      this.searchResult = searchResult;
    },
    // filterクエリの生成
    buildFilterQuery() {
      return '';
    }
  }
}
</script>

<style scoped>
.search-form {
  border: 1px solid;
  padding: 10px;
}
.search-form label {
  display: block;
  float: left;
  width: 100px;
}
.search-result {
  width: 100%;
  margin-top: 20px;
}
.search-result table, th, td {
  border: solid 1px;
  border-collapse: collapse;
}
.search-result th, td {
  padding: 5px;
}
.search-result table {
  width: 100%;
}
</style>

/rcms-api/5/content_keyword、の箇所は、Kuroco管理画面に記載のパスをご記入ください。

npm run devコマンドを実行してローカル環境を立ち上げ、http://localhost:3000/search_keyword にアクセスすると、下記の画面が表示されます。
画面上には以下の入力フォームが表示されます。

Image from Gyazo

下記を除いて、コンポーネントの属性定義は条件検索のものと同様です。

  • テンプレートの.search-form要素
  • dataオブジェクトのsearchInputプロパティ
  • buildFilterQueryメソッド

.search-form要素には、キーワードの入力フォームのみを定義しています。
入力された値は、searchInput.search_keywordに格納します。

<div class="search-form">
    <p>
    <label for="keyword">Keyword</label>
    <input v-model="searchInput.search_keyword" type="text">
    </p>
    <button type="button" @click="search">Search</button>
</div>
data() {
  return {
    searchInput: {
      search_keyword: '',
    },
    searchResult: {},
  }
},

最後にキーワード検索機能を実装すれば完成です。 buildFilterQueryメソッドを編集し、キーワード検索クエリの生成処理を記述します。

methods: {
  // ...
  buildFilterQuery() {
    const filterQuery = Object.entries(this.searchInput).reduce((queries, [col, value]) => {
      switch (col) {
        case 'search_keyword':
          if (value !== '') {
            queries.push(`${col} contains "${value}"`);
          }
          break;
        default:
          break;
      }
      return queries;
    }, []).join(' AND ');
    return filterQuery;
  }
}

以上で、キーワード検索コンポーネントの実装は完了です。

localhostにアクセスしてフォームを操作し、期待通りに結果を取得できているかを確認してください。

Image from Gyazo

以上でキーワード検索の実装を終わります。

参考:商用サービスを利用して検索を実装する

サイト内検索のように網羅的な全文検索を実装したい場合は、商用検索サービスを利用することで実現できます。

本チュートリアルでは具体的な説明方法は割愛しますが、より検索に力を入れたい場合は下記のサービスをご利用することもお勧めします。

お探しのページは見つかりましたか?解決しない場合は、問い合わせフォームからお問い合わせいただくか、Slackコミュニティにご参加ください。