愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

Socket.IO学习笔记

Socket.io是一个事件驱动的即时双向通信支持库,使用它可以很方便地开发出可靠的、快速的即时应用

一、起步:编写一个聊天应用

1、基本结构

以下示例,采用Koa2作为WEB服务的框架,所以首先需要安装Koa并加入依赖:

npm install koa -S

然后,需要编写用来与用户交互的界面,如下:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>基于Socket.io的聊天室DEMO</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font: 13px Helvetica, Arial; }
        form { background: #000; padding: 3px; position: fixed; bottom: 0; width: 100%; }
        form input { border: 0; padding: 10px; width: 90%; margin-right: .5%; }
        form button { width: 9%; background: rgb(130, 224, 255); border: none; padding: 10px; }
        #messages { list-style-type: none; margin: 0; padding: 0; }
        #messages li { padding: 5px 10px; }
        #messages li:nth-child(odd) { background: #eee; }
    </style>
</head>
<body>

    <ul id="messages"></ul>
    <form action="">
        <input id="m" autocomplete="off" />
        <button id="b">Send</button>
    </form>

</body>
</html>

接下来,编写server.js,以响应给用户,如下:

const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const app = new Koa()

const template = fs.readFileSync(path.resolve(__dirname, './index.html'))
app.use(async (ctx) => {
    ctx.type = 'html'
    ctx.body = template
})

app.listen(3000, () => {
    console.log('Application is starting on port 3000')
})

然后,运行node server.js,即可看到如下界面:

2、引入Socket.io

为了实现聊天功能,接下来就需要引入socket.io来实现即时通信了,通过npm安装引入:

npm install socket.io -S

这样子,就会自动安装好必须的包,包括socket.iosocket.io-client
接下来,需要对server.js进行一些修改,使得Http服务和Websocket服务共用一个端口,如下:

const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const app = new Koa()
const server = require('http').Server(app.callback())
const io = require('socket.io')(server)

const template = fs.readFileSync(path.resolve(__dirname, './index.html'))
app.use(async (ctx) => {
    ctx.type = 'html'
    ctx.body = template
})

io.on('connection', (socket) => {
    console.log('An user connected')
})

server.listen(3000, () => {
    console.log('Application is starting on port 3000')
})

每次有新用户连接,都会触发connection事件,此外,当用户断开连接,则会触发disconnect事件,那么,我们可以处理如下:

io.on('connection', (socket) => {
    console.log('An user connected')
    socket.on('disconnect', () => {
        console.log('User disconnected')
    })
})

3、客户端向服务端发送消息

在客户端,需要引入socket.io-client模块,所以可以在</body>之前,加入:

<script src="/js/socket.io.js"></script>

接下来,需要加入客户端连接服务端的逻辑,如下:

<script>
    const socket = io()
</script>

这里,我们并不需要对io()指定任何参数,它默认情况下会尝试连接托管页面的服务器,当完成以上那步工作后,刷新页面,我们就可以看到终端打印了:An user connected
接下来,我们要实现客户端向服务端发送消息的功能,由于socket.io是基于事件的,所以实现也很简单,如下:

<script>
(function() {
    const text = document.querySelector('#m')
    const button = document.querySelector('#b')
    const messages = document.querySelector('#messages')
    const socket = io() 
    button.addEventListener('click', (ev) => {
        const msg = text.value.trim()
        if (msg === '') return
        messages.innerHTML += `<li>${msg}</li>`
        socket.emit('newMessage', msg) // 关键代码
        text.value = ''
        ev.preventDefault()
    })
})()
</script>

4、广播

当用户发送一条消息时,其他用户应该都能够收到这条消息,那么socket.io中实现这一功能的机制叫做广播,广播的消息,将会发送给除了发送者之外的用户。所以,我们可以实现消息广播如下:

// server.js
io.on('connection', (socket) => {
    console.log('An user connected')
    socket.on('newMessage', (msg) => {
        console.log('Message from client: ', msg)
        socket.broadcase.emit('serverMsg', msg)
    })
    socket.on('disconnect', () => {
        console.log('User disconnected')
    })
})
<script>
(function() {
    const text = document.querySelector('#m')
    const button = document.querySelector('#b')
    const messages = document.querySelector('#messages')
    const socket = io()
    socket.on('serverMsg', (msg) => {
        messages.innerHTML += `<li>${msg}</li>`
    })
    button.addEventListener('click', (ev) => {
        const msg = text.value.trim()
        if (msg === '') return
        messages.innerHTML += `<li>${msg}</li>`
        socket.emit('newMessage', msg) // 关键代码
        text.value = ''
        ev.preventDefault()
    })
})()
</script>

若我们想把消息发送给包括发送者在内的用户,那么就不需要广播,修改如下:

// server.js
    socket.on('newMessage', (msg) => {
        console.log('Message from client: ', msg)
        io.emit('serverMsg', msg)
    })

如此,便完成了整个DEMO的编写


二、总览

1、安装

可以通过npm安装socket.io,如下:

npm install socket.io

配合Node自带的http server,如下:

// server.js
const app = require('http').createServer(handler)
const io = require('socket.io')(app)
const fs = require('fs')

function handler(req, res) {
    fs.readFile(__dirname + '/index.html', (err, data) => {
        if (err) {
            res.writeHead(500)
            return res.end('Error loading index.html')
        }
        res.writeHead(200)
        res.end(data)
    })
}

io.on('connection', (socket) => {
    socket.emit('news', { hello: 'world' })
    socket.on('my other event', (data) => {
        console.log(data)
    })
})

app.listen(80)

配合express3/4使用,则如下:

// server.js
const app = require('express')()
const server = require('http').createServer(app)
const io = require('socket.io')(server)

app.get('/', (req, res) => {
    res.sendFile(__dirname, '/index.html')
})

io.on('connection', (socket) => {
    socket.emit('news', { hello: 'world' })
    socket.on('my other event', (data) => {
        console.log(data)
    })
})

配合Koa使用则如同起步例子所示

2、收发事件

Socket.io允许我们发送和接收自定义事件(除了connectmessagedisconnect外的事件),并且只需要使用on()emit(),如下:

const io = require('socket.io')(80)
io.on('connection', (socket) => {
    io.emit('this', { will: 'be received by everyone' })
    socket.on('private message', (from, msg) => {
        console.log('I received a private message by ', from, ' saying', msg)
    })
    socket.on('disconnect', () => {
        io.emit('user disconnected')
    })
})

3、命名空间约束

通常情况下,当应用规模较小时,我们能够控制好消息的收发的情况下,就不必指定命名空间(默认情况下命名空间是/),但如果想要利用第三方库或者共享代码,那么socket.io提供了一种对socket进行命名空间限定的方式。这种方式还有多路复用一个连接的好处,能够让socket.io不需要使用两个websocket连接,使用一个就够了,如下:

// server.js
const io = require('socket.io')(80)
const namespaceA = io
    .of('/namespaceA')
    .on('connection', (socket) => {
        socket.emit('a message', 'hello')
    })

const namespaceB = io
    .of('/news')
    .on('connection', (socket) => {
        socket.emit('another message', 'world')
    })
<!-- client.html -->
<script>
    const namespaceA = io.connect('http://localhost/namespaceA')
    const namespaceB = io.connect('http://localhost/namespaceB')
    namespaceA.on('connect', () => {
        namespaceA.emit('hi')
    })
    namespaceB.on('someMsg', () => {
        namespaceB.emit('xxx')
    })
</script>

4、发送不稳定(volatile)消息

在一些情况下,消息有可能会丢失。比如:我们开发一个抓取Bieber(贾斯汀比伯)实时推特的应用,那么当一个客户端未准备好接收消息的时候(可能因为网络问题,或者因为这个客户端是通过长轮询进行连接的,但是正处于请求-响应周期的中间间隔时间里),这种情况下就会导致和Bieber有关的推特没有被全部抓取,那么应用就是不够健壮的。
这种情况下,可以将这些消息认定为不稳定消息,而socket.io里提供了支持,如下:

const io = require('socket.io')(80)
io.on('connection', (socket) => {
    const tweets = setInterval(() => {
        getBieberTweet((tweet) => {
            socket.volatile.emit('bieber tweet', tweet)
        })
    }, 100)
    socket.on('disconnect', () => {
        clearInterval(tweets)
    })
})

5、发送与获取数据(应答)

有时候,我们需要告知客户端收到了消息,并执行回调,以给客户端一个回执。
这种情况下,只要简单地在send()方法或者emit()方法的最后一个参数里传递一个function就可以了。而且,其实使用emit()方法的情况下,应答是由我们自己完成的,所以就可以自行传递数据,如下:

// server.js
const io = require('socket.io')(80)
io.on('connection', (socket) => {
    socket.on('ferret', (name, fn) => {
        fn('woot')
    })
})
<!-- client.html -->
<script>
    const socket = io() // 当io()未指定参数时,是进行自动发现处理
    // 其实以下也可以不需要监听`connect`,直接监听事件也是可以的
    socket.on('connect', () => {
        socket.emit('ferret', 'tobi', (data) => {
            console.log(data) // 当服务端收到消息后,就客户端就会打印'woot'
        })
    })
</script>

6、广播消息

进行消息广播,只需要在emit()或者send()前加入broadcast即可。广播意味着:将消息发送给除了发送者之外的所有人,如下:

// server.js
const io = require('socket.io')(80)
io.on('connection', (socket) => {
    socket.broadcast.emit('user connected')
})

7、把socket.io当做跨浏览器的WebSocket来使用

socket.io也支持WebSocket语义:只利用messagesend,如下:

// server.js
const io = require('socket.io')(80)
io.on('connection', (socket) => {
    socket.on('message', () => {
        // ...
    })
    socket.on('disconnect', () => {
        // ...
    })
})
<!-- client.html -->
<script>
    const socket = io('http://localhost')
    socket.on('connect', () => {
        socket.send('hi')
        socket.on('message', (msg) => {
            // ...
        })
    })
</script>


三、房间与命名空间

socket.io允许我们对socket限定命名空间,即分配不同的端点或者路径,这种特性能够帮助减少资源数目(即TCP连接数)

1、默认命名空间

默认的命名空间是/,这也是socket.io客户端默认连接、服务端默认监听的命名空间
默认的命名空间,是通过io.sockets或者io标识的,如:

io.sockets.emit('hi', 'everyone')
io.emit('hi', 'everyone') // 其实是上面那种方式的简写形式

每个命名空间都会发送一个connection事件,然后接收Socket实例作为参数,如:

io.on('connection', (socket) => {
    socket.on('disconnect', () => {
        // ...
    })
})

2、自定义命名空间

可以通过服务端的of()方法建立自定义的命名空间,如:

const nsp = io.of('/my-namespace')
nsp.on('connection', (socket) => {
    console.log('someone connected')
})
nsp.emit('hi', 'everyone')

在客户端,则需要像以下这么做来关联命名空间:

const socket = io('/my-namespace')

注意:命名空间是Socket.IO协议的一个实现细则,它和实际底层传输的URL无关(URL默认是/socket.io/

3、房间

在命名空间内部,还可以定义任意的socket可加入(join())和离开(leave())的信道,如:

// 使用join()订阅socket,指定一个信道
io.on('connection', (socket) => {
    socket.join('some room')
})
// 然后,在广播或者发送的时候,使用`to()`或者`in()`(两者是相同的)
io.to('some room').emit('some event')
// 离开一个信道,则可以这么做:
socket.leave('some room')

在Socket.IO内,每个Socket都有一个随机的、不可猜测的、独一无二的标识符Socket#id。方便起见,每个socket都会默认自动地加入由这个ID标识的房间。所以,有了这个机制,要广播消息给其他Socket也是比较容易的,如:

io.on('connection', (socket) => {
    socket.on('say to someone', (id, msg) => {
        socket.broadcast.to(id).emit('my message', msg)
    })
})

在关闭连接的时候,socket都会自动地离开其归属的全部信道,且不需要我们自己做额外的拆卸工作

4、从外部发送信息

在一些情况下,我们需要在Socket.IO进程外的上下文中发送事件给Socket.IO中的命名空间、房间。
有多种方式可以处理这一问题,如可以实现自己的信道发送消息给进程,不过为了简化这一问题,官方提供了两个模块:

  • socket.io-redis
  • socket.io-emitter

然后可以实现Redis适配器:

const io = require('socket.io')(3000)
const redis = require('socket.io-redis')
io.adapter(redis({
    host: 'localhost',
    port: 6397
}))

然后,就可以在其他进程里发送消息给任何信道了:

const io = require('socket.io-emitter')({
    host: '127.0.0.1',
    port: 6397
})
setInterval(() => {
    io.emit('time', new Date())
}, 5000)