实战Demo——SSE Notification Server

@


系统架构总览


消息广播流程(时序图)


SSE 连接生命周期(状态图)


Provider 回调详细流程


线程模型

线程安全策略

数据 保护机制 说明
g_messageLog g_sseMutex 写操作(push_back/pop_front)和读操作都加锁
g_nextSeq g_sseMutex 与 messageLog 一同保护,保证 seq 与消息的一致性
g_baseSeq g_sseMutex 淘汰消息时递增
g_sseCv g_sseMutex wait/notify 都必须在锁内操作
g_clientCount std::atomic<int> 无锁原子操作
g_running std::atomic<bool> 无锁原子操作

消息队列结构

序列号机制

消息入队:   g_messageLog.push_back({g_nextSeq++, json})
消息淘汰:   g_messageLog.pop_front()  →  g_baseSeq++
客户端同步:  while (lastSeq < g_nextSeq) { 发送 seq==lastSeq 的消息; lastSeq++; }
慢客户端:   if (lastSeq < g_baseSeq) { lastSeq = g_baseSeq; /* 跳过已淘汰消息 */ }

服务器启动与关闭流程


SSE 协议数据格式

发送格式 (服务器 → 客户端)

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked

data: {"type":"client_count","count":1}

data: {"type":"server_time","time":"2026-06-30T12:00:02Z"}

data: {"type":"user_message","content":"Hello via SSE!"}

data: {"type":"server_time","time":"2026-06-30T12:00:04Z"}

消息类型

type 触发条件 示例
client_count 客户端首次连接 {"type":"client_count","count":3}
server_time 每2秒定时推送 {"type":"server_time","time":"2026-..."}
user_message POST /message {"type":"user_message","content":"Hello!"}

SSE 帧格式规则

  • 每条消息以 data: <content>\n\n 发送
  • \n\n(两个换行符)是 SSE 协议的事件分隔符
  • content 是 JSON 字符串
  • 空行表示事件边界

关键数据结构


与 cpp-httplib 的集成方式

要求综述

基本信息

项目 内容
难度 ⭐⭐
预估时间 2-3 小时
核心技能 SSE (Server-Sent Events), 后台线程, 条件变量
产出物 一个能实时推送事件的 SSE 服务器

学习目标

  • 理解 SSE 协议的工作原理
  • 掌握条件变量在事件通知中的使用
  • 理解"请求线程"和"推送线程"的协作
  • 为理解 SseTransport 的双队列设计做准备

前置准备

阅读以下文档:

需求规格

实现一个 SSE 推送服务器:

  1. POST /message -- 接收消息,推送给所有 SSE 客户端
  2. GET /events -- SSE 端点,接收实时推送
  3. GET / -- 返回一个简单的 HTML 页面,用于浏览器测试
  4. 定时推送 -- 后台线程每2秒自动推送一次服务器时间
  5. 客户端计数 -- SSE 端点显示当前连接的客户端数量

步骤指引

Step 1: 设计数据结构 (10分钟)

需要:
- 一个消息队列(所有客户端共享)
- 一个条件变量(有新消息时通知客户端线程)
- 每个 SSE 客户端一个独立的线程(阻塞在条件变量上)
- 一个原子计数器(客户端数量)

Step 2: 实现消息系统和 SSE 端点 (40分钟)

核心思路:

  • POST /message 收到消息后,push 到队列,notify_all
  • 每个 GET /events 连接启动一个线程,wait 在条件变量上
  • 有新消息时,所有等待的线程被唤醒,将消息写入 SSE 响应

Step 3: 实现 HTML 测试页面 (10分钟)

<!-- 简单的 HTML 页面,用 EventSource 接收 SSE -->
<!DOCTYPE html>
<html>
<body>
  <h1>SSE Test</h1>
  <div id="messages"></div>
  <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
      document.getElementById('messages').innerHTML += '<p>' + e.data + '</p>';
    };
  </script>
</body>
</html>

Step 4: 测试 (20分钟)

# 启动服务器
./sse_server

# 终端1: 打开 SSE 连接(观察推送)
curl -N http://localhost:8080/events

# 终端2: 发送消息
curl -X POST http://localhost:8080/message -d "Hello via SSE!"

# 浏览器: 打开 http://localhost:8080/ 观察可视化推送

期望输出

# 终端1 的 SSE 流(curl -N 查看)
data: {"type":"server_time","time":"2024-01-01T12:00:02"}

data: {"type":"server_time","time":"2024-01-01T12:00:04"}

data: {"type":"user_message","content":"Hello via SSE!"}

data: {"type":"server_time","time":"2024-01-01T12:00:06"}

提示

  1. SSE 格式:data: <content>\n\n(两个换行符分隔事件)
  2. httplib 的 SSE 响应需要设置 Content-Type 为 text/event-stream
  3. 使用 std::shared_ptr 共享消息队列,避免生命周期问题
  4. 客户端断开连接时(req.is_connection_closed()),需要清理对应的线程
  5. 参考 src/transport/SseTransport.cpp 中的双队列设计

扩展挑战



Code

/**
 * 04_SseServer.cpp — SSE Notification Server
 *
 * Exercise 4: SSE (Server-Sent Events) 推送服务器
 *
 * 功能:
 *   GET  /          — 返回 HTML 测试页面
 *   GET  /events    — SSE 端点,实时推送事件
 *   POST /message   — 接收消息,广播给所有 SSE 客户端
 *
 * 特性:
 *   - 后台线程每2秒自动推送服务器时间
 *   - 显示当前连接的客户端数量
 *   - 消息队列 + 条件变量实现广播
 *   - 支持浏览器和 curl 客户端
 */

#include <iostream>
#include <chrono>
#include <ctime>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <deque>
#include <atomic>
#include <memory>
#include <algorithm>
#include <cstdint>

// MinGW 头文件中缺少 GetAddrInfoExCancel 声明,但 vcpkg 默认启用了
// CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO。取消该宏让 httplib 使用兼容的阻塞式 getaddrinfo。
#ifdef __MINGW32__
#undef CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO
#endif

#include <httplib.h>
#include <nlohmann/json.hpp>

using json = nlohmann::json;

// ======================== 全局 SSE 状态 ========================

// 消息日志: (序列号, JSON消息字符串)
// 使用序列号让每个客户端独立追踪自己的消费进度
std::mutex              g_sseMutex;
std::condition_variable g_sseCv;
std::deque<std::pair<uint64_t, std::string>> g_messageLog;
uint64_t                g_nextSeq  = 0;   // 下一条消息的序列号 (monotonic)
uint64_t                g_baseSeq  = 0;   // messageLog 首条消息的序列号(淘汰后递增)
std::atomic<int>        g_clientCount{0};
std::atomic<bool>       g_running{true};

constexpr size_t kMaxLogSize = 500;       // 最多保留500条历史消息

// ======================== 工具函数 ========================

/** 获取当前 UTC 时间的 ISO 8601 格式字符串 */
std::string GetCurrentTimeISO() {
    auto now = std::chrono::system_clock::now();
    auto tt  = std::chrono::system_clock::to_time_t(now);
    std::tm tm_buf;
#ifdef _WIN32
    gmtime_s(&tm_buf, &tt);
#else
    gmtime_r(&tt, &tm_buf);
#endif
    char buf[32];
    std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_buf);
    return std::string(buf);
}

/** 获取当前本地时间字符串(用于服务器日志) */
std::string GetLocalTimeStr() {
    auto now = std::chrono::system_clock::now();
    auto tt  = std::chrono::system_clock::to_time_t(now);
    std::tm tm_buf;
#ifdef _WIN32
    localtime_s(&tm_buf, &tt);
#else
    localtime_r(&tt, &tm_buf);
#endif
    char buf[32];
    std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf);
    return std::string(buf);
}

/** 推送一条 JSON 消息到所有 SSE 客户端 */
void PushMessage(const std::string& jsonStr) {
    {
        std::lock_guard<std::mutex> lock(g_sseMutex);
        g_messageLog.push_back({g_nextSeq++, jsonStr});

        // 淘汰旧消息,防止内存无限增长
        while (g_messageLog.size() > kMaxLogSize) {
            g_messageLog.pop_front();
            g_baseSeq++;
        }
    }
    g_sseCv.notify_all();

    // 打印到服务器日志
    std::cout << "[" << GetLocalTimeStr() << "] PUSH -> " << jsonStr << std::endl;
}

// ======================== 定时推送线程 ========================

/** 后台线程:每2秒自动推送一次服务器时间 */
void TimerThreadFunc() {
    while (g_running) {
        std::this_thread::sleep_for(std::chrono::seconds(2));
        if (!g_running) break;

        json msg;
        msg["type"] = "server_time";
        msg["time"] = GetCurrentTimeISO();
        PushMessage(msg.dump());
    }
}

// ======================== HTML 测试页面 ========================

/** 返回浏览器端测试页面,使用 EventSource API 接收 SSE */
std::string GetHtmlPage() {
    return R"(<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSE Notification Demo</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', system-ui, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
        .container { max-width: 800px; margin: 0 auto; padding: 20px; }
        h1 { color: #e94560; margin-bottom: 6px; }
        .subtitle { color: #888; margin-bottom: 18px; font-size: 14px; }
        .status-bar { display: flex; gap: 24px; margin-bottom: 16px; font-size: 13px; color: #aaa; }
        .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
        .status-dot.on { background: #4ecca3; }
        .status-dot.off { background: #e94560; }
        .controls { display: flex; gap: 10px; margin-bottom: 18px; }
        #msgInput { flex: 1; padding: 10px 14px; border: 1px solid #333; border-radius: 6px; background: #16213e; color: #eee; font-size: 14px; outline: none; }
        #msgInput:focus { border-color: #e94560; }
        button { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: opacity 0.2s; }
        button:hover { opacity: 0.85; }
        #sendBtn { background: #e94560; color: #fff; }
        #clearBtn { background: #333; color: #ccc; }
        #messages { background: #16213e; border: 1px solid #333; border-radius: 8px; padding: 14px; height: 420px; overflow-y: auto; font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace; font-size: 13px; line-height: 1.6; }
        #messages:empty::after { content: 'Waiting for messages...'; color: #555; }
        .msg { padding: 4px 0; border-bottom: 1px solid #1f1f3a; animation: fadeIn 0.3s ease; }
        .msg:last-child { border-bottom: none; }
        .time-tag { color: #4ecca3; }
        .user-tag { color: #e94560; }
        .system-tag { color: #f0a500; }
        @keyframes fadeIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
    </style>
</head>
<body>
    <div class="container">
        <h1>SSE Notification Demo</h1>
        <p class="subtitle">Realtime push test - Server pushes time every 2 seconds, you can also send custom messages</p>

        <div class="status-bar">
            <span><span class="status-dot on" id="statusDot"></span> Status: <strong id="statusText">Connected</strong></span>
            <span>Online clients: <strong id="clientCount">-</strong></span>
        </div>

        <div class="controls">
            <input type="text" id="msgInput" placeholder="Type a message and press Enter..." />
            <button id="sendBtn">Send</button>
            <button id="clearBtn">Clear</button>
        </div>

        <div id="messages"></div>
    </div>

    <script>
        const messagesDiv   = document.getElementById('messages');
        const msgInput      = document.getElementById('msgInput');
        const statusDot     = document.getElementById('statusDot');
        const statusText    = document.getElementById('statusText');
        const clientCountEl = document.getElementById('clientCount');

        const source = new EventSource('/events');

        source.onopen = () => {
            statusDot.className = 'status-dot on';
            statusText.textContent = 'Connected';
        };

        source.onerror = () => {
            statusDot.className = 'status-dot off';
            statusText.textContent = 'Disconnected (reconnecting...)';
        };

        source.onmessage = (e) => {
            try {
                const data = JSON.parse(e.data);
                const div = document.createElement('div');
                div.className = 'msg';

                if (data.type === 'server_time') {
                    div.innerHTML = '<span class="time-tag">[Time] ' + data.time + '</span> <span class="system-tag">[Server Time]</span>';
                } else if (data.type === 'user_message') {
                    div.innerHTML = '<span class="user-tag">[Msg] ' + escapeHtml(data.content) + '</span> <span class="system-tag">[User Message]</span>';
                } else if (data.type === 'client_count') {
                    clientCountEl.textContent = data.count;
                    div.innerHTML = '<span class="system-tag">[Info] Online clients: ' + data.count + '</span>';
                } else {
                    div.textContent = e.data;
                }

                messagesDiv.appendChild(div);
                messagesDiv.scrollTop = messagesDiv.scrollHeight;
            } catch (_) {
                const div = document.createElement('div');
                div.className = 'msg';
                div.textContent = e.data;
                messagesDiv.appendChild(div);
                messagesDiv.scrollTop = messagesDiv.scrollHeight;
            }
        };

        function escapeHtml(text) {
            const d = document.createElement('div');
            d.textContent = text;
            return d.innerHTML;
        }

        function sendMessage() {
            const text = msgInput.value.trim();
            if (!text) return;
            fetch('/message', { method: 'POST', body: text })
                .then(r => r.json())
                .then(() => { msgInput.value = ''; })
                .catch(err => alert('Send failed: ' + err));
        }

        msgInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') sendMessage();
        });
        document.getElementById('sendBtn').addEventListener('click', sendMessage);
        document.getElementById('clearBtn').addEventListener('click', () => {
            messagesDiv.innerHTML = '';
        });
    </script>
</body>
</html>)";
}

// ======================== SSE 客户端状态 ========================

/**
 * 每个 SSE 连接对应一个 ClientSseState 实例
 * 通过 shared_ptr 传递给 httplib 的 content provider,
 * 使其在多次回调调用间保持状态
 */
struct ClientSseState {
    uint64_t    lastSeq    = 0;       // 该客户端已发送的最后一条消息序列号
    bool        firstCall  = true;    // 是否首次调用(用于发送初始客户端计数)
    std::string initMessage;          // 首次连接时发送的 JSON 消息
};

// ======================== 路由处理函数 ========================

/** GET / — 返回 HTML 测试页面 */
void HandleGetRoot(const httplib::Request& /*req*/, httplib::Response& res) {
    res.set_content(GetHtmlPage(), "text/html; charset=utf-8");
}

/**
 * GET /events — SSE 端点
 *
 * 每个连接通过 httplib 的 chunked content provider 实现长连接。
 * provider 回调在条件变量上阻塞,有新消息时被唤醒并写入 SSE 数据。
 * 使用 1 秒超时定期检测客户端是否断开连接。
 */
void HandleGetEvents(const httplib::Request& /*req*/, httplib::Response& res) {
    int currentCount = ++g_clientCount;
    std::cout << "[" << GetLocalTimeStr() << "] SSE client connected    -> total clients: "
              << currentCount << std::endl;

    // 创建客户端专属状态(shared_ptr 确保生命周期覆盖整个连接)
    auto state = std::make_shared<ClientSseState>();
    {
        std::lock_guard<std::mutex> lock(g_sseMutex);
        state->lastSeq = g_nextSeq;   // 从当前时刻开始,不发送历史消息
    }

    // 构造初始消息:告知客户端当前在线数量
    json initJson;
    initJson["type"]  = "client_count";
    initJson["count"] = currentCount;
    state->initMessage = initJson.dump();

    res.set_chunked_content_provider(
        "text/event-stream",

        // -- provider 回调:在新消息到来时发送 SSE 数据 --
        [state](size_t /*offset*/, httplib::DataSink& sink) -> bool {
            // 首次调用:发送客户端计数
            if (state->firstCall) {
                state->firstCall = false;
                std::string frame = "data: " + state->initMessage + "\n\n";
                if (!sink.write(frame.data(), frame.size())) {
                    return false;   // 客户端已断开
                }
                return true;
            }

            // 等待新消息(1秒超时以便检测客户端断开)
            {
                std::unique_lock<std::mutex> lock(g_sseMutex);
                g_sseCv.wait_for(lock, std::chrono::seconds(1), [&] {
                    return g_nextSeq > state->lastSeq || !g_running;
                });

                // 服务器正在关闭,且没有新消息 — 优雅退出
                if (!g_running && g_nextSeq <= state->lastSeq) {
                    sink.done();
                    return false;
                }

                // 发送所有积压的新消息
                while (state->lastSeq < g_nextSeq && sink.is_writable()) {
                    uint64_t targetSeq = state->lastSeq;

                    // 如果客户端落后太多(消息已被淘汰),跳到最早可用消息
                    if (targetSeq < g_baseSeq) {
                        targetSeq       = g_baseSeq;
                        state->lastSeq  = g_baseSeq;
                    }

                    // 在消息日志中查找对应序列号
                    auto it = std::find_if(g_messageLog.begin(), g_messageLog.end(),
                        [targetSeq](const auto& p) { return p.first == targetSeq; });

                    if (it != g_messageLog.end()) {
                        std::string content = it->second;   // 拷贝出锁
                        lock.unlock();

                        std::string frame = "data: " + content + "\n\n";
                        if (!sink.write(frame.data(), frame.size())) {
                            return false;   // 写入失败,客户端已断开
                        }

                        lock.lock();
                    }
                    state->lastSeq++;
                }
            }

            // 检查连接状态:is_writable 为 false 时停止
            return sink.is_writable();
        },

        // -- done 回调:连接结束时清理 --
        [](bool /*success*/) {
            int remaining = --g_clientCount;
            std::cout << "[" << GetLocalTimeStr() << "] SSE client disconnected -> total clients: "
                      << remaining << std::endl;
        }
    );
}

/** POST /message — 接收用户消息并广播给所有 SSE 客户端 */
void HandlePostMessage(const httplib::Request& req, httplib::Response& res) {
    std::string content = req.body;
    if (content.empty()) {
        content = "(empty message)";
    }

    json msg;
    msg["type"]    = "user_message";
    msg["content"] = content;
    PushMessage(msg.dump());

    json resp;
    resp["status"]  = "sent";
    resp["clients"] = g_clientCount.load();
    res.status = 200;
    res.set_content(resp.dump(), "application/json");
}

// ======================== 主函数 ========================

int main() {
    std::cout << "+------------------------------------------+" << std::endl;
    std::cout << "|     SSE Notification Server              |" << std::endl;
    std::cout << "+------------------------------------------+" << std::endl;
    std::cout << "|  SSE endpoint:  GET  /events             |" << std::endl;
    std::cout << "|  Test page:     GET  /                   |" << std::endl;
    std::cout << "|  Send message:  POST /message            |" << std::endl;
    std::cout << "+------------------------------------------+" << std::endl;
    std::cout << std::endl;
    std::cout << "Timer: push server time every 2 seconds" << std::endl;
    std::cout << "Message queue: max " << kMaxLogSize << " messages" << std::endl;
    std::cout << std::endl;

    // -- 启动定时推送线程 --
    std::thread timerThread(TimerThreadFunc);

    // -- 配置 HTTP 服务器 --
    httplib::Server svr;

    // -- 中间件:CORS + 请求日志 --
    svr.set_pre_routing_handler([](const httplib::Request& req, httplib::Response& res) {
        // CORS 头(允许浏览器跨域访问)
        res.set_header("Access-Control-Allow-Origin",  "*");
        res.set_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
        res.set_header("Access-Control-Allow-Headers", "Content-Type");

        if (req.method != "OPTIONS") {
            std::cout << "[" << GetLocalTimeStr() << "] " << req.method << " " << req.path << std::endl;
        }

        if (req.method == "OPTIONS") {
            res.status = 204;
            return httplib::Server::HandlerResponse::Handled;
        }
        return httplib::Server::HandlerResponse::Unhandled;
    });

    // -- 注册路由 --
    svr.Get("/", [](const httplib::Request& req, httplib::Response& res) {
        HandleGetRoot(req, res);
    });

    svr.Get("/events", [](const httplib::Request& req, httplib::Response& res) {
        HandleGetEvents(req, res);
    });

    svr.Post("/message", [](const httplib::Request& req, httplib::Response& res) {
        HandlePostMessage(req, res);
    });

    // -- 启动服务器(阻塞) --
    std::cout << "Server starting on http://localhost:8080" << std::endl;
    std::cout << "Press Ctrl+C to stop." << std::endl;
    std::cout << std::endl;
    svr.listen("0.0.0.0", 8080);

    // -- 清理 --
    std::cout << "\nShutting down..." << std::endl;
    g_running = false;
    g_sseCv.notify_all();       // 唤醒所有阻塞的 SSE 客户端线程
    timerThread.join();

    std::cout << "Server stopped. Goodbye!" << std::endl;
    return 0;
}

posted @ 2026-06-30 21:32  雪度娃娃  阅读(6)  评论(0)    收藏  举报