教程:为 Cloud Run 构建 WebSocket 聊天服务

本教程介绍如何使用 WebSocket 创建具有多个聊天室的实时聊天服务,并通过持久性连接进行双向通信。通过 WebSocket,客户端和服务器可以互相推送消息,而无需轮询服务器获取更新。

虽然您可以将 Cloud Run 配置为使用会话亲和性,但这会提供尽力而为亲和性,这意味着任何新请求仍然可能会路由到其他实例。因此,聊天服务中的用户消息需要在所有实例之间同步,而不仅仅是在连接到一个实例的客户端之间同步。

设计概览

此示例聊天服务使用 Memorystore for Redis 实例在所有实例中存储和同步用户消息。Redis 使用发布/订阅机制(不要与产品 Cloud Pub/Sub 混淆)将数据推送到连接到任何实例的订阅客户端,从而无需进行 HTTP 轮询来获取更新。

但是,即使有推送更新,任何启动的实例也只会收到推送到容器的新消息。如需加载之前的消息,需要通过永久性存储解决方案存储和检索消息历史记录。此示例使用 Redis 的对象存储区传统功能来缓存和检索消息历史记录。

架构图
该图显示了每个 Cloud Run 实例的多个客户端连接。每个实例都通过无服务器 VPC 访问通道连接器连接到 Memorystore for Redis 实例。

Redis 实例受具有访问权限控制的专用 IP 保护以免受互联网攻击,并且限制为 Redis 实例所在虚拟专用网中运行的服务;因此,Cloud Run 服务需要无服务器 VPC 访问通道连接器才能连接到 Redis。详细了解无服务器 VPC 访问通道

限制

  • 本教程未介绍最终用户身份验证或会话缓存。如需详细了解最终用户身份验证,请参阅有关最终用户身份验证的 Cloud Run 教程。

  • 本教程未实现数据库(例如 Firestore)以无限期地存储和检索聊天记录。

  • 此示例服务还需要其他元素才能用于生产环境。建议使用标准层级 Redis 实例,通过复制和自动故障切换提供高可用性

设置 gcloud 默认值

如需为您的 Cloud Run 服务配置 gcloud 默认值,请执行以下操作:

  1. 设置默认项目:

    gcloud config set project PROJECT_ID

    PROJECT_ID 替换为您在本教程中创建的项目的名称。

  2. 为您选择的区域配置 gcloud:

    gcloud config set run/region REGION

    REGION 替换为您选择的受支持的 Cloud Run 区域

Cloud Run 位置

Cloud Run 是区域级的,这意味着运行 Cloud Run 服务的基础架构位于特定区域,并且由 Google 代管,以便在该区域内的所有可用区以冗余方式提供。

选择用于运行 Cloud Run 服务的区域时,主要考虑该区域能否满足您的延迟时间、可用性或耐用性要求。通常,您可以选择距离用户最近的区域,但除此之外,您还应该考虑 Cloud Run 服务使用的其他 Google Cloud产品的位置。跨多个位置使用 Google Cloud 产品可能会影响服务的延迟时间和费用。

Cloud Run 可在以下区域使用:

基于层级 1 价格

基于层级 2 价格

  • africa-south1(约翰内斯堡)
  • asia-east2(香港)
  • asia-northeast3(韩国首尔)
  • asia-southeast1(新加坡)
  • asia-southeast2 (雅加达)
  • asia-south2(印度德里)
  • australia-southeast1(悉尼)
  • australia-southeast2(墨尔本)
  • europe-central2(波兰,华沙)
  • europe-west10(柏林)
  • europe-west12(都灵)
  • europe-west2(英国伦敦) 叶形图标 二氧化碳排放量低
  • europe-west3(德国法兰克福)
  • europe-west6(瑞士苏黎世) 叶形图标 二氧化碳排放量低
  • me-central1(多哈)
  • me-central2(达曼)
  • northamerica-northeast1(蒙特利尔) 叶形图标 二氧化碳排放量低
  • northamerica-northeast2(多伦多) 叶形图标 二氧化碳排放量低
  • southamerica-east1(巴西圣保罗) 叶形图标 二氧化碳排放量低
  • southamerica-west1(智利圣地亚哥) 叶形图标 二氧化碳排放量低
  • us-west2(洛杉矶)
  • us-west3(盐湖城)
  • us-west4(拉斯维加斯)

如果您已创建 Cloud Run 服务,可在Google Cloud 控制台的 Cloud Run 信息中心内查看区域。

检索代码示例

要检索可用的代码示例,请执行以下操作:

  1. 将示例代码库克隆到本地计算机:

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    或者,您也可以下载该示例的 zip 文件并将其解压缩。

  2. 切换到包含 Cloud Run 示例代码的目录:

    Node.js

    cd nodejs-docs-samples/run/websockets/

了解代码

Socket.io 是一个可实现浏览器与服务器间实时双向通信的库。虽然 Socket.io 不是 WebSocket 实现,但它封装了功能以提供一个较简单的 API,为多个通信协议提供额外功能,例如更高的可靠性、自动重新连接以及广播到所有或部分客户端。

客户端集成

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

客户端为每个连接实例化一个新的套接字实例。由于此示例是服务器端呈现的,因此无需定义服务器网址。套接字实例可以发出和监听事件。

// Initialize Socket.io
const socket = io('', {
  transports: ['websocket'],
});
// Emit "sendMessage" event with message
socket.emit('sendMessage', msg, error => {
  if (error) {
    console.error(error);
  } else {
    // Clear message
    $('#msg').val('');
  }
});
// Listen for new messages
socket.on('message', msg => {
  log(msg.user, msg.text);
});

// Listen for notifications
socket.on('notification', msg => {
  log(msg.title, msg.description);
});

// Listen connect event
socket.on('connect', () => {
  console.log('connected');
});

服务器端集成

在服务器端,Socket.io 服务器完成初始化,并连接到 HTTP 服务器。与客户端类似,Socket.io 服务器与客户端建立连接后,会为每个连接创建一个套接字实例,用于发出和监听消息。Socket.io 还提供了一个简单的界面,用于创建“聊天室”,即套接字可以加入和退出的任意通道。

// Initialize Socket.io
const server = require('http').Server(app);
const io = require('socket.io')(server);

const {createAdapter} = require('@socket.io/redis-adapter');
// Replace in-memory adapter with Redis
const subClient = redisClient.duplicate();
io.adapter(createAdapter(redisClient, subClient));
// Add error handlers
redisClient.on('error', err => {
  console.error(err.message);
});

subClient.on('error', err => {
  console.error(err.message);
});

// Listen for new connection
io.on('connection', socket => {
  // Add listener for "signin" event
  socket.on('signin', async ({user, room}, callback) => {
    try {
      // Record socket ID to user's name and chat room
      addUser(socket.id, user, room);
      // Call join to subscribe the socket to a given channel
      socket.join(room);
      // Emit notification event
      socket.in(room).emit('notification', {
        title: "Someone's here",
        description: `${user} just entered the room`,
      });
      // Retrieve room's message history or return null
      const messages = await getRoomFromCache(room);
      // Use the callback to respond with the room's message history
      // Callbacks are more commonly used for event listeners than promises
      callback(null, messages);
    } catch (err) {
      callback(err, null);
    }
  });

  // Add listener for "updateSocketId" event
  socket.on('updateSocketId', async ({user, room}) => {
    try {
      addUser(socket.id, user, room);
      socket.join(room);
    } catch (err) {
      console.error(err);
    }
  });

  // Add listener for "sendMessage" event
  socket.on('sendMessage', (message, callback) => {
    // Retrieve user's name and chat room  from socket ID
    const {user, room} = getUser(socket.id);
    if (room) {
      const msg = {user, text: message};
      // Push message to clients in chat room
      io.in(room).emit('message', msg);
      addMessageToCache(room, msg);
      callback();
    } else {
      callback('User session not found.');
    }
  });

  // Add listener for disconnection
  socket.on('disconnect', () => {
    // Remove socket ID from list
    const {user, room} = deleteUser(socket.id);
    if (user) {
      io.in(room).emit('notification', {
        title: 'Someone just left',
        description: `${user} just left the room`,
      });
    }
  });
});

Socket.io 还提供了一个 Redis 适配器,可以向所有客户端广播事件,无论是哪个服务器服务套接字。Socket.io 仅使用 Redis 的发布/订阅机制,不存储任何数据。

const {createAdapter} = require('@socket.io/redis-adapter');
// Replace in-memory adapter with Redis
const subClient = redisClient.duplicate();
io.adapter(createAdapter(redisClient, subClient));

Socket.io 的 Redis 适配器可以重复使用用于存储聊天室的消息记录的 Redis 客户端。每个容器都会创建一个与 Redis 实例的连接,而 Cloud Run 可以创建大量实例。这远低于 Redis 可以支持的 65,000 个连接。如果您需要支持这么大的流量,还需要评估无服务器 VPC 访问通道连接器的吞吐量

重新连接

Cloud Run 的最长超时时间为 60 分钟。因此,您需要添加重新连接逻辑,以应对可能的超时情况。在某些情况下,Socket.io 会在断开连接或连接错误事件后自动尝试重新连接。无法保证客户端将重新连接到同一个实例。

// Listen for reconnect event
socket.io.on('reconnect', () => {
  console.log('reconnected');
  // Emit "updateSocketId" event to update the recorded socket ID with user and room
  socket.emit('updateSocketId', {user, room}, error => {
    if (error) {
      console.error(error);
    }
  });
});
// Add listener for "updateSocketId" event
socket.on('updateSocketId', async ({user, room}) => {
  try {
    addUser(socket.id, user, room);
    socket.join(room);
  } catch (err) {
    console.error(err);
  }
});

如果存在活动连接,实例将一直存在,直到所有请求关闭或超时为止。即使您使用 Cloud Run 会话亲和性,也可以将新请求负载均衡到活跃容器,从而允许容器缩容。如果您担心大量容器在流量高峰后继续存在,可以降低超时值上限,从而更频繁地清理未使用的套接字。

发布服务

  1. 创建一个 Memorystore for Redis 实例:

    gcloud redis instances create INSTANCE_ID --size=1 --region=REGION

    INSTANCE_ID 替换为实例名称(例如 my-redis-instance),并将 REGION_ID 替换为所有资源和服务的区域(例如 europe-west1)。

    系统会自动为实例分配一个在默认服务网络范围内的 IP 地址范围。本教程使用 1GB 的内存用于本地缓存 Redis 实例中的消息。详细了解如何针对您的应用场景确定 Memorystore 实例的初始大小

  2. 设置无服务器 VPC 访问通道连接器:

    要连接到 Redis 实例,您的 Cloud Run 服务需要访问 Redis 实例的已获授权的 VPC 网络。

    每个 VPC 连接器都需要有自己的 /28 子网以放置连接器实例。此 IP 范围不得与 VPC 网络中预留的任何现有 IP 地址重叠。例如,10.8.0.0 (/28) 适用于大多数新项目,您也可以指定另一个未使用的自定义 IP 范围,例如 10.9.0.0 (/28)。您可以在Google Cloud 控制台中查看当前预留的 IP 范围。

    gcloud compute networks vpc-access connectors create CONNECTOR_NAME \
      --region REGION \
      --range "10.8.0.0/28"

    CONNECTOR_NAME 替换为您的连接器的名称。

    此命令会在默认 VPC 网络中创建一个连接器,该连接器与 Redis 实例相同,机器大小为 e2-micro。增加连接器的机器大小可以提高连接器的吞吐量,但也会增加费用。连接器还必须与 Redis 实例位于同一区域。详细了解如何配置无服务器 VPC 访问通道

  3. 使用 Redis 实例的授权网络的 IP 地址定义环境变量:

     export REDISHOST=$(gcloud redis instances describe INSTANCE_ID --region REGION --format "value(host)")
  4. 创建一个服务账号作为服务身份。默认情况下,该账号不具备除项目成员资格之外的任何特权。

    gcloud iam service-accounts create chat-identity
    gcloud projects add-iam-policy-binding PROJECT_ID \
    --member=serviceAccount:chat-identity@PROJECT_ID.iam.gserviceaccount.com \
    --role=roles/serviceusage.serviceUsageConsumer
  5. 构建容器映像并部署到 Cloud Run:

    gcloud run deploy chat-app --source . \
        --vpc-connector CONNECTOR_NAME \
        --allow-unauthenticated \
        --timeout 3600 \
        --service-account chat-identity \
        --update-env-vars REDISHOST=$REDISHOST

    在系统提示时通过响应 y 来响应任何提示,以安装所需 API。 您只需为项目执行一次此操作。如果您尚未按照设置页面中的说明为其他提示设置默认值,请通过提供平台和区域来响应这些提示。详细了解如何从源代码部署

测试

如需试用完整服务,请执行以下操作:

  1. 在浏览器中导航至上述部署步骤提供的网址。

  2. 添加你的姓名和聊天室以登录。

  3. 向聊天室发送消息!

如果您选择继续开发这些服务,请注意,它们已限制了 Identity and Access Management (IAM) 对 Google Cloud 其余服务的访问权限,并需要额外的 IAM 角色才能访问众多其他服务。