11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Rails】読み仮名でもオートコンプリート検索できるようにしよう

Last updated at Posted at 2019-03-24

概要

  • 検索にオートコンプリートをつけます
  • 読み仮名(ひらがな)でもオートコンプリートできるようにします。

動作イメージ

c0edcd5d09620e68158dfd6e8ebcd282.gif

環境

  • rails 6.0.0beta3
  • dbはsqliteなのでローカルで適当に試してください。
  • gem
    • fast_jsonapi オートコンプリート用の結果をjsonで返すときにつかう
    • rubyfuri 読み仮名を取得
    • dotenv-rails yahoo japanのapi keyを管理するため
  • javascript libary
    • selectize.js フロント側のオートコンプリート表示を制御

heroku

コード

ローカルで試す場合

gitから https://github.com/junara/fuchu-nosanbutsu/releases/tag/1.1.1 をクローンして下さい。

  • bundle install
bundle install
  • migarate
bin/rails db:migrate
  • データを取り込む
bin/rails c
> Store.import_csv('db/30chokubaijo_map.csv')
  • Storeデータからキーワードを登録
> importer = ItemImporter.new(Store.pluck(:description))
> importer.execute
  • ふりがなを登録
    • .env にyahoo japanのdeveloper apiを取得して書き込んでください。
    • その後以下を事項
bin/rake item:name_hiragana
  • サーバーを起動
bin/rail s
  • webpacker-dev-serverを起動
bin/webpack-dev-server 

下記にアクセス
http://localhost:3000

手順

  • サーバーサイド(オートコンプリートエンドポイント作成)
    • データ取り込み
    • ふりがなデータ取得
    • レスポンスのjson出力
    • エンドポイント
      • 複数キーワードで検索できるように修正
  • フロントエンド
    • javascript環境のセットアップ
      • jquery, selectizeのインストール
    • オートコンプリートの実装

サーバーサイド(オートコンプリートエンドポイント作成)

データ取り込み

オートコンプリート対象となるテーブル Item を作成します。

db/migrate/20190322115059_create_items.rb
class CreateItems < ActiveRecord::Migration[6.0]
  def change
    create_table :items do |t|
      t.string :name
      t.timestamps
    end
  end
end
bin/rails db:migrate

インポート用の ItemImporterを実装します。これは、Store.descriptionをparseしてItem.nameに取り込むクラスです。

app/importers/item_importer.rb
class ItemImporter
  def initialize(descriptions)
    @descriptions = descriptions
  end

  def execute
    ActiveRecord::Base.transaction do
      Item.delete_all
      list = []
      item_names.uniq.reject(&:blank?).each do |item_name|
        list << Item.new(name: item_name)
      end
      Item.import list
    end
  end

  private

  def item_names
    current_item_names = Item.pluck(:name)
    @descriptions.each_with_object(current_item_names) do |str, res|
      res.concat(custom_split(str))
    end
  end

  def custom_split(str)
    return [] if str.nil?
    str.gsub(/ほか|など|あり/, '').split(/・|\(|(|)|、|:|\n| |\)| /)
  end
end

これで取り込みを行います。

rails c
> importer = ItemImporter.new(Store.pluck(:description))
> importer.execute

これで、item.nameにstore.descriptionのデータからパースされたキーワードが保存されています。

Item.pluck(:name) などで確認して下さい。

ふりがなデータ取得

これだけだと ナス茄子なす でオートコンプリートすることができません。そのために、カタカナをひらがなに、さらに茄子を なすに対応させます。

まずふりがな(ひらがな)を格納するカラムをマイグレーションします。

db/migrate/20190324070509_add_name_furi_to_item.rb
class AddNameFuriToItem < ActiveRecord::Migration[6.0]
  def change
    add_column :items, :name_hiragana, :string
  end
end
bin/rails db:migrate 

フリガナを追加するためのメソッドを追加します。
Gemfileに下記のgemを追加します。rubyfuriはyahoo japanのapiを叩いて、読み仮名を取得するgemですdotenv-railsはapiキーを管理するためです。

gem 'dotenv-rails'
gem 'rubyfuri'

で、yahoo japanのdeveloper apiキーを取得します。 https://developer.yahoo.co.jp/webapi/jlp/ このあたりから取得してください。(取得方法は、各自調べて下さい。)

取得したapiキーを .env ファイルに記載します。(gitignoreに記載しているので大丈夫だと思いますが、こちらのAPIキーは決してgithubにアップロードしないで下さい!)

.env
YAHOO_JAPAN_DEVELOPER_CLIENT_ID=あなたのAPIキー

良みこむためのメソッドをItemに付け加えます。

app/models/item.rb
class Item < ApplicationRecord

  def update_name_hiragana(force: false)
    rubyfuri = Rubyfuri::Client.new(ENV['YAHOO_JAPAN_DEVELOPER_CLIENT_ID'])
    update(name_hiragana: rubyfuri.furu(name))
  end
end

先ほど実装したメソッドを利用してitem.name_hiraganaに読み仮名を追加するためのrake taskを作ります。

lib/tasks/item.rake
namespace :item do
  desc 'item.nameに読み仮名をつける'
  # rake item:name_hiragana
  task name_hiragana: :environment do
    Item.all.each {|item| item.update_name_hiragana}
  end
end

上記rake taskを実行します。

> rake item:name_hiragana

Item.pluck(:name_hiragana) 等で読み仮名が格納されたことを確認して下さい。

レスポンスのjson出力

ユーザーが入力したキーワード(フロント側から送られてきたキーワード)に従って、オートコンプリート候補となるレスポンスを返します。レスポンスの形式はなんでもいいです。jbuilderで書いてもいいのですが、意外と面倒。
なので、簡単にいい感じのJSONを返してくれるgemをつかいます。
active_model_serializers が有名ですが、今回はactive_model_serializersと同様の使い勝手で、active_model_serializersよりも速い!とされる、 fast_jsonapi を使います。

まずgemをインストールします。
Gemfileに記述して

gem 'fast_jsonapi' 

ふつーにbundle install。

bundle install

fast_jsonapi でJSONを返すためには、 include FastJsonapi::ObjectSerializer したクラスを作り、出力したいカラムを attributes :id, :name, :name_hiragana のように書きます。これは、一番シンプルな例ですが、has_manyなどの関連でもできます。そちらをやりたい場合は公式のREADME読んでくださいませ。

app/serializers/item_serializer.rb
class ItemSerializer
  include FastJsonapi::ObjectSerializer
  attributes :id, :name, :name_hiragana
end

上記は単純に単純にid, name, name_hiraganaを返します。

なお、返ってくるjsonはこんなかんじ。(この構造はフロント側のコードで使うので頭の片隅に置いておいてください。)

{
  data: [
    {
      id: "167",
      type: "item",
      attributes: {
        id: 167,
        name: "トマト",
        name_hiragana: "とまと"
      }
    },
    {
      id: "168",
      type: "item",
      attributes: {
        id: 168,
        name: "きゅうり",
        name_hiragana: "きゅうり"
      }
    }
  ]
}

このjsonを返すコントローラのアクションはこちらです。フロント側から params[:keyword] として入力した文字列がやってきます。その文字列を検索して、結果をItemSerializerに渡してJSONで返します。 render json: ItemSerializer.new(items)

class AjaxController < ApplicationController
  def item_autocomplete
    items = Item.ransack(name_or_name_hiragana_cont: params[:keyword]).result
    render json: ItemSerializer.new(items)
  end
end

エンドポイント

オートコンプリートとは関係無いですが、複数のキーワードによる検索に対応できるようにします。
split_queries(str) がそれです。keywordが , で区切りだったら分割してransackに渡します。 description_cont_all とすることで、複数のキーワード(and検索)に対応しています。ransackの細かなところは こちら を見ましょう

app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    @stores = Store.ransack(description_cont_all: split_queries(params[:keyword])).result
  end

  private

  def split_queries(str)
    return nil unless str
    str.split(',')
  end
end

エンドポイントをroutes.rbに書きます。これで ajax/item_autocomplete でアクセスできます。(js由来だけに制限したい!とかったら適宜コード書き換えましょう。今回は緩ーく書いています)

config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'
  namespace :ajax do # このあたりを追加
    get :item_autocomplete
  end
end

フロントエンド

javascript環境のセットアップ

(rails 6なので最初からwebpacker入っています。rails 5系だったら 先にwebpackerを先にインストールしておいて下さい。)

jquery, selectizeのインストール

yarn add selectize
yarn add jquery

selectizeのcssを設定。( application.css だったら application.scss にリネームして下さい。)

ちなみに、bopotstrap3だと node_moudles/selectize/dist/css/ を見てbootstrap3用を選択するといいかも。

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_tree .
 *= require_self
 */

@import "selectize/dist/css/selectize.default";

オートコンプリートの実装

ここら辺はrailsでseletize.jsの使うための手順。
sprocketにのっかって書いてもいいけど、ES6で書きたいのでwebpackerつかった実装にしました。
rails 5系で webpackerが入っていない場合はこちらのQiitaの記事 Rails & Webpackerでフロントエンド開発環境を整えるを参考に事前にインストールして下さい。

view

selectizeのターゲットとなるフォームにidを付与します。今回は、 store_search というidを追加しました。それぞれのプロジェクトであわせてください。
こちら後で説明しますが、javascriptを読み込むための javascript_pack_tag を書きます。今の段階でページを表示するとjavascriptがないのでエラーになります。

app/views/home/index.html.erb
<%= javascript_pack_tag 'home' %>

<div>
  <%= form_with url: root_path, method: :get, local: true do |f| %>
    <%= f.text_field :keyword, value: params[:keyword], autocomplete: 'off', id: 'store_search' %>
    <%= f.submit '検索' %>
  <% end %>
</div>

javascript

ようやく最後。Javascriptを書きます!

webpackerのエントリーポイントはデフォルトは app/javascript/packs 。こちらにjavascript書きます。ファイル名は何でもいいのですが、viewとあわせるとわかりやすいので home.js にしました。
で、実際のコードは app/javascript/home 以下に書くのでここでは読み込むだけのコードを書きます。

app/javascript/packs/home.js
require('home')

で、下記のコードが実行されます。ファイル名はindex.jsにしてください。( require('home') だとデフォルトでindex.jsを読み込むので。)

app/javascript/home/index.js
import $ from 'jquery';

require('selectize');

const url = (query) => {
  return `/ajax/item_autocomplete?keyword=${encodeURIComponent(query)}`
};

const items = (data) => {
  return data.map((item) => {
    return {
      text: item['attributes']['name'],
      sub_text: item['attributes']['name_hiragana'], // ひらがなでも検索結果を絞り込めるようにする
      label: item['attributes']['name'],
      value: item['attributes']['name']
    }
  })
};

const renderOption = (item, escape) => {
  return `<div><span>${escape(item.text ? item.text : '')}</span></div>`
};

const renderOptionCreate = (item, escape) => {
  return `<div><span>新規キーワード... </span><span>${escape(item.input) ? item.input : ''}</span></div>`
};

const getAutocomplete = (query, callback) => {
  $.ajax({
    url: url(query),
    type: 'GET',
    error: () => {
      callback()
    },
    success: (res) => {
      callback(items(res.data))
    }
  })
};

const addSelectize = () => {
  $('#store_search').selectize({
    searchField: ['text', 'sub_text'], // 入力値のフィルター対象、ひらがなでも検索結果を絞り込めるようにする
    labelField: 'label', // 表示させるラベル
    valueField: 'value', // inputのvalue
    closeAfterSelect: true,
    create: true, // 新規単語を追加を許可
    createOnBlur: true, // 画面外をタッチすると新規単語を追加
    render: {
      option: (item, escape) => {
        return renderOption(item, escape)
      },
      option_create: (item, escape) => {
        return renderOptionCreate(item, escape)
      }
    },
    load: (query, callback) => {
      if (!query.length) return callback();
      getAutocomplete(query, callback)
    }
  })
};

const initialize = function () {
  addSelectize();
};

window.onload = function () {
  initialize();
};

細かく解説します。

import $ from 'jquery';

require('selectize');

ここで、ライブラリを読み込みます。raisl 6は最初からjqueryがないので、 jqueyをimportして、selectizeも読み込みます。

const addSelectize = () => {
  $('#store_search').selectize({
    searchField: ['text', 'sub_text'], // 入力値のフィルター対象、ひらがなでも検索結果を絞り込めるようにする
    labelField: 'label', // 表示させるラベル
    valueField: 'value', // inputのvalue
    closeAfterSelect: true,
    create: true, // 新規単語を追加を許可
    createOnBlur: true, // 画面外をタッチすると新規単語を追加
    render: {
      option: (item, escape) => {
        return renderOption(item, escape)
      },
      option_create: (item, escape) => {
        return renderOptionCreate(item, escape)
      }
    },
    load: (query, callback) => {
      if (!query.length) return callback();
      getAutocomplete(query, callback)
    }
  })
};

ここら辺がselectizeの中心部。
$('#store_search') はjqueryのいつものやつ。 .selectize でselectizeを付与。

load: (query, callback) => {
      if (!query.length) return callback();
      getAutocomplete(query, callback)
    }

ここで、キーワードが入力されたときに実行されます。ここで、 ajaxリクエスト送ります。

const getAutocomplete = (query, callback) => {
  $.ajax({
    url: url(query),
    type: 'GET',
    error: () => {
      callback()
    },
    success: (res) => {
      callback(items(res.data))
    }
  })
};

みたままですが、queryがあったら下記のurlにリクエストを送ります。

const url = (query) => {
  return `/ajax/item_autocomplete?keyword=${encodeURIComponent(query)}`
};

レスポンスは下記で処理。

    success: (res) => {
      callback(items(res.data))
    }

レスポンスで得られたJSONを使いやすいようにマッピングし直します。

const items = (data) => {
  return data.map((item) => {
    return {
      text: item['attributes']['name'],
      sub_text: item['attributes']['name_hiragana'], // ひらがなでも検索結果を絞り込めるようにする
      label: item['attributes']['name'],
      value: item['attributes']['name']
    }
  })
};

マッピングし直したkeyをここで指定します。意味の詳細は、公式のusage で調べましょう。簡易的には下記のようにコメント書きました。

    searchField: ['text', 'sub_text'], // 入力値のフィルター対象、ひらがなでも検索結果を絞り込めるようにする
    labelField: 'label', // 表示させるラベル
    valueField: 'value', // inputのvalue

オートコンプリートリストおよび、フォームに表示される内容は下記で指定します。

const renderOption = (item, escape) => {
  return `<div><span>${escape(item.text ? item.text : '')}</span></div>`
};

const renderOptionCreate = (item, escape) => {
  return `<div><span>新規キーワード... </span><span>${escape(item.input) ? item.input : ''}</span></div>`
};

既に記述していますが、このjavascriptを使いたいところで読み込むために
<%= javascript_pack_tag 'home' %> をviewに記述します。
今回は、 app/views/home/index.html.erb 記述しました。

最後、webpackerでjavascriptをアセットコンパイルしてもらいます。

bin/webpack-dev-server                    

http://localhost:3000 にアクセスするとOK

c0edcd5d09620e68158dfd6e8ebcd282.gif

以上。
長文読んでくれてありがとうです。

11
7
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
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?