实时更新无处不在——比如体育比赛的实时比分、股票行情更新、聊天应用程序以及物联网数据面板。如果你想构建能够在数据发生变化的瞬间就将其推送给用户的系统,那么你就需要合适的工具。

消息队列遥测传输协议(MQTT)是一种轻量级的消息传递协议,在实现实时数据推送功能方面表现极为出色。结合像Mosquitto这样的代理服务器以及Express这样的Web框架,你完全可以在一个下午的时间内构建出一个可用于实际生产的实时系统。

在本教程中,你将从零开始搭建一个完整的实时足球比赛更新系统。你需要创建一个用于上传比分和比赛详情的管理界面,一个用于查看实时更新的观众界面,以及一个后端服务,该服务会利用MQTT协议立即将数据变化通知给所有连接的客户端。

读完本指南后,你将了解如何将MQTT与Express集成起来,如何配置Mosquitto代理服务器,以及如何使用“服务器发送事件”功能将实时数据传递给Web浏览器。最终,你将会拥有一个可以投入实际生产环境使用的完整系统。

目录

  1. 你将学到什么

  2. 先决条件

  3. 理解系统架构

  4. 什么是MQTT?为什么使用它?

  5. 项目准备工作

  6. 如何配置MQTT代理服务器

  7. 如何构建Express服务器

  8. 如何实现比赛信息路由功能

  9. 如何利用“服务器发送事件”将MQTT数据传递给浏览器

  10. 如何构建管理界面用于上传数据

  11. 如何构建实时观看界面

  12. 如何构建首页

  13. 如何运行并测试系统

  14. 如何将系统扩展到生产环境

  15. API参考文档

  16. 故障排除方法

  17. 总结

您将学到的内容

通过本教程,您将学习如何:

  • 使用 MQTT.js 库将 Express 服务器连接到 MQTT 中间件

  • 发布和订阅 MQTT 主题以实现实时消息传递

  • 利用“服务器发送事件”功能将 MQTT 消息推送到浏览器中

  • 构建用于比赛和比分管理的 REST API

  • 创建一个简单的管理界面,用于上传比赛数据

  • 开发一个能够实时更新信息且无需刷新页面的查看界面

先决条件

在开始之前,您需要满足以下要求:

  • 您的计算机上已安装 Node.js 18 及更高版本

  • 对 JavaScript、Express 和 HTML 有基本了解

  • 拥有用于执行命令的终端或命令行工具

  • 已安装 Docker(可选,用于在容器中运行 Mosquitto)

  • 如果您还没有安装 Node.js,可以从官方网站下载。

    系统架构简介

    该系统由三个主要部分组成:

    1. 管理界面——用于创建比赛、更新比分以及添加进球、罚球等事件的网页。

    2. Express 服务器——接收来自管理界面的 HTTP 请求,将数据发布到 MQTT 中间件,订阅相关主题,并通过“服务器发送事件”功能向查看者推送更新信息。

    3. 查看界面——与服务器连接并实时显示比分及各类事件的网页。

    Architecture-diagram

    工作原理
    当您在管理界面提交比分更新请求时,Express 服务器会向 MQTT 中间件发送消息,并同时订阅这些主题。一旦有新消息到达,服务器会通过“服务器发送事件”功能将其转发给所有已连接的查看者,从而使他们无需刷新页面即可看到最新信息。

    什么是 MQTT?为何要使用它?

    MQTT 即消息队列遥测传输协议。这是一种轻量级的发布-订阅消息传递机制,专为低带宽和不可靠的网络环境设计。它在物联网应用中得到了广泛使用,但同样适用于任何需要向大量订阅者发送实时更新信息的系统。

    以下是选择 MQTT 用于体育赛事更新系统的几个原因:

    • 低开销:消息体积小且传输效率高,因此非常适合处理大量客户端的需求。

    • 内置的服务质量保障机制:您可以指定消息的送达次数(最多一次、至少一次或恰好一次)。

    • 基于主题的路由功能:您可以根据需要按主题组织消息(例如 sports/football/match/123),这样订阅者就能只接收他们所需的信息。

    • 集中式管理机制:由中央中间件(如 Mosquitto)负责所有消息的分发,因此应用程序逻辑可以保持简洁。

    Mosquitto是一款受欢迎的开源MQTT代理,安装和配置都非常简单。

    MQTT主题设计

    MQTT采用分层化的主题结构。对于这个项目来说,所使用的主题如下:

    • sports/football/match/{id}:每场比赛对应一个主题。当你向该主题发布完整的比赛信息时,所有订阅者都会接收到这些数据。这样一来,日后添加新的比赛字段也不会改变主题结构。

    • sports/football/scores:用于发送与比分相关的通知。消息中会包含type字段(如match_createdscore_update),这样订阅者就可以根据类型来处理这些信息。

    • sports/football/events:用于发布与比赛相关的事件信息,比如进球、罚球等。订阅者会收到格式为{ type: 'match_event', matchId, event }的消息。

    在订阅时使用通配符#表示“订阅当前层级及所有下级层级”。因此,sports/football/#会订阅sports/football下的所有主题,包括sports/football/match/abc123sports/football/scores。而通配符+则表示只订阅恰好一个层级。例如,sports/+/match/#会订阅所有与足球比赛相关的主题,而不仅仅是足球这一项目。

    为什么选择使用服务器发送事件而不是WebSockets?

    你可能会疑惑,为什么本教程选用服务器发送事件(SSE)而非WebSockets。实际上这两种技术都可以将数据推送到浏览器端。它们的主要区别在于:

    • 服务器发送事件:单向通信(从服务器到客户端),基于HTTP协议实现。浏览器会自动重新连接,实现起来更为简单,且不需要额外的库文件。

    • WebSockets:双向通信,需要使用不同的协议。虽然灵活性更强,但实现起来也更加复杂。

    对于一个用于查看体育比赛比分的应用来说,只需要从服务器向客户端发送更新信息即可。观众永远不会通过同一通道向服务器发送消息,因此服务器发送事件这种机制更为适用。如果以后需要客户端能够发送命令(比如按联赛来筛选数据),这时可以添加专门的HTTP API,或者改用WebSockets。

    项目设置

    首先为你的项目创建一个新文件夹,然后使用npm进行初始化。mkdir命令用于创建目录,cd命令用于进入该目录,而npm init -y则会生成一份包含默认值的package.json文件,无需用户手动输入任何信息。

    mkdir mqtt-football-scores
    cd mqtt-football-scores
    npm init -y
    

    接下来安装所需的依赖包。每个包在应用程序中都扮演着特定的角色。请在项目根目录下运行以下命令:

    npm install express cors mqtt uuid
    
    • express:用于构建HTTP服务器和API的Web框架,提供了路由处理、中间件功能以及静态文件服务。

    • cors:允许跨源资源共享,这样前端应用就可以从不同的域名或端口调用后端API。

    • mqtt:Node.js版本的MQTT客户端库,负责处理连接、发送消息、订阅数据、重新连接以及服务质量控制等相关操作。

    • uuid:用于生成唯一标识符,确保每场比赛和事件的信息在所有系统中都是唯一的。

    接下来,创建如下的文件夹结构。server文件夹用于存放Node.js后端代码:public文件夹则包含浏览器需要加载的HTML、CSS文件以及客户端JavaScript代码。routes子文件夹将与路由处理相关的文件与主服务器文件分开存储。

    mqtt-football-scores/
    ├── server/
    │   ├── index.js
    │   ├── sse.js
    │   └── routes/
    │       └── matches.js
    ├── public/
    │   ├── index.html
    │   ├── admin.html
    │   └── viewer.html
    ├── mosquitto.conf
    ├── docker-compose.yml
    └── package.json
    

    package.json文件中添加"type": "module"这一条,这样你就可以使用JavaScript模块了(包括导入和导出功能)。type字段告诉Node.js将.js文件视为ES模块,这样一来,你就可以使用importexport语法,而不用CommonJS的requiremodule.exports了。

    {
      "name": "mqtt-football-scores",
      "version": "1.0.0",
      "type": "module",
      "main": "server/index.js"
    }
    

    如何配置MQTT代理服务器

    在服务器启动之前,必须先运行一个MQTT代理服务器。你有三种选择。

    选项1:使用Docker(推荐方案)

    创建一个docker-compose.yml文件。该文件定义了一个名为mosquitto的服务,该服务会运行Eclipse Mosquitto 2镜像。ports指令将主机上的1883端口映射到容器内的1883端口,这样你的Express服务器就可以连接到该代理服务器了。volumes指令会将本地的mosquitto.conf配置文件挂载到容器中,从而使代理服务器使用你的配置设置。restart: unless-stopped选项确保如果容器崩溃或你的机器重新启动时,容器能够自动重启。

    version: "3.8"
    
    services:
      mosquitto:
        image: eclipse-mosquitto:2
        container_name: mqtt-football-mosquitto
        ports:
          - "1883:1883"
        volumes:
          - ./mosquitto.conf:/mosquitto/config/mosquitto.conf
        restart: unless-stopped
    

    创建一个mosquitto.conf文件。listener 1883指令告诉Mosquitto在1883端口上监听MQTT连接请求。protocol mqtt指定了使用标准的MQTT协议。allow_anonymous true选项允许无需用户名和密码即可建立连接,这种设置适合本地开发环境,但在生产环境中应该禁用。log_dest stdoutlog_type all指令会将所有的日志输出显示到控制台上,这样你就可以方便地排查连接问题了。

    listener 1883
    protocol mqtt
    allow_anonymous true
    log.dest stdout
    log_type all
    

    启动代理服务器:

    docker-compose up -d
    

    选项2:本地安装

    在macOS系统中,如果使用了Homebrew,可以按照以下步骤进行配置:

    brew install mosquitto
    mosquitto -c mosquitto.conf
    

    在 Ubuntu 或 Debian 上:

    sudo apt install mosquitto mosquitto-clients
    sudo systemctl start mosquitto
    

    选项 3:公共测试代理服务器

    您可以直接使用 test.mosquitto.org 上的公共测试代理服务器,而无需进行任何安装操作。在启动服务器时,请设置以下环境变量:

    MQTT_BROKER=mqtt://test.mosquitto.org npm start
    

    注意:这个公共代理服务器是共享的,不适合用于生产环境,仅适用于开发和测试用途。

    如何构建 Express 服务器

    在这一步中,您将创建负责运行实时应用程序的主服务器。Express 服务器会处理 HTTP 请求,提供 HTML、JavaScript 等静态文件,并充当浏览器与 MQTT 代理服务器之间的桥梁。此外,它还提供了用于实现实时数据发送和接收的接口。从本质上讲,这个服务器是整个应用程序的核心,它确保了所有组件之间能够进行有效的通信。

    首先,在 server/index.js 文件中创建主服务器代码:

    import express from 'express';
    import cors from 'cors';
    import mqtt from 'mqtt';
    import { fileURLToPath } from 'url';
    import { dirname, join } from 'path';
    import { v4 as uuidv4 } from 'uuid';
    
    import { matchRoutes } from './routes/matches.js';
    import { setupSSE, addSSEClient } from './sse.js';
    
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = dirname(__filename);
    
    const MQTT_BROKER = process.env.MQTT_BROKER || 'mqtt://localhost:1883';
    const PORT = process.env.PORT || 3000;
    
    const app = express();
    app.use(cors());
    app.use(express.json());
    app.use(express.static(join(__dirname, '../public');
    
    let mqttClient = null;
    
    function connectMQTT() {
      mqttClient = mqtt.connect(MQTT_BROKER, {
        clientId: `football-scores-${uuidv4().slice(0, 8)}`,
        reconnectPeriod: 3000,
        connectTimeout: 10000,
      });
    
      mqttClient.on('connect', () => {
        console.log('已连接到 MQTT 代理服务器:', MQTT_BROKER);
        mqttClient.subscribe('sports/football/#', { qos: 1 }, (err) => {
          if (err) console.error('订阅失败:', err);
        });
      });
    
      mqttClient.on('error', (err) => {
        console.error('MQTT 连接出现错误:', err.message);
      });
    
      mqttClient.on('close', () => {
        console.log('MQTT 连接已关闭');
      });
    
      mqttClient.on('reconnect', () => {
        console.log('正在重新连接 MQTT 代理服务器...');
      });
    
      return mqttClient;
    }
    
    const mqttClientInstance = connectMQTT();
    const { publishMatch, publishScoreUpdate, publishEvent, getMatches } = matchRoutes(mqttClientInstance);
    setupSSE(mqttClientInstance);
    
    app.get('/api/events', (req, res) => addSSEClient(res));
    app.post('/api/matches', publishMatch);
    app.patch('/api/matches/:id/score', publishScoreUpdate);
    app.post('/api/matches/:id/events', publishEvent);
    app.get('/api/matches', getMatches);
    
    app.listen(PORT, () => {
      console.log(`足球比分服务器运行地址:http://localhost:${PORT}`);
      console.log(`管理员管理界面:http://localhost:${PORT}/admin.html`);
      console.log(`浏览器端访问地址:http://localhost:${PORT}/viewer.html`);
    });
    

    以下是各部分的功能:

    • 导入模块:`fileURLToPath`和`dirname`这两个工具函数用于复制CommonJS提供的`__dirname`变量,因为ES模块本身并不包含这个变量。你需要`__dirname`来构建通往`public`文件夹的路径。

    • 环境变量设置:`MQTT_BROKER`的环境变量默认值为`mqtt://localhost:1883`,因此服务器会连接到本地的Mosquitto实例。如果你使用Docker或远程代理服务器,可以修改这个值;`PORT`的环境变量默认值为3000。

    • 中间件功能:`cors()`中间件允许来自任何来源的请求,这在开发过程中非常有用;`express.json()`用于解析JSON格式的请求体;`express.static()`则用于提供`public`文件夹中的文件内容,因此用户可以通过`/admin.html`和`/viewer.html`等路径访问这些页面。

    • 连接MQTT服务器:会创建一个MQTT客户端,该客户端具有唯一的标识符、3秒的重新连接间隔以及10秒的超时设置。连接成功后,它会订阅`sports/football/#`这个主题,QoS等级为1。这里的`#`通配符表示“sports/football”目录下的所有主题。

    • 处理匹配相关路由:这些路由处理器负责创建比赛记录、更新比分以及添加事件信息。每个处理器都会通过MQTT发送数据,并以JSON格式返回响应。

    • 配置Server-Sent Events:会在MQTT客户端上注册一个监听器,当有消息到达时,该监听器会将消息内容转发给所有已连接的Server-Sent Events客户端。

    • 添加SSE客户端:当有用户访问`/api/events`路径时,会执行此操作。它负责设置Server-Sent Events的响应头信息,确保连接保持开启状态,并将响应对象添加到活跃客户端的列表中。

    • 路由配置:GET请求的`/api/events`路径用于建立Server-Sent Events数据流;而POST、PATCH和GET请求相关的路由处理则由`matchRoutes`中的处理器来完成。

      • 服务器会从`public`文件夹中提供静态文件,因此你的HTML页面可以放在根目录下直接访问。

        如何实现匹配相关路由

        在这一步中,你需要创建用于管理比赛、比分和事件信息的路由处理器。这些路由使得服务器能够接收来自管理员界面的请求,更新比赛数据,并将实时更新信息发送到MQTT服务器。同时,这些路由还会将比赛信息存储在内存中,以便在会话期间随时检索或修改这些数据。

        首先,在`server/routes/matches.js`文件中开始编写相关代码吧:

        import { v4 as uuidv4 } from 'uuid';

        const TOPIC_MATCH = 'sports/football/match';
        const TOPIC_SCORES = 'sports/football/scores';
        const TOPIC_events = 'sports/football/events';

        const matches = new Map();

        function publish(client, topic, payload, qos = 1) {
        if (!client?.connected) {
        console.warn('未连接到MQTT服务器,因此无法发布消息');
        return false;
        }
        client.publish(topic, JSON.stringify(payload), { qos, retain: false });
        return true;
        }

        export function matchRoutes(mqttClient) {
        return {
        publishMatch: (req, res) => {
        const { homeTeam, awayTeam, league, venue, kickoff } = req.body;
        if (!homeTeam || !awayTeam) {
        return res.status(400).json({ error: '必须提供主队和客队信息' });
        }

        const match = {
        id: uuidv4(),
        homeTeam,
        awayTeam,
        homeScore: 0,
        awayScore: 0,
        league: league || 'Premier League',
        venue: venue || '待确定',
        kickoff: kickoff || new Date().toISOString(),
        status: 'scheduled',
        minute: 0,
        events: [],
        createdAt: new Date().toISOString(),
        };

        matches.set(match.id, match);

        const topic = `\({TOPIC_MATCH}/\){match.id}`;
        publish(mqttClient, topic, match);
        publish(mqttClient, TOPIC_SCORES, { type: 'match_created', match });

        res.status(201).json(match);
        },

        publishScoreUpdate: (req, res) => {
        const { id } = req.params;
        const { homeScore, awayScore, minute, status } = req.body;

        const match = matches.get(id);
        if (!match) {
        return res.status(404).json({ error: '未找到对应的比赛' });
        }

        if (homeScore !== undefined) match.homeScore = homeScore;
        if (awayScore !== undefined) match.awayScore = awayScore;
        if (minute !== undefined) match.minute = minute;
        if (status !== undefined) match.status = status;

        const topic = `\({TOPIC_MATCH}/\){id}`;
        publish(mqttClient, topic, match);
        publish(mqttClient, TOPICSCORES, {
        type: 'score_update',
        matchId: id,
        homeScore: match.homeScore,
        awayScore: match.awayScore,
        minute: match.minute,
        status: match.status,
        });

        res.json(match);
        },

        publishEvent: (req, res) => {
        const { id } = req.params;
        const { type, team, player, minute, description } = req.body;

        const match = matches.get(id);
        if (!match) {
        return res.status(404).json({ error: '未找到对应的比赛' });
        }

        const event = {
        id: uuidv4().slice(0, 8),
        type: type || 'goal',
        team,
        player: player || 'Unknown',
        minute: minute ?? matchminute,
        description: description || `\({type}: \){player}`,
        timestamp: new Date().toISOString(),
        };

        match.events.push(event);
        if (type === 'goal') {
        if (team === match.homeTeam) match.homeScore++;
        else if (team === match.awayTeam) match.awayScore++;
        }

        const topic = `\({TOPIC_MATCH}/\){id}`;
        publish(mqttClient, topic, match);
        publish(mqttClient, TOPIC_events, { type: 'match_event', matchId: id, event });

        res.status(201).json({ match, event });
        },

        getMatches: (req, res) => {
        const list = Array.from(matches.values()).sort(
        (a, b) => new Date(b.createdAt) - new Date(acreatedAt)
        );
        res.json(list);
        },
        };
        }

        这些路由使用内存中的Map来存储匹配结果。在生产环境中,应该用PostgreSQL或MongoDB这样的数据库来替代它。

        关键逻辑说明:

        • publish:这是一个辅助函数,用于在发布消息之前检查MQTT客户端是否已连接。如果代理服务器处于关闭状态,该函数会记录警告信息而不会抛出异常。retain: false选项表示代理服务器不会为新订阅者保存最后一条消息。

        • publishMatch:验证homeTeamawayTeam字段是否存在。使用默认值创建一个比赛对象,包括联赛、场地、开球时间、状态等信息,然后将其存储在Map中,同时将相关信息发布到对应的主题以及比分主题上,最后返回状态为201(“已创建”)的比赛对象。

        • publishScoreUpdate:根据ID查找相应的比赛记录。如果未找到,则返回404错误码。仅更新提供的字段值(使用!== undefined进行判断,因此可以将比分设置为0),然后发布完整的比赛信息以及比分更新通知。

        • publishEvent:创建一个带有唯一短ID的事件对象,并将其添加到比赛的事件数组中。如果事件类型为goal,则根据相关队伍的情况更新主队或客队的得分。随后发布更新后的比赛信息以及事件通知。

        • getMatches:将Map中的数据转换为数组,按照createdAt字段降序排序,最后以JSON格式返回结果列表。

        所使用的MQTT主题:

        • sports/football/match/{id}:用于存储完整的比赛状态信息。在比赛创建或更新时使用该主题。

        • sports/football/scores:用于发送比分变化通知。适用于比赛创建和比分更新场景。

        • sports/football/events:用于发布与比赛相关的事件信息,如进球、罚球等。

        publish函数会使用JavaScript对象表示法(JSON)格式的数据,并设置QoS等级为1(确保消息至少会被发送一次)。MQTT定义了三种QoS等级:0(最多发送一次)、1(至少发送一次)和2(恰好发送一次)。QoS等级1能够保证代理服务器会不断重试,直到订阅者确认收到消息,从而减少因连接短暂中断而导致比分更新信息丢失的情况。

        如何利用“服务器发送事件”技术将MQTT与浏览器连接起来

        在这一步中,我们将创建一个“服务器发送事件”模块,用于将MQTT消息传递给浏览器。由于MQTT是基于TCP协议的,而浏览器无法直接与之建立连接,因此我们使用基于HTTP的流式传输技术——SSE来实现这一功能。SSE能够保持连接状态,使服务器能够实时向已连接的浏览器推送更新信息,这样观众就可以在不刷新页面的情况下立即看到比赛动态。

        现在请在server/sse.js文件中编写相关代码:

        const clients = new Set();
        
        export function setupSSE(mqttClient) {
          if (!mqttClient) return;
        
          mqttClient.on('message', (topic, message) => {
            try {
              const payload = JSON.parse(message.toString());
              const data = JSON.stringify({ topic, ...payload });
              clients.forEach((res) => {
                try {
                  res.write(`data: ${data}\n\n`);
                } catch (e) {
                  clients.delete(res);
                }
              });
            } catch (e) {
              console.error('SSE解析错误:', e.message);
            }
          });
        
          export function addSSEClient(res) {
            res.setHeader('Content-Type', 'text/event-stream');
            res.setHeader('Cache-Control', 'no-cache');
            res.setHeader('Connection', 'keep-alive');
            res.setHeader('X-Accel-Buffering', 'no');
            res.flushHeaders();
        
            clients.add(res);
        
            res.on('close', () => {
              clients.delete(res);
            });
          }
        }

        各部分的说明:

        • 客户端集合:集合用于存储所有活跃的“服务器发送的事件”响应对象。使用集合可以方便地添加或删除客户端,同时避免重复。

        • setupSSE:将一个消息监听器绑定到MQTT客户端上。当有任何消息到达已订阅的主题时,就会触发回调函数。该函数会解析消息的内容(格式为JSON),然后将主题信息以{ topic, ...payload }的形式合并到消息中,并将处理后的结果发送给所有客户端。“服务器发送的事件”格式要求每条消息都必须采用data: {content}\n\n这种格式(即包含两行换行符)。forEach循环会捕获任何写入错误(例如当有客户端断开连接时),并将该客户端从集合中移除。

        • addSSEClient:将Content-Type头设置为text/event-stream,这样浏览器就会将响应内容视为事件流来处理。设置Cache-Control: no-cacheConnection: keep-alive头可以防止浏览器或代理服务器缓存数据或关闭连接。X-Accel-Buffering: no头则会禁用Nginx的缓冲功能,因为这种缓冲机制可能会导致“服务器发送的事件”传输延迟或被阻塞。flushHeaders方法会立即发送这些头部信息,从而确保连接能够正常建立。当有客户端断开连接时,close事件处理程序会将该客户端从集合中移除。

        “服务器发送的事件”是一种单向通信机制(仅从服务器发送到客户端)。对于这种使用场景来说,这样的设计已经足够了,因为观众只需要接收更新信息,而不需要通过同一通道发送消息回服务器。

        如何构建管理员上传接口

        管理员界面是一个简单的HTML页面,通过这个页面,赛事创建者可以新建比赛、更新比分,以及添加进球、罚牌等事件信息。该界面使用了标准的HTML表单,因此用户可以将数据提交到服务器,而JavaScript会负责处理表单提交逻辑及动态更新效果。所有的标记、样式和脚本代码都保存在public/admin.html文件中,Express服务器会将这个文件作为静态页面进行提供。

        管理员界面的HTML结构与样式

        该页面以标准的HTML5模板开始编写。charsetviewport元标签确保了正确的字符编码以及移动设备上的响应式布局。页面还会从Google Fonts中加载“Outfit”字体,从而呈现出干净、现代的外观。

        层叠样式表(CSS)在:root块中使用了自定义属性(变量),这样就可以在一个地方统一修改整个界面的颜色方案。--bg变量用于设置深色背景,--surface用于卡片背景,用于绿色强调色,--text-muted则用于次要文本的颜色。.grid类为表单字段创建了两列布局;当屏幕宽度小于600像素时,这些字段会自动缩成一列。.toast类会将通知框放置在页面的右下角,并且当添加.show类时,会通过transformopacity属性实现滑入动画效果。

        现在在public/admin.html路径下创建这个文件:

        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          管理员界面 - 足球比分上传
          
            :root {
              --bg: #0f1419;
              --surface: #1a2332;
              --surface-hover: #243044;
              --accent: #00d26a;
              --accent-dim: #00a854;
              --text: #e8edf2;
              --text-muted: #8b9aab;
              --border: #2d3a4d;
              --danger: #ff4757;
            }
            * { box-sizing: border-box; margin: 0; padding: 0; }
            body {
              font-family: 'Outfit', sans-serif;
              background: var(--bg);
              color: var(--text);
              min-height: 100vh;
              padding: 2rem;
            }
            .container { max-width: 900px; margin: 0 auto; }
            header {
              display: flex;
              align-items: center;
              gap: 1rem;
              margin-bottom: 2rem;
              padding-bottom: 1rem;
              border-bottom: 1px solid var(--border);
            }
            section {
              background: var(--surface);
              border-radius: 12px;
              padding: 1.5rem;
              margin-bottom: 1.5rem;
              border: 1px solid var(--border);
            }
            .grid {
              display: grid;
              grid-template-columns: 1fr 1fr;
              gap: 1rem;
            }
            @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }
            .form-group { display: flex; flex-direction: column; gap: 0.4rem; }
            .form-group.full { grid-column: 1 / -1; }
            input, select {
              padding: 0.65rem 1rem;
              border: 1px solid var(--border);
              border-radius: 8px;
              background: var(--bg);
              color: var(--text);
              font-family: inherit;
            }
            button {
              padding: 0.75rem 1.5rem;
              border: none;
              border-radius: 8px;
              font-family: inherit;
              font-weight: 600;
              cursor: pointer;
            }
            .btn-primary { background: var(--accent); color: var(--bg); }
            .btn-secondary { background: var(--surface-hover); color: var (--text); border: 1px solid var(--border); }
            .actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1rem; }
            .badge { background: var(--accent); color: var(--bg); font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: 999px; font-weight: 600; }
            .viewer-link { margin-left: auto; color: var(--accent); text-decoration: none; font-weight: 500; }
            section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: var(--text-muted); }
            label { font-size: 0.85rem; font-weight: 500; color: var(--text-muted); }
            .toast {
              position: fixed;
              bottom: 2rem;
              right: 2rem;
              padding: 1rem 1.5rem;
              border-radius: 8px;
              font-weight: 500;
              color: var(--bg);
              background: var(--accent);
              transform: translateY(100px);
              opacity: 0;
              transition: transform 0.3s, opacity 0.3s;
              z-index: 100;
            }
            .toast.show { transform: translateY(0); opacity: 1; }
            .toast.error { background: var(--danger); }
            .match-card {
              display: flex;
              align-items: center;
              justify-content: space-between;
              padding: 1rem;
              background: var(--bg);
              border-radius: 8px;
              margin-bottom: 0.5rem;
              border: 1px solid var(--border);
            }
            .match-score { font-size: 1.5rem; font-weight: 700; color: var(--accent); margin: 0 1rem; }
            .match-info { flex: 1; }
            .match-teams { font-weight: 600; font-size: 1rem; }
            .match-meta { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; }
            .match-actions { display: flex; gap: 0.5rem; }
            .matchActions button { padding: 0.5rem 1rem; font-size: 0.85rem; }
            .match-list { margin-top: 1rem; }
          </style>
        </head>>
        <body>
          
        ⚽ 足球比分 管理员 → 打开查看器 创建新比赛
        主队
        客队
        联赛
        比赛场地
        开球时间(ISO格式)
        创建比赛
        更新比分 选择比赛
        主队得分
        客队得分
        比赛进行到第几分钟
        比赛状态
        更新比分
        添加比赛事件(进球、黄牌等) 选择比赛
        事件类型
        涉及球队
        相关球员
        事件发生的具体分钟数
        添加事件

        正在进行的比赛

        </div>

        这三种形式都使用了 `id` 属性(`createMatch`, `updateScore`, `addEvent`),这样 JavaScript 才能为这些元素绑定提交处理函数。比赛筛选下拉框(`scoreMatchId` 和 `eventMatchId`)会在页面加载时动态生成内容。`matchList` 这个 div 元素用于显示当前正在进行的比赛列表。而 `toast` 元素则被放置在主容器之外,这样它就可以固定在视口中。

        管理员页面的 JavaScript 逻辑

        这段脚本负责处理管理员页面与服务器之间的所有交互。它定义了一些辅助函数,用于显示通知、获取比赛信息、更新用户界面以及提交数据。每当页面加载完成或用户执行了某些操作(创建比赛、更新比赛信息或提交事件数据)后,用户界面都会被重新刷新,从而确保始终能够看到最新的比赛数据。

          <script>
            const API = '/api';
            const toast = document.getElementById('toast');
        
            function showToast(msg, isError = false) {
              toast.textContent = msg;
              toast.className = 'toast show' + (isError ? ' error' : '');
              setTimeout(() => toast.classList.remove('show'), 3000);
            }
        
            async function fetchMatches() {
              const res = await fetch(`${API}/matches`);
              return res.json();
            }
        
            function populateSelects(matches) {
              const opts = matches.map(m => `<option value="\({m.id}">\){m.homeTeam} vs ${m.awayTeam}</option>`).join('');
              const html = '<option value="">>-- 选择比赛 --<>/option>>' + opts;
              document.getElementById('scoreMatchId').innerHTML = html;
              document.getElementById('eventMatchId').innerHTML = html;
            }
        
            function renderMatchList(matches) {
              const list = document.getElementById('matchList');
              if (!matches.length) {
                list.innerHTML = '<p style="color: var(--text-muted);">>尚未有比赛信息。请在上方创建新的比赛。/div>
                    
        /div>
        /div>
        更新比赛信息>
        立即更新比分>
        </div> `).join(''); } function quickScore(id, h, a) { document.getElementById('scoreMatchId').value = id; document.getElementById('homeScore').value = h; document.getElementById('awayScore').value = a; } async function loadMatches() { const matches = await fetchMatches(); populateSelects(matches); renderMatchList(matches); } document.getElementById('createMatch').onsubmit = async (e) => { e.preventDefault(); const body = { homeTeam: document.getElementById('homeTeam').value.trim(), awayTeam: document.getElementById('awayTeam').value.trim(), league: document.getElementById('league').value.trim() || 'Premier League', venue: document.getElementById('venue').value.trim() || 'TBD', kickoff: document.getElementById('kickoff').value ? new Date(document.getElementById('kickoff').value).toISOString() : new Date().ISOString(), }; try { const res = await fetch(`${API}/matches`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (res.ok) { showToast('比赛创建成功!'); document.getElementById('createMatch').reset(); loadMatches(); } else.showToast(data.error || '操作失败', true); } catch (err) { showToast('网络错误', true); } }; document.getElementById('updateScore').onsubmit = async (e) => { e.preventDefault(); const id = document.getElementById('scoreMatchId').value; const body = { homeScore: parseInt(document.getElementById('homeScore').value, 10), awayScore: parseInt(document.getElementById('awayScore').value, 10), minute: parseInt(document.getElementById('minute').value, 10) || undefined, status: document.getElementById('status').value, }; try { const res = await fetch(`\({API}/matches/\){id}/score`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (res.ok) { showToast('比分更新成功!'); loadMatches(); } else.showToast(data.error || '操作失败', true); } catch (err) { showToast('网络错误', true); } }; document.getElementById('addEvent').onsubmit = async (e) => { e.preventDefault(); const id = document.getElementById('eventMatchId').value; const body = { type: document.getElementById('eventType').value, team: document.getElementById('eventTeam').value.trim(), player: document.getElementById('eventPlayer').value.trim(), minute: parseInt(document.getElementById('eventMinute').value, 10) || undefined, description: document.getElementById('eventDesc').value.trim() || undefined, }; try { const res = await fetch(`\({API}/matches/\){id}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (res.ok) { showToast('事件添加成功!'); loadMatches(); } else.showToast(data.error || '操作失败', true); } catch (err) { showToast('网络错误', true); } }; loadMatches(); </script> </body>> </html>>

        各部分的说明:

        • showToast:更新提示信息的文本内容,并添加“show”类以触发CSS动画效果。当参数为true时,错误提示信息的背景会变为红色。setTimeout会在3秒后移除“show”类,从而使提示信息再次隐藏。

        • fetchMatches:调用GET请求,访问/api/matches接口,获取解析后的JSON数据,以便用户界面能够显示最新信息。

        • populateSelects:根据matches数组生成选项元素,并将它们添加到两个下拉列表中,这样在“更新比分”和“添加赛事”表单中就能看到相同的比赛列表。

        • renderMatchList:当没有比赛数据时,会显示占位符;否则会将每场比赛以卡片的形式呈现出来,卡片上会显示球队名称、比分、联赛信息、状态以及“更新”按钮。

        • quickScore:当用户点击某场比赛的“更新”按钮时,会预先填充“更新比分”表单的内容,这样用户就可以直接修改比分而无需重新选择比赛。

        • loadMatches:获取比赛数据,填充下拉列表,并显示比赛列表。该函数会在页面加载时执行,同时在每次成功创建、更新赛事或提交信息后也会被调用。

        • onsubmit:阻止表单的默认提交行为,根据表单中的数据构建请求体,向相应的接口发送请求。如果请求成功,会显示提示信息并调用loadMatches函数来更新用户界面。

        如何构建实时观看界面

        该观看界面能够实时显示比赛动态,并且会连接到“服务器推送的事件”接口,因此能够在服务器发送数据后立即接收到这些信息。

        与管理员页面不同,这个观看界面是只读的:它只会显示实时比分、赛事事件和状态变化,无需用户进行任何操作。该页面采用了深色主题,并配有连接状态指示器,以便用户能够了解实时流是否处于活动状态。

        现在请在public/viewer.html文件中创建代码:

        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          实时足球比分
          
            :root {
              --bg: #0a0e14;
              --surface: #131a24;
              --accent: #00d26a;
              --accent-glow: rgba(0, 210, 106, 0.3);
              --text: #e8edf2;
              --text-muted: #8b9aab;
              --border: #2d3a4d;
              --live: #ff4757;
            }
            * { box-sizing: border-box; margin: 0; padding: 0; }
            body {
              font-family: 'Outfit', sans-serif;
              background: var(--bg);
              color: var(--text);
              min-height: 100vh;
              padding: 2rem;
            }
            .container { max-width: 700px; margin: 0 auto; }
            header { text-align: center; margin-bottom: 2rem; }
            .status {
              display: inline-flex;
              align-items: center;
              gap: 0.5rem;
              font-size: 0.85rem;
              color: var(--text-muted);
            }
            .status-dot {
              width: 8px;
              height: 8px;
              border-radius: 50%;
              background: var(--text-muted);
              animation: pulse 2s infinite;
            }
            .status-dotconnected {
              background: var(--accent);
              box-shadow: 0 0 0 3px var(--accent-glow);
            }
            @keyframes pulse {
              0%, 100% { opacity: 1; }
              50% { opacity: 0.5; }
            }
            .match-card {
              background: var(--surface);
              border-radius: 16px;
              padding: 1.5rem;
              margin-bottom: 1rem;
              border: 1px solid var(--border);
              transition: border-color 0.2s, box-shadow 0.2s;
            }
            .match-card.live {
              border-color: var(--live);
              box-shadow: 0 0 0 1px rgba(255, 71, 87, 0.2);
            }
            .match-header {
              display: flex;
              align-items: center;
              justify-content: space-between;
              margin-bottom: 1rem;
            }
            .league { 
              font-size: 0.8rem; 
              color: var(--text-muted); 
              margin-bottom: 0.25rem; 
            }
            .match-teams {
              display: flex;
              align-items: center;
              justify-content: space-between;
              gap: 1rem;
              margin: 1rem 0;
            }
            .team { flex: 1; text-align: center; font-weight: 600; font-size: 1.1rem; }
            .team.home { text-align: left; }
            .team.away { text-align: right; }
            .score-box {
              display: flex;
              align-items: center;
              justify-content: center;
              min-width: 80px;
              gap: 0.5rem;
            }
            .score { 
              font-size: 2rem; 
              font-weight: 700; 
              color: var(--accent); 
             }
            .status-badge { 
              font-size: 0.7rem; 
              padding: 0.2rem 0.5rem; 
              border-radius: 4px; 
              font-weight: 600; 
             }
            .status-badge.live { background: var(--live); color: white; }
            .status-badgefinished { background: var(--border); color: var(--text-muted); }
            .status-badge.scheduled { background: var(--accent); color: var (--bg); }
            .match-meta { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.5rem; }
            .events { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
            .events h4 { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.5rem; }
            .event { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; padding: 0.35rem 0; border-bottom: 1px solid var(--border); }
            .event:last-child { border-bottom: none; }
            .event-icon { width: 24px; text-align: center; font-size: 1rem; }
            .event.goal .event-icon { color: var(--accent); }
            .event.yellow_card .event-icon { color: #ffd93d; }
            .event.red_card .event-icon { color: var(--live); }
            .feed { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
            .feed h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted); }
            .feed-item { font-size: 0.85rem; padding: 0.5rem 0; color: var(--text-muted); border-bottom: 1px solid var(--border); }
            .feed-item:last-child { border-bottom: none; }
            .feed-item strong { color: var (--text); }
            .empty { text-align: center; padding: 3rem 2rem; color: var(--text-muted); }
            .empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
          </style>
        </head>>
        <body>
          
        ⚽ 实时足球比分
        </span> 正在连接中…</span> 实时动态
        </div>

        各部分的说明:

        • 页眉:显示页面标题和连接状态指示器,让用户能够知道实时流是否处于活动状态。

        • 状态指示圆点:在未连接时该圆点会闪烁,而当SSE连接建立后,它会变为绿色并带有光效。

        • 比赛信息展示区域:id为“matches”的div元素会通过JavaScript动态显示比赛信息,这些信息会随着数据的到来而更新。

        • 实时更新列表

          :按时间顺序显示最新的更新内容,让用户能一目了然地看到最近发生的事件。

        • CSS主题样式

          :使用深色背景及自定义样式,用户可以通过一处设置来调整页面外观。实时标记和边框样式可以突出显示正在进行的比赛。

        • 服务器发送事件的集成功能

          :JavaScript代码会连接到/api/events接口,每当有新数据到达时,就会更新该页面内容。

        页眉部分用于展示标题和连接状态。id为“matches”的div元素用于显示比赛信息,这些信息由JavaScript动态填充。“实时更新列表”区域则会显示最新的更新内容。

        查看器相关的JavaScript逻辑

        这段脚本通过维护内存中的比赛信息集合以及实时更新列表,来实现实时查看界面的功能。它还会连接到服务器发送事件的接口,从而确保数据能够实时地从服务器传输到浏览器。每当有新消息到达时,页面会自动更新,以显示最新的比分、事件详情和比赛信息。

          <script>
            const API = '/api';
            const matches = new Map();
            const feed = [];
            const MAX_feed = 20;
        
            const statusDot = document.getElementById('statusDot');
            const statusText = document.getElementById('statusText');
            const matchesEl = document.getElementById('matches');
            const feedEl = document.getElementById('feed');
        
            function setStatus(connected) {
              statusDot.classList.toggle('connected', connected);
              statusText.textContent = connected ? '正在直播' : '正在重新连接...';
            }
        
            function renderMatches() {
              const list = Array.from(matches.values()).sort(
                (a, b) => new Date(b.createdAt) - new Date(acreatedAt)
              );
              if (!list.length) {
                matchesEl.innerHTML = `
                  <div class="empty">
                    <div class="empty-icon">>⚽</div>
                    

        目前还没有比赛信息。更新内容会实时显示在这里。

        </div> `; return; } matchesEl.innerHTML = list.map(m => ` <div class="match-card \({m.status === 'live' ? '正在直播' : ''}" data-id="\){m.id}">
        \({m.venue} • \){m.kickoff ? new Date(m.kickoff).toLocaleString() : ''}</div>
        \({m.status}</span>
        ${m.homeTeam}</div>
        ${m.homeScore}</span>> &_-&- ${m.awayScore}</span>>
        ${m.awayTeam}</div>
        \({mminute ? `<div class="match-meta">\({m_minute}'<>/div>` : ''); ${m.events?.length ? `
        事件详情 ${m.events.map(e => `
        ${getEventIcon(e.type)}</span>> \({eminute}' \){e.player} (\({e.team}) - \){e.description || e.type}</span>
        `).join('')]
         
        ` : '':
        </div> `).join(''); } function getEventIcon(type) { const icons = { goal: '⚽', yellow_card: '🟨', red_card: '🟥', substitution: '🔄', penalty: '⚽' }; return icons[type] || '•'; } function renderFeed() { const items = feed.slice(-MAX_feed).reverse(); feedEl.innerHTML = items.length ? items.map(f => `<div class="feed-item">>${f}</div>>`.join('') : '<div class="feed-item">>正在等待更新内容...</div>!'; } function addFeedItem(type, msg) { const time = new Date().toLocaleTimeString(); feed.push(`\({time}<>/strong> | \){msg}`); if (feed.length > MAX_feed) feed.shift(); renderFeed(); } function handleMessage(data) { if (data.match) { matches.set(data.match.id, data.match); renderMatches(); } if (data.id && data.homeTeam && data.awayTeam) { matches.set(data.id, data); renderMatches(); } if (data.type === 'match_created' && data.match) { addFeedItem('比赛创建', `新比赛:\({data.match.homeTeam} 对阵 \){data.match.awayTeam}`); } if (data.type === 'score_update') { const m = matches.get(data.matchId); if (m) { m.homeScore = data.homeScore; m.awayScore = data.awayScore; m.minute = dataminute; m.status = data.status; matches.set(data.matchId, m); addFeedItem('比分更新', `\({m.homeTeam} \){data.homeScore}-\({data.awayScore} \){m.awayTeam} (${data_minute || '?'}')`); renderMatches(); } } if (data.type === 'match_event' && data.event) { const m = matches.get(data.matchId); if (m) { m.events = m.events || []; m.events.push(data.event); if (data.event.type === 'goal') { if (data.event.team === m.homeTeam) m.homeScore++; else if (data.event.team === m.awayTeam) m.awayScore++; } matches.set(data.matchId, m); addFeedItem('事件发生', `\({data.event.type}: \){data.event.player} (\({data.event.team}) - \){data.eventminute}'`); renderMatches(); } } } function handleSSEMessage(msg) { try { const data = JSON.parse(msg); if (data.topic && /^sports\/football\/match\/([^/]+)$/.test(data.topic) && data.id) { matches.set(data.id, data); } handleMessage(data); } catch (e) {} } function connectSSE() { const es = new EventSource(`${API}/events`); es.onopen = () => setStatus(true); es.onerror = () => { setStatus(false); es.close(); setTimeout(connectSSE, 3000); }; es.onmessage = (e) => handleSSEMessage(e.data); } async function loadInitial() { try { const res = await fetch(`${API}/matches`); const list = await res.json(); list.forEach(m => matches.set(m.id, m)); renderMatches(); } catch (e) { matchesEl.innerHTML = `
        <>p>无法加载比赛信息。服务器是否正在运行?

        <>/div>>`; } } loadInitial(); connectSSE(); </body>> </html>>

        各部分的说明:

        • matches Map:按照ID存储匹配对象,这样就可以高效地进行更新操作,而无需遍历数组。

        • feed array:保留最近发生的一些事件记录(数量上限为MAX_feed),以确保实时信息展示的效率不会受到影响。

        • setStatus:在状态提示符上显示相应的连接状态,并将状态文本更新为“Live”或“Reconnecting…”,以便用户能够了解当前的连接情况。

        • renderMatches:将Map转换为按时间顺序排列的数组(最新事件排在最前面)。如果没有匹配结果,就会显示空状态;否则就会展示包含联赛信息、比赛场地、参赛队伍、比分、状态标识以及相关事件的列表。

        • getEventIcon:为每种类型的事件生成相应的表情符号,从而让用户能够直观地识别各种事件(如进球、换人等)。

        • renderFeed:当没有更新信息时,会显示实时信息内容;否则就会显示占位符文本。

        • addFeedItem:在信息列表中添加带有时间戳的消息,只保留最新的消息内容,然后重新渲染整个信息列表。

        • handleMessage:处理接收到的数据。它会更新matches Map中的匹配对象信息、比分变化以及各种比赛事件。对于比分变化和进球情况,它会及时调整显示内容,确保观众能够看到实时的变化。

        • handleSSEMessage:解析服务器发送的事件数据,并将其传递给handleMessage函数。如果消息中包含比赛相关信息和完整的数据,就会将其存储在Map中。

        • connectSSE:建立与/api/events的EventSource连接。当连接成功建立后,会将其标记为实时连接状态;如果出现错误,会在三秒后重新尝试连接,这样就可以避免短暂的网络问题导致数据流中断。

        • loadInitial:在页面加载时获取现有的比赛信息,这样即使没有实时更新数据,观众也能看到相关内容。

        如何构建首页

        首页(public/index.html)是一个简单的登录页面,它提供了链接到观众端界面和管理端界面的入口。该页面采用了居中的卡片布局,并设置了两个按钮:主按钮(绿色)用于进入观众端界面,次级按钮(带有边框)用于进入管理端界面。整个页面都使用了相同的深色主题和Outfit字体,以确保视觉一致性。这个页面完全不使用JavaScript,而是由纯静态的HTML和CSS代码构成的。

        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          足球比分 - MQTT实时更新
          
            :root {
              --bg: #0a0e14;
              --surface: #131a24;
              --accent: #00d26a;
              --text: #e8edf2;
              --text-muted: #8b9aab;
            }
            * { box-sizing: border-box; margin: 0; padding: 0; }
            body {
              font-family: 'Outfit', sans-serif;
              background: var(--bg);
              color: var(--text);
              min-height: 100vh;
              display: flex;
              align-items: center;
              justify-content: center;
              padding: 2rem;
            }
            .card {
              background: var(--surface);
              border-radius: 16px;
              padding: 2rem;
              max-width: 400px;
              text-align: center;
              border: 1px solid rgba(255,255,255,0.06);
            }
            h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
            p { color: var(--text-muted); font-size: 0.95rem; margin-bottom: 1.5rem; }
            .links { display: flex; flex-direction: column; gap: 0.75rem; }
            a {
              display: block;
              padding: 1rem 1.5rem;
              background: var(--accent);
              color: var(--bg);
              text-decoration: none;
              font-weight: 600;
              border-radius: 10px;
              transition: opacity 0.2s;
            }
            a:hover { opacity: 0.9; }
            a.secondary {
              background: transparent;
              color: var(--text);
              border: 1px solid rgba(255,255,255,0.15);
            }
          
        
        
          

        ⚽ 足球比分

        通过MQTT和Mosquitto实现实时更新

        当您访问根路径URL(http://localhost:3000/)时,Express服务器会提供index.html文件。因为express.static中间件会从public文件夹中提供文件,而当请求路径为/时,Express会自动返回index.html

        如何运行和测试该系统

        首先启动MQTT代理(可以通过Docker安装或本地安装)。

        然后启动Express服务器:

        npm start

        接下来,在管理面板中访问http://localhost:3000/admin.html,创建一场比赛记录(例如曼彻斯特联对阵利物浦)。

        在另一个标签页或窗口中打开http://localhost:3000/viewer.html。在管理面板中更新比分或添加比赛事件,此时查看页面应该能在瞬间更新,而无需重新加载页面。

        如何将系统扩展到生产环境

        当前的实现方式使用的是内存存储机制。在部署到生产环境时,您需要采取以下措施:

        • 添加数据库:将比赛记录存储在PostgreSQL、MongoDB或其他数据库中。在程序启动时加载这些数据,并确保所有创建、更新和事件操作都能被持久化保存。

        • 添加身份验证机制:使用JSON Web Tokens(JWT)或基于会话的身份验证方式,确保只有授权用户才能上传比分数据。

        • 进行数据校验:利用Joi或Zod等库对请求体中的数据进行校验,以防止无效数据的提交。

        • 启用TLS加密:在生产环境中,为Express服务器使用HTTPS协议,并确保WebSocket通信及MQTTS通信的安全性。

        • 水平扩展系统:如果运行多个服务器实例,每个实例都应该拥有自己的MQTT连接和SSE客户端。MQTT代理会将消息发送给所有订阅者,因此每个实例都会收到更新信息,并将其转发给连接的观众端。

        API参考

        以下是您的服务器提供的API接口列表,供您快速查阅:

        方法 接口地址 功能描述
        GET /api/matches 以JSON数组形式返回所有比赛记录
        POST /api/matches 创建新的比赛记录。请求体格式:{ homeTeam, awayTeam, league?, venue?, kickoff? }
        PATCH /api/matches/:id/score 更新比赛比分。请求体格式:{ homeScore?, awayScore?, minute?, status? }
        POST /api/matches/:id/events 添加比赛事件记录。请求体格式:{ type?, team, player?, minute?, description? }
        GET /api/events 服务器发送的事件流,用于实时更新信息

        status字段可以是scheduledlivehalftimefinished。而type字段对于事件来说,可以是goalyellow_cardred_cardsubstitutionpenalty

        故障排除

        在构建这个系统的过程中,你可能会遇到各种问题。以下是一些常见的故障原因:

        服务器无法连接到MQTT代理。请确认Mosquitto正在运行。如果你使用的是Docker,请执行docker ps命令来检查容器是否处于启动状态。如果使用的是公共MQTT代理,请确保你的设备能够接入互联网,并且防火墙允许端口1883上的数据输出。

        当你更改比赛结果时,查看界面并没有及时更新。打开浏览器的开发者工具,检查“网络”选项卡。发送到/api/events的请求应该处于未完成状态(即请求窗口不会关闭)。如果请求失败或窗口关闭,请查阅服务器日志以查找错误原因。同时,请确认你使用的代理服务器没有缓存或中断长时间持续的连接。

        重新启动服务器后,之前的比赛记录消失了。当前的实现方式是将比赛数据存储在内存中,因此重启服务器会清除这些数据。为了使数据在重启后仍然保留,请按照生产环境配置的要求添加数据库来持久化存储数据。

        总结

        通过本教程,你使用MQTT、Mosquitto和Express构建了一个实时足球赛事更新系统。你学会了以下内容:

        • 如何将Express服务器连接到MQTT代理

        • 如何向MQTT主题发送比赛结果更新信息

        • 如何订阅相关主题,并通过“服务器发送的事件”功能将数据推送到浏览器中

        • 如何创建管理员界面来添加比赛信息和更新比分

        • 如何构建无需页面刷新即可显示实时数据的查看界面

        这种开发模式同样适用于其他实时系统,比如物联网监控面板、实时通知系统、协作编辑工具等等。MQTT为你提供了可靠且可扩展的消息传输机制,而“服务器发送的事件”功能则使你能够轻松地将更新内容推送给Web客户端。

        本教程中的代码为你的开发工作打下了坚实的基础。你可以尝试添加更多功能,比如按联赛筛选比赛信息、记录历史赛事数据或启用推送通知功能,从而让这个系统更好地满足你的需求。

Comments are closed.