从第一次接触Netty到现在我一直觉得ChannelInboundHandler和ChannelOutboundHandler这对概念是新手最容易绕晕的地方。很多人照着官方Demo把ServerBootstrap熬出来了结果一看到pipeline.addLast()里那一串handler就开始犯迷糊明明代码顺序是从前往后add的为什么数据出去的时候顺序就反过来了为什么我在channelRead里直接write客户端却迟迟收不到响应这些坑我全踩过而且踩得很扎实。这篇东西不打算写成API手册式的流水账主要想把我自己理解这套入站出站机制的思路、写代码时的判断方法、以及排查问题上的一些土办法完整梳理一遍。适合三类人看一是刚学完EchoServer、正准备接触自定义协议的初学者二是写了好几个Netty服务但一直对Pipeline传播顺序半懂不懂的开发者三是需要在生产环境排查Handler不生效、消息乱序、ByteBuf泄漏这些疑难杂症的人。看完你会发现Inbound和Outbound其实就是“数据从哪来、往哪去”的问题方向想通了剩下都是细节。1. 整体设计与思路拆解Pipeline里的双向责任链1.1 入站与出站的第一性理解先把最朴素的问题摆出来Netty的ChannelHandler为什么要分入站和出站两种道理其实特别简单——网络数据不是一个方向的。服务端从Socket里读到的数据和要往Socket里写的数据这是两条完全相反的路径。读进来的字节流向业务逻辑这是入站Inbound业务逻辑产生的数据流出到网络这是出站Outbound。用一个生活化的类比你把Pipeline想象成一条传送带传送带上是各种处理工位。数据包从网口进来沿着传送带往前走经过解码工位、业务处理工位这段旅程叫入站。但业务逻辑处理完之后需要把响应数据送回网口这是另一个方向——从业务工位往网口方向走这就是出站。麻烦的地方在于这条传送带上的工位有些只处理入站方向的货物有些只处理出站方向的货物而有些两头都管。Netty里这个“传送带”就是ChannelPipeline它内部维护着一个DefaultChannelHandlerContext的双向链表。每个Handler被包装成一个Context节点节点之间通过next和prev指针连接。入站事件channelRead、channelActive、exceptionCaught等从链表头部流向尾部执行顺序是addLast的添加顺序出站事件write、flush、connect、close等从链表尾部逆流回头部执行顺序与添加顺序完全相反。1.2 Pipeline的装配规则与兜底节点这里有个关键细节即使你一个Handler都不加Pipeline也不是空的。Netty在创建Channel的时候会自动往Pipeline头尾各塞一个节点——HeadContext和TailContext。HeadContext同时实现了ChannelInboundHandler和ChannelOutboundHandler它是出站事件的起点负责真正调用底层的Unsafe去操作Socket同时它也会把入站事件往下一个节点传播。TailContext只实现ChannelInboundHandler如果你的入站事件一路传到尾部都没人处理它会把这个事件“消费”掉——比如channelRead传到Tail如果消息没被释放Tail会帮你释放ByteBuf避免泄漏。这一点对理解问题很关键。很多人写代码发现“我的Handler为什么不执行”查了半天最后发现是没调ctx.fireChannelRead()事件卡在某个节点没往下传。同样出站事件如果没人处理最终也会传到Head由它执行真正的I/O。所以不要把Pipeline当成“我写了哪些Handler”而要理解成“一组Handler像链条一样串起来每个节点决定自己处理还是放行”。1.3 为什么这种双向设计在实际项目中很重要如果只是一个Demo双向设计的价值不明显。但放到真实项目里价值立竿见影。举个例子我在做一个模拟的某物联网网关时物理层收到的是一堆零散的TCP字节流粘包拆包问题我需要一个入站Handler做粘包拆包和解码把原始字节变成业务对象收到业务对象后我需要处理心跳、指令路由、设备状态管理这是另一层入站Handler当业务层要下发命令给设备时我需要一个出站Handler把业务对象编码回字节流再交给底层Socket发送。如果这全都写成一个Handler逻辑必然纠缠不清。如果Netty不区分入站和出站那么解码器可能在数据出去时也被执行一遍造成不可预料的错误。所以分方向的本质是为了让“解码逻辑”始终只处理入站数据让“编码逻辑”始终只处理出站数据让“业务逻辑”只关心业务对象互相不干扰。理解了这个设计意图你在决定一个类到底该继承ChannelInboundHandlerAdapter还是ChannelOutboundHandlerAdapter时就有了判断依据。2. 核心接口方法与实操要点解析2.1 ChannelInboundHandler的事件方法与传播机制ChannelInboundHandler定义了一组事件回调方法每个方法对应一种入站事件。最常用的几个channelRegisteredChannel注册到EventLoop时触发。channelActiveChannel建立连接时触发。服务端里这通常表示某个客户端连上来了。channelInactive连接断开时触发。channelRead读取到数据时触发。这是日常打交道最多的方法。channelReadComplete一次读操作完成后触发。注意它不代表“一批数据读完了”而是“当前这次读循环结束了”。userEventTriggered用户自定义事件触发常用于空闲检测、自定义业务事件。exceptionCaught处理过程中出现异常时触发。这里最容易被忽略、却又最重要的是“传播”的写法。你用ctx.fireChannelRead(msg)把事件传给下一个InboundHandler你不调用事件就到此为止后面的人看不到。而ctx.channel().writeAndFlush()是触发一个出站事件它会从Tail开始往前找OutboundHandler。我在实际项目中见过无数次的问题某个中间Handler里用ctx.write(msg)想往下游传数据结果数据反而跑到出站方向去了下游InboundHandler根本接不到。因为ctx.write()触发的是出站事件而ctx.fireChannelRead()才是入站传播。这个坑稍不留神就能让人排查一整天。2.2 ChannelOutboundHandler的核心方法与Promise机制ChannelOutboundHandler的方法对应的是“想对网络做什么”bind绑定端口服务端启动时触发。connect发起连接客户端启动时触发。disconnect断开连接。close关闭Channel。read申请读取数据。write写入数据。flush将缓冲的数据真正刷到Socket。关于write和flush的区别我多说两句。write只是把数据写进ChannelOutboundBuffer这个缓冲区数据并没有真正发出去flush才是把缓冲区里积压的数据一次性刷到Socket。如果你只write不flush数据会一直积压在内存里客户端什么都收不到。Netty为了简化提供了writeAndFlush一条龙搞定。但你要明白它内部是分两步的先缓冲再冲刷。再看ChannelFuture和ChannelPromise。出站方法的最后一个参数通常是个ChannelPromise它可以理解为“本次写操作完成后的通知器”。你可以在其上添加监听器来感知写是否成功ctx.writeAndFlush(response).addListener((ChannelFutureListener) future - { if (future.isSuccess()) { // 写入成功 } else { // 写入失败通常需要记日志并关闭连接 future.channel().close(); } });这里有一个值得注意的细节出站事件在传播过程中每个OutboundHandler都有机会修改这个Promise或对写操作做拦截。如果某个Handler把原来的Promise换成了新的Promise那么之前添加的监听器可能不会触发。所以生产环境里如果你发现某个write的监听器不执行先去看Pipeline里是否有Handler替换了ChannelPromise。2.3 状态管理与资源释放的心法Handler是有生命周期的handlerAdded和handlerRemoved分别在Handler被加入Pipeline和移除时触发。我一般在handlerAdded里做资源初始化比如创建心跳定时任务在handlerRemoved里做清理比如取消定时任务、关闭连接。还有一个绕不开的话题ByteBuf的释放。ByteBuf是引用计数对象用完后需要release()否则内存泄漏。判断责任归属有一条土办法谁最后消费了数据谁负责释放。如果你在channelRead里把数据原样往下传你自己没有消费就不需要释放。如果你消费了数据但没有把数据继续传给下一个Handler你就必须释放通常用ReferenceCountUtil.release(msg)。如果你把ByteBuf转换成了业务对象字节数组、字符串、POJO这个转换过程通常会释放原始ByteBuf比如ByteBuf.toString(charset)会释放bufByteBufUtil.getBytes也会释放。一个非常省心的替代方案是继承SimpleChannelInboundHandlerI。这个类是ChannelInboundHandlerAdapter的封装它会在你的channelRead0方法执行完后自动帮你释放消息对象。前提是消息类型继承ReferenceCountedByteBuf、ByteBufHolder都满足。如果你的业务Handler只读不改消息拿到就处理完拉倒用SimpleChannelInboundHandler能少写无数release。2.4 Sharable注解的使用误区和线程安全Sharable注解表示这个Handler可以被多个Channel共享使用。它的前提是Handler内部没有任何可变的实例字段或者说所有字段线程安全。一个很典型的反例是在Handler里保存业务状态比如用成员变量记录某个设备的登录状态然后打上Sharable。一旦两个连接共用了这个HandlerA连接的状态可能被B连接覆盖排查起来非常痛苦。我个人的习惯是除非明确知道Handler是无状态的如编解码器、日志Handler否则不要加Sharable。每次pipeline.addLast(new MyHandler())为每个Channel创建独立的Handler实例成本极低换来的是线程安全的确定性。省那一点对象创建开销没意义。3. 实操过程从零手写一个带粘包处理的Handler链3.1 场景设定与协议设计纸上谈兵再多不如实际撸一段代码。我以一个模拟的物联网网关推送服务为例说明一条完整的Handler链该怎么设计。场景某网关每秒钟会向服务端上报一批设备状态数据。TCP协议报文格式非常简单2字节魔数0x5A 0x5A2字节长度字段标识payload的长度无符号短整型范围0-65535N字节payload具体业务数据比如JSON服务端要做的事情接收字节流解决粘包拆包把完整报文解析成DevicePacket对象交给业务Handler处理然后回写一个ACK响应。3.2 Pipeline配置与顺序设计的判断方法服务端的Pipeline配置如下Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); pipeline.addLast(new DevicePacketDecoder()); // 入站: 字节 - DevicePacket pipeline.addLast(new DevicePacketEncoder()); // 出站: DevicePacket - 字节 pipeline.addLast(new DeviceBusinessHandler()); // 入站: 处理业务并触发回写 pipeline.addLast(new ExceptionHandler()); // 入站: 兜底异常 }这里需要解释清楚顺序问题。入站事件按添加顺序传播所以DevicePacketDecoder最先拿到原始字节解码后生成DevicePacket通过fireChannelRead传给DeviceBusinessHandler。DeviceBusinessHandler处理完业务后调用ctx.writeAndFlush(ackPacket)这个事件是出站方向从Pipeline尾部往前传播。它先经过ExceptionHandler——因为ExceptionHandler只实现了Inbound接口不出站方法所以直接跳过再经过DeviceBusinessHandler自身——但它是入站Handler没有重写出站方法所以也跳过最后到达DevicePacketEncoder——它是出站Handler把DevicePacket编码成字节继续传给HeadContext最后由底层I/O发出。看到没有你在addLast的时候DevicePacketEncoder排在DeviceBusinessHandler前面。但是当业务Handler写数据时编码器却会执行。因为出站方向是逆流而上。写代码时DevicePacketEncoder放在哪个位置其实无所谓只要在业务Handler之后或之前都能被经过因为它是从tail向前找的真正决定它能不能被执行的是它是否出现在Pipeline里。但一般把编码器放在业务Handler前面代码逻辑上更容易理解。再补一个知识点多个出站Handler的执行顺序。假设Pipeline按顺序加了HandlerA、HandlerB、HandlerC其中A和C是OutboundHandler。当某处调用ctx.channel().writeAndFlush(msg)时事件从Tail开始逆流依次是C发现C是OutboundHandlerC处理继续逆流到BB若不是OutboundHandler则跳过再到AA处理再往Head传。所以关于出站事件Pipeline中后添加的OutboundHandler反而先执行。这个反直觉的点是面试题和线上Bug的高发区。3.3 入站HandlerDevicePacketDecoder的完整实现粘包拆包我在生产环境比较推荐继承ByteToMessageDecoder它内部已经处理好了累积缓冲区的逻辑省去自己管理ByteBuf的麻烦public class DevicePacketDecoder extends ByteToMessageDecoder { private static final int MAGIC_NUMBER 0x5A5A; private static final int HEADER_LENGTH 4; Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { if (in.readableBytes() HEADER_LENGTH) { return; // 连头部都凑不齐等下次数据到达 } in.markReaderIndex(); int magic in.readUnsignedShort(); if (magic ! MAGIC_NUMBER) { // 魔数不对说明数据流错位这里直接关闭连接比盲目跳过更安全 ctx.close(); return; } int payloadLength in.readUnsignedShort(); if (in.readableBytes() payloadLength) { // 长度不够说明半包回到标记位置等更多数据 in.resetReaderIndex(); return; } byte[] payload new byte[payloadLength]; in.readBytes(payload); out.add(new DevicePacket(magic, payloadLength, payload)); } }几个细节值得展开。markReaderIndex和resetReaderIndex是为了处理半包。如果发现长度不足就把读指针恢复到头部开始的位置下次decode会重新解析。这里面最容易犯的错误是半包时忘了resetReaderIndex导致后续数据来了之后从这个错误的位置继续读。ByteToMessageDecoder的decode方法不会自动帮你回退读指针一定要自己控制。另外一个容易被忽略的点魔数校验失败的处理。我见过某些项目选择“跳过1字节再试”这种做法在极端情况下确实能自愈但也会掩盖链路里更严重的问题。对于内部协议魔数错位通常意味着字节序不同、序列化版本不匹配或者有人误连了端口直接关闭连接让对端重连反而能更快暴露问题。3.4 出站HandlerDevicePacketEncoder的完整实现出站编码器继承MessageToByteEncoderDevicePacketpublic class DevicePacketEncoder extends MessageToByteEncoderDevicePacket { Override protected void encode(ChannelHandlerContext ctx, DevicePacket msg, ByteBuf out) { out.writeShort(msg.getMagic()); out.writeShort(msg.getPayloadLength()); out.writeBytes(msg.getPayload()); } }MessageToByteEncoder内部有一个非常有用的特性它只处理指定泛型类型匹配的消息。如果Outbound事件里的消息不是DevicePacket类型这个Encoder会被自动跳过消息原样往下游传。这意味着你可以把不同类型的编解码器放在同一个Pipeline里各自只消费自己关心的类型。但这里要注意一个隐藏陷阱如果一个Outbound消息没有任何编码器能处理比如业务Handler直接ctx.writeAndFlush(ByteBuf)而Pipeline里只有一个DevicePacketEncoder那么这个ByteBuf会不会发出去答案是不会至少不会以你期望的形式发出去。MessageToByteEncoder发现自己类型不匹配时会调用ctx.write(msg, promise)继续往下传最终到Head时Head对ByteBuf和FileRegion以外的Java对象没有处理能力它会把对象直接丢弃甚至可能抛异常。所以出站时必须保证消息类型能被Pipeline中的某个OutboundHandler处理。3.5 业务Handler怎么处理入站业务并触发回写业务Handler是最容易写糊涂的地方我放一个相对标准的写法public class DeviceBusinessHandler extends ChannelInboundHandlerAdapter { Override public void channelRead(ChannelHandlerContext ctx, Object msg) { if (!(msg instanceof DevicePacket)) { ctx.fireChannelRead(msg); // 不是我的菜往下传 return; } DevicePacket packet (DevicePacket) msg; // 模拟业务处理根据payload内容构造响应 DevicePacket ack buildAckPacket(packet); // 注意这里用的是ctx.writeAndFlush不是channel().writeAndFlush ctx.writeAndFlush(ack); } private DevicePacket buildAckPacket(DevicePacket packet) { byte[] payload ACK.getBytes(StandardCharsets.UTF_8); return new DevicePacket(0x5A5A, payload.length, payload); } }两个容易翻车的地方第一ctx.writeAndFlush与ctx.channel().writeAndFlush的区别。前者从当前Handler的下一个节点开始逆流执行出站Handler后者从Pipeline尾部开始逆流执行。如果当前Handler不在Pipeline尾部结果可能完全一样因为channel().writeAndFlush本质上也是从Tail开始。差别在于如果你重写了当前Handler的write方法ctx.writeAndFlush不会再次触发当前Handler自身的出站方法而channel().writeAndFlush会。这点细微差别在复杂Pipeline里能造成死循环务必记住在Handler内部推荐使用ctx.writeAndFlush。第二不要把耗时的业务逻辑直接扔在channelRead里。Netty的EventLoop是单线程的一个Channel上的所有事件都由同一个EventLoop线程处理。如果你在channelRead里执行了阻塞操作比如查数据库、调远程接口那么这个线程上所有其他Channel的读写都会遭殃。正确的做法是把耗时操作提交到业务线程池处理完再通过ctx.executor()或channel.eventLoop()切回I/O线程继续写数据。切线程的时候要小心不能在业务线程里直接拿着ChannelHandlerContext调用writeAndFlush这样虽然线程安全但EventLoop里的事件顺序可能被打乱。我在实际项目中用了一个简单模式业务线程池处理完后channel.eventLoop().execute(() - ctx.writeAndFlush(result))把回写动作放回I/O线程执行。3.6 测试方法EmbeddedChannel才是王道写完代码不能光靠肉眼检查一定要跑测试。Netty提供了EmbeddedChannel它不需要真实端口在内存里模拟完整的入站出站流程是验证Handler链逻辑的最好工具。Test void testDevicePacketHandlerChain() { EmbeddedChannel channel new EmbeddedChannel( new DevicePacketDecoder(), new DevicePacketEncoder(), new DeviceBusinessHandler() ); // 模拟入站先塞半个包看它是否憋住 ByteBuf halfPacket Unpooled.buffer(); halfPacket.writeShort(0x5A5A); halfPacket.writeShort(10); halfPacket.writeByte(0x01); // 只写了1个字节还差9个 assertFalse(channel.writeInbound(halfPacket)); // 再塞剩下的9个字节此时应该解析出完整包 ByteBuf restPacket Unpooled.buffer(); for (int i 0; i 9; i) { restPacket.writeByte(i); } assertTrue(channel.writeInbound(restPacket)); // 验证出站方向是否有ACK回写 Object outbound channel.readOutbound(); assertTrue(outbound instanceof ByteBuf); ByteBuf ackBuf (ByteBuf) outbound; assertEquals(0x5A5A, ackBuf.readUnsignedShort()); channel.finish(); }这段测试里有个容易忽视的点writeInbound的返回值代表“是否向Pipeline里关联了新的可读数据”。半包时我们返回了false因为没解析出完整消息out列表为空全包时返回true。掌握了这个语义你就能非常精细地测试粘包拆包逻辑。用EmbeddedChannel还有一个好处它天然规避了真实网络的不确定性出问题一定是代码逻辑的问题排查成本低得多。等这条链路在单测里验证稳了再启动真实端口联调效率会高很多。4. 常见问题与排查技巧实录4.1 Handler“不生效”的三大原因我接到过无数咨询最典型的一句话是“我明明加了Handler但代码一次都没执行”。这类问题通常出在下面几个地方当入站事件到达某个Handler时前一个Handler没有调用ctx.fireChannelRead()。这是最典型的“断链”。解码器解析出消息后如果你在decode方法的out列表里加了对象ByteToMessageDecoder框架会自动调用fireChannelRead但你自定义的入站Handler如果想把消息传给下一个必须自己调ctx.fireChannelRead(msg)忘了调就是断链。Handler类型放错了方向。你把入站逻辑写进了ChannelOutboundHandler入站事件根本不经过它自然不执行。Pipeline被动态修改了。比如某个Handler在handlerAdded里又addLast了一个新Handler此时如果EventLoop正在遍历Pipeline可能会造成事件顺序错乱甚至ConcurrentModificationException。Netty允许你在I/O线程中动态增减Handler但要注意不要在回调尚未完成时反复修改同一位置。排查方法很简单在怀疑不生效的Handler的每个方法入口打印一行日志从channelRegistered到channelRead全打一遍。看到底断在哪一环。4.2 出站消息乱序与“写出去的是错的编码”出站方向逆序执行导致的问题往往表现为你期望EncoderA先处理结果EncoderB先跑了。比如一个Pipeline配置了加密Handler和编码Handlerpipeline.addLast(new PacketEncoder()); // 编码成明文 pipeline.addLast(new EncryptHandler()); // 加密数据从业务Handler写出来时逆流先经过后者添加的EncryptHandler加密再往前到PacketEncoder编码成明文。如果业务Handler写入的是一串明文加密Handler拿到明文加密然后编码器把它按明文协议编码对端解密出来就是一堆乱码。正确顺序应该反过来先编码明文包再对整个包加密。所以EncryptHandler应该放在PacketEncoder的前面先添加出站时才后执行。这个例子很好地说明了Pipeline的添加顺序本质上是入站顺序也是出站逆序。你在设计Handler链时不能只顺着入站思路想要拿一张纸画出两条方向的顺序图。尤其是编解码、加密、压缩这类包装型Handler方向错了轻则数据错乱重则直接把连接发崩。4.3 数据发不出去write了却不flush“客户端收不到数据但服务端日志显示已经write了”这类问题八成是没有flush。有些人习惯用ctx.write()攒批量最后忘记调ctx.flush()。在高并发推送场景下攒批量确实是个性能优化手段但每批数据结束后必须主动flush。我是这么做的如果单条消息单独发用writeAndFlush一句话搞定如果是批量推送用write攒N条再flush并且在通道关闭前、EventLoop退出前确保有个兜底flush。你还可以在channelWritabilityChanged回调里做背压处理当Channel不可写时停止生产数据恢复可写时继续推送否则积压的OutboundBuffer会把内存撑爆。4.4 EventLoop线程阻塞与超时假象事故现场是这样的某网关服务在某个时间点开始所有设备的响应全部超时但CPU占用很低连接也没断。最后定位发现某个Handler的channelRead里调了一个同步的第三方接口超时时间设了30秒结果那个接口当时故障了EventLoop线程被阻塞了30秒。由于一台机器上几百个Channel共享同一个EventLoop线程这30秒里所有Channel的读写全部卡住。Netty的线程模型决定了EventLoop是串行执行任务的。I/O线程不是万能的多线程池它只适合快速计算和状态变更。凡是可能阻塞的操作一律丢到独立的线程池。判断标准很粗暴一个方法执行时间超过1毫秒就值得怀疑它该不该待在channelRead里。尤其是反序列化超大数据、写磁盘、调RPC、拿锁等绝对不要放在I/O线程上。4.5 ByteBuf内存泄漏的排查路径Netty对ByteBuf泄漏有完善的检测机制。启动时加上-Dio.netty.leakDetection.levelparanoid生产环境用advanced就好当发生泄漏时日志会打出对象创建位置也就是“最后一次被retain的位置”。这个信息非常有用它能直接定位到是哪个Handler没有释放消息。ByteBuf的释放我建议记住前面提到的那句话谁最后消费谁负责释放。最难查的泄漏往往是“开发了一个通用过滤器把ByteBuf转发给子线程做异步处理而子线程最终忘了release”。这种情况下你在子线程处理完以后必须主动ReferenceCountUtil.release(msg)。如果逻辑需要跨线程传递最好把ByteBuf复制一份再传尤其是原buf还要继续走Pipeline的情况下千万不要让两个线程同时读写同一个ByteBuf。另外说一个排查技巧在异常分支里也必须保证释放逻辑。比如channelRead里对数据做了校验发现非法报文你直接ctx.close()转身走了那个还没释放的ByteBuf就漏了。正确写法是所有分支都要走到release或fireChannelRead没有第三条路。4.6 动态修改Pipeline的注意事项动态修改Pipeline的典型场景是根据设备认证结果在认证通过后往Pipeline里加一个处理业务数据包的Handler或者移除一个限流Handler。这里有几个经验只在I/O线程里修改Pipeline。业务线程想改必须通过channel.eventLoop().execute()提交否则你可能拿到一个不一致的Pipeline视图。修改完调用ctx.pipeline().fireChannelReadComplete()或fireUserEventTriggered()让后续Handler感知到Pipeline状态变化。如果某个Handler要移除自身可以在方法里直接ctx.pipeline().remove(this)但要注意handlerRemoved里有没有需要清理的资源。动态Pipeline是Netty的强大之处也是滥用最容易引发隐性Bug的地方。我个人的原则是少动动就要想清楚。能用静态Pipeline解决的业务不要随便动态增减。5. 最后聊点实战体会写完这套东西我自己最大的感受是Netty的Handler机制一旦想通了方向后面所有问题都迎刃而解。你在读代码、写代码时先问一句“我现在处理的是进来的数据还是出去的数据”然后再决定用哪个接口、调哪个方法基本不会跑偏。还有一个小技巧分享给后来者调试Handler链的时候别急着加复杂日志框架先在每个Handler重写所有事件方法加一行System.out.println把channelId、事件名、threadName打出来。跑一个最小Demo你会非常直观地看到数据是怎么走完整个Pipeline的。等你完全看明白了再把这些调试代码删掉换成正式逻辑。这套笨办法比我后来用任何高端Profiler都管用。Handler链其实很像生产线——入站是来料加工出站是成品出货每道工序各司其职方向不能乱废料不能留。把这些规则刻在脑子里不管是写新服务还是查线上故障你就已经比大多数人少踩一半的坑了。
阅读完成 · 觉得有帮助?