最近发现知乎上有些人批评 Node.js,说 Javascript 的前后端统一是一个笑话。
“呵呵”。
所谓的统一当然是不可能的,前端自身都统一不了,何况前后端。不过,相当程度的重用是完全可行的。在这里我用一个实际的项目来说明,"i瑞士"。
该网站由瑞士国家旅游局立项、开发和维护,从新浪微博上不同的账号抓取和瑞士有关的内容,进行分词识别,打上不同的标签供用户分类浏览。这个产品的目的是,让关心瑞士资讯的用户可以有一个无干扰的、免广告、纯净的资讯获取环境(既有自动分类过滤,也有编辑人工审核)。
我是实现该网站的程序员,这是我做的第二个和前端有关的项目,第一个是 NextDay 的应用介绍网站 http://www.gotonextday.com。
这是一个人的项目,前后端一起开发,历时4个半月左右(最后上线光等备案和各种审核就花了小1个月)。
系统架构
在介绍前后端如何重用之前,首先需要了解一下系统架构:
从左到右来看:
Crawler
Crawler 要做这几件事情:
1. 从新浪微博抓取瑞士有关的微博信息。
2. 对这些信息进行分析和处理,包括:中文分词,微博标签获取,“i瑞士”的标签归纳,对于图片长宽的预取(浏览器布局用),对于优酷视频要获取元信息,短链接事先转换成长链接等,总之就是为后续程序干好各种脏活累活。
3. 根据不同的微博账号的来源和具体内容进行内容发布,有些内容可以直接发布;有些则需要编辑人工审核;有些则延时发布,给编辑一个处理缓冲等等。
chinese-seg 是我为这个项目写的分词框架,有兴趣的同学可以自己阅读 CoffeeScript 源码。本文中提到的我开源出来的几个 github repos 都没有时间写详细的说明文档,但是如果懂 CoffeeScript 的话不难读懂(不建议你看编译出来的 JS 代码,那是优化给机器执行的,不是给人看的)。
总之 Crawler 就是源源不断地将新浪微博的内容预处理之后送入不同的发布队列中(或者直接发布)。
DB
这里的 DB 不是指 MySQL、MongoDB 或者 Redis 这样现有的数据库管理系统,而是我自己写的数据存储服务,最最底层是用的 LevelDB。
之所以不用现成数据库管理系统,有以下原因:
这个项目的服务器都是托管在阿里云上的,而这种云OS的磁盘IO都比较慢,不适合直接安装既有的数据库服务(除了 Redis)。如果要购买阿里云的 RDS 专业的数据库服务,则有两个问题,第一,目前只有关系数据库的选择,而我要保存的数据用 ER 关系来表达并不太适用;第二,就是这些关系数据库没有 4G 以上内存都不太带得动,而者这造成价格呈指数翻上去。这种年年要交费的东西,省点就都是自己的。
其实如果所有内容在内存中都放得下,用 Redis 是很好的选择。NextDay 的后台服务就把用户的礼物数据都保存在 Redis 中,经过压缩和精简处理,1G 内存保存 5 年的用户数据都没问题(别拿来记 log 就好)。
至于阿里云的开放结构化数据服务(OTS)这种私有服务还真不敢现在就用。
至于为什么用 LevelDB 或者如何用,那就需要开一个专题来讨论了,有兴趣的同学可以从下面的视频入手,或者从 LevelUp repo 开始。
https://www.youtube.com/watch?v=C-SbXvXi7Og。
API Server
API Server 为浏览器提供 Websocket 的调用服务,也帮助实现新浪微博的 OAuth 认证,保存用户收藏以及后台转发微博等。
API Server 以 Client 的身份通过 TCP 连接 DB,以 Server 身份供浏览器通过 Websocket 调用。作为 Server,API Server 使用 connect 来完成基本的 HTTP 路由。由于 API Server 实现的 WEB 相关的功能非常少,因此没有劳动 express 的大驾。
Server Proto
既然都是 Server,那么 Crawler, DB 和 API Server 它们都共享一个公共的 Server框架,称为 server-proto。这是为 “i瑞士” 项目做的一个开源项目,同样是用 CoffeeScript 写的,缺少文档说明(对不起大家:( )。
server-proto 将 Server 常用的功能抽象出来,例如,configuration (配置信息获取),一个任务调度系统(基于 node-resque),redis 访问,通过 REPL 在运行时访问内部状态,supportData 用来实现自定义配置文件的获取与刷新,actions 用来载入自定义rpc方法实现,以及 stats(performance counter),streams实现自定义的 NodeJS 的 stream 插件等等。
和其他 Server Framework 不同,server-proto 没有包含任何通信协议相关的部分,其原因是我后面要讲的重点(天空飘来五个字,那都不是事(儿))。
由于缺少用法的说明和实例(例子都在 Crawler, DB, API Server 这些闭源项目中),所以目前不适合其他人阅读和使用,希望最终有机会做出一个完整的可被大家重用的 repo。
另外,我一直在想是用 Promise 还是 Generator + Promise 重写这个框架,但是也要看后面项目机缘了。
WEB CDN
用户看到的所有网页内容相关的 HTML、JS、CSS,IMAGE和SVG,都被部署到了七牛的CDN服务上。用七牛的原因很简单,它是我找到的唯一提供 Free Plan 的比较靠谱的服务商。所以,这个项目没有真正的 WEB Server。以上资产都是从开发机上,通过 Grunt 构建出不同的版本,然后直接部署到 Testing、Staging 或者 Production 环境中。对用户来说,也可以从根上就享受到 CDN 的速度,对我来说,则又省了一台云服务器:)。
浏览器代码的基础框架有两个,一个是 AngularJS,还有就是 NodeJS 。无论是 AngularJS 的框架本身,还是 NodeJS 系统的 Core Modules,本项目用到的 NodeJS User Land 的 Modules (NPM Modules),或者专为本项目写的代码,最终都通过 node-browserify 打包成一个 js 文件(modules 之间就是以 NodeJS 的 require 方式引用),minification 之后大约 439K,gzip 之后 138K。
在前端代码中集成 Node.JS,带来的最大好处就是前后端通讯模式的统一。
通讯模式
在“i瑞士”中,无论是两个后台 Server 之间的通信(API Server <-> DB,或者 Crawler <-> DB),还是 Browser 和 API Server,其通讯模式主要有两种:
RPC 和 States Synchronization(状态同步)。
RPC 模式就是 request/reponse 方式,Client 发起请求,然后等待 Server 的回应,这是大家都很熟悉的方式。不过有一点,之前 Server 和 Server 之间要走一种协议,而浏览器到 Server 之前则只能走另外一种协议(例如:WebSocket,或者 Comet, faye...)。
States Synchronization(状态同步)是指,当某一台服务器上的状态变化了,将自动同步到其他服务器,无需手工发起 RPC 请求。
Scuttlebutt-状态同步协议
在“i瑞士”中,两种方式都被大量使用。例如:用户进行“收藏”是一个典型的 RPC 调用,从浏览器到 API Server 到 DB。而天气信息则是状态同步的一个使用场景。
1. Crawler 从某天气服务商获取瑞士各大城市当前和未来的天气,随后通过 RPC 调用保存到DB 中。DB 是咱自己写的,因此会自动更新服务器上的保存 Weather 对象。
2. 其他 Server,例如: API Server 从一启动设置好将自己的 Weather 对象和 DB 的 Weather 进行同步。
3. 而每个浏览器访问 API Server 时,当 Websocket 连接建立后,也会将自己的 Weather 对象与 API Server 的 Weather 对象设定为同步。
如下图:
从安全角度考虑,DB -> API Server -> Browsers 之间的 Stream (是指 NodeJS Stream)都是只读的,也就是不允许 Browsers 反过来通过变更 Weather 对象来引起整个网络的 Weather 对象变化。
同步算法采用的是 Scuttlebutt(dominictarr 撰写),其基本原理是通过不同的 Peer 之间利用 Vector Clock 算法发现较新的状态,从而将这些较新的状态同步到自身,再扩散到其他将自己当做 Reader 的 Peers 上。
当时为了学习理解 Scuttlebutt 的原理和代码,我 Fork 了原始代码,写了一篇文档作说明,同时在原来的代码上加了很多注释。
Scuttlebutt 是基础同步算法,在其之上可以衍生出不同的数据结构的同步(编写 Scuttlebutt 的特定子类),例如,同步单层对象,多层对象,Global Counter,甚至包括协同编辑中的文档连续同步等等。当然,其同步的基准是时间,前提是各个 Peers 都拥有一致的时间(如果不仅仅是只读的)。有些场景不能保证时间的一致性,例如浏览器,那么先实现一个简单的时间同步算法作为前提。
实现 Scuttlebutt 并不简单。如果在没有 NodeJS 和 node-browserify 的世界中,我们只能用不同的语言,在不同的平台下都实现一遍。而现在,起码在浏览器前端和 NodeJS 的后端间实现状态同步都拥有完全相同的代码。
dnode - 一个 RPC 的 JS 实现
那么如何在浏览器和 Server之间,以及 Server 与 Server 之间采用相同的 RPC Codebase 呢? 这就要感谢同样是 node-browserify 的作者 substack 的 dnode 了。
dnode 实现了一种自由风格的 RPC 模式,无论是 Client 还是 Server 都可以自用声明自己所支持的方法原型,连接后相互交换(如果不需要 Server 调用 Client 的方法,那么仅仅需要 Server 告诉 Client 自己的方法原型即可)。这种方法原型的交换在 RPC 的概念中相当于互换 IDL,只不过不是事先绑定,而是动态交换的。
dnode 概念简单,易于使用,老少咸宜。但是最关键的,也是和 Scuttlebutt 一样的地方就是,通信的 peer 之间只要有 NodeJS stream 的管道即可,而不是绑定到某一种具体的网络协议上(如 TCP 或者 Websocket)。那么换句话说,只要我们让 TCP 或者 Websocket 支持 NodeJS 的 stream,即可自由地使用 stream 上的各种算法实现了。幸运的是,这些几乎都已经存在了。
Stream 和 网络协议
首先 NodeJS 的 Core Modules 中的 TCP 已经是 stream 的实现,所以 Server to Server 之间已经无需自己做了。而浏览器到 Server 之间,目前常用通信 Modules 有socket.io,SockJS, ws, engine.io 等等。他们都有 stream 接口的对应实现:socket.io-stream, shoes, websocket-stream, engine.io-stream。我选用的是 websocket-stream,因为它不像其他框架,都实现了浏览器不支持 Websocket 的 fallback。这一点我不需要,因为 IE 10 之前我都不支持(其实连 IE10我都不想支持啊:( )。
因此,无论是浏览器还是 Server,都拥有了相同的 RPC 框架和同步框架,于是就只剩下了最后一个问题,连接复用。
连接复用
每个浏览器到 Server 的 Websocket 连接越少越好。如果仅仅是一般的基于 stream 的管道,一个管道就会消耗一个 Websocket 连接。那么 dnode,weather 同步就要消耗两个连接,而我要同步的东西可不仅仅是 weather。因此,在一个既有的 stream 上如何同时承载多个的其他 streams,则是要解决的新问题。
dominictarr 的 mux-demux 就是来解决这个问题的。我也搞了个 Fork,汉化了其 readme。另外,这里有一个例子,演示了如何在一个 Websocket stream 上完成 dnode RPC 调用 和 scuttlebutt 同步。
总结
上面提到的还只是最主要的重用部分。其实还有很多小地方也都复用了代码和算法,例如:网络连接的自动重连算法 reconnect-core 以及其 websocket-stream 的具体实现 reconnect-ws (这是我少有的直接用 JS 写的:) )。
读到这里大家可能也和我一样能体会到,如果没有 NodeJS 和 node-browserify,这个项目不可能由一个人在这么短的时间内完成的项目。如果前后台都由一个人来写,采用完全不同的技术平台,在同一时间段内是很割裂的事,就算能做,其项目复杂度也只能大大降低。
用好 NodeJS,深入理解和使用 Stream 是必须的。NodeJS 当年引入 Stream,就是看到管道操作在 Unix 上的巨大成功。这一层标准的抽象,虽然并不完美,却让不同的开发者不约而同地构造出高度可复用的代码。