# 基于websocket的实时单页应用开发框架
## Introduction
### Background
目前绝大部分web应用的都是基于HTTP协议通尊,及由客户端主动发起请求的方式进行数据的交互,这种方式在基本上能满足大部分网站的功能需求,是非常完善的web应用解决方案。随着网络的普及和快速发展,数据的变化变得越来越快,用户的数据的实时性要求也越来越高,在对实时性要求较高的web应用上,基于HTTP通讯的方式就不太实用,因为HTTP请求只能从客户端主动发出,服务端的有新的数据的时候,客户端并不能第一时间获取得到。为了解决这一问题,基于HTTP的解决方案主要有轮询、长轮询和Iframe流。这种三种解决方案缺点就是会浪费很多带宽资源和服务器资源。
HTML5标准中定义了新的通讯方式:WebSocket,该通讯协议目的解决客户端和服务端之间的双向通讯问题,而且在主流浏览器中得到了广泛的支持。目前websocket在web上的应用主要有实时聊天、实时监控、游戏等。事实证明是一个非常可靠的技术。
单页应用是现在web开发的一个主流解决方案,它可以在一个页面中完成传统web应用中多个页面才能完成的复杂功能,而且性能更好,大大提升了用户体验。如果结合WebSocket技术,可以构建出的实时单页web应用,能够进一步提升效率,适应更多复杂的数据需求。
### Motivation
现在WebSocket的应用还不是非常广泛,一般在特定实时场景下才会在web应用中部分采用,相对HTTP请求,WebSocket模式有很大不同,在过于复杂的场景下,会增加开发难度,而且也缺少相对成型的解决方案和参考资料。
如果在不增加太多开发复杂度的情况下全站采用websocket进行通讯,并结合单页应用的特点,能够使web的体验会提高很多。在这种单页应用+websocket模式下,一个页面就完成所有功能,前后端只需要建立一个websocket链接就可以完成所有的数据交互。
所以我打算开发出一个单页应用+WebSocket通讯的web系统开发框架,让开发者可以快速开发出功能复杂的、高效的实时web应用,提供一个此类应用的解决方案和开发模式。
### Current Methods
##### 基于HTTP的实时通讯方案
**轮询**
客户端在每隔一段较短的时间里发送一次新的HTTP请求,服务器无论是否有新的数据的时候都会立即返回结果。这种方式在服务端没有新的数据的时候会产生很多无效的请求,从而浪费带宽和服务器资源。
**长轮询**
客户端发起的请求,服务端在没有新的数据下不会立即返回,而是挂载状态,直到有新的数据的时候再响应这次请求,或者挂载时间达到服务器超时时间限制时候,立即返回,告诉客户端继续发送下一个请求,如此循环。这种方案相对“轮询”方案可以减少HTTP请求次数,但是服务端在挂载请求的时候,依然需要消耗额外的资源,而且难以管理维护。
**iframe流**
该方案是在页面中插入一个隐藏的iframe,其src属性是一个长链接[?]请求,服务端可以不断的传入数据,客户端通过JavaScript进行处理,从而获取到持续更新的数据。这种方案的缺点依然是需要花费服务端额外的资源去维护长连接。
![长连接](../images/长连接.jpg)
##### 多页面应用
多页面应用是将一个web应用根据功能划分成多个html页面,每个页面完成部分功能,用户需要在多个页面之间跳转完成工作。每次页面跳转需要重新请求html文件以及相关的css和JavaScript文件,然后进行渲染。这是目前最常见的web应用架构模式,其缺点是页面之间跳转会因为需要重新下载资源和重新渲染缺乏连贯性,影响用户浏览体验,而且会增加额外的HTTP请求和页面再次渲染的资源消耗,多个页面之间的数据也难以在浏览器端实现共享,需要借助服务的去维护,从而增加前后端开发的耦合度。优点是开发简单,利于搜索引擎检索。
### Contributions
我所我打算实现一个基于websocket通讯的单页应用Web开发框架,方便开发者简单高效的开发出高性能的、实时的web应用。我们框架的主要特点有:
1. 快速上手:不会引入太多复杂的概念,便于开发者快速从HTTP开发模式中过渡到新框架。
2. 简单:降低websocket的开发复杂度。
3. 双向通讯:前后端可以方便主动推送数据,而不需要关心websocket的工作原理。
4. 高效:数据交互速度更快,更加节约资源
5. 低耦合:能够方便的开发出适合更多场景的web应用,甚至是app应用。
## Related Work
### WebSocket
绝大部分的web应用都是基于HTTP协议的,他简单可靠,经过多年发展,现在非常完善,但是由于其无连接的特点,所以每次只能处理一个请求,处理结束之后便断开,而且服务端无法主动发起请求,只能被动的接受请求。为了解决这个问题,于是IETF创建了WebSocket通讯协议,其主要特点是在单个TCP链接上进行全双工通讯,使得客户端和服务端都能够互相主动的推送数据。WebSocket建立链接的时候首先采用HTTP协议进行协商升级协议,后续数据传输再通过WebSocket协议去实现。建立WebSocket链接的过程如图所示。
![](../images/ws-connect.png)
成功建立链接后客户端和服务端就可以随时进行双向数据通信,后续每次通信都不需要携带完整头部信息,直接传输数据文本,更加节省带宽资源,而且还支持拓展子协议。
### React
React是用于构建用户界面的开源JavaScript库,诞生于FaceBook公司的工程师。React将数据和html封装成一个一个的组件,从而构成完整的页面,然后通过更改组件的state数据,使对应的html结构自动实现插入、删除、更改等操作,开发者不需要关注DOM的操作,react能够O(n)的时间复杂度内准确的实现复杂的dom更新,再结合浏览器的history api便能开发出功能复杂的web应用,用户也因此不需要频繁的切换页面便可完成复杂的业务需求。React的核心是虚拟DOM技术和Diff算法。
##### 组件化
React将html代码封装成独立的UI组件,组件之间可以相互嵌套构成复杂的组件,最终构成整个页面,可以简单理解成强化版的html标签。如下代码是一个结合jsx语法的组件实现,该组件是继承React.Conponent的类,其render方法返回的是要渲染的html标签,这种类型的组件可以获取到父组件传递的数据,也可以维护自己的数据,同时拥有生命周期事件和对应的方法。组件化让UI更加灵活,易于维护。
```js
class HelloMessage extends React.Component {
render() {
return (
<div>
Hello {this.props.name}
</div>
);
}
}
```
##### 虚拟DOM
React在数据更新的时候,根据更新后的状态用JavaScript构建DOM树,然后跟上次DOM树进行对比,找到两棵DOM树不同的地方,最后在浏览器真实DOM上对不同的地方进行更新,这样能够以最小改动去更新真实DOM结构,从而提高页面的性能。
##### Diff算法
DOM树的比对虽然是在内存中进行,但是比对操作会在频繁的触发,所以依然需要保证其高效性。直接找出两棵树的不同处的时间复杂度是O(n^3)。React在比对过程中,从上到下逐层比较,同一层级的节点比较时,如果是同一类型的组件,继续进行分层比较,如果组件类型不同,直接替换整个节点及子节点。这样只需要遍历一次树,就可以完成DOM树的比对,将算法复杂度降低到O(n)。
### Redux
Redux是一个开源的应用状态管理JavaScript库,提供可预测化的状态管理,Redux的思想是将视图和数据进行分离,从而实现前端的MVC模式开发[?],能够使程序更加直观、低耦合。Redux可以配合多种UI库使用,在配合React使用的时候,其前端结构图如下图所示。
![react-redux](../images/react-redux.png)
我们的框架采用redux作为数据管理器,在任何时候都可以方便的对组件的状态进行同步,让数据可以预测和维护。
## Our framework
### Architecture
本框架分为客户端部分和服务端部分。我们框架包含两种消息通讯机制,分别是立即消息和订阅消息,前者是用websocket实现类似HTTP请求的一对一消息,后者基本实现思想是发布和订阅模式,客户端自动通知服务端订阅内容,服务端在有数据更新的时候通知订阅的用户。
客户端是基于react和redux构建的单页应用,由携带数据的action触发store的更新,store更新触发视图的自动重新渲染,用户在视图上传发起action,从而形成一个闭环。我们框架在对react的组件进一步包装,使其在挂载和销毁的时候自动通过WebSocket通知服务端更新用户和redux的事件之间的订阅关系。同时服务端在WebSocket基础上实现了类似HTTP请求的立即消息机制,使得客户端可以发起能得到立即回复的请求,服务端有对应路由匹配和控制器处理请求,并可以处理服务的主动发布的逻辑,即根据订阅器里的用户订阅事件进行消息推送,消息数据内容为redux action 对象,被订阅的组件能够准确的收到消息并自动触发事件,从而更新store,最终自动更新视图。系统架构下图所示。
![e](../images/system.png)
##### 服务端架构
服务端主要由消息解析器、路由、控制器、订阅器和链接池组成。WebSocket接受到消息后会经过消息解析器中的请求中间件层层处理,然后构造成一个请求对象,交给相匹配的路由处理,路由会调用相关的控制器对当前请求进行业务逻辑处理,例如进行数据库操作等,处理完后,控制器可以根据订阅器中的用户订阅情况和连接池中的活跃用户构造返回内容,返回的内容依然会经过消息解析器中的返回中间件层层处理,最后通过WebSocket发送给用户。架构图图所示。
![e](../images/server.png)
我们框架中提到的路由不是传统框架中的路由,但是实现的是相同的功能,路由的作用是对HTTP请求的路径进行匹配,然后进行响应,事实证明这种方式能够有效的给每一个HTTP请求添加唯一标识。我们框架也借鉴这种方式,对客户端的每一条消息添加一个头部对象,该对象就包含url属性,服务端根据url建立路由机制,是框架能够处理多样的websocket消息。
结合中间件和路由就可以完成多种业务需求,为了实现自动推送新消息功能,我们用订阅器实现用户以及其订阅关系,框架内置一个值为/subsribe的路由,该路由接受body为redux事件名的请求,并添加当前用户与订阅事件到订阅器中,与之对应的/unsubscribe路由则是删除订阅器中当前用户对该事件的订阅。开发者可以创建任何需要的路由以及控制器实现需要的功能,例如登录、获取文章等需要立即返回的数据请求,客户端会通过请求的唯一标示来保证同一次请求与返回。
##### 客户端架构
客户端主要包括三个部分,视图、状态管理器和WebSocket消息处理器。我们框架的前端部分分两个子框架去实现,分别是jayce和jayce-dom,这样能够将视图层与数据处理层分离,以实现与多种视图框架配合使用,例如vue和angular等。首先我们框架通过传入redux的store对象和配置信息生成一个全局唯一的Jayce实例,该实例包含WebSocket执行方法和redux事件执行方法,实例化Jayce实例后,就会建立WebSocket链接,对于需要服务端主动推送新数据的内容,开发者在编写react组件的时候,可以调用jayce-dom的jayceSubscribe方法将任意组件包装成订阅组件,该组件在生命周期内会通知服务端当前用户订阅redux事件,在组件销毁的时候也请求服务端当前用户取消redux事件的订阅。对于立即消息,用户可以调用Jayce实例的send方法发送请求到服务端,其回调方法里会得到服务端返回的数据,编写上跟AJAX请求无异。前端架构如图所示。
![e](../images/jayce.png)
### 消息协议
除了浏览器初次渲染需要的文件通过HTTP请求外,我们让后续的所有的数据交互都通过WebSocket实现,而不同的类型的消息会有不同的处理逻辑,为了保证不同类型的消息能够被正确处理以及系统的可拓展性,我们在WebSocket的消息基础上建立了一个简单的协议。WebSocket传输的数据内容是字符串文本,在进行消息传递前,都需要经过message parser进行解析。发送端封构造json格式的请求对象,然后转换成json字符串交给WebSocket传输,接收端再解析成json对象,由于客户端和服务端都是基于JavaScript开发,所以json能够直接被处理。每个消息对象包含两个属性,header 和 body。body为数据内容,header有两个固有属性url和type,前者是路由标识,告诉服务端用哪个路由处理器处理,后者是请求类型,框架内置三种类型:IMMEDIATELY、SUBSCRIBE、UNSUBSCRIBE,分别是立即型消息、订阅型消息、取消订阅型消息。除此外,应用也可以根据需要添加其他header信息,从而实现更灵活的业务。
### 自动订阅组件
React通过编写组件的方式去构建浏览器DOM结构,每个组件都有自己的生命周期方法,我们框架在React组件的基础上进行了封装,封装通过`jayceSubscribe()`方法实现。该方法接受两个参数,第一个参数是需要订阅的redux事件数组,第二参数是实例化的Jayce对象,调用`jayceSubscribe()`方法后返回的是一个匿名方法,接受一个react 组件为参数,返回包装后的Jayce 组件。调用示例:
```js
export default jayceSubscribe(['GET_NEW_ARTICLE'], jayce)(Article);
```
其中Article为需要订阅的组件,最终导出的是经过Jayce包装的组件。
Jayce包装组件的过程:
1. 创建一个新组件
2. 在componentWillMount的生命周期事件里执行请求订阅方法
3. componentWillUnmount的生命周期事件里执行取消订阅请求方法
4. 添加需要被包装的组件作为子组件
5. 返回这个新组件
### 订阅器
为了实现服务端能够准确、主动的推送实时性数据,所以服务端需要维护一个用户的store订阅器。订阅器保存的是客户端的redux事件与用户的对用关系,数据结构为一个JavaScript对象,属性名为事件名称,值为订阅该事件的用户连接对象数组,框架内置的/subsribe和/unsubscribe路由及相关控制器实现了对订阅器的自动管理,对开发者而言无需关心订阅器内容,只需根据业务订阅和发布事件,对应的用户都能够自动获取到数据和触发redux事件。
### Message Parse
Message Parser是服务端和客户端可靠通信的枢纽,WebSocket只是建立服务端和客户端之间的全双工通信的渠道,所以我们在需要一个方便,可拓展的消息解析功能,使这些文本消息能够被框架正确识别和处理。通过上文约定的消息协议,在服务端,Message Parser会在服务启动的时候注册系统级和用户级中间件,接收到消息的时候Message Parser会根据消息内容构造json格式的请求对象,然后交个每个'request'型的中间件处理,最后到达路由处理器,服务端返回消息到客户端的时候,返回的消息对象依然会经过消息处理器中的'response'中间件处理,最后处理成为符合消息协议规定的文本内容交给WebSocket发送给客户端。
### middleware
中间件是框架的一个重要组成部分。每一个消息都会流经每一个中间件,这样创建合理的中间件能够完成各种业务需求,例如身份认证、统计分析、消息拦截等。一个中间就是一个方法,接受两个参数,第一个请求对象,第二个是执行下一个中间件的方法,当中间件被注册到框架后,该中间件方法接受到的分别是上一个中间件处理完后的请求对象和调用下一个中间件的方法。如下是框架内置的构建请求对象的中间件示例,它是请求阶段第一个中间件。
```js
function requestBodyParse(ctx, next) {
let req = JSON.parse(ctx.message);
if(req.header && req.body){
ctx.req = req;
next();
} else {
ctx.req = {
header: {
url: '/error'
},
body: ''
}
return;
}
}
module.exports = requestBodyParse
```
该中间件将接受到的消息字符根据消息协议规定格式串转换成对象,处理完后调用next()方法,执行下一个中间件。如果转换失败,则交给框架内置的/error路由处理。然后在框架实例对象上调用use方法注册到消息解析器中。
### Process
在实际使用中,主要存在三个服务端与客户端的交互方式,分别是客户端发送立即得到返回的消息、客户端发送订阅redux和取消订阅事件的消息、服务端发送执行redux事件的消息。
##### 立即请求返回流程
一个web应用中会有大部分操作是立即返回操作,需要马上得到结果。例如提交表单、打开文章详情页面等。这种消息需要保证请求与返回一一对应,即每一次客户端发送消息都会有服务端唯一应答,跟HTTP请求的特点一样。为了实现这一特性,客户端每次发出消息都会在请求对象头部添加唯一标识,并将其添加到请求等待队列,服务端处理请求后返回携带这个标识的消息对象,客户端得到返回后会去请求对待队列中查找并执行相关回调方法。流程简化后如图所示。
![e](../images/immediately.png)
##### 自动订阅流程
自定订阅消息是组件自动完成的,组件会在相关的生命周期方法里发送消息通知服务端订阅和取消订阅redux事件,然后服务端在订阅器中更新该用户对redux 事件的订阅关系。
![e](../images/subscribe.png)
订阅流程本质上跟立即请求消息相同,只是对应的服务端的路由控制器一般不需要返回消息,直接去更新服务器订阅器。
##### 服务端主动推送流程
触发服务端主动推送操作可以来自多种行为,例如其他用户提交数据操作、服务端定时任务等。其流程如图所示。
![e](../images/server-push.png)
只要能获取到框架实例对象,就可以随时在需要的逻辑中调用该实例触发广播、多播、单播操作。
## Experimental results 【待测试】
相对于HTTP模式的web应用,我们框架的主要优势在实现客户端和服务端的全双工通讯和更小的数据传输量上。因此我们通过三个实验对比HTTP服务器和我们框架服务器的性能。以下实验的服务器配置都是1核心处理器、2G运行内存,网络环境为公网。
### 实现全双工通讯功能服务器所需要的资源对比
### 相同数量用户并发下服务器内存消耗对比
### 相同请求内容下数据传输量对比
## Conclusions
这篇文章介绍了一个基于WebSocket的单页应用开发框架,在实时性要求越来越高和单页应用成为主流开发方式的环境下,我们框架为开发者提供了一个满足这两种需求的解决方案和实现,能够让开发者快速高效的开发出实时单页web应用,大大提高产品使用体验,而且对于HTTP这种数据获取方式,我们框架也同样支持,从而满足多种功能需求。实验证明,我们框架能有效降低带宽、服务器等资源消耗,从而降低运维成本。为了适应快速的技术的变化,我们框架尽可能降低各模块的耦合度,在客户端用户可以重构组件包装器就可以配合不同的视图层框架使用,例如vue和angular等,在服务端也可以将订阅器用redis等数据库实现。
我们框架用websocket替代了HTTP协议的web应用通讯方式,虽然底层变化较大,但是对开发者而言跟传统web应用开发模式变化不大,保持路由、控制器、请求、返回、连接池等相关概念和功能,学习简单但是功能强大。