實時更新無處不在——比如體育比賽的實時比分、股票行情更新、聊天應用程序以及物聯網數據面板。如果你想構建能夠在數據發生變化的瞬間就將其推送給用戶的系統,那么你就需要合適的工具。

消息隊列遙測傳輸協議(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.