Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2024 - Leveraging Falcon and Rails for Real-Time Interactivity (Day2)

ogp

こんにちは、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さんもゲームに挑戦していました。

セッションではより詳しいゲームロジックの実装まで紹介されています。リポジトリが公開されていますので、ご自身の環境でプレイすることもできます。

github.com

おわりに

セッションは、ソフトウェアをシンプルにしようという話で締められました。
Web技術は進化を続けていますが、同時に複雑な構造が積み重ねられており、それらが制約として新たな開発の妨げになっているといいます。
ソフトウェアをシンプルにすることで、保守性やパフォーマンスが高く、理解しやすいシステムの開発が可能になります。

また、これからプログラミングを始めようとしている人たちにとって、ソフトウェアアーキテクチャの複雑さは大きな障壁になります。
そこで、初めての人でも使いやすいインタラクティブなWebアプリケーションのフレームワークとして、最後にFalconとLiveを組み合わせたLively gemといくつかの簡単なアプリケーションのデモが紹介されました。 こちらのGemも、デモを含めてGithub上に公開されています。

github.com

感想

紹介されたFlappyBirdの実装はとてもシンプルでわかりやすかったです。そしてゲームの内部処理を全てサーバーで行っているとは感じさせないほどの軽量さは目を見張るものでした。
楽しくRubyを書きながらインタラクティブなWebアプリケーションを作れるのはとても魅力的で、自分でも作ってみたくなりました。

また、システムのパフォーマンスや堅牢生を追い求めるために、Rubyやさらにその内部まで目を向け開発が進められていたのが印象的でした。
このような、ソフトウェアの全てを良くしようという姿勢はRubykaigi全体の雰囲気として強く感じられ、とても刺激的な3日間でした。


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。 jp.techouse.com