RailsとElasticsearchで検索機能をつくり色々試してみる - その１：サンプルアプリケーションの作成

## はじめに
RailsアプリケーションでElasticsearchを使った検索機能を実装する機会があったため、その際に調査して試したことなどを複数回に分けてまとめていく予定です。

その１としてdocker-composeを使ったローカル環境構築と簡単な検索ができるサンプルアプリケーションを作成していきます。
その２以降で検索機能のカスタマイズや実運用を想定した実装などもう少し掘り下げたところを書いていく予定です。

その2以降の記事
[RailsとElasticsearchで検索機能をつくり色々試してみる - ドキュメントの操作](https://qiita.com/yamashun/items/57ca49677df62f928b05)
[RailsとElasticsearchで検索機能をつくり色々試してみる - Rspec](https://qiita.com/yamashun/items/96d7309c038021e4252e)
[RailsとElasticsearchで検索機能をつくり色々試してみる - サジェスト機能の追加](https://qiita.com/yamashun/items/e1f2157e1b3cf3a716e3)
[RailsとElasticsearchで検索機能をつくり色々試してみる - Synonym（類義語）編](https://qiita.com/yamashun/items/bc164a56338b5a50c951)

## サンプルアプリケーション
登録した漫画の情報を検索して表示するアプリケーションを作成していきます。

![search_sample.mov.gif](https://qiita-image-store.s3.amazonaws.com/0/132008/b43df5ec-6336-bb45-b7df-7009dd783573.gif)

## 環境
- Ruby 2.5.3
- Rails 5.2.2
- Mysql 5.7
- Elatsticsearch 6.5.4
- Kibana 6.5.4

### 構成
docker-composeを使ってローカル環境を作成します。

![docker-compose_image.png](https://qiita-image-store.s3.amazonaws.com/0/132008/3902612c-a68f-b336-e636-76f729a44025.png)

Rails：アプリケーション本体
Mysql：データの永続化
Elasticsearch：検索で使用
Kibana：アプリケーションそのものとは無関係（Elasticsearchで色々試すときに使う）

## Rails newまでの流れ
docker-composeを使って環境をつくりRailsとElasticsearchを起動するまでの流れを書いていきます。（本題とはあまり関係ないので不要な人は読み飛ばしてください）

### docker-compose.yml
プロジェクトのルートに以下のようにファイルを置きます。

```sh
.
├── Dockerfile
├── docker
│   ├── es
│   │   └── Dockerfile
│   └── mysql
│       └── my.cnf
└── docker-compose.yml
```


```yml:docker-compose.yml

version: '3'
services:
  # Elasticsearch用のコンテナ
  es:
    build: ./docker/es
    container_name: es_sample
    environment:
      - cluster.name=rails-sample-cluster
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - es_sample_data:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
  # Kibana用のコンテナ
  kibana:
    image: docker.elastic.co/kibana/kibana:6.5.4
    environment:
      SERVER_NAME: localhost:5601
      ELASTICSEARCH_URL: http://es_sample:9200
    ports:
      - 5601:5601
    depends_on:
      - es
  # MYSQL用のコンテナ
  db:
    environment:
      - MYSQL_ROOT_PASSWORD=docker
      - MYSQL_PASSWORD=docker
      - MYSQL_USER=docker
      - MYSQL_DATABASE=rails_es_sample
    build: ./docker/mysql
    ports:
      - "3306:3306"
  # Rails用のコンテナ
  rails:
    build: .
    # 必要であればshなどに bundle install や rails s を実行してrailsを起動する処理を書く
    # command: scripts/start-server.sh
    volumes:
      - .:/app
      # 公式のDockerfile(ruby:2.5.3-stretch)では環境変数のBUNDLE_APP_CONFIGがデフォルトで
      # /usr/local/bundleに設定されているため、dockerのローカルvolumeでマウントしてそこにgemを入れている
      - vendor_bundle:/user/local/bundle
    ports:
      - "3003:3000"
    links:
      - db
      - es
    environment:
      - RAILS_DATABASE_USERNAME=root
      - RAILS_DATABASE_PASSWORD=docker
      - RAILS_DATABASE_NAME=rails_es_sample
      - RAILS_DATABASE_HOST=db
    tty: true
    stdin_open: true

volumes:
  es_sample_data:
    driver: local
  vendor_bundle:
    driver: local
```

```dockerfile:./Dockerfile
FROM ruby:2.5.3-stretch

ENV BUNDLE_GEMFILE=/app/Gemfile \
    BUNDLE_JOBS=2 \
    RAILS_ENV=development \
    LANG=C.UTF-8

RUN apt-get update -qq
RUN apt-get install -y build-essential 
RUN apt-get install -y libpq-dev
RUN apt-get install -y nodejs

# ワーキングディレクトリの設定
RUN mkdir /app
WORKDIR /app
```

```dockerfile:docker/es/Dockerfile
# ElasticDocker
FROM docker.elastic.co/elasticsearch/elasticsearch:6.5.4
# 日本語をあつかうときに使うプラグイン
RUN bin/elasticsearch-plugin install analysis-kuromoji
```

`./docker/mysql/my.cnf`は本題ではないの割愛します。
一応[こちら](https://github.com/yamashun/rails_es_sample/blob/master/docker/mysql/my.cnf)にのせておきます。

### imageのbuildと起動

```sh
# imageのbuildと起動
$ docker-compose up -d

# 起動確認
$ docker-compose ps
          Name                        Command               State                 Ports
-----------------------------------------------------------------------------------------------------
es_sample                  /usr/local/bin/docker-entr ...   Up      0.0.0.0:9200->9200/tcp, 9300/tcp
rails_es_sample_db_1       docker-entrypoint.sh mysqld      Up      0.0.0.0:3306->3306/tcp, 33060/tcp
rails_es_sample_kibana_1   /usr/local/bin/kibana-docker     Up      0.0.0.0:5601->5601/tcp
rails_es_sample_rails_1    irb                              Up      0.0.0.0:3003->3000/tcp
```

### rails new
コンテナに入りrailsプロジェクトを作成していきます

```sh
# コンテナに入る
# 「rails_es_sample_rails_1」 は docker-compose ps の Name
$ docker exec -it rails_es_sample_rails_1 /bin/bash

# コンテナ内で実行
/app# bundle init
```

#### gemファイルを編集

```ruby:./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# railsがコメントアウトされているので外す
gem "rails"
```
#### Railsのインストールとプロジェクト作成

```sh
# railsのコンテナ内
/app# bundle install
/app# bundle exec rails new .

# 以下のようにgemfileを上書きするか聞かれますが、まだ何も追加していない状態なので「Y」で上書き
# Overwrite /app/Gemfile? (enter "h" for help) [Ynaqdhm]
```

#### mysql用の設定
mysqlのアダプタ追加

```ruby:Gemfile
# gem 'sqlite3'
gem 'mysql2'
```

```
/app# bundle install
```

database.ymlがデフォルトのままになっているため修正

```yaml:config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: docker
  host: db


development:
  <<: *default
  database: rails_es_sample
```


#### rails起動

```sh
/app# bundle exec rails s
```

### 起動確認
#### rails
ブラウザで`http://localhost:3003/` にアクセスして、いつものやつが表示されることを確認
<img width="628" alt="スクリーンショット 2019-02-10 19.11.20.png" src="https://qiita-image-store.s3.amazonaws.com/0/132008/2d1149bf-d81b-deb4-0664-714c6db1abbd.png">



### Elasticsearch

```sh

$ curl -XGET http://localhost:9200/
# 以下のようなクラスターやversionの情報が返ればOK
{
  "name" : "338gbNM",
  "cluster_name" : "rails-sample-cluster",
  "cluster_uuid" : "HphoN9CyQcmWeruBOQr1oQ",
  "version" : {
    "number" : "6.5.4",
    "build_flavor" : "default",
    "build_type" : "tar",
    "build_hash" : "d2ef93d",
    "build_date" : "2018-12-17T21:17:40.758843Z",
    "build_snapshot" : false,
    "lucene_version" : "7.5.0",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}
```

### kibana
ブラウザで`http://localhost:5601/app/kibana` にアクセスして以下のような画面が表示されればOK

<img width="690" alt="スクリーンショット 2019-02-10 19.49.23.png" src="https://qiita-image-store.s3.amazonaws.com/0/132008/00721b2d-e1eb-1af2-e31c-bf943ccf2f9a.png">

## ER図
環境は整ったのでサンプルアプリケーションの作成に入っていきます。
ER図ように漫画の情報と関連する作者、出版社、カテゴリを格納するテーブルをを作成していきます。

<img width="414" alt="スクリーンショット 2019-02-09 14.46.36.png" src="https://qiita-image-store.s3.amazonaws.com/0/132008/e13f0c8a-2343-e110-224e-5b88d6c65d64.png">

## モデルとテーブルの作成
migrationファイルを作成していきます。

```shell
# migrationファイルの作成
/app# bundle exec rails g model author name:string
/app# bundle exec rails g model publisher name:string
/app# bundle exec rails g model category name:string
/app# bundle exec rails g model manga author:references publisher:references category:references title:string description:text

# テーブルの作成
/app# bundle exec rails db:migrate
```

## データの準備
`db/seeds.rb`にデータを準備をしていきます。（追加するデータのサンプルを[こちら](https://github.com/yamashun/rails_es_sample/blob/master/db/seeds.rb)）

```sh
/app# db/seeds.rbを修正後に実行
bundle exec rails db:seed
```

## コントローラ、ビュー、ルーティングの追加
`rails g` でファイルを作成して修正していきます。

```sh
/app# bundle exec rails g controller Mangas index --helper=false --assets=false
```

```rb:app/controllers/mangas_controller.rb
class MangasController < ApplicationController
  def index
    @mangas = Manga.all
  end
end
```

```rb:config/routes.rb
Rails.application.routes.draw do
  resources :mangas, only: %i(index)
end
```


```erb:app/views/mangas/index.html.erb
<h1>Mangas</h1>

<table>
  <thead>
    <tr>
      <th>Aauthor</th>
      <th>Publisher</th>
      <th>Category</th>
      <th>Author</th>
      <th>Title</th>
      <th>Description</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @mangas.each do |manga| %>
      <tr>
        <td><%= manga.author.name %></td>
        <td><%= manga.publisher.name %></td>
        <td><%= manga.category.name %></td>
        <td><%= manga.author.name %></td>
        <td><%= manga.title %></td>
        <td><%= manga.description %></td>
      </tr>
    <% end %>
  </tbody>
</table>
```

## Bulmaを使ったスタイルの修正
この時点で、`http://localhost:3003/mangas`にアクセスすると登録したデータがリスト表示されるようになりますが、見た目がしょぼいので、**Bulma**というCSSフレームワークを使って少し見た目を整えます。


### Gem追加
gemを追加して`bundle install`

```rb:Gemfile
gem "bulma-rails", "~> 0.7.2"
```
css -> scssに変更して、bulmaをimportする

```scss:app/assets/stylesheets/application.scss
 /
 *= require_tree .
 *= require_self
 */
 
 @import "bulma";
```

### styleの調整
[修正内容](https://github.com/yamashun/rails_es_sample/commit/e7df8dad9c1dfc44c933aae5743d80590650ae9c)

修正後はこんな感じです。
<img width="871" alt="スクリーンショット 2019-02-10 22.55.20.png" src="https://qiita-image-store.s3.amazonaws.com/0/132008/2aff8bd4-9dea-4f78-18f7-56429dcfefef.png">


## Elasticsearch用のgem追加
前置きが長くなってしまいましたがここからElasticsearch関連の修正を加えていきます。

elasticの公式リポジトリにあるgemを使っていきます。

```rb
gem 'elasticsearch-model', github: 'elasticsearch/elasticsearch-rails', branch: '6.x'
gem 'elasticsearch-rails', github: 'elasticsearch/elasticsearch-rails', branch: '6.x'
```

### elasticsearch-model
`include Elasticsearch::Model`をモデルに追加することで様々なメソッドが使えるようになります。
[ドキュメント](https://www.rubydoc.info/gems/elasticsearch-model/)

### elasticsearch-rails
Elasticsearchを使うためのrakeタスクやloggerのカスタマイズ、templateの提供などができるようです。
[ドキュメント](https://www.rubydoc.info/gems/elasticsearch-rails)

### config設定
接続先の情報を設定します。

```rb:config/initializers/elasticsearch.rb
# 「es」はdocker-composeのservicesに設定した名前
config = {
    host:  ENV['ELASTICSEARCH_HOST'] || "es:9200/",
}

Elasticsearch::Model.client = Elasticsearch::Client.new(config)
```

## concernsの追加
Elasticsearch関連の処理をまとめるconcernを作成していきます。

concernのファイルを作成しmodelでincludeするようにします。

```rb:app/models/manga.rb
class Manga < ApplicationRecord
  include MangaSearchable

  belongs_to :author
  belongs_to :publisher
  belongs_to :category
end
```

```rb:app/models/concerns/manga_searchable.rb
module MangaSearchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model

    # ①index名
    index_name "es_manga_#{Rails.env}"

    # ②マッピング情報
    settings do
      mappings dynamic: 'false' do
        indexes :id,                   type: 'integer'
        indexes :publisher,            type: 'keyword'
        indexes :author,               type: 'keyword'
        indexes :category,             type: 'text', analyzer: 'kuromoji'
        indexes :title,                type: 'text', analyzer: 'kuromoji'
        indexes :description,          type: 'text', analyzer: 'kuromoji'
      end
    end

    # ③mappingの定義に合わせてindexするドキュメントの情報を生成する
    def as_indexed_json(*)
      attributes
        .symbolize_keys
        .slice(:id, :title, :description)
        .merge(publisher: publisher_name, author: author_name, category: category_name)
    end
  end

  def publisher_name
    publisher.name
  end

  def author_name
    author.name
  end

  def category_name
    category.name
  end

  class_methods do
    # ④indexを作成するメソッド
    def create_index!
      client = __elasticsearch__.client
      # すでにindexを作成済みの場合は削除する
      client.indices.delete index: self.index_name rescue nil
      # indexを作成する
      client.indices.create(index: self.index_name,
                            body: {
                                settings: self.settings.to_hash,
                                mappings: self.mappings.to_hash
                            })
    end
  end
end
```

①index名を設定します。誤った操作防止のため環境名を含めるようにしています。

②登録していくドキュメントのマッピング情報を定義しています。ここでフィールドのタイプや、使用するアナライザーなどを指定できます。また、settingsの情報も定義できますが、今回の例ではデフォルトのままとしています。

③モデルの情報を登録するために、mappingで定義した情報に合わせてjsonに変換するためのメソッドです。

④indexを作成するメソッド。作成済みの場合は再作成するように一度削除処理を入れています。


## 動作確認

`Elasticsearch::Model`をincudeすることでgemに追加されたメソッドなどが使えるようになります。
コンソールで動きを確認してみます。


### Elasticsearchとの接続確認

```sh
pry(main)> Manga.__elasticsearch__.client.cluster.health
=> {"cluster_name"=>"rails-sample-cluster",
 "status"=>"green",
 "timed_out"=>false,
 "number_of_nodes"=>1,
 "number_of_data_nodes"=>1,
 "active_primary_shards"=>1,
 "active_shards"=>1,
 "relocating_shards"=>0,
 "initializing_shards"=>0,
 "unassigned_shards"=>0,
 "delayed_unassigned_shards"=>0,
 "number_of_pending_tasks"=>0,
 "number_of_in_flight_fetch"=>0,
 "task_max_waiting_in_queue_millis"=>0,
 "active_shards_percent_as_number"=>100.0}
[5] pry(main)>
```

### indexの作成

```sh

pry(main)> Manga.create_index!
=> {"acknowledged"=>true, "shards_acknowledged"=>true, "index"=>"es_manga_development"}

```

### データの登録
importメソッドでmodelの情報を登録します。さきほど追加した`as_indexed_json`の形式に変換してデータが登録されるます。

```sh
pry(main)> Manga.__elasticsearch__.import
   (5.5ms)  SET NAMES utf8,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
  Manga Load (3.0ms)  SELECT  `mangas`.* FROM `mangas` ORDER BY `mangas`.`id` ASC LIMIT 1000
  Publisher Load (3.3ms)  SELECT  `publishers`.* FROM `publishers` WHERE `publishers`.`id` = 1 LIMIT 1
  Author Load (0.5ms)  SELECT  `authors`.* FROM `authors` WHERE `authors`.`id` = 1 LIMIT 1
```

## 検索機能の追加
Elasticsearchとの接続確認やデータの登録が完了したので、次は検索機能をつくっていきます。

### 検索用のメソッド追加
concernに検索用のメソッドを追加します。今回の例では複数のフィールドのいずれかにマッチするものを検索できるように、`multi_match`と`cross_fields`を指定しています。指定できるクエリなどは[ドキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html)に詳しくのっています。

```rb
  class_methods do
    # ...

    def es_search(query)
      __elasticsearch__.search({
        query: {
          multi_match: {
            fields: %w(id publisher author category title description),
            type: 'cross_fields',
            query: query,
            operator: 'and'
          }
        }
      })
    end
  end
end
```

### controllerの修正
search_wordというパラメータを受けとってさきほど作成したes_searchメソッドで検索します。検索ワードが空の場合は全てのデータを取得します。

```rb:app/controllers/mangas_controller.rb
class MangasController < ApplicationController
  def index
    @mangas = if search_word.present?
                Manga.es_search(search_word).records
              else
                Manga.all
              end
  end

  private

    def search_word
      @search_word ||= params[:search_word]
    end
end
```

### viewの修正
検索窓を追加します。

```erb:app/views/mangas/index.html.erb

// ...
    </div>
  </div>
</section>

// ヘッダーとテーブルの間に検索窓を追加
<div class="container" style="margin-top: 30px">
  <%= form_tag(mangas_path, method: :get, class: "field has-addons has-addons-centered") do %>
    <div class="control">
      <%= text_field_tag :search_word, @search_word, class: "input", placeholder: "漫画を検索する" %>
    </div>
    <div class="control">
      <%= submit_tag "検索", class: "button is-info" %>
    </div>
  <% end %>
</div>


<div class="container" style="margin-top: 50px">
  <table class="table is-striped is-hoverable">
// ...

```

### 動作確認
![search_sample2.mov.gif](https://qiita-image-store.s3.amazonaws.com/0/132008/9c81a87e-4d98-dae0-07e8-a549f9d90aa7.gif)


## ページネーション
とりあえず検索は動くようになりましたが検索結果のデータを全て表示するのは微妙なので、ページネーションを追加していきます。

### gem追加

```ruby:Gemfile
gem 'kaminari'
```
注意点としてはElasticsearchのgemよりも上に追加する必要があります
https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#pagination
> The pagination gems must be added before the Elasticsearch gems in your Gemfile, or loaded first in your application.

### コントローラ修正
Elasticsearchからのresponseに`page`と`per`を追加します。またElasticsearchを経由しない検索の場合にも追加します。

```ruby:app/controllers/mangas_controller.rb
  def index
    @mangas = if search_word.present?
                Manga.es_search(search_word).page(params[:page] || 1).per(5).records
              else
                Manga.page(params[:page] || 1).per(5)
              end
  end
```

### viewの修正
bulmaのスタイルを適用できるようにkaminariのテンプレートを作成します。

```sh
/app# bundle exec rails g kaminari:views default
```
を実行すると`app/views/kaminari`以下にファイルが作成されるので、これらのファイルを修正していきます。
細かい修正が多いので割愛しますが、修正版は[こちら](https://github.com/yamashun/rails_es/commit/cc0fd37af61dfd33e40960607cdaa88b5f2dddfc)にのせておきます。


## まとめ
少し長くなりましたが、docker-composeで環境をつくってrails newするところからElasticsearchで検索を行うサンプルアプリケーションを作成しました。
とりあえず動くものはできたという段階なので、次回はもう少し掘り下げたところを書いていきたいと思います。