这篇文章写在 em-midori 项目建立的第 40 天,从决定写这样一个框架,到现在勉强能用只花了 40 天里空闲的日子。从原先写开源代码光挖坑不填,到这次能把项目带到即将 development ready 的地步,确实在开发和思考的过程有非常多的不同。于是我变把这些地方记录下来,希望给以后的开发和诸位提供一些些帮助。
最早有类似于我开发的这个框架的想法大概已经过去了大半年的时间里。在此之前,我使用的 Web 框架主要是 Rails 和 Sinatra。Rails 的强大我想不必多述,在生产中大量使用 Sinatra 还是出于几个原因。一个是使用 Vue.js 做前端框架后,后端做 API 就足够了,而 Rails API Standalone 则是 Rails 5 发布的,相对来说晚一些;Sinatra 有一个设计良好的 DSL,在做路由的时候更加直观和灵活;最后就是 Rails 基于栈实现的路由匹配速度略慢,导致了大约是 Sinatra 性能的一半。
不过说起性能,无论是 Sinatra 还是 Rails 都是 IO 阻塞 的。主要的原因当然是 IO 非阻塞的框架在用起来的时候通常非常反人类。但 IO 阻塞模型在高并发或长连接下确实表现不佳,以至于 Rails 在 ActionCable 的实现中也不得不使用了非阻塞的 EventMachine。
在 Ruby 上比较有名的,出过三个非阻塞 IO 的 Web 框架,他们是 cramp,sinatra-synchrony 和 angelo。然而前两者都出现在 EventMachine 出现的早期,一个很早就停止维护了,而另一个也受限于完全照搬 sinatra 对异步仅限于网络 IO 的层面。而后者使用了 Celluloid::IO 作为事件模型的实现,然而其开发进度极慢,并有不维护的迹象,因为其特别的 IO 库,也愈发地有不维护的可能。
试图想改变这一问题之前,我考虑过很多问题。一方面是如何优雅地解决问题,而另一方面则是 Ruby 现有生态是否会接受。
此事出现转机主要是两件事。
一个是我们做的某个项目,需要对政府的接口做代理并封装。而这个政府的 API 接口访问奇慢,以至于出现了严重的阻塞导致了性能问题。在一台四核的机器上最后只有 20 req/s 的性能,简直是不能忍受。
另一件事是去 RubyKaigi 2016 听了不少的安利,发现许多演讲更是一个比一个激进。与其说去讨论现有生态是否会接受,不如先把事情做得足够优秀来得更重要。至少,我自己现在有用这东西的需求不是吗。
2016 年 9 月 9 日是 RubyKaigi 2016 的第二天,在 10:25,也就是某个日本人正要介绍如何更好地做语法绑定之前,我开启了这个弥天大坑。一开始考虑的问题是依赖、路线和 API 设计。
我一开始就打算好用 EventMachine 作为事件模型的实现。然而如何减少恼人的 callback,我决定选择在 Fiber 上构建一个类似于 async/await 的语法来避免。至少,依靠元编程的力量,我们无需做把 ES7 编译成 ES5 这种奇怪的工作,也不需要 import future,只需要把它当做框架理所当然的一部分就好了。
今天设计 Web 框架毫无疑问需要对 WebSocket 和 EventSource 进行良好的支持,而不是在 Rack 上打一层奇怪的补丁之类的。至少,我在设计 API 的过程中,参考了 sinatra, grape, rack, angelo 的设计,并阅读了相应的源代码,也是做好了完全的准备。所以,一开始在我脑中的 API 设计很快就变成了这样。
总体来说,它看起来很像 sinatra 和它的姊妹们。但稍有不同的还是有一些地方。
比如:
websocket '/websocket' do |ws|
ws.on :open do
ws.send 'Hello'
end
end
虽然,WebSocket 在 HTTP/1.1 上其实是个 GET 请求,但作为一个 DSL,不应该让开发者考虑底层是什么实现的,而是考虑应用上是怎么做的。所以把 WebSocket 和 EventSource 单独拿出来当动词。
还有例如:
post '/user/login' do
define_error :forbidden_request, :unauthorized_error
begin
request = JSON.parse(@request.body)
UserController.login(request['username'], request['password']).to_json
# => {code: 0, token: String}
rescue ForbiddenRequest => _e
Midori::Response.new(403, {code: 403, message: 'Illegal request'}.to_json)
rescue UnauthorizedError => _e
Midori::Response.new(401, {code: 401, message: 'Incorrect username or password'}.to_json)
rescue => _e
Midori::Response.new(400, {code: 400, message: 'Bad Request'}.to_json)
end
end
在这个例子中,考虑了一个路由的重要特性,那就是希望做到「路由即文档」。请求的类型、参数、可能的错误、错误处理方式都被代码本身描述出来了。唯独正确处理的返回需要单独写一下文档。
当然在这个例子中,还没有使用中间件,而使用中间件能更好解决问题。
相比 Rack 的中间件,我考虑再三,最后把其设计成 Android Xposed 那样 hook 的形式。简单来说中间件流程如下:
请求 -> 中间件 1 -> 中间件 2 -> 处理 -> 中间件 2 -> 中间件 1 -> 返回
这使得 JSON API 的实现变得非常容易,只需要把最靠近处理的那一层加入如下的中间件:
class JSONMiddleware < Middleware
def before(request)
request.body = JSON.parse(request.body) unless request.body == ''
request
end
def after(_request, response)
response.header['Content-Type'] = 'application/json'
response.body = response.body.to_json
response
end
def body_accept
[Hash, Array]
end
end
另外,中间件还允许跳过,类似于:
请求 -> 中间件 1 -> (跳过) -> 中间件 1 -> 返回
这使得实现登录校验功能的中间件变得异常方便和容易。
我在一开始就给项目设定了一些重要的指标,比如持续集成、code coverage 必须是 100%、code climate 评分必须是 4.0。并且提前设计了路线图。这之后被证明是非常有效的,极大且有效避免了我想偷懒而制造理由。这些东西我非常推荐在项目一开始就做。否则当中期加进去发现有巨多事情要做时,就基本失去了做这件事的动力。
真正激励到我的,是 v0.0.4
后的一次关于 Hello World 的性能 benchmark。
在 单核单线程 的情况下进行了如下框架的测试对比
框架 | req/s |
---|---|
Rails | 817 |
Rails API Mode | 930 |
Sinatra | 2563 |
express.js | 6129 |
em-midori | 11440 |
虽然一些特性还没有实现,所以跑得会比之后版本更快。但这充分说明了 Ruby 不但不慢,而且可以非常快。这大大增加了我继续开发的动力。
关于元编程,我最喜欢的一句话来自于《Ruby 元编程》的后记:
元编程不过是编程。
Meta Programming is Just Programming.
元编程做到不伤害,是重要且困难的。在 DSL 中使用元编程实际上是对元编程本身的约束。也就是说,你希望给用户一个无害的新特性。比如路由中 define_error
的实现如下:
module Kernel #:nodoc:
# This method is implemented to dynamically generate class with given name and template.
# Referenced from {Ruby China}[https://ruby-china.org/topics/17382]
def define_class(name, ancestor = Object)
Object.const_set(name, Class.new(ancestor))
Object.const_get(name).class_eval(&Proc.new) if block_given?
Object.const_get(name)
end
def define_error(*args)
args.each do |arg|
class_name = arg.to_s.split('_').map { |word| word[0] = word[0].upcase; word }.join
define_class(class_name, StandardError)
end
end
end
与其直接暴露一个 define_class
,暴露 define_error
就能完成我们对路由定义错误的需求,也不至于被滥用。设计元编程 API 的时候需要多考虑类似的问题。但实际上,好的 DSL API 的设计本身不就是对这些问题能否考虑周到吗?
Ruby 对元编程极好的支持是 Ruby 重要的特性,与其说害怕而放之不用,不如说好好学习如何无害地使用。比如当我试图加入 await 关键字的时候,我一点都不觉得害怕,因为加入带来的好处,以及清晰的实现思路使得其变成非常自然的事情。这也正是「元编程不过是编程」的含义。
在 WebSocket 开发中,由于是通过阅读 RFC 标准实现的,实现出了许多常见客户端并没有的功能。这使得测试变得异常困难。这些一开始考虑时根本没想到会遇到的问题,无疑会严重影响开发的进程。这个测试问题甚至导致了路线图的修改和部分的延误。但这些问题与其说避免,不如说只有不断遇到才能以后更好解决。100% 的测试覆盖率非常难达到,但既然是奔着负责的开源项目去的,又是一个框架项目,这样的问题是不能逃避的。
一开始甚至想要重新造一遍客户端轮子。但再三思考下,还是放弃而直接写入二进制数据流的方式来处理。
it 'should decode masked Hello String correctly' do
websocket.decode(StringIO.new([0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58].pack('C*')))
expect(websocket.msg).to eq('Hello')
end
要做一个完善的开源框架的路是非常长的。短短列一下计划就有很多,比如:
其实平时司空见惯的很多东西,仔细一想只会越来越多。但只要有良好的规划,稳健的迭代,相信最终都能克服的。
写下这篇文章与各位 Rubyist 共勉,虽然只做了微小的工作。希望 Ruby 社区能在越来越多开源项目的帮助下发展得越来越好。
最后,不要脸地求 Star:
(逃