iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 27
0
Modern Web

向 Rails 致敬!30天寫一個網頁框架,再拿來做一個 Todo List系列 第 27

[DAY 27] 復刻 Rails - Routing 威力加強版 - 1

昨天我們做了一個 MiniSinatra 來實作 routing的部分,今天我們將會運用一樣的方式在我們的 Mavericks 加上這個功能,用 DSL 來寫 routing,另外接下來這兩天的文章內容會有點複雜,如果有寫不清楚的地方或是錯誤的地方,再麻煩大家在底下留言告訴我

了解 config/routes.rb

我們先來回想一下 Rails routing 的寫法

Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  root 'tasks#index'
  resources :tasks
end

在之前我們已經實作 Rails.application,接下來我們要實作 routes 這個 instance,會用 draw 來實作加入規則的部分,就如同昨天的 add_route

另外我們發現一件事情是,如果我們在 Rails 專案裡面執行 rake middleware,會發現 routing 也是 Middleware 的其中一員(詳情參考官網)

.
.
(略)
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run MyApp::Application.routes

知道這些東西後,我們就知道實作方法,首先第一步,我們要先為 just_do 加上 config/routes

Mavericks.application.routes.draw do
  root to: 'tasks#index'
  resources :tasks
end

我們替 just_do 加上一個 root 設為我們的首頁,並且加上 taskresources,一個 Rails 很常見的 routing 規則,接著就回到 Maverciks 來實作這部分

修改一下 application.rb

看一下原先 application.rb 的 call method 寫法,我們在 Rack 一執行應用程式時,就把規則寫死在 routing.rb 這個檔案裡面

module Mavericks
  class Application
    def get_controller_and_action(env)
      before, cont, action, after = env["PATH_INFO"].split('/', 4)

      cont = cont.capitalize
      cont += "Controller"

      [Object.const_get(cont), action]
    end
  end
end

在之前將網址用很簡單的方式來做判斷,直接預設網址就是 contoller/action 的格式,並且用簡單的字串分割來取出 Controller 和 Action,現在我們要捨棄這樣的做法,改讓開發者可以自己自訂 routing

所以先把 application.rb 做重構

# mavericks/lib/mavericks/application.rb

module Mavericks
  class Error < StandardError; end

  class Application
    def default_middleware_stack
      Rack::Builder.new
    end

    def app
      @app ||= begin
        stack = default_middleware_stack
        stack.run routes
        stack.to_app
      end
    end

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

    def call(env)
      app.call(env)
    end

    def self.inherited(klass)
      super
      @instance = klass.new
    end

    def self.instance
      @instance
    end

    def initialize!
      config_environment_path = caller.first
      @root = Pathname.new(File.expand_path("../..", config_environment_path))

      raw = @root.join('config/database.yml').read
      database_config = YAML.safe_load(raw)
      database_adapter = database_config['default']['adapter']
      database_name = database_config[Mavericks.env]['database']
      ActiveRecord::Base.establish_connection(database_adapter: database_adapter, database_name: database_name)
      ActiveSupport::Dependencies.autoload_paths = Dir["#{@root}/app/*"]

      load @root.join('config/routes.rb')
    end

    def root
      @root
    end
  end
end

連同底下原本的 indexdefault_render 那些全部刪掉,讓 Application class 只做自己該做的事情,刪完後突然覺得乾淨許多

其中我們建立了幾個新的 method,第一個是 routesroutes 這個 method 呼叫時會回傳一個 instance 讓開發者加入 routing 規則,也就是讓 just_do 在 config/routes.rb 所呼叫的物件

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

另外我們用 Rack::Builder 來實作了簡單的 Middleware Stack

def default_middleware_stack
  Rack::Builder.new
end

def app
  @app ||= begin
    stack = default_middleware_stack
    stack.run routes
    stack.to_app
  end
end

stack.run routes 會將我們所寫的規則加入到 Middleware,讓每一次的 reuqest 都會做 routing 的處理

另外我們也必須在 initialize! 裡面把 config/routes.rb 一起 load 進來,跟資料庫設定是一樣的道理

def initialize!
  # .
  # .
  # (略)
  load @root.join('config/routes.rb')
end

修改完後,我們就要來處理 ActionDispatch 的部分

ActionDispatch

接下來我們就來實作 route_set 的部分,先新增 action_dispatch/routing/route_set.rb 並且加入以下程式碼

module ActionDispatch
  module Routing
    class Route
      attr_accessor :method, :path, :controller, :action, :name

      def initialize(method, path, controller, action, name)
        @method = method
        @path = path
        @controller = controller
        @action = action
        @name = name
      end

      def match?(request)
        request.request_method == method && request.path_info == path
      end

      def controller_class
        "#{controller.classify}Controller".constantize
      end

      def dispatch(request)
        controller = controller_class.new
        controller.request = request
        controller.response = Rack::Response.new
        controller.process(action)
        controller.default_render(action) unless controller.content
        controller.render_layout
        controller.response.finish
      end
    end

    class RouteSet
      def initialize
        @routes = []
      end

      def add_route(*args)
        route = Route.new(*args)
        @routes << route
        route
      end

      def find_route(request)
        @routes.detect { |route| route.match?(request) }
      end

      def draw(&block)
        mapper = Mapper.new(self)
        mapper.instance_eval(&block)
      end

      def call(env)
        request = Rack::Request.new(env)
        if route = find_route(request)
          route.dispatch(request)
        else
          [404, {'Content-Type' => 'text/plain'}, ['Not found page']]
        end
      end
    end
  end
end

RouteSet

這裡我實作了兩個 class,一個是 RouteSet,另一個是 RouteRouteSet 主要做的事情就是接到 env 的參數後,去分析要做什麼樣的事情,剛剛提到因為 RouteSet 也是 Middleware 的一部分,所以一樣要建立 call method 來處理 request

def call(env)
  request = Rack::Request.new(env)
  if route = find_route(request)
    route.dispatch(request)
  else
    [404, {'Content-Type' => 'text/plain'}, ['Not found page']]
  end
end

我們用之前提到過的 Rack::Request 來包裝處理傳進來的 env,將 request 傳遞給 find_route 做尋找判斷有沒有對應的 routing 規則,有的話就執行 Controller 的 Action,也就是 route.dispatch(request),如果沒有在規則裡面就判斷為 404 找不到頁面

Route

Route 我們可以稱做為一個個的 Routing「規則」,可以看到在裡面放了這個規則的

  • method: HTTP Methods
  • path: request 的路徑,也就是 env['PATH_INFO']
  • controller: 執行那個 Controller
  • action: 執行那個 Action
  • name: 替規則命名,可以建立 url helper,例如 root_path

所以如果是這樣寫

root to: 'tasks#index'

那對應的值就是

  • method: GET
  • path: /
  • controller: tasks
  • action: index
  • name: root

match? 昨天有提到過就是用來比對 request 與規則是不是符合

def match?(request)
  request.request_method == method && request.path_info == path
end

而整個規則真正執行 Controller 和 Action 部分就寫在這裡

def controller_class
  "#{controller.classify}Controller".constantize
end

def dispatch(request)
  controller = controller_class.new
  controller.request = request
  controller.response = Rack::Response.new
  controller.process(action)
  controller.default_render(action) unless controller.content
  controller.render_layout
  controller.response.finish
end

我們需要有一個 method 來將傳進來的字串 controller,轉換成常數也就是 Controller 的 class,另外也需要在 ActionSupport 裡面的 String 加上新的擴充來處理這段轉換

# mavericks/lib/active_support/string.rb

class String
  # .
  # .
  # (略)
  def classify
    self.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
  end

  def constantize
    names = self.split('::')
    names.shift if names.empty? || names.first.empty?

    constant = Object
    names.each do |name|
      constant = constant.const_get(name, false) || constant.const_missing(name)
    end
    constant
  end
end

這裡我們直接借用 Rails 的處理方式,做法是將 tasks 轉成像是 Tasks,最後再利用 const_getconst_missing 來尋找 class,細節部分就不太多的說明

而最後執行 dispatch 的部分,我們會 new 一個 Rack::Response 並且做 render 最後回傳 response

今天我們聚集在執行 Routing 的部分,明天我們會繼續來實作定義規則的部分,也就是將 DSL 轉換成定義規則

def draw(&block)
  mapper = Mapper.new(self)
  mapper.instance_eval(&block)
end

完整的程式碼明天會一起發布


上一篇
[DAY 26] 復刻 Rails - 關於 Routing
下一篇
[DAY 28] 復刻 Rails - Routing 威力加強版 - 2
系列文
向 Rails 致敬!30天寫一個網頁框架,再拿來做一個 Todo List30

尚未有邦友留言

立即登入留言