Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

AnyCable 1.0: RubyとGoによるリアルタイムWebの4年間(翻訳)

概要

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

AnyCable 1.0: RubyとGoによるリアルタイムWebの4年間(翻訳)

晴れてAnyCable 1.0のリリースをお知らせできるときがやってまいりました。AnyCableはAction Cableに投入するだけで使えるターボ拡張であり、Action Cableと同じAPIに依存しつつRailsの外部でも動作できます。私の「いかれた(demented: 私の名前Dementyevのもじり)」アイデアを、リアルタイムRubyアプリケーション向けの頑丈なバックボーンとして結実するのに4年もの歳月を要しました。皆さんが本記事でAnyCableの新しい機能を発見し、私たちの成功と失敗から学び、AnyCableの未来を垣間見、そしてRubyとGoを併用して2つの世界にとってベストなものを構築する方法を知っていただければと思います。

AnyCableは、RubyやRailsで構築されたリアルタイムアプリケーションに高パフォーマンスとスケーラビリティをもたらします。低レベルのWebSocketハンドリング処理をRubyからGoに移し、しかもRails(または純粋なRuby)コードベースにあるビジネスロジックをすべて手つかずのままにできるようにしました。言い換えると、AnyCableは生産性を犠牲にすることなくアプリケーションのパフォーマンスを高められるわけです。

AnyCableのアーキテクチャやパフォーマンスの特徴についてはAnyCableの紹介記事を、完全な説明については公式Webサイトをどうぞ。

このプロジェクトを長くやっている間に、Ruby gemとGoバイナリをツールファミリーに編成したり、ひとりでメンテしていたのが多くのコラボレーターやコントリビューターからなる素晴らしいチームに移行したり、200ワード足らずのREADMEを50件を超える記事を擁する立派なドキュメントWebサイトにしたりしました。まあ昔を懐かしむのはこのぐらいにして、本日のトピックについてご説明しましょう。

AnyCable 1.0のハイライト

AnyCable v1の背後に隠れている主なアイデアは、Action CableからAnyCableにできるかぎりスムーズに移行できるようにすることでした。当初はAnyCableをまさにRailsアプリケーション向けのプラグアンドプレイソリューションとして作り始めましたが、これはほとんどのユースケースにおいて真ではありませんでした。多くの人々が潜在的な注意点について長い記事を書いたりissueを投げたり回避方法やハックを共有したりしてくれました。そしてv0.6.0リリースで少し明るい未来へと小さな一歩を進め、そしてv1.0で大きく羽ばたきました!

AnyCable 1.0は、Action CableからAnyCableへの可能な限りスムーズな移行を目指しています。

詳しくは公式のリリースノートに全部書いてありますが、皆さまのためにいくつかハイライトを拾ってみましょう。

インタラクティブなクイックスタート

既存のプロジェクトでAnyCableを使うための設定はなかなか一筋縄でいきませんでした。こうした高度なツールでは「gemを追加してbundle installしておしまい」というアプローチは当てはまりません。WebSocketサーバーをインストールし、development環境とproduction環境のフレームワーク設定を更新し、潜在的な非互換性についてユーザーに通知する必要があります。

その結果「インタラクティブなRailsジェネレータ」(rails g anycable:setup)というアイデアにたどり着きました。以下を含む初期セットアップタスクのほとんどがジェネレータで自動化されます。

  • AnyCableに必要な設定ファイルやセッテイングを追加する
  • プロジェクトでDeviseを使っている場合、AnyCableの認証サポートを事前に設定する
  • anycable-goサーバーのインストール(またはdevelopment環境でDockerを検出したらdocker-compose.ymlにこのサーバーを追加する)
  • アプリケーションをHerokuで実行するための準備(必要な場合)
  • 静的な互換性チェックの実行

AnyCableセットアップジェネレーターを動かしているところ
このスクリプトの設計では「ヒストリカルなAnyCableセットアップを添えてあるissueについてはすべてジェネレーターでカバーする」というルールに従いました。

なので、anycable:setupを実行して何か問題があったらお知らせいただければ修正いたします!

Action Cableとの互換性をさらに向上

私たちは、リモート切断機能のサポート(ActionCable.server.remote_connections.where(user: user).disconnect)や、チャネルのステートをアクション間で維持する機能(通常はAction Cableのインスタンス変数経由で行う)を導入することで、サポート外のAction Cable機能を4つからわずか2つに減らしました。

class RoomChannel < ApplicationCable::Channel
  # AnyCable API similar to attr_accessor
  state_attr_accessor :room

  def subscribed
    self.room = Room.find(params["room_id"])
    stream_for room
  end

  def speak(data)
    broadcast_to room, message: data["message"]
  end
end

当初は、インスタンス変数やattributeリーダー/ライターをうまいこと検出してハイジャックすることでAnyCableステート管理機能にこれらを追加することを構想していました。実際以下のようなアルゴリズムを実装してみたりもしたのです。

  • チャネルクラスのソースコードを解析して既知のインスタンス変数名をすべて抽出する(明示的に定義されているものやattr_{reader|writer|accessor}で定義されているもの)
  • それらの変数名をステートアクセサとして暗黙に登録する(state_attr_accessorを利用)
  • リーダー/ライターによるインスタンス変数の直接利用部分をすべて@room → self.roomのように書き直す(トランスパイルが好きでしょうがないものですから)

ここにどれだけのエッジケースがありうるか想像できますか?幸いその時点で引き返すことが可能だったので、引き返すことに決めました。そしてstate_attr_accessorがAnyCableがAction Cableに追加するメインかつ唯一のAPIメソッドになりました。

互換性に関連するもうひとつの大きな改善は、Rackミドルウェアを用いてRackのリクエスト情報を拡張する機能です。

Rackミドルウェアの典型的なユースケースはDevise認証です。Deviseの背後にはWardenがあり、これが最終的にミドルウェアに依存します。このミドルウェアはWarden::Managerインスタンスを初期化したものをrequest.env["warden"]に保存し、これが後でDeviseで使われます。

v1.0以前は、AnyCable内のリクエストオブジェクトはアプリケーションミドルウェアがまったく考慮されていませんでした。このため、認証のようなよくあるシナリオが以下のコード例のように複雑になっていたのです。

# AnyCable <1.0
def connect
  self.user = find_verified_user || reject_unauthorized_connection
end

def find_verified_user
  app_cookies_key = Rails.application.config.session_options[:key] ||
                    raise("No session cookies key in config")

  env["rack.session"] = cookies.encrypted[app_cookies_key]
  Warden::SessionSerializer.new(env).fetch(:user)
end

AnyCable 1.0でRackミドルウェアがサポートされたことで、上のコードが以下のようにAction Cable向けに既にお使いであろうコードと非常に近いものになりました。

# AnyCable >=1.0
def connect
  self.user = env["warden"].user(:user) || reject_unauthorized_connection
end

この機能によって、request.sessionもAnyCable内でいつでもアクセス可能になりました。おかげで、次にお話しする別の互換性問題の解決にも役立ちました。

AnyCableと相性抜群のStimulusReflex

StimulusReflexおよびその関連プロジェクトであるCableReadyはRuby on Railsコミュニティで続々と人気を得ていますが、無理もないでしょう。いかなるフロントエンドフレームワークにもロックインされることのない「極めてシンプル」「サーバーサイドレンダリング」「HTML over wire」アプローチというアイデアに皆さんが興奮しないわけがありませんよね。

残念ながら、AnyCable v0.6の時点でStimulusReflexを最初に実行してみようとしたところ、「クラシックな」Action Cableアプリでは見たこともないようなissueが続々見つかりました。私はいかなる代償を払ってでもこれらを修正することを決意し、そして今やStimulusReflexExpoはAnyCable上で何の問題もなくすいすい動いています!

新しいデモアプリ

1個のよいサンプルアプリは、100万行のドキュメントに優る。

私たちは最初のリリース時からデモアプリを用意していたのですが、あっという間に役立たずの化け物に変わってしまいました。最も大きな過ちは、1つのコードベースにあらゆるユースケースを全部盛りにしてしまったことと、正しいテストカバレッジもCI設定もなかったことでした(おかげでアップグレードのつらかったこと...)。

そこで、古いアプリを修正するのではなく、まったく新しいアプリをスクラッチから作り直すことに決めました。AnyWork: AnyCable Rails Demoをご覧ください。

AnyCable Railsデモアプリより
構築にRails 6、StimulusTailwindCSSといったモダンなツールを用いたことで、このサンプルアプリはさながら「オムニアプリ」とでもいうべき仕上がりとなりました。つまりアプリのブランチごとにさまざまなバリエーションが用意され、それぞれが異なる利用シナリオを表すようになりましたし、どのバリエーションのプルリクにも詳細な記述を用意してあります。たとえば「From Action to Any」をご覧ください。

そういうわけで、デモアプリはあたかも実際のコードに沿った別の形のドキュメントのようなものになっています。私たちは、ドキュメント記事のほとんどをその記事専用のデモアプリのバリエーションにリンクすることを計画中です。更新情報を見逃したくない方はリポジトリにご登録ください!

Herokuへのデプロイ手順の改良

現在も、AnyCableをHerokuにデプロイするには2つのアプリケーションが必要になります。(Herokuアドオンも含めて)これを回避する方法も検討しつつ、私たちがproductionで蓄積したHerokuの経験を元にして既存のAnyCableドキュメントの改良を進めているところです。

Y Combinatorが設立したスタートアップがHerokuでスケールするために私たちが提供した支援については「Big on Heroku: Scaling Fountain without losing a drop」をご覧ください。

個人的に最も興味深い追加ドキュメントは「Choosing the right formation」です。この章には以下のように、アプリケーションの負荷に応じて必要なdyno数を算出する公式が提供されています。

Heroku formation formula

Heroku設定の算出

Cableとの4年間で得た教訓

私はAnyCable以外にも、RailsのAction Cableテスト、Railsの外の世界に向けたLite Cable、GUIよりもターミナル操作が好きな人向けのAction Cable CLIを手掛けてきました。

この4年というもの、AnyCableを始めとするcableと名の付くものたちに相当な時間を捧げてきました。その間にさまざまな決定を迫られ、中にはその決定で最終的に自分が幸せになりそこねたこともありました。

まぶしく輝く未来を築き上げるには、過去の振り返りが必要です。それでは始めましょう!

「実にクールなプロジェクトだよ」DHH

AnyCableという名前を最初にドキュメントにしたためた日は、今を去ること2016年06月10日のことでした。開催日の迫ったカンファレンス発表のネタをあれこれ考えながらAnyCableをこしらえたのです。当時の私はRailsClub Moscow(現在はRubyRussiaに改名)で発表したいと思っていたのですが、話すネタに困っていました。そこでEvil Martiansの同僚たちといくつかのアイデアを共有し、その中でウケのよかった「改良版Action Cable」を発表してみたくなり、プロトタイプ制作意欲も高まりました。こうして私はカンファレンスで発表するようになり、カンファレンスのネタのためにオープンソースプロジェクトをメンテナンスするようになったというわけです。

実を言うと、2017年後半まで、私はもちろんMartiansのメンバーの誰もAnyCableをproductionで使ってなどいませんでした(素のAction Cableすら使ってなかったのですが)。その1年半ちょっとの間、私はずっと手探り状態が続いていました。私にあるものといえば、ユーザーがGitHubに投げたいくつかのissueとGitterのチャンネルだけでした。DHHが「実にクールなプロジェクトだよ」と絶賛したプロジェクト↓にもかかわらず、当時の私はモチベの熱もほとんど冷めてしまい、いつしか「カンファレンスドリブンな」開発者と成り果てていました。v0.5.0は私がRubyConfMYで発表したタイミングでリリースされ、0.6.0はRubyConfのタイミングでリリースされました。Q&Aタイムで最も答えに窮したのは「AnyCableをどんなふうにproductionで使ってますか?」という質問を受けたときでした。「A dinnae, ye ken」と答えたときの私は、インチキ薬のセールスマンにでもなったような心持ちでした(訳注: A dinnae, ye kenは「I don't know」のスコットランド方言をおどけて使ったと思われます)。

この状況が変わったのは、2018年の暮れのことでした。多くのプロジェクトが続々とAction Cableを採用し始めるようになり、Evil MartiansもeBayなどの顧客で採用した後も勢いは止まらず、プロジェクトのいくつかは大きく成長し、高負荷をうまく扱うにはAnyCableが必要だということが世に知られるようになりました。Evil Martiansにも商用サポートの引き合いが増え始め、数年前の「楽しいプロジェクト」にもとうとうバトルテストの試練を受ける機会が巡ってきたのです。

「オープンソースのためのオープンソース」は楽しくない。

AnyCableは試練を乗り越えたものの、自分のつらみに直接関連しない巨大オープンソースプロジェクトには今後関わるまいと当時ひとりごちたものでした。

AnyCable-Goですべてが変わった

AnyCableの最初のサーバー実装はErlangで書かれていましたが、優れたgRPCツール、配布の容易さ(シングルバイナリはシンプルの極みでしょう)、コミュニティの大きさ(つまりコントリビューターも多い)を備えているGo言語に乗り換えたのは正しい選択でした。

それにGoでの開発は滑り出しから驚くほど短期間で書けました。最初に動くようになったバージョンをビルドしたときはわずか1週間、コミット数にいたってはたった8つです!当時の私はGoの初心者同然でしたし、Goの歴史も現在より浅かったこともあって、(Rubyコミュニティと比べて)コード編成のベストプラクティスを見い出すのにかなり手こずりました。そして選んだのが「典型的なGo way」です。mainパッケージは1つだけ、リポジトリのルートディレクトリにいくつかファイルを配置、強結合、テストは「なし」または少なめ(なお後述のブラックボックステストは行いましたが)、という具合です。さて、こんなことをしてたら「メンテほぼ不能」への道に迷い込むでしょうか?

v0.6.0では多くの機能を盛り込む計画になっていましたが、後に実現したそれらの機能をアーキテクチャとして実装するのは、明らかに当時の私には無理そうでした(そもそもアーキテクチャ不在だったとも言えます)。そして大リファクタリング大会が始まったのです。

The Code City visualization for AnyCable-Go

GoCityによるAnyCable-Goビジュアル表示: v0.5.0(左)、v1.0.0(右)
このリファクタリングは「Structuring applications in Go」という他とは一味違うブログ記事にヒントを得て、Goコードのマイクロオプティマイゼーション、エラーハンドリング、ポインタの利用について学びました。さらに設計(私のRuby目線で申し上げるならアーキテクチャ)の優れたGoのオープンソースプロジェクトを物色し始め、その結果faktorycentrifugotelegrafというプロジェクトを見い出しました。他の人が書いたコードを参考にしたり(ときには拝借したり)することで、散らかったコードを現在楽しく作業できるコードベースに変えるのに役立ちました。

複雑で信頼性の高いソフトウェアを書くのは決して容易ではない。

今思えば、その「先人の知恵」を最初から取り入れておけばよさそうなものなのに、なぜ私はそうしなかったのか。おそらく、Go言語は学びやすく短期間にリリースできるという評判に釣られたのでしょう(実際のアプローチは「一発しばいてデプロイ完了(訳注: 「slap shit together and deploy」はロシアで最近流行りのITジョークで、ろくにテストせずにデプロイする行為を指すそうです)」に近いことがわかってきました)。Goといえども、複雑で信頼性の高いソフトウェアを書くのは決して容易ではありません。

日の目を見なかった「AnyCable-bility」という名前

Action Cable互換のWebSocketサーバー向けにコンフォーマンステスト(訳注: コンプライアンステストとも呼ばれます)のツール、すなわちAnyTを書いたことは、私が決して後悔していないことのひとつです。AnyTはクライアント〜サーバー間のさまざまな通信シナリオを記述する結合テストのコレクションであり、テスト対象サーバーに沿って実行するCLIです。

開発ツールへの投資は、長期的には報われるものである。

こうしたツールがなかったら、AnyCable Rack serverのような新しいサーバーの実装を書くことも、既存のサーバーのリファクタリングも、はるかに難しい作業になったことでしょう。AnyTのおかげでリグレッション(=バグの再発)を避け、AnyCableの開発をAction Cableと同期させることができました(私たちはAction Cableでもテストを実行することにしています)。

AnyTについてひとつ面白い話があります。当初このツールの名前は「anycablebility」で、その名前でリリースまで行いましたが、その後奇妙極まりないことが起きました。私のRubyGemsサイトのオーナーシップが盗まれたのです🙀!アクセス権を取り返せなかったので、いっそ名前を変えることにしました。皆さんもきっと新しい名前の方がいいですよね?

Rails互換性の二面性

前述したとおり、AnyCableはAction Cableを置き換えないアドオンとして設計されました。実際私たちはAction CableのRubyコードやクライアントのJavaScriptライブラリに今も依存しています。

Action Cableを意識的にサポートしていなかったら、ここまで多くのユーザーを勝ち得ることはなかったでしょう。つまり戦略は正しかったというわけです。

一方、AnyCableが進化するに連れて依存性は低下しつつあります。互換性に関するリソースを調査する必要がありますが、欲しい機能をすべて追加するというわけにはいきません。その場合、Action Cableのコードのハックやカスタムクライアントの作成が必須になるでしょう。

現在のこの状況は、ちょうどGitHub向けCLIであるhubで起きた話と似ています。私たちも同じ教訓を身をもって思い知ったのでした。

AnyからManyへ: ケーブルの未来のために皆さんのお力を求めています

v1.0リリースの目的は2つあります。1つ目はAnyCableが安定し、productionで使えることを知らしめる(実際は割と前からそうなっていますが)。2つ目は私にとって重要な点である、v2の開発を始められる状態になったということです!

ここからはAnyCableの将来について願望ベースで語っていきたいと思います。

AnyCable 2: プロトコルの改善、Rubyやその他の言語で使える独自の「チャネル」フレームワークを自前で持つ、JSクライアントの現代化、WebSocketゲートウェイ。

AnyCable 2.0は革命的なパラダイムシフトになります。私たちは今後Action Cableの猿真似をやめます。

まず第1に、プロトコルを見直したいと思っています。たとえば、一意のセッションID、ストリーム内で単調に増加するメッセージID、アクション(perform)のACK、バッチ操作などを考えています。これらの変更が完了すれば、信頼性の高いデリバリーや真のRPCエクスペリエンスといった機能を追加しやすくなります。

プロトコルを変更するとなると、クライアント側のコードも新たに書く必要が生じるでしょうし、新しいプロトコルをサポートするためだけではなく、開発エクスペリエンスを改善するためにも既存コードの書き直しが必要になるでしょう。

以下は、AnyCable JSクライアントの理論上のコード例です。

// channels/chat.js
import { Channel } from 'anycable'

export default class extends Channel {
  static identifier = 'chat';

  fetchHistory = () => this.perform('fetchHistory')
}

// index.js
import ChatChannel from 'channels/chat'

const roomId = 42
// JSのcamelCase形式キーはサーバー側で自動的にRubyのsnake_case形式に変換される
const channel = new ChatChannel({roomId})

// async呼び出しはすべてPromiseベースとすることでawaitが使えるようになる
// (もちろんasync関数の内部で)
await channel.connect()

// 別形式のイベントAPI
channel.on('connect', () => console.log('Connected'))

// メッセージのACKはここで使われる
const messages = await channel.fetchHistory()

// 受信メッセージへサブスクライブする
channel.on('message', message => console.log(message))

さらに別の便利機能として、(Loguxでやっているような)コネクションをブラウザタブ間で共有できる機能を最初からサポートする計画もあります。

上のコード例では、チャネルIDにRubyのクラス名ではなく"chat"のようなシンプルな文字列を用いていることにご注目ください。抽象化の漏洩(leaky abstraction)よりは明示的に定義されたチャネルIDの方がずっとよいと信じています。クライアントアプリはこちら側のRubyコードの詳細について関知すべきではありません。

他の多くの問題と同様、この問題を排除するために「チャネル」用のカスタムフレームワークを提供する予定ですが、APIはAction Cableと「十分近い」ものにする予定です。そうすることで、ほとんどの場合application_cable/channel.rbのActionCable::Channel::BaseAnyCable::Channel::Baseに変更するだけでシンプルに移行できるようになります。

つまりAnyCableのRuby APIは、現在サポートされているAction Cable APIの上位セットになる。

このカスタムフレームワークは「ケーブルの種類を問わない」ものになります。フレームワークの責務はビジネスロジックに限定され、特定の転送手段やサーバー実装に関する知識を一切持ち合わせません。これによって、AnyCable以外にFalconIodineなどでもフレームワークを利用できるようになります。

人気の高いリアルタイム機能のいくつかは、すぐ使える形で同梱されるか、プラグインとして提供されます。こうした機能として、「存在トラッキング」や「チャネルレス・サブスクリプション」などを考えています。

しかしそれだけでは終わりません。

私たちが素晴らしい新プロトコルを手にしてRailsへの依存を切り離すときが来たら、おそらく次はRuby以外への移植も検討するときでしょう。AnyCable for PythonやAnyCable for PHPがあったらいいと思いませんか?

AnyCableをさまざまな言語で動かすことを検討するうちに、AnyCableをWebSocketゲートウェイとして使うという、くらくらするような素晴らしいアイデアをもうひとつ思いつきました。AnyCable-Goにルーティング機構を追加すれば、さまざまなバックエンドでさまざまなチャネルをさばけるようになるでしょう。クライアント側はマイクロサービスアーキテクチャの詳細について一切知識を持つべきではなく、1つのコネクションですべてを消費できるようになるのです!Apollo FederationでやれるようなことをWebSocketで行えるというわけです。

少々先走りすぎたようなのでこの辺にしておきましょう。私はAction Cableのアドオンに長い年月を捧げてきましたが、上で述べた夢が実現するには果たしてどのぐらいの年月がかかるでしょうか?10年かそこらでしょうか?しかしこれはオープンソースプロジェクトですし、自分が「退屈な」商用開発に従事していることを忘れないようにしないと...

AnyCableがGitHub Sponsorsに登録しました

そういったわけで、GitHub SponsorsプログラムでAnyCableのスポンサーシッププログラムを立ち上げてみる気になりました。スポンサーシッププログラムによって、私や他のコントリビュータが業務時間の外で貴重な時間をAnyCableに捧げられるようになります。私たちと一緒に素敵なリアルタイムの未来を築き上げましょう❤️!

AnyCableを真に自分のものにしましょう

AnyCableは拡張も設定も簡単です。特定のスタートアップ事業向けのファインチューニングを必要とする方にはカスタムソリューションや商用サポートを提供しています。ぜひAnyCable公式Webサイトをチェックしてください。そしてproduction環境で必要なものについて議論したい方は、いつでもお気軽にEvil Martiansのフォームまでご一報ください。


AnyCableは、使いやすいAction Cable APIのスピードアップと信頼性の向上をもたらします。だからこそ、AnyCableが当初「強化版Action Cable(Action Cable on steroid)」と呼ばれていたのです。皆さんのRuby on Railsアプリケーションがリアルタイム機能に依存しているのであれば、AnyCableの導入こそがインフラコストを節約しつつユーザーに素晴らしいリアルタイム機能を提供する上で最もシンプルな方法です。AnyCableには、「分析機能」「Prometheusとの統合」「無切断デプロイ」「Rails以外のアプリのサポート」といったproduction環境で必要となる多くの機能がすぐ使える状態で同梱されています。

おたより発掘

AnyCable 1.0: RubyとGoによるリアルタイムWebの4年間(翻訳)|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

「一発しばいてデプロイ完了」は味わい深い

2020/07/29 17:48

関連記事

Ruby NextトランスパイラでRubyの新機能を使おう(翻訳)

Fullstaq Rubyの第一印象とDocker/Kubenetes Rubyアプリとの統合(翻訳)


CONTACT

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