LoginSignup
67
63

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-02-11

はじめに

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

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

その2以降の記事
RailsとElasticsearchで検索機能をつくり色々試してみる - ドキュメントの操作
RailsとElasticsearchで検索機能をつくり色々試してみる - Rspec
RailsとElasticsearchで検索機能をつくり色々試してみる - サジェスト機能の追加
RailsとElasticsearchで検索機能をつくり色々試してみる - Synonym(類義語)編

サンプルアプリケーション

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

search_sample.mov.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

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

Rails newまでの流れ

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

docker-compose.yml

プロジェクトのルートに以下のようにファイルを置きます。

.
├── Dockerfile
├── docker
│   ├── es
│   │   └── Dockerfile
│   └── mysql
│       └── my.cnf
└── docker-compose.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
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
docker/es/Dockerfile
# ElasticDocker
FROM docker.elastic.co/elasticsearch/elasticsearch:6.5.4
# 日本語をあつかうときに使うプラグイン
RUN bin/elasticsearch-plugin install analysis-kuromoji

./docker/mysql/my.cnfは本題ではないの割愛します。
一応こちらにのせておきます。

imageのbuildと起動

# 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プロジェクトを作成していきます

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

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

gemファイルを編集

./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

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

# railsがコメントアウトされているので外す
gem "rails"

Railsのインストールとプロジェクト作成

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

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

mysql用の設定

mysqlのアダプタ追加

Gemfile
# gem 'sqlite3'
gem 'mysql2'
/app# bundle install

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

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起動

/app# bundle exec rails s

起動確認

rails

ブラウザでhttp://localhost:3003/ にアクセスして、いつものやつが表示されることを確認
スクリーンショット 2019-02-10 19.11.20.png

Elasticsearch


$ 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

スクリーンショット 2019-02-10 19.49.23.png

ER図

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

スクリーンショット 2019-02-09 14.46.36.png

モデルとテーブルの作成

migrationファイルを作成していきます。

# 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にデータを準備をしていきます。(追加するデータのサンプルをこちら

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

コントローラ、ビュー、ルーティングの追加

rails g でファイルを作成して修正していきます。

/app# bundle exec rails g controller Mangas index --helper=false --assets=false
app/controllers/mangas_controller.rb
class MangasController < ApplicationController
  def index
    @mangas = Manga.all
  end
end
config/routes.rb
Rails.application.routes.draw do
  resources :mangas, only: %i(index)
end
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

Gemfile
gem "bulma-rails", "~> 0.7.2"

css -> scssに変更して、bulmaをimportする

app/assets/stylesheets/application.scss
 /
 *= require_tree .
 *= require_self
 */

 @import "bulma";

styleの調整

修正内容

修正後はこんな感じです。
スクリーンショット 2019-02-10 22.55.20.png

Elasticsearch用のgem追加

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

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

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をモデルに追加することで様々なメソッドが使えるようになります。
ドキュメント

elasticsearch-rails

Elasticsearchを使うためのrakeタスクやloggerのカスタマイズ、templateの提供などができるようです。
ドキュメント

config設定

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

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するようにします。

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

  belongs_to :author
  belongs_to :publisher
  belongs_to :category
end
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との接続確認

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の作成


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

データの登録

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

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_matchcross_fieldsを指定しています。指定できるクエリなどはドキュメントに詳しくのっています。

  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メソッドで検索します。検索ワードが空の場合は全てのデータを取得します。

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の修正

検索窓を追加します。

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

ページネーション

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

gem追加

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にpageperを追加します。またElasticsearchを経由しない検索の場合にも追加します。

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のテンプレートを作成します。

/app# bundle exec rails g kaminari:views default

を実行するとapp/views/kaminari以下にファイルが作成されるので、これらのファイルを修正していきます。
細かい修正が多いので割愛しますが、修正版はこちらにのせておきます。

まとめ

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

67
63
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
67
63