はじめに
本稿は、RailsとElasticsearchで検索機能をつくり色々試してみる - その1:サンプルアプリケーションの作成 を一部改変した内容でRailsとElasticsearchのキーワード機能を実装するハンズオン資料です。
※ 主な変更点
・ Ruby / Railsのバージョンは2020年の執筆時点でほぼ最新に変更
・ Elasticsearch と PostgreSQL のみをDocker化
・ Railsは通常通りのローカル環境で動かせるようにした
違いがピンと来ない方もいらっしゃるかと思いますので補足しますとRails自体はDocker化しないことによってDockerの知見が少ない方でも学習上のデバッグや値の確認が容易に出来るようにしてあります。
このハンズオンで扱う技術
技術スタック | バージョン |
---|---|
Ruby | 2.6.5 |
Ruby on Rails | 6.0 |
Elasticsearch | 6.5.4 |
PostgreSQL | 12 |
Docker | 19.03.5 |
このハンズオンを終えると出来るようになること
・ Railsアプリでミドルウェア(Elasticsearch / PostgreSQL)のみをDocker化すること
・ Elasticsearchによる簡単なキーワード検索機能の実装
・ Elasticsearchでの簡単なテストをRSpecで書くこと
前提環境
・ Macであること
・ Docker for Macがインストールされていること
・ rbenvなどのRubyの実行環境がローカルに出来ていること
なるべくハマりどころは細かくメモしていこうと思いますので、
初心者の方も気軽にチャレンジしてみてください。
目次
結構ボリューミーですが、少しずつやっていきましょう!
手順1:アプリの土台を作成
手順2:debug用のgemを追加
手順3:Docker関連ディレクトリとファイルの新規作成
手順4:Docker上のPostgreSQLとRailsが疎通するための設定を追加する
手順5:Docker上で動くPostgreSQLとRailsの疎通確認
手順6:Elasticsearchの起動確認
手順7:検索対象となるモデルの定義
手順8:サンプルデータの投入
手順9:コントローラー・ビュー・ルーティングの追加
手順10:スタイルの調整
手順11:Elasticsearch用のgemの追加
手順12:ElasticsearchをRailsアプリ上で動かせるようにする
手順13:Elasticsearchの動作確認
手順14:検索機能の追加
手順15:ページネーションの追加
手順16:RSpecのセットアップ
手順17:Elasticsearchのテストを追加
Elasticsearchって何なの?
・ Elastic 社が開発しているオープンソースの全文検索エンジン
・ JSONフォーマットで柔軟にデータを格納出来るドキュメント指向データベース
・ 大量ドキュメントから目的の単語を含むドキュメントを高速に抽出出来る
これだけだとピンと来ない方もいると思うのですが、一旦今のところは キーワード検索をMySQLやPostgreSQLなどのRDBよりも高速に行うことが出来るデータベース
だと理解して先に進みましょう。
手順1:アプリの土台を作成
DBはPostgreSQLを使用します!
$ rails new elasticsearch_rails_sandbox --database=postgresql --skip-bundle
$ cd elasticsearch_rails_sandbox
手順2:debug用のgemを追加
※ 不要なら飛ばしてOK
値の確認などを行いやすいようにdebug用途のgemを入れておきます。
group :development, :test do
<中略>
# 以下の3つを新規で追加
gem 'pry-rails'
gem 'pry-byebug'
gem 'pry-doc'
end
手順3:Docker関連ディレクトリとファイルの新規作成
まずappディレクトリと同階層にdockerディレクトリとdocker-compose.ymlというファイルを作ります。次にdockerディレクトリの中にelasticsearchディレクトリとpostgresqlディレクトリを作り、elasticsearchディレクトリの中にのみDockerfileという拡張子なしのファイルを作ります。
そして、以下に続く設定の通りにdocker-compose.ymlとDockerfileを編集します。
ディレクトリ構成の確認
アプリ名のディレクトリ
├── app
├── docker
│ ├── elasticsearch
│ │ └── Dockerfile
│ └── postgresql
└── docker-compose.yml
docker-compose.ymlの設定
version: '3'
services:
postgresql:
image: postgres:12
volumes:
- ./docker/postgresql/data:/usr/local/var/postgres
ports:
- 127.0.0.1:5432:5432
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecretpassword1234
PGDATA: /usr/local/var/postgres
elasticsearch:
build:
context: .
dockerfile: ./docker/elasticsearch/Dockerfile
volumes:
- ./docker/elasticsearch/data:/usr/share/elasticsearch/data
ports:
- 127.0.0.1:9200:9200
environment:
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "xpack.security.enabled=false"
- "xpack.monitoring.enabled=false"
docker/elasticsearch/Dockerfileの設定
- PostgreSQL は一旦、
docker/postgresql
ディレクトリを作成するだけでOK - Elasticseachは
docker/elasticsearch/Dockerfile
にDocker上で使うElasticseachのバージョンとインストールするプラグインを記載する (日本語の形態素解析用のプラグインを入れています)
FROM docker.elastic.co/elasticsearch/elasticsearch:6.5.4
RUN bin/elasticsearch-plugin install analysis-kuromoji
手順4:Docker上のPostgreSQLとRailsが疎通するための設定を追加する
default: &default
adapter: postgresql
port: 5432
username: postgres
password: mysecretpassword1234
host: 127.0.0.1
encoding: unicode
pool: 5
development:
<<: *default
database: elasticsearch_rails_sandbox_development
test:
<<: *default
database: elasticsearch_rails_sandbox_test
production:
<<: *default
database: elasticsearch_rails_sandbox_production
username: elasticsearch_rails_sandbox
password: <%= ENV['ELASTICSEARCH_RAILS_SANDBOX_DATABASE_PASSWORD'] %>
ハマりポイント解説
docker-compose.ymlに記載した内容とdatabase.ymlの内容で齟齬があるとRailsがDBに接続出来ないので注意
username: postgres
password: mysecretpassword1234
↑上記の部分と↓以下の POSTGRES_USER
と POSTGRES_PASSWORD
の部分が一致してる必要があります
version: '3'
services:
postgresql:
image: postgres:12
volumes:
- ./docker/postgresql/data:/usr/local/var/postgres
ports:
- 127.0.0.1:5432:5432
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecretpassword1234
手順5:Docker上で動くPostgreSQLとRailsの疎通確認
dockerコンテナを作成・バックグラウンド起動します
# docker-compose.ymlと同じ階層で実行すること
$ docker-compose up -d
# 停止する場合は以下のコマンドを実行
$ docker-compose stop
# プロセスを確認したい場合はdocker-compose psで確認出来る
# statusがUpなら起動しているExitなら停止している
$ docker-compose ps
Name Command State Ports
-----------------------------------------------------------------------------------------------------------------
elasticsearch_rails_sandbox_elastic /usr/local/bin/docker-entr ... Up 127.0.0.1:9200->9200/tcp, 9300/tcp
search_1
elasticsearch_rails_sandbox_postgre docker-entrypoint.sh postgres Up 127.0.0.1:5432->5432/tcp
sql_1
PostgreSQLとRailsの疎通確認
ローカルでDB作成コマンドを実行し、 localhost:3000
にアクセスしてRailsの初期画面が表示出来たらOK。
$ bundle exec rails db:create
手順6:Elasticsearchの起動確認
事前に docker-compose up -d
でバックグラウンドで動かしておきましょう!
curlで起動確認します。
# docker-compose.ymlでElasticsearchに割り当てたポート番号をcurlする
$ curl -XGET http://localhost:9200/
{
"name" : "CBsfjEf",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "2BT15kU2RsKTYbD-a6x74w",
"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"
}
手順7:検索対象となるモデルの定義
$ bin/rails g model author name:string
$ bin/rails g model publisher name:string
$ bin/rails g model category name:string
$ bin/rails g model manga author:references publisher:references category:references title:string description:text
手順8:サンプルデータの投入
以下はサンプルデータ投入用のseed.rbです。
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
# Character.create(name: 'Luke', movie: movies.first)
# category
ct1 = Category.create(name: 'バトル・アクション')
ct2 = Category.create(name: 'ギャグ・コメディ')
ct3 = Category.create(name: 'ファンタジー')
ct4 = Category.create(name: 'スポーツ')
ct5 = Category.create(name: 'ラブコメ')
ct6 = Category.create(name: '恋愛')
ct7 = Category.create(name: '異世界')
ct8 = Category.create(name: '日常系')
ct9 = Category.create(name: 'グルメ')
ct10 = Category.create(name: 'ミステリー・サスペンス')
ct11 = Category.create(name: 'ホラー')
ct12 = Category.create(name: 'SF')
ct13 = Category.create(name: 'ロボット')
ct14 = Category.create(name: '歴史')
ct15 = Category.create(name: '少女漫画')
ct16 = Category.create(name: '戦争')
ct17 = Category.create(name: '職業・ビジネス')
ct18 = Category.create(name: 'お色気')
ct19 = Category.create(name: '学園もの')
# 出版社
pb1 = Publisher.create(name: '集英社')
pb2 = Publisher.create(name: '講談社')
pb3 = Publisher.create(name: '小学館')
pb4 = Publisher.create(name: '芳文社')
pb5 = Publisher.create(name: '双葉社')
# 作者
at1 = Author.create(name: '原泰久')
at2 = Author.create(name: '堀越耕平')
at3 = Author.create(name: '清水茜')
at4 = Author.create(name: '井上雄彦')
at5 = Author.create(name: '吉田秋生')
at6 = Author.create(name: '野田サトル')
at7 = Author.create(name: 'あfろ')
at8 = Author.create(name: '神尾葉子')
at9 = Author.create(name: '冨樫義博')
at10 = Author.create(name: '川上秦樹')
at11 = Author.create(name: 'こうの史代')
at12 = Author.create(name: '古舘春一')
at13 = Author.create(name: '三田紀房')
at14 = Author.create(name: '藤沢とおる')
# 漫画
Manga.create(title: "キングダム", publisher: pb1, author: at1, category: ct14, description: "時は紀元前―。いまだ一度も統一されたことのない中国大陸は、500年の大戦争時代。苛烈な戦乱の世に生きる少年・信は、自らの腕で天下に名を成すことを目指す!!")
Manga.create(title: "僕のヒーローアカデミア", publisher: pb1, author: at3, category: ct1, description: "多くの人間が“個性という力を持つ。だが、それは必ずしも正義の為の力ではない。しかし、避けられぬ悪が存在する様に、そこには必ず我らヒーローがいる! ん? 私が誰かって? HA‐HA‐HA‐HA‐HA! さぁ、始まるぞ少年! 君だけの夢に突き進め! “Plus Ultra!!")
Manga.create(title: "はたらく細胞", publisher: pb2, author: at3, category: ct1, description: "人間1人あたりの細胞の数、およそ60兆個! そこには細胞の数だけ仕事(ドラマ)がある! ウイルスや細菌が体内に侵入した時、アレルギー反応が起こった時、ケガをした時などなど、白血球と赤血球を中心とした体内細胞の人知れぬ活躍を描いた「細胞擬人化漫画」の話題作、ついに登場!!肺炎球菌! スギ花粉症! インフルエンザ! すり傷! 次々とこの世界(体)を襲う脅威。その時、体の中ではどんな攻防が繰り広げられているのか!? 白血球、赤血球、血小板、B細胞、T細胞...etc.彼らは働く、24時間365日休みなく!")
Manga.create(title: "スラムダンク SLAM DUNK 新装再編版", publisher: pb1, author: at4, category: ct4, description: '中学時代、50人の女の子にフラれた桜木花道。そんな男が、進学した湘北高校で赤木晴子に一目惚れ! 「バスケットは…お好きですか?」。この一言が、ワルで名高い花道の高校生活を変えることに!!')
Manga.create(title: "BANANA FISH バナナフィッシュ 復刻版全巻BOX", publisher: pb3, author: at5, category: ct15, description: 'フラワーコミックスの黄色いカバーを完全再現!!吉田秋生の不朽の名作が復刻版BOXとなって登場しました。フラワーコミックスの黄色いカバーを完全再現したコミックスと、特典ポストカードをセットにした完全保存版。ポストカードはファン垂涎の、アッシュ・英二のイラストをセレクトしたここでしか手に入らないオリジナルです。')
Manga.create(title: "ゴールデンカムイ", publisher: pb1, author: at6, category: ct1, description: '『不死身の杉元』日露戦争での鬼神の如き武功から、そう謳われた兵士は、ある目的の為に大金を欲し、かつてゴールドラッシュに沸いた北海道へ足を踏み入れる。そこにはアイヌが隠した莫大な埋蔵金への手掛かりが!? 立ち塞がる圧倒的な大自然と凶悪な死刑囚。そして、アイヌの少女、エゾ狼との出逢い。『黄金を巡る生存競争』開幕ッ!!!!')
Manga.create(title: "ゆるキャン△", publisher: pb4, author: at7, category: ct8, description: '富士山が見える湖畔で、一人キャンプをする女の子、リン。一人自転車に乗り、富士山を見にきた女の子、なでしこ。二人でカップラーメンを食べて見た景色は…。読めばキャンプに行きたくなる。行かなくても行った気分になる。そんな新感覚キャンプマンガの登場です!')
Manga.create(title: "花のち晴れ〜花男 Next Season〜", publisher: pb1, author: at8, category: ct6, description: '英徳学園からF4が卒業して2年…。F4のリーダー・道明寺司に憧れる神楽木晴は、「コレクト5」を結成し、学園の品格を保つため“庶民狩りを始めた!! 隠れ庶民として学園に通う江戸川音はバイト中に晴と遭遇し!?')
Manga.create(title: "HUNTER×HUNTER ハンター×ハンター", publisher: pb1, author: at9, category: ct1, description: '父と同じハンターになるため、そして父に会うため、ゴンの旅が始まった。同じようにハンターになるため試験を受ける、レオリオ・クラピカ・キルアと共に、次々と難関を突破していくが…!?')
Manga.create(title: "転生したらスライムだった件", publisher: pb2, author: at10, category: ct7, description: '通り魔に刺されて死んだと思ったら、異世界でスライムに転生しちゃってた!?相手の能力を奪う「捕食者」と世界の理を知る「大賢者」、2つのユニークスキルを武器に、スライムの大冒険が今始まる!異世界転生モノの名作を、原作者完全監修でコミカライズ!')
Manga.create(title: "この世界の片隅に", publisher: pb5, author: at11, category: ct16, description: '平成の名作・ロングセラー「夕凪の街 桜の国」の第2弾ともいうべき本作。戦中の広島県の軍都、呉を舞台にした家族ドラマ。主人公、すずは広島市から呉へ嫁ぎ、新しい家族、新しい街、新しい世界に戸惑う。しかし、一日一日を確かに健気に生きていく…。')
Manga.create(title: "スラムダンク SLAM DUNK", publisher: pb1, author: at4, category: ct4, description: '中学3年間で50人もの女性にフラれた高校1年の不良少年・桜木花道は背の高さと身体能力からバスケットボール部の主将の妹、赤木晴子にバスケット部への入部を薦められる。彼女に一目惚れした「初心者」花道は彼女目当てに入部するも、練習・試合を通じて徐々にバスケットの面白さに目覚めていき、才能を開花させながら、全国制覇を目指していくのであったが……。')
Manga.create(title: "ハイキュー!!", publisher: pb1, author: at12, category: ct4, description: 'おれは飛べる!! バレーボールに魅せられ、中学最初で最後の公式戦に臨んだ日向翔陽。だが、「コート上の王様」と異名を取る天才選手・影山に惨敗してしまう。リベンジを誓い烏野高校バレー部の門を叩く日向だが!?')
Manga.create(title: "インベスターZ", publisher: pb2, author: at13, category: ct17, description: '創立130年の超進学校・道塾学園に、トップで合格した財前孝史。入学式翌日に、財前に明かされた学園の秘密。各学年成績トップ6人のみが参加する「投資部」が存在するのだ。彼らの使命は3000億を運用し、年8%以上の利回りを生み出すこと。それゆえ日本最高基準の教育設備を誇る道塾学園は学費が無料だった!「この世で一番エキサイティングなゲーム、人間の血が最も沸き返る究極の勝負……それは金……投資だよ!」')
Manga.create(title: "GTO", publisher: pb2, author: at14, category: ct19, description: "かつて最強の不良「鬼爆」の一人として湘南に君臨した鬼塚英吉は、辻堂高校を中退後、優羅志亜(ユーラシア)大学に替え玉試験で入学した。彼は持ち前の体力と度胸、純粋な一途さと若干の不純な動機で、教師を目指した。無茶苦茶だが、目先の理屈よりも「ものの道理」を通そうとする鬼塚の行為に東京吉祥学苑理事長の桜井良子が目を付け、ある事情を隠して中等部の教員として採用する。学園内に蔓延する不正義や生徒内に淀むイジメの問題、そして何より体面や体裁に振り回され、臭いものに蓋をして見て見ぬ振りをしてしまう大人たち、それを信じられなくなって屈折してしまった子どもたち。この学園には様々な問題が山積していたのである。桜井は、鬼塚が問題に真っ向からぶつかり、豪快な力技で解決してくれることに一縷の望みを託すようになる。")
※ docker-compose up -d
で事前にpostgresqlを起動した状態で行う点に注意
以下のコマンドからseedデータを投入します。
$ bin/rails db:seed
手順9:コントローラー・ビュー・ルーティングの追加
$ bin/rails g controller Mangas index --helper=false --assets=false
class MangasController < ApplicationController
def index
@mangas = Manga.all
end
end
Rails.application.routes.draw do
root 'mangas#index'
resources :mangas, only: %i(index)
end
<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>
手順10:スタイルの調整
bulma-railsを追加してスタイルを調整します。
gem "bulma-rails", "~> 0.7.2"
Gemfileに追記出来たら、bundle installします。
$ bundle install
cssをscssに変更します。
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
* vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require ./manga
*= require_tree .
*= require_self
*/
// https://github.com/joshuajansen/bulma-rails
@import "bulma";
ここは手動で微調整します。
.table tr {
td:nth-child(-n+3) {
width:100px;
}
td:nth-child(4) {
width:150px;
}
}
ビューもbulmaを適用していきます。
<section class="hero is-info">
<div class="hero-body">
<div class="container">
<h1 class="title">
漫画検索
</h1>
</div>
</div>
</section>
<div class="container" style="margin-top: 50px">
<table class="table is-striped is-hoverable">
<thead class="has-background-info">
<tr>
<th class="has-text-white-bis">出版社</th>
<th class="has-text-white-bis">ジャンル</th>
<th class="has-text-white-bis">著者</th>
<th class="has-text-white-bis">タイトル</th>
<th class="has-text-white-bis">説明</th>
</tr>
</thead>
<tbody>
<% @mangas.each do |manga| %>
<tr>
<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>
</div>
手順11:Elasticsearch用のgemの追加
※ Elasticsearchのメジャーバージョンとgemのメジャーバージョンは揃えないと動作しないので注意しましょう。
今回はElasticsearchが6.5.4なのでgemも6系を使います。
gem 'elasticsearch-rails', git: 'git://github.com/elastic/elasticsearch-rails.git', branch: '6.x'
gem 'elasticsearch-model', git: 'git://github.com/elastic/elasticsearch-rails.git', branch: '6.x'
*elasticsearch-rails
*elasticsearch-model
$ bundle install
手順12:ElasticsearchをRailsアプリ上で動かせるようにする
configの設定
コメントにも書いていますが、host名は docker-compose
の servicesに設定した名前の「elasticsearch」ではなく「localhost」
を指定しないとエラーになってしまったので、そこに留意してconfigを書いています。
# hostはdocker-composeのservicesに設定した名前「elasticsearch」ではなく「localhost」を指定しないとエラーになるので注意
# 参考:https://qiita.com/s_yasunaga/items/b0dac7f962c265158a34
config = {
host: ENV['ELASTICSEARCH_HOST'] || "localhost",
port: ENV['ELASTICSEARCH_PORT'] || "9200",
user: ENV['ELASTICSEARCH_USER'] || "",
password: ENV['ELASTICSEARCH_PASSWORD'] || ""
}
Elasticsearch::Model.client = Elasticsearch::Client.new(config)
concernを追加
modelに検索モジュールをincludeします。
class Manga < ApplicationRecord
include MangaSearch::Engine
belongs_to :author
belongs_to :publisher
belongs_to :category
end
検索用モジュールは以下の通りです。
追って用語を補足します。
module MangaSearch
module Engine
extend ActiveSupport::Concern
included do
include Elasticsearch::Model
# ①index名
index_name "es_manga_#{Rails.env}"
# ②mapping情報
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
end
*Elasticsearch関連用語の補足
※皆さんが普段使っているであろうRDBとの比較表
RDB | Elasticsearch |
---|---|
database | index |
table | type |
schema | mapping |
column | field |
record | document |
手順13:Elasticsearchの動作確認
- RailsコンソールからElasticsearchの疎通確認を行います。
(ここで接続が出来ていないとFaraday::ConnectionFailed
が発生します。)
$ Manga.__elasticsearch__.client.cluster.health
=> {"cluster_name"=>"docker-cluster",
"status"=>"yellow",
"timed_out"=>false,
"number_of_nodes"=>1,
"number_of_data_nodes"=>1,
"active_primary_shards"=>10,
"active_shards"=>10,
"relocating_shards"=>0,
"initializing_shards"=>0,
"unassigned_shards"=>10,
"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"=>50.0}
- indexを作成します。
$ Manga.create_index!
=> {"acknowledged"=>true, "shards_acknowledged"=>true, "index"=>"es_manga_development"}
- importメソッドでmodelの情報を登録します。さっき追加したas_indexed_jsonの形式に変換してデータが登録されます。
$ Manga.__elasticsearch__.import
Manga Load (12.7ms) SELECT "mangas".* FROM "mangas" ORDER BY "mangas"."id" ASC LIMIT $1 [["LIMIT", 1000]]
Publisher Load (5.9ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (7.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Category Load (7.2ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 14], ["LIMIT", 1]]
Publisher Load (3.2ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (4.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
Category Load (3.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Publisher Load (4.5ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Author Load (3.9ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
Category Load (3.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Publisher Load (3.3ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (3.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Category Load (2.6ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Publisher Load (3.9ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
Author Load (3.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 5], ["LIMIT", 1]]
Category Load (4.2ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 15], ["LIMIT", 1]]
Publisher Load (2.7ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (2.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 6], ["LIMIT", 1]]
Category Load (2.6ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Publisher Load (1.6ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Author Load (1.9ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 7], ["LIMIT", 1]]
Category Load (1.8ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 8], ["LIMIT", 1]]
Publisher Load (1.9ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (2.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 8], ["LIMIT", 1]]
Category Load (2.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 6], ["LIMIT", 1]]
Publisher Load (1.6ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (1.7ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 9], ["LIMIT", 1]]
Category Load (1.9ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Publisher Load (2.2ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Author Load (2.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 10], ["LIMIT", 1]]
Category Load (1.8ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 7], ["LIMIT", 1]]
Publisher Load (1.6ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 5], ["LIMIT", 1]]
Author Load (1.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 11], ["LIMIT", 1]]
Category Load (2.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 16], ["LIMIT", 1]]
Publisher Load (1.8ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (1.8ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Category Load (2.0ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Publisher Load (4.1ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
Author Load (2.8ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 12], ["LIMIT", 1]]
Category Load (1.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
Publisher Load (1.6ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Author Load (2.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 13], ["LIMIT", 1]]
Category Load (1.8ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 17], ["LIMIT", 1]]
Publisher Load (1.5ms) SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
Author Load (1.8ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT $2 [["id", 14], ["LIMIT", 1]]
Category Load (1.6ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = $1 LIMIT $2 [["id", 19], ["LIMIT", 1]]
=> 0
手順14:検索機能の追加
Elasticsearchの疎通確認が出来たので検索モジュールに検索メソッドを追加します。
検索モジュールに検索メソッドを追加
<中略>
def search(query)
elasticsearch__.search({
query: {
multi_match: {
fields: %w(publisher author category title description),
type: 'cross_fields',
query: query,
operator: 'and'
}
}
})
end
実装を補足
-
multi_match
は複数のフィールドにまたがって検索したい場合に利用するオプションです。 -
fields
では検索対象のフィールドを指定しています。 -
type
でmulti_match
の検索タイプを指定していて、ここではcross_fields
という複数のフィールドを結合して、一つのフィールドのように扱うタイプを指定しています。
multi_match: {
fields: %w(publisher author category title description),
type: 'cross_fields',
上記の実装の他にもどういったクエリの書き方があるのか詳しく調べたい場合は、公式ドキュメントのQuery DSLの部分を読んでみて下さい!
コントローラーの修正
- 検索メソッドをコントローラーに反映します。
class MangasController < ApplicationController
def index
@mangas = if query.present?
Manga.search(query).records
else
Manga.all
end
end
private
def query
@query ||= params[:query]
end
end
ビューの修正
- ヘッダーとテーブルの間に検索窓を追加します。
<section class="hero is-info">
<div class="hero-body">
<div class="container">
<h1 class="title">
漫画検索
</h1>
</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 :query, @query, 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">
<thead class="has-background-info">
<tr>
<th class="has-text-white-bis">出版社</th>
<th class="has-text-white-bis">ジャンル</th>
<th class="has-text-white-bis">著者</th>
<th class="has-text-white-bis">タイトル</th>
<th class="has-text-white-bis">説明</th>
</tr>
</thead>
<tbody>
<% @mangas.each do |manga| %>
<tr>
<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>
<%= paginate @mangas %>
</div>
手順15:ページネーションの追加
このままだと検索結果を全件表示することになってしまうので、ページネーションを追加していきます。
※ 注意点はこちらを参照
kaminari
はElasticsearch関連のgemよりも上に追加するようにしましょう。
gem 'kaminari'
kaminariをコントローラーに適用
変更点はElasticsearchや通常のDBへの検索両方に page
と per
で何ページ目を何件取るかを設定します。
通常APIを作る際は page
の方だけ params[:page]
で取得して、 per
部分は任意の値を設定することが多いのかな?と思うので、今回はそういった実装になっています。
class MangasController < ApplicationController
def index
@mangas = if query.present?
Manga.search(query).page(page_number).per(5).records
else
Manga.page(page_number).per(5)
end
end
private
def query
@query ||= params[:query]
end
def page_number
[params[:page].to_i, 1].max
end
end
kaminariのための日本語設定を追加
kaminariのページネーション部分を日本語表記にするための設定を追加します。
application.rb
にconfig.i18n.default_locale = :ja
を追加しましょう。
module ElasticsearchRailsSandbox
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 6.0
<中略>
config.i18n.default_locale = :ja
end
end
新規で日本語設定ymlファイルを以下の通りの内容で作成します。
ja:
views:
pagination:
first: "« 最初"
last: "最後 »"
previous: "‹ 前"
next: "次 ›"
truncate: "..."
kaminariのためのビューの変更
kaminariのテンプレートを作成するコマンドを実行します。
$ bundle exec rails g kaminari:views default
実行すると app/views/kaminari
以下にファイルが作成されるので、これらのファイルを修正していきます。kaminariについてはおまけ的な部分なので、以下に完成したビューを示すのみになりますので悪しからず
kaminari/_next_page.html.erb
<%# Link to the "Next" page
- available local variables
url: url to the next page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote, class: 'pagination-next' %>
kaminari/_page.html.erb
<%# Link showing page number
- available local variables
page: a page object for "this" page
url: url to this page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<li>
<% if page.current? -%>
<%= link_to page, '#', {remote: remote, rel: page.rel, class: "pagination-link is-current"} %>
<% else -%>
<%= link_to page, url, {remote: remote, rel: page.rel, class: "pagination-link"} %>
<% end -%>
</li>
kaminari/_paginator.html.erb
<%# The container tag
- available local variables
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
paginator: the paginator that renders the pagination tags inside
-%>
<%= paginator.render do -%>
<nav class="pagination is-centered" role="navigation" aria-label="pager">
<%= prev_page_tag unless current_page.first? %>
<% unless current_page.out_of_range? %>
<%= next_page_tag unless current_page.last? %>
<% end %>
<ul class="pagination-list">
<% each_page do |page| -%>
<% if page.left_outer? || page.right_outer? || page.inside_window? -%>
<%= page_tag page %>
<% elsif !page.was_truncated? -%>
<%= gap_tag %>
<% end -%>
<% end -%>
</ul>
</nav>
<% end -%>
kaminari/_prev_page.html.erb
<%# Link to the "Previous" page
- available local variables
url: url to the previous page
current_page: a page object for the currently displayed page
total_pages: total number of pages
per_page: number of items to fetch per page
remote: data-remote
-%>
<%= link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote, class: 'pagination-previous' %>
views/mangas/index.html.erb
<%= paginate @mangas %>
を追加しています。
<section class="hero is-info">
<div class="hero-body">
<div class="container">
<h1 class="title">
漫画検索
</h1>
</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 :query, @query, 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">
<thead class="has-background-info">
<tr>
<th class="has-text-white-bis">出版社</th>
<th class="has-text-white-bis">ジャンル</th>
<th class="has-text-white-bis">著者</th>
<th class="has-text-white-bis">タイトル</th>
<th class="has-text-white-bis">説明</th>
</tr>
</thead>
<tbody>
<% @mangas.each do |manga| %>
<tr>
<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>
<%= paginate @mangas %>
</div>
手順16:RSpecのセットアップ
RSpecに必要なGemの追加
※'spring-commands-rspec'
はRSpecをSpring経由で実行するためのgemです。
以下のgemを追加したら bundle install
しましょう。
group :test do
<中略>
gem 'rspec-rails'
gem 'spring-commands-rspec'
gem 'factory_bot_rails'
end
RSpecの各種設定ファイルを生成するコマンドを実行
$ bundle exec rails generate rspec:install
※生成されるファイル
.rspec
spec/rails_helper.rb
spec/spec_helper.rb
.rspecの内容を修正
--color
は出力結果の色付け
--format documentation
は specファイルのdescribeやitなどのコメント部分を結果と共に表示してくれるので、付けると視覚的にテスト結果が分かりやすくなるオプションです。
--require spec_helper
--color
--format documentation
RSpecをspringを経由して実行出来るようにセットアップ
以下のコマンドを実行します。
$ bundle exec spring binstub rspec
生成されるファイルは以下の通りです。
#!/usr/bin/env ruby
begin
load File.expand_path('../spring', __FILE__)
rescue LoadError => e
raise unless e.message.include?('spring')
end
require 'bundler/setup'
load Gem.bin_path('rspec-core', 'rspec')
#!/usr/bin/env ruby
# This file loads Spring without using Bundler, in order to be fast.
# It gets overwritten when you run the `spring binstub` command.
unless defined?(Spring)
require 'rubygems'
require 'bundler'
lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read)
spring = lockfile.specs.detect { |spec| spec.name == 'spring' }
if spring
Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
gem 'spring', spring.version
require 'spring/binstub'
end
end
FactoryBotの各種ファイルを追加
FactoryBot.define do
factory :author do
sequence(:name) { |n| "TEST#{n}太郎" }
end
end
FactoryBot.define do
factory :category do
name { %w(ラブコメ ファンタジー サスペンス バトル スポーツ サイコスリラー 日常系).sample }
end
end
FactoryBot.define do
factory :manga do
association :category, factory: :category
association :author, factory: :author
association :publisher, factory: :publisher
sequence(:title) { |n| "TEST_PRODUCT#{n}" }
sequence(:description) { |n| "TEST_DESCRIPTION#{n}" }
end
end
FactoryBot.define do
factory :publisher do
sequence(:name) { |n| "TEST#{n}出版" }
end
end
FactoryBotの名前空間を省略出来るようにする定義を追加
テストデータの呼び出しを FactoryBot.create(:◯◯) → create(:◯◯)
に簡略化出来る設定など細かい設定を以下の通りに追加しておきます。
RSpec.configure do |config|
<中略>
# テストデータの呼び出しをFactoryBot.create(:◯◯) → create(:◯◯)に簡略化
config.include FactoryBot::Syntax::Methods
# springを使用してrspecを動かしているとfactoryで作成したデータが正しく読み込まれないことがあるので
# 毎回全てのexample実行前にfactory_botを再読込させる
config.before :all do
FactoryBot.reload
end
end
手順17:Elasticsearchのテストを追加
RSpecでElasticsearchのindexを作成するための設定
ElasticsearchをRSpec上でテストする際の設定をしていきます。
※テスト実行時にmetaデータを渡し、それがelasticsearchだった場合に該当リソースのindexを作成するように設定しています。
RSpec.configure do |config|
<中略>
# elasticsearchのテストの場合のみIndexを作成する
config.before :each do |example|
if example.metadata[:elasticsearch] && example.metadata[:model_name]
# meta情報のモデル名の文字列をクラス定数に変換し、Elasticsearchのindexを作成する
class_constant = example.metadata[:model_name].classify.constantize
class_constant.create_index!
end
end
end
キーワード検索のテスト
require 'rails_helper'
RSpec.describe MangaSearch::Engine, elasticsearch: true, model_name: "manga" do
describe 'Manga.search' do
describe '検索ワードにマッチする漫画の検索' do
let!(:manga_1) do
create(:manga, title: 'キングダム', description: '時は紀元前―。いまだ一度も統一...')
end
let!(:manga_2) do
create(:manga, title: '僕のヒーローアカデミア', description: '多くの人間が“個性という力を持つ...')
end
let!(:manga_3) do
create(:manga, title: 'はたらく細胞', description: '人間1人あたりの細胞の数、およそ60兆個...')
end
# 作成したデータをelasticsearchに登録する
# refresh: true を追加することで登録したデータをすぐに検索できるようにする
before { Manga.__elasticsearch__.import(refresh: true) }
subject { Manga.search(query).records.pluck(:id) }
context '検索ワードがタイトルにマッチする場合' do
let(:query) { 'キングダム' }
it '検索ワードにマッチする漫画を取得する' do
is_expected.to include manga_1.id
end
end
context '検索ワードが複数ある場合' do
let(:query) { '人間 個性' }
it '両方の検索ワードにマッチする漫画を取得する' do
is_expected.to include manga_2.id
end
end
context '検索ワードが本文にマッチする場合' do
let(:query) { '60兆個' }
it '検索ワードにマッチする漫画を取得する' do
is_expected.to include manga_3.id
end
end
end
end
end
まとめ
以上でハンズオンのカリキュラムは終了になります。
駆け足ではありましたが、Dockerで環境を作って、Rails上でElasticsearchを試せるようになりました。
この後はサンプルアプリを自分なりに改造しつつ、デバッグしつつ、Elasticsearchの勉強用の教材として利用して頂ければ嬉しいです。
最後までお付き合い下さり、ありがとうございました