这是 ShenJS 上我分享内容的一个整理版,原来的题目就叫 Real-Time App Practice - What I Learned On Building BearyChat
主要就是分享一些用 Web 技术做实时的经验,不怎么涉及到长连接的技术,主要是应用层面的内容,大家可在 http://shenjs.meteor.com 看原 Slide。
我从大概2012年开始接触实时应用,自己就一直蛮感兴趣,业余时间做了一些小作品,可直到去年起开始着手打造 BearyChat 这个依赖实时的团队沟通工具,也终于能在工作中有机会做一个相对严肃的实时产品了。
对于实时应用,我曾经怀疑它的网络世界的重要性。对于维基百科,技术论坛,淘宝,搜素引擎这些东西它们不是实时的(指不会在不刷新页面的情况下获得更新)对我并没有什么影响。 那么实时真的重要吗?是不是有点搞技术的自己这边在幻想这个东西很重要。
后来从做了一段时间实时系统后,最大的感受是,实时是也是一种信息量。当你对一个东西对你来讲特别重要的时候,你就想尽快的知道关于它的任何信息,最新的更新有没有及时看到?还有谁也在看同样的内容?在这样一个场景下实时就是十分重要的。
BearyChat 是一个为工作设计的沟通工具,产品里有团队成员,讨论组,机器人,上传的文件等概念,技术上前端主要在用 Angular。 后端分为 API 服务器和长连接服务器,大部分 CRUD 工作主要依赖 API 服务器通过 AJAX 完成。长连接服务器则负责消息分发,尽量去掉业务逻辑,只转发 API 服务器发送过来的事件,并且也负责传递客户端发来消息。
+--------+ +------------+ +----+
| | ------ AJAX ----- | API Server |--| DB |
| | +------------+ +----+
| | | |
| Client | R +-----+
| | P |queue|
| Web / | C +-----+
| Mobile | | |
| | +-------------------+
| | -- SEND/RECEIVE -- | Keep-Alive Server |
+--------+ +-------------------+
很多网站的实时功能比如新消息提醒,一般都只是做为一个加分,即使没有也影响不大。 但是对 BearyChat 这样一个用于工作场景的系统来讲,要求就会高很多,比如:
一致性,保证大家看到的东西都是一样的,你所做的操作在哪里都要产生一样的效果。
及时性,要保证在有网络链接的时候,一有新的内容就要立即呈现出来。
稳定性,系统能长期的正常的工作,并且能外界条件不理想的情况下,表现出最合理的行为。
下面就是我们为了满足这些需求,去做了哪些努力。
要做好复杂的应用,基础要打牢,长连接部分需要有一个合适的协议,来让实时系统运转起来。
现有的 IM 协议里,XMPP 功能强大,有一定扩展性,又有现成的实现。我们在初期是采用他来启动的项目,但是做的过程中发现,XMPP 自己带有的业务逻辑太多,很多和我们需求并不匹配,在尝试了一段时间后放弃了。
后来有了自己对 IM 协议的理解后,我们最终选择了自己定制协议,下面是几个点。
-
使用事件类型来让客户端识别,可以灵活的处理不同的情况。
-
增加时间戳,让所有的收到的推送都能确定事件发生的时间,而不是依赖客户端收到的时间。
{
type: "update_user_connection",
ts: 1436560255813,
data: {
uid: "=bw52Q",
connection: "connected"
}
}
- 使用 Call ID 来做识别,和 API 的 Request/Response 模式不同,长连接的收发不能一一对应上。对于客户端发送的消息,就需要用一个客户端生成的 ID 用来识别对应的关系。
{
type: "channel_message",
call_id: 2,
channel_id: "=bw6Pg",
text: "shenjs"
}
{
type: "reply",
call_id: 2,
ts: 1436560264158,
key: "1436560264158.0000",
status: "ok",
code: 0
}
- 长连接里的数据内容和 API 服务器使用同样的结构和字段,有利于复用,减少出错的可能。
{
type: "channel_visible",
data: {
inactive: false,
description:null,
...
same as API return
...
}
}
- 浏览器自身很难及时知道长连接已经断开,需要定时的发送 ping/pong 请求来尽快知道连接已经断开。
{
type: "ping",
call_id: 3
}
{
type: "reply",
status: "ok",
ts: 1436695680405,
call_id: 3,
code: 0
}
另外介绍一些更多的选择: DDP 是 Meteor 前后端数据同步协议,还支持类似 RPC 的调用方式,抽象出来让你不必在关系接口的细节。 SwarmJS 是一个基于 CRDT 实时数据层,可以支持实时。
协议部分结束,进入前端的数据层,有这么几个方法论,可以帮助你让你应用真正的实时起来。
-
要知道在服务器的数据才是对的,客户端要做的是尽快尽可能和服务器的数据保持一致。
-
给所有的数据建立和唯一标识符的对应关系,客户端可以识别相同的数据实体。
-
按照单例的用法使用数据,也保证同样的对象全局唯一,比如说在一条消息里的用户,和用户列表里的数据都是同一个内存对象。
-
仔细检查数据获取更新的逻辑,能根据自己业务特点可以把逻辑整理清楚,确保数据一致和同步。
-
API 则需要保证可以获取请求时间点之前的历史数据。
-
在长连接建立后,要将前端需要的所有数据更新都推送到客户端。
-
长连接建立起来之后,再去获取历史数据,可以避免时间差造成的数据丢失。
-
对于时序上可能存在的问题,比如数据有先后依赖,需要有容错机制。
- 使用 emit/on 衔接 websocket client 和应用代码,按事件类型处理事件
(WebSocket Client)
onMessage = (e) ->
data = angular.fromJson e.data
$rootScope.$emit data.type, data
...
(Event Handlers)
@on 'channel_message', (event, params) ->
...
@on 'update_message', (event, params) ->
...
@on 'delete_message', (event, params) ->
...
- 对 API 和长连接使用同样的处理代码
(Channel Model)
joinHandler: (data) ->
@isMember = true
@clearUnread()
...
join: ->
API.joinChannel
id: @id
, (data) =>
@joinHandler()
.$promise
...
(Event Handlers)
@on 'join_channel', (event, params) ->
channel = Channels.getOrAdd params
channel.joinHandler(params.data)
以上是就我们做 BearyChat 经验分享,技术上并没有使用很先进的比较 Magic 的方案,主要考虑是希望各个环节都相对可控,不过不管那种方案其中道理也基本相通,希望能对实时有兴趣的读者有些帮助。