如何构建Sinatra?

680 查看

今天是伟大的爵士乐大师法兰克.辛纳区(Frank Sinatra)诞辰一百周年。天时地利人和,正是翻译这篇文章的好日子,错过再等一百年。

杭州此刻在下雨,阴冷潮湿。耳边是摇曳的爵士乐,我把猫关进了阳台,打开电脑,开始胡说八道。

(可以跳过正文直接看最后的完整代码)

原文链接:https://robots.thoughtbot.com/lets-build-a-sinatra


构建一个Sinatra

Sinatra是一个基于Ruby的快速开发Web应用程序基于特定域(domain-specific)语言。在一些小项目中使用过后,我决定一探究竟。

Sinatra是由什么组成的?

Sinatra的核心是Rack。我写过一篇文章关于Rack,如果你对Rack的工作原理有些困惑,那篇文章值得一读。Sinatra在Rack之上提供了一个给力的DSL。来看个例子:

get "/hello" do
  [200, {}, "Hello from Sinatra!"]
end

post "/hello" do
  [200, {}, "Hello from a post-Sinatra world!"]
end

当这段代码执行的时候,我们发送一个GET/hello,将看到Hello from Sinatra!;发送一个POST请求给/hello将看到Hello from a post-Sinatra world!。但这个时候,任何其他请求都将返回404.

结构

Sinatra 的源码,我们一起提炼出一个类似Sinatra的结构。

我们将创造一个基于Sinatra那种可继承可扩展的类。它保存请求路由表(GET /hello handleHello),当收到GET /hello请求的时候,能去调用handleHello函数。事实上,它能很好的处理所有的请求。当收到请求的时候,它会遍历一遍路由表,如果没有合适的请求,就返回404。

OK,开搞。

就叫它Nancy吧,别问我为什么。

第一步要做的事是:创建一个类,它有一个get方法,能捕获请求的path并找到对应的函数。


# nancy.rb

require "rack"

module Nancy

 class Base

 def initialize

 @routes = {}

 end

 attr_reader :routes

 def get(path, &handler)

 route("GET", path, &handler)

 end

 private

 def route(verb, path, &handler)

 @routes[verb] ||= {}

 @routes[verb][path] = handler

 end

 end

end

route函数接收一个动词(HTTP请求方法名),一个路径和一回调方法并保存到一个Hash结构中。这样设计可以让POST /helloGET /hello不会混乱。

然后在下面加一些测试代码:

nancy = Nancy::Base.new

nancy.get "/hello" do
  [200, {}, ["Nancy says hello"]]
end

puts nancy.routes

可以看到,Nancy使用了nancy.get替代了Sinatra的get显得没那么简洁,本文最后会解决这个问题。

如果我们这时执行程序,会看到:


{ "GET" =\> { "/hello" =\> \#\<Proc:0x007fea4a185a88@nancy.rb:26\> } }

这个返回结果,我们的路由表工作的很好。

引入了 Rack 的 Nancy

现在我们给Nancy增加调用Rack的call方法,让它成为一个最小的Rack程序。这些代码是我的另一篇Rack文章中的:

# nancy.rb
def call(env)
  @request = Rack::Request.new(env)
  verb = @request.request_method
  requested_path = @request.path_info

  handler = @routes[verb][requested_path]

  handler.call
end

首先,我们从Rack的请求的env环境变量参数中的得到请求方法(HTTP/GET等)和路径(/the/path),然后根据这些信息去路由表中招对应的回调方法并调用它。回调方法需返回一个固定的结构,这个结构包含状态码、HTTP Header和返回的内容,这个结构正是Rack的Call所需要的,它会经由Rack返回给用户。

我们增加一个这样的回调给Nancy::Base

nancy = Nancy::Base.new

nancy.get "/hello" do
  [200, {}, ["Nancy says hello"]]
end

# This line is new!
Rack::Handler::WEBrick.run nancy, Port: 9292

现在这个Rack App已经能运行了。我们使用WEBrick作为服务端,它是Ruby内置的。

nancy = Nancy::Base.new

nancy.get "/hello" do
  [200, {}, ["Nancy says hello"]]
end

# This line is new!
Rack::Handler::WEBrick.run nancy, Port: 9292

执行ruby nancy.rb,访问http://localhost:9292/hello,一切工作的很好。需要注意,Nancy不会自动重新加载,你所做的任何改动都必须重新启动才会生效。Ctrl+C能在终端中停止它。

错误处理

访问路由表中处理的路径它能正常的工作,但是访问路由表中不存在的路径比如http://localhost:9292/bad你只能看到Rack返回的默认错误信息,一个不友好的Internal Server Error页面。我们看下如何自定义一个错误信息。

我们需要修改call方法

def call(env)
   @request = Rack::Request.new(env)
   verb = @request.request_method
   requested_path = @request.path_info

-  handler = @routes[verb][requested_path]
-
-  handler.call
+  handler = @routes.fetch(verb, {}).fetch(requested_path, nil)

+  if handler
+    handler.call
+  else
+    [404, {}, ["Oops! No route for #{verb} #{requested_path}"]]
+  end
 end

现在,如果请求一个路由表中没有定义的路径回返回一个404状态码和错误信息。

从 HTTP 请求中得到更多信息

nancy.get现在只能得到路径,但要想正常工作,它需要得到更多的信息,比如请求的参数等。有关请求的环境变量被封装在Rack::Requestparams中。

我们给Nancy::Base增加一个新的方法params

module Nancy
  class Base
    #
    # ...other methods....
    #

    def params
      @request.params
    end
  end
end

需要这些请求信息的回调处理中,可以访问这个params方法来得到。

访问 params

再来看一下刚刚添加的这个params实例方法。

修改调用回调这部分代码:

 if handler
-  handler.call
+  instance_eval(&handler)
 else
   [404, {}, ["Oops! Couldn't find #{verb} #{requested_path}"]]
 end

这里面有一些小把戏让人困惑,为啥要用instance_eval替代call呢?

  • handler是一个没有上下文的lambda

  • 如果我们使用call去调用这个lambda,它是没法访问Nancy::Base的实例方法的。

  • 使用instance_eval替代call来调用,Nancy::Base的实例信息会被注入进去,它可以访问Nancy::Base的实例变量和方法(上下文)了。

所以,现在我们能访问params在handler block中了。试试看:

nancy.get "/" do
  [200, {}, ["Your params are #{params.inspect}"]]
end

访问http://localhost:9292/?foo=bar&hello=goodbye,有关请求的信息,都会被打印出来。

支持任意的 HTTP 方法

到目前为止,nancy.get能正常的处理GET请求了。但这还不够,我们要支持更多的HTTP方法。支持它们的代码和get很相似:

# nancy.rb
def post(path, &handler)
  route("POST", path, &handler)
end

def put(path, &handler)
  route("PUT", path, &handler)
end

def patch(path, &handler)
  route("PATCH", path, &handler)
end

def delete(path, &handler)
  route("DELETE", path, &handler)
end

通常在POSTPUT请求中,我们会想访问请求的内容(request body)。既然现在在回调中,我们已经可以访问Nancy::Base的实例方法和变量了,让@request变得可见就好(迷糊的去翻上面的call方法代码):

attr_reader :request

访问requrest实例变量在回调中:

nancy.post "/" do
  [200, {}, request.body]
end

访问测试:

$ curl --data "body is hello" localhost:9292
body is hello

现代化进程

我们来做以下优化:

  1. 使用params实例方法来替代直接调用request.params

def params
  request.params
end
  1. 允许回调方法返回一个字符串

   if handler
-    instance_eval(&handler)
+    result = instance_eval(&handler)
+    if result.class == String
+      [200, {}, [result]]
+    else
+      result
+    end
   else
     [404, {}, ["Oops! Couldn't find #{verb} #{requested_path}"]]
   end

这样处理回调就简化很多:

nancy.get "/hello" do
  "Nancy says hello!"
end

使用代理模式继续优化 Nancy::Application

在使用Sinatra的时候,我们使用getpost来进行请求处理优雅强大又直观。它是怎么做到的呢?先考虑Nancy的结构。它执行的时候,我们调用Nancy::Base.new得到一个新的实例,然后添加处理path的函数,然后执行。那么,如果有一个单例,就可以实现Sinatra的效果,将文件中处理路径的方法添加给这个单例并执行即可。(译者注:这段的译文和原文没关系,纯属杜撰。如果迷惑,请参考原文)

是时候考虑将nancy.get优化为get了。
增加Nancy::Base单例:

module Nancy
  class Base
    # methods...
  end

  Application = Base.new
end

增加回调:

nancy_application = Nancy::Application

nancy_application.get "/hello" do
  "Nancy::Application says hello"
end

# Use `nancy_application,` not `nancy`
Rack::Handler::WEBrick.run nancy_application, Port: 9292

增加代理器(这部分代码来自Sinatra的源码):

module Nancy
  module Delegator
    def self.delegate(*methods, to:)
      Array(methods).each do |method_name|
        define_method(method_name) do |*args, &block|
          to.send(method_name, *args, &block)
        end

        private method_name
      end
    end

    delegate :get, :patch, :put, :post, :delete, :head, to: Application
  end
end

引入Nancy::DelegateNancy模块:

include Nancy::Delegator

Nancy::Delegator提供代理如getpatchpost,等一系列方法。当在Nancy::Application中调用这些方法的时候,它会按图索骥找到代理器的这些方法。我们实现了和Sinatra一样的效果。

现在可以删掉那些创建Nancy::Base::newnancy_application的代码啦!Nancy的使用已经无限接近Sinatra了:

t "/bare-get" do
  "Whoa, it works!"
end

post "/" do
  request.body.read
end

Rack::Handler::WEBrick.run Nancy::Application, Port: 9292

还能使用rackup来进行调用:

# config.ru
require "./nancy"

run Nancy::Application

Nancy的完整代码:

# nancy.rb
require "rack"

module Nancy
  class Base
    def initialize
      @routes = {}
    end

    attr_reader :routes

    def get(path, &handler)
      route("GET", path, &handler)
    end

    def post(path, &handler)
      route("POST", path, &handler)
    end

    def put(path, &handler)
      route("PUT", path, &handler)
    end

    def patch(path, &handler)
      route("PATCH", path, &handler)
    end

    def delete(path, &handler)
      route("DELETE", path, &handler)
    end

    def head(path, &handler)
      route("HEAD", path, &handler)
    end

    def call(env)
      @request = Rack::Request.new(env)
      verb = @request.request_method
      requested_path = @request.path_info

      handler = @routes.fetch(verb, {}).fetch(requested_path, nil)

      if handler
        result = instance_eval(&handler)
        if result.class == String
          [200, {}, [result]]
        else
          result
        end
      else
        [404, {}, ["Oops! No route for #{verb} #{requested_path}"]]
      end
    end

    attr_reader :request

    private

    def route(verb, path, &handler)
      @routes[verb] ||= {}
      @routes[verb][path] = handler
    end

    def params
      @request.params
    end
  end

  Application = Base.new

  module Delegator
    def self.delegate(*methods, to:)
      Array(methods).each do |method_name|
        define_method(method_name) do |*args, &block|
          to.send(method_name, *args, &block)
        end

        private method_name
      end
    end

    delegate :get, :patch, :put, :post, :delete, :head, to: Application
  end
end

include Nancy::Delegator

Nancy的使用代码:

# app.rb
# run with `ruby app.rb`
require "./nancy"

get "/" do
  "Hey there!"
end

Rack::Handler::WEBrick.run Nancy::Application, Port: 9292

我们来回顾一下都发生了什么:

  • 起名为N(T)an(i)c(r)y Sinatra(别问为什么)

  • 实现一个以来Rack的Web App

  • 简化nancy.getget

  • 支持子类化Nancy::Base来实现更丰富的自定义。

课外阅读

Sinatra的代码几乎全部都在base.rb。代码密度有点大,阅读完本文再去看,更容易理解一些了。从call!开始是个不错的选择。然后是Response类,它是Rack::Response的子类,请求返回的信息封装在这里。还有Sinatra是基于类的,Nancy是基于对象,有些在Nancy中的示例方法,在Sinatra中是作为类方法实现的,这也是需要注意的一点。