开源项目 自制开源 Web 框架的 40 天 - em-midori

dsh0416 · 发布于 2016年10月18日 · 最后由 bestjane 回复于 2017年1月18日 · 6412 次阅读
21472
本帖已被设为精华帖!

这篇文章写在 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

以后的计划

要做一个完善的开源框架的路是非常长的。短短列一下计划就有很多,比如:

  • 实现异步文件读写
  • 实现异步数据库读写驱动
  • 驱动接入数据库 ORM
  • 实现 helpers 帮助方法
  • 用中间件重构早期硬编码
  • ...

其实平时司空见惯的很多东西,仔细一想只会越来越多。但只要有良好的规划,稳健的迭代,相信最终都能克服的。

写下这篇文章与各位 Rubyist 共勉,虽然只做了微小的工作。希望 Ruby 社区能在越来越多开源项目的帮助下发展得越来越好。

最后,不要脸地求 Star:

(逃

共收到 34 条回复
1 Rei 将本帖设为了精华贴 10月18日 03:21
8744

厉害了我的哥!

9442

👍 这个logo几个意思?😄

11524

绿,健康的颜色。

2

再加上各种必要的 Middleware 以后就慢下来了 😄

1342

在日语里面, みどり (读作midori)写出来的汉字就是

96

厉害,高大上

1107

已经读完代码并且骗了几个PR了,还有很多工作要做的,很担心随着功能增多,效率和 Sinatra 趋同。不过,如果提供完全的异步IO,你说的场景肯定会比目前市面上的快吧

21472

#8楼 @jasl 现在这东西还是有很多地方值得性能优化的。特别是路由匹配的部分,Ruby 的正则是一个 NFA 的实现,如果徒手写一个 DFA 的实现的话,足够完成现在的需求,但匹配的算法复杂度可以降到线性。但现在功能还不是很全,暂时还没有考虑这么多。

1107

#9楼 @dsh0416 我其实第一个看的部分就是路由XD 现在的实现太初级了,我记得之前我好像在跪圈群里介绍过 mustermann ?Rails的Journey强大归强大,但是代码可读性太差了

我在想的是路由抽成接口 RouterEngine,然后写一个简单的Wrapper MustermannRouterEngine 就接入进来了,以后要是有更高的性能实现也容易替换

其他的部分过了一遍但是现在印象不深了

27

#10楼 @jasl 同意!router 应该抽象成 Middleware 比较好。 想要把社区 IO 相关的库转成 EM 模式,任重道远啊!

12楼 已删除
1638

好厉害。

21472

感谢诸位如此慷慨的 Star,人生第一次上 GitHub Trending 了。。。

以及感谢各位的意见 Benchmark 跟踪和路由匹配的重构都将会尽快

突然发现成为了过去 24 小时加 Star 最多的 Ruby 项目。。。受宠若惊。。。

9965

恶狠狠的点了个赞, 并且收藏. 并且github 加星.

9800

星星大户。。

4584

赞!!!!!!!!!!!!!

96

一个是我们做的某个项目,需要对政府的接口做代理并封装。而这个政府的 API 接口访问奇慢,以至于出现了严重的阻塞导致了性能问题。在一台四核的机器上最后只有 20 req/s 的性能,简直是不能忍受。

你希望的非阻塞对于你说的这个引用场景来说是饮鸩止渴啊,如果服务依赖一个阻塞的点(在这里就是政府的接口),那非阻塞接收的请求越多,这个单点处理的能力就只有 20 req/s,其它接收的请求要么返回失败要么等着,如果等着,那压力全部打在阻塞的部分,有可能直接把服务拖死。我司也用完全非阻塞的 web 框架,已经碰到过几次类似的问题了,最经典的场景就是服务强依赖数据库,数据库是阻塞的,接收得请求太多,服务端倒是跑得欢了,然而结果就是直接 hang 死数据库,导致整个服务都不可用,这甚至比低 qps 更差劲。即使没 hang 死数据库,因为非阻塞模型基本都需要进行事件轮询,等待的请求多了导致每一个请求的响应时间会进一步增长,造成更大的压力。

什么场景适合完全的非阻塞?非 CPU 密集、非 IO 密集、高并发。所以绝大多数的 web 场景根本用不上非阻塞的 web 框架,我能想到的业务场景也就 IM、推送、MMORPG 游戏适合用。其它场景,比方说要经常读写数据库的,用不用说实话没什么提升,从工程层面提升数据库的性能才是最要紧的。

96

看 benchmark 拿阻塞的 RoR 和 Sinatra 来比,胜之不武啊~ Sinatra 有没有接 EventMachine 或者之类的非阻塞版本,更想看这之间的对决~ (不过如果是纯返回 hello world 的场景,只要还有内存,可 handle 的 request 就可以直线起飞,快慢好像已经不是那么重要了。。

21472

#19楼 @gwotzehsing Sinatra 用 thin 的话就已经相当于包了一层异步了。sinatra-synchrony 又不维护了。。。其实裸跑 hello world EM 本身倒是有性能限制。。。不过加上一些别的东西就不算限制了。之后会把数据库驱动都做一遍。。。至少会把我平时用到的东西尽可能都搞一下。

244

之前没有关注这个项目,不过这个轮子很多人都尝试过,所以不敢过于乐观,但是对于作者,坚持做下去肯定是有很大收益的,加油!

21472

#22楼 @huacnlee 说实话,我现在感到压力巨大,因为这项目的进度还不是很完善,就已经被那么多人盯着给我做 code review 了。这几天都没怎么好好睡好都。。。

1026

#19楼 @gwotzehsing 需要一个request队列来解决这个磁盘瓶颈的问题。

并且监控队列的长度和“清request”的速度,观察系统的性能压力。

队列多了以后,平衡队列的数量来保证CPU的利用率。之后通过调度器来控制队列的依赖顺序及优先级,最后通过限制每个队列的长度,来控制延迟和吞吐量之间的关系。

IO优化都是这么一套玩法,应该没啥。

15999

厉害,感觉和 koa 好像, 中间件模型 以及 async await

9442

#24楼 @dsh0416 守住,加油。😄

15420

尝试了一下,挺有意思的,加油⛽️

244

#24楼 @dsh0416 别太有压力,开源项目一开始都是非常有趣而不是非常完善,所以该睡要好好睡

16154

值得期待

16899

支持一下丁老板

11147

#18楼 @gwotzehsing 要想让非阻塞的框架慢下来很容易,加锁、队列、调度什么的,方法一抓一大把; 要想让阻塞的框架快起来相对会难一些。

9572

👍 加油!

2938

加油!

3221

支持

21472 dsh0416 midori 百日记 中提及了此贴 12月16日 23:19
14793

加油! 👍

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册