Tech Racho エンジニアの「?」を「!」に。
  • 開発

新しいRailsフロントエンド開発(3)Webpackの詳細、ActionCableの実装とHerokuへのデプロイ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

新しい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つ作成してデータベースに保存し、authortextが正しく設定されたmessageパーシャルをレンダリングして、生成されたHTMLを「chat」チャンネルでブロードキャストするということを意味します。

ここでご注意いただきたいのは、app/channelsの内部からはApplicationControllerrenderメソッドにも、コンポーネントをレンダリングするカスタム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の標準コールバック(connecteddisconnectedreceived)を定義するオブジェクトです。ここで必要なのは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)の種類を表す単なるテキストです。
  • 最後に、モジュールからsendMessagesetCallbackを両方とも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ログを調べると次のように表示されます。

chat_channelの最初のブロードキャスト

chat_channelの最初のブロードキャスト

これで、フォームを送信すると、バックエンドで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コマンドが使えるようになります。

アプリのデプロイを準備するうえで必要な点がいくつかあります。

最初に、既存のProcfilerails serverwebpack-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.devforeman 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.ymlproductionに、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がない場合、次のようにnpmrails-ujsを再インストールしないとUnobtrusive JavaScriptを理解できなくなります(link_tomethod: :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の作成とメンテナンスは、フランスの開発会社OuvragesEtamin Studioが、Evil Martiansとは独立に行っています。


お読みいただきありがとうございました!

本チュートリアル3部作(全貌を理解するにはすべてお読みください)では、Webpackerを完全に採り入れてアセットパイプラインを取り除き、Reactなどのフロントエンドフレームワークについて学ばずに、できるだけRailsの組み込みツールを用いて「コンポーネント」のコンセプトに基づいてRailsのフロントエンドコードを編成する方法を学びました。本チュートリアルで作ったシンプルなチャットアプリは、Evil Martiansによって現実のプロジェクトで積極的に用いられている方法でデプロイ可能です。

本チュートリアルを進めるうえで何か問題がありましたら、お気軽にGitHubのissueを開いてお知らせください。


Part 1 | Part 2 | Part 3

スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

新しいRailsフロントエンド開発(2)コンポーネントベースでアプリを書く(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。