20191223のRailsに関する記事は22件です。

rails6.1から追加される予定のActionView::Componentを4ステップで入門

rails6.1から追加される予定のActionView::Componentを4ステップで入門

準備

事前にサンプルrailsアプリケーションを作成しておきます。

$ rails new try_action_view_compponent
$ cd try_action_view_compponent
$ rails g scaffold user
$ rails db:create
$ rails db:migrate

"actionview-component"の基本的な使い方

1. Gemfileにgem "actionview-component"を追加

Gemfile
+ gem "actionview-component"
bundle install

2. config/application.rbでモジュールをrequireする

config/application.rb
+ require "action_view/component/railtie"

3. componentをgenerate

$ rails g component User name

作成された以下のファイルがコンポーネントファイルです。

app/component/user_component.html.erb
app/component/user_component.rb

使いやすくするために編集します。
ここでは、classと要素を外部注入するコンポーネントにします。

app/components/user_component.html.erb
- <div>Add User template here</div>
+ <span class="<%= name %>">
+   <%= content %>
+ </span> 
app/components/user_component.rb
class UserComponent < ActionView::Component::Base
+ attr_reader :name

  def initialize(name:)
    @name = name
  end
end

4. viewからコンポーネントを呼び出す

事前準備でscaffoldコマンドで作成しておいたusersのviewsから呼び出してみます。

app/views/users/index.html.erb
<h1>Users</h1>

+ <%= render(UserComponent, name: "test name") do %>
+   Hello, World!
+ <% end %>

rails sして画面からlocalhost:port/usersにアクセスすると呼び出したコンポーネントが表示される事が確認できます。

renderについて

renderメソッドの第一引数にコンポーネントのクラス名を第二引数に変数のキーワード引数を渡します。
ブロックの中に記述した文字列が、コンポーネント内でcontent変数を使用する事で呼び出す事ができます。

その他

バリデーション

modelのvalidationの様にコンポーネントに注入される変数の入力チェックがあるので試してみましょう。

バリデーション宣言を追加します。
ここではname変数は入力必須にします。

app/components/user_component.rb
class UserComponent < ActionView::Component::Base
  attr_reader :name

+ validates :name, presence: true

viewから呼び出す時にnameに空文字を渡してみます。

app/views/users/index.html.erb
<h1>Users</h1>

- <%= render(UserComponent, name: "test name") do %>
+ <%= render(UserComponent, name: "") do %>
+   Hello, World!
+ <% end %>

rails sして、localhost:port/usersにアクセスすると以下のエラーが表示される事が確認できます。

ActiveModel::ValidationError in Users#index
Validation failed: Name can't be blank

CSSファイル

以下の様にサイドカーCSSを配置するのがベストプラクティスの様です。

- /app
  - /components
    - /alert
      - alert.css
      - alert.html.erb
      - alert.rb

See: https://github.com/github/actionview-component/issues/55

動作環境

$ rails -v
Rails 6.0.2.1
$ ruby -v
ruby 2.6.4p104 (2019-08-28 revision 67798) [x86_64-darwin17]

サンプルコード

https://github.com/soartec-lab/try_action_view_component

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

【Rails 6】(初心者向け)Ajax版最小構成CRUDアプリ(ページ移動をゼロに!)

Railsの学習で最初に学ぶのが,メッセージの作成・表示(読み取り)・更新・削除のできるCRUDアプリであると思います。私も最初にCRUDアプリを作成したときは,正直理解が追いつきませんでした:sweat_smile:

ところが,実際に作成してみるといろいろと不満が出てくることでしょう。一番は「見た目」だと思いますが,

「ボタンをクリックするたびにページを移動するのは嫌だなあ……」

と思いませんでしたか?特に本番環境では読み込みに時間がかかってしまいます。そこで,この記事では,ページ遷移ゼロの最小構成CRUDアプリを作成していきたいと思います:grinning:

通常のCRUDアプリを理解していたならば,本記事も理解できるようなるべく丁寧に解説していきます。

この記事では,Rails標準の Ajax の使い方を学ぶことだけに焦点を当てます。そのため,見た目・バリデーション・例外処理など細かいことは全て削ります。なお, jQuery の使用は避けて, Javascript を使用することとします。

完成後のアプリ

ajax_crud_sample.gif

開発環境

  • macOS Catalina 10.15.2
  • Ruby 2.6.4
  • Rails 6.0.2

手順

0. 準備

  • まずはアプリを作成し,最低限の準備をしていきます。
    • Heroku へのデプロイまで挑戦されたい場合は, rails new のところで -d postgresql オプションを付け, $ rails db:create も実行して下さい。
ターミナル
$ rails new ajax_crud_sample
$ cd ajax_crud_sample
$ rails g controller messages index
$ rails g model Message content:string
$ rails db:migrate
  • 念のため,$ rails sでサーバーを起動し,http://localhost:3000にアクセスし,「Yay! You’re on Rails!」を確認しておいて下さい。

  • CRUDアプリを作成するための,ルーティングを設定しておきます。

config/routes.rb
Rails.application.routes.draw do
-  get 'messages/index'
+  root 'messages#index'
+  resources :messages
end
  • 一覧ページに投稿メッセージが表示されるようにします。
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def index
    @messages = Message.all
  end
end
app/views/messages/index.html.erb
<!-- 各メッセージに対し,部分テンプレート _message.html.erb を呼び出す -->
<%= render @messages %>
app/views/messages/_message.html.erb
<p>
  <%= message.content %>
</p>

  • 確認用として,適当なメッセージをデータベースに投入します
db/seeds.rb
messages = %w[おはよう こんにちは こんばんは]
messages.each do |message|
  Message.create!(content: message)
end
puts '初期データの保存に成功しました!'
ターミナル
$ rails db:seed
  • トップページ(http://localhost:3000)にアクセスし,次のように表示されたならばOKです!

スクリーンショット 2019-12-23 10.54.26.png

1.メッセージの作成

準備ができましたので,まずはメッセージを投稿し,ページ遷移無しでそのメッセージを追加表示できるようにしましょう!

  • 最初にフォームを作成します。
app/views/messages/index.html.erb
<!-- 部分テンプレート _form.html.erb を呼び出す -->
<%= render 'form' %>
<hr>
<!-- 各メッセージに対し, _message.html.erb を呼び出す -->
<%= render @messages %>
app/views/messages/_form.html.erb
<!-- 新規メッセージ投稿用のフォーム -->
<%= form_with model: Message.new do |f| %>
  <%= f.text_field :content %>
  <%= f.submit "投稿" %>
<% end %>
  • ここでトップページを確認してみて下さい。次のように表示されていればOKです!

スクリーンショット 2019-12-23 11.12.59.png

もちろん,このままでは「投稿ボタン」を押しても,何も起こりません。対応するcreateアクションでメッセージの保存を指示します。

app/controllers/messages_controller.rb
  # (略)
  # ********** 以下を追加 ********** 
  def create
    Message.create!(message_params)
  end

  private

  # Strong Parameters はサボらずに使っておくこととします
  def message_params
    params.require(:message).permit(:content)
  end
  # ********** 以上を追加 ********** 
end

この時点で,フォームに入力して投稿してみて下さい。ページ内には変化は起きませんが,SQLが発行されていることをターミナルから確認できます。ページを更新してみて下さい。先ほど投稿したメッセージが反映されているはずです!

ここで,通常のCRUDアプリならば,例えば次のように書くでしょう。

app/controllers/messages_controller.rb
  def create
    Message.create!(message_params)
    # 実際に次の一行を追加して確認してもよいですが,その後削除して下さい!
    redirect_to root_path
  end

このようにすれば,新規メッセージを投稿 するだけでなく ページ更新 まで行われますので,一応,新規投稿機能ができたことになります。

ところが,今回は ページ遷移無し で投稿メッセージを追加表示させたいので,これではダメです:scream:ここで,トップページのソースを確認してみます。

<form action="/messages" accept-charset="UTF-8" data-remote="true" method="post">
<!-- (略) -->
</form>

フォームの箇所に, data-remote="true" が入っているはずです。

form_withでフォームを作成した場合は,local: trueオプションを付けない限り,自動的にこのデータ属性が追加されます。

この data-remote="true"含まれていない 場合は,コントローラの createメソッドで指示をしない限り, create.html.erb が呼び出されます。create.html.erb を自動で呼び出すにはlocal: trueオプションを付けなければなりません。
(なお, form_forform_tag の使用は 現在推奨されておりません

では, data-remote="true"含まれている 場合はどうなるのでしょうか。実は,自動的に Ajax を利用して非同期通信(ページ遷移無しに通信)が行われ, create メソッドが実行されます。更に,指示がない場合は create.js.erb が呼び出されるようになるのです。つまり,ページ遷移は起こらず,この create.js.erb に書いた処理が実行されるようになるのです!

Javascript を使うことで,ページの一部分を変更することができます。そこで,

  • 投稿したメッセージを,メッセージ一覧の一番下に追加する

というプログラムを書くことで, ページ遷移無し に新規メッセージを追加表示できるのです!具体的には次のように書きます。

app/views/messages/index.html.erb
<%= render 'form' %>
<hr>
<!-- メッセージ一覧を div タグで囲み,idを付けておく -->
<div id="messages">
  <%= render @messages %>
</div>
app/controllers/messages_controller.rb
  # 以下を変更(新規メッセージを create.js.erb で使えるようにインスタンス変数に入れておく)
  def create
    @message = Message.create!(message_params)
  end
  • 次の create.js.erb を作成します。
app/views/messages/create.js.erb
// メッセージ一覧の一番下に新規メッセージを追加する
document.getElementById('messages').insertAdjacentHTML('beforeend', '<%= j(render @message) %>')

Javascriptに慣れていない方もいらっしゃると思いますので,解説を入れます。 document.getElementById('messages') は, messagesというidが付いている要素を取得する操作です。今回のケースならば,次が取得されます。

document.getElementById('messages')
<div id="messages">
    <p>
        おはよう
    </p>
    <p>
        こんにちは
    </p>
    <p>
        こんばんは
    </p>
</div>

ここの</div>の前に新規メッセージを追加したいので,続けて次を書くことになります。

.insertAdjacentHTML('beforeend', //新規メッセージ// )

「新規メッセージ」の箇所は,例えば'<%= @message.content %>' としてもメッセージが追加されるのですが,これではダメです!

'<%= @message.content %>'ただの文字列 です。<p>タグで囲まれていませんので,2回投稿すると前のメッセージと繋がってしまいます

そもそも,単に文字列を追加したいのではなく, _message.html.erb に書いたテンプレートで新規メッセージを追加したいわけです。そこで,j(render @message)と書くことになります。

j とはなんぞや?」と思われたかもしれません。これは, escape_javascript メソッドです。 _message.html.erb 内の改行 \n をエスケープしないとJavascriptの構文エラーが発生します。

さて,メッセージを投稿すれば,ただデータベースに保存されるだけでなく,新規メッセージが一番下に追加表示されるようになりましたが,もう一つ問題があります。投稿したのに, フォームの文字が残ったまま になっています!

ページを更新していないので,指示をしなければ当然フォームの文字も残ったままになります。そこで,create.js.erb に次を追加して下さい。

app/views/messages/create.js.erb
// (略)
// フォームの文字を空にする
document.getElementById('message_content').value = ''

実は,フォームのテキストフィールドに id="message_content" が自動で付いています。これを取得し,値を空にする指示を出せばOKです。ここの id は ハイフン ではなく アンダーバー ですのでご注意下さい。

2. メッセージの削除

次にメッセージの削除機能を付けます:open_mouth:

  • まずは削除用のリンクを作成します。
app/views/messages/_message.html.erb
<!-- idを追加 -->
<p id="message-<%= message.id %>">
  <%= message.content %>
  <!-- 削除のリンクを追加 -->
  <%= link_to '削除', message_path(message),
              method: :delete,
              data: { confirm: '削除しますか?', remote: true } %>
</p>

  • コントローラに次を追加します
app/controllers/messages_controller.rb
  # idからメッセージを取り出す操作は他でも必要となるので最初からまとめておきます
  before_action :set_message, only: %i[destroy]
  # (略)
  def destroy
    @message.destroy!
  end

  private
  # (略)
  def set_message
    @message = Message.find(params[:id])
  end

普通のCRUDアプリとの違いは,まず, data 属性に remote: true を入れていることです。これで,コントローラの destroy メソッドの後にページ遷移が起こらず, destroy.html.erb ではなく destroy.js.erb が動作するようになります。

また,削除するメッセージを特定できるようにするため,各メッセージに id を付けておきます。

この時点でデータベースからメッセージの削除はできます。ところが,何も指示しなければページを更新しない限り,ページ内からメッセージが消えません。そこで,destroy.js.erb にメッセージを削除するプログラムを書きます。

app/views/messages/destroy.js.erb
document.getElementById('message-<%= @message.id %>').outerHTML = ''

ここも解説を入れておきます。削除する @message.id2 であるとすると,

document.getElementById('message-<%= @message.id %>')

により,次が取り出されます。

<p id="message-2">
    こんにちは
    <a data-confirm="削除しますか?" data-remote="true" rel="nofollow" data-method="delete" href="/messages/2">削除</a>
</p>

全てを消去したいので, outerHTML を空にするように指示します。これでページ遷移無しにメッセージが消えるようになりました!

3. メッセージの更新

メッセージの更新は少々大変です。普通のCRUDアプリならば,更新ボタンを押した後「更新用のページ」に移動させます。今回はページ遷移無しに更新部分を実装したいので,まずは更新ボタンを押した時に「更新用のフォーム」を表示できるようにしなければなりません。

なるべく簡単にしたいと思いますので,この記事では「新規メッセージの投稿フォーム」を「更新用のフォーム」に置き換えることにします。

  • まずは,更新フォームを呼び出すリンクを作成します。
app/views/messages/_message.html.erb
<p id="message-<%= message.id %>">
  <%= message.content %>
  <!-- ********** 以下を追加 ********** -->
  <%= link_to '更新', edit_message_path(message), data: { remote: true } %>
  <!-- ********** 以上を追加 ********** -->
  <%= link_to '削除', message_path(message),
              method: :delete,
              data: { confirm: '削除しますか?', remote: true } %>
</p>

  • フォームを更新用としても使えるように,フォームの部分テンプレートを修正しておきます。
app/views/messages/_form.html.erb
<!-- Message.new を 変数 message に変更 -->
<%= form_with model: message do |f| %>
  <%= f.text_field :content %>
<!-- ボタンの文字を 変数 value に変更 -->
  <%= f.submit value %>
<% end %>
app/views/messages/index.html.erb
<!-- 新規投稿メッセージなので,ボタンの文字は「投稿」とする -->
<%= render 'form', message: Message.new, value: '投稿' %>
<hr>
<div id="messages">
  <%= render @messages %>
</div>

この時点で次のような表示になります。

スクリーンショット 2019-12-23 19.57.35.png

  • コントローラに edit, update を追加します。
app/controllers/messages_controller.rb
  # edit, update でも呼び出すようにする
  before_action :message_set, only: %i[destroy edit update]

  # 更新用のフォームに置き換えることだけに使用する
  def edit
  end

  def update
    @message.update!(message_params)
  end

更新のリンクをクリックしたときに動作するのは edit.js.erb です。ここに,「更新用のフォーム」に置き換える操作を書きましょう。

app/views/messages/edit.js.erb
// フォームは一つしか無いので, id を使わず,タグ名から要素を取得
// ここの変数宣言に let や const を使用すると2回目にエラーが発生します
var form = document.getElementsByTagName('form')[0]
// フォームを更新用フォームに置き換えます。ボタンの文字は「更新」にします。
form.outerHTML = '<%= j(render 'form', message: @message, value: '更新') %>'

例えば,2番目のメッセージにある更新ボタンをクリックすると,次のような表示に変わります。

スクリーンショット 2019-12-23 21.01.06.png

コントローラにデータベースを更新する操作はすでに入れています。あとは更新用フォームの「更新」ボタンを押したときに,ページ内のメッセージを更新するようにします。

app/views/messages/update.js.erb
// 対応するメッセージを更新
document.getElementById('message-<%= @message.id %>').innerHTML = '<%= j(render @message) %>'
// フォームを新規投稿フォームに戻す
var form = document.getElementsByTagName('form')[0]
form.outerHTML = '<%= j(render 'form', message: Message.new, value: '投稿') %>'

これで,メッセージの更新も ページ遷移無し で実現できるようになりました:grin:

4. おまけ

最後に,フォームを「更新用」に変更したあと,「新規投稿用」に戻すリンクを作っておきます。見た目がいまいちですが,本筋から外れますので許して下さい:sweat_smile:

  • 更新用フォームにだけ「取消」のリンクを追加
app/views/messages/_form.html.erb
<%= form_with model: message do |f| %>
  <%= f.text_field :content %>
  <%= f.submit value %>
<!-- ********** 以下を追加 ********** -->
  <% if value == '更新' %>
    <%= link_to '取消', new_message_path, data: { remote: true } %>
  <% end %>
<!-- ********** 以上を追加 ********** -->
<% end %>
  • コントローラに追加
app/controllers/messages_controller.rb
  # 新規投稿用のフォームに置き換えることだけに使用する
  def new
  end
  • 「取消」ボタンを押したとき,「新規投稿用フォーム」に戻すようにする
app/views/messages/new.js.erb
var form = document.getElementsByTagName('form')[0]
form.outerHTML = '<%= j(render 'form', message: Message.new, value: '投稿') %>'

これで,「新規投稿用フォーム」に戻すこともできるようになりました。

今回の記事は Ajax を利用して ページ遷移無し でCRUDアプリを作成することのみに重点をおいて解説をしました。少しでも理解を進めるお手伝いができたならば幸いです。お疲れ様でした:grinning:

最終的なコード

$ git clone https://github.com/T-Tsujii/ajax_crud_sample.git
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

.orderで並び替えたデータ取得時の注意点

映画レビューアプリを作成中に気づいたことです!

レビュー投稿画面に移行時に
①Movieテーブル最新データ1つ取得→ fleshmovie = Movie.order(updated_at: :desc).limit(1)
②最新データidカラムの値をインスタンス変数に定義→ @movie = fleshmovie.id

reviews_controller.rb
def new
  fleshmovie = Movie.order(updated_at: :desc).limit(1)
  @movie = fleshmovie.id
end
error_code
[2] pry(#<ReviewsController>)> @movie
  Movie Load (0.6ms)  SELECT  `movies`.* FROM `movies` ORDER BY `movies`.`updated_at` DESC LIMIT 1
  ↳ app/controllers/reviews_controller.rb:10
=> [#<Movie:0x00007fb5d5e845d0
  id: 37,
  mtdb_id: "10315",
  created_at: Mon, 23 Dec 2019 11:08:37 UTC +00:00,
  updated_at: Mon, 23 Dec 2019 11:08:37 UTC +00:00>]
[3] pry(#<ReviewsController>)> @movie.id
NoMethodError: undefined method `id' for #<Movie::ActiveRecord_Relation:0x00007fb5d6b6bd98>
Did you mean?  ids

@movieで最新データは取得できているのに、idカラムが参照できない!

rails documentを確認
.orderは並び替えるメソッドなので、@movieは配列を取得している気がする
まず配列の1つめを取得する必要がありそう!

fleshmovie.id → fleshmovie[0].idで解決しました!

reviews_controller.rb
def new
  fleshmovie = Movie.order(updated_at: :desc).limit(1)
  @movie = fleshmovie[0].id
end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【環境構築】Docker + Rails6 + Vue.js + Vuetifyの環境構築手順

はじめに

Docker + Rails6 + Vue.js + Vuetifyの開発環境構築手順をまとめました。

以下の記事を参考にさせて頂きました!ありがとうございます:bow_tone1:

環境

OS: macOS Catalina 10.15.1
zsh: 5.7.1
Ruby: 2.6.5
Rails: 6.0.2
Docker: 19.03.5
docker-compose: 1.24.1
Vue: 2.6.10
vue-router: 2.6.10
vuex: 3.1.2
vuetify: 2.1.0

1.準備

作成するアプリケーション名はhogeappとします。
まずは以下ファイルを作成して下さい。

Dockerfile

yarnが必要になるので、Dockerfileに反映しています。

hogeapp/Dockerfile
FROM ruby:2.6.5

RUN apt-get update -qq && \
  apt-get install -y build-essential \
  libpq-dev \
  nodejs \
  vim

RUN apt-get update && apt-get install -y curl apt-transport-https wget && \
  curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
  echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
  apt-get update && apt-get install -y yarn

RUN mkdir /app_name
ENV APP_ROOT /app_name
WORKDIR $APP_ROOT

COPY ./Gemfile $APP_ROOT/Gemfile
COPY ./Gemfile.lock $APP_ROOT/Gemfile.lock
RUN bundle install

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT [ "entrypoint.sh" ]
ADD . $APP_ROOT

docker-compose.yml

hogeapp/docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: root
    volumes:
      - db-data:/var/lib/mysql
    ports:
      - "3306:3306"
  web:
    build: .
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app_name
    ports:
      - "3000:3000"
    links:
      - db
    tty: true
    stdin_open: true
    depends_on:
      - db
  data:
    image: busybox
    volumes:
      - db-data:/var/lib/mysql
    tty: true
volumes:
  db-data:
    driver: local

Gemfile

hogeapp/Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

gem 'rails', '~> 6.0.2', '>= 6.0.2.1'

Gemfile.lock

中身は空で作成します。

hogeapp/Gemfile.lock

entrypoint.sh

hogeapp/entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /app_name/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"

これで必要なファイルが揃ったので、次はRailsアプリの作成です。

2.Railsアプリの作成

rails new

$ docker-compose run web rails new . --force --database=mysql --skip-bundle

※このエラーが出たら

Error response from daemon: OCI runtime create failed: container_linux.go:346: starting container process caused "exec: \"rails\": executable file not found in $PATH": unknown

$ docker-compose build

※何らかのgemが不足しているようなエラーが出たら

自分は以下のようなエラーが発生しました。

Could not find public_suffix-4.0.1 in any of the sources

$ docker-compose run web bundle install

一度bundle installを試してみて下さい。

database.ymlの変更

config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password #ここを変更
  host: db #ここを変更

コンテナが立ち上がるか確認

docker-compose up -dだとログが見えないので、無事に環境構築が完了するまでは-dなしが良いと思います。

$ docker-compose up

db:create

無事にコンテナが立ち上がったら、DBを作成しましょう。

$ docker-compose exec web rails db:create

Yay! You’re on Rails!

localhost:3000にアクセスして確認してみましょう。

image.png

これでRailsはOKなので、次はVue.jsです!

3.Vue.jsの導入

webpackerのインストール

$ docker-compose exec web rails webpacker:install

Vue.jsのインストール

$ docker-compose exec web rails webpacker:install:vue

Vue.jsとの連携を確認

Railsでコントローラーを作ってみて、Vue.jsと連携出来るかを確認してみます。

$ docker-compose exec web rails g controller home index
app/views/home/index.html.erb
<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>

このように変更し、hello_vue.jsを読み込みます。

※Vue.jsのインストール時にhello_vue.jsはデフォルトで作成されています。

Railsのルーティングを設定

config/routes.rb
root to: 'home#index'

ブラウザで確認

localhost:3000にアクセスし、下記画面が出力されているか確認してみて下さい。

image.png

これでVue.jsの導入はOKですが、他の単一ファイルコンポーネントも作成し、読み込めるかどうかを確認しておきます。

4.他の単一ファイルコンポーネントが読み込めるかどうか確認する

Top.vueの作成

app/javascript/components/ディレクトリを作成し、その中にTop.vueを作成します。

Top.vueの中身は以下のようにしました。

app/javascript/components/Top.vue
<template>
  <section id="top">
    <h1>This is Top.vue!</h1>
  </section>
</template>

<script>
  export default {
    name: 'Top'
  }
</script>

<style>
  h1 {
    text-align: center;
  }
</style>

app.vueを変更

app/javascript/app.vue
<template>
  <div id="app">
    <p>{{ message }}</p>
    <Top/> //追記
  </div>
</template>

<script>
import Top from "./components/Top"; //追記

export default {
  data: function () {
    return {
      message: "Hello Vue!"
    }
  },
  components: {
    Top, //追記
  }
}
</script>
...以下略

ブラウザで確認

再度読み込みすると、以下のような画面になっているはずです。

image.png

これできちんと単一ファイルコンポーネントが読み込まれていることが確認できたので、Vue.jsはOKです。

次はラスト!Vuetifyの導入です。

5.Vuetifyの導入

Vuetifyのインストール

$ docker-compose exec web yarn add vuetify -D

hello_vue.jsに追記

hello_vue.js
import Vue from 'vue'
import Vuetify from 'vuetify' //追加
import "vuetify/dist/vuetify.min.css" //追加
import App from '../app.vue'

Vue.use(Vuetify) //追加

const vuetify = new Vuetify(); //追加

document.addEventListener('DOMContentLoaded', () => {
  const app = new Vue({
    vuetify, //追加
    render: h => h(App)
  }).$mount()
  document.body.appendChild(app.$el)

  console.log(app)
})

application.html.erbの変更

application.html.erbでVuetify用にフォントとアイコンの読み込みと不要な箇所の削除を行います。

app/views/layouts/application.html.erb
  <head>
...略
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
...略
  </head>

下記タグは不要なので削除しておきます。

app/views/layouts/application.html.erb
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

これで準備が整ったので、あとはVuetifyのコンポーネントが反映されるかどうか、作成して確認してみましょう。

Header.vueの作成

新規にHeader.vueを作成し、<v-app-bar>を組み込んでみます。

app/javascript/components/Header.vue
<template>
  <header id="header">
    <v-app-bar>
      <v-app-bar-nav-icon></v-app-bar-nav-icon>
      <v-toolbar-title>This is Header.vue</v-toolbar-title>
    </v-app-bar>
  </header>
</template>

<script>
  export default {
    name: 'Header',
  }
</script>

app.vueを変更

Header.vueを読み込みつつ、全体をまとめている<div><v-app>タグに変更しておきます。

app/javascript/app.vue
<template>
  <v-app id="app"> //divから変更
    <Header/> //追記
    <h1>This is app.vue</h1> //一応追記。どっちでもいいです
    <p>{{ message }}</p>
    <Top/>
  </v-app>
</template>

<script>
import Header from "./components/Header"; //追記
import Top from "./components/Top";

export default {
  data: function () {
    return {
      message: "Hello Vue!"
    }
  },
  components: {
    Header, //追記
    Top,
  }
}
</script>

これで再度localhost:3000にアクセスし、問題ないかを確認してみます。

image.png

無事に画像のようなヘッダーが表示されていれば完了です!

以上です!お疲れ様でした!:clap:

おわりに

最後まで読んで頂きありがとうございました:bow_tone1:

どなたかのお役に立てれば幸いです:relaxed:

参考にさせて頂いたサイト(いつもありがとうございます)

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

Railsで排他制御

model以外で特定の処理にロックをかけたい事象が発生したので、その時の対応です。

module Lockable
  extend ActiveSupport::Concern

  class UnableLockError < StandardError; end

  KEY_PREFIX = 'lock/'

  def with_lock(key, expire = 30.seconds)
    real_key = "#{KEY_PREFIX}#{key}"
    value = SecureRandom.hex(10)

    raise UnableLockError unless lock(real_key, value, expire)

    begin
      yield
    ensure
      unlock(real_key, value)
    end
  end

  private

  def lock(key, value, expire)
    redis_client.set(key, value, ex: expire, nx: true)
  end

  def unlock(key, value)
    redis_client.del(key) if $redis.get(key) == value
  end

  def redis_client
    $redis
  end
end
def hoge
  with_lock(key) do
    # somthing
  end
rescue Lockable::UnableLockError
  # handle error
end

redisを用いて対応しました。
もしお困りの方がいらっしゃれば...参考にしてくださいmm

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

ネストしたオブジェクトをYupでバリデーションする

Railsでいうnested attributesみたいなやつ :bulb:

{
  "title" => "title dayo",
  "body" => "body dayo",
  "tag_attributes" => {
    "0" => { "name" => "hoge" },
    "1" => { "name" => "fuga"}
  }
}

Yup.lazyとlodashのmapValuesを使いました。

import * as Yup from "yup";
import mapValues from "lodash.mapvalues";

interface Form {
  title: string;
  body: string;
  tag_attributes: {
    [key: string]: {
      name: number;
    };
  };
}

const validationSchema = Yup.object().shape({
  title: Yup.string(),
  body: Yup.string(),
  tag_attributes: Yup.lazy<object>(obj =>
    Yup.object(
      mapValues(obj, () =>
        Yup.object().shape({
          name: Yup.string()
        })
      )
    )
  )
});

以上です :hugging:

参考

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

Ruby on Rails 初歩の初歩

備忘録

こんにちは!HLDの井出です。(https://t.co/FiYK5Qty3J?amp=1)
今回は、Railsについての備忘録を残していきます。
インストールからscaffoldまでです。

環境

  • Windows10
  • viniciusfs/centos7
  • rails 5.1.3

準備

Rubyのインストール
参考:https://github.com/rbenv/rbenv
流れは、まず管理ツールのrbenvをインストール。
          ↓
インストールできるバージョンの確認
$ rbenv install --list
欲しいバージョンをインストールしたら、
$ rbenv grobal バージョン
$ rbenv rehash
そして、
$ ruby version
で、バージョンが表示されたらOK!!

railsのインストール

$ gem install rails -v x.x.x (Xは欲しいバージョン)

その前に
下拵えで、nodejsとsqlite3をインストールしておきます。しておかないまま、railsのインストールを進めるとjavascriptが分らんけど?って怒られます。。。

起動!!

インストールができたら、任意のディレクトリで、
rails new app
そうすると、勝手にいろいろと作ってくれる。

アドレス確認

$ ip a
でアドレスを確認して、ブラウザに表示してみます。
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP qlen 1000
link/ether 08:00:27:c1:f7:7a brd ff:ff:ff:ff:ff:ff
inet 192.168.33.10/24 brd 192.168.33.255 scope global eth1
valid_lft forever preferred_lft forever
inet6 fe80::a00:27ff:fec1:f77a/64 scope link
valid_lft forever preferred_lft forever

192.168.33.10なので、
$ rails server -b 192.168.33.10 -d
として、サーバー立ち上げます。
Runと出たらOK!!
アドレスをコピペしてブラウザに表示させてみます。

scaffoldで簡単なアプリを作ってみる

無事に、鯖が立ち上がったら、sccaffoldで簡単なアプリを作ってみます。
rails g scaffold Memo title:string body:text
とすると、メモアプリをサクッとrailsが用意してくれます。
できたら、
$ rails db:migrate
とします。
ブラウザで192.168.33.10/memosとして表示されたらOK!!

殺してみる

$ ps aux | grep puma
でプロセス番号がわかります。
vagrant のあとに表示されている数字です。
$ kill -9 プロセス番号
とすると、今立ち上げていたアプリを終わらせることができます。

後記

サクッとでしたが、railsのインストールからアプリの立ち上げまでの流れを見てみました。
あとは、コントローラーとか色々作り込んでみてください。
参考になれば嬉しいです。

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

チーム開発時にクラス名で起きる問題を解決する

はじめに

チーム開発の際にレイアウトの崩れが起きないようにする方法について投稿します。

実行

例えばAさんとBさんが一緒に同じアプリを作っているとします。
現時点で、二人はそれぞれ次のようなファイルを作っています。

Aさん

.body
  .body__contents1
    コンテンツ1
  .body__contents2
    コンテンツ2
&__body{
  background-color: #00F;
}

Bさん

.body
  .body__top
    トップ
  .body__bottom
    ボトム
&__body{
  color: #00F;
}

Aさん作成のビューファイルではbackround-color: #00F;となっているので、背景が青色になり、文字色の設定はなにもないのでデフォルトの黒で表示されます。
Bさん作成のビューファイルの方はcolor: #00F;となっているので、文字色が青になり、背景はデフォルトの白色になります。

この状態はAさん・Bさんが別々に開発を行っていれば特に問題はありませんが、チーム開発を行っていると問題が生じます。

2人のファイルをGitを操作するなどして1つのアプリフォルダにまとめるとどうなるでしょうか?
Aさん作成のビューファイルもBさん作成のビューファイルも、1つにまとめる前と同じように表示されれば良いのですが、今回はそうはいきません。
実際には全面青色で表示されます
ファイルを見比べると、両方のビューファイルも一番上のクラス名がbodyになっています。
同じように、CSSのファイルも&__bodyという風にbodyクラスに適用されるようになっています。
これにより、2つのCSSファイルが両方のビューファイルに適用され、背景色も文字色も青色となり、全てが青色で表示されてしまいます。

これを防ぐためには、クラス名を他のファイルと間違えないように設定すると良いです。
例えば次のようにします。

.a-body
  .a-body__contents1
    コンテンツ1
  .a-body__contents2
    コンテンツ2
&__a-body{
  background-color: #00F;
}

Bさん

.b-body
  .b-body__top
    トップ
  .b-body__bottom
    ボトム
&__b-body{
  color: #00F;
}

Aさん方はbodya-bodyと書き換え、Bさんの方はb-bodyに書き換えています。
こうすれば両方のビューファイルが適切に表示されるようになります。

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

チーム開発時にクラス名が原因で起きる問題を解決する

はじめに

チーム開発の際にレイアウトの崩れが起きないようにする方法について投稿します。

実行

例えばAさんとBさんが一緒に同じアプリを作っているとします。
現時点で、二人はそれぞれ次のようなファイルを作っています。

Aさん

.body
  .body__contents1
    コンテンツ1
  .body__contents2
    コンテンツ2
&__body{
  background-color: #00F;
}

Bさん

.body
  .body__top
    トップ
  .body__bottom
    ボトム
&__body{
  color: #00F;
}

Aさん作成のビューファイルではbackround-color: #00F;となっているので、背景が青色になり、文字色の設定はなにもないのでデフォルトの黒で表示されます。
Bさん作成のビューファイルの方はcolor: #00F;となっているので、文字色が青になり、背景はデフォルトの白色になります。

この状態はAさん・Bさんが別々に開発を行っていれば特に問題はありませんが、チーム開発を行っていると問題が生じます。

2人のファイルをGitを操作するなどして1つのアプリフォルダにまとめるとどうなるでしょうか?
Aさん作成のビューファイルもBさん作成のビューファイルも、1つにまとめる前と同じように表示されれば良いのですが、今回はそうはいきません。
実際には全面青色で表示されます
ファイルを見比べると、両方のビューファイルも一番上のクラス名がbodyになっています。
同じように、CSSのファイルも&__bodyという風にbodyクラスに適用されるようになっています。
これにより、2つのCSSファイルが両方のビューファイルに適用され、背景色も文字色も青色となり、全てが青色で表示されてしまいます。

これを防ぐためには、クラス名を他のファイルと間違えないように設定すると良いです。
例えば次のようにします。

.a-body
  .a-body__contents1
    コンテンツ1
  .a-body__contents2
    コンテンツ2
&__a-body{
  background-color: #00F;
}

Bさん

.b-body
  .b-body__top
    トップ
  .b-body__bottom
    ボトム
&__b-body{
  color: #00F;
}

Aさん方はbodya-bodyと書き換え、Bさんの方はb-bodyに書き換えています。
こうすれば両方のビューファイルが適切に表示されるようになります。

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

Railsでテーブルのカラム名を安全にリネームする

テーブルのカラム名を変更した時のメモ

環境

  • Rails 5.2.4
  • MySQL 5.7.28

背景

テーブルのカラム名がイケてないので変えたいけど、使用箇所が多いので手を出しにくい。
でもカラム名を変えたい。

やること

  1. テーブルのカラム名を変える
  2. 警告を出すための独自Deprecatorを作成する
  3. 変更前のカラム名でもアクセスできるようにエイリアスを貼る

警告は出るが変更前のカラム名でもアクセスできるようにしておくことで、
段階的に使用箇所を修正していけるという寸法です。

やり方

1. テーブルのカラム名を変える

普通にカラム名を変更するマイグレーションを作ります。
例としてUsersテーブルのnameカラムをfull_nameにリネームします。

app/db/migrate/rename_name_to_users.rb
class RenameNameToUsers < ActiveRecord::Migration[5.2]
  def change
    rename_column :users, :name, :full_name
  end
end

2. 警告を出すための独自Deprecatorを作成する

app/supports/deprecator/will_be_removed.rb
class Deprecator::WillBeRemoved
  def deprecation_warning(deprecated_method_name, message)
    ActiveSupport::Deprecation.warn("`#{deprecated_method_name}` will be removed. #{message}")
  end
end

3. 変更前のカラム名でもアクセスできるようにエイリアスを貼る

app/models/user.rb
class User < ApplicationRecord
  # TODO: カラム名変更の影響を抑えるために一時的にエイリアスを作成。
  alias_attribute :name, :full_name
  deprecate name: 'Please use `full_name` instead.', deprecator: Deprecator::WillBeRemoved.new
end

確認

$ rails c
pry(main)> user = User.find(1)
pry(main)> user.name
DEPRECATION WARNING: `name` will be removed. Please use `full_name` instead. (called from <main> at (pry):3)
=> "test name"
pry(main)> user.full_name
=> "test name"

補足

独自のDeprecatorを作成しなくても警告を出すことはできます。

app/models/user.rb
class User < ApplicationRecord
  # TODO: カラム名変更の影響を抑えるために一時的にエイリアスを作成。
  alias_attribute :name, :full_name
  deprecate name: 'Please use `full_name` instead.'
end

が、以下のようにRails用の警告文になってしまうので独自に作った方が良いと思います。

pry(main)> user.name
DEPRECATION WARNING: name is deprecated and will be removed from Rails 6.0 (Please use `full_name` instead.) (called from <main> at (pry):3)

参考

Railsのカラムにaliasをかける
railsのdeprecateってメソッドが便利ぽい

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

Rails6 のちょい足しな新機能を試す113(MySQL enum set 編)

はじめに

Rails 6 に追加された新機能を試す第113段。 今回は、MySQL enum set 編です。
Rails 6 では、MySQL の enum や set のカラムの schema dump が正しく出力されるようになりました。

Ruby 2.6.5, Rails 6.0.2.1, MySQL 8.0.16 で確認しました。 (Rails 6.0.0 でこの修正が入っています。)

$ rails --version
Rails 6.0.2.1

今回は、users テーブル (User モデル)に enumset のカラムを追加して試してみます。

Rails プロジェクトを作成する

$ rails new rails_sandbox
$ cd rails_sandbox

User モデルを作成する

name カラムだけもつ User モデルを作成します。

enumset のカラムは、後で、migration ファイルを直接編集して、追加します。

bin/rails g model User name

migration ファイルを編集する

migration ファイルを編集して、 enum カラムの generationset カラムの learning を追加します。

db/migrate/20191221002717_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name
      t.column :generation, "enum('baby', 'toddler', 'preschool', 'gradeschool', 'teen', 'young_adult')"
      t.column :learning, "set('piano', 'english', 'swimming', 'ballet', 'calligraphy')"

      t.timestamps
    end
  end
end

seed データを作成する

機能を試すためには、作る必要は、無いのですが、一応、 seed データを1件作成します。

db/seeds.rb
User.create(name: 'Dave', generation: 'gradeschool', learning: 'english,swimming')

マイグレーションを実行する

マイグレーションを実行します。
shell
$ bin/rails db:create db:migrate

スキーマファイルを確認する

できたスキーマファイルを確認します。

db/schema.rb
ActiveRecord::Schema.define(version: 2019_12_21_002717) do

  create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "name"
    t.column "generation", "enum('baby','toddler','preschool','gradeschool','teen','young_adult')"
    t.column "learning", "set('piano','english','swimming','ballet','calligraphy')"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

end

Rails 5 では

Rails 5.2.4 では、スキーマファイルは以下のようになってしまいます。

generationlearning のカラムが string になってしまい、ENUM と SET であることがわからなくなってしまいます。

db/schema.rb
ActiveRecord::Schema.define(version: 2019_12_21_005503) do

  create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t|
    t.string "name"
    t.string "generation", limit: 11
    t.string "learning", limit: 41
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

end

MySQL で直接確認する

なお、MySQL で直接確認した場合は、Rails 6.0.2.1 も、Rails 5.2.4 も同じです。

mysql> show columns from users;
+------------+-----------------------------------------------------------------------+------+-----+---------+----------------+
| Field      | Type                                                                  | Null | Key | Default | Extra          |
+------------+-----------------------------------------------------------------------+------+-----+---------+----------------+
| id         | bigint(20)                                                            | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255)                                                          | YES  |     | NULL    |                |
| generation | enum('baby','toddler','preschool','gradeschool','teen','young_adult') | YES  |     | NULL    |                |
| learning   | set('piano','english','swimming','ballet','calligraphy')              | YES  |     | NULL    |                |
| created_at | datetime(6)                                                           | NO   |     | NULL    |                |
| updated_at | datetime(6)                                                           | NO   |     | NULL    |                |
+------------+-----------------------------------------------------------------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

bin/rails db:reset を試す

bin/rails db:reset を実行してから、MySQL で直接確認すると

Rails 6.0.2.1 は、

mysql> show columns from users;
+------------+-----------------------------------------------------------------------+------+-----+---------+----------------+
| Field      | Type                                                                  | Null | Key | Default | Extra          |
+------------+-----------------------------------------------------------------------+------+-----+---------+----------------+
| id         | bigint(20)                                                            | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255)                                                          | YES  |     | NULL    |                |
| generation | enum('baby','toddler','preschool','gradeschool','teen','young_adult') | YES  |     | NULL    |                |
| learning   | set('piano','english','swimming','ballet','calligraphy')              | YES  |     | NULL    |                |
| created_at | datetime(6)                                                           | NO   |     | NULL    |                |
| updated_at | datetime(6)                                                           | NO   |     | NULL    |                |
+------------+-----------------------------------------------------------------------+------+-----+---------+----------------+
6 rows in set (0.01 sec)

と変わりないですが、Rails 5.2.4 では

mysql> show columns from users;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | YES  |     | NULL    |                |
| generation | varchar(11)  | YES  |     | NULL    |                |
| learning   | varchar(41)  | YES  |     | NULL    |                |
| created_at | datetime     | NO   |     | NULL    |                |
| updated_at | datetime     | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

と変わってしまいました。

rails console からデータを検索する

(ここからはおまけです。)

bin/rails db:reset で seed データが登録されたので、検索してみます。

irb(main):006:0> User.where(generation: 'gradeschool')
  User Load (1.2ms)  SELECT `users`.* FROM `users` WHERE `users`.`generation` = 'gradeschool' LIMIT 11
=> #<ActiveRecord::Relation [#<User id: 1, name: "Dave", generation: "gradeschool", learning: "english,swimming", created_at: "2019-12-21 02:04:41", updated_at: "2019-12-21 02:04:41">]>
irb(main):007:0> User.where("learning like '%swimming%'")
  User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE (learning like '%swimming%') LIMIT 11
=> #<ActiveRecord::Relation [#<User id: 1, name: "Dave", generation: "gradeschool", learning: "english,swimming", created_at: "2019-12-21 02:04:41", updated_at: "2019-12-21 02:04:41">]>

正しくないデータを投入する

generation や、learning に不正なデータを指定して登録すると ActiveRecord::StatementInvalid が発生します。

generation も learning も不正なデータの場合を試してみます。

最後のエラーメッセージが Data truncated for column 'generation' at row 1 であることから generation に問題があることがわかります。
(が、どうして truncate されたか、ちょっとわかりにくいです...。)

irb(main):008:0> User.create(name: 'NG', generation: 'NG', learning: 'NG')
   (0.5ms)  BEGIN
  User Create (1.6ms)  INSERT INTO `users` (`name`, `generation`, `learning`, `created_at`, `updated_at`) VALUES ('NG', 'NG', 'NG', '2019-12-21 02:15:29.767037', '2019-12-21 02:15:29.767037')
   (0.4ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):8
ActiveRecord::StatementInvalid (Mysql2::Error: Data truncated for column 'generation' at row 1)

generation は正しいが、 learning が不正なデータの場合です。

最後のエラーメッセージが Data truncated for column 'learning' at row 1 であることから learning に問題があることがわかります。

irb(main):009:0> User.create(name: 'NG', generation: 'gradeschool', learning: 'NG')
   (0.4ms)  BEGIN
  User Create (1.2ms)  INSERT INTO `users` (`name`, `generation`, `learning`, `created_at`, `updated_at`) VALUES ('NG', 'gradeschool', 'NG', '2019-12-21 02:15:50.398770', '2019-12-21 02:15:50.398770')
   (0.2ms)  ROLLBACK
Traceback (most recent call last):
        2: from (irb):9
        1: from (irb):9:in `rescue in irb_binding'
ActiveRecord::StatementInvalid (Mysql2::Error: Data truncated for column 'learning' at row 1)

Rails 5 では

bin/rails db:migrate で作成した場合は、Rails 6 と同じ動作ですが、 bin/rails db:reset すると Type がただの string になってしまうため、不正なデータでも登録できてしまいます。

ActiveRecord の enum との関係

ActiveRecord の enum と MySQL の enum は全く関係がなく、以下のように、 User モデルに enum を定義しても、MySQL の generation カラムの enum とは連動せず、期待通りの動作はしません。

app/models/user.rb
class User < ApplicationRecord
  enum generation: %i[baby toddler preschool gradeschool teen young_adult]
end

試したソース

https://github.com/suketa/rails_sandbox/tree/try113_mysql_enum

参考情報

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

rails indexってなんだ

indexってなんだ

検索速度をあげるモノ

なんで上がるのか

検索して行く時通常は、上から検索されていく。

しかし、indexをはると、userテーブルの場合、アルファベット順に並び替えてくれるらしい。

こうゆうことか?

青山で検索する

#index貼らない

神田
青木
田中
青山

## データが煩雑なので、上から検索するしかない


# index貼る

青木
青山
神田
田中

## アルファベット順だからあ行の中で検索がかけられるのかな?

メリット・デメリット

  • デメリットは書き込み速度が遅くなるらしい。

参考記事

https://qiita.com/seiya1121/items/fb074d727c6f40a55f22

負荷対策
https://qiita.com/N-qiita/items/4c9b3efdaefb32181fc9

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

#Rails - In controller Block double request / duplicated proceeding with DB lock Transaction and respond ok or ng

class SomeController
  def create
    ProceedingHistory.transaction do
      begin
        ProceedingHistory.create(proceeding_id: proceeding_id)
      rescue ActiveRecord::RecordNotUnique
        head :bad_request
        return
      end

      do_something
    end

    head :ok
  end
end

# == Schema Information
#
# Table name: proceeding_histories
#
#  id              :bigint           not null, primary key
#  created_at      :datetime
#  proceeding_history_id :string(255)
#
# Indexes
#
#  index_proceeding_histories_on_proceeding_history_id  (proceeding_history_id) UNIQUE
#

class ProceedingHistory < ApplicationRecord
end

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2863

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

#Rails の controller で処理の多重送信を防ぐために、model に 処理のid単位の履歴テーブルを作成して、transaction lock と rollback を利用する例

処理一個はユニークな proceeding_id を持つものとする
重複処理が起こった場合はレコードがINSERTできずにbad request を返す
処理本体が失敗した場合はrollbackされて再度処理可能状態となる

class SomeController
  def create
    ProceedingHistory.transaction do
      begin
        ProceedingHistory.create(proceeding_id: proceeding_id)
      rescue ActiveRecord::RecordNotUnique
        head :bad_request
        return
      end

      do_something
    end

    head :ok
  end
end

# == Schema Information
#
# Table name: proceeding_histories
#
#  id              :bigint           not null, primary key
#  created_at      :datetime
#  proceeding_history_id :string(255)
#
# Indexes
#
#  index_proceeding_histories_on_proceeding_history_id  (proceeding_history_id) UNIQUE
#

class ProceedingHistory < ApplicationRecord
end

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2864

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

Railsでfullcalendarを使ってみる(Ajax通信でイベント登録)

概要

gemのfullcalndarを使ってイベントの登録をAjax通信でできるようにする。

  • カレンダーの日付をクリックするとイベント登録用のモーダルが表示
    image.png

  • 登録ボタンを押すと、イベントが登録される
    image.png

コード

  • jsファイル
    dayClickでカレンダーの日付をクリックした時のアクションを記述する。クリックした日付情報を取得し、イベント登録用のモーダルに自動入力させる。
calendar.js
$('#calendar').fullCalendar({
    events: '/shops/events.json',
    timeFormat: 'H:mm',
    eventColor: '#63ceef',
    lang: 'ja',
    dayClick: function (start, end, jsEvent, view) {
      //クリックした日付情報を取得
      const year = moment(start).year();
      const month = moment(start).month()+1; //1月が0のため+1する
      const day = moment(start).date();
      //イベント登録のためnewアクションを発火
      $.ajax({
        type: 'GET',
        url: '/shops/events/new',
      }).done(function (res) {
        //イベント登録用のhtmlを作成
        $('.modal-body').html(res);
        //イベント登録フォームの日付をクリックした日付とする
        $('#event_start_time_1i').val(year);
        $('#event_start_time_2i').val(month);
        $('#event_start_time_3i').val(day);
        //イベント登録フォームのモーダル表示
        $('#modal').modal();
        // 成功処理
      }).fail(function (result) {
        // 失敗処理
        alert('エラーが発生しました。運営に問い合わせてください。')
      });
    },

  });
  • イベントコントローラー
    render_to_stringを使って、イベント登録フォームのviewを文字列として取得し、モーダル上に表示する。
events_controller.rb
  def new
    @event = Event.new
    render plain: render_to_string(partial: 'form_new', layout: false, locals: { event: @event })
  end
  • イベント登録フォーム
_form_new.html.slim
h1 新規イベント追加
= form_with model: event, url: shops_events_path do |f|
  = render "devise/shared/error_messages", resource: f.object
  .form-group
    = f.label :title
    = f.text_field :title, class:'form-control mb-3', required: true
  .form-group
      = f.label :start_time
      br
      = f.datetime_select :start_time, {default: Date.today + 19.hours + 00.minutes, minute_step: 10}, class:'form-control bootstrap-date mb-3'
  .form-group
    = f.label :description
    = f.text_area :description, class:'form-control mb-3'
  .actions
    = f.submit class: "btn btn-primary mb-3 js-event-create-btn"
  • createアクション時の処理
    removeEventsの後、refetchEventsするとカレンダー上のイベントが再描画される。
create.js.slim
| $('#modal').modal('toggle');
| $("#calendar").fullCalendar('removeEvents');
| $("#calendar").fullCalendar('refetchEvents');

結論

fullcalendarを使ってAjax通信でイベント登録することができた。

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

byebugでよく使うコマンド

バッチ処理のコンテナに入る

$ docker attach container_name

よく使うコマンド

byebugで止めた場所を確認

(byebug) list

byebugコマンド一覧を表示

(byebug) help

next

(byebug) n

次のループに回す

(byebug) c

byebugの終了

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

Railsチュートリアル 第13章 ユーザーのマイクロポスト - Micropostモデル

基本的なモデル

Micropostモデルが持つ属性

Micropostモデルの基本となる属性は以下の2つです。

  • content属性
    • マイクロポストの内容
  • user_id属性
    • 特定のユーザーと、当該マイクロポストを関連付ける

実際には、Railsにより自動生成される属性もあるため、Micropostモデルの全体像は以下のようになります。

Micropost.png

Text型とは

RDBにおけるText型は、(存在する場合)String型とは異なる、文字列を内容として格納する型です。Railsチュートリアルでは、String型と対比した場合のText型の特徴として、以下の事柄が挙げられています。

  • String型に対応するのはテキストフィールドであり、Test型に対応するのはテキストエリアである
    • マイクロポストの投稿フィールドとして用いる場合、1行のテキストフィールドより複数行のテキストエリアのほうが自然である
  • 将来における柔軟性に富む
    • 言語に応じて投稿の長さを調整できる、など

以上のような理由から、今回のサンプルアプリケーションにおいて、マイクロポストの投稿・保存は、Text型の属性を用いて行うこととします。

Micropostモデルの生成

モデルを生成するためのコマンドは、rails generate modelコマンドですね。今回は以下のコマンドを用いてMicropostモデルを生成します。

# rails generate model Micropost content:text user:references
Running via Spring preloader in process 1289
      invoke  active_record
      create    db/migrate/20191218224953_create_microposts.rb
      create    app/models/micropost.rb
      invoke    test_unit
      create      test/models/micropost_test.rb
      create      test/fixtures/microposts.yml

結果、以下のようなモデルが生成されます。例によってApplicationRecordを継承しています。

app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
end

user:referencesbelongs_to :userという、見慣れないコードが存在しますね。大まかには、「RDBのインデックスと外部キー参照が定義されたuser_idカラムを自動追加し、UserとMicropostを紐付けする準備を行う」という意味合いのコードです。これらのコードが意味する事柄については、後の「User/Micropostの関連付け」の項で、より詳しく解説していきます。

Micropostモデルのマイグレーション

上述rails generate modelコマンドで生成されたMicropostモデルのマイグレーションは、初期状態では以下のようになっています。

db/migrate/[timestamp]_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[5.1]
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true

      t.timestamps
    end
  end
end

t.references :user, foreign_key: trueというのも、user:referencesbelongs_to :userと同様、UserとMicropostの紐付けに関するコードです。

また、t.timestampsというコードが自動で追加されています。「created_at属性およびupdated_at属性の定義と、その値の自動保存」を実装するためのコードです。Userモデルのマイグレーションでも、同様のコードが登場していましたね。

Micropostモデルのマイグレーションに、新たなインデックスを追加する

Micropostモデルにおいては、「user_idに関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出す」という主要なユースケースが存在します。当該ユースケースを効率よく実行できるようにするために、必要なインデックスを追加していきましょう。すなわち、「user_id属性とcreated_at属性の組に対応するインデックス」ですね。

db/migrate/[timestamp]_create_microposts.rb
  class CreateMicroposts < ActiveRecord::Migration[5.1]
    def change
      create_table :microposts do |t|
        t.text :content
        t.references :user, foreign_key: true

        t.timestamps
      end
+     add_index :microposts, [:user_id, :created_at]
    end
  end

複合キーインデックスとは

RDBにおいて、「複数の属性の組み合わせ」に対して設定されるインデックスのことを指します。「単一の属性では重複が発生する」という場合などに用いられます。なお、日本語では「複合インデックス」や「複数列インデックス」という言い回しのほうがより一般的なようです。

RailsのActive Recordに複合キーインデックスを作成させるには、対応するマイグレーションにおいて、add_indexの2番目の引数に、複合キーインデックスを構成するすべての属性を指すシンボルを配列で与える必要があります。

例えば、以下のような感じですね。

add_index :microposts, [:user_id, :created_at]

Micropostモデルの生成をRDBに反映する

例によってrails db:migrateコマンドです。

# rails db:migrate
== [timestamp] CreateMicroposts: migrating =================================
-- create_table(:microposts)
   -> 0.0213s
-- add_index(:microposts, [:user_id, :created_at])
   -> 0.0058s
== [timestamp] CreateMicroposts: migrated (0.0349s) ========================

なお、現在RailsサーバーやRailsコンソールが起動されている場合は、先にexitしておく必要があります。そうでないとrails db:migrateが失敗します。

演習 - 基本的なモデル

1.1. RailsコンソールでMicropost.newを実行し、インスタンスを変数micropostに代入してください。

# rails console

>> micropost = Micropost.new
=> #<Micropost id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil>

1.2. その後、user_idに最初のユーザーのidを、contentに "Lorem ipsum" をそれぞれ代入してみてください。この時点では、 micropostオブジェクトのマジックカラム (created_atupdated_at) には何が入っているでしょうか?

>> micropost[:user_id] = 1
=> 1
>> micropost[:content] = "Lorem ipsum"
=> "Lorem ipsum"

>> pp micropost[:created_at]
nil
>> pp micropost[:updated_at]
nil

created_atupdated_atの内容は、いずれもnilになっていますね。

2.1. 先ほど作ったオブジェクトを使って、micropost.userを実行してみましょう。どのような結果が返ってくるでしょうか?

>> micropost.user
  User Load (2.5ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-12-02 23:07:26", updated_at: "2019-12-02 23:07:26", password_digest: "$2a$10$AXWNXZNDu9NkLx3tvgr80umb9xpbx1KwPz9ndp/7Pwr...", remember_digest: nil, admin: true, activation_digest: "$2a$10$Q.bVywQrrgEJC6Mg0IhdXONY5M/0jQYm4/ZEBwhJfxc...", activated: true, activated_at: "2019-12-02 23:07:26", reset_digest: nil, reset_sent_at: nil>

usersテーブルに対し、SQLのSELECT文を、抽出対象カラムをid・抽出条件をmicropost[:user_id]として実行しています。

結果、id=1のUserオブジェクトが返ってきます。

2.2. また、micropost.user.nameを実行した場合の結果はどうなるでしょうか?

>> micropost.user.name
=> "Example User"

>> User.find(1).name
  User Load (5.6ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> "Example User"

id=1であるUserオブジェクトのname属性の値が返ってきます。User.find(1).nameと同じ結果ですね。

3.1. 先ほど作ったmicropostオブジェクトをデータベースに保存してみましょう。

>> micropost.save
   (0.5ms)  begin transaction
  SQL (19.6ms)  INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2019-12-19 05:11:44.579268"], ["updated_at", "2019-12-19 05:11:44.579268"]]
   (9.8ms)  commit transaction

特にバリデーションが設定されていないため、何も考えずにmicropost.saveメソッドを実行しても、micropostオブジェクトの内容が正しくRDBに保存されます。

3.2. この時点でもう一度マジックカラムの内容を調べてみましょう。今度はどのような値が入っているでしょうか?

>> pp micropost[:created_at]
Thu, 19 Dec 2019 05:11:44 UTC +00:00

>> pp micropost[:updated_at]
Thu, 19 Dec 2019 05:11:44 UTC +00:00

created_atおよびupdated_atの内容には、先ほどのmicropost.saveのログに表示された時刻と同じ時刻が保存されています。

Micropostのバリデーション

Micropostモデルに対する最初のテスト

  • setupメソッドで与えたMicropostオブジェクトが有効であることを確認する
  • user_idが存在しないMicropostオブジェクトが有効でないことを確認する
test/models/micropost_test.rb
require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  def setup
    @user = users(:rhakurei)
    #HACK: このコードは慣習的に正しくないため、要修正
    @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
  end

  test "should be valid" do
    assert @micropost.valid?
  end

  test "user id should be present" do
    @micropost.user_id = nil
    assert_not @micropost.valid?
  end
end

Micropostモデルに対する最初のテストの実行

# rails test test/models/micropost_test.rb
Running via Spring preloader in process 1340
Started with run options --seed 11643

  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.74715s
2 tests, 2 assertions, 0 failures, 0 errors, 0 skips

現時点においては、当該テストは問題なく成功します。

Micropostモデルに、テスト駆動で新たな機能を追加していく

長くなりましたので、別記事で解説します。

演習 - Micropostのバリデーション

1.1. Railsコンソールを開き、user_idcontentが空になっているmicropostオブジェクトを作ってみてください。このオブジェクトに対してvalid?を実行すると、失敗することを確認してみましょう。

>> micropost = Micropost.new
=> #<Micropost id: nil, content: nil, user_id: nil, created_at: nil, updated_at: nil>
>> micropost.valid?
=> false

micropost.valid?を実行すると、少々の待ち時間の後にfalseという結果が返ってきます。

1.2. また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?

1.1. の続きとなります。

>> micropost.errors.full_messages
=> ["User must exist", "Content can't be blank"]
  • User must exist
  • Content can't be blank

以上のエラーメッセージが返ってきていますね。

2.1. コンソールを開き、今度はuser_idが空でcontentが141文字以上のmicropostオブジェクトを作ってみてください。このオブジェクトに対してvalid?を実行すると、失敗することを確認してみましょう。

>> micropost = Micropost.new(user_id: nil, content: "a" * 141)
=> #<Micropost id: nil, content: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...", user_id: nil, created_at: nil, updated_at: nil>
>> micropost.valid?
=> false

文字列の乗算により、141文字の文字列を生成し、その内容をcontentの内容としています。

"a" * 141

2.2. また、生成されたエラーメッセージにはどんな内容が書かれているでしょうか?

>> micropost.errors.full_messages
=> ["User must exist", "Content is too long (maximum is 140 characters)"]
  • User must exist
  • Content is too long (maximum is 140 characters)

以上のエラーメッセージが返ってきていますね。

User/Micropostの関連付け

# rails generate model Micropost content:text user:references
(Micropostモデルより)
belongs_to :user
(Micropostのマイグレーションより)
t.references :user, foreign_key: true

Micropostモデルの生成に際して、以上のような見慣れないコードが登場しました。これらはどのような意味を持つのでしょうか。その解説です。

Micropost belongs to User

micropost belongs to user.png

MicropostとそのUserは、belongs_to(1対1)という関係性があります。

User has many Microposts

user has many microposts.png

UserとそのMicropostには、has_many(1対多)という関係性があります。

モデル定義に記述する、belongs_tohas_many

まず、Micropostモデルにbelongs_to :userというコードが必要になります。ただ、このコードは、Micropostモデルの生成時点で自動で生成されています。

app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  # ...略
end

さらに、Userモデルにhas_many :micropostsというコードが必要になります。こちらは手動で追加する必要があります。

app/models/user.rb
  class User < ApplicationRecord
+   has_many :microposts
    ...略
  end

User/Micropost関連メソッド

上述のbelongs_tohas_manyというモデル同士の関係性をモデルに記述することにより、以下のようなメソッドを利用することが可能になります。

メソッド名 用途
micropost.user Micropostに紐付いたUserオブジェクトを返す
user.microposts Userのマイクロポストの集合を返す
user.microposts.create(arg) userに紐付いたマイクロポストを生成する。失敗時にはnilを返す
user.microposts.create!(arg) userに紐付いたマイクロポストを生成する。失敗時には例外を発生させる
user.microposts.build(arg) userに紐付いた新しいMicropostオブジェクトを返す
user.microposts.find_by(id: 1) userに紐付いていて、id1であるマイクロポストを検索する

Micropostモデルに対するテストのsetupメソッドを、慣習的に正しいマイクロポストの生成方法に書き換えていく

関連するモデルにbelongs_tohas_manyを実装すれば、慣習的に正しいマイクロポストの生成を実装できるようになります。test/models/micropost_test.rbsetupメソッドを、慣習的に正しいマイクロポストの生成方法に書き換えていきましょう。

test/models/micropost_test.rb
  require 'test_helper'

  class MicropostTest < ActiveSupport::TestCase
    def setup
      @user = users(:rhakurei)
-     #HACK: このコードは慣習的に正しくないため、要修正
-     @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
+     @micropost = @user.microposts.build(content: "Lorem ipsum")
    end

    test "should be valid" do
      assert @micropost.valid?
    end

    test "user id should be present" do
      @micropost.user_id = nil
      assert_not @micropost.valid?
    end

    test "content should be present" do
      @micropost.content = "  "
      assert_not @micropost.valid?
    end

    test "content should be at most 140 characters" do
      @micropost.content = "a" * 141
      assert_not @micropost.valid?
    end
  end

Micropostモデルに対するテストのsetupメソッドを、慣習的に正しいマイクロポストの生成方法をテストで実装すると、テストが成功しなくなる

この時点でtest/models/micropost_test.rbに対するテストを実行すると、テストが成功しなくなります。

# rails test test/models/micropost_test.rb
Running via Spring preloader in process 1476
Started with run options --seed 20502

 FAIL["test_user_id_should_be_present", MicropostTest, 0.7893391999969026]
 test_user_id_should_be_present#MicropostTest (0.79s)
        Expected true to be nil or false
        test/models/micropost_test.rb:15:in `block in <class:MicropostTest>'

  4/4: [===================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.81867s
4 tests, 4 assertions, 1 failures, 0 errors, 0 skips

私の環境では、test/models/micropost_test.rbの14行目〜15行目の内容は以下のようになっています。

test/models/micropost_test.rb(14〜15行目)
@micropost.user_id = nil
assert_not @micropost.valid?

@micropost.user_idnilであるのに、@micropost.valid?trueを返してしまっている」という失敗ですね。

Micropostモデルに、ユーザーの存在性を検証するバリデーションを追加する

先ほどの問題を解消するためには、Micropostモデルに、ユーザーの存在性を検証するバリデーションを追加する必要があります。app/models/micropost.rbのコードの変更点は以下のとおりです。

app/models/micropost.rb
  class Micropost < ApplicationRecord
    belongs_to :user
+   validates :user_id, presence: true
    validates :content, presence: true, length: { maximum: 140 }
  end

上記変更を反映した後に、再びテストを実行してみます。

# rails test test/models/micropost_test.rb
Running via Spring preloader in process 1489
Started with run options --seed 47152

  4/4: [===================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.87582s
4 tests, 4 assertions, 0 failures, 0 errors, 0 skips

今度はテストが成功するようになります。

演習 - User/Micropostの関連付け

1. データベースにいる最初のユーザーを変数userに代入してください。そのuserオブジェクトを使ってmicropost = user.microposts.create(content: "Lorem ipsum")を実行すると、どのような結果が得られるでしょうか?

>> user = User.first

>> micropost = user.microposts.create(content: "Lorem ipsum")
   (0.1ms)  begin transaction
  SQL (15.5ms)  INSERT INTO "microposts" ("content", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["content", "Lorem ipsum"], ["user_id", 1], ["created_at", "2019-12-20 13:10:45.903917"], ["updated_at", "2019-12-20 13:10:45.903917"]]
   (10.2ms)  commit transaction
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2019-12-20 13:10:45", updated_at: "2019-12-20 13:10:45">

実際に生成されたマイクロポストが返ってきています。

2.1. 先ほどの演習課題で、データベース上に新しいマイクロポストが追加されたはずです。user.microposts.find(micropost.id)を実行して、本当に追加されたのかを確かめてみましょう。

>> user.microposts.find(micropost.id)
  Micropost Load (6.3ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? AND "microposts"."id" = ? LIMIT ?  [["user_id", 1], ["id", 2], ["LIMIT", 1]]
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2019-12-20 13:10:45", updated_at: "2019-12-20 13:10:45">

確かに 1. で追加されたマイクロポストを返してきています。

2.2. また、先ほど実行したmicropost.idの部分をmicropostに変更すると、結果はどうなるでしょうか?

>> user.microposts.find(micropost)
Traceback (most recent call last):
        1: from (irb):5
ArgumentError (You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`.)

ArgumentErrorを投げてきていますね。

.findActiveRecord::Baseを継承したオブジェクトそのものを渡すとArgumentErrorを投げる」というのは、Rails 5.1 以降の仕様となります。 - rails/CHANGELOG.md at v5.1.0 · rails/rails · GitHub

Raise ArgumentError when passing an ActiveRecord::Base instance to .find, > .exists? and .update.

Rafael Mendonça França

3.1. user == micropost.userを実行した結果はどうなるでしょうか?

>> user == micropost.user
=> true

3.2. また、user.microposts.first == micropostを実行した結果はどうなるでしょうか? それぞれ確認してみてください。

>> user.microposts.first == micropost
  Micropost Load (27.9ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
=> false

当該ユーザーにより、今回の演習 1. 以前に一度マイクロポストを追加したことがありました。そのため、今回追加したマイクロポストとuser.microposts.firstは一致せず、user.microposts.first == micropostはfalse`を返します。

>> user.microposts.first
  Micropost Load (7.8ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."id" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
=> #<Micropost id: 1, content: "Lorem ipsum", user_id: 1, created_at: "2019-12-19 05:11:44", updated_at: "2019-12-19 05:11:44">

マイクロポストを改良する

マイクロポストが投稿日時の降順(=新しい順)に表示されるようにする

「マイクロポストが投稿日時の降順(=新しい順)に表示されるようにする」という実装を、Micropostモデルに追加します。実装およびテストについては、別記事で解説します。

ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする」という実装を、Userモデルに追加します。実装およびテストについては、別記事で解説します。

Railsチュートリアル 第13章 ユーザーのマイクロポスト - 「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする」という実装を、Userモデルに追加する」

演習 - マイクロポストを改良する

1. Micropost.first.created_atの実行結果と、Micropost.last.created_atの実行結果を比べてみましょう。

>> Micropost.first.created_at
  Micropost Load (14.9ms)  SELECT  "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" DESC LIMIT ?  [["LIMIT", 1]]
=> Fri, 20 Dec 2019 13:10:45 UTC +00:00

>> Micropost.last.created_at
  Micropost Load (0.5ms)  SELECT  "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" ASC LIMIT ?  [["LIMIT", 1]]
=> Thu, 19 Dec 2019 05:11:44 UTC +00:00

確かにMicropost.first.created_atの結果の日時よりも、Micropost.last.created_atの日時のほうが過去の日時となっています。

2. Micropost.firstを実行したときに発行されるSQL文はどうなっているでしょうか? 同様にして、Micropost.lastの場合はどうなっているでしょうか?

ヒント: それぞれをコンソール上で実行したときに表示される文字列が、SQL文になります。

>> Micropost.first
  Micropost Load (0.3ms)  SELECT  "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" DESC LIMIT ?  [["LIMIT", 1]]

>> Micropost.last 
  Micropost Load (0.3ms)  SELECT  "microposts".* FROM "microposts" ORDER BY "microposts"."created_at" ASC LIMIT ?  [["LIMIT", 1]]

firstのほうのSQL文にはDESCという句が含まれており、一方lastのほうのSQL文にはASCという句が含まれています。

3.1. データベース上の最初のユーザーを変数userに代入してください。そのuserオブジェクトが最初に投稿したマイクロポストのidはいくつでしょうか?

>> user = User.first
  User Load (1.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, ...>

>> user.microposts.last.id
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" ASC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
=> 1

現状の実装において、「ユーザーが最初に投稿したマイクロポスト」というのは、user.microposts.lastというメソッドで取得することができます。そのidのみを取得するのであれば、user.microposts.last.idというメソッドですね。

user.microposts.last.idの戻り値は1ですね。

3.2. 次に、destroyメソッドを使ってそのuserオブジェクトを削除してみてください。削除すると、そのuserに紐付いていたマイクロポストも削除されていることをMicropost.findで確認してみましょう。

>> user.destroy
   (0.2ms)  SAVEPOINT active_record_1
  Micropost Load (0.4ms)  SELECT "microposts".* FROM "microposts" WHERE "microposts"."user_id" = ? ORDER BY "microposts"."created_at" DESC  [["user_id", 1]]
  SQL (18.6ms)  DELETE FROM "microposts" WHERE "microposts"."id" = ?  [["id", 2]]
  SQL (0.2ms)  DELETE FROM "microposts" WHERE "microposts"."id" = ?  [["id", 1]]
  SQL (5.6ms)  DELETE FROM "users" WHERE "users"."id" = ?  [["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1

>> Micropost.find(1)
  Micropost Load (0.2ms)  SELECT  "microposts".* FROM "microposts" WHERE "microposts"."id" = ? ORDER BY "microposts"."created_at" DESC LIMIT ?  [["id", 1], ["LIMIT", 1]]
Traceback (most recent call last):
        1: from (irb):11
ActiveRecord::RecordNotFound (Couldn't find Micropost with 'id'=1)

ポイントは以下です。

  • userオブジェクトのdestroyメソッドを実行した時点で、micropostsテーブルのレコードに対してもSQL文DELETEがいくつか発行されている
    • 削除対象のレコードを確定するために、当該ユーザーのidWHERE句に持つSQL文SELECTが発行されている
  • 削除されたマイクロポストのIDを引数に取ってMicropost.findを実行すると、ActiveRecord::RecordNotFoundというエラーが投げられてくる

余談 - Userモデルのhas_manyメソッドにdependent: :destroyオプションがない場合

>> user.destroy
   (0.6ms)  SAVEPOINT active_record_1
  SQL (25.4ms)  DELETE FROM "users" WHERE "users"."id" = ?  [["id", 1]]
   (0.2ms)  ROLLBACK TO SAVEPOINT active_record_1
Traceback (most recent call last):
        1: from (irb):20
ActiveRecord::InvalidForeignKey (SQLite3::ConstraintException: FOREIGN KEY constraint failed: DELETE FROM "users" WHERE "users"."id" = ?)

user.destroyActiveRecord::InvalidForeignKeyというエラーで失敗します。

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

Railsチュートリアル 第13章 ユーザーのマイクロポスト - 「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする」という実装を、Userモデルに追加する」

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されていること」に対するテストを実装する

テストの内容は以下のようになります。名前は「associated microposts should be destroyed」とします。

test "associated microposts should be destroyed" do
  @user.save
  @user.microposts.create!(content: "Lorem ipsum")
  assert_difference 'Micropost.count', -1 do
    @user.destroy
  end
end

テストを追加する箇所は、test/models/user_test.rbとなります。test/models/micropost_test.rbではありませんので注意が必要です。

test/models/user_test.rb
  require 'test_helper'

  class UserTest < ActiveSupport::TestCase

    def setup
      @user = User.new(name: "Example User", email: "user@example.com",
                      password: "foobar", password_confirmation: "foobar")
    end

    ...略
+
+   test "associated microposts should be destroyed" do
+     @user.save
+     @user.microposts.create!(content: "Lorem ipsum")
+     assert_difference 'Micropost.count', -1 do
+       @user.destroy
+     end
+   end
  end

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されていること」に対するテストを追加した時点でのテストの結果

# rails test test/models/user_test.rb
Running via Spring preloader in process 1718
Started with run options --seed 27055

 FAIL["test_associated_microposts_should_be_destroyed", UserTest, 1.2237603999965359]
 test_associated_microposts_should_be_destroyed#UserTest (1.22s)
        "Micropost.count" didn't change by -1.
        Expected: 4
          Actual: 5
        test/models/user_test.rb:81:in `block in <class:UserTest>'

  13/13: [=================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.61126s
13 tests, 22 assertions, 1 failures, 0 errors, 0 skips

以下のようなメッセージが表示されて、テストが失敗しています。

"Micropost.count" didn't change by -1.

「マイクロポストの数が1つ減っていなければならないのに、1つ減っていない」という趣旨のメッセージですね。想定通りの動作です。

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄される」という実装を追加する

has_manyメソッドにdependent :destroyオプションを追加すれば、上記の機能を実装することができます。具体的には、app/models/user.rbを以下のように変更します。

app/models/user.rb
  class User < ApplicationRecord
-   has_many :microposts
+   has_many :microposts, dependent: :destroy
    ...略
  end

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄される」という実装を追加した時点で、再びテストを実行してみる

test/models/user_test.rbを対象とするテストを再度実施してみましょう。

# rails test test/models/user_test.rb
Running via Spring preloader in process 1731
Started with run options --seed 63273

  13/13: [=================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.56839s
13 tests, 22 assertions, 0 failures, 0 errors, 0 skips

無事テストが成功するようになりました。

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

Railsチュートリアル 第13章 ユーザーのマイクロポスト - 「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されるようにする」という実装を、Userモデルに追加する

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されていること」に対するテストを実装する

テストの内容は以下のようになります。名前は「associated microposts should be destroyed」とします。

test "associated microposts should be destroyed" do
  @user.save
  @user.microposts.create!(content: "Lorem ipsum")
  assert_difference 'Micropost.count', -1 do
    @user.destroy
  end
end

テストを追加する箇所は、test/models/user_test.rbとなります。test/models/micropost_test.rbではありませんので注意が必要です。

test/models/user_test.rb
  require 'test_helper'

  class UserTest < ActiveSupport::TestCase

    def setup
      @user = User.new(name: "Example User", email: "user@example.com",
                      password: "foobar", password_confirmation: "foobar")
    end

    ...略
+
+   test "associated microposts should be destroyed" do
+     @user.save
+     @user.microposts.create!(content: "Lorem ipsum")
+     assert_difference 'Micropost.count', -1 do
+       @user.destroy
+     end
+   end
  end

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄されていること」に対するテストを追加した時点でのテストの結果

# rails test test/models/user_test.rb
Running via Spring preloader in process 1718
Started with run options --seed 27055

 FAIL["test_associated_microposts_should_be_destroyed", UserTest, 1.2237603999965359]
 test_associated_microposts_should_be_destroyed#UserTest (1.22s)
        "Micropost.count" didn't change by -1.
        Expected: 4
          Actual: 5
        test/models/user_test.rb:81:in `block in <class:UserTest>'

  13/13: [=================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.61126s
13 tests, 22 assertions, 1 failures, 0 errors, 0 skips

以下のようなメッセージが表示されて、テストが失敗しています。

"Micropost.count" didn't change by -1.

「マイクロポストの数が1つ減っていなければならないのに、1つ減っていない」という趣旨のメッセージですね。想定通りの動作です。

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄される」という実装を追加する

has_manyメソッドにdependent :destroyオプションを追加すれば、上記の機能を実装することができます。具体的には、app/models/user.rbを以下のように変更します。

app/models/user.rb
  class User < ApplicationRecord
-   has_many :microposts
+   has_many :microposts, dependent: :destroy
    ...略
  end

「ユーザーが削除されると、当該ユーザーのマイクロポストも同時に破棄される」という実装を追加した時点で、再びテストを実行してみる

test/models/user_test.rbを対象とするテストを再度実施してみましょう。

# rails test test/models/user_test.rb
Running via Spring preloader in process 1731
Started with run options --seed 63273

  13/13: [=================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.56839s
13 tests, 22 assertions, 0 failures, 0 errors, 0 skips

無事テストが成功するようになりました。

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

Sidekiq 6の新機能・変更点

はじめに

Ruby on Rails (以下Rails) でバックグラウンドジョブを実行する際によく使用されるGemである、Sidekiqのバージョン6.0が今年(2019年)9月にリリースされました。このバージョンでは、Active Jobへのオプション指定などの機能強化と共に、パフォーマンス向上が行われ、sidekiqctlコマンドが廃止されるなどの大きな変更が行われています。本記事では実際に使用した経験を元に、Sidekiq 6の新機能や変更点について説明を行います。

注意点

本記事で取り扱うエディション

本記事ではGitHubで公開しているOSS版のSidekiqについて記述しています。Pro / Enterprise版を使用している場合は、公式サイト(英語)を参考にしてください。

サポートされるRails / Ruby / Redisのバージョン

サポートされるバージョンは以下の通りになります。

  • Ruby on Rails 5以上
  • Ruby 2.5以上
  • Redis 4以上

上記より古いバージョンを使用している場合は、アップグレードを事前に行う必要があります。

新機能

Active JobでSidekiq独自のオプションを指定可能に

ActiveJob::Baseを継承したジョブで使用する際に、sidekiq_optionsを指定することで、Sidekiq独自のオプションを一部使用できるようになりました。

以下はリトライの回数と、Web UIで表示するバックトレースの行数をsidekiq_optionsで指定しています。

class MyJob < ActiveJob::Base
  queue_as :myqueue
  sidekiq_options retry: 10, backtrace: 20
  def perform(...)
  end
end

以下が使用できるオプションの一覧となります。

オプション 内容
retry 整数 リトライ回数を指定する
backtrace true, false, 整数 SidekiqのWeb UIにバックトレースを表示するかどうか
整数の場合は指定した行数で表示する

参考

ログの出力形式を選択可能に

ログの出力機能がプラグイン化され、出力形式を選択できるようになりました。例えば、JSON形式の出力を行う場合は、以下のようにSidekiq::Logger::Formatters::JSONを指定します。

Sidekiq.configure_server do |config|
  config.log_formatter = Sidekiq::Logger::Formatters::JSON.new
end

以下が使用できるログの出力形式の一覧となります。

フォーマッタ 説明
Sidekiq::Logger::Formatters::Pretty 通常の出力
Sidekiq::Logger::Formatters::WithoutTimestamp タイムスタンプなしで出力
Sidekiq::Logger::Formatters::JSON JSON形式で出力

ログ出力の例

Sidekiq::Logger::Formatters::Pretty

2019-08-31T15:36:07.569Z pid=82859 tid=11cy9br class=HardWorker jid=528f1b0ddc4a9d0690464fe4 INFO: start

Sidekiq::Logger::Formatters::WithoutTimestamp

pid=82859 tid=119pz7z class=HardWorker jid=b7f805c545c78770d30dc1fd elapsed=0.089 INFO: done

`Sidekiq::Logger::Formatters::JSON

{"ts":"2019-09-01T22:34:59.778Z","pid":90069,"tid":"104v8ph","lvl":"INFO","msg":"Running in ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin17]"}

参考

ジョブ毎のログレベル指定が可能に

ワーカーの.setメソッドの引数でlog_levelを指定することで、特定のジョブのログの出力レベルを指定できるようになりました。

MyWorker.set(log_level: :debug).perform_async(...)

SidekiqはデフォルトでRubyの標準ライブラリのLoggerを使用しており、log_levelで指定できるのは、:unknown:fatal:warn:info:debugになります。

参考

ジョブのタグ付けが可能に

ワーカーのクラスのsidekiq_optionstagsを指定することで、ジョブのタグ付けができるようになりました。

class MyWorker
  include Sidekiq::Worker
  sidekiq_options tags: ['bank-ops', 'alpha']
  ...
end

参考

Web UIでのダークモードのサポート

ダークモードがサポートされたブラウザ(IEやEdge、Operaは非サポート)でモードが有効な状態でWeb UI(Sidekiqの管理画面)にアクセスした場合は、黒ベースの画面で表示するようになりました。

参考

変更点

sidekiqctlコマンドの削除

Sidekiqプロセスを停止、終了するために用いられてきた、sidekiqctlコマンドが廃止されました。Sidekiq 6では、このコマンドの代わりにkillコマンドでシグナルを送ることで、プロセスの停止、終了を行うことができます。

廃止されたコマンド 代替となるコマンド 説明
sidekiqctl quiet kill -TSTP <pid> ワーカーの各スレッドを停止
sidekiqctl stop kill -TERM <pid> (上記コマンドの実行後に)プロセスを終了

デーモン化を廃止し、サービスとして実行するように

サービスの管理をOS側(Systemd、Upstartなど)で行うようにするために、デーモン化を廃止しました。このため、本番環境などサーバーで動かす場合は、Sidekiqのサービスの設定ファイルを新たに作成し、サービスを有効にする必要があります。 

デーモン化の廃止に至った背景に関しては、Sidekiq作者による説明が以下のページにあります。

上記の記事では、以前から使用されてきた長時間動作するデーモンプロセスが、現在ではSystemdを使用したサービスに置き換えられていて、このことによって、ロギングやクラッシュ時の再実行などの処理を自前で実装することがなく、より堅牢な実行が可能になった、ということが書かれています。

Sidekiqのサービスの設定、起動や停止コマンドに関しては、この記事の次の節のアップグレードの部分を参考にしてください。

logfilepidfileコマンド引数の廃止 

上記のデーモン化の廃止に伴って、ログの出力や、プロセスIDの保持をSystemdなどのサービス側で行うようになったので、sidekiqコマンドからlogfilepidfileコマンド引数が削除されました。 

デフォルトのシャットダウン時間が8秒から25秒に変更に

HerokuのDynoとAmazon ECS コンテナがアプリケーションのシャットダウンで30秒のタイムアウトを使用するようになったため、Sidekiqも8秒から25秒にタイムアウト時間を伸ばしました。以前の挙動に戻すには、オプション-t 8sidekiqコマンドに対して指定します。

REDIS_PROVIDER環境変数の検証を行うように

REDIS_PROVIDER環境変数は、RedisサーバーのURLを保持する他の環境変数を指定するのに使用します。もし、REDIS_PROVIDER変数に直接URLなどの不適切な文字列な文字列が指定されている場合は、以下のような警告を出すようになりました。

REDIS_PROVIDER should be set to the name of the variable which contains the Redis URL, not a URL itself.
Platforms like Heroku will sell addons that publish a *_URL variable.  You need to tell Sidekiq with REDIS_PROVIDER, e.g.:

参考

パフォーマンスの向上

生成するオブジェクトを減らすなど、細かいコードのチューニングを行うことによって、Sidekiq 5系列と比較して、10-15%実行速度が向上しているそうです。

参考

アップグレード方法

Gemのアップグレード

最初に、Gemfileに以下のように指定して、bundle updateを実行して、Sidekiq 5系列の最新版にアップグレードします。

gem 'sidekiq', '< 6'

Sidekiqを動かしてみて、非推奨の警告(Deprecation warnings)が出ないようにコードの修正を行います。

修正が完了したら、Gemfileに以下のように指定して、再度bundle updateを実行して、Sidekiq 6.xへアップグレードします。

gem 'sidekiq', '< 7'

サービスの設定ファイルの作成

変更点で述べたように、本番環境では、Sidekiqプロセスはデーモンでなくサービスとして起動するようになったため、設定ファイルを作成する必要があります。

開発環境では、これまで通りユーザーがsidekiqコマンドを実行してフォアグラウンドで起動し、ログは標準出力および標準エラー出力に送信されるため、サービスとして起動させる必要はありません。

SystemdでSidekiqをサービスとして実行するには、以下のファイルを元に設定ファイルを生成し、サーバーの適切な場所(CentOSの場合は/usr/lib/systemd/system、Ubuntuの場合は/lib/systemd/system)に配置する必要があります。

https://raw.githubusercontent.com/mperham/sidekiq/master/examples/systemd/sidekiq.service

ファイルの配置を行ったら、以下のコマンドでサービスを有効にします。

$ sudo systemctl enable sidekiq

サービスの起動と停止

設定ファイルを配置後は、以下のコマンドでサービスの起動と停止を行います。

サーバーの起動

$ sudo systemctl start sidekiq

サーバーの停止

$ sudo systemctl stop sidekiq

参考URL

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

SkyWay API + Rails6 + Vue でビデオチャットアプリを作る② 複数同時接続

SkyWayAPIを使って複数人でのビデオチャットアプリに挑戦します!
先週投稿した「SkyWay API + Rails6 + Vue でビデオチャットアプリを作る①」の続きです。

目標物

カンファレンス型のビデオチャットアプリ作成。
部屋は既に作られていて、そこに入室したところから開始です。

注意

前回の回で使ったコードを基本使い回します。
railsのプロジェクトがあること、webpackerがインストールされていることを前提に進めていきます。

サンプルコードの分析

SkyWayが提供している複数同時接続のパターンのDEMOです。
https://example.webrtc.ecl.ntt.com/room/index.html

そのソースコードです。
パッとみてよくわからない部分があったので、上から順にコメントをつけていきました。

githubリポジトリ
https://github.com/skyway/skyway-js-sdk/tree/master/examples/room

script.js
//Peerモデルを定義
const Peer = window.Peer;

(async function main() {
  //操作がDOMをここで取得
  const localVideo = document.getElementById('js-local-stream');
  const joinTrigger = document.getElementById('js-join-trigger');
  const leaveTrigger = document.getElementById('js-leave-trigger');
  const remoteVideos = document.getElementById('js-remote-streams');
  const roomId = document.getElementById('js-room-id');
  const roomMode = document.getElementById('js-room-mode');
  const localText = document.getElementById('js-local-text');
  const sendTrigger = document.getElementById('js-send-trigger');
  const messages = document.getElementById('js-messages');
  const meta = document.getElementById('js-meta');
  const sdkSrc = document.querySelector('script[src*=skyway]');

  meta.innerText = `
    UA: ${navigator.userAgent}
    SDK: ${sdkSrc ? sdkSrc.src : 'unknown'}
  `.trim();

 //同時接続モードがSFUなのかMESHなのかをここで設定
  const getRoomModeByHash = () => (location.hash === '#sfu' ? 'sfu' : 'mesh');
 //divタグに接続モードを挿入
  roomMode.textContent = getRoomModeByHash();
 //接続モードの変更を感知するリスナーを設置
  window.addEventListener(
    'hashchange',
    () => (roomMode.textContent = getRoomModeByHash())
  );

 //自分の映像と音声をlocalStreamに代入
  const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: true,
    })
    .catch(console.error);

  // localStreamをdiv(localVideo)に挿入
  localVideo.muted = true;
  localVideo.srcObject = localStream;
  localVideo.playsInline = true;
  await localVideo.play().catch(console.error);

  // Peerのインスタンス作成
  const peer = (window.peer = new Peer({
    key: window.__SKYWAY_KEY__,
    debug: 3,
  }));

  // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
  joinTrigger.addEventListener('click', () => {
    if (!peer.open) {
      return;
    }

  //部屋に接続するメソッド(joinRoom)
    const room = peer.joinRoom(roomId.value, {
      mode: getRoomModeByHash(),
      stream: localStream,
    });

  //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
    room.once('open', () => {
      messages.textContent += '=== You joined ===\n';
    });
  //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
    room.on('peerJoin', peerId => {
      messages.textContent += `=== ${peerId} joined ===\n`;
    });

    //重要: streamの内容に変更があった時(stream)videoタグを作って流す
    room.on('stream', async stream => {
      const newVideo = document.createElement('video');
      newVideo.srcObject = stream;
      newVideo.playsInline = true;
      // 誰かが退出した時どの人が退出したかわかるように、data-peer-idを付与
      newVideo.setAttribute('data-peer-id', stream.peerId);
      remoteVideos.append(newVideo);
      await newVideo.play().catch(console.error);
    });

    //重要: 誰かがテキストメッセージを送った時、messagesを更新
    room.on('data', ({ data, src }) => {
      messages.textContent += `${src}: ${data}\n`;
    });

    // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
    room.on('peerLeave', peerId => {
      const remoteVideo = remoteVideos.querySelector(
        `[data-peer-id=${peerId}]`
      );
    //videoストリームを止める上では定番の書き方らしい。https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/stop
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
      remoteVideo.srcObject = null;
      remoteVideo.remove();

      messages.textContent += `=== ${peerId} left ===\n`;
    });

    // 自分が退出した場合の処理
    room.once('close', () => {
    //メッセージ送信ボタンを押せなくする
      sendTrigger.removeEventListener('click', onClickSend);
    //messagesに== You left ===\nを表示
      messages.textContent += '== You left ===\n';
    //remoteVideos以下の全てのvideoタグのストリームを停めてから削除
      Array.from(remoteVideos.children).forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
    });

    // ボタン(sendTrigger)を押すとonClickSendを発動
    sendTrigger.addEventListener('click', onClickSend);
  // ボタン(leaveTrigger)を押すとroom.close()を発動
    leaveTrigger.addEventListener('click', () => room.close(), { once: true });

   //テキストメッセージを送る処理
    function onClickSend() {
      room.send(localText.value);
      messages.textContent += `${peer.id}: ${localText.value}\n`;
      localText.value = '';
    }
  });

  peer.on('error', console.error);
})();
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SkyWay - Room example</title>
    <link rel="stylesheet" href="../_shared/style.css">
  </head>
  <body>
    <div class="container">
      <h1 class="heading">Room example</h1>
      <p class="note">
        Change Room mode (before join in a room):
        <a href="#">mesh</a> / <a href="#sfu">sfu</a>
      </p>
      <div class="room">
        <div>
          <video id="js-local-stream"></video>
          <span id="js-room-mode"></span>:
          <input type="text" placeholder="Room Name" id="js-room-id">
          <button id="js-join-trigger">Join</button>
          <button id="js-leave-trigger">Leave</button>
        </div>

        <div class="remote-streams" id="js-remote-streams"></div>

        <div>
          <pre class="messages" id="js-messages"></pre>
          <input type="text" id="js-local-text">
          <button id="js-send-trigger">Send</button>
        </div>
      </div>
      <p class="meta" id="js-meta"></p>
    </div>
    <script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
    <script src="../_shared/key.js"></script>
    <script src="./script.js"></script>
  </body>
</html>

サンプルコードのvue.js化

上記の内容をvue.jsに書き換えていきます!

サンプルと違う部分

  • スタイルは一旦全部無視です。
  • 接続モードは本来2種類ありますが、今回はsfuをデフォルトに実装しました。
  • railsのviewを経由してroomIdを取得するようにしています。
room.vue
<template>
    <div id="app">
        <template v-for="stream in remoteStreams">
            <!-- ①srcObjectをバインドする -->
            <video 
                autoplay 
                playsinline
                :srcObject.prop="stream"
            ></video>
        </template>
        <video id="my-video" muted="true" width="500" autoplay playsinline></video>
        <p>ROOM ID: <span id="room-id">{{ roomId }}</span></p>
        <button v-if="roomOpened === true" @click="leaveRoom" class="button--green">Leave</button>
        <button v-else @click="joinRoom" class="button--green">Join</button>
        <br />
        <div>
            マイク:
            <select v-model="selectedAudio" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(audio, key, index) in audios" v-bind:key="index" :value="audio.value">
                {{ audio.text }}
            </option>
            </select>

            カメラ: 
            <select v-model="selectedVideo" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(video, key, index) in videos" v-bind:key="index" :value="video.value">
                {{ video.text }}
            </option>
            </select>
        </div>

        <template v-for="message in messages">
            <p>{{message}}</p>
        </template>
    </div>
</template>

<script>
const API_KEY = "6d7fe6d0-40c7-4acd-9586-063dd7b633dd"; 
// const Peer = require('../skyway-js');
export default {
    data: function () {
        return {
            audios: [],
            videos: [],
            selectedAudio: '',
            selectedVideo: '',
            localStream: {},
            messages: [],
            roomId: "",
            remoteStreams: [],
            roomOpened: false
        }
    },
    methods: {
        // 端末のカメラ音声設定
        onChange: function(){
            if(this.selectedAudio != '' && this.selectedVideo != ''){
                this.connectLocalCamera();
            }
        },
        connectLocalCamera: async function(){
            const constraints = {
                audio: this.selectedAudio ? { deviceId: { exact: this.selectedAudio } } : false,
                video: this.selectedVideo ? { deviceId: { exact: this.selectedVideo } } : false
            }
            const stream = await navigator.mediaDevices.getUserMedia(constraints);
            document.getElementById('my-video').srcObject = stream;
            this.localStream = stream;
        },
        leaveRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = false;
            this.room.close();
        },
        // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
        joinRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = true;
          //部屋に接続するメソッド(joinRoom)
            this.room = this.peer.joinRoom(this.roomId, {
                mode: "sfu",
                stream: this.localStream,
            });
          //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
            this.room.once('open', () => {
                this.messages.push('=== You joined ===');
            });
          //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
            this.room.on('peerJoin', peerId => {
                this.messages.push(`=== ${peerId} joined ===`);
            });
            //重要: streamの内容に変更があった時(stream)videoタグを作って流す
            this.room.on('stream', async stream => {
                await this.remoteStreams.push(stream);
            });

            //重要: 誰かがテキストメッセージを送った時、messagesを更新
            this.room.on('data', ({ data, src }) => {
                this.messages.push(`${src}: ${data}`);
            });

            // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
            this.room.on('peerLeave', peerId => {
                const index = this.remoteStreams.findIndex((v) => v.peerId === peerId);
                const removedStream = this.remoteStreams.splice(index, 1);
                this.messages.push(`=== ${peerId} left ===`);
            });

            // 自分が退出した場合の処理
            this.room.once('close', () => {
               //メッセージ送信ボタンを押せなくする
                this.messages.length = 0;
            });
        }
    },

    created: async function(){
        const element = document.getElementById("room")
        const data = JSON.parse(element.getAttribute('data'))
        this.roomId = data.roomId
        //ここでpeerのリスナーを設置
        this.peer = new Peer({key: API_KEY, debug: 3}); //新規にPeerオブジェクトの作成

        //デバイスへのアクセス
        const deviceInfos = await navigator.mediaDevices.enumerateDevices();

        //オーディオデバイスの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'audioinput')
        .map(audio => this.audios.push({text: audio.label || `Microphone ${this.audios.length + 1}`, value: audio.deviceId}));

        //カメラの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'videoinput')
        .map(video => this.videos.push({text: video.label || `Camera  ${this.videos.length - 1}`, value: video.deviceId}));      
    }
}
</script>

<style scoped>
    p {
    font-size: 2em;
    text-align: center;
    }
</style>

vue化のコツ

本記事の趣旨とは異なりますが、素のJSをvueに書き換える時のコツです。
- 定数の定義をcreatedフックに集める
- クリック系のリスナーは全部関数に切り出してDOMの@ clickで発火するようにする
- その他のリスナーはcreatedフックに集める(又は、任意のアクション内)
- 変数をdataに整理する
- createElementやappendなどでDOMを挿入するケースは、dataとfor文をうまく使ってまとめる

videoタグのsrcをバインドさせる時、srcがオブジェクトの場合 :srcObject.prop="オブジェクト"という形で渡してあげないとエラーになります。

参考記事:vue.jsで複数のvideoタグを扱う
https://qiita.com/dbgso/items/271d903237b41dffcc6d

Rails側のコード

rails側の設定です。

rooms/show.html.erb
// roomIdを渡す処理
<% props = {
    roomId: "aiueo" 
  }.to_json
%>

<div id='room' data="<%= props %>">
    <room/>
</div>

<%= javascript_pack_tag 'room' %>
<%= stylesheet_pack_tag 'room' %>
routes.rb
Rails.application.routes.draw do
  get 'rooms/show'
  root 'rooms#show'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
rooms_controller.rb
class RoomsController < ApplicationController
  def show
  end
end

結果

これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。

スクリーンショット 2019-12-23 0.38.47.png

残念なお知らせ

相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です?‍♂️

最後に

今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。

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

SkyWay API + Rails6 + Vue でビデオチャットアプリを作る② 複数人同時接続

SkyWayAPIを使って複数人でのビデオチャットアプリに挑戦します!
先週投稿した「SkyWay API + Rails6 + Vue でビデオチャットアプリを作る①」の続きです。

目標物

複数人が同時に参加できるビデオチャットアプリの作成。
部屋は既に作られていて、そこに入室したところから開始です。

注意

前回の回で使ったコードを基本使い回します。
railsのプロジェクトがあること、webpackerがインストールされていることを前提に進めていきます。

サンプルコードの分析

SkyWayが提供している複数同時接続のパターンのDEMOです。
https://example.webrtc.ecl.ntt.com/room/index.html

そのソースコードです。
パッとみてよくわからない部分があったので、上から順にコメントをつけていきました。

githubリポジトリ
https://github.com/skyway/skyway-js-sdk/tree/master/examples/room

script.js
//Peerモデルを定義
const Peer = window.Peer;

(async function main() {
  //操作がDOMをここで取得
  const localVideo = document.getElementById('js-local-stream');
  const joinTrigger = document.getElementById('js-join-trigger');
  const leaveTrigger = document.getElementById('js-leave-trigger');
  const remoteVideos = document.getElementById('js-remote-streams');
  const roomId = document.getElementById('js-room-id');
  const roomMode = document.getElementById('js-room-mode');
  const localText = document.getElementById('js-local-text');
  const sendTrigger = document.getElementById('js-send-trigger');
  const messages = document.getElementById('js-messages');
  const meta = document.getElementById('js-meta');
  const sdkSrc = document.querySelector('script[src*=skyway]');

  meta.innerText = `
    UA: ${navigator.userAgent}
    SDK: ${sdkSrc ? sdkSrc.src : 'unknown'}
  `.trim();

 //同時接続モードがSFUなのかMESHなのかをここで設定
  const getRoomModeByHash = () => (location.hash === '#sfu' ? 'sfu' : 'mesh');
 //divタグに接続モードを挿入
  roomMode.textContent = getRoomModeByHash();
 //接続モードの変更を感知するリスナーを設置
  window.addEventListener(
    'hashchange',
    () => (roomMode.textContent = getRoomModeByHash())
  );

 //自分の映像と音声をlocalStreamに代入
  const localStream = await navigator.mediaDevices
    .getUserMedia({
      audio: true,
      video: true,
    })
    .catch(console.error);

  // localStreamをdiv(localVideo)に挿入
  localVideo.muted = true;
  localVideo.srcObject = localStream;
  localVideo.playsInline = true;
  await localVideo.play().catch(console.error);

  // Peerのインスタンス作成
  const peer = (window.peer = new Peer({
    key: window.__SKYWAY_KEY__,
    debug: 3,
  }));

  // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
  joinTrigger.addEventListener('click', () => {
    if (!peer.open) {
      return;
    }

  //部屋に接続するメソッド(joinRoom)
    const room = peer.joinRoom(roomId.value, {
      mode: getRoomModeByHash(),
      stream: localStream,
    });

  //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
    room.once('open', () => {
      messages.textContent += '=== You joined ===\n';
    });
  //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
    room.on('peerJoin', peerId => {
      messages.textContent += `=== ${peerId} joined ===\n`;
    });

    //重要: streamの内容に変更があった時(stream)videoタグを作って流す
    room.on('stream', async stream => {
      const newVideo = document.createElement('video');
      newVideo.srcObject = stream;
      newVideo.playsInline = true;
      // 誰かが退出した時どの人が退出したかわかるように、data-peer-idを付与
      newVideo.setAttribute('data-peer-id', stream.peerId);
      remoteVideos.append(newVideo);
      await newVideo.play().catch(console.error);
    });

    //重要: 誰かがテキストメッセージを送った時、messagesを更新
    room.on('data', ({ data, src }) => {
      messages.textContent += `${src}: ${data}\n`;
    });

    // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
    room.on('peerLeave', peerId => {
      const remoteVideo = remoteVideos.querySelector(
        `[data-peer-id=${peerId}]`
      );
    //videoストリームを止める上では定番の書き方らしい。https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/stop
      remoteVideo.srcObject.getTracks().forEach(track => track.stop());
      remoteVideo.srcObject = null;
      remoteVideo.remove();

      messages.textContent += `=== ${peerId} left ===\n`;
    });

    // 自分が退出した場合の処理
    room.once('close', () => {
    //メッセージ送信ボタンを押せなくする
      sendTrigger.removeEventListener('click', onClickSend);
    //messagesに== You left ===\nを表示
      messages.textContent += '== You left ===\n';
    //remoteVideos以下の全てのvideoタグのストリームを停めてから削除
      Array.from(remoteVideos.children).forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
    });

    // ボタン(sendTrigger)を押すとonClickSendを発動
    sendTrigger.addEventListener('click', onClickSend);
  // ボタン(leaveTrigger)を押すとroom.close()を発動
    leaveTrigger.addEventListener('click', () => room.close(), { once: true });

   //テキストメッセージを送る処理
    function onClickSend() {
      room.send(localText.value);
      messages.textContent += `${peer.id}: ${localText.value}\n`;
      localText.value = '';
    }
  });

  peer.on('error', console.error);
})();
index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>SkyWay - Room example</title>
    <link rel="stylesheet" href="../_shared/style.css">
  </head>
  <body>
    <div class="container">
      <h1 class="heading">Room example</h1>
      <p class="note">
        Change Room mode (before join in a room):
        <a href="#">mesh</a> / <a href="#sfu">sfu</a>
      </p>
      <div class="room">
        <div>
          <video id="js-local-stream"></video>
          <span id="js-room-mode"></span>:
          <input type="text" placeholder="Room Name" id="js-room-id">
          <button id="js-join-trigger">Join</button>
          <button id="js-leave-trigger">Leave</button>
        </div>

        <div class="remote-streams" id="js-remote-streams"></div>

        <div>
          <pre class="messages" id="js-messages"></pre>
          <input type="text" id="js-local-text">
          <button id="js-send-trigger">Send</button>
        </div>
      </div>
      <p class="meta" id="js-meta"></p>
    </div>
    <script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
    <script src="../_shared/key.js"></script>
    <script src="./script.js"></script>
  </body>
</html>

サンプルコードのvue.js化

上記の内容をvue.jsに書き換えていきます!

サンプルと違う部分

  • スタイルは一旦全部無視です。
  • 接続モードは本来2種類ありますが、今回はsfuをデフォルトに実装しました。
  • railsのviewを経由してroomIdを取得するようにしています。
room.vue
<template>
    <div id="app">
        <template v-for="stream in remoteStreams">
            <!-- ①srcObjectをバインドする -->
            <video 
                autoplay 
                playsinline
                :srcObject.prop="stream"
            ></video>
        </template>
        <video id="my-video" muted="true" width="500" autoplay playsinline></video>
        <p>ROOM ID: <span id="room-id">{{ roomId }}</span></p>
        <button v-if="roomOpened === true" @click="leaveRoom" class="button--green">Leave</button>
        <button v-else @click="joinRoom" class="button--green">Join</button>
        <br />
        <div>
            マイク:
            <select v-model="selectedAudio" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(audio, key, index) in audios" v-bind:key="index" :value="audio.value">
                {{ audio.text }}
            </option>
            </select>

            カメラ: 
            <select v-model="selectedVideo" @change="onChange">
            <option disabled value="">Please select one</option>
            <option v-for="(video, key, index) in videos" v-bind:key="index" :value="video.value">
                {{ video.text }}
            </option>
            </select>
        </div>

        <template v-for="message in messages">
            <p>{{message}}</p>
        </template>
    </div>
</template>

<script>
const API_KEY = "6d7fe6d0-40c7-4acd-9586-063dd7b633dd"; 
// const Peer = require('../skyway-js');
export default {
    data: function () {
        return {
            audios: [],
            videos: [],
            selectedAudio: '',
            selectedVideo: '',
            localStream: {},
            messages: [],
            roomId: "",
            remoteStreams: [],
            roomOpened: false
        }
    },
    methods: {
        // 端末のカメラ音声設定
        onChange: function(){
            if(this.selectedAudio != '' && this.selectedVideo != ''){
                this.connectLocalCamera();
            }
        },
        connectLocalCamera: async function(){
            const constraints = {
                audio: this.selectedAudio ? { deviceId: { exact: this.selectedAudio } } : false,
                video: this.selectedVideo ? { deviceId: { exact: this.selectedVideo } } : false
            }
            const stream = await navigator.mediaDevices.getUserMedia(constraints);
            document.getElementById('my-video').srcObject = stream;
            this.localStream = stream;
        },
        leaveRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = false;
            this.room.close();
        },
        // 「div(joinTrigger)が押される&既に接続が始まっていなかったら接続」するリスナーを設置
        joinRoom: function(){
            if (!this.peer.open) {
                return;
            }
            this.roomOpened = true;
          //部屋に接続するメソッド(joinRoom)
            this.room = this.peer.joinRoom(this.roomId, {
                mode: "sfu",
                stream: this.localStream,
            });
          //部屋に接続できた時(open)に一度だけdiv(messages)に=== You joined ===を表示
            this.room.once('open', () => {
                this.messages.push('=== You joined ===');
            });
          //部屋に誰かが接続してきた時(peerJoin)、いつでもdiv(messages)に下記のテキストを表示
            this.room.on('peerJoin', peerId => {
                this.messages.push(`=== ${peerId} joined ===`);
            });
            //重要: streamの内容に変更があった時(stream)videoタグを作って流す
            this.room.on('stream', async stream => {
                await this.remoteStreams.push(stream);
            });

            //重要: 誰かがテキストメッセージを送った時、messagesを更新
            this.room.on('data', ({ data, src }) => {
                this.messages.push(`${src}: ${data}`);
            });

            // 誰かが退出した場合、div(remoteVideos)内にある、任意のdata-peer-idがついたvideoタグの内容を空にして削除する
            this.room.on('peerLeave', peerId => {
                const index = this.remoteStreams.findIndex((v) => v.peerId === peerId);
                const removedStream = this.remoteStreams.splice(index, 1);
                this.messages.push(`=== ${peerId} left ===`);
            });

            // 自分が退出した場合の処理
            this.room.once('close', () => {
               //メッセージ送信ボタンを押せなくする
                this.messages.length = 0;
            });
        }
    },

    created: async function(){
        const element = document.getElementById("room")
        const data = JSON.parse(element.getAttribute('data'))
        this.roomId = data.roomId
        //ここでpeerのリスナーを設置
        this.peer = new Peer({key: API_KEY, debug: 3}); //新規にPeerオブジェクトの作成

        //デバイスへのアクセス
        const deviceInfos = await navigator.mediaDevices.enumerateDevices();

        //オーディオデバイスの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'audioinput')
        .map(audio => this.audios.push({text: audio.label || `Microphone ${this.audios.length + 1}`, value: audio.deviceId}));

        //カメラの情報を取得
        deviceInfos
        .filter(deviceInfo => deviceInfo.kind === 'videoinput')
        .map(video => this.videos.push({text: video.label || `Camera  ${this.videos.length - 1}`, value: video.deviceId}));      
    }
}
</script>

<style scoped>
    p {
    font-size: 2em;
    text-align: center;
    }
</style>

vue化のコツ

本記事の趣旨とは異なりますが、素のJSをvueに書き換える時のコツです。
- 定数の定義をcreatedフックに集める
- クリック系のリスナーは全部関数に切り出してDOMの@ clickで発火するようにする
- その他のリスナーはcreatedフックに集める(又は、任意のアクション内)
- 変数をdataに整理する
- createElementやappendなどでDOMを挿入するケースは、dataとfor文をうまく使ってまとめる

videoタグのsrcをバインドさせる時、srcがオブジェクトの場合 :srcObject.prop="オブジェクト"という形で渡してあげないとエラーになります。

参考記事:vue.jsで複数のvideoタグを扱う
https://qiita.com/dbgso/items/271d903237b41dffcc6d

Rails側のコード

rails側の設定です。

rooms/show.html.erb
// roomIdを渡す処理
<% props = {
    roomId: "aiueo" 
  }.to_json
%>

<div id='room' data="<%= props %>">
    <room/>
</div>

<%= javascript_pack_tag 'room' %>
<%= stylesheet_pack_tag 'room' %>
routes.rb
Rails.application.routes.draw do
  get 'rooms/show'
  root 'rooms#show'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
rooms_controller.rb
class RoomsController < ApplicationController
  def show
  end
end

結果

これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。

スクリーンショット 2019-12-23 0.38.47.png

残念なお知らせ

相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です?‍♂️

最後に

今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。

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