Gemを利用せずにRailsにApache Solrを導入する

# 1.本記事では何をやるのか

既存のRailsアプリケーションにApache Solrを導入します。

# 2.対象となるRailsアプリケーションの簡単な説明

人生の様々な失敗談を投稿する掲示板サイトです。

https://www.shippai-for-everyone.com/

一般的な掲示板と同じように、投稿された記事は検索フォームから検索することができます。
下記画像における青枠部分が検索フォームです。
![スクリーンショット 2023-02-02 23.05.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/4ddb60af-7fb5-2586-df3e-548781aa64c6.png)
# 3.なぜApache Solrを導入するのか？

ある程度の柔軟性の高い検索機能を提供し、ユーザーが記事を探しやすい状態を実現したいからです。

現状だと、「大学受験」で検索した場合に、「大学を受験」というフレーズにはヒットしません。
なぜなら、記事の検索時には下記のようなSQLを利用して検索を行なっているからです。

```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にインデックスする

![インデックス.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/64a9961f-fd08-8370-bab1-4872bdd119b9.png)

### Solr用のテーブルを作成し、その内容をインデックスする

既存では、記事が投稿されると、postsという投稿内容を保持するテーブルに記事が保存されるだけです。
それに対して、Solrを導入した場合にはpostsテーブルの他にSolr用のテーブルに記事が保存されるようにします。
そして、SolrのData Import Handler（DIH）という機能を利用して、Solr用のテーブルの内容を元にインデックスを行えるようにします。

:::note info
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](https://railsguides.jp/action_text_overview.html)を導入してるからです。
それにより、投稿内容の実体は別テーブルに保持されており、かつHTMLタグを含んだものになっています。
Solrにインデックスする際には、HTMLタグは不要なので、それらを取り除いたものをSolr用のテーブルに保持することにしました。

### Solr用のテーブルには何を入れるのか？

記事のタイトルと本文で検索を行いたいので、Solr用のテーブルには下記のデータを入れることにします。

- 記事のid（postsテーブルのid）
- 記事のタイトル（postsテーブルに入っているタイトル）
- 記事の本文（postsテーブルに入っている本文）

### Solrのスキーマ定義は？

Solr用のテーブルのスキーマに合わせて、下記のようになフィールドを定義します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/4df0ac16-325f-83b1-9a59-952fe1c9336c.png)
デフォルト通りのオプションについては説明を割愛し、特筆すべき箇所（赤字）のみ説明します。

まず、indexedオプションについてです。記事のタイトルと本文で検索を行いたいので、それらのみtrueに設定します。
次に、storedオプションについてです。後ほど詳しく述べますが、Solrから検索結果として取得したいのはpostsテーブルのidのみのため、それだけをtrueに設定します。
最後に、requiredオプションについてです。記事のid、タイトル、本文はどれも必ず値を持つことが期待されるためtrueに設定します。

### どうやって定期的に差分取り込みするのか？

[Gem](https://github.com/javan/whenever)を利用してSolrに取り込みのリクエストを送るRakeタスクをcron登録します。

## 記事をSolrで検索する

![検索する.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/e4eeed88-e4ec-165e-808d-823f716427c9.png)

### どのように検索を実現するか？

既存の設計では、先述したようなSQL文によりDBに対して記事の検索が行われていました。
それに対して、今回は検索キーワードに合うような記事のidをSolrから取得し、その他の「タイトル」や「投稿日時」といった情報をDBから取得するようにします。

# 6.実装

下記の2段階に分けて実装を行います。

1. 記事をSolrにインデックスする
2. 記事をSolrで検索する

また、記事をSolrにインデックスする実装は下記の手順に分けます。

- [Rails]Solr用テーブルを作成する
- [Rails]記事投稿時にSolr用テーブルも更新する
- [Solr]コアを作成し、スキーマを定義する
- [Solr]DIHを設定する
- [Rails]Rakeタスクで定期的に取り込みを行うようにする



- [Rails]Solrにリクエストを送る。
- [Rails]Solrのレスポンスを元にDBにリクエストを送る。

## [Rails]Solr用テーブルを作成する

設計で記載した通りにテーブル、モデルを作成しました。ここでは、そのテーブルを仮名称としてpost_for_solrsテーブルを呼びます。
また、詳しい実装手順は省略します。

https://railsguides.jp/active_record_migrations.html

## [Rails]記事投稿時にSolr用テーブルも更新する

Postモデルのafter_saveで、post_for_solrsテーブルも更新されるようなコールバックを設定しました。

```app/models/post.rb
class Post < ApplicationRecord
  # 色々と省略

  after_save :register_to_post_for_solrs

  def register_to_post_for_solrs
    PostForSolr.register_post(self)
  end
end
```

```app/models/post_for_solr.rb
class PostForSolr < ApplicationRecord
  # 色々と省略

  class << self
    def register_post(post)
      # PostForSolrをcreate or updateするような処理
    end
  end
end
```

## [Solr]コアを作成し、スキーマを定義する

Schema API経由で、設計で記載した通りにスキーマを定義しました。
詳しい手順は省略します。

https://solr.apache.org/guide/6_6/schema-api.html

## [Solr]DIHを設定する

本やインターネット上の記事にあまり手厚い説明が無かったため、詳しく書きます。

### SQLite用のJDBCドライバをダウンロードし、Solrに配置する

下記リンクからダウンロードしてください。
一応、私は3.36.0.3を選択し、動作確認ができています。

https://search.maven.org/artifact/org.xerial/sqlite-jdbc

ダウンロードしたら、`#{SOLR}/server/lib`にドライバを配置してください。

### solrconfig.xmlを修正する

`<config>`下に下記の記述を追加してください。
SQLite用のJDBCドライバと、DIH用のファイルを読み込んでいます。

```xml
<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`は適宜変更してください。

```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には取り込み元のデータベースや取り込みを行う際のクエリを指定します。
ざっくりとしたイメージとしては下記の通りです。

```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を利用している場合、データベースのパスは下記の手順で取得できます。

```bash
# Railsアプリケーション下で実行
rails dbconsole
> .database
```

また、db-data-config.xmlの書き方の詳しい説明は下記に記載されています。

https://solr.apache.org/guide/6_6/uploading-structured-data-store-data-with-the-data-import-handler.html#configuring-the-dih-configuration-file

### [Rails]Rakeタスクで定期的に取り込みを行うようにする

まずは、取り込みを行うためのRakeタスクを作成します。
lib/tasks下に新規ファイルを作成します。
作成するタスクのイメージはざっくりと下記のようなイメージです。

```ruby
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クラスを用いています。
ざっくりとしたイメージは下記の通りです。

```ruby
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登録します。

https://github.com/javan/whenever

## [Rails×Solr]記事の検索を行う

Solrで検索リクエストを送る際にも、独自のApiClientクラスを利用します。
ざっくりとしたイメージは下記の通りです。

今回は投稿のタイトルと本文でOR検索を行いたいので、qパラメーターにそのように記述します。
また、Solrから取得したいのは投稿のidだけなので、flパラメーターにはpost_idだけを指定します。

```ruby
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メソッドがあったので、それを下記のようなイメージで書き換えます。

```ruby
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.実際に検索してみる

例として下記のような投稿を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/d0043256-c6db-e120-3260-684099386e8b.png)

「大学受験」で検索するとヒットします。
本文には「大学を受験」というフレーズしかありませんが、ヒットさせることができていますね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/b518826c-6468-35a1-0818-edf4b4a8cfaf.png)

また、「友人」で検索してもヒットします。
こちらも、本文には「交友関係」しかありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643206/df03d106-09ee-149c-eb99-0fef6469e857.png)