- 投稿日:2019-12-23T22:50:29+09:00
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 install2. 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.rbclass UserComponent < ActionView::Component::Base + attr_reader :name def initialize(name:) @name = name end end4. 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.rbclass 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 blankCSSファイル
以下の様にサイドカーCSSを配置するのがベストプラクティスの様です。
- /app - /components - /alert - alert.css - alert.html.erb - alert.rbSee: 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]サンプルコード
- 投稿日:2019-12-23T22:25:30+09:00
【Rails 6】(初心者向け)Ajax版最小構成CRUDアプリ(ページ移動をゼロに!)
Railsの学習で最初に学ぶのが,メッセージの作成・表示(読み取り)・更新・削除のできるCRUDアプリであると思います。私も最初にCRUDアプリを作成したときは,正直理解が追いつきませんでした
ところが,実際に作成してみるといろいろと不満が出てくることでしょう。一番は「見た目」だと思いますが,
「ボタンをクリックするたびにページを移動するのは嫌だなあ……」
と思いませんでしたか?特に本番環境では読み込みに時間がかかってしまいます。そこで,この記事では,ページ遷移ゼロの最小構成CRUDアプリを作成していきたいと思います
通常のCRUDアプリを理解していたならば,本記事も理解できるようなるべく丁寧に解説していきます。
この記事では,Rails標準の Ajax の使い方を学ぶことだけに焦点を当てます。そのため,見た目・バリデーション・例外処理など細かいことは全て削ります。なお, jQuery の使用は避けて, Javascript を使用することとします。
完成後のアプリ
開発環境
- 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.rbRails.application.routes.draw do - get 'messages/index' + root 'messages#index' + resources :messages end
- 一覧ページに投稿メッセージが表示されるようにします。
app/controllers/messages_controller.rbclass MessagesController < ApplicationController def index @messages = Message.all end endapp/views/messages/index.html.erb<!-- 各メッセージに対し,部分テンプレート _message.html.erb を呼び出す --> <%= render @messages %>app/views/messages/_message.html.erb<p> <%= message.content %> </p>
- 確認用として,適当なメッセージをデータベースに投入します
db/seeds.rbmessages = %w[おはよう こんにちは こんばんは] messages.each do |message| Message.create!(content: message) end puts '初期データの保存に成功しました!'ターミナル$ rails db:seed
- トップページ(
http://localhost:3000
)にアクセスし,次のように表示されたならばOKです!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です!
もちろん,このままでは「投稿ボタン」を押しても,何も起こりません。対応する
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.rbdef create Message.create!(message_params) # 実際に次の一行を追加して確認してもよいですが,その後削除して下さい! redirect_to root_path endこのようにすれば,新規メッセージを投稿 するだけでなく ページ更新 まで行われますので,一応,新規投稿機能ができたことになります。
ところが,今回は ページ遷移無し で投稿メッセージを追加表示させたいので,これではダメですここで,トップページのソースを確認してみます。
<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_for
やform_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. メッセージの削除
次にメッセージの削除機能を付けます
- まずは削除用のリンクを作成します。
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.erbdocument.getElementById('message-<%= @message.id %>').outerHTML = ''ここも解説を入れておきます。削除する
@message.id
が2
であるとすると,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>この時点で次のような表示になります。
- コントローラに
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番目のメッセージにある更新ボタンをクリックすると,次のような表示に変わります。
コントローラにデータベースを更新する操作はすでに入れています。あとは更新用フォームの「更新」ボタンを押したときに,ページ内のメッセージを更新するようにします。
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: '投稿') %>'これで,メッセージの更新も ページ遷移無し で実現できるようになりました
4. おまけ
最後に,フォームを「更新用」に変更したあと,「新規投稿用」に戻すリンクを作っておきます。見た目がいまいちですが,本筋から外れますので許して下さい
- 更新用フォームにだけ「取消」のリンクを追加
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.erbvar form = document.getElementsByTagName('form')[0] form.outerHTML = '<%= j(render 'form', message: Message.new, value: '投稿') %>'これで,「新規投稿用フォーム」に戻すこともできるようになりました。
今回の記事は Ajax を利用して ページ遷移無し でCRUDアプリを作成することのみに重点をおいて解説をしました。少しでも理解を進めるお手伝いができたならば幸いです。お疲れ様でした
最終的なコード
- GitHubに完成後のコードをアップしております
$ git clone https://github.com/T-Tsujii/ajax_crud_sample.git
- 投稿日:2019-12-23T21:09:47+09:00
.orderで並び替えたデータ取得時の注意点
映画レビューアプリを作成中に気づいたことです!
レビュー投稿画面に移行時に
①Movieテーブル最新データ1つ取得→ fleshmovie = Movie.order(updated_at: :desc).limit(1)
②最新データidカラムの値をインスタンス変数に定義→ @movie = fleshmovie.idreviews_controller.rbdef new fleshmovie = Movie.order(updated_at: :desc).limit(1) @movie = fleshmovie.id enderror_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.rbdef new fleshmovie = Movie.order(updated_at: :desc).limit(1) @movie = fleshmovie[0].id end
- 投稿日:2019-12-23T20:15:15+09:00
【環境構築】Docker + Rails6 + Vue.js + Vuetifyの環境構築手順
はじめに
Docker + Rails6 + Vue.js + Vuetifyの開発環境構築手順をまとめました。
以下の記事を参考にさせて頂きました!ありがとうございます
- webpackerを使ってRuby on Rails 6.0とVue.jsを連携する方法(フロントエンド編)
- Rails+Vue.js+Vuetify環境の構築手順 - Qiita
- 【Rails6】10分でRails + Vue + Vuetifyの環境を構築する - Qiita
環境
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.01.準備
作成するアプリケーション名はhogeappとします。
まずは以下ファイルを作成して下さい。Dockerfile
yarnが必要になるので、Dockerfileに反映しています。
hogeapp/DockerfileFROM 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_ROOTdocker-compose.yml
hogeapp/docker-compose.ymlversion: "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: localGemfile
hogeapp/Gemfilesource '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.lockentrypoint.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.ymldefault: &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:createYay! You’re on Rails!
localhost:3000
にアクセスして確認してみましょう。これでRailsはOKなので、次はVue.jsです!
3.Vue.jsの導入
webpackerのインストール
$ docker-compose exec web rails webpacker:installVue.jsのインストール
$ docker-compose exec web rails webpacker:install:vueVue.jsとの連携を確認
Railsでコントローラーを作ってみて、Vue.jsと連携出来るかを確認してみます。
$ docker-compose exec web rails g controller home indexapp/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.rbroot to: 'home#index'ブラウザで確認
localhost:3000にアクセスし、下記画面が出力されているか確認してみて下さい。
これで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> ...以下略ブラウザで確認
再度読み込みすると、以下のような画面になっているはずです。
これできちんと単一ファイルコンポーネントが読み込まれていることが確認できたので、Vue.jsはOKです。
次はラスト!Vuetifyの導入です。
5.Vuetifyの導入
Vuetifyのインストール
$ docker-compose exec web yarn add vuetify -D
hello_vue.js
に追記hello_vue.jsimport 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にアクセスし、問題ないかを確認してみます。
無事に画像のようなヘッダーが表示されていれば完了です!
以上です!お疲れ様でした!
おわりに
最後まで読んで頂きありがとうございました
どなたかのお役に立てれば幸いです
参考にさせて頂いたサイト(いつもありがとうございます)
- 投稿日:2019-12-23T20:05:17+09:00
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 enddef hoge with_lock(key) do # somthing end rescue Lockable::UnableLockError # handle error endredisを用いて対応しました。
もしお困りの方がいらっしゃれば...参考にしてくださいmm
- 投稿日:2019-12-23T19:24:49+09:00
ネストしたオブジェクトをYupでバリデーションする
Railsでいうnested attributesみたいなやつ
{ "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() }) ) ) ) });以上です
参考
- 投稿日:2019-12-23T17:35:43+09:00
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のインストールからアプリの立ち上げまでの流れを見てみました。
あとは、コントローラーとか色々作り込んでみてください。
参考になれば嬉しいです。
- 投稿日:2019-12-23T15:07:11+09:00
チーム開発時にクラス名で起きる問題を解決する
はじめに
チーム開発の際にレイアウトの崩れが起きないようにする方法について投稿します。
実行
例えば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さん方は
body
をa-body
と書き換え、Bさんの方はb-body
に書き換えています。
こうすれば両方のビューファイルが適切に表示されるようになります。
- 投稿日:2019-12-23T15:07:11+09:00
チーム開発時にクラス名が原因で起きる問題を解決する
はじめに
チーム開発の際にレイアウトの崩れが起きないようにする方法について投稿します。
実行
例えば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さん方は
body
をa-body
と書き換え、Bさんの方はb-body
に書き換えています。
こうすれば両方のビューファイルが適切に表示されるようになります。
- 投稿日:2019-12-23T13:49:44+09:00
Railsでテーブルのカラム名を安全にリネームする
テーブルのカラム名を変更した時のメモ
環境
- Rails 5.2.4
- MySQL 5.7.28
背景
テーブルのカラム名がイケてないので変えたいけど、使用箇所が多いので手を出しにくい。
でもカラム名を変えたい。やること
- テーブルのカラム名を変える
- 警告を出すための独自Deprecatorを作成する
- 変更前のカラム名でもアクセスできるようにエイリアスを貼る
警告は出るが変更前のカラム名でもアクセスできるようにしておくことで、
段階的に使用箇所を修正していけるという寸法です。やり方
1. テーブルのカラム名を変える
普通にカラム名を変更するマイグレーションを作ります。
例としてUsersテーブルのnameカラムをfull_nameにリネームします。app/db/migrate/rename_name_to_users.rbclass RenameNameToUsers < ActiveRecord::Migration[5.2] def change rename_column :users, :name, :full_name end end2. 警告を出すための独自Deprecatorを作成する
app/supports/deprecator/will_be_removed.rbclass Deprecator::WillBeRemoved def deprecation_warning(deprecated_method_name, message) ActiveSupport::Deprecation.warn("`#{deprecated_method_name}` will be removed. #{message}") end end3. 変更前のカラム名でもアクセスできるようにエイリアスを貼る
app/models/user.rbclass 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.rbclass 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)参考
- 投稿日:2019-12-23T12:47:08+09:00
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 モデル)に
enum
とset
のカラムを追加して試してみます。Rails プロジェクトを作成する
$ rails new rails_sandbox $ cd rails_sandboxUser モデルを作成する
name
カラムだけもつUser
モデルを作成します。
enum
とset
のカラムは、後で、migration ファイルを直接編集して、追加します。bin/rails g model User namemigration ファイルを編集する
migration ファイルを編集して、
enum
カラムのgeneration
とset
カラムのlearning
を追加します。db/migrate/20191221002717_create_users.rbclass 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 endseed データを作成する
機能を試すためには、作る必要は、無いのですが、一応、 seed データを1件作成します。
db/seeds.rbUser.create(name: 'Dave', generation: 'gradeschool', learning: 'english,swimming')マイグレーションを実行する
マイグレーションを実行します。
shell
$ bin/rails db:create db:migrate
スキーマファイルを確認する
できたスキーマファイルを確認します。
db/schema.rbActiveRecord::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 endRails 5 では
Rails 5.2.4 では、スキーマファイルは以下のようになってしまいます。
generation
とlearning
のカラムが string になってしまい、ENUM と SET であることがわからなくなってしまいます。db/schema.rbActiveRecord::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 endMySQL で直接確認する
なお、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.rbclass User < ApplicationRecord enum generation: %i[baby toddler preschool gradeschool teen young_adult] end試したソース
https://github.com/suketa/rails_sandbox/tree/try113_mysql_enum
参考情報
- 投稿日:2019-12-23T12:30:42+09:00
rails indexってなんだ
indexってなんだ
検索速度をあげるモノ
なんで上がるのか
検索して行く時通常は、上から検索されていく。
しかし、indexをはると、userテーブルの場合、アルファベット順に並び替えてくれるらしい。
こうゆうことか?
青山で検索する
#index貼らない 神田 青木 田中 青山 ## データが煩雑なので、上から検索するしかない # index貼る 青木 青山 神田 田中 ## アルファベット順だからあ行の中で検索がかけられるのかな?メリット・デメリット
- デメリットは書き込み速度が遅くなるらしい。
参考記事
- 投稿日:2019-12-23T12:05:23+09:00
#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 endOriginal by Github issue
- 投稿日:2019-12-23T12:05:21+09:00
#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 endOriginal by Github issue
- 投稿日:2019-12-23T11:58:03+09:00
Railsでfullcalendarを使ってみる(Ajax通信でイベント登録)
概要
gemのfullcalndarを使ってイベントの登録をAjax通信でできるようにする。
コード
- 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.rbdef new @event = Event.new render plain: render_to_string(partial: 'form_new', layout: false, locals: { event: @event }) end
- イベント登録フォーム
_form_new.html.slimh1 新規イベント追加 = 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通信でイベント登録することができた。
- 投稿日:2019-12-23T09:26:47+09:00
byebugでよく使うコマンド
- 投稿日:2019-12-23T07:56:42+09:00
Railsチュートリアル 第13章 ユーザーのマイクロポスト - Micropostモデル
基本的なモデル
Micropostモデルが持つ属性
Micropostモデルの基本となる属性は以下の2つです。
content
属性
- マイクロポストの内容
user_id
属性
- 特定のユーザーと、当該マイクロポストを関連付ける
実際には、Railsにより自動生成される属性もあるため、Micropostモデルの全体像は以下のようになります。
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.rbclass Micropost < ApplicationRecord belongs_to :user end
user:references
やbelongs_to :user
という、見慣れないコードが存在しますね。大まかには、「RDBのインデックスと外部キー参照が定義されたuser_id
カラムを自動追加し、UserとMicropostを紐付けする準備を行う」という意味合いのコードです。これらのコードが意味する事柄については、後の「User/Micropostの関連付け」の項で、より詳しく解説していきます。Micropostモデルのマイグレーション
上述
rails generate model
コマンドで生成されたMicropostモデルのマイグレーションは、初期状態では以下のようになっています。db/migrate/[timestamp]_create_microposts.rbclass 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:references
やbelongs_to :user
と同様、UserとMicropostの紐付けに関するコードです。また、
t.timestamps
というコードが自動で追加されています。「created_at
属性およびupdated_at
属性の定義と、その値の自動保存」を実装するためのコードです。Userモデルのマイグレーションでも、同様のコードが登場していましたね。Micropostモデルのマイグレーションに、新たなインデックスを追加する
Micropostモデルにおいては、「
user_id
に関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出す」という主要なユースケースが存在します。当該ユースケースを効率よく実行できるようにするために、必要なインデックスを追加していきましょう。すなわち、「user_id
属性とcreated_at
属性の組に対応するインデックス」ですね。db/migrate/[timestamp]_create_microposts.rbclass 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_at
とupdated_at
) には何が入っているでしょうか?>> micropost[:user_id] = 1 => 1 >> micropost[:content] = "Lorem ipsum" => "Lorem ipsum" >> pp micropost[:created_at] nil >> pp micropost[:updated_at] nil
created_at
とupdated_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.rbrequire '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 endMicropostモデルに対する最初のテストの実行
# 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_id
とcontent
が空になっている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" * 1412.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: trueMicropostモデルの生成に際して、以上のような見慣れないコードが登場しました。これらはどのような意味を持つのでしょうか。その解説です。
Micropost belongs to User
MicropostとそのUserは、
belongs_to
(1対1)という関係性があります。User has many Microposts
UserとそのMicropostには、
has_many
(1対多)という関係性があります。モデル定義に記述する、
belongs_to
とhas_many
まず、Micropostモデルに
belongs_to :user
というコードが必要になります。ただ、このコードは、Micropostモデルの生成時点で自動で生成されています。app/models/micropost.rbclass Micropost < ApplicationRecord belongs_to :user # ...略 endさらに、Userモデルに
has_many :microposts
というコードが必要になります。こちらは手動で追加する必要があります。app/models/user.rbclass User < ApplicationRecord + has_many :microposts ...略 end
User/Micropost関連メソッド
上述の
belongs_to
やhas_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
に紐付いていて、id
が1
であるマイクロポストを検索するMicropostモデルに対するテストの
setup
メソッドを、慣習的に正しいマイクロポストの生成方法に書き換えていく関連するモデルに
belongs_to
とhas_many
を実装すれば、慣習的に正しいマイクロポストの生成を実装できるようになります。test/models/micropost_test.rb
のsetup
メソッドを、慣習的に正しいマイクロポストの生成方法に書き換えていきましょう。test/models/micropost_test.rbrequire '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 endMicropostモデルに対するテストの
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_id
がnil
であるのに、@micropost.valid?
がtrue
を返してしまっている」という失敗ですね。Micropostモデルに、ユーザーの存在性を検証するバリデーションを追加する
先ほどの問題を解消するためには、Micropostモデルに、ユーザーの存在性を検証するバリデーションを追加する必要があります。
app/models/micropost.rb
のコードの変更点は以下のとおりです。app/models/micropost.rbclass 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
を投げてきていますね。「
.find
にActiveRecord::Base
を継承したオブジェクトそのものを渡すとArgumentError
を投げる」というのは、Rails 5.1 以降の仕様となります。 - rails/CHANGELOG.md at v5.1.0 · rails/rails · GitHubRaise ArgumentError when passing an ActiveRecord::Base instance to .find, > .exists? and .update. Rafael Mendonça França3.1.
user == micropost.user
を実行した結果はどうなるでしょうか?>> user == micropost.user => true3.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
がいくつか発行されている
- 削除対象のレコードを確定するために、当該ユーザーの
id
をWHERE
句に持つ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.destroy
がActiveRecord::InvalidForeignKey
というエラーで失敗します。
- 投稿日:2019-12-23T07:39:06+09:00
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.rbrequire '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.rbclass 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無事テストが成功するようになりました。
- 投稿日:2019-12-23T07:39:06+09:00
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.rbrequire '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.rbclass 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無事テストが成功するようになりました。
- 投稿日:2019-12-23T01:44:16+09:00
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]"}参考
- Pluggable Log Formatter by Tensho · Pull Request #4093 · mperham/sidekiq
- Logging · mperham/sidekiq Wiki
ジョブ毎のログレベル指定が可能に
ワーカーの
.set
メソッドの引数でlog_level
を指定することで、特定のジョブのログの出力レベルを指定できるようになりました。MyWorker.set(log_level: :debug).perform_async(...)SidekiqはデフォルトでRubyの標準ライブラリのLoggerを使用しており、
log_level
で指定できるのは、:unknown
、:fatal
、:warn
、:info
、:debug
になります。参考
- Support job-specific log levels by fatkodima · Pull Request #4287 · mperham/sidekiq
- Job-specific log levels · Issue #4286 · mperham/sidekiq
- Logging · mperham/sidekiq Wiki
ジョブのタグ付けが可能に
ワーカーのクラスの
sidekiq_options
にtags
を指定することで、ジョブのタグ付けができるようになりました。class MyWorker include Sidekiq::Worker sidekiq_options tags: ['bank-ops', 'alpha'] ... end参考
- Support job tags by fatkodima · Pull Request #4280 · mperham/sidekiq
- Support for ad-hoc job tags? · Issue #4073 · mperham/sidekiq
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のサービスの設定、起動や停止コマンドに関しては、この記事の次の節のアップグレードの部分を参考にしてください。
logfile
、pidfile
コマンド引数の廃止上記のデーモン化の廃止に伴って、ログの出力や、プロセスIDの保持をSystemdなどのサービス側で行うようになったので、
sidekiq
コマンドからlogfile
、pidfile
コマンド引数が削除されました。デフォルトのシャットダウン時間が8秒から25秒に変更に
HerokuのDynoとAmazon ECS コンテナがアプリケーションのシャットダウンで30秒のタイムアウトを使用するようになったため、Sidekiqも8秒から25秒にタイムアウト時間を伸ばしました。以前の挙動に戻すには、オプション
-t 8
をsidekiq
コマンドに対して指定します。
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%実行速度が向上しているそうです。
参考
- Optimize cloning of job payload by fatkodima · Pull Request #4303 · mperham/sidekiq
- Speedup UnitOfWork#queue_name by fatkodima · Pull Request #4299 · mperham/sidekiq
- Reduce allocated objects by fatkodima · Pull Request #4269 · mperham/sidekiq
アップグレード方法
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
- 投稿日:2019-12-23T00:54:31+09:00
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/roomscript.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/271d903237b41dffcc6dRails側のコード
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.rbRails.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 endrooms_controller.rbclass RoomsController < ApplicationController def show end end結果
これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。残念なお知らせ
相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です?♂️
最後に
今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。
- 投稿日:2019-12-23T00:54:31+09:00
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/roomscript.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/271d903237b41dffcc6dRails側のコード
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.rbRails.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 endrooms_controller.rbclass RoomsController < ApplicationController def show end end結果
これでうまく動くとこんな感じです。
4つのウィンドウを開いてテストしています。残念なお知らせ
相手が部屋から出た時の挙動にバグがあります。近日中に直す予定です?♂️
最後に
今回はこれで以上です。
同時接続もSkyWayAPIを使うと楽チンです。
コピペでも動くと思うので、是非ご自身でも試してみてください。