1.本記事では何をやるのか
既存のRailsアプリケーションにApache Solrを導入します。
2.対象となるRailsアプリケーションの簡単な説明
人生の様々な失敗談を投稿する掲示板サイトです。
一般的な掲示板と同じように、投稿された記事は検索フォームから検索することができます。
下記画像における青枠部分が検索フォームです。
3.なぜApache Solrを導入するのか?
ある程度の柔軟性の高い検索機能を提供し、ユーザーが記事を探しやすい状態を実現したいからです。
現状だと、「大学受験」で検索した場合に、「大学を受験」というフレーズにはヒットしません。
なぜなら、記事の検索時には下記のようなSQLを利用して検索を行なっているからです。
select * from posts where headline = "%キーワード%" or comment = "%キーワード%";
裏テーマ
この取り組みを通じて、RailsアプリケーションにApache Solrを導入するための知見を増やしたいからです。
そのため、下記を意識して取り込みたいと思います。
- Solrの詳細な機能ではなく、RailsとSolrをシステム的に繋ぎ合わせるためにはどうすれば良いか?に関して重点的に知識を深める。
- RailsとSolrをよしなに繋ぎ合わせてくれるようなGemは利用しない。
4.環境
本記事では、ひとまず開発環境で動かすことを目指します。
開発環境におけるバージョンは下記の通りです。
- Ruby 2.7.3
- Rails 6.1.3.2
- SQLite 3.37.0
- Apache Solr 6.6.6
5.設計
考えるべきことは下記の2つです。
- どうやって記事を登録するか?(インデックスするか?)
- どうやって記事を検索するか?
記事をSolrにインデックスする
Solr用のテーブルを作成し、その内容をインデックスする
既存では、記事が投稿されると、postsという投稿内容を保持するテーブルに記事が保存されるだけです。
それに対して、Solrを導入した場合にはpostsテーブルの他にSolr用のテーブルに記事が保存されるようにします。
そして、SolrのData Import Handler(DIH)という機能を利用して、Solr用のテーブルの内容を元にインデックスを行えるようにします。
Data Import Handlerに関する公式ドキュメント
https://solr.apache.org/guide/6_6/uploading-structured-data-store-data-with-the-data-import-handler.html
なぜ、Data Import Handlerを利用するのか?
記事をSolrにインデックスする際のマスターデータを残すことができるからです。
例えば、DIHを利用しない設計として、記事を登録した際に記事の内容を都度HTTPリクエスト経由でSolrにインデックスする方法が考えられます。この方法だと、Solrを稼働しているサーバーが落ちてしまった際に、「どの記事までインデックスできているのか?」が分かりにくくなってしまいます。
それに対して、Solr用のテーブルというマスターデータを残しておけば、何かトラブルが発生したとしても、そのテーブルの内容をDIHを利用してインデックスし直せばOKです。
なぜ、Solr用のテーブルを作成するのか?
postsテーブルもSolr用のテーブルも両方とも投稿内容を保持するテーブルです。
Solr用のテーブルを新たに新設した理由は、このアプリケーションにおいてAction Textを導入してるからです。
それにより、投稿内容の実体は別テーブルに保持されており、かつHTMLタグを含んだものになっています。
Solrにインデックスする際には、HTMLタグは不要なので、それらを取り除いたものをSolr用のテーブルに保持することにしました。
Solr用のテーブルには何を入れるのか?
記事のタイトルと本文で検索を行いたいので、Solr用のテーブルには下記のデータを入れることにします。
- 記事のid(postsテーブルのid)
- 記事のタイトル(postsテーブルに入っているタイトル)
- 記事の本文(postsテーブルに入っている本文)
Solrのスキーマ定義は?
Solr用のテーブルのスキーマに合わせて、下記のようになフィールドを定義します。
デフォルト通りのオプションについては説明を割愛し、特筆すべき箇所(赤字)のみ説明します。
まず、indexedオプションについてです。記事のタイトルと本文で検索を行いたいので、それらのみtrueに設定します。
次に、storedオプションについてです。後ほど詳しく述べますが、Solrから検索結果として取得したいのはpostsテーブルのidのみのため、それだけをtrueに設定します。
最後に、requiredオプションについてです。記事のid、タイトル、本文はどれも必ず値を持つことが期待されるためtrueに設定します。
どうやって定期的に差分取り込みするのか?
Gemを利用してSolrに取り込みのリクエストを送るRakeタスクをcron登録します。
記事をSolrで検索する
どのように検索を実現するか?
既存の設計では、先述したようなSQL文によりDBに対して記事の検索が行われていました。
それに対して、今回は検索キーワードに合うような記事のidをSolrから取得し、その他の「タイトル」や「投稿日時」といった情報をDBから取得するようにします。
6.実装
下記の2段階に分けて実装を行います。
- 記事をSolrにインデックスする
- 記事をSolrで検索する
また、記事をSolrにインデックスする実装は下記の手順に分けます。
-
[Rails]Solr用テーブルを作成する
-
[Rails]記事投稿時にSolr用テーブルも更新する
-
[Solr]コアを作成し、スキーマを定義する
-
[Solr]DIHを設定する
-
[Rails]Rakeタスクで定期的に取り込みを行うようにする
-
[Rails]Solrにリクエストを送る。
-
[Rails]Solrのレスポンスを元にDBにリクエストを送る。
[Rails]Solr用テーブルを作成する
設計で記載した通りにテーブル、モデルを作成しました。ここでは、そのテーブルを仮名称としてpost_for_solrsテーブルを呼びます。
また、詳しい実装手順は省略します。
[Rails]記事投稿時にSolr用テーブルも更新する
Postモデルのafter_saveで、post_for_solrsテーブルも更新されるようなコールバックを設定しました。
class Post < ApplicationRecord
# 色々と省略
after_save :register_to_post_for_solrs
def register_to_post_for_solrs
PostForSolr.register_post(self)
end
end
class PostForSolr < ApplicationRecord
# 色々と省略
class << self
def register_post(post)
# PostForSolrをcreate or updateするような処理
end
end
end
[Solr]コアを作成し、スキーマを定義する
Schema API経由で、設計で記載した通りにスキーマを定義しました。
詳しい手順は省略します。
[Solr]DIHを設定する
本やインターネット上の記事にあまり手厚い説明が無かったため、詳しく書きます。
SQLite用のJDBCドライバをダウンロードし、Solrに配置する
下記リンクからダウンロードしてください。
一応、私は3.36.0.3を選択し、動作確認ができています。
ダウンロードしたら、#{SOLR}/server/lib
にドライバを配置してください。
solrconfig.xmlを修正する
<config>
下に下記の記述を追加してください。
SQLite用のJDBCドライバと、DIH用のファイルを読み込んでいます。
<lib dir="${solr.install.dir:../../..}/lib/" regex="sqlite-jdbc-.*\.jar" />
<lib dir="${solr.install.dir:../../../..}/dist/" regex="solr-dataimporthandler-.*\.jar" />
同じく<config>
下に下記の記述を追加してください。
/dataimport
やdb-data-config.xml
は適宜変更してください。
<requestHandler name="/dataimport" class="org.apache.solr.handler.dataimport.DataImportHandler">
<lst name="defaults">
<str name="config">db-data-config.xml</str>
</lst>
</requestHandler>
db-data-config.xmlを作成する。
#{SOLR}/server/solr/#{コア名}/conf
にdb-data-config.xmlを作成してください。(ファイル名はsolrconfig.xmlの設定に合わせて適宜変更してください)
db-data-config.xmlには取り込み元のデータベースや取り込みを行う際のクエリを指定します。
ざっくりとしたイメージとしては下記の通りです。
<dataConfig>
<dataSource driver="org.sqlite.JDBC" url="jdbc:sqlite:データベースのパス" />
<document>
<entity
pk="id"
query="全件取り込みを行う際に必要なレコードとカラムを取得するクエリ"
deltaQuery="差分取り込みを行う際の取り込み対象のレコードの主キーを取得するクエリ"
deltaImportQuery="deltaQueryで取得した主キーを元に取り込み対象のレコードとカラムを取得するクエリ"
>
<field column="カラム名_1" />
<field column="カラム名_2" />
<field column="カラム名_3" />
</entity>
</document>
</dataConfig>
開発環境でSQLiteを利用している場合、データベースのパスは下記の手順で取得できます。
# Railsアプリケーション下で実行
rails dbconsole
> .database
また、db-data-config.xmlの書き方の詳しい説明は下記に記載されています。
[Rails]Rakeタスクで定期的に取り込みを行うようにする
まずは、取り込みを行うためのRakeタスクを作成します。
lib/tasks下に新規ファイルを作成します。
作成するタスクのイメージはざっくりと下記のようなイメージです。
require_relative "../../app/api_clients/base"
require_relative "../../app/api_clients/solr_data_import_caller"
namespace :solr_data_import do
desc "全件取り込みを行う"
task :full_import do
api_caller = SolrDataImportCaller.new
api_caller.call(full_import_flg: true)
end
desc "差分取り込みを行う"
task :delta_import do
api_caller = SolrDataImportCaller.new
api_caller.call(full_import_flg: false)
end
end
Solrにリクエストを送る際には、独自に作成したApiClientクラスを用いています。
ざっくりとしたイメージは下記の通りです。
require "net/http"
require "json"
# Solrで検索を行う際にも利用するので、なるべく汎用的に使えるようにする
class Base
def initialize(end_point)
@end_point = end_point
end
def get(options: nil)
uri = URI.parse(@end_point)
uri.query = URI.encode_www_form(options) unless options.nil?
response = Net::HTTP.get_response(uri)
JSON.parse(response.body, symbolize_names: true)
end
end
class SolrDataImportCaller < Base
END_POINT_SERACH_POST = "#{solrのエンドポイント}/dataimport/"
def initialize
super(END_POINT_SERACH_POST)
end
def call(full_import_flg: true)
command = full_import_flg ? "full-import" : "delta-import"
options = {
command: command,
wt: "json"
}
response = get(options: options)
end
end
Rakeタスクを作成したら、Wheneverを利用してcron登録します。
[Rails×Solr]記事の検索を行う
Solrで検索リクエストを送る際にも、独自のApiClientクラスを利用します。
ざっくりとしたイメージは下記の通りです。
今回は投稿のタイトルと本文でOR検索を行いたいので、qパラメーターにそのように記述します。
また、Solrから取得したいのは投稿のidだけなので、flパラメーターにはpost_idだけを指定します。
require "net/http"
require "json"
class SolrSearchPostsCaller < Base
END_POINT_SERACH_POST = "#{Solrのエンドポイント}/select/"
def initialize
super(END_POINT_SERACH_POST)
end
def call(search_word)
options = {
q: "title:#{search_word} OR content:#{search_word}",
wt: "json",
fl: "post_id"
}
response = get(options: options)
result = {}
result[:post_ids] = response[:response][:docs].map{|hash| hash[:post_id] }
result
end
end
今まで、SQLを利用して記事の検索を行なっていたPostモデルのsearchメソッドがあったので、それを下記のようなイメージで書き換えます。
class Post < ApplicationRecord
# 色々と省略
class << self
def search(search_word)
return nil if search_word.blank?
api_client = SolrSearchPostsCaller.new
response = api_client.call(search_word)
post_ids = response[:post_ids]
Post
.where(id: post_ids)
#
#好きなようにクエリを追加する
#
end
end
end
7.実際に検索してみる
例として下記のような投稿を行います。
「大学受験」で検索するとヒットします。
本文には「大学を受験」というフレーズしかありませんが、ヒットさせることができていますね。
また、「友人」で検索してもヒットします。
こちらも、本文には「交友関係」しかありません。