news 2026/9/13 15:10:30

基于Boost.ASIO的C++异步STOMP客户端设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Boost.ASIO的C++异步STOMP客户端设计与实现

简介:面向C++网络开发者的一个基于Boost.ASIO实现STOMP客户端的示例工程,核心解决与消息中间件之间建立连接、订阅、发布、心跳维持等通信问题,适合希望学习异步网络编程与消息协议实现的中级C++工程师。压缩包共26个文件,以cpp/hpp源码为主,辅以Makefile构建脚本、debian打包配置、README及辅助shell脚本,整体仅22KB,结构紧凑、便于快速阅读。目前已有135人学习。示例代码通过stomp_connection、stomp_session等类组织STOMP会话,完整演示了CONNECT、SUBSCRIBE等命令的封装发送、基于分隔符的响应读取、异步定时器心跳,以及异常处理与线程安全设计,同时提供了多线程环境下的同步思路。通过阅读这一工程,可以掌握TCP套接字封装、异步读写、定时器等Boost.ASIO核心用法,为后续开发高性能网络服务打下基础,也适合作为消息客户端开发与协议解析的参考模板。

1. 为什么C++客户端要用Boost.ASIO写STOMP协议

STOMP协议本身轻到只有命令、Header、空行、Body四个部分,但把它做成稳定的异步客户端,真正的复杂度全在帧边界处理上。Boost.ASIO暴露的是TCP字节流,STOMP是文本帧协议,两者之间需要一个能处理粘包拆包、Content-Length感知、心跳超时和重连的会话层。常见误区是用一次read_some拿到的数据直接parse,实际一个TCP包可能横跨半个帧,一个帧也可能被拆成三段。这套实现面向需要对接ActiveMQ、Artemis等消息中间件的后台C++服务,目标是用ASIO的异步接口搭出能处理重连、心跳、事务和消息回调的STOMP客户端骨架,同时把网络层和协议层拆开,方便后续接其他Broker。

2. STOMP 1.2帧格式与C++编解码实现

2.1 先把帧格式钉死:命令、Header、空行、Body、\0

STOMP 1.2的一个完整帧长这样,注意结尾有一个不可见的\0字节:

SEND destination:/queue/test content-length:11 content-type:text/plain hello world \0

第一行是命令,支持的命令包括CONNECT、CONNECTED、SEND、SUBSCRIBE、UNSUBSCRIBE、ACK、NACK、BEGIN、COMMIT、ABORT、DISCONNECT、MESSAGE、RECEIPT、ERROR。之后是若干行key:value格式的Header,空一行之后是消息体。Header里值不能包含换行、冒号和反斜杠,STOMP 1.2规定用转义序列表示:\n代表换行,\c代表冒号,\\代表反斜杠。这条规则在实际服务端返回的Header里很常见,ActiveMQ的destination如果包含队列名中的冒号,你就能看到\c

关于帧结束符,很多实现里是body + \0,body之后不额外加EOL。这里建议客户端发送时统一按“Header区 + 空行 + body + \0”构造,不要依赖是否追加EOL,因为不同Broker对这个位置的容忍度不一致。另一个容易踩的坑是:如果body内部可能包含\0字节,那么必须显式提供content-length头,否则接收端读到第一个\0就认为帧结束了,消息会被截断。所以这里有个坏味道值得注意:不要用裸写SEND帧的方式,统一封装一个编码器。

2.2 帧编码:把请求对象序列化成字节流

一个最小可用的编码函数如下,重点是自动补content-length和转义Header值:

#include <string> #include <map> // STOMP 1.2 Header 值转义 std::string escapeHeader(const std::string& raw) { std::string out; out.reserve(raw.size()); for (char c : raw) { switch (c) { case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\\': out += "\\\\"; break; case ':': out += "\\c"; break; default: out += c; } } return out; } // 序列化一帧 std::string encodeFrame(const std::string& command, std::map<std::string, std::string> headers, const std::string& body) { headers["content-length"] = std::to_string(body.size()); std::string out = command + "\n"; for (const auto& [k, v] : headers) { out += escapeHeader(k) + ":" + escapeHeader(v) + "\n"; } out += "\n"; out += body; out += '\0'; return out; }

逻辑说明:encodeFrame强行把content-length写进Header,这样机器就能明确知道帧体长度,不会因为body里出现\0而被截断。注意escapeHeader对key也做了转义,key虽然通常不含特殊字符,但统一处理更稳妥。调用方只需要给destination、id这类业务值,转义细节不用关心。

参数说明:command是STOMP命令字符串,headers是键值对集合,body是原始消息字节。编码后返回的std::string可以直接交给ASIO的async_write。有一点要提醒,content-length是字节数而不是字符数,所以body里如果是UTF-8中文,std::string::size()返回的字节数正好对得上,不用担心。

2.3 帧解析:从streambuf里安全切出完整一帧

配套的解析函数接收一个字节缓冲,尝试从中提取出一帧,返回是否成功以及消费了多少字节:

#include <optional> #include <sstream> #include <vector> struct StompFrame { std::string command; std::map<std::string, std::string> headers; std::string body; }; std::string unescapeHeader(const std::string& raw) { std::string out; for (size_t i = 0; i < raw.size(); ++i) { if (raw[i] == '\\' && i + 1 < raw.size()) { switch (raw[i + 1]) { case 'n': out += '\n'; ++i; break; case 'r': out += '\r'; ++i; break; case 'c': out += ':'; ++i; break; case '\\': out += '\\'; ++i; break; default: out += raw[i]; break; } } else { out += raw[i]; } } return out; } bool extractFrame(const std::string& buf, StompFrame& frame, size_t& consumed) { size_t hEnd = buf.find("\n\n"); if (hEnd == std::string::npos) { // 兼容 \r\n 换行 hEnd = buf.find("\r\n\r\n"); if (hEnd == std::string::npos) return false; hEnd += 2; } std::istringstream hs(buf.substr(0, hEnd)); std::string line; std::getline(hs, line); if (!line.empty() && line.back() == '\r') line.pop_back(); frame.command = line; while (std::getline(hs, line)) { if (line.empty()) break; if (!line.empty() && line.back() == '\r') line.pop_back(); size_t p = line.find(':'); if (p == std::string::npos) continue; std::string key = unescapeHeader(line.substr(0, p)); std::string value = unescapeHeader(line.substr(p + 1)); frame.headers[key] = value; } size_t cl; auto it = frame.headers.find("content-length"); if (it != frame.headers.end()) { cl = std::stoull(it->second); } else { size_t nullPos = buf.find('\0', hEnd + 2); if (nullPos == std::string::npos) return false; cl = nullPos - (hEnd + 2); } if (buf.size() < hEnd + 2 + cl) return false; frame.body = buf.substr(hEnd + 2, cl); consumed = hEnd + 2 + cl; return true; }

逻辑说明:extractFrame先定位Header区的结束位置,也就是第一个空行,然后逐行解析出命令和Header。拿到content-length后,用它决定帧体长度,而不是依赖\0定位,这正是能规避消息体内含\0导致截断的关键。解析成功后,consumed返回本帧占用的总字节数,调用方把这段从缓冲头部切掉,剩余字节继续给下一帧解析。

参数说明:buf是累计的接收缓冲,frame是输出参数,consumed告诉调用方消费了多少。这里用std::istringstream解析Header,注意入口处的hEnd + 2跳过了空行,content-length不存在时才走\0查找分支。实际测试中,服务端返回的Header顺序不固定,所以用find而不是假设Header在固定位置。

3. Boost.ASIO网络层:从async_connect到async_read_until

3.1 单io_context驱动异步消息循环

Boost.ASIO的网络层核心是io_context,它是事件循环的中枢,所有异步操作最终都回到它身上分发。常见做法是一个进程里只有一个io_context,用一到两个线程跑io_context.run()。这里涉及C++多线程的一个重点:如果用了多个线程跑run(),回调可能在不同线程执行,共享的socket、streambuf和状态变量都要考虑并发。最简单方案是全程只用一个线程跑循环,避免锁;如果确实需要多个线程,可以把共享操作post到同一个strand上,strand保证同一时刻只有一个回调在执行。

很多C++面试题会问“ASIO的handler线程安全性”,答案的边界就在这里:async_readasync_write不能在多个线程同时发起,但post到strand上的任务可以并行等待。客户端场景下,一个线程跑io_context足够了,性能瓶颈通常在Broker和消息处理回调,而不是网络循环本身。

3.2 建立连接并发送CONNECT帧

用ASIO的tcp::resolver异步解析域名,然后async_connect,代码结构如下:

#include <boost/asio.hpp> #include <boost/asio/steady_timer.hpp> namespace asio = boost::asio; using asio::ip::tcp; class StompSession { public: StompSession(asio::io_context& io, std::string host, uint16_t port) : io_(io), socket_(io), resolver_(io), heartbeatTimer_(io), reconnectTimer_(io), host_(std::move(host)), port_(port) {} void connect() { state_ = State::Connecting; resolver_.async_resolve(host_, std::to_string(port_), [this](const boost::system::error_code& ec, const tcp::resolver::results_type& endpoints) { if (ec) { startReconnect(); return; } asio::async_connect(socket_, endpoints, [this](const boost::system::error_code& ec, const tcp::endpoint&) { if (ec) { startReconnect(); return; } sendConnectFrame(); }); }); } private: void sendConnectFrame() { auto raw = encodeFrame("CONNECT", { {"accept-version", "1.2"}, {"host", host_}, {"heart-beat", "15000,15000"} }, ""); asio::async_write(socket_, asio::buffer(raw), [this](const boost::system::error_code& ec, size_t) { if (ec) { startReconnect(); return; } startRead(); }); } asio::io_context& io_; tcp::socket socket_; tcp::resolver resolver_; asio::steady_timer heartbeatTimer_; asio::steady_timer reconnectTimer_; std::string host_; uint16_t port_; enum class State { Disconnected, Connecting, Connected, Reconnecting }; State state_ = State::Disconnected; };

逻辑说明:async_resolveasync_connect是两步独立的异步操作,失败统一走startReconnect()。连接成功后立刻发送CONNECT帧,请求协议版本1.2,并声明15秒发送一次心跳、15秒期望接收一次心跳。connect()返回前不会阻塞,所有流程靠回调驱动,这就是Boost.ASIO典型的C++回调函数例子,回调里需要的状态都靠捕获this传递。

参数说明:accept-version可以写成1.0,1.1,1.2让服务端选,但如果服务端返回1.0,Header转义规则不同,需要额外兼容。这里直接锁定1.2,Artemis和ActiveMQ 5.x都支持。host头必须与Broker配置的虚拟主机一致,不是IP或主机名随意填,很多握手失败都栽在这个Header上。

3.3 async_read_until读帧的内存管理

网络层读帧推荐用async_read_until,把\0作为分隔条件,收到完整结束符才回调。但消息体内可能含\0,所以仅靠它不够,需要配合内部缓冲:

asio::streambuf readBuf_; std::string rxBuf_; void startRead() { asio::async_read_until(socket_, readBuf_, '\0', [this](const boost::system::error_code& ec, size_t) { if (ec) { startReconnect(); return; } // 把streambuf里的数据搬进string缓冲 auto bufs = readBuf_.data(); std::string chunk(asio::buffers_begin(bufs), asio::buffers_begin(bufs) + readBuf_.size()); readBuf_.consume(readBuf_.size()); rxBuf_ += chunk; // 循环解析缓冲中的所有完整帧 StompFrame frame; size_t consumed = 0; while (extractFrame(rxBuf_, frame, consumed)) { rxBuf_.erase(0, consumed); dispatchFrame(frame); } startRead(); }); }

逻辑说明:async_read_until只要收到\0就会触发回调,但缓冲区里可能同时存有多个帧,也可能只有一个不完整帧的开头。把streambuf里的全部字节搬进rxBuf_后,用extractFrame循环解析,解析不了的残缺数据留在rxBuf_里,等下一个数据包到达后拼接。这个方案天然处理了TCP粘包拆包,不会因为一次网络读取而丢帧。

参数说明:chunk的大小用readBuf_.size()而不是回调参数bytes_transferred,因为在多个帧情况下,streambuf里积累的数据可能比本次读取多。consume必须调用,否则streambuf内存无限增长。另外,std::istreambuf_iterator配合streambuf也是一个可行方案,但如果要保留剩余数据,还是string缓冲更顺手。

3.4 写入:多线程调用send和管理写队列

STOMP客户端经常在业务线程里调send,不能直接对同一个socket并发调async_write,需要做一个写队列。推荐用asio::post把写操作串行化到IO线程:

std::mutex writeMutex_; std::queue<std::string> writeQueue_; bool writing_ = false; void enqueueFrame(std::string raw) { { std::lock_guard<std::mutex> lk(writeMutex_); writeQueue_.push(std::move(raw)); } asio::post(io_, [this]() { doWrite(); }); } void doWrite() { std::unique_lock<std::mutex> lk(writeMutex_); if (writeQueue_.empty()) { writing_ = false; return; } if (writing_) return; writing_ = true; std::string raw = std::move(writeQueue_.front()); writeQueue_.pop(); lk.unlock(); asio::async_write(socket_, asio::buffer(raw), [this](const boost::system::error_code& ec, size_t) { if (ec) { startReconnect(); return; } writing_ = false; asio::post(io_, [this]() { doWrite(); }); }); }

逻辑说明:enqueueFrame先把数据放进队列,然后post到IO线程串行处理。由于doWrite可能被多个post重复触发,需要writing_标志防止对同一个socket并发发起两次写。每次async_write完成后再次post自己,实现队列的逐条消费。这里的关键是锁只保护队列操作,绝不持有锁等待网络I/O,否则业务线程会被阻塞。

参数说明:asio::post在Boost 1.66之后支持直接传给io_context,老版本需要io_.post(...)writing_标志必须在IO线程里修改,不能在业务线程直接重置,否则会出现两次async_write同时等待的竞态。

4. STOMP会话状态机:握手、心跳与事务控制

4.1 状态机设计:Disconnected / Connecting / Connected / Reconnecting

STOMP客户端比裸TCP客户端复杂在需要自己维护会话生命周期。用一个枚举状态机把所有状态迁移收敛起来,避免回调里到处飞线:

状态进入条件主要事件下一个状态
Disconnected构造完成调用connect()Connecting
Connecting发起async_resolve解析/连接失败Reconnecting
Connecting连接成功发送CONNECT帧Connected(收到CONNECTED后)
Connected收到CONNECTEDSEND/SUBSCRIBE/心跳超时Reconnecting
Reconnecting连接断开/心跳超时退避定时器到点Connecting

代码实现里,handleFailure这个入口专门统一处理断线原因,无论socket错误、读取超时还是收到ERROR帧,都走这里:

void handleFailure(const std::string& reason) { if (state_ == State::Disconnected || state_ == State::Reconnecting) return; state_ = State::Reconnecting; boost::system::error_code ignore; socket_.close(ignore); heartbeatTimer_.cancel(); startReconnect(); }

逻辑说明:handleFailure先判断状态,避免重复进入重连流程。关闭socket会触发未完成的async_read回调并传入operation_aborted错误,这个错误只用于终止读循环,不要当作真正的断线处理。状态机是客户端稳定性的骨架,所有回调的第一件事都应该是检查state_,防止断线后继续发业务帧。

4.2 CONNECT/CONNECTED握手与版本协商

握手不是发出去CONNECT帧就算完成,必须等服务端返回CONNECTED帧。在dispatchFrame里识别这个关键帧:

void dispatchFrame(const StompFrame& frame) { if (frame.command == "CONNECTED") { if (state_ != State::Connecting) return; state_ = State::Connected; auto it = frame.headers.find("heart-beat"); if (it != frame.headers.end()) { negotiateHeartbeat(it->second); } startHeartbeat(); onConnected(); // 用户回调:重新订阅等操作 } else if (frame.command == "ERROR") { handleFailure("broker error: " + frame.body); } else if (frame.command == "MESSAGE") { onMessage(frame); } else if (frame.command == "RECEIPT") { onReceipt(frame); } }

逻辑说明:收到CONNECTED后,客户端才真正进入Connected状态。negotiateHeartbeat解析服务端返回的心跳配置,成功后启动心跳定时器。这里要特别注意:服务端如果返回的协议版本是1.0而不是1.2,后续所有帧的Header转义规则都不同,如果没做兼容直接解析会乱。建议握手时声明多个版本,收到CONNECTED后检查version头,再决定是否启用1.2的转义。

参数说明:CONNECT帧里的host是必填项,服务端靠它路由虚拟主机;loginpasscode在需要认证时加入Headers。accept-version逗号分隔多个版本,服务端选它支持的版本返回。

4.3 心跳协商与heart-beat定时器实现

STOMP 1.2的心跳协商规则通过CONNECT和CONNECTED帧里的heart-beat头完成。值格式是send,receive两个毫秒数,发送方写自己期望的发送间隔、期望的接收间隔。实际落地时的取值逻辑如下:

std::chrono::milliseconds clientSendHb_{0}; std::chrono::milliseconds serverSendHb_{0}; std::chrono::steady_clock::time_point lastReceive_; void negotiateHeartbeat(const std::string& serverHb) { auto comma = serverHb.find(','); if (comma == std::string::npos) { clientSendHb_ = std::chrono::milliseconds(0); serverSendHb_ = std::chrono::milliseconds(0); return; } long sx = std::stol(serverHb.substr(0, comma)); // 服务端声明它能发送心跳,客户端把它当作读超时参考 serverSendHb_ = std::chrono::milliseconds(sx > 0 ? sx : 0); // 服务端声明它期望接收的间隔,客户端按这个发送心跳 long sy = std::stol(serverHb.substr(comma + 1)); clientSendHb_ = std::chrono::milliseconds(sy > 0 ? sy : 0); if (serverSendHb_.count() > 0) { lastReceive_ = std::chrono::steady_clock::now(); startReadTimeout(); } }

逻辑说明:heart-beat协商的常见做法是取双方声明的值做调整。客户端发送心跳的间隔以服务端返回的第二个值为准;如果服务端返回0,0,表示它不关心心跳,客户端关闭发送心跳。读超时检查用服务端声明的发送间隔乘以2作为容忍窗口,这是处理网络抖动的通用做法,ActiveMQ和Artemis在这个参数上都比较宽容。

发送心跳的定时器实现:

void startHeartbeat() { if (clientSendHb_.count() <= 0) return; heartbeatTimer_.expires_at_after(clientSendHb_); heartbeatTimer_.async_wait([this](const boost::system::error_code& ec) { if (ec) return; // 定时器被取消 if (state_ != State::Connected) return; static const char hb = '\n'; asio::write(socket_, asio::buffer(&hb, 1)); startHeartbeat(); }); } void startReadTimeout() { // 每500ms检查一次是否超时,超时走handleFailure }

逻辑说明:心跳发送用同步asio::write,一个字节的写操作几乎不会阻塞。客户端注册的定时器必须在断线时调用cancel(),否则断开连接后定时器仍然触发,会尝试向已关闭的socket写数据。读超时检查放在独立的监视定时器里,每次收到任何消息更新lastReceive_,超过2 * serverSendHb_就触发重连。

注意:一些成熟的C++ STOMP库会把读超时集成到async_read_untildeadline里,但ASIO的做法是叠加steady_timer,逻辑更直接。

4.4 事务用transaction头串起BEGIN/COMMIT/ABORT

STOMP事务用于保证SEND、ACK、NACK等操作的原子性。客户端需要先发BEGIN,后续帧带同一个transaction头,最后COMMIT或ABORT:

std::unordered_set<std::string> activeTxns_; void beginTransaction(const std::string& txnId) { activeTxns_.insert(txnId); auto raw = encodeFrame("BEGIN", {{"transaction", txnId}}, ""); enqueueFrame(std::move(raw)); } void sendInTransaction(const std::string& dest, const std::string& body, const std::string& txnId) { auto raw = encodeFrame("SEND", { {"destination", dest}, {"transaction", txnId} }, body); enqueueFrame(std::move(raw)); } void commitTransaction(const std::string& txnId) { activeTxns_.erase(txnId); auto raw = encodeFrame("COMMIT", {{"transaction", txnId}}, ""); enqueueFrame(std::move(raw)); }

逻辑说明:事务在断线后会失效,Broker会自动回滚未COMMIT的事务。因此activeTxns_集合必须在handleFailure里清空,不要让一个失效的transaction id残留,后续收到同一事务的消息直接忽略。客户端不应该为同一个事务同时发两个BEGIN,那是协议没有定义的行为,Broker的行为也各不相同。

参数说明:BEGIN本身可以带receipt头要求Broker回RECEIPT确认,但如果每次BEGIN都等RECEIPT,吞吐量会明显下降。没有receipt的情况下,BEGIN、COMMIT、ABORT是即发即忘的,Broker的幂等性由客户端保证。

5. STOMP断线重连策略与三个提升稳定性的细节

5.1 指数退避重连,而不是固定2秒重连

固定间隔重连在Broker宕机期间会把客户端变成连接风暴的来源。二进制指数退避配合抖动是常用策略:

int reconnectAttempt_ = 0; void startReconnect() { state_ = State::Reconnecting; reconnectAttempt_ = std::min(reconnectAttempt_ + 1, 6); auto delayMs = std::min(30000, 500 * (1 << reconnectAttempt_)); // 加随机抖动,避免多客户端同时重连 delayMs += std::rand() % 500; reconnectTimer_.expires_after(std::chrono::milliseconds(delayMs)); reconnectTimer_.async_wait([this](const boost::system::error_code& ec) { if (ec) return; activeTxns_.clear(); connect(); }); }

逻辑说明:重连成功后要记得把reconnectAttempt_清零,否则下次断线会直接跳到很长的退避时间。重连后旧的TCP连接上订阅关系全部失效,必须在Connected回调里重新SUBSCRIBE所有需要恢复的destination。这里有一个常见坑:订阅的可靠性要求重放未确认消息,重连后Broker会把客户端未ACK的消息重新投递,所以业务层要考虑幂等,而不是简单从断点续传。

5.2 三个提升稳定性的细节

第一个细节:发送SEND帧时永远让encodeFrame补上content-length头,不要手动构造帧。这样即使业务侧传入的body含\0,也不会在链路上造成截断。第二个细节:给socket设置tcp::no_delay,关闭Nagle算法,STOMP这类高频小帧请求能明显降低延迟:

boost::asio::ip::tcp::no_delay option(true); boost::system::error_code ec; socket_.set_option(option, ec);

第三个细节:不要在栈上定义大消息体。STOMP消息大小不受协议限制,但业务回调里如果直接拿栈上的char[]接收MESSAGE body,很容易触发栈溢出。所有body统一用std::string,它内部走堆分配。这跟C++里常见的“字符串数组初始化用栈还是堆”问题是一个道理,消息体这种不确定大小的对象不配放在栈空间里。

5.3 本地自测:没有Broker怎么验握手

启动一个几百行的Python Socket服务端就能验证客户端握手逻辑:

import socket srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('127.0.0.1', 61613)) srv.listen(1) conn, _ = srv.accept() data = conn.recv(4096).decode() assert data.startswith("CONNECT"), data conn.sendall(b"CONNECTED\nversion:1.2\nheart-beat:0,0\nsession:test\n\n\0") print("handshake ok") time.sleep(1) conn.close()

运行这个脚本后,再启动客户端连接本地61613端口,客户端控制台如果打印出Connected且没有触发重连,说明握手链路是通的。接下来可以在服务端短暂sleep后直接关闭连接,观察客户端是否按指数退避重新发起CONNECT——这是验证状态机和重连逻辑最快的方式。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 15:09:30

Codex插件系统深度解析:plugin.json与marketplace.json原理与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 15:08:59

LangGraph:AI应用的状态机编排协议与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 15:04:50

SpringBoot+Vue垃圾分类回收网站开发指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华