前后端消息推送
在浏览某些网页的时候,例如 WebQQ
、京东在线客服服务、CSDN私信消息等类似的情况下,我们可以在网页上进行在线聊天,或者即时消息的收取与回复,可见,这种功能的需求由来已久,并且应用广泛。
网上关于这方面的文章也能搜到一大堆,不过基本上都是理论,真正能够运行的代码很少,原理性的东西我就不当搬运工了,本文主要是贴示例代码,最多在代码中穿插一点便于理解,本文主要的示例代码基于 javascript
,服务端基于 nodejs
的 koa(1/2)
框架实现。
模拟推送
Web
端 常见的消息推送实际上大多数都是模拟推送,之所以是模拟推送,是因为这种实现并不是服务器主动推送,本质依旧是客户端发起请求,服务端返回数据,起主动作用的是客户端。
短轮询
实现上最简单的一种模拟推送方法,原理就是客户端不断地向服务端发请求,如果服务端数据有更新,服务端就把数据发送回来,客户端就能接收到新数据了。
一种实现的示例如下:
-
const loadXMLDoc = (url, callback) => {
-
let xmlhttp
-
if(window.XMLHttpRequest) {
-
// IE7+ Firefox Chrome Safari 等现代浏览器执行的代码
-
xmlhttp = new XMLHttpRequest()
-
} else {
-
// IE5 IE6浏览器等老旧浏览器执行的代码
-
xmlhttp = new ActiveXObject('Microsoft.XMLHTTP')
-
}
-
-
xmlhttp.onreadystatechange = () => {
-
if(xmlhttp.readyState === 4 && xmlhttp.status === 200) {
-
document.getElementById('box1').innerHTML = xmlhttp.responseText
-
callback && callback()
-
}
-
}
-
// 打开链接发送请求
-
xmlhttp.open('GET', 'http://127.0.0.1:3000/' + url, true)
-
xmlhttp.send()
-
}
-
-
// 轮询
-
setInterval(function() {
-
loadXMLDoc('fetchMsg')
-
}, 2000)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
上述代码,设置定时任务,每隔 2s
使用 ajax
发起一次请求,客户端根据服务端返回的数据来进行决定执行对应的操作,除了发送 ajax
,你还可以使用 fetch
:
-
fetch('localhost:3000/fetchMsg', {
-
headers: {
-
'Accept': 'application/json, text/plain, */*'
-
}
-
}
- 1
- 2
- 3
- 4
- 5
引申:
fetch
目前的浏览器支持度还很低,所以在实际生产环境中使用的时候,最好添加一些polyfill
,一种垫片使用顺序示例如下:
-es5
的polyfill
— es5-shim
-Promise
的polyfill
— es6-promise -IE8+
-fetch
的polyfill
— fetch -IE10+
如果你在使用某种框架,例如 vue
或者 angular
,那么你同样可以使用这些框架自带的请求方法,总之基于页面的友好访问性,在发送请求的同时不要刷新页面就行了。
优点:
前后端程序都很容易编写,没什么技术难度
缺点:
这种方法因为需要对服务器进行持续不断的请求,就算你设置的请求间隔时间很长,但在用户访问量比较大的情况下,也很容易给服务器带来很大的压力,而且绝大部分情况下都是无效请求,浪费带宽和服务器资源,一般不会用于实际生产环境的,自己知道一下就行了。
长轮询
相比于上一种实现,长轮询同样是客户端发起请求,服务端返回数据,只不过不同的是,在长轮询的情况下,服务器端在接到客户端请求之后,如果发现数据库中的数据并没有更新或者不符合要求,那么就不会立即响应客户端,而是 hold
住这次请求,直到符合要求的数据到达或者因为超时等原因才会关闭连接,客户端在接收到新数据或者连接被关闭后,再次发起新的请求。
为了节约资源,一次长轮询的周期时间最好在 10s ~ 25s
左右,长连接也是实际生产环境中,被广泛运用于实时通信的技术。
客户端代码如下:
-
function getData() {
-
loadXMLDoc('holdFetchMsg', ()=>{
-
getData()
-
})
-
}
-
getData()
- 1
- 2
- 3
- 4
- 5
- 6
想要在连接断开或发生错误的时候,再次发起请求连接,实现也很简单,以下问使用 fetch
实现示例:
-
function getData() {
-
let result = fetch('http://127.0.0.1:3000/holdFetchMsg', {
-
headers: {
-
'Accept': 'application/json, text/plain, */*'
-
}
-
})
-
result.then(res => {
-
return res.text()
-
}).then(data => {
-
document.getElementById('box1').innerHTML = data
-
}).catch(e => {
-
console.log('Catch Error:', e)
-
}).then(() => {
-
getData()
-
})
-
}
-
getData()
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
一种较为直观的服务器 hold
住连接的实现如下:
-
router.get('/holdFetchMsg', (ctx, next)=> {
-
let i = 0
-
while(true) {
-
// 这里的条件在实际环境中可以换成是到数据库查询数据的操作
-
// 如果查询到了符合要求的数据,再 break
-
// 不过这种可能会导致服务器进行例如疯狂查询数据库的操作,非常不友好
-
if(++i > 2222222222) {
-
ctx.body = '做我的狗吧'
-
break
-
}
-
}
-
})
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
还有一种方法,不过这种纯粹是为了 hold
住而 hold
住,可以作为上一种方法的辅助,解决诸如服务端进行疯狂查询数据库的操作,类似于 Java
中的 Thread.sleep()
操作
-
let delay = 2000, i = 0
-
while(true) {
-
let startTime = new Date().getTime()
-
// 这里的条件在实际环境中可以换成是到数据库查询数据的操作
-
if(++i > 3) {
-
ctx.body = '做我的狗吧'
-
break
-
} else {
-
// 休息会,别那么频繁地进行诸如查询数据库的操作
-
// delay 为每次查询后 sleep的时间
-
while(new Date().getTime() < startTime + delay);
-
}
-
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
如果你现在的 Nodejs
版本支持 ES6
中的 Generator
的话,那么还可以这样(koa1
环境, Generator
写法):
-
app.use(function* (next){
-
let i = 0
-
const sleep = ms => {
-
return new Promise(function timer(resolve){
-
setTimeout(()=>{
-
if(++i > 3) {
-
resolve()
-
} else {
-
timer(resolve)
-
}
-
}, ms)
-
})
-
}
-
yield sleep(2000)
-
this.body = '做我的狗吧'
-
})
-
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
如果你现在的 Nodejs
版本支持 ES7
中的 async/await
的话,,那么还有一种 hold
住连接的方法可供选择(koa2
环境):
-
router.get('/holdFestchMsg', async(ctx, next) => {
-
let i = 0
-
const sleep = ms => {
-
return new Promise(function timer(resolve) {
-
setTimeout(async()=>{
-
// 这里的条件在实际环境中可以换成是到数据库查询数据的操作
-
if(++i > 3) {
-
resolve()
-
} else {
-
timer(resolve)
-
}
-
}, ms)
-
})
-
}
-
await sleep(2000)
-
ctx.body = '做我的狗吧'
-
})
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
优点:
尽管长轮询不可能做到每一次的响应都是有用的数据,因为服务器超时或者客户端网络环境的变化,以及服务端为了更好的分配资源而自动在一个
心跳
周期的末尾断掉连接等原因,而导致长轮询不可能一直存在,必须要不断地进行断开和连接操作,但无论如何,相比于短轮询来说,长轮询耗费资源明显小了很多
缺点:
服务器
hold
连接依旧会消耗不少的资源,特别是当连接数很大的时候,返回数据顺序无保证,难于管理维护。
长连接
这种是基于 iframe
或者 script
实现的,主要原理大概就是在主页面中插入一个隐藏的 iframe(script)
,然后这个 iframe(script)
的 src
属性指向服务端获取数据的接口,因为是iframe(script)
是隐藏的,而且 iframe(script)
的 刷新也不会导致 主页面刷新,所以可以为这个 iframe(script)
设置一个定时器,让其每隔一段时间就朝服务器发送一次请求,这样就能获得服务端的最新数据了。
先说一下 利用 script
的长连接:
前端实现:
-
<script>
-
function callback(msg) {
-
// 得到后端返回的数据
-
console.log(msg);
-
}
-
function createScript() {
-
let script = document.createElement('script')
-
script.src = 'http://127.0.0.1:3000/fetchMsg'
-
document.body.appendChild(script)
-
document.body.removeChild(script)
-
}
-
</script>
-
<script>
-
window.onload = function() {
-
setInterval(()=>{
-
createScript()
-
}, 3000)
-
}
-
</script>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
后端实现:
-
router.get('/fetchMsg', (ctx, next)=> {
-
ctx.body = 'callback("做我的狗吧")'
-
})
- 1
- 2
- 3
主要是在前端,一共两条 script
脚本,大致左右就是在一定的时间间隔内(示例为 3s
)就动态地在页面中增删一个链接为用于请求后端数据的 script
脚本。
后端则返回一段字符串,这段字符串在返回前端时,有一个 callback
字段调用前端的代码,类似于 jsonp
的请求。
注意:修改一个已经执行过的
script
脚本的src
属性是没什么卵用的,修改之后,最多在页面的DOM
上发生一些变化,而浏览器既不会发请求,也不会执行脚本,所以这里采用动态增删整个script
标签的做法。
可以看到,这种方法其实与短轮询没什么区别,唯一的区别在于短轮询保证每次请求都能收到响应,但上述示例的长连接不一定每次都能得到响应,如果下一次长连接开始请求,上一次连接还没得到响应,则上一次连接将被终止。
当然,如果你想长连接每次也都能保证得到响应也是可以的,大致做法就是在页面中插入不止一条 script
标签,每条标签对应一个请求,等到当前请求到达再决定是否移除当前 script
标签。
如果想要得到有序的数据响应,则还可以将 setInterval
换成递归调用,例如:
-
function createScript() {
-
let script = document.createElement('script')
-
script.src = 'http://127.0.0.1:3000/fetchMsg'
-
document.body.appendChild(script)
-
script.onload = ()=> {
-
document.body.removeChild(script)
-
// 约束轮询的频率
-
setTimeout(()=>{
-
createScript()
-
}, 2000)
-
}
-
}
-
-
window.onload = function() {
-
createScript()
-
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
使用 iframe
的方式与此类似,就不赘述了,不过需要注意的是, iframe
可能存在跨域的情况,可能会比 script
方式麻烦一些。
WebSocket
WebSoket
是 HTML5
新增的 API
,具体介绍如下(来源w3c菜鸟教程)
WebSocket是HTML5开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
在WebSocket API中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。
浏览器通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,连接建立以后,客户端和服务器端就可以通过 TCP 连接直接交换数据。
当你获取 Web Socket 连接后,你可以通过 send() 方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。
上面所提到的短轮询、长轮询、长连接,本质都是单向通信,客户端主动发起请求,服务端被动响应请求,但 WebSocket
则已经是全双工通讯了,也就是说无论是客户端还是服务端都能主动向对方发起响应,服务器具备了真正的 推送
能力。
一段简单的 客户端 WebSocket
代码示例如下:
-
function myWebSocket() {
-
let ws = new WebSocket('ws://localhost:3000')
-
ws.onopen = ()=> {
-
console.log('send data')
-
ws.send('client send data')
-
}
-
-
ws.onmessage = (e)=> {
-
let receiveMsg = e.data
-
console.log('client get data')
-
}
-
-
ws.onerror = (e)=>{
-
console.log('Catch Error:', e)
-
}
-
-
ws.onclose = ()=> {
-
console.log('ws close')
-
}
-
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
想要让客户端的 WebSocket
能够连接上服务器,服务端必须要具备能够响应 WebSocket
类型的请求才行,一般的服务器是没有自带这种能力的,所以必须要对服务器端程序代码做出些改变。
自己封装服务器端响应 WebSocket
的代码可能会涉及到很底层的东西,所以一般都是使用第三方封装好的库,基于nodejs
的 WebSocket
库有很多,ws 功能简单, API
形式更贴近于原生,大名鼎鼎的 socket.io 是与 Nodejs
联手开发,功能齐全,被广泛运用于游戏、实时通讯等应用。
以下给出一种基于 socket.io 实现 简单客户端和服务端通信的示例:
客户端:
-
// HTML
-
<body>
-
<ul id="messages"></ul>
-
<form action="" id="msgForm">
-
<input id="m" autocomplete="off" /><input type="submit" class="submit" value="submit">
-
</form>
-
</body>
-
-
// 引入 socket.io
-
<script src='/socket.io/socket.io.js'></script>
-
<script>
-
function appendEle(parent, childValue, position = 'appendChild') {
-
let child = document.createElement('li')
-
child.innerHTML = childValue
-
parent[position](child)
-
}
-
-
function socketIO(msgForm, msgBox, msgList) {
-
const socket = io()
-
msgForm.addEventListener('submit', (e)=>{
-
e.preventDefault()
-
socket.emit('chat message', msgBox.value)
-
appendEle(msgList, '<b>Client: </b>' + msgBox.value)
-
msgBox.value = ''
-
})
-
-
socket.on('chat message', (msg)=>{
-
appendEle(msgList, msg)
-
})
-
}
-
-
window.onload = ()=>{
-
let msgForm = document.querySelector('#msgForm')
-
let msgBox = document.querySelector('#m')
-
let msgList = document.querySelector('#messages')
-
socketIO(msgForm, msgBox, msgList)
-
}
-
</script>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
服务端实现:
-
const app = require('express')()
-
const http = require('http').Server(app)
-
const io = require('socket.io')(http)
-
-
app.get('/', (req, res)=> {
-
res.sendFile(__dirname + '/index.html')
-
})
-
-
io.on('connection', socket=>{
-
console.log('a user connected')
-
socket.on('disconnect', ()=>{
-
console.log('user disconnect')
-
})
-
socket.on('chat message', (msg)=>{
-
console.log('clien get message: ', msg)
-
setTimeout(()=>{
-
io.emit('chat message', '<b>Server:</b>' + ' Are you Sure? -- Come from your father')
-
}, 1500)
-
})
-
})
-
-
http.listen(3000, ()=> {
-
console.log('Server running at 3000.')
-
})
-
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
效果如下:
注、websocket是javaweb实现即时消息推送最佳方案,但是需要服务器jdk在版本7以上支持,低版本浏览器还不支持,所以要支持低版本即时消息推送还需要选择另外一种方法。
使用反向ajax框架DWR
DWR(Direct Web RemoTIng)是一个Web远程调用AJAX扩展框架,通过DWR客户端的JavaScript可以直接调用Web服务器上的JavaBean类的方法,解决了原有AJAX应用必需请求HTTP控制组件(如Servlet,Struts的AcTIon等)才能调用服务器端业务类的方法,从而简化了AJAX应用的开发。使用DWR可以不需要编写复杂的控制层组件。
1.2 DWR反向AJAX技术
正常情况下,DWR调用服务器端的JavaBean对象方法使用正向请求/响应模式,也称为拉模式(Pull Model),由客户端JavaScript调用JavaBean方法,返回结果通过回调方法更新页面上的HTML元素,实现监控数据的显示。这种正向模式符合一般的管理系统应用,但对监控系统实时性要求较高的应用却力不从心。而反向模式即推模式(Push Model),是适应监控系统的最佳方式,由服务器组件将取得的监控数据推送到Web客户端,不需要客户端主动请求,而是被动接收。因而无需进行Web层进行页面刷新,即可实现数据更新显示。
最新版本的DWR 2.X增加了反向(Reverse AJAX)功能,通过反向AJAX功能,服务器端的JavaBean对象可以将取得的数据直接推送到指定的客户端页面,写到指定的HTML元素内,这个过程不需要客户端进行任何的请求操作。