服务器交付
¥Server delivery
有两种常见的方法可以在重新连接时同步客户端的状态:
¥There are two common ways to synchronize the state of the client upon reconnection:
- 服务器发送整个状态 - ¥either the server sends the whole state 
- 或者客户端跟踪它处理的最后一个事件,并且服务器发送丢失的部分 - ¥or the client keeps track of the last event it has processed and the server sends the missing pieces 
两者都是完全有效的解决方案,选择一种将取决于你的用例。在本教程中,我们将选择后者。
¥Both are totally valid solutions and choosing one will depend on your use case. In this tutorial, we will go with the latter.
首先,让我们保留聊天应用的消息。今天有很多很棒的选择,我们在这里使用 SQLite。
¥First, let's persist the messages of our chat application. Today there are plenty of great options, we will use SQLite here.
让我们安装必要的包:
¥Let's install the necessary packages:
- NPM
- Yarn
- pnpm
- Bun
npm install sqlite sqlite3
yarn add sqlite sqlite3
pnpm add sqlite sqlite3
bun add sqlite sqlite3
我们将简单地将每条消息存储在 SQL 表中:
¥We will simply store each message in a SQL table:
- CommonJS
- ES modules
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
async function main() {
  // open the database file
  const db = await open({
    filename: 'chat.db',
    driver: sqlite3.Database
  });
  // create our 'messages' table (you can ignore the 'client_offset' column for now)
  await db.exec(`
    CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        client_offset TEXT UNIQUE,
        content TEXT
    );
  `);
  const app = express();
  const server = createServer(app);
  const io = new Server(server, {
    connectionStateRecovery: {}
  });
  app.get('/', (req, res) => {
    res.sendFile(join(__dirname, 'index.html'));
  });
  io.on('connection', (socket) => {
    socket.on('chat message', async (msg) => {
      let result;
      try {
        // store the message in the database
        result = await db.run('INSERT INTO messages (content) VALUES (?)', msg);
      } catch (e) {
        // TODO handle the failure
        return;
      }
      // include the offset with the message
      io.emit('chat message', msg, result.lastID);
    });
  });
  server.listen(3000, () => {
    console.log('server running at http://localhost:3000');
  });
}
main();
import express from 'express';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { Server } from 'socket.io';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
// open the database file
const db = await open({
  filename: 'chat.db',
  driver: sqlite3.Database
});
// create our 'messages' table (you can ignore the 'client_offset' column for now)
await db.exec(`
  CREATE TABLE IF NOT EXISTS messages (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      client_offset TEXT UNIQUE,
      content TEXT
  );
`);
const app = express();
const server = createServer(app);
const io = new Server(server, {
  connectionStateRecovery: {}
});
const __dirname = dirname(fileURLToPath(import.meta.url));
app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'index.html'));
});
io.on('connection', (socket) => {
  socket.on('chat message', async (msg) => {
    let result;
    try {
      // store the message in the database
      result = await db.run('INSERT INTO messages (content) VALUES (?)', msg);
    } catch (e) {
      // TODO handle the failure
      return;
    }
    // include the offset with the message
    io.emit('chat message', msg, result.lastID);
  });
});
server.listen(3000, () => {
  console.log('server running at http://localhost:3000');
});
然后客户端将跟踪偏移量:
¥The client will then keep track of the offset:
- ES6
- ES5
<script>
  const socket = io({
    auth: {
      serverOffset: 0
    }
  });
  const form = document.getElementById('form');
  const input = document.getElementById('input');
  const messages = document.getElementById('messages');
  form.addEventListener('submit', (e) => {
    e.preventDefault();
    if (input.value) {
      socket.emit('chat message', input.value);
      input.value = '';
    }
  });
  socket.on('chat message', (msg, serverOffset) => {
    const item = document.createElement('li');
    item.textContent = msg;
    messages.appendChild(item);
    window.scrollTo(0, document.body.scrollHeight);
    socket.auth.serverOffset = serverOffset;
  });
</script>
<script>
  var socket = io({
    auth: {
      serverOffset: 0
    }
  });
  var form = document.getElementById('form');
  var input = document.getElementById('input');
  var messages = document.getElementById('messages');
  form.addEventListener('submit', function(e) {
    e.preventDefault();
    if (input.value) {
      socket.emit('chat message', input.value);
      input.value = '';
    }
  });
  socket.on('chat message', function(msg, serverOffset) {
    var item = document.createElement('li');
    item.textContent = msg;
    messages.appendChild(item);
    window.scrollTo(0, document.body.scrollHeight);
    socket.auth.serverOffset = serverOffset;
  });
</script>
最后,服务器将在(重新)连接时发送丢失的消息:
¥And finally the server will send the missing messages upon (re)connection:
// [...]
io.on('connection', async (socket) => {
  socket.on('chat message', async (msg) => {
    let result;
    try {
      result = await db.run('INSERT INTO messages (content) VALUES (?)', msg);
    } catch (e) {
      // TODO handle the failure
      return;
    }
    io.emit('chat message', msg, result.lastID);
  });
  if (!socket.recovered) {
    // if the connection state recovery was not successful
    try {
      await db.each('SELECT id, content FROM messages WHERE id > ?',
        [socket.handshake.auth.serverOffset || 0],
        (_err, row) => {
          socket.emit('chat message', row.content, row.id);
        }
      )
    } catch (e) {
      // something went wrong
    }
  }
});
// [...]
让我们看看它的实际效果:
¥Let's see it in action:
/videos/tutorial/server-delivery.mp4
正如你在上面的视频中看到的,它在临时断开连接和全页面刷新后都可以工作。
¥As you can see in the video above, it works both after a temporary disconnection and a full page refresh.
与 "连接状态恢复" 功能的区别在于,成功恢复可能不需要访问主数据库(例如,它可能从 Redis 流中获取消息)。
¥The difference with the "Connection state recovery" feature is that a successful recovery might not need to hit your main database (it might fetch the messages from a Redis stream for example).
好的,现在我们来谈谈客户端交付。
¥OK, now let's talk about the client delivery.