20190714のRailsに関する記事は16件です。

Railsでカラムの値によって動的にmixinする

  • Userモデル
    • job_id: references
  • Jobモデル
    • klass: string

を考える

class User < ApplicationRecord
  belongs_to :job
end

class Job < ApplicationRecord
  has_many :users

  def method_missing(name, *args)
    fail if klass.blank? || self.class.included_modules.include?("Modules::#{self.klass.camelize}".constantize)
    extend("Modules::#{self.klass.camelize}".constantize)
    send(name, *args) and return if respond_to?(name)
    fail
  end
end

module Modules
  module Engineer
    def bark
      p 'hoge'
    end
  end
end

engineer = Job.create(klass: 'engineer')
user = User.create(job: engineer)
user.job.bark # => hoge

やや冗長だけどやや安全版↓

class User < ApplicationRecord
  belongs_to :job
end

class Job < ApplicationRecord
  has_many :users

  def bark
    fail
  end

  def mixined
    klass.present? ? extend("Modules::#{self.klass.camelize}".constantize) : self
  end
end

module Modules
  module Engineer
    def bark
      p 'hoge'
    end
  end
end

engineer = Job.create(klass: 'engineer')
user = User.create(job: engineer)
user.job.mixined.bark # => hoge

というのは嘘で素直にSTIしたほうが良いのではないでしょうか。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6が出る前にActionMailboxを試してみる

ActionMailBox とは

Rails6で入るframeworkの一つです。
今までメールの送信機能として ActionMailer ではありましたが、
メール受信に関する機能がなく、そこで入ったのが ActionMailbox です。

ドキュメントなど

機能を3行で

  • SendGrid などのメールサービスでメールを受信したときに、Railsの専用エンドポイントに転送する
  • ApplicationController と似た ApplicationMailbox でroutingを書く
  • ApplicationMailbox を継承したクラスでメール受信時の処理を行う

ActionMailbox を使うとできそうなこと

  • GitHubにある、コメントのメールに返信したらGitHub上でもコメントできる、的な機能
  • メールサポートシステム(メールディラーなどの代わり)
    • ActionMailer, ActionMailbox を使って、ユーザー情報とも紐づけてサポート対応をより簡単にできるかも。

処理の流れのイメージ

例えば、『Aさんが書いたブログにコメントをされ、その通知をAさんにメールで送信、Aさんがメールに返信したらコメントを返せる』という機能を考えてみます。

  • ブログにコメントがされる
  • ActionMailer でコメントされた通知をAさんにメールで送る
    • to はAさんのメールアドレス(a-san@example.comとします)
    • from には、ブログ・コメントのIDを埋め込んでおきます。 reply-blog_1-comment-2@someapp.example.com という感じ。
  • Aさんはは送られてきたメールに返信する
  • メールサービスがメールを受信し、railsのアプリケーションに転送
    • 受信・転送がされるように設定しておく必要があります。
      • ドメインの設定と受信時に転送する設定が必要です。
      • 詳しくは、RailsGuide や各メールサービスのドキュメントを見てください。
  • ApplicationMailboxの routing でどのmailboxで処理をするか振り分ける
    • 基本的には、メールを受信したときのtoで振り分けをします
      • ここでは reply-blog_1-comment-2@someapp.example.com というメールアドレスです。
  • FooMailbox#process でメール受信時の処理をします。
    • to( a-sann@example.com )メールアドレスで、ユーザー情報を検索
    • from( reply-blog_1-comment-2@someapp.example.com )メールアドレスで、コメントすべきblog, commentを特定
    • bodyにある文章でコメントを追加

実装する上で知っておくと理解が早いこと

RailsGuide には、ActionMailbox の最低限の情報が載っているだけです。

全体像理解してからなら十分読めると思いますが、それまではエスパーするのはなかなか難しいです。

ApplicationMailbox のroutingで、受信したメールの toで処理を振り分ける

# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
  routing /^save@/i     => :forwards
  routing /@replies\./i => :replies
  # routing :all => :foos
end

# app/mailboxes/forwards_mailbox.rb
class ForwardsMailbox < ApplicationMailbox
  def process
    # ...
  end
end

# app/mailboxes/replies_mailbox.rb
class RepliesMailbox < ApplicationMailbox
  def process
    # ...
  end
end

RailsGuideに書かれてるコードを参考に見てみます。
ActionMailbox::Baseを継承した ApplicationMailboxroutingを書きます。
受信したメールの toが、routingに書いた正規表現にマッチしたら指定のmailboxに振り分けします。
例えば、 topic+1@replies.example.com というメールアドレスでメールを受信した場合、
routing /@replies\./i => :repliesの正規表現にマッチするので、 RepliesMailboxクラスに振り分けられます。

Mailboxの実装のサンプル

https://gorails.com/episodes/action-mailbox-rails-6 で実装するものがこのような感じです。

class RepliesMailbox < ApplicationMailbox
  MATCHER = /\Areply-(.+)@reply.example.com/i # domain部分は例で書いてあるだけです。

  before_processing :require_user

  def process
    discussion.comments.create(
      user: user,
      body: mail.decoded,
    )
  end

  private

  def user
    # ユーザーは事前に同じメールアドレスでサイトに登録されてる必要があります。
    @user ||= User.find_by(email: mail.from)
  end

  def require_user
    unless user
      # bounce_with でbounceの処理をする。ここで#processの処理は停止します
      # ユーザーが見つからない場合、bounceのメールを送信する
      bounce_with UserMailer.missing(inboud_email) 
    end
  end

  def discussion
    @discussion ||= Discussion.find(discussion_id)
  end

  # メールアドレスからdiscussion_idを取り出す
  def discussion_id
    recipient = mail.recipients.find { |r| MATCHER.match?(r) } 
    recipient[MATCHER, 1]
end
end
  • mailbox は controller と似た感じで実装できます
  • 上記のサンプルコードでは、受信時のメールアドレス(to, recipients)から duscussion_id を取り出しています。
    • このような実装をする場合、メールアドレスが重要なので、まずアプリ側からそういうメールアドレスを作ってユーザーにメールを送信する必要があるでしょう。
      • サイト側からメールを送るときの from のアドレスが重要ということです。それに返信してもらったら、受信時のtoになるので。
      • もっと賢い方法をもしご存知でしたらぜひ教えていただけるとありがたいです :bow:
  • User, Discussion, Comment, UserMailer などは当然自前で実装が必要です。
    • RailsGuide のサンプルコードの Person, Forward, Forwards::RoutingMailer あたりも自前実装のはずです。

development環境で試す

https://railsguides.jp/action_mailbox_basics.html#action-mailboxをdevelopment環境で使う

/rails/conductor/action_mailbox/inbound_emailsActionMailbox::InboundEmailのcontrollerが用意されています。
/rails/conductor/action_mailbox/inbound_emails/new でメール入力フォームが表示され、メール受信時の挙動を再現できます。

ローカル環境で実際のメールの受信を試してみる

Mailgun や SendGrid のようなメール送受信サービスを使う場合、
外部からのリクエストを受け付けられるようにする必要があります。

https://ngrok.com/
ngrokを使うと良いでしょう。
ngrokを使うと、外部からアクセスできるURLが発行できます。

いくつか手順があります。
Mailgunを例にします。

  • Mailgunのアカウント登録を済ませる
  • ngrokを ngrok http 3000 起動してURLを発行する

    • railsを3000ポート以外で起動する場合はそれに合わせて設定してください。
    • 無料で使ってる分には、発行されるURLは起動のたびに変わるので注意してください。
  • Mailgun で、メールを受信したときに Rails にメールを転送するように設定する

    • RailsGuide に書くメールサービスごとに設定の仕方が書いてあります。
      • Mailgun の場合、 https://railsguides.jp/action_mailbox_basics.html#mailgun
      • 設定するURLは https://<ngrokで発行されたドメイン>/rails/action_mailbox/mailgun/inbound_emails/mimeになります。
      • pathは、メールサービスごとに違うので注意してください。
  • Mailgun から発行された API key を Rails の Credentials に設定する

  • Rails が Mailgun を使用するように設定する

  • Rails を起動する

  • Mailgun で発行されたドメインに対してメールを送る

これで基本的には動くはずです。
動かない場合、以下を確認してみると原因特定につながるかと思います。

おわりに

ActionMailbox の触りの紹介の記事でした。
まだこの記事を書いている段階では Rails6 はリリースされていませんし、ネットには情報がとても少ないです。
ですので、実際運用しようと実装してみると、もっと知らないといけないことはあるかもしれないですし、
ハマりどころもあるかもしれません。

ですが、この記事がとりあえず触ってみるきっかけになれば幸いです :smile:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6が出る前のActionMailbox入門

ActionMailBox とは

Rails6で入るframeworkの一つです。
今までメールの送信機能として ActionMailer ではありましたが、
メール受信に関する機能がなく、そこで入ったのが ActionMailbox です。

ドキュメントなど

機能を3行で

  • SendGrid などのメールサービスでメールを受信したときに、Railsの専用エンドポイントに転送する
  • ApplicationController と似た ApplicationMailbox でroutingを書く
  • ApplicationMailbox を継承したクラスでメール受信時の処理を行う

ActionMailbox を使うとできそうなこと

  • GitHubにある、コメントのメールに返信したらGitHub上でもコメントできる、的な機能
  • メールサポートシステム(メールディラーなどの代わり)
    • ActionMailer, ActionMailbox を使って、ユーザー情報とも紐づけてサポート対応をより簡単にできるかも。

処理の流れのイメージ

例えば、『Aさんが書いたブログにコメントをされ、その通知をAさんにメールで送信、Aさんがメールに返信したらコメントを返せる』という機能を考えてみます。

  • ブログにコメントがされる
  • ActionMailer でコメントされた通知をAさんにメールで送る
    • to はAさんのメールアドレス(a-san@example.comとします)
    • from には、ブログ・コメントのIDを埋め込んでおきます。 reply-blog_1-comment-2@someapp.example.com という感じ。
  • Aさんはは送られてきたメールに返信する
  • メールサービスがメールを受信し、railsのアプリケーションに転送
    • 受信・転送がされるように設定しておく必要があります。
      • ドメインの設定と受信時に転送する設定が必要です。
      • 詳しくは、RailsGuide や各メールサービスのドキュメントを見てください。
  • ApplicationMailboxの routing でどのmailboxで処理をするか振り分ける
    • 基本的には、メールを受信したときのtoで振り分けをします
      • ここでは reply-blog_1-comment-2@someapp.example.com というメールアドレスです。
  • FooMailbox#process でメール受信時の処理をします。
    • to( a-sann@example.com )メールアドレスで、ユーザー情報を検索
    • from( reply-blog_1-comment-2@someapp.example.com )メールアドレスで、コメントすべきblog, commentを特定
    • bodyにある文章でコメントを追加

実装する上で知っておくと理解が早いこと

RailsGuide には、ActionMailbox の最低限の情報が載っているだけです。

全体像理解してからなら十分読めると思いますが、それまではエスパーするのはなかなか難しいです。

ApplicationMailbox のroutingで、受信したメールの toで処理を振り分ける

# app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
  routing /^save@/i     => :forwards
  routing /@replies\./i => :replies
  # routing :all => :foos
end

# app/mailboxes/forwards_mailbox.rb
class ForwardsMailbox < ApplicationMailbox
  def process
    # ...
  end
end

# app/mailboxes/replies_mailbox.rb
class RepliesMailbox < ApplicationMailbox
  def process
    # ...
  end
end

RailsGuideに書かれてるコードを参考に見てみます。
ActionMailbox::Baseを継承した ApplicationMailboxroutingを書きます。
受信したメールの toが、routingに書いた正規表現にマッチしたら指定のmailboxに振り分けします。
例えば、 topic+1@replies.example.com というメールアドレスでメールを受信した場合、
routing /@replies\./i => :repliesの正規表現にマッチするので、 RepliesMailboxクラスに振り分けられます。

Mailboxの実装のサンプル

https://gorails.com/episodes/action-mailbox-rails-6 で実装するものがこのような感じです。

class RepliesMailbox < ApplicationMailbox
  MATCHER = /\Areply-(.+)@reply.example.com/i # domain部分は例で書いてあるだけです。

  before_processing :require_user

  def process
    discussion.comments.create(
      user: user,
      body: mail.decoded,
    )
  end

  private

  def user
    # ユーザーは事前に同じメールアドレスでサイトに登録されてる必要があります。
    @user ||= User.find_by(email: mail.from)
  end

  def require_user
    unless user
      # bounce_with でbounceの処理をする。ここで#processの処理は停止します
      # ユーザーが見つからない場合、bounceのメールを送信する
      bounce_with UserMailer.missing(inboud_email) 
    end
  end

  def discussion
    @discussion ||= Discussion.find(discussion_id)
  end

  # メールアドレスからdiscussion_idを取り出す
  def discussion_id
    recipient = mail.recipients.find { |r| MATCHER.match?(r) } 
    recipient[MATCHER, 1]
end
end
  • mailbox は controller と似た感じで実装できます
  • 上記のサンプルコードでは、受信時のメールアドレス(to, recipients)から duscussion_id を取り出しています。
    • このような実装をする場合、メールアドレスが重要なので、まずアプリ側からそういうメールアドレスを作ってユーザーにメールを送信する必要があるでしょう。
      • サイト側からメールを送るときの from のアドレスが重要ということです。それに返信してもらったら、受信時のtoになるので。
      • もっと賢い方法をもしご存知でしたらぜひ教えていただけるとありがたいです :bow:
  • User, Discussion, Comment, UserMailer などは当然自前で実装が必要です。
    • RailsGuide のサンプルコードの Person, Forward, Forwards::RoutingMailer あたりも自前実装のはずです。

development環境で試す

https://railsguides.jp/action_mailbox_basics.html#action-mailboxをdevelopment環境で使う

/rails/conductor/action_mailbox/inbound_emailsActionMailbox::InboundEmailのcontrollerが用意されています。
/rails/conductor/action_mailbox/inbound_emails/new でメール入力フォームが表示され、メール受信時の挙動を再現できます。

ローカル環境で実際のメールの受信を試してみる

Mailgun や SendGrid のようなメール送受信サービスを使う場合、
外部からのリクエストを受け付けられるようにする必要があります。

https://ngrok.com/
ngrokを使うと良いでしょう。
ngrokを使うと、外部からアクセスできるURLが発行できます。

いくつか手順があります。
Mailgunを例にします。

  • Mailgunのアカウント登録を済ませる
  • ngrokを ngrok http 3000 起動してURLを発行する

    • railsを3000ポート以外で起動する場合はそれに合わせて設定してください。
    • 無料で使ってる分には、発行されるURLは起動のたびに変わるので注意してください。
  • Mailgun で、メールを受信したときに Rails にメールを転送するように設定する

    • RailsGuide に書くメールサービスごとに設定の仕方が書いてあります。
      • Mailgun の場合、 https://railsguides.jp/action_mailbox_basics.html#mailgun
      • 設定するURLは https://<ngrokで発行されたドメイン>/rails/action_mailbox/mailgun/inbound_emails/mimeになります。
      • pathは、メールサービスごとに違うので注意してください。
  • Mailgun から発行された API key を Rails の Credentials に設定する

  • Rails が Mailgun を使用するように設定する

  • Rails を起動する

  • Mailgun で発行されたドメインに対してメールを送る

これで基本的には動くはずです。
動かない場合、以下を確認してみると原因特定につながるかと思います。

おわりに

ActionMailbox の触りの紹介の記事でした。
まだこの記事を書いている段階では Rails6 はリリースされていませんし、ネットには情報がとても少ないです。
ですので、実際運用しようと実装してみると、もっと知らないといけないことはあるかもしれないですし、
ハマりどころもあるかもしれません。

ですが、この記事がとりあえず触ってみるきっかけになれば幸いです :smile:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

7/14 今日学んだ事(登録機能)

会員登録機能の実装

作業内容

1.usersテーブルにnicknameカラムを追加
2.ビューを追加
3.ストロングパラメータを追加
4.バリテーションの追加

1.usersテーブルにnicknameカラムを追加する

usersテーブルにnicknameカラムをstring型で追加する

ターミナル.
$ rails g migration Addカラム名Toテーブル名 カラム名:データ型

となるので

$ rails g migration AddNicknameToUseres nickname:string

とターミナルで実行

マイグレーションの実行

ターミナル.
$ rake db:migrate

・RailsはAdd ~~ ToUsersのように最後に記述するテーブル名でカラムを追加するテーブルを判断しているので、ファイル名は正しく打ち込む

2.ビューの追加

サインアップ画面にnicknameを入力するフォームと、アイコン画像をアップロードできるフォームを作成。

・サインアップ画面はRailsのform_forメソッドを使って記述する。
・ニックネームは『テキストフィールド』のフィールドを生成する
・アイコン画像は『ファイルフィールド』のフィールドを生成する

生成方法

テキストフィールド

new.html.erb
<%= f.text_field :カラム名 %>

ファイルフィールド

new.html.erb
<%= f.file_field :カラム名 %>

のフォームタグを利用すると生成される。
※new.html.erbは新規作成するテキスト

3.ストロングパラメータを追加

まずnicknameとimageが設定されていないため、deviseのstrong_parametersに新しく許可するパラメーターを追加する必要がある。
そこで、deviseのdevise_parameter_sanitizerメソッドを使用する。

●devise_parameter_sanitizer

・strong_parametersに対してパラメータをー追加できる。
・before_actionに設定する※
・記述するのはDeviseのコントローラを継承したコントローラかもしくはApplicationController

コード

devise_parameter_sanitizer.permit(追加したいメソッドの種類, keys: [追加したいパラメーター名])

引数の値(処理)
1. :sign_up(新規登録時)
2. :sign_in(ログイン時)
3. :account_update(レコードの更新時)

複数パラメータを追加する場合

devise_parameter_sanitizer.permit(追加したいメソッドの種類, keys: [:パラメーター1, :パラメーター2,..])

『,』カンマで区切る。

注意
※直接before_actionに設定してはいけない
devise_parameter_sanitizerを呼び出すためのメソッドを作成してそのメソッドを呼び出すようにする。

application_controller.rb
before_action :configure_permitted_parameters

  def configure_permitted_parameters
    # devise_parameter_sanitizerメソッドを呼び出す
  end

すべてのコントローラがApplicationControllerを継承しているため、この記述ではすべてのコントローラのアクションの前でdevise_parameter_sanitizerメソッドが呼び出される。
devise_parameter_sanitizerメソッドはdeviseで追加されたメソッドなので、Deviseのコントローラ以外で呼び出すことができない。よって、before_actionを適応するコントローラを指定する。

before_actionではifというオプションを指定することができる。これはbefore_actionを呼び出す条件を指定するもの。今回はコントローラの種類を指定するので以下のように記述する。

application_controller.rb
before_action :メソッド名, if: :コントローラ名?

application_controller.rb
before_action :configure_permitted_parameters, if: :devise_controller?

devise_controllerが動いた時のみこのアクションを事前に動かしたいので、ifオプションを利用してそのように設定する。
※configure_permitted_parametersはdeviseで利用出来るパラメーターを設定でるdevise関連のストロングパラメーターの管理メソッド。

4.バリテーションの追加

バリテーションとは
オブジェクトがDBに保存される前に、そのデータが正しいかどうかを検証する仕組み

今回やりたい事

・nicknameが入力されていないとエラーがでるようにバリテーションを設定する。
・nicknameの入力が必須と分かるようテキストフィールドにプレイスホルダーを設定する

●validation(検証)

入力フォームを通じてビューからサーバーへ側へパラメーターが送られてきた際、正常な値か検証できる機能。

●validates :カラム名, presence: true

フォームの中身があるか検出し、無い場合は保存しない。

userのnicknameの入力を必須にする場合

user.rb
class User < ApplicationRecord
    validates :nickname, presence: true

と記述する。

● placeholder: ''

読み:(プレイスホルダー)

・フォームの値が空の時に、''で囲んだ文字を薄く表示しておくことができる。
・入力する人が、何を入力するか分かりやすくなる
今回はnicknameの入力欄に反映させたいので、text_fieldメソッドのオプションとして、以下のように利用する。

new.html.erb
<%= f.text field :nickname, placeholder:'ニックネームを入力(必須)' %>

・ニックネームを設定していないとエラーがでるようにした
・ニックネームの入力欄にプレイスホルダーを設定できた

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails newするときの流れ

新しくRailsプロジェクトを作る時の流れをメモとしてざっくり残しておきます。

- Railsをインストール -

$ mkdir ○◯◯
$ cd ◯◯◯
$ bundle init

bundle initによりGemfileが作成されるので、Gemfileを編集。

Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails" #ここのコメントアウトを外す

そしたら、
$ bundle install --path vendor/bundle
--pathでパスを指定することによって、指定されたパスにRailsがインストールされる。

- Rails new する -

bundle exec rails new . -B -d mysql -T
Rails new。
Overwrite /Users/apple/Desktop/App/Gemfile? (enter "h" for help) [Ynaqdhm]と、Gemfileを上書きしていいか聞かれるので、Yes。
Rspecを使いたいのでテストはスキップ。

- DBを作成する -

usernameとpasswordを指定。

config/database.yml
  default: &default
  adapter: mysql2
  encoding: utf8
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: #ユーザー名
  password: #パスワード
  socket: /tmp/mysql.sock

ターミナルで

$ bundle config --local build.mysql2 "--with-cppflags=-I/usr/local/opt/openssl/include"
$ bundle config --local build.mysql2 "--with-ldflags=-L/usr/local/opt/openssl/lib"

この二つのコマンドを実行し、もう一度bundle installします。

そしてbin/rails db:create

上記の2つのコマンドを入力してbundle installという工程を飛ばすとエラーが出ます(自分の環境だと)。

- Rspecの設定をする -

Gemfile
・・・
group :development, :test do
 gem 'rspec-rails' #それぞれ、必要があればバージョン指定をする。
 gem 'factory_bot_rails'
 gem 'spring-commands-rspec'
end
・・・

Gemfileに上記の3つを追加し、bundle install

$ bin/rails generate rspec:install
RSpecをインストール。

.rspec
--require spec_helper
--format documentation #この行を追加するとテストを実行した際にログが読みやすくなる。

次は$ bundle exec spring binstub rspec
このコマンドを打つとbin/rspecコマンドでテストが実行できるようになる。

config/application.rb
module Test
 class Application < Rails::Application
 ...
  config.generators do |g|
   g.test_framework :rspec,
   fixtures: true,
   view_specs: false,
   helper_specs: false,
   routing_specs: false,
   request_specs: false
  end
 ...
 end
end

上記の行を追加。
これでRSpecのセットアップは完了です。

- Slimを導入する -

Gemfile
...
gem 'slim-rails'
gem 'html2slim'

bundle install でgemをインストール。
bundle exec erb2slim app/views/layouts --delete
erbファイルを削除。

- Bootstrapをインストール -

Gemfile
gem 'bootstrap;

bundle install

$ rm app/assets/stylesheets/application.css
sassを使うために、application.cssを削除。
application.scssを作成し、

application.scss
@import 'bootstrap';

追加。

- i18nで日本語化 -

$ wget https://raw.githubusercontent.com/svenfuchs/rails-18n/master/rails/locale/ja.yml --output-document=config/locales/ja.yml

rawファイルをja.ymlにダウンロード。
config/initializers下にlocale.rbファイルを作成。

config/initializers/locale.rb
 I18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s]
 I18n.config.available_locales = :ja
 I18n.default_locale = :ja

これで完了です。

- 終わり -

こんな感じで一通り終了です。
いつも忘れてしまうので、ザックリまとめてみました。
ここをこうした方が良いよとか、アドバイスがあったら教えていただけると嬉しいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsアプリをHerokuにデプロイ(Nginx・Unicorn使用)

目次

  1. エラーとの遭遇
  2. デプロイまでの手順
  3. Nginxの設定
  4. Unicornの設定
  5. デプロイ
  6. Nginxの確認方法
  7. まとめ

1. エラーとの遭遇

めっちゃニッチな需要だけど、簡単なBlogのCRUDを実装したRailsアプリをHerokuにデプロイする際にNginxとUnicornを使用したい状況があった。

これまではheroku-buildpack-nginxのREADMEにあるExisting AppNew App通りにやればできたハズ、、、
だったけど、この前同じ手順でデプロイをしたところ、App crushedが出てしまった...

まあ、herokuのrestartでもすればなおるだろうと試してみたが、ダメだった?‍♀️

とりあえずログを見ようと$ heroku logs -tを試したらH10の文字が...
H10てたまに出るけど、エラーを直すだけでH10自体の意味をあまり考えたことがなかった。
H10 - App crashed
なるほど...??

確かweb dynoってHTTPのリクエストとかを捌いてんだっけ、、って感じになったので原因を探ってみることにした。

H10が出た時はとりあえず下記コマンドからconsoleを開いて、BlogをcountしてみたりするとよくみるRailsのエラーが表示されることがある。

Terminal
$ heroku run rails c

・・・エラーが出ない?

じゃあCRUD機能は問題ないと仮定して、どこがダメなのかな〜とあちこち確認を始めた。

この時はまだ余裕があったので、とりあえずProcfileとかconfig/の中とか確認してみればいっかなーとか考えてbuildpackのドキュメント通りにconfigファイル書くときにタイポでもしたかと目を皿にして確認。

・・・ちゃんとできてる?

早いけどこの辺で長期化を覚悟し始めた

  • buildpackの読み込みの順番を変えてみる
  • Gemfileの確認
  • herokuのログの再確認
  • rails newから再度やり直し

とかそれぞれ何回もやったけど、ダメだった。
herokuのログには下記の内容が毎秒吐き出されてる...???

2019-07-14T02:22:44.859554+00:00 app[web.1]: buildpack=nginx at=app-initialization
2019-07-14T02:22:45.861308+00:00 app[web.1]: buildpack=nginx at=app-initialization
2019-07-14T02:22:46.863727+00:00 app[web.1]: buildpack=nginx at=app-initialization

ここを確認してみたけど、多分buildpackに従ってNginxを使おうとしてるけど、肝心のNginxが反応ないのかな...?
とか生意気に仮定は立てるけど、どうしたら動くのかが思いつかない...
ここまでで既に数時間経っていたので、先輩に聞いてみた。

半日くらいして、眠気で目を血走らせた先輩から「できた」と言われ、マジかと思いつつ確認してみると

確かにできてる...?

よーわかんないけどを連発しながら仮定を説明してもらった。(自分も本当によーわからんかった。??)

とりあえず言われた通り、下記のようにしたらうまく動くようになりました。

2. デプロイまでの手順

  • Railsのプロジェクト内で下記を実行。
Terminal
$ heroku create # herokuにアプリ枠を作成

$ heroku buildpacks:add heroku/ruby # Rubyのbuildpackを追加

$ heroku buildpacks:add https://github.com/heroku/heroku-buildpack-nginx # HerokuでNginxを使用するbuildpackの追加


ここまでは今までやってきたことと一緒 ?‍♀️

3. Nginxのconfigurationファイルの作成

ここからが、先輩から教えてもらって追加した設定☝️

  • config/nginx.confを作成する。

そういえば今までNginxの設定は特にしてなかったな...?とか思いながら言われた通りにやる。

下記のコードはこの記事からココを参考にしたみたい。(この人の記事よくみる気がする...?)

nginx.conf
worker_processes <%= ENV['NGINX_WORKERS'] || 4 %>;

events {
  use epoll;
  accept_mutex on;
  worker_connections 1024;
}

http {
  gzip on;
  gzip_comp_level 3;
  gzip_min_length 150;
  gzip_proxied any;
  gzip_types text/plain text/css text/json text/javascript
    application/javascript application/x-javascript application/json
    application/rss+xml application/vnd.ms-fontobject application/x-font-ttf
    application/xml font/opentype image/svg+xml text/xml;

  server_tokens off;

  log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id';
  access_log logs/nginx/access.log l2met;
  error_log logs/nginx/error.log;

  include mime.types;
  default_type application/octet-stream;
  sendfile on;

  # Must read the body in 5 seconds.
  client_body_timeout 5;

  upstream app_server {
    server unix:/tmp/nginx.socket fail_timeout=0;
  }

  server {
    listen <%= ENV["PORT"] %>;
    server_name _;
    keepalive_timeout 5;

    root /app/public; # path to your app

    location / {
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $http_host;
      proxy_redirect off;
      proxy_pass http://app_server;
    }

    location ~* ^/documentation/(.*) {
      set $s3_bucket        'my_bucket.s3-website-us-east-1.amazonaws.com';
      set $url_full         '$1';
      resolver              8.8.8.8 valid=300s;
      resolver_timeout      10s;

      index index.html;

      proxy_hide_header       x-amz-id-2;
      proxy_hide_header       x-amz-request-id;
      proxy_hide_header       Set-Cookie;
      proxy_set_header        X-Real-IP $remote_addr;
      proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header        Host $s3_bucket;
      proxy_ignore_headers    "Set-Cookie";
      proxy_buffering         off;
      proxy_intercept_errors  on;
      proxy_redirect          off;
      proxy_pass              http://$s3_bucket/$url_full;
    }

    location ~* \.(eot|oft|svg|ttf|woff)$ {
      add_header Access-Control-Allow-Origin *;
      expires max;
      log_not_found off;
      access_log off;
      add_header Cache-Control public;
    }

    location ~* ^/assets/ {
      gzip_static on;

      # Per RFC2616 - 1 year maximum expiry
      expires 1y;
      add_header Cache-Control public;

      # Some browsers still send conditional-GET requests if there's a
      # Last-Modified header or an ETag header even if they haven't
      # reached the expiry date sent in the Expires header.
      add_header Last-Modified "";
      add_header ETag "";
      break;
    }
  }
}

先輩曰く必要ない記述もありそうとのこと。とりあえず動かすだけだから今は気にしないでおく。

4. Unicornの設定

  • Gemfileからpumaを削除し、unicornを追記
Gemfile
gem 'unicorn'
  • pumaは使用しないのでconfig/puma.rbを削除する。

  • Gemfile.lock更新のため、$ bundleしておく

ここも元々やってたことと一緒だった ?‍♀️

  • config/unicorn.rbを作成する。

ここもこれまでと一緒 ?‍♀️...と思っていたらちょっと違った。

これまではbuildpackドキュメントのUpdate Unicorn Configを参考にしてたけど、下記のようにするみたい。

unicorn.rb
worker_processes Integer(ENV["WEB_CONCURRENCY"] || 3)
timeout 15
preload_app true
worker_processes 4
listen 'unix:///tmp/nginx.socket', backlog: 1024

before_fork do |server,worker|
    FileUtils.touch('/tmp/app-initialized')
end

FileUtils.touchについては、さっきの記事Configuring Pumaを読む限り、それぞれの順番を守もらうための設定らしい。?

こんな感じ?

The start-nginx script from the Nginx Buildpack will wait until the /tmp/app-initialized file is present before Nginx binds to the public port.

アプリケーションサーバの起動を待ってからNginxと連携するみたい。

  • 次に、Procfileを作成する。

config/unicorn.rbを読み込む設定。

Procfile
web: bin/start-nginx bundle exec unicorn -c config/unicorn.rb

5.デプロイ

やっとデプロイ??

ここはいつも通り、addしてcommitしてpushすればOK?‍♀️

  • herokuでDBを作成する。
Terminal
$ heroku run rake db:migrate RAILS_ENV=production

これもいつも通りなはず。

ここまでできたら、あとは$ heroku openなどでアプリを開いて実際に動作確認。
ここでエラーが出ている場合は、Railsアプリ内で何か間違っているか、ここまでの手順に何か抜け漏れがあるハズ?

6. Nginxの確認方法

しっかりとheroku上でアプリが動くことを確認できたら、いよいよNginxがしっかり動いているか確認してみる。

確認方法は複数あると覆いますが、ここではwgetコマンドを使用して確認していきます。

下記を実行してみると、下記のような結果が返ってくるハズ。

Terminal
$ wget -S --spider <HerokuアプリのURL>

Spider mode enabled. Check if remote file exists.
--2019-07-14 17:29:09-- <HerokuアプリのURL>
Resolving  <HerokuアプリのURL>...
Connecting to  <HerokuアプリのURL>|... connected.
HTTP request sent, awaiting response...
  HTTP/1.1 200 OK
  Connection: keep-alive
  Server: nginx    # ここにNginxが表示される。
  Date: Sun, 14 Jul 2019 08:29:09 GMT
  Content-Type: text/html; charset=utf-8
  X-Frame-Options: SAMEORIGIN
  X-Xss-Protection: 1; mode=block
  X-Content-Type-Options: nosniff
  X-Download-Options: noopen
  X-Permitted-Cross-Domain-Policies: none
  Referrer-Policy: strict-origin-when-cross-origin
  Cache-Control: max-age=0, private, must-revalidate
  X-Runtime: 0.090119
  Via: 1.1 vegur
Length: unspecified [text/html]
Remote file exists and could contain further links,
but recursion is disabled -- not retrieving.

Spiderモードだって?
カッコいいけどよく分かんないから調べてみると、こんな感じらしい。
--spiderはwgetコマンドのオプションみたい。これまで何も考えずに使ってた?

ファイルをダウンロードせず、URLの存在だけチェックする(“Web spider”として動作する)。例えば、ブックマークをチェックするなら「wget --spider --force-html -i bookmarks.html」のように指定する

  • wgetコマンドが使用できない場合は、brewからインストールしましょう。

7. まとめ

長くなりましたが、これでHerokuにデプロイしたアプリでNginxが動いていることがしっかりと確認できました!

先輩のおかげで助かった...
自分だけだと多分ここまでくるのに何日もかかったハズ?

※今回herokuにデプロイするために作成したRailsプロジェクト
READMEにも簡潔に一連の内容を書いてみました。
https://github.com/kawaaa26/herokuapp_nginx

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsチュートリアルのために作成したDockerコンテナを起動し、必要なモジュールをインストールする for Windows

拙著RailsチュートリアルのためにDockerコンテナの作成 for Windowsの続きとなります。「Yay! You're on Rails!」と表示されるところまで持っていきます。

前回停止させたコンテナを再び起動

まず、前回停止させたDockerコンテナが存在することを確認します。

powershell
$ docker container ls -l
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                    PORTS               NAMES
e11d56031051        ruby:2.5.1          "/bin/bash"         22 hours ago        Exited (0) 21 hours ago                       RailsTutorialTest

STATUSも、想定通りExited (0)になっています。

停止状態のDockerコンテナは、docker start {コンテナ名}コマンドで起動することができます。

powershell
$ docker start RailsTutorialTest
RailsTutorialTest

改めてdocker lsコマンドを実行します。

powershell
$ docker container ls
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                    NAMES
e11d56031051        ruby:2.5.1          "/bin/bash"         22 hours ago        Up About a minute   0.0.0.0:8080->3000/tcp   RailsTutorialTest

RailsTutorialTestコンテナが正常に起動されています。

続いて、RailsTutorialTestコンテナのシェルに入りましょう。

powershell
$ docker container exec -it RailsTutorialTest /bin/bash
root@e11d56031051:/#

正常にRailsTutorialTestコンテナのシェルに入れました。

docker container execのオプション

  • -it…ホスト側の標準入出力とDockerコンテナの標準入出力を接続する
    • -i…ホスト側の標準入力とDockerコンテナの標準入力を接続する
    • -t…Dockerコンテナの標準出力とホスト側の標準出力を接続する
    • ホスト側からDockerコンテナ上のシェルを操作するために必要な設定
  • コマンドを実行するコンテナの名前
  • コンテナで実行するコマンド
    • コマンドを実行するコンテナの名前の後に入力する
    • 今回は/bin/bashである

Railsのインストール

bash
# apt update
# apt install -y nodejs
# gem install rails -v 5.1.6

aptリポジトリの一覧を更新

まずはapt updateでaptリポジトリの一覧を更新します。リポジトリを追加・削除した場合には必須となる操作です。

Node.jsのインストール

続いて、apt install -y nodejsでNode.jsをインストールします。
RailsではrubyのExecJS gemを使用し、ExecJS gemの実行にはJavaScriptランタイムが必要となるためです。

CentOS 7.5にRuby on Railsチュートリアルの環境を構築するによると、JavascriptランタイムなしでExecJS gemを実行しようとした場合、以下のようなエラーが発生して実行できないそうです。

bash
$ rails server
/home/vagrant/.gem/ruby/gems/execjs-2.7.0/lib/execjs/runtimes.rb:58:in `autodetect': Could not find a JavaScript runtime. See https://github.com/rails/execjs for a list of available runtimes. (ExecJS::RuntimeUnavailable)
...

Rails本体のインストール

ここまで完了したら、いよいよRailsのインストールです。バージョンはRailsチュートリアルで使っている5.1.6を指定します。

bash
Successfully installed rails-5.1.6

ひとまずここまで到達できました。

Rails本体がインストールされていることの確認

bash
# rails -v
Rails 5.1.6

rails -vコマンドにより、インストールされているRailsのバージョンを確認することができます。Rails 5.1.6が正しくインストールされているようです。

gemをインストールするためにGemfileを修正する

bash
# cd /var/www
# rails _5.1.6_ new hello_app

以下は、rails-vコマンドにより生成されたGemfileとRailsチュートリアルのリスト1.5に記載されていたGemfileの差分です。

Gemfile
 source 'https://rubygems.org'

-git_source(:github) do |repo_name|
-  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
-  "https://github.com/#{repo_name}.git"
-end
-
-
-# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
-gem 'rails', '~> 5.1.6'
-# Use sqlite3 as the database for Active Record
-gem 'sqlite3'
-# Use Puma as the app server
-gem 'puma', '~> 3.7'
-# Use SCSS for stylesheets
-gem 'sass-rails', '~> 5.0'
-# Use Uglifier as compressor for JavaScript assets
-gem 'uglifier', '>= 1.3.0'
-# See https://github.com/rails/execjs#readme for more supported runtimes
-# gem 'therubyracer', platforms: :ruby
-
-# Use CoffeeScript for .coffee assets and views
-gem 'coffee-rails', '~> 4.2'
-# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
-gem 'turbolinks', '~> 5'
-# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
-gem 'jbuilder', '~> 2.5'
-# Use Redis adapter to run Action Cable in production
-# gem 'redis', '~> 4.0'
-# Use ActiveModel has_secure_password
-# gem 'bcrypt', '~> 3.1.7'
-
-# Use Capistrano for deployment
-# gem 'capistrano-rails', group: :development
+gem 'rails',        '5.1.6'
+gem 'puma',         '3.9.1'
+gem 'sass-rails',   '5.0.6'
+gem 'uglifier',     '3.2.0'
+gem 'coffee-rails', '4.2.2'
+gem 'jquery-rails', '4.3.1'
+gem 'turbolinks',   '5.0.1'
+gem 'jbuilder',     '2.6.4'

 group :development, :test do
-  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
-  gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
-  # Adds support for Capybara system testing and selenium driver
-  gem 'capybara', '~> 2.13'
-  gem 'selenium-webdriver'
+  gem 'sqlite3',      '1.3.13'
+  gem 'byebug', '9.0.6', platform: :mri
 end

 group :development do
-  # Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
-  gem 'web-console', '>= 3.3.0'
-  gem 'listen', '>= 3.0.5', '< 3.2'
-  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
-  gem 'spring'
-  gem 'spring-watcher-listen', '~> 2.0.0'
+  gem 'web-console',           '3.5.1'
+  gem 'listen',                '3.1.5'
+  gem 'spring',                '2.0.2'
+  gem 'spring-watcher-listen', '2.0.1'
 end

-# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
+# Windows環境ではtzinfo-dataというgemを含める必要があります
 gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

なお、この差分は、以下のコマンドにより取得したものをコピーアンドペーストしたものです。

powershell
$ git --no-pager diff HEAD^..HEAD
bash
# cd hello_app
# bundle install

最初の実行結果は、以下の通りになりました。

bash
You have requested:
  spring = 2.0.2

The bundle currently has spring locked at 2.1.0.
Try running `bundle update spring`

If you are updating multiple gems in your Gemfile at once,
try passing them all to `bundle update`

…おや、うまくいっていませんね。というわけで、以下のコマンドを実行してみます。

bash
# bundle update

何やら色々とインストールされていますね。最後に以下のメッセージが表示されるまで待ちます。

bash
Bundle updated!

改めて、以下のコマンドを実行します。

bash
# bundle install

再び何やら色々と表示されましたね。最後に以下のメッセージが表示されるまで待ちます。

bash
Bundle complete! 15 Gemfile dependencies, 64 gems now installed.
Bundled gems are installed into `/usr/local/bundle`

bundle installが無事完了しました。

Yay! You're on Rails!

bundle installまで終われば、実際に動かすことができるRailsアプリケーションの作成は完了しています。RailsのローカルWebサーバー機能を使い、生成されたRailsアプリケーションを実際に動かしています。

bash
# rails server

以下のメッセージが表示され、ローカルWebサーバーが起動されます。

bash
=> Booting Puma
=> Rails 5.1.6 application starting in development
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.9.1 (ruby 2.5.1-p57), codename: Private Caller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

Dockerコンテナ側の3000番ポートは、ホスト側の8080番ポートに連結されています。というわけで、ブラウザでhttp://localhost:8080/にアクセスしてみます。

yay_you_re_on_rails.png

Yay! You're on Rails!…というわけで、無事Railsアプリケーションが動作するところまでの環境構築が完了しました。

演習

1. デフォルトのRailsページに表示されているものと比べて、今の自分のコンピュータにあるRubyのバージョンはいくつになっていますか? コマンドラインでruby -vを実行することで簡単に確認できます。

powershell
$ ruby -v
ruby 2.6.3p62 (2019-04-16 revision 67580) [x64-mingw32]

上述の結果になりました。2.6.3p62でしょうか。

2. 同様にして、Railsのバージョンも調べてみましょう。調べたバージョンはリスト 1.1でインストールしたバージョンと一致しているでしょうか?

powershell
$ rails -v
rails : 用語 'rails' は、コマンドレット、関数、スクリプト ファイル、または操作可能なプログラムの名前として認識されませ
ん。名前が正しく記述されていることを確認し、パスが含まれている場合はそのパスが正しいことを確認してから、再試行してくだ
さい。
発生場所 行:1 文字:1
+ rails -v
+ ~~~~~
    + CategoryInfo          : ObjectNotFound: (rails:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

そもそもホストのWindows環境にRailsはインストールされていませんでした。

番外. Gemのバージョンを調べてみましょう。

powershell
$ gem -v
3.0.2

Gemのバージョンは3.0.2でした。

関連リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactでHTMLを作成し、Clickした際にそれぞれの情報をモーダルに表示・非表示させる(Railsで)

はじめに

ReactでHTMLを作成するまでは案外すんなりと行ったのですが、クリックした際に「それぞれの情報を持たせてモーダルを表示する」ことに苦労したので忘備録としてまとめておきます。
最終的にはこのような感じになります。
たくさんの方法を試したので不必要なものもあるかもしれません。
その点は指摘していただけると幸いです。
下の画像をクリックした際にその情報を表示させています。
表示させる内容は個人で変更してください。
react modal.jpg
876747d1b80a09753cfdb7b53b817923.jpg

1.gemの導入とインストール

この2つのgemをGemfileに加え、bundle installしてください

Gemfile
gem 'react-rails'
gem 'webpacker'

その後、それぞれインストールしてください

Terminal
$ bundle install
$ rails webpacker:install  
$ rails webpacker:install:react 
$ rails generate react:install

するとapp以下にapp/javascript/componentsフォルダが作成されます。
私自身、app/assets以下にJavascriptフォルダがあるのに大丈夫なのかと思いましたが、問題ありません。

2. application.html.hamlにtagを追加

application.html.hamlに以下の記述を加えてください

application.html.haml
= javascript_pack_tag 'application'

この際にTerminalでyarnがどうこうというエラーが出るかもしれません。
その場合$ yarn install等、各自で調べて解決してください。

これらでRails上でReactを使う準備ができましたので、いよいよjsファイルに記述していきます。

3. Components以下にjsファイルの作成

Components以下にApp.jsGraduate.jsを作成します。
これらの名前は自分で決めてくださって結構です。
ではこちらのコードをコピーしてください

App.js
import React from 'react';
import Graduate from './Graduate';

const lessonList = [
  {
    name: '開成太郎(2017)',
    school: '一条高校',
    image: 'https://cdn.pixabay.com/photo/2018/01/03/12/33/graduation-3058263__480.jpg',
    introduction: '中学一年生の時は全然テストの点数を取れなかったけど、ここに通い始めて2ヶ月ほどで目に見えて点数が上がるようになりました',
  },
  {
    name: '開成花子(2016)',
    school: '奈良高校',
    image: 'https://image.shutterstock.com/image-photo/japanese-student-browse-job-information-260nw-1010242525.jpg',
    introduction: '学校の授業では物足りず、塾に通うことに決めました。塾では私に合わせた応用問題を別途用意してもらい、そのおかげで合格できました',
  },
  {
    name: '開成三郎(2019)',
    school: '郡山高校',
    image: 'https://image.shutterstock.com/image-photo/high-school-student-graduation-260nw-1242927238.jpg',
    introduction: '土曜日や日曜日も朝早くから開塾してくれたおかげでたくさんの勉強時間を確保することができました。',
  },
  {
    name: '開成四郎(2015)',
    school: '登美ケ丘高校',
    image: 'https://cdn.pixabay.com/photo/2017/02/05/00/08/graduation-2038864__480.jpg',
    introduction: '二年生まで部活一筋だったため、勉強の進捗度は他の人に比べて劣っていたけれど、その分個別に補習の時間を組んでくれたおかげで合格できました。',
  }
];

class App extends React.Component {
  render() {
    return (
        <div className="performance">
        {lessonList.map((lessonItem) => {
      return (
          <Graduate
            name={lessonItem.name}
            image={lessonItem.image}
            school={lessonItem.school}
            introduction={lessonItem.introduction}
            />
      );
    })}
      </div>
    );
  }
}

export default App;

それでは解説していきます。

import React from 'react';
import Graduate from './Graduate';

まずimportとは輸入という意味です。
その名の通り、一行目ではReactを二行目ではGraduateファイルを読み込んでいます。
このおかげでReactを使うことができ、またGraduateにパラメーター(props)を渡すことができます。

const lessonList = [
  {
    name: '開成太郎(2017)',
    school: '一条高校',
    image: 'https://cdn.pixabay.com/photo/2018/01/03/12/33/graduation-3058263__480.jpg',
    introduction: '中学一年生の時は全然テストの点数を取れなかったけど、ここに通い始めて2ヶ月ほどで目に見えて点数が上がるようになりました',
  },
  {
    name: '開成花子(2016)',
    school: '奈良高校',
    image: 'https://image.shutterstock.com/image-photo/japanese-student-browse-job-information-260nw-1010242525.jpg',
    introduction: '学校の授業では物足りず、塾に通うことに決めました。塾では私に合わせた応用問題を別途用意してもらい、そのおかげで合格できました',
  },
  {
    name: '開成三郎(2019)',
    school: '郡山高校',
    image: 'https://image.shutterstock.com/image-photo/high-school-student-graduation-260nw-1242927238.jpg',
    introduction: '土曜日や日曜日も朝早くから開塾してくれたおかげでたくさんの勉強時間を確保することができました。',
  },
  {
    name: '開成四郎(2015)',
    school: '登美ケ丘高校',
    image: 'https://cdn.pixabay.com/photo/2017/02/05/00/08/graduation-2038864__480.jpg',
    introduction: '二年生まで部活一筋だったため、勉強の進捗度は他の人に比べて劣っていたけれど、その分個別に補習の時間を組んでくれたおかげで合格できました。',
  }
];

この部分ではそれぞれのハッシュを配列に入れています。これをあとでmapメソッドで一つずづ表示させていきます。

class App extends React.Component {
  render() {
    return (
        <div className="performance">
        {lessonList.map((lessonItem) => {
      return (
          <Graduate
            name={lessonItem.name}
            image={lessonItem.image}
            school={lessonItem.school}
            introduction={lessonItem.introduction}
            />
      );
    })}
      </div>
    );
  }
}

この部分でComponentを作成しています。(extendsは広げるという意味)
ConmonentはJavascriptの関数のようなものです。
その中のreturnでHTMLを返し、表示させています。またこのような記法をJSXと言います。
また、JSXには約束事があり、複数の要素を返すことができません。なので図の場合、performanceクラスを親要素としその中に色々と要素を追加しています。

{lessonList.map((lessonItem)ではlessonListの数だけ繰り返し、returnを読み込んでいます。
return内でとすることでGraduateコンポーネントを呼び出しています。これが可能なのはimport Graduate from './Graduate';のおかげです。
sosite
呼び出す際にはname,school,image,introductionのパラメーターを渡しています。(props)
このパラメーターをGraduate.js側で使うわけです。ちなみにJSX内でjavascriptの記法を用いる際は中括弧が必要なため、中括弧内に書いてあります。

export default App;

App.jsの最後の行ですが、exportとは輸出という意味です。
これのおかげでHTMLファイルでApp.jsを呼び出すことができ、returnとして要素をHTMLに追加することができます。App.jsがGraduateを二行目でimportできているのもGraduate.jsで最後にexport default Graduate;としているからです。

Graduate.js
import React from 'react';

class Graduate extends React.Component {
  constructor(props) {
    super(props);
    this.state = {isModalOpen: false};
  }

  handleClickLesson() {
    this.setState({isModalOpen: true});
  }

  handleClickClose() {
    this.setState({isModalOpen: false});
  }
  render() {
    let modal;
    if (this.state.isModalOpen) {
      modal = (
        <div className='modal-area'>
        <div className='modal-inner'>
          <div className='modal-header'></div>
          <div className='modal-introduction'>
            <h2>{this.props.name}</h2>
            <p>{this.props.introduction}</p>
          </div>
          <button
            className='modal-close-btn'
            onClick={() => this.handleClickClose()}
          >
            とじる
          </button>
        </div>
      </div>
      )
    };

    return (
      <div className="graduate">
          <img className="graduate__image" src={this.props.image}
               onClick={() => {this.handleClickLesson()}}
          />
          <div className="graduate__school">{this.props.school} 合格!</div>
          <div className="graduate__student">{this.props.name}</div>
          {modal}
      </div>
    );
  }
}

export default Graduate;

この部分で、実際に表示されるHTMLを作成しています。App.jsから渡されたパラメータ(props)を使って書いていきます。

constructor(props) {
    super(props);
    this.state = {isModalOpen: false};
  }

新しくstate(状態)というものが出てきましたが、propsとstateは少し異なり、stateはそのComponent内で保持されるものであって、propsみたいにComponentからComponentに渡すことはできません。詳しくはこちら
この部分でモーダルの表示・非表示を管理しています。

  handleClickLesson() {
    this.setState({isModalOpen: true});
  }

  handleClickClose() {
    this.setState({isModalOpen: false});
  }

ここで先ほどのstateを関数(handleClickLesson or handleClickClose)が呼ばれた時に更新しています。この時に注意して欲しいのが、更新する際はsetStateとしないといけないことです。

let modal;
    if (this.state.isModalOpen) {
      modal = (
        <div className='modal-area'>
        <div className='modal-inner'>
          <div className='modal-header'></div>
          <div className='modal-introduction'>
            <h2>{this.props.name}</h2>
            <p>{this.props.introduction}</p>
          </div>
          <button
            className='modal-close-btn'
            onClick={() => this.handleClickClose()}
          >
            とじる
          </button>
        </div>
      </div>
      )
    };

ここでisModalOpenがtrueの場合のみ、変数modalに値を代入し、表示させます。
そしてモーダルの中にonClick={() => this.handleClickClose()}があると思いますが、このボタンを押すことでisModalOpenがfalseになり再び非表示になります。
またApp.jsからもらってきたパラメータ(props)を{this.props.name}とすることで代入することができます。
またReactではclassをclassNameと記載します。

return (
      <div className="graduate">
          <img className="graduate__image" src={this.props.image}
               onClick={() => {this.handleClickLesson()}}
          />
          <div className="graduate__school">{this.props.school} 合格!</div>
          <div className="graduate__student">{this.props.name}</div>
          {modal}
      </div>
    );

最後ですね。
この部分で常時表示させるHTMLをApp.jsからもらったpropsを使って作成しています。
そして{modal}の部分で先ほどの変数を代入しているわけですね。imgクラスにonClickが設定されているため、画像をクリックするとisModalOpenがtrueになり、値が代入されたmodalが表示されるわけです。
では最後にHTML側でApp.jsを呼んであげましよう。

hamlの場合は

index.haml.haml
= react_component("App")

htmlの場合は

index.html.erb
<%= react_component("App") %>

これで完成です。
CSSだけ記載しておきます。
お好みで変更してください。

stylesheet.css
.performance {
    .graduate {
      padding: 30px 0;
      display: inline-block;
      width: 25%;
      text-align: center;
      &__student {
        font-size: 15px;
        padding-top: 10px;
        text-align: right;
        padding-right: 20px;
      }
      &__school {
        font-size: 20px;
        padding-top: 10px;
      }
      &__image {
        cursor: pointer;
        height: 160px;
        width: 160px;
        border-radius: 50%;
      }
      .modal-area {
        z-index: 2;
        position: fixed;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        background-color: rgba(0, 0, 0, 0.6);
        .modal-inner {
          position: absolute;
          top: 8%;
          right: 0;
          left: 0;
          width: 480px;
          padding-bottom: 60px;
          margin: auto;
          background-color: rgb(255, 255, 255);
          .modal-header {
            margin-bottom: 60px;
          }
          .modal-introduction p {
            color: #5876a3;
            width: 384px;
            line-height: 32px;
            text-align: left;
            margin: 36px auto 40px;
          }
          .modal-close-btn {
            font-size: 13px;
            color: #8491a5;
            width: 200px;
            padding: 16px 0;
            border: 0;
            background-color: #f0f4f9;
            cursor: pointer;
          }
          .modal-close-btn:hover {
            color: #8491a5;
            background-color: #ccd9ea;
            transition: .3s ease-in-out;
          }
        }
      }
    }
  }

番外編

Click時にモーダルが表示されるが非表示にならない場合

モーダル実装時にモーダルが閉じないというバグが起こりました。
原因を調べてみると閉じるボタンを押した際に一回閉じてから再度開いています。
これがその時のコードです。

<div className="graduate"
     onClick={() => {this.handleClickLesson()}}>
          <img className="graduate__image" src={this.props.image}/>
          <div className="graduate__school">{this.props.school} 合格!</div>
          <div className="graduate__student">{this.props.name}</div>
          {modal}
      </div>

何が問題かというと一番の親要素であるgraduateにopenmodalを設定しているせいで、その子要素であるモーダル内のclossmodalを押した際に同時に親要素のopenmodalも呼ばれてしまうからです。
なのでそれぞれのClick機能は親要素、子要素の関係に注意しましょう。

参考資料

React公式HP
Progate
https://qiita.com/k-penguin-sato/items/e3cc04f787cf3254cfae
https://qiita.com/kyrieleison/items/78b3295ff3f37969ab50

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RailsとElasticsearchで検索機能をつくり色々試してみる - サジェスト機能の追加

はじめに

elasticsearch-railsを使うことが前提の記事になります。記事の中で出てくるサンプルや環境は、RailsとElasticsearchで検索機能をつくり色々試してみる - その1:サンプルアプリケーションの作成をもとにしています。

サジェストを実装する上でのElasticsearchのデータスキーマやアナライザー等の設計部分はElasticsearch キーワードサジェスト日本語のための設計の記事を参考にさせてもらっています。

本記事では、サジェスト機能をelasticsearch-railsを使ってRailsアプリケーションに追加していく方法に焦点を当てて紹介していきます。最終的なソースコードはGitHubに上げておきます。

完成イメージ

suggest.mov.gif

こんな感じで検索窓に2文字以上入力すると候補となるキーワードをサジェストする機能を追加します。

全体像

検索履歴を保存してサジェストワードとして使用

実際のアプリケーションを想定してユーザーの検索したキーワードをサジェストワードとして使用するようにします。
要件によっては検索してほしいキーワードをサジェストワードに登録していく方法などいろんな方法がが考えられますが今回はこの方法でいきます。

キーワードの登録.png

Railsアプリを経由してサジェストワードを取得

ユーザーの環境からElasticsearchが公開されているような場合は、ブラウザから直接Elasticsearchにリクエストを送ることも考えられますが、今回はRailsアプリケーションのみからElasticsearchが公開されている場合を想定して、Railsアプリケーションを経由してサジェストワードを返却していきます。

サジェスト_リクエストの流れ.png

検索ワードの保存

ここから実装に入っていきます。まずは検索ワードを保存するテーブルを追加し保存していきます。
後ほどElasticsearchにキーワードを登録する際に、検索にひっかからないワードはサジェストのワードからは除外できるように検索されたワードだけでなくhitした件数も保存するようにします。

保存用のテーブル追加

$ bundle exec rails g migration create_search_word_log

キーワードとhitした件数を保存するカラムを追加します。

db/migrate/20190601132134_create_search_word_log.rb
class CreateSearchWordLog < ActiveRecord::Migration[5.2]
  def change
    create_table :search_word_logs do |t|
      t.string :word
      t.integer :hit_number

      t.timestamps
    end
  end
end
$ bundle exec rails db:migrate

対応するモデルを追加します。

app/models/search_word_log.rb
class SearchWordLog < ApplicationRecord
end

保存処理追加

コントローラに検索履歴をためるための処理を追加していきます。

app/controllers/mangas_controller.rb
class MangasController < ApplicationController
  before_action :set_manga, only: [:show, :edit, :update, :destroy]
  + after_action :save_search_log, only: [:index]

  def index
    @mangas = if search_word.present?
                Manga.es_search(search_word).page(params[:page] || 1).per(5).records
              else
                Manga.page(params[:page] || 1).per(5)
              end
  end

  private

 +  def save_search_log
 +    return if search_word.blank?

 +    SearchWordLog.create(word: search_word, hit_number: @mangas.size)
 +  end

サジェスト用のindexの定義と検索クエリの作成

ここまででサジェストに登録するワードの準備できたので、Elasticsearchにどうのように登録していくかの定義と同時に検索のクエリを追加していきます。
検索機能で追加したものと同様にconcernを作成します。

app/models/concerns/search_word_log_searchable.rb
module SearchWordLogSearchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model

    # a. サジェスト用のindex
    index_name "es_search_log_#{Rails.env}"

    # b. self.analyzer_settingsで下のほうに定義したanalyzerを使えるようにする。
    settings analysis: self.analyzer_settings do
      mappings dynamic: 'false' do
        indexes :id, type: 'text', analyzer: 'kuromoji'
        # c. マルチフィールドを定義する
        indexes :word, type: 'text', fielddata: true, analyzer: 'keyword_analyzer' do
          indexes :autocomplete, type: 'text', analyzer: 'autocomplete_index_analyzer', search_analyzer: 'autocomplete_search_analyzer'
          indexes :readingform,  type: 'text', analyzer: 'readingform_index_analyzer',  search_analyzer: 'readingform_search_analyzer'
        end
        indexes :hit_number,     type: 'integer'
        indexes :created_at,     type: 'date', format: 'YYYY-MM-dd kk:mm:ss'
      end
    end

    def as_indexed_json(*)
      attributes
        .symbolize_keys
        .slice(:id, :word, :hit_number)
        .merge(
          created_at: created_at.strftime("%Y-%m-%d %H:%M:%S")
        )
    end
  end

  class_methods do
    def create_index!
      client = __elasticsearch__.client
      client.indices.delete index: self.index_name rescue nil
      client.indices.create(index: self.index_name,
                            body: {
                                settings: self.settings.to_hash,
                                mappings: self.mappings.to_hash
                            })
    end

    # d. 検索クエリの定義
    def es_search(query)
      __elasticsearch__.search({
        size: 0,
        query: {
          bool: {
            should: [{
              match: {
                "word.autocomplete" => {
                  query: query
                }
              }
            }, {
              match: {
                "word.readingform" => {
                  query: query,
                  fuzziness: "AUTO",
                  operator: "and"
                }
              }
            }]
          }
        },
        aggs: {
          keywords: {
            terms: {
              field: "word",
              order: {
                _count: "desc"
              },
              size: "10"
            }
          }
        }
      })
    end

   # e. analyzerの定義
    def analyzer_settings
      {
        analyzer: {
          keyword_analyzer: {
            type: "custom",
            char_filter: ["normalize", "whitespaces"],
            tokenizer: "keyword",
            filter: ["lowercase", "trim", "maxlength"]
          },
          autocomplete_index_analyzer: {
            type: "custom",
            char_filter: ["normalize", "whitespaces"],
            tokenizer: "keyword",
            filter: ["lowercase", "trim", "maxlength", "engram"]
          },
          autocomplete_search_analyzer: {
            type: "custom",
            char_filter: ["normalize", "whitespaces"],
            tokenizer: "keyword",
            filter: ["lowercase", "trim", "maxlength"]
          },
          readingform_index_analyzer: {
            type: "custom",
            char_filter: ["normalize", "whitespaces"],
            tokenizer: "japanese_normal",
            filter: ["lowercase", "trim", "readingform", "asciifolding", "maxlength", "engram"]
          },
          readingform_search_analyzer:  {
            type: "custom",
            char_filter: ["normalize", "whitespaces", "katakana", "romaji"],
            tokenizer: "japanese_normal",
            filter: ["lowercase", "trim", "maxlength", "readingform", "asciifolding"]
          },
        },
        filter: {
          readingform: {
            type: "kuromoji_readingform",
            use_romaji: true
          },
          engram: {
            type: "edgeNGram",
            min_gram: 1,
            max_gram: 36
          },
          maxlength: {
            type: "length",
            max: 36
          }
        },
        char_filter: {
          normalize: {
            type: "icu_normalizer",
            name: "nfkc_cf",
            mode: "compose",
          },
          katakana: {
            type: "mapping",
            mappings: [
              "ぁ=>ァ", "ぃ=>ィ", "ぅ=>ゥ", "ぇ=>ェ", "ぉ=>ォ",
              "っ=>ッ", "ゃ=>ャ", "ゅ=>ュ", "ょ=>ョ",
              "が=>ガ", "ぎ=>ギ", "ぐ=>グ", "げ=>ゲ", "ご=>ゴ",
              "ざ=>ザ", "じ=>ジ", "ず=>ズ", "ぜ=>ゼ", "ぞ=>ゾ",
              "だ=>ダ", "ぢ=>ヂ", "づ=>ヅ", "で=>デ", "ど=>ド",
              "ば=>バ", "び=>ビ", "ぶ=>ブ", "べ=>ベ", "ぼ=>ボ",
              "ぱ=>パ", "ぴ=>ピ", "ぷ=>プ", "ぺ=>ペ", "ぽ=>ポ",
              "ゔ=>ヴ",
              "あ=>ア", "い=>イ", "う=>ウ", "え=>エ", "お=>オ",
              "か=>カ", "き=>キ", "く=>ク", "け=>ケ", "こ=>コ",
              "さ=>サ", "し=>シ", "す=>ス", "せ=>セ", "そ=>ソ",
              "た=>タ", "ち=>チ", "つ=>ツ", "て=>テ", "と=>ト",
              "な=>ナ", "に=>ニ", "ぬ=>ヌ", "ね=>ネ", "の=>ノ",
              "は=>ハ", "ひ=>ヒ", "ふ=>フ", "へ=>ヘ", "ほ=>ホ",
              "ま=>マ", "み=>ミ", "む=>ム", "め=>メ", "も=>モ",
              "や=>ヤ", "ゆ=>ユ", "よ=>ヨ",
              "ら=>ラ", "り=>リ", "る=>ル", "れ=>レ", "ろ=>ロ",
              "わ=>ワ", "を=>ヲ", "ん=>ン"
            ]
          },
          romaji: {
            type: "mapping",
            mappings: [
              "キャ=>kya", "キュ=>kyu", "キョ=>kyo",
              "シャ=>sha", "シュ=>shu", "ショ=>sho",
              "チャ=>cha", "チュ=>chu", "チョ=>cho",
              "ニャ=>nya", "ニュ=>nyu", "ニョ=>nyo",
              "ヒャ=>hya", "ヒュ=>hyu", "ヒョ=>hyo",
              "ミャ=>mya", "ミュ=>myu", "ミョ=>myo",
              "リャ=>rya", "リュ=>ryu", "リョ=>ryo",
              "ファ=>fa", "フィ=>fi", "フェ=>fe", "フォ=>fo",
              "ギャ=>gya", "ギュ=>gyu", "ギョ=>gyo",
              "ジャ=>ja", "ジュ=>ju", "ジョ=>jo",
              "ヂャ=>ja", "ヂュ=>ju", "ヂョ=>jo",
              "ビャ=>bya", "ビュ=>byu", "ビョ=>byo",
              "ヴァ=>va", "ヴィ=>vi", "ヴ=>v", "ヴェ=>ve", "ヴォ=>vo",
              "ァ=>a", "ィ=>i", "ゥ=>u", "ェ=>e", "ォ=>o",
              "ッ=>t",
              "ャ=>ya", "ュ=>yu", "ョ=>yo",
              "ガ=>ga", "ギ=>gi", "グ=>gu", "ゲ=>ge", "ゴ=>go",
              "ザ=>za", "ジ=>ji", "ズ=>zu", "ゼ=>ze", "ゾ=>zo",
              "ダ=>da", "ヂ=>ji", "ヅ=>zu", "デ=>de", "ド=>do",
              "バ=>ba", "ビ=>bi", "ブ=>bu", "ベ=>be", "ボ=>bo",
              "パ=>pa", "ピ=>pi", "プ=>pu", "ペ=>pe", "ポ=>po",
              "ア=>a", "イ=>i", "ウ=>u", "エ=>e", "オ=>o",
              "カ=>ka", "キ=>ki", "ク=>ku", "ケ=>ke", "コ=>ko",
              "サ=>sa", "シ=>shi", "ス=>su", "セ=>se", "ソ=>so",
              "タ=>ta", "チ=>chi", "ツ=>tsu", "テ=>te", "ト=>to",
              "ナ=>na", "ニ=>ni", "ヌ=>nu", "ネ=>ne", "ノ=>no",
              "ハ=>ha", "ヒ=>hi", "フ=>fu", "ヘ=>he", "ホ=>ho",
              "マ=>ma", "ミ=>mi", "ム=>mu", "メ=>me", "モ=>mo",
              "ヤ=>ya", "ユ=>yu", "ヨ=>yo",
              "ラ=>ra", "リ=>ri", "ル=>ru", "レ=>re", "ロ=>ro",
              "ワ=>wa", "ヲ=>o", "ン=>n"
            ]
          },
          whitespaces: {
            type: "pattern_replace",
            pattern: "\\s{2,}",
            replacement: "\u0020"
          },
        },
        tokenizer: {
          japanese_normal: {
            mode: "normal",
            type: "kuromoji_tokenizer"
          },
          engram: {
            type: "edgeNGram",
            min_gram: 1,
            max_gram: 36
          }
        },
      }
    end
  end
end

作成したconcernをモデルで使えるようにinclude

app/models/search_word_log.rb
class SearchWordLog < ApplicationRecord
  + include SearchWordLogSearchable
end

定義の解説

b. analyzerの登録

self.analyzer_settings の箇所を追加することで analyzer_settings メソッド内に独自に定義したアナライザーを登録しています。

c.マルチフィールドの定義

Elasticsearchへの登録、検索、読み仮名での登録、検索、検索結果の集計とキーワードの表示で別のAnalyzerを使いたいため、マルチフィールドを使いその中で登録と検索で別のAnalyzerを使うようにマッピングを定義しています。
それぞれの用途は以下の表のようになります。各Analyzerで具体的にどのようにキーワードがアナライズされるかの例を記事の最後に載せていますので詳しくはそちらを参照ください。

filed analyzer 用途
word keyword_analyzer 検索した結果の集計とサジェストとして表示する文字列を登録するためのアナライザー
word.autocomplete autocomplete_index_analyzer 検索ワードを加工して登録していくためのアナライザー
word.autocomplete autocomplete_search_analyzer 前方一致検索を行う際に使用するのアナライザー
word.readingform readingform_index_analyzer 読み仮名でhitできるように検索ワードを加工して登録していくためのアナライザー
word.readingform readingform_search_analyzer 読み仮名で前方一致検索を行う際に使用するのアナライザー

それぞれどこで使用されているかは、全体像で使った図でいうと以下のようなイメージになります。

キーワード登録時のアナライザーのイメージ

キーワードの登録.png

検索時のアナライザーのイメージ

サジェスト_リクエストの流れ.png

docker imageの修正

さきほどのanalyzerの定義でchar_filterにicu_normalizerを指定しているのでanalysis-icuのプラグインを使えるように以下を追加してイメージをビルドしておきます。

docker/es/Dockerfile
RUN bin/elasticsearch-plugin install analysis-icu

index追加

indexの定義が作成できたのでrakeタスクを追加してindexを作成します。

lib/tasks/elasticsearch.rake
namespace :elasticsearch do

 + desc 'サジェスト用のindex作成'
 + task :create_suggest_index => :environment do
 +   SearchWordLog.create_index!
 + end
end
$ bundle exec rake elasticsearch:create_suggest_index

テスト用に検索履歴を追加

画面上でぽちぽち登録していくか、スクリプトを書いて search_word_logs テーブルにデータ登録していきます。とりあえずサンプルとして動かすなら数十件ほど追加すればOKかと思います。

データがたたまったところでindexに登録していきます。

検索にhitしたキーワードのみをサジェストワードとして使いたいので、scopeを使用します。

app/models/search_word_log.rb
class SearchWordLog < ApplicationRecord
  include SearchWordLogSearchable

  + scope :searchable_word, -> {
  +   where('hit_number > 0')
  + }
end

rakeタスクを登録して実行します。

rb:lib/tasks/elasticsearch.rake
namespace :elasticsearch do


  + desc 'サジェスト用のキーワードを登録'
  + task :import_suggest_word => :environment do
  +   SearchWordLog.__elasticsearch__.import scope: 'searchable_word'
  + end
end
bundle exec rake elasticsearch:import_suggest_word

サジェストを返却するAPIの追加

GET /mangas/suggest?word={keyword} でサジェストワードを取得できるように修正していきます。

ルーティング追加

config/routes.rb
Rails.application.routes.draw do
  resources :mangas do
   + collection do
   +   get :suggest
   + end
  end
end

コントローラーにアクション追加

app/controllers/mangas_controller.rb
class MangasController < ApplicationController

  ・・・

  + def suggest
  +  # concerns に追加した `es_search` メソッドで検索
  +  suggest_words = SearchWordLog.es_search(params[:word]).aggregations["keywords"]["buckets"]
  +  render json: { suggest_words: suggest_words.map{|word| word["key"]} }
  + end

  private

  ・・・
end

ポイントとしては、今回定義したes_searchメソッドではaggregationsで集計してhitした件数が多いキーワードを取得しているため、aggregationsメソッドを使うことで結果を取り出すことができます。
es_searchで以下のような形式のjsonが返却されaggregations["keywords"]["buckets"]で対象のキーワードの配列を取得しています。

{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 6,
    "max_score" : 0.0,
    "hits" : [ ]
  },
  "aggregations" : {
    "keywords" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "スライム",
          "doc_count" : 3
        },
        {
          "key" : "スラムダンク",
          "doc_count" : 3
        }
      ]
    }
  }
}

View側の修正

最後にview側に修正を入れて行きます。今回はライブラリを使わずにシンプルなJavascriptを追加します。

js追加

app/assets/javascripts/suggest.js
document.addEventListener('turbolinks:load', function () { // turbolinksを使用しない場合はここは不要 
  let timeout = null;

  function selectSuggest(e) {
    document.querySelector("#search_word").value = e.target.textContent;
    document.querySelector(".SearchForm").submit();
  }

  function displayNoneSuggestList () {
    let classList = document.querySelector(".dropdown").classList;
    if (classList.contains("is-active")) {
      classList.remove("is-active")
    }
  }

  function displaySuggestList() {
    let classList = document.querySelector(".dropdown").classList;
    if (!classList.contains("is-active")) {
      classList.add("is-active")
    }
  }

  // apiのレスポンスを元にhtmlを成形してサジェストリストを表示する
  function updateSuggestWords(words) {
    if (words.length > 0) {
      const dropdownMenu = document.querySelector("#dropdown-menu");

      let wordList = "";
      words.forEach(word => {
        wordList += `<div class="dropdown-item" role="option">${word}</div>`;
      });

      const html = `<div id="sugget-list" class="dropdown-content">${wordList}</div>`;

      dropdownMenu.innerHTML = html;
      displaySuggestList();

      const suggestList = document.querySelectorAll(".dropdown-item");
      if (suggestList) {
     // サジェストワードが選択された場合にそのワードで検索を行うようにイベントリスナーに登録
        suggestList.forEach(element => {
          element.addEventListener("click", selectSuggest);
        });
      }
    } else {
      displayNoneSuggestList();
    }
  }

  function getSuggetWords(e) {
    const value = e.target.value;
    clearTimeout(timeout);
    // 2文字以上入力された場合にサジェストワードを取得
    if (value.length > 1) {
    // setTimeoutを使ってapiが呼ばれるまでに時間を置く
      timeout = setTimeout(function () {
        fetch(`http://localhost:3003/mangas/suggest?word=${value}`)
          .then(res => {
            return res.json();
          })
          .then(resJson => {
            updateSuggestWords(resJson.suggest_words);
          })
          .catch(error => console.log(error))
      }, 300);
    } else {
      displayNoneSuggestList()
    }
  }

  // 検索ワードの入力をイベントリスナーに登録
  const inputWord = document.querySelector("#search_word");
  inputWord.addEventListener("input", getSuggetWords);
});

解説

検索ワードが入力されたらapiからサジェストワードを取得してリストを更新するメソッド(getSuggetWords)をイベントリスナーに登録します。
getSuggetWordsでは入力の度にapiが呼ばれてサジェストが何度も変わるのは使いづらいので、2文字以上入力された場合に0.3秒後にapiを呼ぶようにしています。
apiからのレスポンスをupdateSuggestWordsに渡してhtmlを成型してサジェストリストを追加しています。追加するリストにイベントリスナーを登録してキーワードが選択された場合にselectSuggestメソッドを呼び検索を実行するようにしています。
このサンプルアプリではCSSは、bulmaを使用っているので、bulmaのdropdownのクラスを使ってスタイルを整えています。

view修正

jsで使用するクラスや要素を追加し、jsを読み込みます。

app/views/mangas/index.html.erb
<div class="container" style="margin-top: 30px">
-  <%= form_tag(mangas_path, method: :get, class: "field has-addons has-addons-centered") do %>
-    <div class="control">
+  <%= form_tag(mangas_path, method: :get, class: "field has-addons has-addons-centered SearchForm", autocomplete: "off") do %>
+    <div class="control SearchInput">
      <%= text_field_tag :search_word, @search_word, class: "input", placeholder: "漫画を検索する" %>
+   <div class="dropdown">
+       <div class="dropdown-menu" id="dropdown-menu" role="menu">
+       </div>
      </div>
    </div>
    <div class="control">
      <%= submit_tag "検索", class: "button is-info" %>
    </div>
  <% end %>
</div>

   ・
   ・
   ・

+ <%= javascript_include_tag 'suggest' %>

追加したjsを読み込むためにinitializer修正

config/initializers/assets.rb
+ Rails.application.config.assets.precompile += %w(suggest.js)

以上で完成です!

おまけ:analyzerの動作確認

今回追加したanalyzerが実際にどのように動作するかを「転生」という単語を例に見ていきます。

documentの登録

まずはdocument登録する際のanalyzerの動きを確認します。
「転生」という単語を登録する場合以下のようにanalyzeされてindexされています。

analyzer_indexs.png

それぞれのAnalyzer APIでの確認結果は以下のようになります。

keyword_analyzer

request
POST /es_search_log_development/_analyze
{
  "analyzer": "keyword_analyzer",
  "text": "転生"
}
response
{
  "tokens" : [
    {
      "token" : "転生",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}

autocomplete_index_analyzer

request
POST /es_search_log_development/_analyze
{
  "analyzer": "autocomplete_index_analyzer",
  "text": "転生"
}
resoponse
{
  "tokens" : [
    {
      "token" : "転",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "転生",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}

readingform_index_analyzer

request
POST /es_search_log_development/_analyze
{
  "analyzer": "readingform_index_analyzer",
  "text": "転生"
}
response
{
  "tokens" : [
    {
      "token" : "t",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "te",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "ten",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "tens",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "tense",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "tensei",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}

documentのsearch

続いてsearch用のanalyzerの動きを見ます。
今度は「転生」、「てんせ」、「ten」のワードで確認します。
以下のイメージのようにanalyzeされています。

analyzer_seach.png

autocomplete_search_analyzer

「転生」
request
POST /es_search_log_development/_analyze
{
  "analyzer": "autocomplete_search_analyzer",
  "text": "転生"
}
{
  "tokens" : [
    {
      "token" : "転生",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}
「てんせ」
request
POST /es_search_log_development/_analyze
{
  "analyzer": "autocomplete_search_analyzer",
  "text": "てんせ"
}
{
  "tokens" : [
    {
      "token" : "てんせ",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    }
  ]
}

「ten」

request
POST /es_search_log_development/_analyze
{
  "analyzer": "autocomplete_search_analyzer",
  "text": "ten"
}
{
  "tokens" : [
    {
      "token" : "ten",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    }
  ]
}

readingform_search_analyzer

「転生」
POST /es_search_log_development/_analyze
{
  "analyzer": "readingform_search_analyzer",
  "text": "転生"
}
{
  "tokens" : [
    {
      "token" : "tensei",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}
「てんせ」
POST /es_search_log_development/_analyze
{
  "analyzer": "readingform_search_analyzer",
  "text": "てんせ"
}
{
  "tokens" : [
    {
      "token" : "tense",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    }
  ]
}
「ten」
POST /es_search_log_development/_analyze
{
  "analyzer": "readingform_search_analyzer",
  "text": "ten"
}
{
  "tokens" : [
    {
      "token" : "ten",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "word",
      "position" : 0
    }
  ]
}

サジェストワード検索時のイメージ

indexとsearchのanalyzerの動きをふまえると、「転生」をindexして「転生」、「てんせ」、「ten」を入力した場合、全て「転生」がサジェストされることがわかります。

suggest_image.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

kaminariのページネーションを中央寄せにする

  • kaminariを導入しているとビューで pagination メソッドを呼ぶと思いますが、その場合、自動的に ul 要素に pagination クラスが適用されます。
  • pagination クラスのCSSを以下のようにカスタマイズすると、ページネーションを中央寄せにすることができます。
app/assets/stylesheets/application.scss
.pagination {
  justify-content: center;
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

わたしがRailsチュートリアルで学んだこと【7章】

  • 注意:プログラミング歴34日の初心者が書いています

  • 注意:間違っていたら優しく教えてください(喜びます)

「Ruby on Rails チュートリアル実例を使ってRailsを学ぼう」
https://railstutorial.jp/

素晴らしいチュートリアルに感謝します。

8.1 セッション

HTTPは、その場限り。以前の情報を全く持たないリクエストです。
そのため、ログイン情報の保持には、セッションという接続を使います。

Railsでセッションを実装する方法として最も一般的なのは、cookiesを使う方法です。cookiesとは、ユーザーのブラウザに保存される小さなテキストデータです。

セッションについても例に漏れず、Sessionsコントローラで操作します。

コントローラを追加したら、routes.rbに対応するルーティングを追加します。今回は、

  • new 新しいセッションのページ (ログイン画面)

  • create 新しいセッションの作成 (実際のログイン)

  • destroy セッションの削除 (ログアウト)

という3つのアクションを、/loginというURLに紐づけます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

わたしがRailsチュートリアルで学んだこと【8章】

  • 注意:プログラミング歴34日の初心者が書いています

  • 注意:間違っていたら優しく教えてください(喜びます)

「Ruby on Rails チュートリアル実例を使ってRailsを学ぼう」
https://railstutorial.jp/

素晴らしいチュートリアルに感謝します。

8.1 セッション

HTTPは、その場限り。以前の情報を全く持たないリクエストです。
そのため、ログイン情報の保持には、セッションという接続を使います。

Railsでセッションを実装する方法として最も一般的なのは、cookiesを使う方法です。cookiesとは、ユーザーのブラウザに保存される小さなテキストデータです。

セッションについても例に漏れず、Sessionsコントローラで操作します。

コントローラを追加したら、routes.rbに対応するルーティングを追加します。今回は、

  • new 新しいセッションのページ (ログイン画面)

  • create 新しいセッションの作成 (実際のログイン)

  • destroy セッションの削除 (ログアウト)

という3つのアクションを、/loginというURLに紐づけます。

8.1.2 ログインフォーム

フォームに入力されたログイン情報を保存したい、でもセッションにはUserのときのようなモデル、データベースはありません。

form_for(:session, url: login_path)

そのため上記のように、データを保存するリソース名(ここでは:session)とそのパスを指定ながら、form_forでformタグを生成します。

rb
<%= form_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

送信されたデータは、

  • params[:session][:email]

  • params[:session][:password]

で受け取ることができます。こうしてみると、リソース名(ここでは:session)をシンボルで記述する意味がわかりますね。

"session" => { 
            "email" => "foo@bar.com",
            "password" => "something",  
          }

送信されるデータはこういうハッシュになってるということですね。

これが、login_pathのPOST、つまりcreateアクションに受け渡されるということです。

8.1.3 ユーザーの検索と認証

sessionコントローラのcreateアクションでparamsからデータを受け取ることができます。

renderredirect_toについて

flashで表示させるエラーメッセージは、redirect_toの場合は次のページ移動で消えるのに、rederではページ移動しても消えずに残ったままになってしまう。

renderを使用する場合はflash.nowを使う。

renderとは

  • renderは指定したviewを呼び出します。
  def new
  end

これも実はrenderが動いています。
GETアクション内になにも記述がない場合は、

  def new
    render 'new'
  end

に勝手に補完されています。

redirect_toとは

  • redirect_toはHTTPリクエスト(GET)をサーバーに送ります。
def create
    @user = User.new(user_params)
    if @user.save
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

わかりにくいですが、ここでもRailsの自動補完が効いているのでした。
redirect_toの行は、以下と同じ意味です。

redirect_to user_url(@user)

8.1.5 フラッシュのテスト

require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  test "login with invalid information" do
    get login_path #1
    assert_template 'sessions/new' #2
    post login_path, params: { session: { email: "", password: "" } } #3
    assert_template 'sessions/new' #4
    assert_not flash.empty? #4 flashはemptyでない
    get root_path #5
    assert flash.empty? #6 flashはemptyである
  end
end
  1. ログイン用のパスを開く
  2. 新しいセッションのフォームが正しく表示されたことを確認する
  3. わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
  4. 新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認する
  5. 別のページ (Homeページなど) にいったん移動する
  6. 移動先のページでフラッシュメッセージが表示されていないことを確認する

8.2 ログイン

ApplicationコントローラにSessionヘルパーモジュールを読み込む

ApplicationコントローラにSessionヘルパーモジュールを読み込むと、他の全てのコントローラで使用できるようになります。
それは、他の全てのコントローラが、Applicationコントローラを継承しているからです。

8.2.1 log_inメソッド

sessionメソッドについて

session[:name]

sessionメソッドはRailsにあらかじめ組み込まれているメソッドです。
名前をつけて、セッションに登録できます。

これは、8章冒頭に自分でrails -gして作ったSessionsコントローラとは無関係です。

module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id #user.idを`:user_id`という名前で保存
  end
end

どのviewでも使うことができるように、ヘルパーに関数を定義します。
sessionメソッドで作成した一時cookiesは自動的に暗号化されます。すごい。

ブラウザからcookiesの情報を調べてみてください。の演習について

AWS Cloud9を使っている人は、amazonawsでcookies検索すると出るかと思います。

8.2.2 現在のユーザー

「例外を発生させる」or「例外を発生させない」

User.find(session[:user_id]) #例外が発生する
User.find_by(id: session[:user_id]) ##例外が発生しない(nillを返す)
  • findメソッドは例外を発生させて処理を中断する。

  • find_byメソッドはnilを返すため処理は中断されない。

「ユーザページの表示」の場合は、ユーザーのidが無いのは「例外」です。(表示するページもなにもない)

一方、「ユーザーのログイン」の場合、まだログインしていないなど、クッキーにデータがあったりなかったり、両方の状態が考えられます。そのため、「例外」での処理はしません。

現在のユーザーを定義する

  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

「もし、session[:user_id]が存在するなら、
@current_usernillならUser.find_by(id: session[:user_id])を代入する」

 @current_user ||= User.find_by(id: session[:user_id])

上記は、以下と同じです。

if @current_user.nil?
  @current_user = User.find_by(id: session[:user_id])
else
  @current_user
end

まず、@current_usernilかどうか(値があるかどうか)チェック。
@current_usernil(つまりログインしてない)なら、セッションIDからuserを検索して@current_userとします。

もし@current_usernilでないなら、すでにログイン中ですので、そのまま@current_userを返します。

8.2.3 レイアウトリンクを変更する

ログインしている場合orしていない場合で、レイアウトを変更します。
view内で、ifを使って表示する内容を変える。

ifの条件分岐を使用して、current_userがtrueかfalseか(ログインしているか否か)で分岐させます。

ユーザーがログイン中の状態とは「sessionにユーザーidが存在している」こと、つまりcurrent_userがnilではないという状態を指します。

# ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  end

ログインしているかどうかを確かめるメソッドをヘルパーに定義して、便利にすぐ呼び出せるようにしておきます。

truefalseなど真偽値を返すメソッドは名前に「?」をつけて置くのがルールです。

リスト 8.21: fixture向けのdigestメソッドを追加する

全く意味がわかりません。誰かたすけてください。

8.3 ログアウト

session.delete(:user_id)

上記のdeleteメソッドでセッションを削除できます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

bin/rails g kaminari:views bootstrap4 を叩くと No such file or directory @ rb_sysopen のエラーになる

事象

  • kaminariを導入した後に、Bootstrapの見た目にしたいということで bin/rails g kaminari:views bootstrap4 を叩いてファイル生成するのですが、以下のようなエラーとなりました。
$ bin/rails g kaminari:views bootstrap4
Running via Spring preloader in process 2526
Traceback (most recent call last):
        37: from -e:1:in `<main>'
        36: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'
        35: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/2.5.0/rubygems/core_ext/kernel_require.rb:59:in `require'
        34: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.3/lib/active_support/dependencies.rb:285:in `load'
        33: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.3/lib/active_support/dependencies.rb:257:in `load_dependency'
        32: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.3/lib/active_support/dependencies.rb:285:in `block in load'
        31: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:54:in `load'
        30: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:54:in `load'
        29: from /Users/user_name/book_match/bin/rails:9:in `<top (required)>'
        28: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.3/lib/active_support/dependencies.rb:291:in `require'
        27: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.3/lib/active_support/dependencies.rb:257:in `load_dependency'
        26: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/activesupport-5.2.3/lib/active_support/dependencies.rb:291:in `block in require'
        25: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:30:in `require'
        24: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:21:in `require_with_bootsnap_lfi'
        23: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/loaded_features_index.rb:92:in `register'
        22: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `block in require_with_bootsnap_lfi'
        21: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.4.4/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:22:in `require'
        20: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/railties-5.2.3/lib/rails/commands.rb:18:in `<top (required)>'
        19: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/railties-5.2.3/lib/rails/command.rb:46:in `invoke'
        18: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/railties-5.2.3/lib/rails/command/base.rb:65:in `perform'
        17: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor.rb:387:in `dispatch'
        16: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:126:in `invoke_command'
        15: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/command.rb:27:in `run'
        14: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/railties-5.2.3/lib/rails/commands/generate/generate_command.rb:26:in `perform'
        13: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/railties-5.2.3/lib/rails/generators.rb:276:in `invoke'
        12: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/base.rb:466:in `start'
        11: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/group.rb:232:in `dispatch'
        10: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:133:in `invoke_all'
         9: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:133:in `map'
         8: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:133:in `each'
         7: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:133:in `block in invoke_all'
         6: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:126:in `invoke_command'
         5: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/command.rb:27:in `run'
         4: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/kaminari-0.17.0/lib/generators/kaminari/views_generator.rb:27:in `copy_or_fetch'
         3: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/kaminari-0.17.0/lib/generators/kaminari/views_generator.rb:39:in `themes'
         2: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/kaminari-0.17.0/lib/generators/kaminari/views_generator.rb:99:in `get_files_in_master'
         1: from /Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/kaminari-0.17.0/lib/generators/kaminari/views_generator.rb:99:in `open'
/Users/user_name/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/kaminari-0.17.0/lib/generators/kaminari/views_generator.rb:99:in `initialize': No such file or directory @ rb_sysopen - https://api.github.com/repos/amatsuda/kaminari_themes/git/refs/heads/master (Errno::ENOENT)

対応策

  • 参考:rails g kaminari:views bootstrap3が実行できない
    • こちらのページを参考に、また、以前別プロジェクトでkaminariを導入していた時のライブラリのジェネレーターファイルと比較してみたところ、以下のような require 'open-uri' の記述がありませんでした。
  • ということで、 require 'open-uri' を以下のように追記してから再度実行してみると、上手くいきました。
lib/generators/kaminari/views_generator.rb
module Kaminari
  module Generators

    require 'open-uri' # この行を追記

    class ViewsGenerator < Rails::Generators::NamedBase
$ bin/rails g kaminari:views bootstrap4
Running via Spring preloader in process 2545
      downloading app/views/kaminari/_first_page.html.slim from kaminari_themes...
      create  app/views/kaminari/_first_page.html.slim
      downloading app/views/kaminari/_gap.html.slim from kaminari_themes...
      create  app/views/kaminari/_gap.html.slim
      downloading app/views/kaminari/_last_page.html.slim from kaminari_themes...
      create  app/views/kaminari/_last_page.html.slim
      downloading app/views/kaminari/_next_page.html.slim from kaminari_themes...
      create  app/views/kaminari/_next_page.html.slim
      downloading app/views/kaminari/_page.html.slim from kaminari_themes...
      create  app/views/kaminari/_page.html.slim
      downloading app/views/kaminari/_paginator.html.slim from kaminari_themes...
      create  app/views/kaminari/_paginator.html.slim
      downloading app/views/kaminari/_prev_page.html.slim from kaminari_themes...
      create  app/views/kaminari/_prev_page.html.slim
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

いいね機能を非同期で実装【Rails】【jQuery】

この記事の内容

like.gif
TODOリストを共有できるアプリを作っていて、いいね機能を非同期にて実装しました。
すでにたくさんのQiita記事がありますが、つまったポイントもあったので、自分なりにまとめ直してみます。
(コンセプトは「人生でやりたいこと100のリストの共有」なので、todoをdreamという言葉を使って表現しています。)

前提

Rails 5.2.3

構成

userの詳細ページにdreamリストが表示されています。
viewの構成としては、
/views/users/show.html.erb内で同じ階層の_dream.heml.erbが部分テンプレートとして呼ばれ、userのdreamを繰り返し表示しています。

CSSフレームワークはMaterializeを使用しています。

アソシエーション:
users - has_many :dreams, has_many :likes
dreams - belongs_to :user, has_many :likes
likes - belongs_to :user, belongs_to :dream

流れ

  1. jQueryの準備
  2. いいねボタンを作成
  3. コントローラ記述
  4. remote: trueにてjs.erbファイルを呼び出し
  5. js.erbファイル作成

実行

1. jQueryの準備

非同期化するにあたり、Rails内でjQueryを使えるように準備します。
まずはgemの導入です。

Gemfile
gem 'jquery-rails'

ターミナルで bundle installします。
そしてapplication.jsに記述を追加します。

app/assets/javascripts/application.js
//= require jquery3
//= require rails-ujs
//= require_tree .

順番が重要です。jqueryを最初に読み込む必要があります。

2. いいねボタンを作成

後から使い回ししやすいように、部分テンプレート化しています。

_dream.html.erbではテーブルでtodoリストを表示しているので、いいねの項目がtd内に入っています。
idの記載については5の項目で説明します。

app/views/users/_dream.html.erb
# いいね機能該当部分
<td id="like-<%= dream.id %>">
  <%= render partial: "like", locals: { dream: dream } %>
</td>

_like.html.erbではすでにいいねがあるかないかで★か☆かを出し分けて(Materializeのアイコンを使用しています)、最後にいいね数をdream.likes.lengthで表示しています。

app/views/users/_like.html.erb
<% if Like.find_by(user_id: current_user.id, dream_id: dream.id) %>
  <%= link_to "/dreams/#{dream.id}/likes", method: :delete %>
    <i class="material-icons">star</i>
  <% end %>
<% else %>
  <%= link_to "/dreams/#{dream.id}/likes", method: :post %>
    <i class="material-icons">star_border</i>
    <% end %>
<% end %>
<%= dream.likes.length %>

3. コントローラ記述

いいねのcreateとdestroyを定義していきます。

app/controllers/likes_controller.rb
class LikesController < ApplicationController
  before_action :set_dream

  def create
    @like = Like.create(user_id: current_user.id, dream_id: @dream.id)
  end

  def destroy
    @like = Like.find_by(user_id: current_user.id, dream_id: @dream.id)
    @like.destroy
  end

  private
  def set_dream
    @dream = Dream.find(params[:dream_id])
  end
end

ルーティングも忘れずに。

config/routes.rb
post '/dreams/:dream_id/likes' => "likes#create"
delete '/dreams/:dream_id/likes' => "likes#destroy"

この時点で、非同期ではないですがいいね機能が実装できているはず。

4. remote: trueにてjs.erbファイルを呼び出し

いいねボタンのlink_toremote: trueを追加します。

app/views/users/_like.html.erb
<% if Like.find_by(user_id: current_user.id, dream_id: dream.id) %>
  <%= link_to "/dreams/#{dream.id}/likes", method: :delete, remote: true do %>
    <i class="material-icons">star</i>
  <% end %>
<% else %>
  <%= link_to "/dreams/#{dream.id}/likes", method: :post, remote: true do %>
    <i class="material-icons">star_border</i>
    <% end %>
<% end %>
<%= dream.likes.length %>

この記述により、通常であれば、link_toで呼ばれるアクションに対応するhtml.erbファイルを呼び出すところ、js.erbファイルを呼び出せるようになります。
なのでページ遷移を行わず非同期で通信が行われるようになります。

5. js.erbファイル作成

js.erbファイルはその名前の通り、javascriptのファイルでありながら、ERBタグを使うことでrubyのコードを書ける優れものです。
コントローラーで定義したインスタンスを<% %><%= %>を使うことでそのままjsファイルに記述できます。

まずはcreateとdestroyそれぞれのアクションに対応するjs.erbファイルを作成します。
app/views/likes/create.js.erb
app/views/likes/destroy.js.erb

ここで2.で記述していたidについて説明します。

app/views/users/_like.html.erb(再掲)
<td id="like-<%= dream.id %>">
  <%= render partial: "like", locals: { dream: dream } %>
</td>

jsでイベントを発火させるためには、idまたはclassでセレクタを指定しますが、今回はどのdreamに対するいいねなのかを判別するために、id内にそのdreamのidを含める必要があります。
上のように書くことによって、id="like-1"のようなidを指定することができます。

ここまで来たらあとはjs.erbファイルの記述だけです。

app/views/likes/create.erb
$("#like-<%= @dream.id %>").html("<%= j(render partial: 'users/like', locals: { dream: @dream }) %>");
app/views/likes/destroy.erb
$("#like-<%= @dream.id %>").html("<%= j(render partial: 'users/like', locals: { dream: @dream }) %>");

これだけです。

まずはセレクタの指定ですが、js.erbファイルなのでコントローラで定義した変数が使えます。
id="like-<%= dream.id %>"に対応するように指定します。

そして、jQueryのhtml()メソッドで、指定したセレクタのhtmlを置き換えます。

その置き換える内容が
"<%= j(render partial: 'users/like', locals: { dream: @dream }) %>"の部分です。
部分テンプレートの_like.html.erbを呼び出しています。
(renderの前にあるjは、escape_javascriptのエイリアスで、改行と括弧をエスケープしてくれるメソッドです。)

これにより、likeが更新された状態で、ifで条件分岐されたりlikes.countが表示されたりします。

非同期処理の実現!

一度流れをつかむことができれば応用が効きそうなので、今後もいろいろなところで使ってみようと思います。

参考

Railsで remote: true と js.erbを使って簡単にAjax(非同期通信)を実装しよう!(いいね機能のデモ付)

 

分かりにくい点・間違っている点などがありましたらご指摘いただきますよう、よろしくお願いいたします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Carrierwaveの導入で手間取ったので忘備録。

1.まずは公式ページを。

https://github.com/carrierwaveuploader/carrierwave#installation

2.Imagemagickのインストール

$ brew install imagemagick

3.Gemのインストール

$ gem install carrierwave

4.Gemfileの編集

gem 'carrierwave'
gem 'mini_magick'

からの、、bundel install

5.postsテーブルに image column を追加

$ rails g migration add_image_to_posts image:string

6.uploader ファイルを作成

$ rails g uploader image

7.models/post.rb を編集

以下を追加

mount_uploader :image, ImageUploader

8.posts_controller のストロングパラムズを編集

:image,:image_cache を追加

def post_params
   params.require(:post).permit(:title,:content,:image,:image_cache)
end

9.image_uploader の編集

include CarrierWave::MiniMagick

  storage :file

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

# 画像の上限を700pxにする
  process :resize_to_limit => [700, 700]

# jpg,jpeg,gif,pngしか受け付けない
  def extension_white_list
    %w(jpg jpeg gif png)
  end

10.new page

<div>
    <%= f.label :image %>
    <%= image_tag @post.image.url if @post.image? %>
    <%= f.file_field :image %>
    <%= f.hidden_field :image_cache %>
</div>

11.confirm page

<h1>Are you ok?</h1>
<%=form_with(model:@post,url:posts_path,local:true)do |f|%>
  <%=f.hidden_field :title%>
  <%=f.hidden_field :content%>
  <%=f.hidden_field :image_cache%>

  <p>Title:<%=@post.title%></p>
  <p>Content:<%=@post.content%></p>
  <p>Image:<%=@post.image.url %></p>
  <%=f.submit 'OK!'%>
<% end %>

<%=form_with(model:@post,url:new_post_path,local:true,method:'get')do |f|%>
  <%=f.hidden_field :title%>
  <%=f.hidden_field :content%>
  <%=f.hidden_field :image_cache%>
  <%=f.submit 'Back!',name:'back'%>
<% end %>

12.index page

<div>
        <% if post.image? %>
        <%= image_tag post.image.url %>
        <% else %>
        <p>No Image</p>
        <% end %>
 </div>

13.show page

<div>
    <% if @post.image? %>
    <%= image_tag @post.image.url %>
    <% else %>
    <p>No Image</p>
    <% end %>
</div>

以上です。

注意点)

当初、以下のようなサムネイル作成メソッドをimage_uploaderに入れていたが、それが原因で日本語名の画像ファイルが文字化けしてエラーとなっていた。
これを削除すると日本語名の画像ファイルも正常にアップロードできるようになった。

version :thumb do
    process :resize_to_limit => [300, 300]
  end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

herokuにアップする画像を永続的に表示させる方法【Rails】

heroku に画像をアップできるアプリケーションをデプロイしたとしても、アップした画像は数時間で消えるかデプロイするたびに消えてしまいます。
しかし、画像が消えるということを回避できる方法があります。

それは、画像をデータベースに格納するときにバイナリデータとして保存する方法です。

ですが、この記事の方法は「モックアップ用の簡易なアプリケーション」「アクセスがなく自分しか使わないアプリケーション」といったケースでやるのが好ましいです。
下記の注意点を読んでから、アプリケーションの状況に応じて実施を決断するようにしてください。

注意点

以下のサイトの回答者の方の答えが非常にわかりやすくて、画像をデータベースに保存するデメリット(とメリット)についてまとめられているので、参考にしてみてください。

データベースに画像を保存するのはありでしょうか? - teratail

テーブルを作成

今回は、既存のPostテーブルに紐付けしたPhotoテーブルのなかに画像を保存していくとします。
PostテーブルPhotoテーブルの関係は、1対多です。

まずは、画像を保存するためのテーブルの作成から行います。
画像を保存するためのカラムは、bynary型にします。
コンソールに下記のコマンドを打ち込み実行します。

$ rails g model Photo

そうすることで、マイグレーションファイルが生成されるので下記のように編集します。

xxxxxxxxxxxxxx_create_photos.rb
class CreateUsers < ActiveRecord::Migration

  def change
    create_table : photos do |t|
      t.bynary :image, nill: false
      t.references :post, foreign_key: true, null: false

      t.timestamps null: false
    end
  end
end

マイグレーションファイルを編集したら、下記のコマンドを実行してテーブルを作成しましょう。

$ rails db:migrate

作成した全てのテーブル情報は「schema.rb」ファイルでも確認することができます。

コントローラを編集

コントーラファイルには、「画像をデータベースに保存する」と「画像をデータベースから取り出す」といった2つの動きを追加していきます。

画像をデータベースに保存する

画像をデータベースに保存するには、readというメソッドが重要になります。
photos_controller.rb」ファイルを作成して、下記を追加します。

photos_controller.rb
def create
  @post = Post.new(post_params)

  # バイナリ化した画像の呼び出し
  params[:post][:photos_attributes]["0"][:image].each_with_index do |item, i|

    if i != 0
      @post.photos.build
    end

    @post.photos[i].image = item.read
  end
end

private
  def post_params
    params.require(:post).permit(photos_attributes: [:image])
  end

画像をデータベースから取り出す

画像をデータベースから取り出すには、send_dataというメソッドが重要です。

photos_controller.rb
def send_img
  tmpbin = Photo.find(params[:id])
  send_data(tmpbin.image, :type => 'image/jpeg', :disposition => 'inline')
end

ビューを編集

ビューには、「画像のアップロード」と「画像の表示」とこれまた2つの動きを追加していきます。

画像のアップロード

画像をアップできるようにフォームを設置します。

new.html.erb
<%= form_with model: @post, multipart: true do |f| %>
  <%= f.field_for :photos do |i| %>
    <%= i.file_field :image, type: :file %>
  <% end %>

  <%= f.submit "投稿する" %>
<% end %>

画像の表示

次に、データベースに保存した画像が表示されるように下記を追加します。

index.html.erb
<% @post.photos.each do |photos| %>
  <%= image_tag url_for(:controller => 'photos', :action => 'send_img', id => photos.id %>
<% end %>

ルートの設定を追加

最後の仕上げとして、「routes.rb」に下記を追加します。

routes.rb
get '/photos/send_img/:id', to: 'photos#send_img'

これでバイナリデータとした画像の保存から表示までを終わらせることができました。
heroku にデプロイするアプリケーションで画像投稿機能が付いているものでも、永続的に画像を表示させ続けられることでしょう!

参考にさせてもらった記事

rails で画像ファイルを DB に保存する

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む