news 2026/10/23 5:49:17

Java+Jnetpcap网络嗅探器实战:抓包解析与TCP流重组

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java+Jnetpcap网络嗅探器实战:抓包解析与TCP流重组

简介:一份基于Java与Jnetpcap库实现的网络嗅探器完整项目,面向希望学习网络协议分析与抓包技术的小白及进阶者,适用于毕设、课程设计或工程实训。项目实现了运行主机网卡选择、链路层至应用层的全协议数据包捕获,并逐层解析包头信息;内置Ethernet、IP、ARP、ICMP、UDP、TCP、HTTP七种协议的过滤分析,支持源/目的IP及包内容关键字过滤,以及基于IP+Port的TCP流追踪,分析结果可保存。资源共72个文件,以Java源码、class字节码、XML配置和17张运行截图为主,附带jar库与说明文档,压缩包整体仅1.85MB,便于快速部署与代码研读。目前已有467人学习,适合需要完整可运行示例以参考抓包核心流程与界面设计的学习者。

1. 用Java和Jnetpcap写网络嗅探器:抓包程序到底在抓什么

做Java后端的人几乎都遇过这种局面:联调时接口报错,两边都坚持“我发了/我没收到”,能一锤定音的只有一台抓包程序。网络嗅探器这个名字听着高,本质就是抓包程序加封包分析程序。Jnetpcap在Java界比较特殊,它是把libpcap这套底层抓包能力通过JNI暴露给Java的封装,也就是说你不用碰C/C++就能拿到网卡上的原始帧。这篇文章把“基于Java+Jnetpcap的网络嗅探器设计与实现”这条路走通:从环境配置讲到抓包循环、封包解析、TCP流重组,再讲JNI崩溃和回环流量这些绕不开的坑。适合正在做课程设计、流量分析工具,或想给团队搭一个内网抓包小工具的从业者。如果你只是想抓自己进程发出的应用层数据,那用nio就够了;抓包程序的价值在于,连别人发给你的包也能看见。

2. Jnetpcap的抓包链路:为什么Socket硬写不行,环境三件套怎么配

2.1 Java工程师的第一道坎:Socket只能看见自己进程的数据

Java标准库的Socket是应用层编程接口,数据到达网卡之前已经被协议栈消化完了,程序只能被动接收发给当前进程的包。而网络嗅探器要把网卡设置成混杂模式,让驱动把链路上所有帧都交一份上来。这一层能力在操作系统里通常由libpcap提供。

Jnetpcap就是在JNI边界上把libpcap的函数映射成Java对象的库,核心类是JpcapCaptor。有人会问,为什么不直接用JNI调libpcap?可以,但头文件、指针、内存释放这些事会让开发成本翻几倍。Jnetpcap把这些封装成Packet、Header、PcapIf一套对象模型,Java工程师上手成本低很多。它和早期jpcap项目相比,封包分析的类型体系更完整,Ethernet、IP、TCP、UDP这些头部都有对应描述。

这里有个很容易被想当然的点:网卡驱动把帧拷贝到内核缓冲,再通过Pcap库交到用户空间,这里有两次拷贝和一次系统调用。所以网络嗅探器从来不做“抓一个包解析一个包”这种串行活,而是先批量收包,再异步解析。这个思路会贯穿后面所有代码。

2.2 环境三件套:JDK版本、Npcap、jnetpcap.dll的匹配关系

Jnetpcap是早年项目,对新的JDK适配比较慢。我一般锁JDK 8,而不是追最新版。JDK 9以后模块化对ClassPath访问有限制,Jnetpcap这种靠JNI加载native库的组件容易出幺蛾子。如果你电脑里装了多个JDK,启动时用java -version确认一下当前版本,再决定要不要切环境变量。

第二件是抓包驱动。Windows上装Npcap,安装时勾选“WinPcap API兼容层”,Jnetpcap才能通过wpcap.dll找到Pcap函数族。Linux上直接装libpcap-dev即可,macOS自带的libpcap也能用。第三件是jnetpcap的jar和dll,这两个文件要放到classpath和java.library.path里,位数必须和JDK一致——64位JDK配64位dll,否则运行时报“Wrong architecture”。

启动命令这么写:

java -Djava.library.path=./lib \ -cp ./lib/jnetpcap.jar:./out \ com.example.sniffer.Main

-Djava.library.path指定native库目录,jnetpcap的dll和jar都放在./lib下。classpath用冒号分隔,Windows下要改成英文分号,经常有人在这里配置完却报“no jnetpcap in java.library.path”,然后发现dll根本没放进java.library.path指向的目录。JNI查找native库依赖的是java.library.path,不是classpath,这个区别排查时最容易被忽略。

2.3 先写个能跑的:枚举网卡并打印MAC地址

第一步不急着抓包,先把本机网卡设备列表枚举出来。这块没有技术深度,但能验证环境配置是否到位,项目里习惯叫“设备发现”:

import org.jnetpcap.Pcap; import org.jnetpcap.PcapIf; public class ListDevices { public static void main(String[] args) { StringBuilder errbuf = new StringBuilder(); java.util.List<PcapIf> alldevs = new java.util.ArrayList<>(); int r = Pcap.findAllDevs(alldevs, errbuf); if (r == Pcap.ERROR || alldevs.isEmpty()) { System.err.println("设备枚举失败: " + errbuf); return; } for (PcapIf dev : alldevs) { System.out.println("设备名: " + dev.getName()); System.out.println("描述: " + dev.getDescription()); byte[] mac = dev.getMacAddress(); if (mac != null) { StringBuilder sb = new StringBuilder(); for (byte b : mac) { if (sb.length() > 0) sb.append(':'); sb.append(String.format("%02X", b)); } System.out.println("MAC地址: " + sb); } for (java.net.InetAddress addr : dev.getAddresses()) { System.out.println("IP地址: " + addr.getHostAddress()); } } } }

Pcap.findAllDevs把本机所有网卡装进List,失败信息写入errbuf。getMacAddress方法对虚拟网卡、回环网卡经常返回null,所以必须先判空再格式化。有一类常见报错是“找不到wpcap.dll”,这通常是Npcap没装兼容层,或者装了32位版本而JDK是64位,优先从这两个方向查。能把IP和MAC打出来,说明Jnetpcap的native层已经通了,可以进入下一步。

2.4 打开网卡的最小代码:openLive的四个参数

设备枚举通过后,用Pcap.openLive打开指定网卡开始收包:

String device = alldevs.get(0).getName(); StringBuilder errbuf = new StringBuilder(); int snaplen = 65535; int promisc = Pcap.MODE_PROMISCUOUS; // 1,混杂模式 int timeout = 10; // 10 毫秒 Pcap pcap = Pcap.openLive(device, snaplen, promisc, timeout, errbuf);

第一个参数是网卡名称,来自findAllDevs返回的PcapIf.getName,在Windows上通常是\Device\NPF_开头的GUID字符串,不是给人看的网卡名。snapLen控制每帧最多拷贝多少字节给应用层;promisc设置为1代表混杂模式,0代表只收本机流量;timeout是内核缓冲向应用提交数据的时间间隔,单位毫秒。这几个参数的具体权衡放下一章展开,先记住一点:pcap对象创建成功后要判断是否为空,openLive失败时pcap为null而不是抛异常,错误信息在errbuf里。

3. 把报文不间断捞上来:抓包循环与三个必调参数

3.1 两种抓包循环:loop回调与nextExPacket阻塞式

抓包循环有两种写法,各有适用场景。第一种是回调式,调用pcap.loop然后由libpcap的分发线程逐帧调用你的handler:

pcap.loop(-1, new JPacketHandler<Object>() { @Override public void nextPacket(JPacket packet, Object user) { // 回调运行在Jnetpcap的native分发线程,禁止在这里做耗时解析 int caplen = packet.getCaptureHeader().caplen(); System.out.println("捕获长度=" + caplen); } }, null);

loop的第一个参数是要抓的包数,-1表示无限抓包直到主动中断。回调在每个包到达时立刻触发,实时性好,适合做流量监听。但千万注意:回调执行期间后续包会在内核缓冲里积压,如果回调里做了数据库写入、正则匹配这类耗时操作,丢包率会直线上升。第二种是阻塞式:

JPacket packet = pcap.nextExPacket(); if (packet != null) { // 拿到一帧,交给工作线程处理 }

nextExPacket每次取一帧,没有包时阻塞在native方法里,适合自己控制消费节奏的场景。我一般用nextExPacket作为主循环的取包方式,把帧投递到独立线程池做解析,这样抓包线程和解析线程互不阻塞。

3.2 三个必调参数:snapLen、promisc、timeout的权衡

openLive里那三个参数是抓包程序最核心的调优点,我按实际项目里的经验列一张参数表:

参数典型值作用踩坑点
snapLen65535每帧最多拷贝给应用的字节数设太小会截断帧尾部,TCP payload拿不全
promisc1混杂模式,收链路上所有帧部分虚拟网卡驱动会忽略此标志
timeout10~50内核缓冲向用户态提交的时间间隔设0在部分平台变成非阻塞,循环空转

以太网默认MTU是1500字节,但加上VLAN Tag、巨型帧,单帧可能超过9000字节,snapLen设65535是Pcap官方的通用建议。设小了最典型的现象是“包能抓到,但TCP payload永远是1448字节”,因为超过snapLen的部分被内核丢了。这个参数只影响拷贝长度,不影响线路上实际的帧,所以调大到65535没有副作用。

timeout影响着实时性和吞吐量的平衡。抓交互式调试流量,设10毫秒让包尽快到达应用层;抓大流量做离线分析,设长到50毫秒,让内核把一批帧合并提交,减少用户态唤醒次数。timeout设为0时要特别小心:在Windows上这代表非阻塞模式,没有包时nextExPacket立即返回null,循环空转,CPU直接跑满。

3.3 后台抓包线程与解析解耦:队列缓冲怎么做

抓包程序一旦需要做界面展示或复杂解析,就必须把“收包”和“处理包”拆到两个线程。回调线程收到帧后,把数据拷贝成独立对象投进阻塞队列,消费线程负责解析。这是最常见的生产级抓包结构:

BlockingQueue<PcapPacket> queue = new LinkedBlockingQueue<>(1024); pcap.loop(-1, (packet, user) -> { // PcapPacket必须拷贝,回调里的packet是复用的native缓冲区 PcapPacket copy = new PcapPacket(packet); if (!queue.offer(copy)) { copy.clear(); // 队列满说明消费跟不上,宁可丢帧也不能内存堆积 } }, null);

这里的核心是PcapPacket的拷贝。Jnetpcap为了性能会复用同一块native内存,回调对象在处理完下一帧后内容就被覆盖,所以必须new PcapPacket(packet)做一次深拷贝。这个native内存管理机制对Java工程师来说像个黑匣子,最典型的表现就是“放到List里的包,循环跑几轮后全变成最后一帧的数据”,根因就是没拷包。

队列设置上限是保护性设计。消费线程一旦卡住,队列满后新帧直接丢弃,而不是让抓包线程无限堆积导致内存溢出。实际生产里建议队列容量设为2048到4096之间,太大浪费内存,太小容易丢帧。

4. 把帧一层层剥开:Ethernet、IP、TCP头部解析与HTTP流重组

4.1 解析方式选型:用Header类型还是手动按偏移读

Jnetpcap自带Ethernet、Ip4、Tcp等Header类型,理论上可以packet.getHeader(new EthernetHeader())直接拿结构化字段。但我在实际项目里更常用手动偏移解析,原因有两个:一是默认Header类型在解析前要做类型匹配,性能开销比直接读字节高一个量级;二是抓包程序经常要处理畸形帧、非标准帧,Header框架对长度校验不通过的情况容易抛异常或返回null。

协议帧结构是公开且固定的。以太网帧头部14字节:目标MAC占6字节、源MAC占6字节、类型字段占2字节,类型0x0800代表IPv4。IP头紧跟其后,第一个字节的高4位是版本号,低4位是IHL,IHL乘以4就是IP头长度。TCP头在IP头之后,源端口和目的端口各占2字节。这一套偏移规则掌握后,解析程序就变成一个字节读取问题,可控性最强。

4.2 手写一个解包方法:从原始帧里读TCP四元组

下面这个方法从JPacket原始字节里解析出IP层跟TCP层的核心字段:

public void dissect(JPacket packet) { int capLen = packet.getCaptureHeader().caplen(); if (capLen < 14) { return; // 以太网头都不完整,直接丢弃 } // 以太网类型字段:偏移12,占2字节,0x0800表示IPv4 int ethType = (packet.getUByte(12) << 8) | packet.getUByte(13); if (ethType != 0x0800) { return; // 先只处理IPv4,IPv6是0x86DD } int ipStart = 14; byte versionIhl = packet.getUByte(ipStart); int ipHeaderLen = (versionIhl & 0x0F) * 4; // IHL字段单位是4字节 byte proto = packet.getUByte(ipStart + 9); // 协议字段,6=TCP if (proto != 6) { return; } // IP源地址和目的地址各占4字节,手动拼成整数 long srcIp = ((long) (packet.getUByte(ipStart + 12) & 0xFF) << 24) | ((packet.getUByte(ipStart + 13) & 0xFF) << 16) | ((packet.getUByte(ipStart + 14) & 0xFF) << 8) | (packet.getUByte(ipStart + 15) & 0xFF); int tcpStart = ipStart + ipHeaderLen; int srcPort = (packet.getUByte(tcpStart) << 8) | packet.getUByte(tcpStart + 1); int dstPort = (packet.getUByte(tcpStart + 2) << 8) | packet.getUByte(tcpStart + 3); // TCP数据偏移字段:偏移12,高4位乘以4就是TCP头长度 int tcpHeaderLen = (packet.getUByte(tcpStart + 12) >> 4) * 4; long seq = ((long) (packet.getUByte(tcpStart + 4) & 0xFF) << 24) | ((packet.getUByte(tcpStart + 5) & 0xFF) << 16) | ((packet.getUByte(tcpStart + 6) & 0xFF) << 8) | (packet.getUByte(tcpStart + 7) & 0xFF); System.out.printf("%s:%d -> %s:%d seq=%d tcpHeaderLen=%d%n", ipToString(srcIp), srcPort, ipToString(dstIp), dstPort, seq, tcpHeaderLen); }

代码里用的是packet.getUByte(index)逐字节读取,再做位移拼装。这里有个必须注意的细节:网络字节序是大端,而Java的int原样读取字节数组会按小端解释,所以源端口不能直接读整型,必须手动把高字节左移8位再或上低字节。这个位运算的思路,其实就是java基础里原码补码和位运算在实际协议解析中最常见的用法。ipToString工具方法把整数型IP按字节拆回点分十进制:

private String ipToString(long ip) { return (ip >> 24 & 0xFF) + "." + (ip >> 16 & 0xFF) + "." + (ip >> 8 & 0xFF) + "." + (ip & 0xFF); }

4.3 简化版TCP流重组:按四元组聚合payload

抓包程序不能只展示单帧,还得能把属于同一条TCP连接的报文拼在一起,才能看到完整的HTTP请求或响应。生产级的TCP重组要处理序列号比对、乱序、重传、窗口更新,工程量很大。抓包分析工具里一般先做简化版,按四元组聚合,把顺序到达的payload连续写入缓冲区:

Map<TcpKey, ByteBuffer> streams = new HashMap<>(); // 收到TCP packet后,假设已有srcIp、srcPort、dstIp、dstPort和payload偏移 TcpKey key = new TcpKey(srcIp, srcPort, dstIp, dstPort); ByteBuffer buf = streams.computeIfAbsent(key, k -> ByteBuffer.allocate(64 * 1024)); int payloadOffset = tcpStart + tcpHeaderLen; int payloadLen = capLen - payloadOffset; if (payloadLen > 0) { byte[] payload = packet.getByteArray(payloadOffset, payloadLen); buf.put(payload); }

TcpKey这个类需要实现equals和hashCode,字段就是四元组里的四个值。这种写法能还原“顺序到达、没有丢包”的连续TCP流,对HTTP这种文本协议已经够用了。但如果链路有重传或乱序,简化版会把重复段重复写入,所以生产环境的完整实现必须校验序列号连续性。缓冲区设为64KB是保守值,承载一次HTTP请求和响应通常足够,超长响应可以设计成滚动写入磁盘。

拿到完整流之后,HTTP解析就简单了,因为HTTP/1.1头是文本协议。把payload转成String,按\r\n\r\n切分,第一段是请求行或状态行,后面是Header列表,\r\n\r\n之后是body。这一步建议单独抽一个HttpParser类,不要和抓包循环混在一起。

4.4 落盘自检:用JpcapWriter写lcap文件

抓包程序做完解析后,第一件要做的验证工作是落盘。Jnetpcap提供JpcapWriter,可以把原始帧直接写成lcap文件,用Wireshark打开对照检查:

JpcapWriter writer = JpcapWriter.openDumpFile(pcap, "capture.lcap"); // 每抓到一个包后 writer.writePacket(packet);

lcap文件格式和Wireshark的pcap格式兼容,这样你自己的解析逻辑和Wireshark解析的结果就能逐帧对比。我习惯抓包循环里同时做两件事:一是解析帧结构打日志,二是把原始帧全量落盘。这样一旦解析结果和预期不符,可以直接打开lcap查证,而不是重新抓一遍。

4.5 减少无效帧:setFilter先过滤再抓

流量大的环境中,全量抓包再逐帧解析会浪费大量CPU在无关流量上。Jnetpcap支持Pcap过滤器,直接在内核态完成初筛:

pcap.setFilter("tcp port 80", true);

setFilter的第二个参数是优化开关,传true让libpcap优化过滤表达式。常见过滤写法有“host 192.168.1.1”“tcp port 443”“udp”“net 192.168.1.0/24”,组合用“and”“or”。在抓包程序里我一般会把这部分做成可配置项,用命令行参数传入过滤表达式,因为实际使用中抓HTTP和抓DNS的过滤条件完全不同。

5. Jnetpcap抓包程序避坑指南:JNI崩溃、环回流量、内存泄漏

5.1 JVM直接崩溃,异常栈都没有

现象:抓包程序运行一会儿,控制台没有任何异常信息,进程直接消失,系统日志里只留一个hs_err_pid*.log文件。

原因:Jnetpcap的native层崩溃。JNI代码里出现段错误时,Java异常机制捕获不到,属于C语言层面的崩溃,表现形式就是JVM直接退出。最常见的根因是jnetpcap.dll位数与JDK不匹配,或者Npcap版本与jnetpcap不兼容。

解决:先到进程工作目录找hs_err_pid文件,用文本编辑器打开,重点看“Native frames”段落,崩溃栈通常指向jnetpcap的native函数。确认dll位数以后,再把Npcap卸载重装,安装时勾选完整兼容层选项。这类问题不要试图用try-catch兜住,JNI崩溃不是Java异常。

5.2 抓不到本机回环流量

现象:程序访问本机服务时抓不到包,抓别的机器流量正常。

原因:回环流量不经过物理网卡,WinPcap时代根本没有回环网卡抽象,Npcap虽然提供Npcap Loopback Adapter,但默认配置下并不启用回环捕获。

解决:安装Npcap时选择支持回环流量,设备枚举时单独找名为“Npcap Loopback Adapter”的网卡,用这个接口抓包。要注意的是,请求本机服务时流量直接发到本机IP,网卡层面的过滤规则和物理网卡不一样,过滤表达式需要放宽到tcp port 8080这种端口级条件。

5.3 内存占用持续上涨

现象:抓包程序运行数小时后,内存从几百MB涨到几个GB,最后卡死。

原因:典型情况是回调里的Packet没有拷贝就直接存入容器,或者拷贝后没有释放。Jnetpcap的JPacket引用的是native内存,Java的GC管不到这块区域,对象不被引用时native内存也不一定立刻归还。另一个原因是阻塞队列无界增长,消费线程跟不上。

解决:回调里一律new PcapPacket(packet)深拷贝,队列设置上限并加丢帧策略。PcapPacket提供clear方法,确认帧不再使用后调用clear释放native内存。排查时用jcmd GC.class_histogram看PcapPacket实例数量,如果实例数持续增长,就是容器引用没释放;如果实例数不变但内存增长,问题出在native内存。

5.4 网卡能枚举,openLive也成功,但收不到包

现象:设备列表打印正常,openLive没有报错,循环里却一直拿不到包。

原因:权限不足是第一嫌疑。Windows下捕获原始帧需要管理员权限,普通用户运行时Pcap调用看起来正常,但驱动层拒绝交出数据。其次是Npcap服务未启动,或者混杂模式被网卡驱动忽略。虚拟机和无线网卡的驱动对混杂模式支持最差,经常静默失败。

解决:先以管理员身份重启程序,再看Npcap服务状态:

sc query npcap

服务未启动就在管理员终端执行sc start npcap。如果程序和Npcap都正常,换一块物理有线网卡试。无线网卡在Windows上默认不允许混杂模式捕获,这是驱动层的限制,不是代码问题。

6. 进阶玩法:按会话聚合流量,交叉验证抓包结果

6.1 连接级聚合统计

把单帧解析做成了还不能叫嗅探器,得把海量包聚合成“连接视图”,才能看出流量规律。我常用的做法是维护一个四元组到统计对象的映射:

Map<TcpKey, ConnStat> stats = new HashMap<>(); // 每解析完一个TCP包 ConnStat st = stats.computeIfAbsent(key, k -> new ConnStat()); st.packets++; st.bytes += payloadLen;

循环结束后按bytes排序输出,立刻能看出哪条连接在跑大流量,哪条连接包数多但数据量小。这种聚合视图就是抓包程序的核心输出,比单帧列表直观得多。课程设计或团队内网工具做到这一步,已经具备实用价值。

6.2 三种方法交叉验证抓包结果

要验证抓包程序正确与否,我习惯三个手段一起上:第一,开Wireshark同时抓同一张网卡,对比两侧的TCP四元组和序列号是否一致;第二,本机用curl访问一个公网HTTP站点,确认抓到的帧里能看到完整的GET请求行和Host头;第三,ping网关地址,检查ICMP请求和应答的标识字段是否对应。这三个手段能分别验证链路层、TCP解析和IP层处理。

有一次排查一个TCP流重组问题,抓到的HTTP请求总是缺尾部数据,最后发现是snapLen设成了1500,巨型帧的payload被截断,导致payloadLen为负。从那以后我把snapLen、过滤表达式、队列消费速度这三个参数写进了程序启动日志,每次跑起来先确认环境再抓包。这个习惯帮我少走了很多弯路,希望帮到你。

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

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

Python包管理进阶:十大pip高级用法与依赖管理实战

开头搞Python的人&#xff0c;没有一个敢说自己没碰过pip。但说实话&#xff0c;大多数人对pip的使用还停留在pip install xxx和pip list这个层面。真正到了生产环境、离线环境、多人协作、依赖冲突排查的时候&#xff0c;你才会发现pip远远不是一个“装包工具”那么简单。这篇…

作者头像 李华
网站建设 2026/10/23 5:49:08

Agent-Reach 实战:用命令行和 Python 高效构建 AI Agent

1. 从零认识 Agent-Reach&#xff1a;一个把 AI Agent 拉回命令行的工具第一次看到 Agent-Reach 这个名字&#xff0c;我下意识把它和市面上那些"一键生成智能体"的图形化平台归到了一类。真正上手之后才发现&#xff0c;它走的是完全相反的路子——把 AI Agent 的能…

作者头像 李华
网站建设 2026/10/19 3:14:17

LeetCode 871 贪心算法与最大堆:最优加油策略与内存优化详解

1. 题目拆解与整体思路1.1 先把这个“最低加油次数”问题翻译成人话LeetCode 871题&#xff0c;Minimum Number of Refueling Stops&#xff0c;属于典型的“决策类”困难题。题目描述其实不长&#xff1a;你开一辆车从起点出发&#xff0c;初始油量是 startFuel&#xff0c;终…

作者头像 李华
网站建设 2026/10/19 3:14:23

单词拆分问题的动态规划解法:从状态设计到剪枝优化

这道题在力扣热题100里排在第82位&#xff0c;是动态规划专题里非常经典的一道入门题。很多人第一次看到“单词拆分”这个标题&#xff0c;会觉得是要处理什么天然的文本分割问题&#xff0c;但实际上它考察的就是一个很朴素的思路&#xff1a;一个字符串能不能被拆成若干个字典…

作者头像 李华
网站建设 2026/10/22 9:23:02

从零构建Python学习打卡工具:SQLite数据模型与趋势图实现

我最早做这个工具&#xff0c;是因为自己也栽过跟头。年初立了个flag&#xff0c;说每天要学一小时Python&#xff0c;结果前三天热情高涨&#xff0c;第四天加班回家倒头就睡&#xff0c;第五天刷手机刷到十二点突然想起来还没学&#xff0c;心一横直接放弃。到月底复盘&#…

作者头像 李华