5章
Active Jobによる非同期処理
Active Job の基礎 - Railsガイド
のような時間がかかる処理で利用される。
1
|
$ rails g job async_log
|
1
2
3
4
5
6
7
8
9
|
class AsyncLogJob < ApplicationJob
queue_as :default
self.queue_adapter = :resque # オーバーライドできる
def perform(*args)
# Do something later
p 'うぇい'
end
end
|
ActiveJobはデフォルトでasync
アダプターが設定されている。
Railsプロセス中のスプレッドプールでジョブを実行することができる。
1
2
3
4
5
6
|
irb(main):001:0> AsyncLogJob.perform_later
Enqueued AsyncLogJob (Job ID: 866b1eca-b425-4e7b-bc79-3131b9b7b95a) to Async(default)
Performing AsyncLogJob (Job ID: 866b1eca-b425-4e7b-bc79-3131b9b7b95a) from Async(default) enqueued at 2020-09-04T09:15:32Z
=> #<AsyncLogJob:0x00007fbf81b63170 @arguments=[], @job_id="866b1eca-b425-4e7b-bc79-3131b9b7b95a", @queue_name="default", @priority=nil, @executions=0, @exception_executions={}, @provider_job_id="ebb5736a-57fa-4890-97b2-8ba20c224947">
irb(main):002:0> "うぇい"
Performed AsyncLogJob (Job ID: 866b1eca-b425-4e7b-bc79-3131b9b7b95a) from Async(default) in 0.09ms
|
引数を渡すときはperform_later(hoge)
という感じに渡す。
時間差があるときはwait
(○秒・分・時間後)やwait_until
(実行時刻)などを用いて以下のように記述する。
1
2
3
4
5
6
|
irb(main):005:0> AsyncLogJob.set(wait: 5.second).perform_later
Enqueued AsyncLogJob (Job ID: 11fb7339-b726-4534-9889-00f66087c037) to Async(default) at 2020-09-04 09:10:23 UTC
=> #<AsyncLogJob:0x00007fbf8163a658 @arguments=[], @job_id="1f94b1f8-9451-4fe6-84cc-46a3073a6069", @queue_name="default", @priority=nil, @executions=0, @exception_executions={}, @provider_job_id="6fc6a5e6-06bf-4a6e-b632-9c42cc14209d", @scheduled_at=1599210881.7301629>
irb(main):006:0> Performing AsyncLogJob (Job ID: 1f94b1f8-9451-4fe6-84cc-46a3073a6069) from Async(default) enqueued at 2020-09-04T09:14:36Z
"うぇい"
Performed AsyncLogJob (Job ID: 1f94b1f8-9451-4fe6-84cc-46a3073a6069) from Async(default) in 0.1ms
|
asyncアダプターはプロセスを再起動するとジョブが消える。
複数キューを使う場合。
- 特定のジョブを切り分ける
- 重要度に応じてキューを使い分ける
Active Jobを使うか直接利用するか?
ActiveJobを使うといい場合
- 標準的な機能のみ使う
- バックエンド選択をまだしていない
- バックエンドを差し替える可能性がある
- Active Jobに依存した機能を使う
- Action Mailer
- Action Mailbox
- Active Storage
- Active Recordオブジェクトをキューへ追加するときに、デフォルトで使用されているGlobal IDを使った変換処理を利用する
非同期バックエンドを直接使うといい場合
- Active Jobからでは使えない機能がある
- Sidekiqでは外部Gem(Sidekiq Pro, Sidekiq Enterpriseなど)を使うとき
Active Storage
1
2
3
4
|
$ rails active_storage:install
Copied migration 20200905070422_create_active_storage_tables.active_storage.rb from active_storage
$ rails g scaffold user name portrait:attachment
|
1
2
3
|
class User < ApplicationRecord
has_one_attached :portrait
end
|
1
2
3
|
class Book < ApplicationRecord
has_many_attached :pictures
end
|
ActiveRecord::Schema.define(version: 2020_09_05_071839) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.integer "record_id", null: false
t.integer "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.bigint "byte_size", null: false
t.string "checksum", null: false
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "books", force: :cascade do |t|
t.string "title"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
create_table "users", force: :cascade do |t|
t.string "name"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
end
1
2
|
irb(main):002:0> user.portrait
=> #<ActiveStorage::Attached::One:0x00007fa3aabc5c50 @name="portrait", @record=#<User id: 1, name: "admin", created_at: "2020-09-05 09:01:31", updated_at: "2020-09-05 09:01:31">>
|
【rails】Active Storage - Qiita
User -< ActiveStorage::Attachment >- ActiveStorage::Blob
Post -<
ポリモーフィックなのでこんな感じのテーブル構造。
サムネイル
- Gemによって作成する(アップロード時に生成される)
- CarrierWave(アップロード時)
- Shrine(アップロード後に非同期で)
- ActiveStorageの機能(画像URLにアクセスしたときに生成される)
- ImageProcessing
- ImageMagick
1
|
<%= image_tag @user.portrait.variant(resize_to_limit: [100, 100]) %>
|
variant
メソッドに渡したオプションは。ImageProcessingライブラリにメソッドが存在していればそっとのメソッドとして、存在していなければImageMagickのコマンドとして実行される。
@user.portrait.variant(resize_to_limit: [100, 100])
と記述した場合、戻り値はBlobのIDと変換形式を含んだURLが生成される。
1
2
3
4
|
irb(main):004:0> user.portrait.variant(resize_to_limit: [100, 100])
ActiveStorage::Attachment Load (0.4ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 1], ["record_type", "User"], ["name", "portrait"], ["LIMIT", 1]]
ActiveStorage::Blob Load (0.2ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<ActiveStorage::Variant:0x00007fa3aaca0f58 @blob=#<ActiveStorage::Blob id: 1, key: "pf42amrmpitko8t7747cbfj8vsj0", filename: "tangchi-lee-jS_LsILp5hk-unsplash.jpg", content_type: "image/jpeg", metadata: {"identified"=>true, "analyzed"=>true}, byte_size: 304017, checksum: "1NwI5yh330iiI8K9LfhzqA==", created_at: "2020-09-05 09:01:31">, @variation=#<ActiveStorage::Variation:0x00007fa3aaca8ac8 @transformations={:resize_to_limit=>[100, 100]}>>
|
ファイルへのアクセス制限
画像を表示するためのページには制限をかけつつ、画像そのものに対するURLには制限をかけない。
ActiveStorageを利用した場合、リクエストを受けたアプリケーションサーバーはファイル用の期限付きURLを作成し、そこにリダイレクトする(期限はデフォ5分)。
ダイレクトアップロード
省略。
Active STorageの問題点
- validationヘルパーの不足
- cacheの不足
Action Mailer
Action Mailer の基礎 - Railsガイド
1
2
3
4
5
6
7
8
9
|
$ rails g mailer UserMailer
Running via Spring preloader in process 73389
create app/mailers/user_mailer.rb
invoke erb
create app/views/user_mailer
invoke test_unit
create test/mailers/user_mailer_test.rb
create test/mailers/previews/user_mailer_preview.rb
|
1
2
3
4
5
6
|
class UserMailer < ApplicationMailer
def welcome
@name = params[:name]
mail(to: params[:to], subject: "登録完了")
end
end
|
params
で値を取得するのはコントローラと同じ。
UserMailer.with(to: "foo@example.com", name: "foo").welcome.deliver_now
で実行する。
ActiveJobを用いて非同期で送信する場合はdeliver_later
メソッドを使用する。
メールの本文はapp/views/user_mailer/welcome.html.erb
, app/views/user_mailer/welcome.text.erb
を作成して実装する。
普通のViewと同じ。
プレビュー機能
テストでwelcomeメソッドを定義する。
1
2
3
4
5
6
|
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
def welcome
UserMailer.with(to: "foo@example.com", name: "foo").welcome
end
end
|
Action Mailbox
あるメールアドレスにメールを送信されたときに何かをする、など。
自動送信メールへの返信などで使われる。
ActiveJobとActiveStorageが必要。
1
2
3
4
5
6
|
$ rails action_mailbox:install
Copying application_mailbox.rb to app/mailboxes
create app/mailboxes/application_mailbox.rb
Copied migration 20200905114414_create_action_mailbox_tables.action_mailbox.rb from action_mailbox
$ rails db:migrate
|
1
2
3
4
|
Rails.application.configure do
# Prepare the ingress controller used to receive mail
config.action_mailbox.ingress = :sendgrid
end
|
省略。
Sendgrid
→https://actionmailbox:YOUR_PASSWORD@example.com/rails/action_mailbox/sendgrid/inbound_emails
→ApplicationMailbox
のルーティング
→FooMailbox#process
Action Textによるリッチテキスト機能
- WYSIWYGエディタ
- リッチテキストコンテンツを保存するモデル
- リッチテキストを取り扱うヘルパーメソッド
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
$ rails action_text:install
Copying actiontext.scss to app/assets/stylesheets
create app/assets/stylesheets/actiontext.scss
Copying fixtures to test/fixtures/action_text/rich_texts.yml
create test/fixtures/action_text/rich_texts.yml
Copying blob rendering partial to app/views/active_storage/blobs/_blob.html.erb
create app/views/active_storage/blobs/_blob.html.erb
Installing JavaScript dependencies
run yarn add trix@^1.2.0 @rails/actiontext@^6.0.3-2 from "."
yarn add v1.22.5
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
warning " > rails-erb-loader@5.5.2" has unmet peer dependency "webpack@^2.0.0 || >= 3.0.0-rc.0 || ^3.0.0".
warning " > webpack-dev-server@3.11.0" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0".
warning "webpack-dev-server > webpack-dev-middleware@3.7.2" has unmet peer dependency "webpack@^4.0.0".
[4/4] 🔨 Building fresh packages...
success Saved lockfile.
success Saved 2 new dependencies.
info Direct dependencies
├─ @rails/actiontext@6.0.3-2
└─ trix@1.2.4
info All dependencies
├─ @rails/actiontext@6.0.3-2
└─ trix@1.2.4
✨ Done in 13.18s.
Adding trix to app/javascript/packs/application.js
append app/javascript/packs/application.js
Adding @rails/actiontext to app/javascript/packs/application.js
append app/javascript/packs/application.js
Copied migration 20200905120646_create_action_text_tables.action_text.rb from action_text
|
1
2
3
4
|
class User < ApplicationRecord
has_rich_text :content # 追加する
has_one_attached :portrait
end
|
<%= form_with(model: user, local: true) do |form| %>
<!-- 省略 -->
<div class="field">
<%= form.label :content %>
<%= form.rich_text_area :content %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
ドラッグアンドドロップによるファイルアップロード機能
$ brew install imagemagick
1
2
3
4
|
...
# Use Active Storage variant
gem 'image_processing', '~> 1.2'
...
|
image_processing
をbundle installしていなくてもできた……あれ??
注意点
post
と:content
はahs_manyなので、N+1問題に気をつける
- eager_loadして呼び出すメソッド
with_rich_text_#{name}
とwith_rich_text_#{name}_and_embeds
@posts = Post.with_rich_text_content
Action Cable
WebSocketを使ったリアルタイム処理を提供するライブラリ。
1
2
3
4
5
6
7
8
|
$ rails g channel room speak
Running via Spring preloader in process 79960
invoke test_unit
create test/channels/room_channel_test.rb
create app/channels/room_channel.rb
identical app/javascript/channels/index.js
identical app/javascript/channels/consumer.js
create app/javascript/channels/room_channel.js
|
app/channels/room_channel.rb
とapp/javascript/channels/room_channel.js
がWebSocket処理のサーバーサイドとクライアントサイドを受け持つ。
1
2
3
4
5
6
7
8
|
$ rails s
...
Completed 200 OK in 127ms (Views: 114.7ms | ActiveRecord: 1.2ms | Allocations: 21609)
Started GET "/cable" for 127.0.0.1 at 2020-09-06 01:16:48 +0900
Started GET "/cable/" [WebSocket] for 127.0.0.1 at 2020-09-06 01:16:49 +0900
Successfully upgraded to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
RoomChannel is transmitting the subscription confirmation
|
このように表示されたらWebSocketでの接続ができたということ。
(意味としては、/cableにRackサーバーがマウントされていて、WebSocket通信開始時にそこへアクセスしている)
1
2
3
4
5
6
7
8
9
10
11
12
13
|
class RoomChannel < ApplicationCable::Channel
def subscribed # 購読後に呼ばれる
stream_from "room_channel" # ブロードキャスト(接続している全購読者への送信)用のストリーム名を設定
end
def unsubscribed # 購読解除後に呼ばれる
end
def speak(data) # クライアント側から呼び出されたときに実行される。メソッド名は任意
# room_cannelに接続している人全員にメッセージを送信する
ActionCable.server.broadcast("room_channel", { message: data["message"]})
end
end
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
import consumer from "./consumer";
consumer.subscriptions.create("RoomChannel", {
connected() {
// チャネルとの接続時
},
disconnected() {
// チャネルとの切断時
},
received(data) {
// サーバーからデータを受信したとき
alert(data["message"]+"を送信しました");
},
speak: function (message) {
// RoomChannel#speakをWebSocket経由で呼び出す
// thisはSubscription
return this.perform("speak", { message: message});
},
});
|
window.App = consumer.subscriptions.create("RoomChannel", {...
と定義して、App.speak("Hi!")
を実行すると、ActionCable経由でreceived
メソッドが実行される。
チャット
1
2
3
4
5
6
7
8
|
<div id="messages">
<%= render @messages %>
</div>
<br>
<form>
<label>Say something:</label>
<input type="text" data-behavior="room_speaker">
</form>
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
...
connected() {
// チャネルとの接続時
document
.querySelector('input[data-behavior="room_speaker"]')
.addEventListener("keypress", (event) => {
if (event.key === "Enter") {
this.speak(event.target.value);
event.target.value = ""; // inputフィールドを空にする
return event.preventDefault; // Enterキーを押すことによって生じるイベントを無効化する
}
});
},
received(data) {
// サーバーからデータを受信したとき
const element = document.querySelector("#messages");
// div#messagesの内部の最後にdiv.message(部分テンプレート)を追加する
element.insertAdjacentHTML("beforeend", data["message"]);
},
...
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class RoomChannel < ApplicationCable::Channel
...
def speak(data)
message = Message.create!(content: data['message']) # 送信されたメッセージからデータベースに保存する
# messageが入った部分テンプレートHTMLをフロントに送信する
ActionCable.server.broadcast("room_channel", { message: render_message(message) })
end
private
def render_message(message)
# 部分テンプレートから生成したHTML
ApplicationController.render(partial: 'messages/message', locals: { message: message })
end
end
|
ApplicationController.render
でコントローラ外からテンプレートのレンダリングを行うことができる。
送信時のログはこんな感じ。
1
2
3
4
5
6
7
8
9
10
11
|
RoomChannel#speak({"message"=>"てすと"})
(0.1ms) begin transaction
↳ app/channels/room_channel.rb:10:in `speak'
Message Create (0.6ms) INSERT INTO "messages" ("content", "created_at", "updated_at") VALUES (?, ?, ?) [["content", "てすと"], ["created_at", "2020-09-06 05:38:21.300677"], ["updated_at", "2020-09-06 05:38:21.300677"]]
↳ app/channels/room_channel.rb:10:in `speak'
(1.1ms) commit transaction
↳ app/channels/room_channel.rb:10:in `speak'
Rendered messages/_message.html.erb (Duration: 0.1ms | Allocations: 9)
[ActionCable] Broadcasting to room_channel: {:message=>"<div class=\"message\">\n てすと\n</div>\n"}
RoomChannel transmitting {"message"=>"<div class=\"message\">\n てすと\n</div>\n"} (via streamed from room_channel)
RoomChannel transmitting {"message"=>"<div class=\"message\">\n てすと\n</div>\n"} (via streamed from room_channel)
|
注意点など
アダプターはデフォルトで、開発・テスト環境ではasync、本番環境ではredisを使う設定になっている。
config/cable.yml
参照
その他、スタンドアローン構成にする設定、WebSocketの認証認可処理、テスト。省略。