12
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

株式会社ラグザイアAdvent Calendar 2023

Day 21

ActionDispatch等をスタンドアローンで動かしてミニマムなRailsのような何かをDIYしたらRailsともっと仲良くなれた話

Last updated at Posted at 2023-12-20

前置き

Webアプリケーションエンジニアとして働き始めて4年といくらか、そこそこの浮気期間は挟みつつも長らく付き合い続けているRuby on Railsだが、たまに必要に迫られてコードを見るくらいでその中身に関しては正直まるで知らない。

Railsのことをもっと知りたい。いったいどうすれば良いだろう...

o0300022814160302731.jpeg

スクリーンショット 2023-12-17 11.51.21.png

もちろんRailsになっちまうのはDHHでもなければ無理なので、Railsのコンポーネント、今回の場合は特にActionPack(ActionDispatch, ActionController辺り)を使って、自作の超ミニマムなRailsっぽい何かを作りました。イメージとしては自作PCです。

予防線

  • 「超ミニマムなRailsっぽい何か」ですが、これはフレームワークですらありません。rails newの結果できる類の「Railsアプリケーションの土台」をミニマムな形で作った感じです。おそらくはrailtiesの一部に該当するような話なのかなと思ってます(調べ切れてませんが)
  • Railsのコードは多少は見ていますがその構造まで模倣はしていません。実際のRailsの構造とは全く違うものになっています。
  • あくまで学習目的で当然ながら使えるものではないですので、諸々の粗も含めてその辺りはご承知おきください。
  • 解釈とかに誤りがあったらぜひ(優しく)教えてください。

記事の流れ

以下の流れで「超ミニマムなRailsっぽい何か」 を作成します。(今後 MiniRailsDiy と呼称)

  1. Pumaで動く最低限のRackアプリケーションを作る
  2. ActionDispatch, ActionController等をスタンドアローンで動かす
  3. MiniRailsDiy」のアプリケーションにする

各章では作業や検討の流れを口語体で冗長に書いています。内容だけ知りたい方は各章の 「作ったコード」 の項目だけみていただければ良いと思います。

結論だけ知りたいよという方は、こちらから最終的なコードをご参照ください。

MiniRailsDiyの作成

1. Pumaで動く最低限のRackアプリケーションを作る

何はともかく土台を作ろう。
Railsアプリケーションの運用において、よく用いられるアプリケーションサーバーといえばPumaだろう。PumaはRackベースのアプリケーションサーバーで、Railsに限らずRackアプリケーションであれば何でも動かすことができる。
Puma上で動く最低限のRackアプリケーションを作れば、これが MiniRailsDiy を作る第一歩になる...気がする。

作ったコード

ということで簡単に作ってみた。

Gemfile
gem 'puma'
gem 'rack'
config.ru
require 'rack'

run lambda { |_env|
  [200, { 'Content-Type' => 'text/html' }, ['Hello world!']]
}

ソースコード全体はこちら
これはもうほぼPumaのサンプルコードだな。

実行結果

実行すると以下のようにHello world!が返ってくる。

スクリーンショット 2023-12-16 19.32.55.png

スクリーンショット 2023-12-16 19.34.09.png

まぁとりあえず...最低限これで良いか。

コードの説明

config.ruで最低限のRackアプリケーションを定義して、Pumaを動作させただけ。Pumaはデフォルトでconfig.ruを探しにいくので、特に追加の設定も必要ない。

ただconfig.rubにおける以下のコードは、今後も何度か出てくる概念が含まれているので少し説明する。

run lambda { |_env|
  [200, { 'Content-Type' => 'text/html' }, ['Hello world!']]
}

1. runメソッド

このメソッドはRackアプリケーションのエントリポイントとして機能し、サーバーから受け取ったリクエストを最終的に処理するアプリケーションの定義に使われる。つまり、run メソッドは リクエストの処理を担当するアプリケーション部分を指定する ために使用されている。

参考: https://github.com/rack/rack/blob/64ad26e3381da2ce1853638a2c4ea241c2ad3729/lib/rack/builder.rb#L34C5-L34C76

2. lamda式の中身とRackのレスポンス

lamda式は1つの配列([200, { 'Content-Type' => 'text/html' }, ['Hello world!']])を返している。この配列の要素はそれぞれ、ステータスコード、レスポンスヘッダ、レスポンスボディを表しており、以下のRack Specificationに則ったものになっている。

A Rack application is a Ruby object (not a class) that responds to call. It takes exactly one argument, the environment and returns a non-frozen Array of exactly three values: The status, the headers, and the body.
(https://github.com/rack/rack/blob/main/SPEC.rdoc)

2. ActionDispatch, ActionController等をスタンドアローンで動かす

とりあえず土台はできたので拡張していくことにする。何かしらRailsのコンポーネントを組み入れて、MiniRailsDiyをRailsっぽい感じに近づけていきたい。では最初に何を入れるのが良いだろうか。

やはりRailsといえばActiveRecordだろうか? ただActiveRecordのインスタンスがポツンあっても別にRailsっぽい感じにはならない気がするな。

そうなると次の候補としてはルーティングとかcontrollerかな? route.rbのようにルーティングを定義できて、いつもの書き方でcontrollerを定義できて、何かしらレスポンスを返せるなら、けっこうRailsっぽくなる気がする。

それをやるなら...そう、ActionPackだね。

Action Pack is a framework for handling and responding to web requests. It provides mechanisms for routing (mapping request URLs to actions), defining controllers that implement actions, and generating responses. In short, Action Pack provides the controller layer in the MVC paradigm.
(https://github.com/rails/rails/tree/main/actionpack)

上記はActionPackの説明文だが、まさに求めている機能だ。これを組み込めれば結構いい感じになるんじゃないか?

ただちょっと不安なんだけど、これってスタンドアローンで動かすことができるものなのだろうか?

However, these modules are designed to function on their own and can be used outside of Rails.

まぁ...一応こう書いてはあるな。信じてやってみるか...。

作ったコード

できた。こんな感じ。

config.ru
require 'rack'
require 'action_dispatch'
require 'active_support/all'
require 'abstract_controller'
require 'action_controller'
Dir.glob('./app/controllers/**/*.rb').sort.each { |file| require file }

app = ActionDispatch::Routing::RouteSet.new
app.draw do
  get '/home', to: 'home#index'
end

run app
app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    render plain: 'test index'
  end
end
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base; end

ソースコード全体はこちら
(少し差分はありますがスルーしてください)

...Railsっぽくなってきたような?

config.ru上ではあるものの、routes.rbに似た形でルーティングを設定されているし、app/controllers以下はほぼRailsのControllerそのままだ。

実行結果

実行すると以下のようにtest indexが返ってくる。

スクリーンショット 2023-12-16 22.30.28.png

コードの説明

ActionDispatch::Routing::RouteSet.new について

おそらくコードを読んで一番疑問に思うのは、config.ruのActionDispatch::Routing::RouteSet.newだと思う。急に出てきたこいつは一体何者で、何故ここでrunしているのか?以下でその理由をできる範囲で説明する。
(正直理解し切れていない面はある。誤ってたら申し訳ない。コメントください。)

Railsアプリケーションのroutes.rbは通常以下のようになっている。

(よくあるRailsアプリの)routes.rb
Rails.application.routes.draw do
  # get 'posts/index'
end

Railsと同じルーティングを実現するためには、これと同じことができればよさそうだ。ではRails.application.routesとは一体なんなのか?というと...

> Rails.application.routes
=> #<ActionDispatch::Routing::RouteSet:0x000000010c405828>

どうやらActionDispatch関連のもので、ActionDispatch::Routing::RouteSetのインスタンスが入っているらしい。

ActionDispatch::Routingに関する説明を読んでみると、この辺りでroutes.rbに書くようなDSLが実現されていることも間違いなさそうだ。

The routing module provides URL rewriting in native Ruby. It's a way to redirect incoming requests to controllers and actions. This replaces mod_rewrite rules. Best of all, Rails' \Routing works with any web server.
Routes are defined in config/routes.rb.
(https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/routing.rb#L4)

へーそうなんだ。ってことは ActionDispatch::Routing::RouteSet を自分で作って、drawメソッドを実行すればルーティングは設定できてしまうのかな?

ActionDispatch::Routing::RouteSet を自分で作る
app = ActionDispatch::Routing::RouteSet.new
app.draw do
  get '/home', to: 'home#index'
end

なんとなく方向性は見えてきた気がする。
ただ仮にそこまでは良いとしても、これをどうやってRackアプリケーションとして仕上げれば良いだろうか?

ここで急な話題展開になるがRailsガイドの「RailsとRack」を参考にして、bin/rails middlewareを実施してみると、RackアプリケーションとしてのRailsがやっていることが見えてくる。

use ActionDispatch::HostAuthorization
use Rack::Sendfile
...
use Rack::TempfileReaper
run MyApp::Application.routes

色々あるが、最終的にはrun MyApp::Application.routesをやっているようだ。なんか見たことある形だが...route.rbのRails.application.routes(.draw)にちょっと似ているな。ただまたも突然出てきたMyApp::Applicationとはなんだ?

調べてみるとどうやらこれはRails.applicationの中身らしい。
(HelloRailsは私の手元のRailsアプリの名称)
スクリーンショット 2023-12-17 18.17.21.png

つまりMyApp::Application.routesの中には、Rails.application.routesと同様にActionDispatch::Routing::RouteSetのインスタンスが入っているということになる。

であるならActionDispatch::Routing::RouteSetのインスタンスは既にRackアプリケーションの仕様を満たしていることになるのか。

そうなら話は早い。MiniRailsDiyでも同じようにしてみよう。

config.rb
app = ActionDispatch::Routing::RouteSet.new
...
run app

この考えはおおよそあっていたようで、前述の通り期待通りの実行結果となった。

RailsをRackアプリケーションという観点から見ると、その核となるのはActionDispatch(::Routing::RouteSetのインスタンス)という捉え方が、もしかしたらできるのかもしれない。

(本当はRailsのコードも見てもっと裏とりをすべき箇所なのだけれども今回は時間の関係でそこまでできなかった。この先はキミの目で確かめてくれ! )

Controllerに関して

ActionController::Base を利用するために以下のrequireが必要になる。

require 'abstract_controller'
require 'action_controller'

これは本当にただrequireしただけ。app/controllersというディレクトリ構造も自分で作ったが特にroutes.rbとの繋ぎこみ設定等も不要だった。おそらくActionDispatchなるActionControllerなりがデフォルトで設定を持つなり名前空間を参照しているからrequireで事足りるなりでうまいことやってるのだろう。

「MiniRailsDiy」のアプリケーションにする

ここまででMiniRailsDiyはかなり「ミニマムなRailsっぽい何か」に近づいてきた。ただまだ現状3つほど気になることがある。

1つ目はconfig.ruに直接ルーティングが書かれていること。ここはroutes.rbと同じように別ファイルに切り出したい。

2つ目は、今のconfig.ruでActionDispatch由来のインスタンスを直接runしていること。Railsアプリのconfig.ruでは、run Rails.applicationというように見た目上Rails由来っぽいものをrunしているのでこれに合わせたい。

3つ目はソースコード上にいまだにMiniRailsDiyが登場していないことだ。せっかく呼称を作ったのだから使ってあげたい。

さてどうしようか...
少なくとも、Rails.applicationにあたるものを作成する必要はあるかな。ひとまずこれをMiniRailsDiy.applicationとでもしておこう。

その上でMiniRailsDiy.applicationは以下を満たす必要がありそうだ。

  1. MiniRailsDiy.application はRackアプリを返す必要がある。
  2. 1.のRackアプリは、実際の動作としてはActionDispatch::Routing::RouteSetのインスタンスをrunするものである必要がある
  3. MiniRailsDiy.application.routesActionDispatch::Routing::RouteSetのインスタンスを返す必要がある

なんか結構面倒そうだな...初期化処理を用意して、MiniRailsDiy.applicationのインスタンスを作成して云々やれば良いのかな...?

作ったコード

やってみたら普通にできた。
主要なコードは以下の通り。

config.ru
require './config/initialize'

run MiniRailsDiy.application
routes.rb
MiniRailsDiy.application.routes.draw do
  get '/home', to: 'home#index'
end
initialize.rb
require 'rack'
require 'action_dispatch'
require 'active_support/all'
require 'abstract_controller'
require 'action_controller'
require './mini_rails_diy'
require './config/routes'
Dir.glob('./app/controllers/**/*.rb').sort.each { |file| require file }
mini_rails_diy.rb
class MiniRailsDiy
  attr_reader :routes, :application

  def initialize
    @routes = ActionDispatch::Routing::RouteSet.new
    routes = @routes
    @application = Rack::Builder.new do
      run routes
    end
  end

  def call(env)
    @application.call(env)
  end

  class << self
    attr_accessor :application
  end
end

MiniRailsDiy.application = MiniRailsDiy.new

最終的なコードはこちら

実行結果

スクリーンショット 2023-12-16 22.30.28.png

以前と同様に、test indexが表示される。

だいぶスッキリしてきた気がする。

コードの説明

ここではMiniRailsDiy(mini_rails_diy.rb)について説明する。
このクラスは前述の3つの要件を以下の形で満たしている。

MiniRailsDiy.application はRackアプリである&ActionDispatch::Routing::RouteSetのインスタンスをrunする

Rackアプリの最低限の要件は、前述の通り以下が挙げられる

  • callメソッドを持つこと
  • 要素が3つの配列(ステータスコード、リクエストヘッダ、リクエストボディ)を返すこと

MiniRailsDiyでは、シングルトンパターン的な実装で、MiniRailsDiy.applicationが常に1つのMiniRailsDiyのインスタンスを返すようにしている。

class MiniRailsDiy
  ...
  class << self
    attr_accessor :application
  end
end

MiniRailsDiy.application = MiniRailsDiy.new

またcallメソッドを持つためRackアプリケーションとしてPumaからのリクエストを受け取ることができる。

class MiniRailsDiy
  attr_reader :routes, :application

  def initialize
    ...
    @application = ...
  end

  def call(env)
    @application.call(env)
  end
  ...
end
...

加えてcallメソッドは、内部でActionDispatch::Routing::RouteSet由来のRackアプリケーションをcallしているので、実際の動作はActionDispatch::Routing::RouteSetをrunしているのと同等になる。

class MiniRailsDiy
  attr_reader :routes, :application

  def initialize
    @routes = ActionDispatch::Routing::RouteSet.new
    routes = @routes

    @application = Rack::Builder.new do
      run routes
    end
  end

  def call(env)
    # 内部的にはActionDispatch::Routing::RouteSetのRackアプリケーションを保持し、それをcallしている
    @application.call(env)
  end
end
...

MiniRailsDiy.application.routesActionDispatch::Routing::RouteSetのインスタンスを返す

ここは既に前項の話で実現できている。
初期化時に作成した ActionDispatch::Routing::RouteSet.new を routesメソッドからアクセスできるようにすれば良い

class MiniRailsDiy
  # 初期化時にroutesに ActionDispatch::Routing::RouteSet.new のインスタンスを入れている
  attr_reader :routes, ...

  def initialize
    @routes = ActionDispatch::Routing::RouteSet.new
    ...
  end
end

作ったものを振り返ってみる

さて最後に作ったものを振り返ってみる。
そこそこにRailsっぽくなったのではないだろうか。

Gemfile
source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'actionpack', "~> 7.1.2"
gem 'activesupport'
gem 'puma'
gem 'rack'
config.ru
require './config/initialize'

run MiniRailsDiy.application
initialize.rb
require 'rack'
require 'action_dispatch'
require 'active_support/all'
require 'abstract_controller'
require 'action_controller'

require './mini_rails_diy'
require './config/routes'
Dir.glob('./app/controllers/**/*.rb').sort.each { |file| require file }
routes.rb
MiniRailsDiy.application.routes.draw do
  get '/home', to: 'home#index'
end
application_controller.rb
class ApplicationController < ActionController::Base; end
home_controller.rb
class HomeController < ApplicationController
  def index
    render plain: 'test index'
  end
end
mini_rails_diy.rb
class MiniRailsDiy
  attr_reader :routes, :application

  def initialize
    @routes = ActionDispatch::Routing::RouteSet.new
    routes = @routes
    @application = Rack::Builder.new do
      run routes
    end
  end

  def call(env)
    @application.call(env)
  end

  class << self
    attr_accessor :application
  end
end

MiniRailsDiy.application = MiniRailsDiy.new

でもmini_rails_diy.rbはちょっと変なことしているような気がしなくもないな。良い書き方があったらコメントください。

あとはこの先やるとすればひとまずは以下だろうか。実現すればさらにRailsっぽくなりそうだ。

  • initialize.rbやmini_rails_diy.rbのGem化
  • ActiveRecordやActionViewの組み込み

でももう疲れたので今回はやらない...

所感

とても長い記事になってしまった。いつも同じことを言っている気がする。なぜ私が記事を書くとこんなに長くなってしまうのだろうか。

ただその分、Ruby on Railsがどういうものか、これまでとは違う側面から理解が進んだように思う。以下2つに分けて学んだことを記載する。

Railsアプリケーションとその背後に存在するRackについて

Railsアプリケーションが根底でRackアプリケーションであることは、普段あまり意識も注目もしていなかったが、ミニマムなRailsを構築する過程でこの点を強く実感することができた。この取り組みを通じて、Rackベースのアプリケーションの具体的な構造についてもより深く学ぶ機会になった。

特に、アプリケーションサーバーからのリクエストをcallメソッドで受け取り、それを処理して配列形式のレスポンス(ステータスコード、レスポンスヘッダ、レスポンスボディ)を返すというRackの仕様に則った基本的な仕組みを理解することができた。この仕組みを適切に実装することで、RackベースのWebサーバーやアプリケーションサーバーとスムーズに連携することができる。その点にRackの価値があるのだろう。

このプロセスを経て、Railsアプリケーションの背後にあるRackの役割とその意義について、自分なりの理解を深めることができたと感じている。

RackアプリケーションとActionPackの役割

RailsアプリケーションをRackアプリケーションとして捉えるとき、その核となる部分はActionPack(特にActionDispatchとActionController)によって構成されている、そう表現してもそこまで間違っていないのではないかと感じた。
自分が作成したMiniRailsDiyでは、ActionDispatchとActionControllerの二つのコンポーネントの、さらにその一部の機能だけを利用している。にもかかわらず、Railsのような見た目のWebアプリケーションを実現することができた。

ここに他のコンポーネント、例えばActiveRecordやActionViewなどを組み入れたなら、Railsの基本的な外観と機能を模倣するには十分であるように感じる。もちろん、Railsがその価値を最大限に発揮するのは、これらのコンポーネントだけでなく、多くの他の機能との連携を通じてであることも理解している。ただこの取り組みを通じて、Railsの基本的な構造とその機能の仕組みについて、より深い理解を得ることができた。

まぁぶっちゃけすごくしょぼいrailtiesを作っただけな気はしている。それが合っているのかいないのか、その辺もおいおい調べていこうと思う。

参考サイト

https://github.com/rack/rack
https://github.com/puma/puma
https://github.com/rails/rails/tree/main/actionpack
https://github.com/rails/rails/tree/main/railties
https://qiita.com/ogawatti/items/8f58aaee506c4646a094

12
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?