纯Java数据包处理实战:抓包解析过滤统计全链路

简介:这份资源面向具备一定Java与网络编程基础、希望研究高性能数据包处理的开发者,是一套基于Intel数据平面开发套件(DPDK)的Java快速数据包处理框架。它最初作为毕业设计项目开发,包含DPDKJava主框架及报告后重构的DPDKJava-R版本,后者在数据包处理速度与线程相似性处理上有所优化,并配套防火墙、PacketCap等应用示例,可用于学习Java与DPDK结合的实现思路。压缩包共1910个文件,约44.17MB,以C源码与头文件为主,辅以Makefile构建脚本、Java源码、PNG与SVG图示、RST文档及PDF演示文稿,覆盖框架、原生应用与Java应用多个层次。目前已有183人学习。读者可从中获取完整的框架源码、演示文稿、安装脚本与防火墙、抓包等模块实现,便于理解DPDK在Java侧的封装方式与性能优化路径。

1. 从一次抓包排查说起:这个 Java 数据包处理项目到底能干什么

线上服务偶发丢包,日志里只有一行超时,没有任何堆栈。这种时候最想干的事就是把经过网卡的数据包原样捞出来,一层层剥开看。packet-pro就是冲着这个场景来的——一个用 Java 实现的数据包处理研究项目,把抓包、解析、过滤、统计这条链路完整地跑通,代码结构清晰到可以当教学样本,也能直接拿来做二次开发。它适合两类人:一类是想搞懂 TCP/IP 协议栈在代码层面长什么样的后端开发者,另一类是需要在 Java 环境里做流量分析、协议解析、数据包过滤的工程人员。整个项目不依赖任何本地库,纯 Java 实现,跨平台跑起来没有额外负担,这一点在需要快速验证协议逻辑时特别省心。

2. 技术选型与整体架构:为什么用纯 Java 啃数据包

2.1 核心依赖与运行环境

项目走的是纯 Java 路线,没有 JNI 调用,也没有依赖 libpcap 这类本地库。这意味着你在 Windows、Linux、macOS 上拿到的行为是一致的,不需要为了跑一个 Demo 去折腾编译环境。构建工具用的是 Maven,JDK 版本要求 8 以上,实际开发时用 11 或 17 都行。核心依赖只有两个方向:一是 Java 标准库里的java.nio包,用来做缓冲区管理和字节操作;二是可选的日志门面,方便在解析过程中输出调试信息。

常见做法是在pom.xml里把打包插件配好,打出一个包含所有依赖的 fat jar,这样部署到测试机上直接java -jar就能跑。如果你打算在项目里加自己的协议解析器,建议保持这个零本地依赖的原则,否则跨平台部署时会遇到动态库路径问题,这类翻车现场在团队协作里特别浪费时间。

2.2 分层架构设计

整个项目按职责切成四层,从上到下依次是:

层级职责关键类/包
采集层从网卡或文件读取原始字节流PacketCapture
解析层按协议栈逐层拆解以太网帧、IP 包、TCP/UDP 段EthernetParser、IPParser、TcpParser
过滤层按规则匹配数据包,决定放行或丢弃PacketFilter、FilterRule
统计层汇总流量指标,输出计数和速率TrafficStats

这个分层的价值在于,每一层只关心自己的输入输出,解析层不需要知道数据是从网卡来的还是从 pcap 文件读的,过滤层也不需要关心包是怎么解析出来的。二次开发时你可以在任意一层做替换,比如把采集层换成读取离线文件,或者把统计层换成写数据库,都不会影响其他部分。

2.3 数据包在项目里的流转路径

一个数据包从进入系统到被处理完,走的是这样一条链路:采集层拿到原始字节数组后,先交给解析层。解析层从以太网帧头开始,根据类型字段判断上层是 IPv4 还是 IPv6,再根据 IP 头里的协议号决定交给 TCP 还是 UDP 解析器。每一层解析完都会生成一个对应的对象,最终组装成一个完整的Packet对象。这个对象随后进入过滤层,匹配预设规则,命中丢弃规则的包直接跳过统计,命中放行规则的包进入统计层累加指标。

这条链路里有个设计细节值得注意:解析层用的是「解析到哪层算哪层」的策略,遇到不支持的协议不会抛异常中断,而是标记为未知协议后继续传递。这样在处理混合流量时不会因为一个异常包导致整个采集流程挂掉,稳定性上考虑得比较务实。

3. 核心模块拆解:从字节流到结构化数据包的完整链路

3.1 采集层:字节流的来源抽象

采集层定义了一个PacketSource接口,把「从哪里拿数据」这件事抽象出来。目前项目里有两个实现:一个是从网卡直接读取,另一个是从 pcap 文件回放。接口本身很简单,核心方法就一个readPacket(),返回原始字节数组。

public interface PacketSource { // 读取一个原始数据包,返回字节数组;没有更多数据时返回 null byte[] readPacket() throws IOException; // 关闭数据源,释放资源 void close() throws IOException; }

这个接口的设计意图是让上层完全不感知数据来源。你在开发时如果手头没有真实网卡权限,可以直接用文件回放的方式跑通整条链路,等逻辑验证完了再切到网卡模式。参数方面,网卡模式需要指定网卡名称,文件模式需要指定 pcap 文件路径,这两个参数都通过配置文件注入,不硬编码在代码里。

3.2 解析层:逐层剥开协议头

解析层是整个项目最核心的部分,也是代码量最大的地方。以 IPv4 解析为例,一个 IP 头固定 20 字节(不含选项),解析器需要从这 20 个字节里提取版本号、头长度、总长度、协议号、源 IP、目的 IP 等字段。

public class IPParser { public IPPacket parse(byte[] data, int offset) { IPPacket packet = new IPPacket(); // 第一个字节的高 4 位是版本号,低 4 位是头长度(单位是 4 字节) int versionAndIHL = data[offset] & 0xFF; packet.setVersion((versionAndIHL >> 4) & 0x0F); int ihl = versionAndIHL & 0x0F; packet.setHeaderLength(ihl * 4); // 总长度占 2 字节,表示整个 IP 包的长度 packet.setTotalLength(((data[offset + 2] & 0xFF) << 8) | (data[offset + 3] & 0xFF)); // 协议号:6 表示 TCP,17 表示 UDP packet.setProtocol(data[offset + 9] & 0xFF); // 源 IP 和目的 IP 各占 4 字节 packet.setSourceIP(parseIPAddress(data, offset + 12)); packet.setDestinationIP(parseIPAddress(data, offset + 16)); return packet; } }

这段代码里有两个容易踩坑的地方。一是 Java 的byte是有符号的,直接参与位运算会出问题,所以每个字节都要先& 0xFF转成无符号整数。二是头长度字段的单位是 4 字节,算实际字节数时要乘以 4,这个细节在解析带选项的 IP 包时特别关键,忘了乘就会导致后续字段全部错位。TCP 解析器同理,需要从 TCP 头里提取源端口、目的端口、序列号、标志位等信息,逻辑类似但字段更多。

3.3 过滤层:规则匹配与动作执行

过滤层接收解析好的Packet对象,按规则列表逐条匹配。规则支持按源 IP、目的 IP、端口、协议类型组合条件。匹配到规则后执行对应动作,目前支持DROP和PASS两种。

public class PacketFilter { private List<FilterRule> rules; public FilterResult filter(Packet packet) { for (FilterRule rule : rules) { // 规则匹配采用「与」逻辑,所有条件都满足才算命中 if (rule.matches(packet)) { return new FilterResult(rule.getAction(), rule); } } // 没有命中任何规则时,默认放行 return new FilterResult(Action.PASS, null); } }

规则匹配的顺序很重要,项目里是按列表顺序依次匹配,命中第一条就返回。这意味着如果你同时配了「丢弃所有 TCP」和「放行 80 端口」两条规则,把丢弃规则放在前面会导致 80 端口的包也被丢掉。我一般会把粒度细的规则放在前面,粒度粗的放在后面,这样逻辑更符合直觉。

3.4 统计层:流量指标的实时汇总

统计层用LongAdder而不是AtomicLong来做计数,原因是LongAdder在高并发场景下的写性能更好,多个线程同时累加时竞争更少。统计的指标包括总包数、总字节数、各协议包数、各端口包数。

public class TrafficStats { private final LongAdder totalPackets = new LongAdder(); private final LongAdder totalBytes = new LongAdder(); private final Map<Integer, LongAdder> protocolCounters = new ConcurrentHashMap<>(); public void record(Packet packet) { totalPackets.increment(); totalBytes.add(packet.getLength()); // 按协议号分别计数,computeIfAbsent 保证线程安全地初始化计数器 protocolCounters.computeIfAbsent(packet.getProtocol(), k -> new LongAdder()) .increment(); } }

统计层的数据可以通过定时任务定期输出到控制台或日志,也可以暴露成 HTTP 接口供外部拉取。项目里默认是每 5 秒输出一次汇总信息,这个间隔可以在配置文件里调整。如果你要做实时监控,可以把输出改成推送到消息队列,统计层本身不需要改动。

4. 跑起来才算数:编译、配置与运行验证

4.1 环境准备与编译打包

先把 JDK 和 Maven 装好,JDK 版本 8 以上即可。进入项目根目录后执行编译命令:

# 清理并打包,跳过测试以加快首次编译速度 mvn clean package -DskipTests

编译成功后会在target目录下生成一个可执行的 jar 文件。如果编译过程中报错提示找不到某个类,先检查 Maven 依赖是否下载完整,可以执行mvn dependency:resolve单独拉取依赖。网络环境不好的话,配置一下国内镜像仓库会快很多。

4.2 配置文件说明

项目根目录下的config.properties是主配置文件,关键参数如下:

参数名含义默认值
source.type数据源类型,可选nic或filefile
source.file.pathpcap 文件路径,file模式下必填无
source.nic.name网卡名称,nic模式下必填无
filter.rules过滤规则文件路径rules.txt
stats.interval.seconds统计输出间隔(秒)5

第一次跑建议先用file模式,准备一个 pcap 文件作为输入,这样不依赖网卡权限,排查问题也方便。等文件模式跑通了再切到nic模式。

4.3 运行与结果验证

用文件模式启动的命令如下:

java -jar target/packet-pro.jar --config=config.properties

启动后如果配置正确,控制台会先输出数据源初始化信息,然后每隔 5 秒打印一次统计汇总。验证是否正常工作的最简单方法是看统计数字有没有增长,以及协议分布是否符合预期。如果统计数字一直是零,先检查 pcap 文件路径是否正确,再确认文件里确实有数据包。如果解析过程中出现大量未知协议,可能是 pcap 文件的链路层类型不是以太网,这种情况需要调整解析器的入口偏移量。

5. 避坑与常见问题排查

5.1 字节序问题导致解析结果错乱

现象:解析出来的 IP 地址和端口号跟预期完全对不上,数字看起来像是随机值。

原因:网络协议用的是大端序,而 Java 默认的ByteBuffer在某些操作下用的是本地字节序。如果直接往ByteBuffer里 put 再 get,没有显式设置字节序,就会翻车。

解决:所有涉及多字节字段的读取,统一用ByteBuffer.order(ByteOrder.BIG_ENDIAN)显式指定大端序,或者手动用移位运算拼接字节。项目里解析层全部采用手动移位的方式,就是为了避免这个玄学问题。

5.2 网卡模式权限不足

现象:切换到nic模式后启动报错,提示权限被拒绝或者找不到网卡。

原因:在 Linux 上读取原始套接字需要 root 权限,在 Windows 上需要安装特定驱动并授予管理员权限。另外网卡名称写错也会导致找不到设备。

解决:Linux 下用sudo启动,或者给 Java 进程授予CAP_NET_RAW能力。Windows 下用管理员身份运行命令行。网卡名称先用系统命令确认,Linux 用ip link,Windows 用ipconfig。

5.3 过滤规则顺序导致意外丢弃

现象:明明配了放行某端口的规则,但该端口的包还是被丢了。

原因:过滤层是按顺序匹配,命中第一条就返回。如果一条范围更大的丢弃规则排在前面,后面的放行规则永远不会被检查到。

解决:调整规则文件里的顺序,把精确匹配的规则放在前面,宽泛匹配的放在后面。或者在规则里加上优先级字段,过滤时先按优先级排序再匹配。

5.4 大流量下统计输出延迟

现象:流量大的时候统计信息输出明显滞后,甚至出现丢包计数不准。

原因:统计输出和包处理在同一个线程里,输出时阻塞了处理流程。另外LongAdder虽然写性能好,但读取时是弱一致的,极端情况下读到的值不是最新。

解决:把统计输出放到独立线程里,用定时任务触发,不阻塞主处理流程。对一致性要求高的场景,可以在输出前调用sum()强制汇总,但会牺牲一点性能。

5.5 pcap 文件链路层类型不匹配

现象:用文件模式跑某些 pcap 文件时,解析出来的协议全是未知,或者字段明显错位。

原因:pcap 文件支持多种链路层类型,常见的是以太网,但也有 Linux cooked mode、raw IP 等。项目默认按以太网帧解析,遇到其他类型就会从错误的偏移量开始读。

解决:先读 pcap 文件头里的链路层类型字段,根据类型决定解析入口。项目里预留了扩展点,可以在PacketSource实现里做适配,把非以太网帧转换成以太网帧格式再交给解析层。

6. 二次开发改造:加一个自定义协议解析器

假设你要在项目里加一个 ARP 协议解析器,因为默认只处理了 IP 层以上的协议。改造思路分三步:定义 ARP 包对象、写解析器、注册到解析链路。

先定义ArpPacket类,字段包括操作码、发送方 MAC、发送方 IP、目标方 MAC、目标方 IP。然后写ArpParser,从以太网帧类型字段为0x0806时进入解析。ARP 头固定 28 字节,字段偏移量是固定的,解析逻辑比 IP 简单。

public class ArpParser { public ArpPacket parse(byte[] data, int offset) { ArpPacket packet = new ArpPacket(); // 操作码:1 表示请求,2 表示应答 packet.setOperation(((data[offset + 6] & 0xFF) << 8) | (data[offset + 7] & 0xFF)); // 发送方 MAC 地址占 6 字节 packet.setSenderMac(parseMac(data, offset + 8)); // 发送方 IP 地址占 4 字节 packet.setSenderIp(parseIPAddress(data, offset + 14)); // 目标方 MAC 地址占 6 字节 packet.setTargetMac(parseMac(data, offset + 18)); // 目标方 IP 地址占 4 字节 packet.setTargetIp(parseIPAddress(data, offset + 24)); return packet; } }

注册到解析链路时,在以太网解析器里加一个分支判断,类型字段为0x0806时调用ArpParser,否则走原来的 IP 解析流程。改完之后用文件模式跑一个包含 ARP 包的 pcap 文件验证,看统计输出里 ARP 计数是否正常增长。

验证方法上,我一般会先构造一个最小测试用例:手动拼一个 ARP 请求包的字节数组,直接调解析器,断言每个字段的值。单元测试通过后再跑真实 pcap 文件,这样出问题时能快速定位是解析逻辑错了还是数据源有问题。

从那以后我每次加新协议解析器,都强制先写单元测试再跑真实数据,这个习惯帮我省了很多来回排查的时间。希望帮到你。

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

相关新闻

Notepad++新手入门:语法高亮、编码转换与正则替换实战指南

Notepad++新手入门:语法高亮、编码转换与正则替换实战指南

简介&#xff1a;本资源为Windows平台下轻量级代码编辑器Notepad的完整安装与配置包&#xff0c;面向初学者至中高级程序员&#xff0c;解决日常文本编辑、多语言代码编写、远程文件协作及轻量级开发环境搭建等核心需求。压缩包共392个文件&#xff0c;涵盖128个XML配置与界面定…

2026/10/21 2:42:59 阅读更多 →
Mybatis一级缓存和结合Spring Framework后失效的源码探究

Mybatis一级缓存和结合Spring Framework后失效的源码探究

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

2026/10/21 2:42:59 阅读更多 →
LangGraph多智能体编排:主管架构与分层架构实战指南

LangGraph多智能体编排:主管架构与分层架构实战指南

“多智能体”这几年有点被当成“AI应用进阶的标配”了&#xff0c;好像不拆出三五个Agent就显得不够高级。我的观点反而比较朴素&#xff1a;多智能体不是用来炫技的&#xff0c;它是当单个Agent的复杂度跨过某个临界点之后&#xff0c;不得不做的工程拆分。真正决定一个系统能…

2026/10/21 2:42:59 阅读更多 →

最新新闻

51单片机贪吃蛇实战:12864驱动、EEPROM掉电保存与嵌入式分层设计

51单片机贪吃蛇实战:12864驱动、EEPROM掉电保存与嵌入式分层设计

简介&#xff1a;本资源是一套面向大学本科课程设计的51单片机贪吃蛇游戏完整开发方案&#xff0c;适用于单片机原理与应用、嵌入式系统实践等课程学习与项目实训。资源包含Proteus仿真工程&#xff08;.pdsprj&#xff09;、Keil C51源码&#xff08;.c/.a51&#xff09;、编译…

2026/10/21 3:32:25 阅读更多 →
图书推荐系统实战:Python协同过滤从原理到代码避坑指南

图书推荐系统实战:Python协同过滤从原理到代码避坑指南

我记得第一次带学生跑这个图书推荐系统项目时&#xff0c;好几个人卡在了同一个地方&#xff1a;代码能跑通&#xff0c;但推荐结果全是乱的。后来查了半天&#xff0c;问题出在一个被注释掉的函数上。这个项目在中文互联网上流传了很多版本&#xff0c;绝大多数人拿到的都是简…

2026/10/21 3:32:25 阅读更多 →
空间注意力模块实战指南:原理、代码与调参经验

空间注意力模块实战指南:原理、代码与调参经验

做视觉任务这些年&#xff0c;大家都会遇到一个很实际的需求&#xff1a;网络深了、宽了&#xff0c;但该学的东西没学会。SpatialAttention空间注意力模块&#xff0c;是我调过的结构里性价比最高的一个——代码就几十行、参数量几乎可以忽略&#xff0c;却能明显改变网络的关…

2026/10/21 3:32:25 阅读更多 →
awesome-shizuku vs Dhizuku:两大Android权限提升工具终极对比指南

awesome-shizuku vs Dhizuku:两大Android权限提升工具终极对比指南

awesome-shizuku vs Dhizuku&#xff1a;两大Android权限提升工具终极对比指南 在Android开发的世界中&#xff0c;awesome-shizuku和Dhizuku是两个备受关注的高权限工具&#xff0c;它们都致力于让普通应用获得系统级权限&#xff0c;从而突破Android系统的限制。无论你是普通…

2026/10/21 3:32:25 阅读更多 →
CANN内测实战:从环境初始化到模型推理的完整指南

CANN内测实战:从环境初始化到模型推理的完整指南

前阵子看到某社区的“内测招募&#xff0c;免费体验CANN开发环境”活动&#xff0c;我第一时间就报名了。说实话&#xff0c;当时打动我的不只是“免费”两个字&#xff0c;而是想看看这个面向昇腾AI处理器的异构计算架构&#xff0c;在过去一年里到底进化到了什么程度。CANN这…

2026/10/21 3:32:25 阅读更多 →
Redis单线程还是多线程?从4.0到7.2的线程模型演进真相

Redis单线程还是多线程?从4.0到7.2的线程模型演进真相

1. 这个问题为什么值得花十分钟认真搞懂“Redis 到底是单线程还是多线程&#xff1f;”——这句提问在技术群、面试现场、甚至线上课程评论区里&#xff0c;几乎每周都会高频出现。它表面看是个“是非题”&#xff0c;但背后牵扯的是你对 Redis 性能本质、架构演进逻辑、以及高…

2026/10/21 3:31:24 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事&#xff1a;AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里&#xff0c;按宣传口径&#xff0c;本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”&#xff0c;而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起&#xff1a;用户到底在追求什么“封号重开”这四个字&#xff0c;放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流&#xff0c;背后是大量的时间和精力成本。正常情况下&#xff0c;没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”&#xff0c;而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时&#xff0c;就意识到&#xff1a;单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/20 9:50:45 阅读更多 →