- 新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)
- 新しいRailsフロントエンド開発(2)コンポーネントベースでアプリを書く(翻訳)
- 新しいRailsフロントエンド開発(3)Webpackの詳細、ActionCableの実装とHerokuへのデプロイ(翻訳)
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Evil Front Part 3: Modern Front-end in Rails
- 原文公開日: 2017/12/20
- 著者: Andy Barnov、Alexey Plutalov
- サイト: Evil Martians
新しいRailsフロントエンド開発(3)Webpackの詳細、ActionCableの実装とHerokuへのデプロイ(翻訳)
前書き
本記事は、フロントエンドのフレームワークに依存しないRailsプレゼンテーションロジックを現代的かつモジュール単位かつコンポーネントベースで扱う方法を独断に基いて解説するガイドです。3部構成のチュートリアルで、例を元に最新のフロントエンド技術の最小限に学習し、Railsフロントエンド周りをすべて理解しましょう。
Part 2までのおさらい
こちらもお読みください:
Part 2までに、「コンポーネント」アプローチを用いてチャットアプリの骨格を組み立てました。各コンポーネントは、アプリのfrontend
部分の内部のフォルダとして表現されており、それぞれが.erb
パーシャル、.css
スタイルシート、.js
スクリプトの3つのファイルで構成されています。現時点でJavaScriptコードに含まれているのは、ネストしたコンポーネントを読み込むためのimport
文だけです。これによってすべてのパーツがapplication.js
のエントリポイントとして含まれるようになり、Webpacker gemでこれらをまとめてCSSやJSのバンドルをビルドできるようになっています。
今回のチュートリアルの最後の章では、JavaScriptを用いてチャットが動くようにする予定です。公式のRailsドキュメントは未だにSprocketsやCoffeeScriptが前提になっているため、ActionCableをES6モジュールから用いる方法についても解説します。
「sprockets抜き」アプリが完成したら、Herokuにデプロイします。
完成版のEvil Chatアプリのコードをすぐにもご覧になりたい場合はGitHubのリポジトリをどうぞ。
ご存知かと思いますが、ActionCableの理解はそれほど簡単ではありませんので、できるだけ手順ごとに動作を明示的に解説してみます。経験豊富な開発者の知性を過小評価する意図はありませんのでご了承ください。途中でActionCableを十分理解できた方は、解説をスキップしてコードスニペットまで進めてください。コードスニペットは通常のSprockets実装と異なっているため、Railsガイド(訳注: 英語版Edgeガイドです)のコード例はWebpackで動作しません。
ActionCableのRuby部分
まずは、チャットのチャンネルの生成が必要です。
$ rails g channel chat
これでapp/channels/
の内部にchat_channel.rb
というファイルが作成されます。
ActionCableはRailsでWebSocketsと統合されており、サーバー側のロジックをRubyで書き、クライアント側のロジックをJavaScriptで書くことができます。ActionCableのクールな点は、ブラウザ上で実行されるJavaScriptから、サーバー側のRubyメソッドを呼び出せることです。chat_channel.rb
はチャット用のメソッドを定義する場所であり、全登録ユーザーのデータのストリーミング(本チュートリアルの場合、新しいメッセージでDOMを更新する少量のHTMLです)も担当します。
チャンネル固有の機能を扱う前に、ActionCableが認証済みユーザーのみをブロードキャストすることを担保する必要があります。アプリ作成時に生成したapp/channels/application_cable
フォルダの内部を見ると、WebSockets認証を担当するconnection.rb
ファイルがあります。Part 2の認証が非常にシンプルだったことを思い出しましょう。session
ハッシュ内に単にusername
キーを作成し、ユーザーがどんなusernameでも使えるようになっていました。以下は今回必要なコードです。
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = request.session.fetch("username", nil)
reject_unauthorized_connection unless current_user
end
end
end
ここではセッションからusernameを取り出そうとしています。usernameがない場合、接続を拒否します。実際には、新しいユーザーは「log in」画面を経由するまでActionCableのブロードキャストを受け取りません。
続いてchat_channel.rb
に手を加えます。
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat"
end
# サーバーがメッセージ形式のコンテンツを受け取ると呼び出される
def send_message(payload)
message = Message.new(author: current_user, text: payload["message"])
if message.save
ActionCable.server.broadcast "chat", message: render(message)
end
end
private
def render(message)
ApplicationController.new.helpers.c("message", message: message)
end
end
subscribed
メソッドは接続が認証されると呼び出されます。stream_from
は、「chat」チャンネルでブロードキャストされるどんなメッセージでもクライアントに到達できるということを表します。
このsend_message
メソッドは最も興味深い部分です。今はアプリのRuby部分の内部なので、ActiveRecordと直接やり取りできます。私たちのシンプルな例では、「メッセージを1件送信する」というのは、Messageモデルの新しいインスタンスを1つ作成してデータベースに保存し、author
とtext
が正しく設定されたmessage
パーシャルをレンダリングして、生成されたHTMLを「chat」チャンネルでブロードキャストするということを意味します。
ここでご注意いただきたいのは、app/channels
の内部からはApplicationController
のrender
メソッドにも、コンポーネントをレンダリングするカスタムc
ヘルパーにも直接アクセスできないという点です。そこで、ヘルパーを間接的に呼び出す別のrender
定義を作成します。そのために、ApplicationController
のインスタンスを1つ作成して、ApplicationHelper
モジュールで定義したヘルパーにアクセスします。今私たちが関心を抱いているのはc
ヘルパーなので、ApplicationController.new.helpers.c
でアクセスします。
ActionCableのJavaScript部分
純粋なrails new
で生成したRails 5.1アプリでは、ActionCableのクライアント部分(JavaScriptで記述されている)はアセットパイプラインでインクルードされます。私たちがapp/assets
を削除したときに、この標準的な実装も効率よく取り除かれていますので、ActionCableのJavaScriptライブラリを再度インストールする必要があります。今度はYarn経由でnpm
からインストールします。
$ yarn add actioncable
さて、WebpackでActionCable(あるいは別のJavaScriptライブラリ)を用いる場合の特別な点とは何でしょうか?
Sprocketsを使うと、JavaScriptファイルが結合後に共通のスコープで共有されたものを扱うことになるため、this.js
で宣言されたものは何であってもthis.js
が事前に読み込まれていればその後のthat.js
からアクセスできます。Webpackはこの点が違っており、より抑制の効いたアプローチを採用しています。Ross Kaffenbergerの良記事から引用します。
これは、ブラウザでのJavaScriptバンドル方法のパラダイムがSprocketsとWebpackで根底から異なっていることを理解するうえで役立ちます。
この違いは、Webpackの動作の中核部分にあります。Webpackでは、SprocketsのようにJavaScriptコードをグローバルスコープで結合するのではなく、個別のJavaScriptモジュールをクロージャ経由で個別のスコープに仕切っているので、モジュール間のアクセスをimport
経由で宣言することが必須になります。これらのJavaScriptモジュールは、デフォルトでは一切グローバルスコープには公開されません。
私たちはES6のexport
文やimport
文を多用しなければならなくなります。しかし私たちは、最初にfrontend
内にclient
フォルダを作成しています。ActionCableの(JavaScript)クライアントはここに置きます。
$ mkdir frontend/client
$ touch frontend/client/cable.js
cable.js
は、「cable」コネクションのconsumerインスタンスの作成に使われます。Sprocketsで書かれた標準的なRailsサンプルでは、これはグローバルなApp
オブジェクトの一部として作成されるのが普通です。公式のActionCableドキュメントや世にあまたあるチュートリアルでは次のようなコードが使われています。
// これはコピペしてはいけません!
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);
このコード例を、私たちのモジュールベースのシステムに合わせて調整する必要があります。また、consumerが作成済みの場合には既存のコネクションを再利用してcreateConsumer
関数の再呼び出しを避けたいと思います。そのためにグローバルなwindow
変数を使いたくないので、別のアプローチを採用します。私たちのcable.js
モジュールは、コネクションのインスタンスをconsumer
内部変数に保持し、createChannel
関数をexportします。この関数は既存のconsumerをchatチャネルにサブスクライブするか、新しいconsumerインスタンスを1つ作成します。それではコードをcable.js
に書いてみましょう。
// frontend/client/cable.js
import cable from "actioncable";
let consumer;
function createChannel(...args) {
if (!consumer) {
consumer = cable.createConsumer();
}
return consumer.subscriptions.create(...args);
}
export default createChannel;
createChannel
関数は汎用なので、consumerを特定のチャンネルにサブスクライブしたいどんな箇所からでも正しい引数を与えて使うことができます。したがって、サーバー側のchat_channel.rb
のRubyコードに対応するクライアント側JavaScriptコードとなるファイルが別途必要になります。このファイルをchat.js
と呼ぶことにしましょう。
$ touch frontend/client/chat.js
コードは次のとおりです。
// frontend/client/chat.js
import createChannel from "client/cable";
let callback; // 後で関数を保持するための変数を宣言
const chat = createChannel("ChatChannel", {
received({ message }) {
if (callback) callback.call(null, message);
}
});
// メッセージを1件送信する: `perform`メソッドは、対応するRubyメソッド(chat_channel.rbで定義)を呼び出す
// ここがJavaScriptとRubyをつなぐ架け橋です!
function sendMessage(message) {
chat.perform("send_message", { message });
}
// メッセージを1件受け取る: ChatChannelで何かを受信すると
// このコールバックが呼び出される
function setCallback(fn) {
callback = fn;
}
export { sendMessage, setCallback };
この部分は難解なので、説明のテンポを落としてじっくり見てみましょう。
細かな動作は次のようになっています。
cable.js
からcreateChannel
関数をimportします。- この関数に2つの引数を与えて呼び出します。チャンネルの名前(Rubyの
some_channel
のような名前はJavaScriptではSomeChannel
とし、両者の命名慣習を壊さないようにしなければならない点に注意)と、ActionCableの標準コールバック(connected
、disconnected
、received
)を定義するオブジェクトです。ここで必要なのはreceived
コールバックのみです。このコールバックは、ブロードキャストされたデータをJavaScriptオブジェクトの形式として引数として持つチャンネルブロードキャストをconsumerが受け取ると呼び出されます(RubyとJavaScriptオブジェクトの変換はRails自身が行います)。 - ここから少々ややこしくなります。
message
オブジェクトを受信したら、何らかの関数を呼び出す必要があります。コンポーネントのこの部分は、必要に応じてDOMを扱う方法を責務上知っていなければならないので、この関数をここで定義したくありません。そこで、setCallback
という汎用的な関数を1つ作成します。この関数は、正しいコンポーネントから呼び出されると、メッセージ受信後に呼び出したいコンポーネント固有のあらゆる関数を保存するcallback
変数を変更します。 sendMessage
は、コネクションインスタンスのperform
メソッドを呼び出します。ここはActionCableの最も魔術的な部分であり、JavaScriptからRubyのメソッドを呼び出します。これはchat_channel.rb
からsend_message
メソッドをトリガして、message
オブジェクトを引数として渡します。この{ message }
という記法は、ES6の{ message: message }
のショートハンドです。ここではペイロードがmessage
キーの下にあることを前提としています。このコンテキストにおける「message」は、メッセージフォームに含まれるユーザー(visitor)の種類を表す単なるテキストです。- 最後に、モジュールから
sendMessage
とsetCallback
を両方ともexportし、後でコンポーネントで使えるようにします。
明確なメッセージを1件送信する
それでは最初にメッセージの送信を扱いましょう。この責務を引き受けるべきコンポーネントはどれでしょうか?Part 2では、個別のメッセージ用にmessage
コンポーネントを、メッセージのリスト用にmessages
コンポーネントを、テキストの送信にはmessage-form
を使いました。ブルーの大きな「Send」ボタンはmessage-form
の内部にあるので、ここに置くのが正解です。frontend/components/message-form/message-form.js
のコードを変更しましょう。
// frontend/components/message-form/message-form.js
// client/chat.jsからsendMessageをimportする必要がある
import { sendMessage } from "client/chat";
import "./message-form.css";
const form = document.querySelector(".js-message-form");
const input = form.querySelector(".js-message-form--input");
const submit = form.querySelector(".js-message-form--submit");
function submitForm() {
// sendMessageを呼び出し、その結果Rubyのsend_messageメソッドが呼ばれて
// ActiveRecordでMessageインスタンスが作成される
sendMessage(input.value);
input.value = "";
input.focus();
}
// コマンドキー(またはCtrlキー)+Enterでメッセージを送信できる
input.addEventListener("keydown", event => {
if (event.keyCode === 13 && event.metaKey) {
event.preventDefault();
submitForm();
}
});
// ボタンをクリックして送信してもよい
submit.addEventListener("click", event => {
event.preventDefault();
submitForm();
});
動作を確認しましょう。もう一度サーバーを起動して認証し、メッセージボックスに適当なテキストを入力してコマンド+Enterキーを押し、Railsログを調べると次のように表示されます。
これで、フォームを送信すると、バックエンドでMessage
インスタンスが新たに1つ作成され、メッセージのパーシャルが生成されてActionCableですべての登録ユーザーにブロードキャストされます。残るは、HTMLで受け取った文字列をDOMに挿入してページに表示するだけです。
受信したメッセージ
新しいメッセージをその都度動的にページに挿入する責務を負うのはmessages
コンポーネントです。元々このコンポーネントはデータベース内のすべてのメッセージをレンダリングする責務を負っていることがその理由です。
ここで行う必要があるのは、chat.js
モジュールのsetCallback
関数を呼び出して、ブロードキャストされたメッセージを引数として受け取る別の関数に渡すことだけです。もう一度おさらいしましょう。chat.js
モジュールは、chatチャンネルで何かがブロードキャストされると、常にreceived
イベントに対して何か操作を行える状態になりますが、正確な操作については(明示的に示すまでは)関知しません。これを行うには、実行したい関数をsetCallback
に渡します。
messages.js
の新しいコードは次のとおりです。
// frontend/components/messages/messages.js
import { setCallback } from "client/chat";
import "components/message/message";
import "./messages.css";
const messages = document.querySelector(".js-messages");
const content = messages.querySelector(".js-messages--content");
function scrollToBottom() {
content.scrollTop = content.scrollHeight;
}
scrollToBottom();
// ActionCableで新しいメッセージを1件受け取るたびに
// このコード片を呼び出すよう`chat.js`に伝える
setCallback(message => {
content.insertAdjacentHTML("beforeend", message);
scrollToBottom();
});
ここでchat.js
モジュールに渡しているのは、メッセージのリストを上にスクロールして、新しいメッセージのHTMLを下に追加するだけのシンプルな関数です。これで、2種類の異なるブラウザを立ち上げて、それぞれ別のニックネームでログインしてチャットしてみると、以下のようにすべて正常に動作していることがわかります。
Herokuにデプロイする
いよいよアプリをHerokuにデプロイして、本番環境でもチャットできることを確認しましょう。最初にHerokuアカウントを用意し、自分のPCにHeroku CLIがインストールされていることを確認します。これでターミナルでheroku
コマンドが使えるようになります。
アプリのデプロイを準備するうえで必要な点がいくつかあります。
最初に、既存のProcfile
(rails server
やwebpack-dev-server
の実行に使われる)をProcfile.dev
に変更します。dev
なしのProcfile
はHerokuで使います。また、本番環境ではwebpack-dev-server
が実行されないようにしたいと思います。
Procfile.dev
は次のようになります。
server: bin/rails server
assets: bin/webpack-dev-server
メインのProcfile
にはserver
行だけを残します。
server: bin/rails server
注意: この変更を行った後でアプリをlocalhostで実行したい場合は、hivemind Procfile.dev
(使っているプロセスマネージャによってはovermind s -f Procfile.dev
やforeman run -f Procfile.dev
など)で起動する必要があります。
次に、ビルドタスクがHeroku側で認識されるようにする必要があります。
RubyアプリをプッシュしていることがHeroku側で認識されると、assets:precompile
を起動しようとします。これはアセットパイプラインでアセットをビルドするのに昔から使われているタスクです。しかしWebpackerを使う場合は、別のyarn:install
タスクとwebpacker:compile
タスクを呼び出す必要があります。
最新バージョンのRailsとWebpacker(3.2.0)は、Sprocketsを無効にしてあってもassets:precompile
でSprocketsを起動できます(試しにローカルでbundle exec rails assets:precompile
を実行してみると、パッケージがビルドされてpublic
フォルダに置かれる様子を見ることができます)。
ただし本記事執筆時点では、Rails 5.1.4とWebpacker 3.2.0による「Sprockets抜き」アプリのHerokuでのビルドは失敗しました。Vladimir Dementyevのおかげで回避方法がわかりました。Rakefile
で明示的にassets:precompile
を定義する必要があります。
# Rakefile
require_relative 'config/application'
# この行を追加
Rake::Task.define_task("assets:precompile" => ["yarn:install", "webpacker:compile"])
Rails.application.load_tasks
RailsとWebpackerのコントリビューターは現在も本番環境でのアセットのビルドをできるだけ楽にする最善の方法を模索中なので、この部分は将来変更される可能性があります。すべてが落ち着いて、追加のハックなしでHerokuでアプリをビルドできるようになれば理想です。
また、HerokuでActionCableを動かすためには本番でRedisを有効にする必要もあります。Gemfileのgem 'redis', '~> 3.0'
のコメントを解除してください(注意: バージョン4はRails 5.1のActionCableで認識されません: 5.2で修正予定)。
config/cable.yml
のproduction
に、url
の正しい設定が含まれていることを確認します。
development:
adapter: async
test:
adapter: async
production:
adapter: redis
url: <%= ENV["REDIS_URL"] %>
channel_prefix: evil_chat_production
REDIS_URL
環境変数に正しいRedisサーバーのURLを設定するために、Heroku Redisアドオンを使います。
そして最後に、config/environments/production.rb
に以下の行を追加してください。
config.secret_key_base = ENV["SECRET_KEY_BASE"]
secrets.yml
をソースコントロールにコミットしない場合は、この行が必要です(ただしRailsの「encrypted secrets」を設定していない場合はこの行を追加すべきではありません)。
ついにデプロイ準備ができました。
$ heroku create YOUR_APP_NAME
$ heroku addons:create heroku-redis:hobby-dev
数分後にHeroku Redisアドオンが作成されたら(heroku addons:info
でステータスを確認できます)、次を実行します。
$ git add . && git commit -m "prepare for deploy"
$ git push heroku master
アプリのビルドが完了したら、heroku run rails db:migrate
を実行してproductionのデータベースを準備します。すべてうまくいけば、デプロイしたアプリをheroku open
でブラウザに表示できます。
うまく動いた方、おめでとうございます!
補足: 静的なアセットについて
今回ビルドしたアプリでは静的なアセットを使っていませんが、Webpackerで静的なアセットを扱う方法についても触れておく価値があると思います。ここでは画像を扱いたいとしましょう。最初に、画像の置き場所を決める必要があります。frontend
フォルダの下のimages
フォルダにまとめて置くか、画面の表示を担当するコンポーネントの下に個別の画像を置きます。画像をどこに置くとしても、画像がWebpack manifestに現れるようにするには、画像をJavaScriptにimport
して最終的にapplication.js
のエントリポイントに含まれるようにする必要があります。
app/assets/images
の下にある既存の画像をすべてfrontend/static
に素早く移動してstatic.js
エントリポイントにリンクする方法については、Gistをご覧ください。
画像の数が多すぎて、ヘルパーモジュールのバンドル項目を増やしたくない場合(Webpackのfile-loader
は、ファイルごとにパスを返す責任だけを持つモジュールを1つ生成します)、packs
の下に個別のエントリポイントを作成して(static.js
などのように)呼び出すこともできます。
そして、asset_pack_path
ヘルパーとimage_tag
を組み合わせると、正しい<img src="">
を生成できます。
画像とコンポーネントをまとめる方法は次のような感じになります。
- フォルダ構造:
frontend/components/header
├── _header.html.erb
├── header.css
├── header.js
└── static
└── logo.png
header.js
は次のようになります。
import "./header.css";
import "./static/logo.png"
これで次のようにERBパーシャルに書けます。
<%= image_tag asset_pack_path('./static/logo.png') %>
別の方法としては、image_tag
を使うのを我慢し、代わりにCSSでurl
ヘルパーを用いてWebpackのcss-loaderがデフォルトでプロジェクトに含める画像を直接読み込む方法もあります。これで、次のようにCSSのbackground-
プロパティとして要素に画像を割り当てることができます。
.header {
&--logo {
width: 100px;
height: 100px;
margin-bottom: 25px;
background-image: url("./static/logo.png");
background-size: 100%;
}
}
この方法にする場合、JavaScriptファイルで画像をimport
する必要も生じません。なお、url()
はフォントにも使えます。
プロジェクトのリポジトリには、SVGアイコンをCSSから読み込む例も含まれています。インラインSVGを使いたい場合は、postcss-inline-svg
モジュールを使うこともできます。
「Sprockets抜き」をやってみてわかったこと
ActionCableを使った場合とまったく同様に、RailsでSprocketを無効にすると他のいくつかの部分についてもnpm
で再インストールする必要が生じます。
- Turbolinks
プロジェクトでTurbolinksを再度有効にするには以下のようにします。
$ yarn add turbolinks
// frontend/packs/application.js
import Turbolinks from "turbolinks";
Turbolinks.start();
- UJS
RailsにSprocketsがない場合、次のようにnpm
でrails-ujs
を再インストールしないとUnobtrusive JavaScriptを理解できなくなります(link_to
のmethod: :delete
の設定など)。
$ yarn add rails-ujs
// frontend/packs/application.js
import Rails from "rails-ujs";
Rails.start();
本チュートリアルからヒントを得たプロジェクトの紹介
- Komponentは、本記事で解説した「コンポーネントベースのアプローチ」をRailsプロジェクトに取り入れやすくするgemです。このgemに含まれるジェネレーターは、
frontend
フォルダの作成、Webpacker configの変更、コンポーネント作成を単一のコマンドで行なえます。また、パーシャルにふさわしいテンプレートエンジンを検出したり、コンポーネントごとの「プロパティ」やヘルパーの設定に使える.rb
ファイルでコンポーネントを拡張したりします。
Komponent gemの作成とメンテナンスは、フランスの開発会社OuvragesとEtamin Studioが、Evil Martiansとは独立に行っています。
お読みいただきありがとうございました!
本チュートリアル3部作(全貌を理解するにはすべてお読みください)では、Webpackerを完全に採り入れてアセットパイプラインを取り除き、Reactなどのフロントエンドフレームワークについて学ばずに、できるだけRailsの組み込みツールを用いて「コンポーネント」のコンセプトに基づいてRailsのフロントエンドコードを編成する方法を学びました。本チュートリアルで作ったシンプルなチャットアプリは、Evil Martiansによって現実のプロジェクトで積極的に用いられている方法でデプロイ可能です。
本チュートリアルを進めるうえで何か問題がありましたら、お気軽にGitHubのissueを開いてお知らせください。
スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。