This page looks best with JavaScript enabled
⚠️

『パーフェクトRuby on Rails』5章

 ·   ·  ☕ 9 分で読めます
✏️

5章

Active Jobによる非同期処理

Active Job の基礎 - Railsガイド

  • メール送信
  • CSVファイルを作る

のような時間がかかる処理で利用される。

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

フォーム

1
<%= @post.content %>

表示

ドラッグアンドドロップによるファイルアップロード機能

$ 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.rbapp/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メソッドが実行される。

Hi!

チャット

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)

Image from Gyazo

注意点など

アダプターはデフォルトで、開発・テスト環境ではasync、本番環境ではredisを使う設定になっている。
config/cable.yml参照

その他、スタンドアローン構成にする設定、WebSocketの認証認可処理、テスト。省略。

Share on

END
END
@aiandrox

 
目次