こんにちは、2024年に新卒入社し、株式会社Techouseクラウドハウス労務でバックエンドエンジニアをしているsatohです。
本記事では、Rubykaigi 2024 2日目のSamuel Williams(@ioquatix) さんによるセッション、「Leveraging Falcon and Rails for Real-Time Interactivity」について紹介させていただきます。
Leveraging Falcon and Rails for Real-Time Interactivity
2日目のキーノートはリアルタイム通信についての歴史とRubyでの試みの話でした。
セッションの後半では、リアルタイムな双方向通信を使ったWebブラウザ上で遊べるゲームが、作成から完成までの実装とともに紹介されました。
リアルタイムコミュニケーションとウェブ技術の歴史
1978年、初めての公共の電子掲示板(BBS, Bulletin Board System)が誕生し、コンピュータ通信によるリアルタイムなコミュニケーションが広まりました。また、1984年にはTrade warというBBS上で遊べる初めてのゲームが開発されています。
その後、1990年代には世界中へアクセスできるWorld Wide Webが広まり、1996年にはearth 2025というブラウザ上で動くゲームが開発されました。
インタラクティブなやりとりを提供していたBBSに比べて、当初のWorld Wide Webは情報の表示および検索に特化したものでしたが、Webでの通信は時代とともに主流となっていきました。
そして2005年、ウェブアプリケーションフレームワークである Ruby on Rails の誕生がウェブ技術の大きなマイルストーンとなります。「設定より規約」と「Don't repeat yourself」の理念によってアプリケーションの開発工程が格段とシンプルになり、生産性を大きく向上させる革新となりました。
それからWeb技術の進化に伴い、Webでもインタラクティブな通信を簡単なインターフェースで実現したいという需要が高まっていきます。
しかし、RailsはRackの提供するインターフェースを利用しており、その通信はHTTP標準のリクエスト/レスポンスプロトコルに則ったものでした。リクエスト/レスポンスの通信モデルは何らかのリクエストが送られるまでサーバー側はクライアントに情報を送ることができない構造となっており、インタラクティブな通信には適していません。
そこで、Railsではリアルタイム通信のためのフレームワークであるAction Cableが導入されることになりますが、Rubyは元々多数の同時進行する処理を扱うことを想定してデザインされておらず、Action Cableで多数のリアルタイムなコネクションを扱うのは技術的に困難でした。
2012年には、ブラウザゲームのBrowserQuestが発表されます。
BrowserQuestはHTML5の双方向通信の機能とNode.jsのスケーラビリティを活用することで、ゲームのワールドや他プレイヤーとのリアルタイムなインタラクションができるものとなっており、Webアプリケーションの新たな可能性を示しました。
Async
そして2017年、@ioquatixさんにより、Rubyの非同期I/OフレームワークであるAsyncが作られました。
シンプルなHTTPサーバーでは、大きな処理が必要なリクエストが送られるとサーバーがブロックされ、他のリクエストに応答できなくなってしまう問題があります。
そこでAsyncを利用して、レスポンスを生成する処理をFiberで実行することで、他のリクエストへの応答を同時並行で行うことができ、サーバーのブロックを回避できます。
AsyncのFiber schedulerにより、async/awaitに代表されるような構文と異なり、並行実行に適したコーディングをさほど意識せずともIOのread/writeやスレッドのjoin、mutexのロック待機など多様な処理をノンブロッキングで実行できます。
さらに、Asyncでは以下の特徴により高いパフォーマンスを実現しています。
- io-event gemが提供する、各OSのIOインターフェースを利用したRuby向けの高パフォーマンスなイベントループ
- Ruby向けのIOバッファを活用した効率的なread/write
- 主なCPUアーキテクチャに対応したコルーチンのアセンブリ実装による、高効率なコンテキストスイッチ
また、Ruby自体にも並行処理プログラムをサポートする機能拡張が行われています。
- Fiber Storageが導入され、リクエスト情報の管理などをシンプルに行えるように
- Fiber#kill を追加し、エラーハンドリングや処理の中断をしやすくロバストな並行処理が可能に
- IOイベント待ちのタイムアウトを設定できるようにし、安全なIO処理を実現
ちなみに、Rubyでの並行処理を実現するツールは常に開発が進められており、Asyncの他にRactorやThreadなども利用できます。
こうしたツール同士は競争関係にあると捉えられることがありますが、Rubyをよりよくしていくために協働しているものであると話されていました。
Falcon
Async上で動くHTTPサーバーとして、同じく@ioquatixさんの開発したFalconが紹介されました。
Falconの機能は、その下の3つのGemに支えられています。
メインのサーバー機能はAsync HTTPにて実装されており、HTTP/1およびHTTP/2のプロトコルに対応しています。また、Async Serviceによりサービスの設定および実行を管理でき、実際の実行はAsync Containerの提供するコンテナで行われます。Async Containerはマルチプロセスおよびマルチスレッドでの実行に対応しており、将来的には複数のRactor上での実行もサポートするかもしれないとのことでした。
Active RecordでのFiberのサポートを含む多くの開発により、RailsとFalconの互換性はほとんど達成されてきています。現在は最後の一手として、Action Cableをデフォルト以外のサーバー実装で実行できるようにする開発が進められているそうです。
Flappy bird
リアルタイム双方向通信Webアプリケーションのデモとして、RailsとFalconに加え、Websockets通信によりインタラクティブなHTMLビューを提供するLive gemを使ったブラウザゲームが紹介されました。
今回実装されたゲームはFlappy Birdという2013年に作られたゲームのレプリカです。
このゲームは右に飛んでいる鳥が障害物に当たらないようにスペースキーでジャンプさせるというシンプルなものですが、キー入力をクライアントから送り、60FPSでの画面の更新情報をサーバーから送るという双方向通信により成り立っています。
セッションでは、このゲームアプリケーションの作成から完成までの実装が紹介されました。
Gemfile設定
ますはじめに、新しいrailsのアプリケーションを作成します。
Gemfileを編集し、HTTPサーバーとしてpumaではなくfalconを使うように変更します。
< gem "puma", ">= 5.0" --- > gem "falcon", ">= 0.47" > gem "console-adapter-rails", "~> 0.3" > gem "live", "~> 0.8" > gem "xrb-rails"
コントローラー
コントローラーを追加します。初回接続時のリクエストを処理するindexアクションと、Websocketでの通信を処理するliveアクションを定義します。
require 'async/websocket/adapters/rails' class GameController < ApplicationController RESOLVER = Live::Resolver.allow(FlappyView) def index @view = FlappyView.new end skip_before_action :verify_authenticity_token, only: :live def live self.response = Async::WebSocket::Adapters::Rails.open(request) do |connection| Live::Page.new(RESOLVER).run(connection) end end end
ルーティング設定
アクションに対応するようにルーティングを設定します。
# Defines the root path route ("/") root "game#index" # The websocket route for the game: match "live", to: "game#live", via: [:get, :connect]
JavaScript
クライアントからのデータ送信のため、LiveのJavaScript用ライブラリを読み込むようにします。
import {Live} from "@socketry/live" window.live = Live.start()
pin "morphdom" # @2.7.2 pin "@socketry/live", to: "@socketry--live.js" # @0.13.0
Liveビューの実装
あとはコントローラーで呼び出しているFlappyViewクラスを実装すれば、アプリケーションを動かすことができます。ここでは、FlappyViewクラスの実装の中から、特に通信を担う部分を説明します。
サーバーからの通信
start_game!
メソッドによりゲームが開始されると、run!
メソッド内のループが実行されます。
def start_game! if @game @game.stop @game = nil end self.reset! @game = self.run! end
ループ内では、step
メソッドでゲームの進行処理を行った上で、Live gemが用意しているupdate!
メソッドによりhtml要素の更新をクライアントに送信します。
def run!(dt = 1.0/60.0) Async do while true start_time = Async::Clock.now self.step(dt) self.update! duration = Async::Clock.now - start_time sleep(dt - duration) if duration < dt end end end
クライアントからの通信
クライアントに送られるhtmlはrender
メソッドで組み立てられます。
render
メソッドの1行目で、html要素のonkeypress属性にlive-js
ライブラリのforwardEvent
メソッドを設定しています。
def forward_keypress "live.forwardEvent(#{JSON.dump(@id)}, event, {value: event.target.value, key: event.key})" end def render(builder) builder.tag(:div, class: "flappy", tabIndex: 0, onKeyPress: forward_keypress) do if @game builder.inline_tag(:div, class: "score") do builder.text(@score) end else builder.inline_tag(:div, class: "prompt") do builder.text(@prompt) builder.inline_tag(:ol, class: "highscores") do Highscore.connection_pool.with_connection do Highscore.order(score: :desc).limit(10).each do |highscore| builder.inline_tag(:li) do builder.text("#{highscore.name}: #{highscore.score}") end end end end end end @bird&.render(builder) @pipes&.each do |pipe| pipe.render(builder) end end end
これにより、ボタンを押すとサーバーへそのイベントが送信されます。送られたイベントはhandle
メソッドに渡され、鳥がジャンプする処理を実行します。
def handle(event) case event[:type] when "keypress" detail = event[:detail] if @game.nil? start_game! elsif detail[:key] == " " @bird&.jump end end end
コードの全文はこちら(クリックすると展開されます)
# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023, by Samuel Williams. require 'live' class FlappyView < Live::View WIDTH = 420 HEIGHT = 640 GRAVITY = -9.8 * 50.0 class BoundingBox def initialize(x, y, width, height) @x = x @y = y @width = width @height = height end attr :x attr :y attr :width attr :height def right @x + @width end def top @y + @height end def intersect?(other) !( self.right < other.x || self.x > other.right || self.top < other.y || self.y > other.top ) end def to_s "#<#{self.class} (#{@x}, #{@y}, #{@width}, #{@height}>" end end class Bird < BoundingBox def initialize(x = 30, y = HEIGHT / 2, width: 34, height: 24) super(x, y, width, height) @velocity = 0.0 end def step(dt) @velocity += GRAVITY * dt @y += @velocity * dt if @y > HEIGHT @y = HEIGHT @velocity = 0.0 end end def jump @velocity = 300.0 end def render(builder) rotation = (@velocity / 20.0).clamp(-40.0, 40.0) rotate = "rotate(#{-rotation}deg)"; builder.inline_tag(:div, class: 'bird', style: "left: #{@x}px; bottom: #{@y}px; width: #{@width}px; height: #{@height}px; transform: #{rotate};") end end class Pipe def initialize(x, y, offset = 100, width: 44, height: 700) @x = x @y = y @offset = offset @width = width @height = height @difficulty = 0.0 @scored = false end attr_accessor :x attr_accessor :y attr_accessor :offset # Whether the bird has passed through the pipe. attr_accessor :scored def scaled_random rand(-1.0..1.0) * [@difficulty, 1.0].min end def reset! @x = WIDTH + (rand * 10) @y = HEIGHT/2 + (HEIGHT/2 * scaled_random) if @offset > 50 @offset -= (@difficulty * 10) end @difficulty += 0.1 @scored = false end def step(dt) @x -= 100 * dt if self.right < 0 reset! end end def right @x + @width end def top @y + @offset end def bottom (@y - @offset) - @height end def upper_bounding_box BoundingBox.new(@x, self.top, @width, @height) end def lower_bounding_box BoundingBox.new(@x, self.bottom, @width, @height) end def intersect?(other) upper_bounding_box.intersect?(other) || lower_bounding_box.intersect?(other) end def render(builder) display = "display: none;" if @x > WIDTH builder.inline_tag(:div, class: 'pipe', style: "left: #{@x}px; bottom: #{self.bottom}px; width: #{@width}px; height: #{@height}px; #{display}") builder.inline_tag(:div, class: 'pipe', style: "left: #{@x}px; bottom: #{self.top}px; width: #{@width}px; height: #{@height}px; #{display}") end end def initialize(...) super @game = nil @bird = nil @pipes = nil # Defaults: @score = 0 @prompt = "Press Space to Start" end def handle(event) case event[:type] when "keypress" detail = event[:detail] if @game.nil? start_game! elsif detail[:key] == " " @bird&.jump end end end def forward_keypress "live.forwardEvent(#{JSON.dump(@id)}, event, {value: event.target.value, key: event.key})" end def reset! @bird = Bird.new @pipes = [ Pipe.new(WIDTH * 1/2, HEIGHT/2), Pipe.new(WIDTH * 2/2, HEIGHT/2) ] @score = 0 end def game_over! Highscore.connection_pool.with_connection do Highscore.create!(name: "Anonymous", score: @score) end @prompt = "Game Over! Score: #{@score}. Press Space to Restart" @game = nil self.update! raise Async::Stop end def start_game! if @game @game.stop @game = nil end self.reset! @game = self.run! end def step(dt) @bird.step(dt) @pipes.each do |pipe| pipe.step(dt) if pipe.right < @bird.x && !pipe.scored @score += 1 pipe.scored = true end if pipe.intersect?(@bird) return game_over! end end if @bird.top < 0 return game_over! end end def run!(dt = 1.0/60.0) Async do while true start_time = Async::Clock.now self.step(dt) self.update! duration = Async::Clock.now - start_time sleep(dt - duration) if duration < dt end end end def close if @game @game.stop @game = nil end super end def render(builder) builder.tag(:div, class: "flappy", tabIndex: 0, onKeyPress: forward_keypress) do if @game builder.inline_tag(:div, class: "score") do builder.text(@score) end else builder.inline_tag(:div, class: "prompt") do builder.text(@prompt) builder.inline_tag(:ol, class: "highscores") do Highscore.connection_pool.with_connection do Highscore.order(score: :desc).limit(10).each do |highscore| builder.inline_tag(:li) do builder.text("#{highscore.name}: #{highscore.score}") end end end end end end @bird&.render(builder) @pipes&.each do |pipe| pipe.render(builder) end end end end
以上で実装が完了し、実際にプレイする様子が披露されました。ゲストとして@yukihiro_matzさんもゲームに挑戦していました。
セッションではより詳しいゲームロジックの実装まで紹介されています。リポジトリが公開されていますので、ご自身の環境でプレイすることもできます。
おわりに
セッションは、ソフトウェアをシンプルにしようという話で締められました。
Web技術は進化を続けていますが、同時に複雑な構造が積み重ねられており、それらが制約として新たな開発の妨げになっているといいます。
ソフトウェアをシンプルにすることで、保守性やパフォーマンスが高く、理解しやすいシステムの開発が可能になります。
また、これからプログラミングを始めようとしている人たちにとって、ソフトウェアアーキテクチャの複雑さは大きな障壁になります。
そこで、初めての人でも使いやすいインタラクティブなWebアプリケーションのフレームワークとして、最後にFalconとLiveを組み合わせたLively gemといくつかの簡単なアプリケーションのデモが紹介されました。
こちらのGemも、デモを含めてGithub上に公開されています。
感想
紹介されたFlappyBirdの実装はとてもシンプルでわかりやすかったです。そしてゲームの内部処理を全てサーバーで行っているとは感じさせないほどの軽量さは目を見張るものでした。
楽しくRubyを書きながらインタラクティブなWebアプリケーションを作れるのはとても魅力的で、自分でも作ってみたくなりました。
また、システムのパフォーマンスや堅牢生を追い求めるために、Rubyやさらにその内部まで目を向け開発が進められていたのが印象的でした。
このような、ソフトウェアの全てを良くしようという姿勢はRubykaigi全体の雰囲気として強く感じられ、とても刺激的な3日間でした。
Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。 jp.techouse.com