Nuxt.js+Algoliaで全文検索可能なタスク管理アプリを実装するハンズオン！

## はじめに
私はよく家でFirebaseを使って個人開発をするのですが、Firebaseのデータベースは全文検索や文字列の部分一致検索など柔軟な検索機能を作るのには向いていません。そこで、[Firebase公式で紹介されていた](https://firebase.google.com/docs/firestore/solutions/search?hl=ja)Algoliaに興味を持ち、簡単なタスク管理アプリを作ってみました。

使ってみると感動するくらい簡単に全文検索機能の導入ができたので、学んだ知識の整理と、そしてまだAlgoliaを触ったことのない方への紹介も兼ねてこの記事を書くことにしました。

この記事では、前半でAlgoliaの基本的な機能や用語についてまとめ、Algoliaがざっくりどんなことができるサービスなのかを紹介します。そして後半で[Nuxt.js](https://ja.nuxtjs.org/)を使って実際にタスク管理アプリを実装しながら、ハンズオン形式でAlgoliaの具体的な使い方について解説します。

## 対象読者
- Vue.js・Nuxt.jsなどのコンポーネント指向のSPAフレームワークを使ったことがある人
- Algoliaに興味はあるけど触ったことはない人
- Algoliaの使い方を実例を見ながら理解したい人
- 今日、今、まさにこの記事でAlgoliaの存在を知って「おっ、おもしろそうじゃん」と思った人
- 全文検索機能の実装に悩んでいる人

## Algoliaの概要
[Algolia](https://www.algolia.com/)は全文検索を始めとする検索機能をアプリケーションに手軽に取り入れることができるSaas（Search as a Service）です。

コンソールがわかりやすく、APIクライアントを公式で用意してくれているため、全文検索を始めとする各種検索機能を簡単に実現することができます。

しかも[公式ドキュメント内](https://www.algolia.com/doc/guides/getting-started/what-is-algolia/?language=php#algolia-in-a-few-words)で「世界中どこでも100msで検索結果を返すぜ！」と謳っているのでパフォーマンスにかなり力を入れているサービスです。

この節ではそんなAlgoliaの用語や機能をざっくり説明します。

### インデックスとレコード
RDBでいう所のテーブルとレコードのような概念として、Algoliaにはインデックスとレコードという概念があります。

インデックスは複数のデータを束ねる親の概念で、スキーマレスである（あらかじめレコードの形を決めなくて良い）点と検索に特化した造りになっている点でRDBのテーブルと異なります。

インデックスの中の要素それぞれがレコードと呼ばれ、その実態はスキーマレスなJSONオブジェクトです。

レコードの構造が自由なので、（極端な例ですが）例えば以下のように全く異なる構造のJSONオブジェクトを1つのインデックス内で共存させることができます。

```json
{
  "name": "田中太郎",
  "type": "Person",
  "gender": "man",
  "age": 20
}
{
  "title": "吾輩は猫である",
  "type": "Book",
  "price": 1200,
  "author": "夏目漱石"
}
{
  "name": "忠犬ハチ公像",
  "type": "Place",
  "latitude": 35.658034,
  "longitude": 139.701636
}
```

### 利用可能なデータ型
Algoliaのレコードには以下のデータ型を含めることができます。

```json
{
  "string": "あいうえお",
  "int": 10,
  "float": 3.1415,
  "boolean": true,
  "nested_object": {
    "field1": 123,
    "field2": "abc"
  },
  "array": [
    "this",
    "is",
    "array"
  ]
}
```

### 機能
Algoliaの持つ機能から無料で利用可能なものを抜粋しました。この記事の実装で扱うものには「★」をつけています。

| 機能 | 説明 |
| --- | --- |
| インデックスの管理★ | APIを使ってレコードの登録・更新・削除ができる |
| 全文検索★ | インデックスに登録されたレコードを全文検索する。レコード内の言語は問わない（例えば右から左に読むような言語にも対応している）し、タイポも考慮した検索結果を返してくれる |
| ハイライト★ | 全文検索でヒットした箇所をハイライトして画面に表示することができる |
| スニペット | 検索結果の全体ではなく、マッチした周辺のみを検索結果として返す |
| ファセット | レコードにカテゴリを付けることで指定したカテゴリのみ検索結果に含めることができる。例えば、「本」カテゴリの中から「夏目漱石」で全文検索することができる |
| ランキングの設定 | どのようなレコードを優先して表示するかを簡単に変更できる |
| 類義語 | 検索エンジンに同じ意味として解釈してほしい単語（=類義語）を登録しておくことで、検索の際に考慮してくれるようになる |
| 位置情報検索 | 位置情報をもとに検索することができる |

有料プランやエンタープライズプランでは、上記に加えてユーザの分析・サービスの監視・検索結果の個人最適化など、より多くの機能が提供されるようです。

### お値段
こちらはあくまで記事の初稿執筆時点（2018年12月）のものですのであくまで目安としてご覧ください。最新の無料枠や有料プランの詳細については[こちら](https://www.algolia.com/pricing)をご覧ください

#### 無料プラン
Algoliaには以下のような無料プランがあリます。個人開発レベルのアプリケーションやプロトタイプ作成の用途であれば、（よほどバズったり有名になったりしなければ）実用に耐えうるのではないでしょうか。

| 対象 | 制限 |
| --- | --- |
| 1秒あたりのクエリ | 30query/sec |
| 1レコードの最大サイズ | 10KB |
| 最大レコード数 | 1万レコード |
| 最大オペレーション数 | 10万オペレーション/month |

※ 1オペレーション = 検索1回 or レコードの登録・変更・削除1回

#### 有料プラン
有料プランには従量課金のプランと定額プランがあります。

従量課金は以下の通り。
個人開発用途で考えるとなかなかのお値段ですね...。

- +5万レコード毎に$25
- +10万オペレーション毎に$10

### エコシステム
[このページ](https://www.algolia.com/doc/api-reference/)に主要な言語から利用可能なAPIクライアントや各種フレームワークからAlgoliaをより簡単に使えるようにするライブラリを見ることができます。

また、この記事ではUIを自作しますが、各種フレームワーク向けに[公式のUIライブラリ](https://www.algolia.com/doc/guides/search-ui/search-libraries/)も公開されています。

## ハンズオン

### 完成イメージ
いよいよAlgoliaを使ってアプリケーションを実装していきます。

作成するアプリは以下のような機能を持つシンプルなタスク管理アプリケーションです。

- タスクの追加
- タスクを検索
- 検索結果の該当部分をハイライトする

完成イメージは以下のgif画像のようになります。

![(null).2018-12-11 05_43_01.gif](https://qiita-image-store.s3.amazonaws.com/0/216033/9ec7e11a-2078-f693-56bc-4988fd456b68.gif)

### 利用するライブラリのバージョン
今回利用するライブラリは以下の通りです。

最低限の見た目を整えるために[Bootstrap Vue](https://bootstrap-vue.js.org/)というVue.js用のコンポーネントライブラリを導入していますが、この記事の主役はAlgoliaなので詳細な解説はしません。見た目はどうでもいいからAlgoliaの機能だけ体験したいという方は無視して通常のHTMLタグを使って進めていただいても構いません。

手順通り実行していってもうまくいかない場合はライブラリのバージョンを確認してみてください。

| npmパッケージ | バージョン |
| --- | --- |
| nuxt | 2.14.6 |
| algoliasearch | 4.6.0 |
| bootstrap-vue | 2.17.3 |

### Nuxt.jsでHello, world!
何はともあれHello world!します。

まず、プロジェクトフォルダを作って必要なライブラリを入れます。

```sh
# プロジェクトフォルダを作る
mkdir algolia-sample-todo-app
cd algolia-sample-todo-app

# npmでHello, worldに必要なライブラリをインストール
npm init
npm install --save nuxt bootstrap-vue
```

プロジェクトルートに`nuxt.config.js`を作成して以下のように設定します。

```js:nuxt.config.js
module.exports = {
  // 今回はSPAとして開発します
  mode: 'spa',

  // Bootstrap Vueを使えるようにします
  modules: [,
    'bootstrap-vue/nuxt'
  ]
}
```

最後に、Hello, world!と表示するだけのインデックスページを作ります。

`pages/index.vue`を作成し、以下のように記載してください。

```vue:pages/index.vue
<template>
  <div>
    <h1>Hello, Nuxt.js</h1>
  </div>
</template>

<script>
export default {
}
</script>
```

あとは、`package.json`に以下のようにnpmスクリプトの設定をして`npm run dev`と実行すると`localhost:3000`でページが立ち上がります。

```json:package.json
{
  ...（中略）...

  "scripts": {
    "dev": "nuxt"
  },

  ...（中略）...
}
```

出来上がった画面は以下のようになっているはずです。

<img width="551" alt="スクリーンショット 2018-12-11 2.25.09.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/b3236a11-5a11-9628-7899-c26b80b292c5.png">

### Algoliaのアカウント登録
次に、Algoliaのアカウント登録をします。

[こちら](https://www.algolia.com/)のサイトにアクセスし、右上の「SIGN UP」から登録します。

アカウント情報を入力して、
<img width="1150" alt="スクリーンショット 2018-12-11 2.35.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/4f58dda3-262d-a9b0-8c22-352f52de4d97.png">

データセンターを選択して、
<img width="1139" alt="スクリーンショット 2018-12-11 2.35.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/0cce5765-e499-f92b-8e35-0c75acb04568.png">

プロジェクト情報を入力すれば完了です。
<img width="1114" alt="スクリーンショット 2018-12-11 2.36.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/5761be08-43a8-9a5a-cdbc-76653f8cc9a7.png">

登録完了後、このような画面に飛ぶので「Go to dashboard」
からダッシュボードへ移動します。
<img width="996" alt="スクリーンショット 2018-12-11 2.39.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/6c5e75d9-9c3b-162a-bd0c-b877ba9ff1d5.png">

ダッシュボードへ移動すると何やらチュートリアルの案内が出てきますが今回は「Skip For Now」で無視します。
<img width="870" alt="スクリーンショット 2018-12-11 2.40.55.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/40bbd1f4-500a-2253-0ddf-d72618a32b3c.png">

### アプリIDとAPIキー取得

サイドバーにある「API Keys」メニューをクリックすると、アプリケーションIDとAPIキーを取得できる画面に遷移します。

<img width="995" alt="スクリーンショット 2018-12-11 2.45.57.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/93460a84-f314-4a49-11b9-433ecd39c199.png">


AlgoliaのAPIを利用するために必要なため、`algolia.config.js`というファイルをプロジェクトのルート（`package.json`と同じフォルダ内）に作成し、「Application ID」と「Admin API Key」を以下のように設定します。

```js:algolia.config.js
export default {
  appId: '<APPLICATION ID>',
  apiKey: '<ADMIN API KEY>'
}
```

### 【注意！！！】 APIキーの取り扱い
今回APIキーとして管理者キーを利用しますが、これはAlgoliaの機能をできるだけ簡単なハンズオン形式で体験していただくためです。フロントエンドもバックエンドも実装しなければならないハンズオンにしてしまうと重たい記事になりそう、という思いからフロントエンドで全ての処理を実装しています。

管理者キーが第3者の手に渡ってしまうと、インデックスやレコードを好き放題操作することができてしまいます。そのため、今回のように**フロントエンドのコードに管理者キーを埋め込むことは、絶対本番運用では絶対にやってはいけません。**

本番運用のフロントエンドでは検索のみ可能なAPIキーを利用し、管理者キーを利用したデータの追加・更新・削除は必ずバックエンドで行うようにしてください。

今回の例はあくまでも簡単のための措置であることをご理解ください。

### APIクライアントをインストール
以下のコマンドを実行して、AlgoliaのJavaScript用APIクライアントである[algoliasearch](https://github.com/algolia/algoliasearch-client-javascript)をインストールします。

```sh
npm install --save algoliasearch
```

algoliasearchはAlgoliaのAPIの呼び出し処理をラップし、実装を簡単に行えるようにしてくれたものです。

### タスクの登録機能
では、実際にAlgoliaにデータを登録してみましょう。

`pages/index.vue`を以下のように修正します。

```vue:pages/index.vue
<template>
  <!-- b-XXXという名前のタグはBootstrap Vueのコンポーネントです -->
  <div>
    <b-button class="my-2" variant="primary" block @click="openRegisterModal">TODOを追加</b-button>

    <!-- 登録用ダイアログ -->
    <b-modal v-model="registerModalIsVisible"
             @ok="registerTodo"
             @calcel="clearInput">
      <b-form-group label="Title" label-for="title">
       <b-form-input id="title" type="text" v-model="todoInput.title" />
      </b-form-group>
      <b-form-group label="Description" label-for="description">
       <b-form-input id="description" type="text" v-model="todoInput.description" />
      </b-form-group>
    </b-modal>
  </div>
</template>

<script>
import * as algoliasearch from 'algoliasearch'
import config from '~/algolia.config.js'

const client = algoliasearch(config.appId, config.apiKey)
const index = client.initIndex('todo')

export default {
  data () {
    return {
      todoInput: {
        title: '',
        description: '',
        done: false
      },
      registerModalIsVisible: false
    }
  },
  methods: {
    clearInput () {
      this.todoInput.title = ''
      this.todoInput.description = '',
      this.todoInput.done = false
    },
    openRegisterModal () {
      this.registerModalIsVisible = true
    },
    async registerTodo () {      
      await index.saveObject(this.todoInput, { autoGenerateObjectIDIfNotExist: true })
      this.clearInput()
    }
  }
}
</script>
```

すると画面はこのようになります。

![(null).2018-12-11 03_53_43.gif](https://qiita-image-store.s3.amazonaws.com/0/216033/855e4292-b52b-9c2a-f930-0857a3bc7fb3.gif)

まずスクリプトの一番上で`algoliasearch`とアプリケーションIDなどを保存した`algolia.config.js`を読み込んで、APIクライアントを初期化しています。

```js
import * as algoliasearch from 'algoliasearch'
import config from '~/algolia.config.js'

const client = algoliasearch(config.appId, config.apiKey)
```

その後、タスクの登録先となるtodoインデックスにアクセスするためのオブジェクトを取得しています。

```js
const index = client.initIndex('todo')
```

ここで「ん？ Algoliaにアカウント登録はしたけど、インデックスを作るなんて操作はしていないぞ」と思った方、心配いりません。

Algoliaはレコード登録時に対象のインデックスが存在しない場合、その場でインデックスを作成してくれるのです。

そのため、モーダルの「OK」ボタンを押したときに実行される以下のメソッドで初めてレコードを登録する際に、インデックスは勝手に作成されます。

```js
async registerTodo () {      
  await index.saveObject(this.todoInput, { autoGenerateObjectIDIfNotExist: true })
  this.clearInput()
}
```

レコードの登録は非常に簡単で、`index.saveObject(登録したいオブジェクト, { autoGenerateObjectIDIfNotExist: true })`
を呼び出すだけです。

実際にTODOを追加してからAlgolia画面に戻り、サイドバーから「Indices」を開いてみてください。

以下のようにtodoインデックスができ、レコードが追加されていることが確認できれば成功です！

<img width="973" alt="スクリーンショット 2018-12-11 3.55.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/216033/c93f023a-176a-2f24-fe8e-bc896c8df168.png">

※ ちなみに、IndicesはIndexと同じ意味です。アメリカ英語とイギリス英語の違いなんだそう（公式ドキュメントより）

### 全文検索機能
無事タスクを登録できたので、いよいよ本題の登録したタスクを全文検索する機能を作ります。レコードが少ないと検索の恩恵が感じられないため、実装を始める前にいくつかタスクを追加しておくと良いです。

`pages/index.vue`を以下のように修正します。

```diff:pages/index.vue
<template>
  <div>
+   <b-form-row>
+     <b-col cols="10">
+       <b-form-input type="text" v-model="query" />
+     </b-col>
+     <b-col cols="2">
+       <b-button block @click="searchTodo">検索</b-button>
+     </b-col>
+   </b-form-row>
    <b-button class="my-2" variant="primary" block @click="openRegisterModal">TODOを追加</b-button>

+   <!-- TODO一覧 -->
+   <b-card class="my-2"
+           v-for="todo in todoList" 
+           :key="todo.objectID"
+           :title="todo.title">
+     <p>{{ todo.description }}</p>
+   </b-card>

    <!-- 登録用ダイアログ -->
    <!-- 変更がないため省略 -->
  </div>
</template>

<script>
import * as algoliasearch from 'algoliasearch'
import config from '~/algolia.config.js'

const client = algoliasearch(config.appId, config.apiKey)
const index = client.initIndex('todo')

export default {
+ async asyncData () {
+   let searchResult = await index.search('')
+   return {
+     todoList: searchResult.hits
+   }
+ },
  data () {
    return {
      todoInput: {
        title: '',
        description: '',
        done: false
      },
      registerModalIsVisible: false,
+     query: '',
+     todoList: []
    }
  },
  methods: {
    // 変更がないため他のメソッドは省略
+   async searchTodo () {
+     let searchResult = await index.search(this.query)
+     this.todoList = searchResult.hits
+   }
  }
}
</script>
```

検索の実装も非常に簡単で、`index.search('検索したい文字列をここに入力')`とするだけで全文検索できてしまいます。また、クエリ部分を空にして`index.search('')`とすると全件取得することができます。検索結果は戻り値として受け取ったオブジェクトの`hits`プロパティに入っているので、これを利用します。

新しく追加された`asyncData`メソッドはNuxt.jsのライフサイクルフックです。画面が描画される前に非同期通信を行いたい場合に利用し、returnした値がdataメソッドで定義したローカルステートとマージされます。

今回の場合、画面表示前に非同期処理で全件取得したタスクの一覧を、ローカルステートの`todoList`に入れる処理をしています。そのため、画面を表示したときにはすでにタスクの一覧が表示されている状態となります。

ここまでで、見た目は以下のようになります。

![(null).2018-12-11 05_11_58.gif](https://qiita-image-store.s3.amazonaws.com/0/216033/e322e7ca-e392-2c80-a08e-b6311646baaa.gif)

無事全文検索機能が実装できていそうですね！

ただし、今の状態では新しくタスクを追加した際そのタスクが一覧に出てこないという問題があります。これは、Algoliaのクライアントがレスポンスをキャッシュするためです。

この記事の中ではその問題には対応しませんが、公式ドキュメントの[Cache on requests and responses](https://www.algolia.com/doc/api-client/advanced/configure-the-client/javascript/?language=javascript#cache-on-requests-and-responses)を参照してください。

### ハイライト機能
最後にハイライト機能を実装します。

今回は検索する文字列にマッチした部分の色が黄色になるように実装してみます。

`pages/index.vue`を以下のように修正してください。

```diff:pages/index.vue
<template>
  <div>
    <!-- 変更がないため省略 -->

    <!-- TODO一覧 -->
    <b-card class="my-2"
-           v-for="todo in todoList" 
+           v-for="todo in highlightedTodoList" 
            :key="todo.objectID">
+     <b-card-title v-html="todo.title"></b-card-title>
-     <p>{{ todo.description }}</p>
+     <p v-html="todo.description"></p>
    </b-card>

    <!-- 登録用ダイアログ -->
    <!-- 変更がないため省略 -->
  </div>
</template>

<script>
import * as algoliasearch from 'algoliasearch'
import config from '~/algolia.config.js'

const client = algoliasearch(config.appId, config.apiKey)
const index = client.initIndex('todo')

+index.setSettings({
+  attributesToHighlight: [
+    'title',
+    'description'
+  ],
+  highlightPreTag: '<em class="search-highlight">',
+  highlightPostTag: '</em>'
+})

export default {
  async asyncData () {
    // 変更がないため省略
  },
  data () {
    // 変更がないため省略
  },
  methods: {
    // 変更がないため省略
- }
+ },
+ computed: {
+   highlightedTodoList () {
+     return this.todoList.map(todo => {
+       let highlightedTodo = Object.assign({}, todo)
+       if (todo._highlightResult && todo._highlightResult.title.matchLevel !== 'none') {
+         highlightedTodo.title = todo._highlightResult.title.value
+       }
+       if (todo._highlightResult && todo._highlightResult.description.matchLevel !== 'none') {
+         highlightedTodo.description = todo._highlightResult.description.value
+       }
+       return highlightedTodo
+     })
+   }
+ }
}
</script>

+<style>
+.search-highlight {
+  background-color: yellow;
+}
+</style>
```

まず、スクリプトの最初に追加した以下の設定部分ですが、ここではハイライトに関する設定をしています。

```js
index.setSettings({
  attributesToHighlight: [
    'title',
    'description'
  ],
  highlightPreTag: '<em class="search-highlight">',
  highlightPostTag: '</em>'
})
```

それぞれの設定内容は

- attributesToHighlight ： ハイライトの対象になるレコードの項目名
- highlightPreTag ： マッチした部分の前に追加されるHTMLタグ
- highlightPostTag ： マッチした部分の後ろに追加されるHTMLタグ

となります。

この設定によって、`title`または`description`の中で検索文字列にマッチした部分があれば`<em class="search-highlight">マッチした文字列</em>`のようになります。

ハイライトに関する情報は各検索結果の`_highlightResult`プロパティから取得できます。`_highlightResult`は以下のような形式です。

```js
{
    value: 'ここはマッチしていない<em class="search-highlight">マッチした文字列</em>ここはマッチしていない',
    matchLevel: 'full/partial/none',
    fullyHighlighted: true/false,
    matchedWords: [ "検索文字列の内マッチしたワードの配列" ]
}
```

今回の例では、`computed`プロパティとして`highlightedTodoList`を作成し、ローカルステートの`todoList`がハイライト情報を含んでいればそちらを画面に表示するように書き換えています。

ハイライトされた文字列には`search-highlight`クラスがつくことになっているので、その背景が黄色になるようスタイルを当ててハイライトしています。

以上でタスクの追加と全文検索、そしてハイライトができるアプリの完成です！

実装開始前に貼ったものと同じですが、以下が完成イメージになります。

![(null).2018-12-11 05_43_01.gif](https://qiita-image-store.s3.amazonaws.com/0/216033/2573269a-d379-43fd-8ebc-a9ef39b57264.gif)


## まとめ
この記事ではAlgoliaの概要を理解し、ハンズオン形式で全文検索可能なタスク管理アプリケーションを作るところまでを解説しました。

実際にAlgoliaに触れてみて、Algoliaを導入するメリットは開発者フレンドリーなドキュメント、エコシステム、そしてAlgolia自体の素晴らしい機能群によりとにかく素早く検索機能を取り入れられること、逆にデメリットはお値段が高いことくらいなのかなと感じました。

なので、個人開発で小規模なアプリを作ったり、スピード感を持ってプロトタイプを作ったりする場合にAlgoliaは大活躍しそうです。また、高価なプランで契約できるプロジェクトの場合にも、パーソナライズや分析を始めとするAlgoliaの強力な機能が簡単に導入できるのは魅力的なのではないかと感じました。

これを機に興味を持っていただけた方はぜひ自分でいじってみたり、個人で開発しているアプリケーションに組み込んでみたりして遊んでみてください。

## ソースコード
今回記事で作成した機能に加え、以下の機能を盛り込んだ完成版として[GitHub](https://github.com/mido-app/algolia-sample-todo-app)にソースコードを公開しています。

- タスクの編集・削除
- タスクの完了
- （追加したタスクが一覧に出ない問題の対処） ← 一部面倒になって対応しきれていない部分があります

※ 繰り返しになりますが、APIキーの取り扱いに注意してご利用ください