实战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 的双队列设计做准备
前置准备
阅读以下文档:
- 02-网络编程基础 -- SSE 协议部分
- 03-并发编程基础 -- condition_variable
- 项目中的
src/transport/SseTransport.cpp-- 观察双队列实现
需求规格
实现一个 SSE 推送服务器:
- POST /message -- 接收消息,推送给所有 SSE 客户端
- GET /events -- SSE 端点,接收实时推送
- GET / -- 返回一个简单的 HTML 页面,用于浏览器测试
- 定时推送 -- 后台线程每2秒自动推送一次服务器时间
- 客户端计数 -- 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"}
提示
- SSE 格式:
data: <content>\n\n(两个换行符分隔事件) httplib的 SSE 响应需要设置 Content-Type 为text/event-stream- 使用
std::shared_ptr共享消息队列,避免生命周期问题 - 客户端断开连接时(
req.is_connection_closed()),需要清理对应的线程 - 参考
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;
}
浙公网安备 33010602011771号