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

dsh0416 · October 18, 2016 · Last by jakitto replied at June 03, 2017 · 13868 hits
Topic has been selected as the excellent topic by the admin.

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

(逃

Rei mark as excellent topic. 18 Oct 03:21

厉害了我的哥!

👍 这个 logo 几个意思?😄

绿,健康的颜色。

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

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

厉害,高大上

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

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

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

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

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

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

12 Floor has deleted

好厉害。

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

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

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

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

星星大户。。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

值得期待

支持一下丁老板

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

加油!

dsh0416 in midori 百日记 mention this topic. 16 Dec 23:19

看完了这个,我想用 Go 写一个 Web 框架……

39 Floor has deleted

最近打算用 Ruby 重写 Autumn,不过也只是重复制造 Rails~

槽点太多,我一时不知从何说起。我随手列举一下吧:

  1. 闭源,没有 Tutorial,Document 几乎不全,却声称可以:Just build your site with Autumn now!
  2. 就文档里提到的这些函数,基本上全部是 midori 当前进度的子集。midori 已经支持 MySQL 和 Postgres 数据库使用 Sequel ORM 的一切操作了。还支持 HTTP/FTP 无需额外声明回调的访问。
  3. 不是所有 MVC 都是 Rails。你就算用 Ruby 重写了你现在的框架,连 Padrino 都算不上,更不要说 Rails 了。
  4. 这个 v0.01 的版本号我无法做出评价
  5. 我不知道这个「另外业务比你的强,你这个只有骨架。」的结论是何处得出的。我暂时没有看到这个框架上对语法的任何优势,没有见到相关的说明,或者比较。
  6. 另外这个「我没用 NIO,而是直接用 TCP + 非阻模型」。。。我觉得你最好理解一下你说的这几个词汇。。。
  7. 另外这个 iOS 上跑 Ruby 的问题,可以学习一下 RubyMotion。
  8. 最关键的问题是,你就给我一个二进制,你让我怎么承认一下你的框架确实写得很好啊。。。总不能让我去搞 LLVM 反编译。。。
Reply to dsh0416

顶一个队长

Reply to dsh0416
  1. 我是跑来娱乐的,公司后来不用,就暂时放一边了(Cocoa / Foundation 是 macOS / iOS 专属,Linux 的 GNUStep 已挂,移植失败),最近已经用 C++ 重写,配合 nginx 负载均衡来用。
  2. 因为是内部的东西,而且,我没有你们这么爱分享(毕竟我不是乐意开源的,不知道是不是因为 AppStore 付费习惯了闭源主义)。
  3. 然后,我并不打算重写 Rails 那么多的业务逻辑,所以,我的原文意思是我不打算重复制造齿轮,实际我写的部分几乎也只有 PHP CodeIgniter 业务的一半,我还是喜欢轻量级。
  4. 我看了你的项目,你的项目没有数据业务部分,我的框架好歹不仅开发了服务器层,MVC 层是有的,我还重复制造了用于这个框架的“FMDB”(做过移动开发的应该知道这个),作为数据模型数据连接的底层。我看你的源码,mysql2.rb 都只不过实现了数据库的 exec。
  5. 我说的是我不像你用 Ruby 的 NIO 库,而是用纯 C 写的 TCP non-block,语言过于简练到让人误解。
  6. 我喜欢低调,多做少说,我预测你这套东西,迟早可能会放弃的 😅
  7. 噢对了,Rails 只是框架,是否 IO 阻塞,你可以换服务器的,就像 Perl - Plack,你可以跑在 HTTP::Server::Simple,也可以跑在 Starman。
  8. 事件模型服务器是 Node 本身设计了一套事件调度,我觉得,你是不是爱屋及乌然后又异想天开把它用 Ruby 重新制造?
  9. 静静在一边看你装逼 🎉
Reply to jakitto

别的我先不说。

噢对了,Rails 只是框架,是否 IO 阻塞,你可以换服务器的,就像 Perl - Plack,你可以跑在 HTTP::Server::Simple ,也可以跑在 Starman。

建议你重新读一遍这文章。

事件模型服务器是 Node 本身设计了一套事件调度,我觉得,你是不是爱屋及乌然后又异想天开把它用 Ruby 重新制造?

EventLoop 从上古世纪的 C 代码里就有了,还变成 Node 设计的了,我看连 Erlang 都不想说话。

然后,我并不打算重写 Rails 那么多的业务逻辑,所以,我的原文意思是我不打算重复制造齿轮,实际我写的部分几乎也只有 PHP CodeIgniter 业务的一半,我还是喜欢轻量级。

一头声称自己搞了有业务逻辑的 Controller,一头又说自己喜欢轻量级,我没有见过这么分裂的想法。

我还重复制造了用于这个框架的“FMDB”(做过移动开发的应该知道这个),作为数据模型数据连接的底层。我看你的源码,mysql2.rb 都只不过实现了数据库的 exec。

建议你回头重新认识一下元编程,再看看什么叫不过实现了数据库的 exec

说到自己没有的东西,一口一个轻量级;说到自己有的东西,一口一个你没有。一边说自己已经实现了业务逻辑,和 Rails 对比又说自己不打算重复造轮子。反而到了数据库的时候,又说自己重新造了 FMDB 的轮子而我只实现了 exec。

双标也不带这么玩的。

你要真觉得自己框架好,我觉得无所谓。但不能自己打自己脸地瞎搞啊。

因为是内部的东西,而且,我没有你们这么爱分享

你跑来说你的框架实现得更好,既不说好在哪里有不说实现细节也不开源最后告诉我是内部的东西。这和「这个世界上有神,但神是不可知的」有什么区别?

我喜欢低调,多做少说,我预测你这套东西,迟早可能会放弃的

那我来预测几件事吧。

  1. 微软,迟早可能会倒闭的。
  2. 上海房价,迟早可能会跌的。
Reply to dsh0416

你的原文明确写着:

我一开始就打算好用 EventMachine 作为事件模型的实现。然而如何减少恼人的 callback,我决定选择在 Fiber 上构建一个类似于 async/await 的语法来避免。至少,依靠元编程的力量,我们无需做把 ES7 编译成 ES5 这种奇怪的工作

你不是在提示你想说的是 JS 环境,然后与之关联的明显就是 node 的 EM

还有,元编程到处都是 DSL,是我最不喜欢的风格,元编程跟如何抽象 exec 没有任何关联,前者是风格,后者是功能。

我说的是,重复制造 FMDB 可以带来 OOP 的抽象,而你元编程到处都是 DSL 去二次拼接,让我联想起 C 语言的宏,到处都是宏,可读性真心差。

你用了才知道好和不好呀,为什么一定要说代码好与不好呢?Cocoa 彻底闭源,起身年代跟 MFC 几乎同时,但是真心比 MFC 强多了去了,Cocoa 视图抽象模型更正确。你说你某个开源框架有多好,因为开源,我觉得这个根本没有任何关联。FVWM 开源啊,怎么没人来吹捧了,Cocoa 的开源复制者 GNUStep 开源,你觉得跟 Cocoa 有可比性?

Reply to jakitto

你的 controller 很轻量,fmdb 实现得很轮子,model 很业务逻辑。我用了才知道好和不好,在下自愧不如。

Reply to dsh0416

在我的观点里,是很轻量了,你尝试对比 SSH 三重奏 😺 或者 Spring Boost 就可见了。

感谢你的 First try!谢谢你的用心~

Reply to dsh0416

不同的人站在不同的角度(或者你一直以来都是习惯于 Ruby 的元编程)去看,世界观是不一样的,我只是跟你说:你尝一下这杯 Autumn,味道如何,我觉得哪个哪个可能会比你的 midori 味道会如何,你可以完全不必在意。

你喝了,可以发表看法,喜欢与不喜欢,看个人。

我这边喜欢 OOP 分层实现,而不是元编程,但是同时又要兼具扩展性,顺带告诉你,Autumn 有个叫做 AutumnDatabase 协议(接口),可以扩展所有数据库,AutumnSQLite 是默认存储(Rails 不也是?),然后上层 AD 来调用,再上层用户态可以轻易 AM(AutumnModel) 读写。

其实模式比较传统,我后来用 Moo.js 重写了新的模式,到时候如果有机会,再改吧。

补充:噢,对了,就算你的是 non-block 的模式,其实 GIL 还是带来了另一个问题,如果不用 JRuby 或者 Rubinius 的话~

You need to Sign in before reply, if you don't have an account, please Sign up first.