Help us understand the problem. What is going on with this article?

Rails6でElasticsearchのキーワード検索実装ハンズオン

はじめに

本稿は、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で書くこと

9143142233140178_2020-03-01_15.28.19.gif

※ 完成リポジトリ
https://github.com/Shigeyuki-fukuda/elasticsearch_rails_sandbox

前提環境

・ 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って何なの?

公式ドキュメント :mag:

・ 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を入れておきます。

Gemfile
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の設定

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 :ok_hand:
  • Elasticseachは docker/elasticsearch/Dockerfile にDocker上で使うElasticseachのバージョンとインストールするプラグインを記載する :pencil: (日本語の形態素解析用のプラグインを入れています)
docker/elasticsearch/Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:6.5.4
RUN bin/elasticsearch-plugin install analysis-kuromoji

手順4:Docker上のPostgreSQLとRailsが疎通するための設定を追加する

config/database.yml
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'] %>

:warning: ハマりポイント解説

docker-compose.ymlに記載した内容とdatabase.ymlの内容で齟齬があるとRailsがDBに接続出来ないので注意 :sob:

config/database.yml
username: postgres
password: mysecretpassword1234

↑上記の部分と↓以下の POSTGRES_USERPOSTGRES_PASSWORD の部分が一致してる必要があります :raising_hand:

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

手順5:Docker上で動くPostgreSQLとRailsの疎通確認

dockerコンテナを作成・バックグラウンド起動します :rocket:

# 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

Elasticsearch*Railsハンズオン1.png (262.6 kB)

手順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:検索対象となるモデルの定義

image.png

$ 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です。

db/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
app/controllers/mangas_controller.rb
class MangasController < ApplicationController
  def index
    @mangas = Manga.all
  end
end
config/routes.rb
Rails.application.routes.draw do
  root 'mangas#index'
  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>

手順10:スタイルの調整

bulma-railsを追加してスタイルを調整します。

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

Gemfileに追記出来たら、bundle installします。

$ bundle install

cssをscssに変更します。

app/assets/stylesheets/application.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";

ここは手動で微調整します。

app/assets/stylesheets/manga.scss
.table tr {
  td:nth-child(-n+3) {
    width:100px;
  }
  td:nth-child(4) {
    width:150px;
  }
}

ビューもbulmaを適用していきます。

app/views/mangas/index.html.erb
<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系を使います。

Gemfile
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 :pencil:

公式ドキュメント

*elasticsearch-model :pencil:

公式ドキュメント

$ bundle install

手順12:ElasticsearchをRailsアプリ上で動かせるようにする

configの設定

コメントにも書いていますが、host名は docker-composeservicesに設定した名前の「elasticsearch」ではなく「localhost」 を指定しないとエラーになってしまったので、そこに留意してconfigを書いています。

config/initializers/elasticsearch.rb
# 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します。

app/models/manga.rb
class Manga < ApplicationRecord
  include MangaSearch::Engine

  belongs_to :author
  belongs_to :publisher
  belongs_to :category
end

検索用モジュールは以下の通りです。
追って用語を補足します。

app/models/concerns/manga_search/engine.rb
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の疎通確認が出来たので検索モジュールに検索メソッドを追加します。

検索モジュールに検索メソッドを追加

app/models/concerns/manga_search/engine.rb
<中略>

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 では検索対象のフィールドを指定しています。
  • typemulti_match の検索タイプを指定していて、ここでは cross_fields という複数のフィールドを結合して、一つのフィールドのように扱うタイプを指定しています。
multi_match: {  
  fields: %w(publisher author category title description),  
  type: 'cross_fields',

上記の実装の他にもどういったクエリの書き方があるのか詳しく調べたい場合は、公式ドキュメントのQuery DSLの部分を読んでみて下さい!

コントローラーの修正

  • 検索メソッドをコントローラーに反映します。
app/controllers/mangas_controller.rb
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

ビューの修正

  • ヘッダーとテーブルの間に検索窓を追加します。
app/views/mangas/index.html.erb
<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よりも上に追加するようにしましょう。

Gemfile
gem 'kaminari'

kaminariをコントローラーに適用

変更点はElasticsearchや通常のDBへの検索両方に pageper で何ページ目を何件取るかを設定します。
通常APIを作る際は page の方だけ params[:page] で取得して、 per 部分は任意の値を設定することが多いのかな?と思うので、今回はそういった実装になっています。

app/controllers/mangas_controller.rb
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.rbconfig.i18n.default_locale = :jaを追加しましょう。

config/application.rb
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ファイルを以下の通りの内容で作成します。

config/locales/ja.yml
ja:
  views:
    pagination:
      first: "&laquo; 最初"
      last: "最後 &raquo;"
      previous: "&lsaquo; 前"
      next: " &rsaquo;"
      truncate: "..."

kaminariのためのビューの変更

kaminariのテンプレートを作成するコマンドを実行します。

$ bundle exec rails g kaminari:views default

実行すると app/views/kaminari 以下にファイルが作成されるので、これらのファイルを修正していきます。kaminariについてはおまけ的な部分なので、以下に完成したビューを示すのみになりますので悪しからず :pray:

kaminari/_next_page.html.erb

app/views/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

app/views/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

app/views/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

app/views/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 %> を追加しています。

app/views/mangas/index.html.erb
<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 しましょう。

Gemfile
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などのコメント部分を結果と共に表示してくれるので、付けると視覚的にテスト結果が分かりやすくなるオプションです。

.rspec
--require spec_helper
--color
--format documentation

RSpecをspringを経由して実行出来るようにセットアップ

以下のコマンドを実行します。

$ bundle exec spring binstub rspec

生成されるファイルは以下の通りです。

bin/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')
bin/spring
#!/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の各種ファイルを追加

spec/factories/author.rb
FactoryBot.define do
  factory :author do
    sequence(:name) { |n| "TEST#{n}太郎" }
  end
end
spec/factories/category.rb
FactoryBot.define do
  factory :category do
    name { %w(ラブコメ ファンタジー サスペンス バトル スポーツ サイコスリラー 日常系).sample }
  end
end
spec/factories/manga.rb
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
spec/factories/publisher.rb
FactoryBot.define do
  factory :publisher do
    sequence(:name) { |n| "TEST#{n}出版" }
  end
end

FactoryBotの名前空間を省略出来るようにする定義を追加

テストデータの呼び出しを FactoryBot.create(:◯◯) → create(:◯◯) に簡略化出来る設定など細かい設定を以下の通りに追加しておきます。

spec/rails_helper.rb
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を作成するように設定しています。

spec/rails_helper.rb
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

キーワード検索のテスト

spec/models/concerns/manga_search/engine_spec.rb
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の勉強用の教材として利用して頂ければ嬉しいです。
最後までお付き合い下さり、ありがとうございました :relaxed:

参考

fuqda
Tama.rbという地域Rubyコミュニティを主催しています。 https://tamarb.connpass.com はてなブログはこちらです! https://fuqda.hatenablog.com/
storesjp
インターネットビジネスの企画・開発・運営、マーケティング、プロモーション、コンテンツの企画・制作
https://about.stores.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした