RubyでWebフレームワークを自作する

# RubyでWebフレームワークを自作する
## 動機
Webフレームワークを使ってアプリケーションを作成することは良くあるが、実際にその中身やどういった動きをするのか知りたい。
今回は、RailsライクなMVCフレームワークを自作します。

## Rackで作成していく
ディレクトリを作成して、 `bundle init` でGemfileを作成します。

```zsh
❯ mkdir my_framework
❯ bundle init
```

`rack`をインストールします。
また、デバッグしやすくするために `pry` もインストールします。

```ruby:Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

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

gem 'rack'
gem 'pry'
```

```zsh
❯ bundle install
```

## Rackとは
 一言で言うと、RackはRubyで作られているWebサーバーを立ち上げるためのインターフェースです。

 昔のWebアプリケーションフレームワークの実装は、特定のWebサーバーに依存しているものが多く、使用したいフレームワークのために環境が制限されたり、その逆にフレームワークが縛られることがありました。そこで、Pythonでは、WSGIという両者間の標準インターフェースが作られWSGIに対応しているフレームワークと、WSGIを介して連携できるWebサーバとを好みの組み合わせで使用できるようになりました。
 RackはWSGIに影響して開発されたWebサーバとWebアプリケーション/フレームワーク間の標準インターフェースを定める「仕様」です。

Rackアプリケーションとして最低限必要なもの

- callというメソッドを持っていること
- callメソッドの引数としてWebサーバからのリクエストを受けること
- callメソッドは，次の要素を含むレスポンスを返すること
  - ステータスコード
  - レスポンスヘッダ（Hash）
  - レスポンスボディ（Array）
  
	
## Hello Worldを表示させる

```ruby
class App
  def call(env)
    [200, {}, [‘Hello World’]]
  end
end
```
ここでは、ステータスコードに200、レスポンスヘッダは空の{}、レスポンスボディに `Hello world` を設定しいます。
envはCGI環境変数であり、以下のような値と使用しているアプリケーションサーバーのインスタンスを取得することができます。`localhost:9292/posts` にアクセスした際のenvの値を設定します。

```shell
[1] pry(#<App>)> env
=> {"rack.version"=>[1, 3],
 "rack.errors"=>#<Rack::Lint::ErrorWrapper:0x00007f8ed1b65f00 @error=#<IO:<STDERR>>>,
 "rack.multithread"=>true,
 "rack.multiprocess"=>false,
 "rack.run_once"=>false,
 "SCRIPT_NAME"=>"",
 "QUERY_STRING"=>"",
 "SERVER_PROTOCOL"=>"HTTP/1.1",
 "SERVER_SOFTWARE"=>"puma 4.2.1 Distant Airhorns",
 "GATEWAY_INTERFACE"=>"CGI/1.2",
 "REQUEST_METHOD"=>"GET",
 "REQUEST_PATH"=>"/posts",
 "REQUEST_URI"=>"/posts",
 "HTTP_VERSION"=>"HTTP/1.1",
 "HTTP_HOST"=>"localhost:9292",
 "HTTP_CONNECTION"=>"keep-alive",
 "HTTP_CACHE_CONTROL"=>"max-age=0",
 "HTTP_UPGRADE_INSECURE_REQUESTS"=>"1",
 "HTTP_USER_AGENT"=>"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36",
 "HTTP_SEC_FETCH_USER"=>"?1",
 "HTTP_ACCEPT"=>"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3",
 "HTTP_SEC_FETCH_SITE"=>"none",
 "HTTP_SEC_FETCH_MODE"=>"navigate",
 "HTTP_ACCEPT_ENCODING"=>"gzip, deflate, br",
 "HTTP_ACCEPT_LANGUAGE"=>"ja,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh-HK;q=0.6,zh;q=0.5",
 "HTTP_COOKIE"=> … 略
```

```config.ru
require 'rubygems'
require 'bundler'
Bundler.require

require "./app"

run App.new
```

Rackの`use` と `run` について
use: 後述のRackアプリケーションを指定する
run: 後述のRackミドルウェアを指定する

```shell
❯ bundle exec rackup
[2019-11-02 01:20:02] INFO  WEBrick 1.4.2
[2019-11-02 01:20:02] INFO  ruby 2.6.3 (2019-04-16) [universal.x86_64-darwin19]
[2019-11-02 01:20:02] INFO  WEBrick::HTTPServer#start: pid=4316 port=9292
```

このコマンドを実行後  `localhost:9292` にアクセスします。
`Hello world` という文言が表示されると思います。

### WEBrickの代わりにpumaを利用する（おまけ）
Gemfileにpumaをインストールして、WEBrickからpumaにアプリケーションサーバーを変更します。

```ruby:Gemfile
gem 'puma'
```

WEBrickはシングルスレッドなのに対して、pumaはマルチスレッドとして実行することができるためWEBrickより速度が速い場合が多いです。
[GitHub - ruby/webrick: HTTP server toolkit](https://github.com/ruby/webrick)
[GitHub - puma/puma: A Ruby/Rack web server built for concurrency](https://github.com/puma/puma)

rubyの標準ライブラリに`benchmark`というライブラリがあるのでそれで測ってみました。
https://docs.ruby-lang.org/ja/2.6.0/library/benchmark.html

```
                 user     system      total        real
WEBrick      0.000006   0.000002   0.000008 (  0.000004)
puma         0.000006   0.000001   0.000007 (  0.000003)
```
pumaのほうが少し速い。これちゃんと測定できているのか？？

## ルーティングを作成する
```app/routes.yml
‘/‘: ‘main#index’
‘/hello’: ‘main#hello’
‘/posts’: ‘posts#index’
```
左が`URLのパス`で、右が`コントローラ名#アクション名`です。
パスとコントローラ#アクションの対応させるハッシュを後に作成するためです。

```lib/router.rb
class Router
  attr_reader :routes

  def initialize(routes)
    @routes = routes
  end

  def resolve(env)
    path = env[‘REQUEST_PATH’]
    if routes.key?(path)
      ctrl(routes[path]).call
    else
      Controller.new.not_found
    end
  rescue Exception => error
    puts error.message
    puts error.backtrace
    Controller.new.internal_error
  end

  private

  def ctrl(string)
    ctrl_name, action_name = string.split(‘#’)
    klass = Object.const_get “#{ctrl_name.capitalize}Controller”
    klass.new(name: ctrl_name, action: action_name.to_sym)
  end
end
```
`env['REQUEST_PATH']`はアクセスしたURLのパスを取得することができます。このパスによって、`404 not found`にするかコントローラのアクションを実行するかを判断します。
それ以外のサーバーエラーの場合は`500 error`を出すようにします。


```app.rb
require 'yaml'
ROUTES = YAML.load(File.read(File.join(File.dirname(__FILE__), 'app', 'routes.yml')))

require './lib/router'

class App
  attr_reader :router

  def initialize
    @router = Router.new(ROUTES)
  end

  def call(env)
    result = router.resolve(env)
    # [200, {}, [‘Hello world’]]
    [result.status, result.headers, result.content]
  end
end
```

ここでは、コントローラ指定されたステータス、ヘッダー、コンテンツを指定できるようにします。

## コントローラを作成する
Controllerの親クラスを作成します。
コントラクタでは、controllerの名前とアクションを初期化します。

```lib/controller.rb
class Controller
  attr_reader :name, :action
  attr_accessor :status, :headers, :content

  def initialize(name: nil, action: nil)
    @name = name
    @action = action
  end

  def call
    send(action)
    self.status = 200
    self.headers = {"Content-Type" => "text/html"}
    self.content = ["Hello world"]
    self
  end

  def not_found
    self.status = 404
    self.headers = {}
    self.content = ["Nothing found"]
    self
  end

  def internal_error
    self.status = 500
    self.headers = {}
    self.content = ["Internal error"]
    self
  end
end
```

ここまで実装すると、`localhost:9292` 以下のURLにアクセスするとHello Worldを表示します。

### ルーティングに対応するコントローラを作成する
`MainController.rb`, `PostsController.rb` を作成します。

```app/controllers/main_controller.rb
class MainController < Controller
  def index
    @title = 'MainController#index'
    @arr = %w(one two three)
  end

  def hello
    @hello = 'hello'
  end
end
```

indexアクションには、インスタンス変数 @titleに文字列、@arrには[‘one’, ’two’, ‘three’] という配列を入れます。
同様 hello アクションには @helloに文字列を入れます。

```app/controllers/posts_controller.rb
class PostsController < Controller
  def index

  end
end
```

前述したControllerクラスを継承することで、例外などに対応することができます。

```app.rb
Dir[File.join(File.dirname(__FILE__), 'lib', '*.rb')].each {|file| require file }
Dir[File.join(File.dirname(__FILE__), 'app', '**', '*.rb')].each {|file| require file }
...
```
app.rbからコントローラがわかるようにパスを指定します。

## ビューファイルを作成する
今回は`slim`を利用します。

```ruby:Gemfile
gem 'slim'
```

以下を追加します。

```lib/controller.rb
def call
    send(action)
    self.status = 200
    self.headers = {"Content-Type" => "text/html"}
-	   self.content = ['Hello world']
+    self.content = [template.render(self)]
    self
  end

+  def template
+    Slim::Template.new(File.join(App.root, 'app', 'views', "#{self.name}", "#{self.action}.slim")) 
+  end
```

テンプレートエンジンにslimテンプレートを指定します。
また、コントローラ#アクションに応じたビューファイルがレンダリングされるようになります。
つまり、`main#index`に対応するビューファイルは `views/main/index.slim` となります。

```app.rb
…
class App
…
  def self.root
    File.dirname(__FILE__)
  end
end
```


```app/views/main/index.slim
h1 = @title
- @arr.each do |elem|
  li = elem
```

ここで、同様に `bundle exec rackup` でサーバーを起動し、 `localhost:9292`にアクセスすると以下のように表示されます。存在しないルーティングでは `Nothing found`
`localhost:9292/hello` では `Internal error`が表示されます。これはルーティングとアクションがあるにも関わらずビューファイルがないためですね。
![スクリーンショット 2019-11-02 3.19.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/9a43fb8a-891e-4637-b244-a219f78d8868.png)


## モデルを作成する
DBを使用しやすくするためにORMである`sequel`gemを使用します。
https://github.com/jeremyevans/sequel

```ruby:Gemfile
gem 'sequel'
```

モデルクラスを作成していきます。 `Sequel::Model` を継承していきます。
```post.rb
class Post < Sequel::Model(DB)
end
```

```app/controllers/posts_controller.rb
class PostsController < Controller
  def index
+    @posts = Post.all
  end
end
```
Postモデルの全データを@postsに格納します。

ビューファイルも作成しておきます。

```app/views/posts/index.slim
- @posts.each do |post|
  h2 = post.title
  div = post.content
  hr
```

### DBを用意します
DBとしてsqlite3を用います。

```ruby:Gemfile
gem 'sqlite3'
```

Database.ymlにDBの設定を記述します。使用するDBのパスを書きます。

```app/database.yml
adapter: sqlite
database: ‘./app/db/development.sqlite3’
```

`DB = Sequel.connect(config)` で `database.yml`の設定を読み込みDBへ接続します。

```app.rb
db_config_file = File.join(File.dirname(__FILE__), ‘app’, ‘database.yml’)
if File.exist?(db_config_file)
  config = YAML.load(File.read(db_config_file))
  DB = Sequel.connect(config)
  Sequel.extension :migration
end

Dir[File.join(File.dirname(__FILE__), ‘lib', '*.rb’)].each{ |file| require file }

if DB
  Sequel::Migrator.run(DB, File.join(File.dirname(__FILE__), 'app', 'db', 'migrations'))
end

Dir[File.join(File.dirname(__FILE__), ‘app’, ‘**’,’*.rb’)].each{ |file| require file }
…
```

`Sequel::Migrator.run(DB, File.join(File.dirname(__FILE__), 'app', 'db', 'migrations'))` で `app/db/migrations/` のマイグレーションファイルを実行し、テーブルを作成します。

マイグレーションファイルを作成し、postsテーブルを作成します。

```app/db/migrations/001_create_table_posts.rb
class CreateTablePosts< Sequel::Migration
  def up
    create_table :posts do
      primary_key :id
      column :title, :text
      String :content
      index :title
    end
  end

  def down
    drop_table :posts
  end
end
```

このままでは、データがないので、seedファイルも作成します。

```app/db/seeds/posts.rb
Post.create(
    title: 'Post title1',
    content: 'dummy text…'
)

Post.create(
    title: 'Post title2',
    content: 'dummy text…'
)
```
こうすることで、 `bundle exec rackup` をした際に、テーブルとダミーデータが作成されます。

![スクリーンショット 2019-11-02 4.07.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154845/39f89535-9033-6fac-a98f-702ab3ff7919.png)

## ディレクトリ構成
ここまで簡易的なRailsライクなWebフレームワークを作成してきました。
最後にディレクトリ構成を記載します。

```
app/
├── controllers
│   ├── main_controller.rb
│   └── posts_controller.rb
├── database.yml
├── db
│   ├── development.sqlite3
│   ├── migrations
│   │   └── 001_create_table_posts.rb
│   └── seeds
│       └── posts.rb
├── models
│   └── post.rb
├── routes.yml
└── views
    ├── main
    │   └── index.slim
    └── posts
        └── index.slim
lib/
├── controller.rb
└── router.rb

app.rb
config.ru
Gemfile
Gemfile.lock
```

## Githubリポジトリ
また、最終的な[リポジトリ](https://github.com/TaigaMikami/my_web_framework)です。

## 参考資料
[GitHub - rack/rack: a modular Ruby webserver interface](https://github.com/rack/rack)
[GitHub - jeremyevans/sequel: Sequel: The Database Toolkit for Ruby](https://github.com/jeremyevans/sequel)
[How to write an MVC framework in Ruby | articles about programming on mkdev](https://mkdev.me/en/posts/how-to-write-an-mvc-framework-in-ruby)
[Rack解説 - Rackの構造とRack DSL - Qiita](https://qiita.com/higuma/items/838f4f58bc4a0645950a)
[第23回　Rackとは何か（1）Rackの生まれた背景：Ruby Freaks Lounge｜gihyo.jp … 技術評論社](https://gihyo.jp/dev/serial/01/ruby/0023)
[Ruby でベンチマークを取る方法 - Qiita](https://qiita.com/scivola/items/c5b2aeaf7d67a9ef310a)
[【ruby小ネタ】$: や File.dirname(__FILE__)　が何なのかさっぱり分からなかった件 - 東京伊勢海老通信](https://altarf.net/computer/ruby/509)