news 2026/5/16 15:40:06

深度解析Java NIO与Tars框架网络通信模型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度解析Java NIO与Tars框架网络通信模型

1. 项目概述:从NIO到Tars,一次网络编程的深度解构

如果你是一名Java后端开发者,或者对分布式微服务架构感兴趣,那么“高性能RPC框架”这个词对你来说一定不陌生。在众多选择中,腾讯开源的Tars框架因其在内部历经十余年、支撑海量业务(如每日百亿级推送)的实战考验而备受关注。今天,我们不谈宏观架构,而是聚焦于一个更底层、更核心的模块:网络通信。在Tars-Java 1.7.2及之前的版本中,其网络编程的基石正是Java NIO。很多人听说过NIO,知道它“非阻塞”、“高性能”,但真正能说清楚其原理,并能在一个成熟框架的源码中追踪其脉络的,却不多。本文将带你深入Tars-Java的源码腹地,亲手拆解它如何运用NIO构建起高并发的通信骨架。这不仅是一次源码阅读,更是一次对Java网络编程核心思想的实战演练。无论你是想深入理解Tars,还是希望夯实自己的网络编程功底,这篇文章都将提供一条清晰的路径。

2. 基石探秘:Java NIO的核心原理与工作机制

在深入Tars的源码之前,我们必须先打好地基,彻底理解Java NIO这套API的设计哲学和运作机制。很多开发者对NIO的认知停留在“非阻塞”和“Selector”这几个关键词上,但这远远不够。要读懂Tars的网络模型,我们需要从更本质的层面去理解NIO是如何重新组织IO操作的。

2.1 范式转换:从流到通道与缓冲区

传统的Java IO(或称BIO)是面向流的(Stream-Oriented)。你可以把流想象成一根单向流动的水管,数据像水一样一个字节一个字节地顺序通过。要读取数据,你需要从输入流中一个字节一个字节地读;要写入数据,也需要一个字节一个字节地写。这种模型简单直观,但有一个致命问题:IO操作是阻塞的。当线程从流中读取数据时,如果数据还没有准备好,线程就会被挂起,直到数据到达。这在处理大量并发连接时,会迅速耗尽线程资源。

NIO则引入了全新的抽象:通道(Channel)和缓冲区(Buffer)。这是一种面向块(Block-Oriented)或面向缓冲区(Buffer-Oriented)的IO。

  • 通道(Channel):可以看作是双向的“管道”,它既可以从通道读取数据到缓冲区,也可以将缓冲区中的数据写入通道。通道本身并不直接持有数据,它只负责传输。关键的Channel类型包括用于文件操作的FileChannel、用于TCP网络通信的SocketChannelServerSocketChannel,以及用于UDP的DatagramChannel。在Tars的上下文中,我们主要关注后三者。
  • 缓冲区(Buffer):一个容器对象,用于临时存储数据。所有数据的读写都必须通过缓冲区进行。你可以把它想象成一个数据中转站。NIO为每种基本Java类型(除了Boolean)都提供了对应的Buffer类,最常用的是ByteBuffer

这个转变的核心优势在于,IO操作的单位从字节变成了缓冲区块。应用程序可以一次性将一大块数据读入缓冲区,或者从缓冲区一次性写出一大块数据,减少了系统调用的次数,提升了效率。更重要的是,通道可以被配置为非阻塞(Non-blocking)模式。

实操心得:Buffer的状态切换是初学者的第一个坑。Buffer有三个关键属性:capacity(容量)、position(位置)、limit(限制)。在写模式下,position表示当前写入位置,limit等于capacity。调用flip()方法切换到读模式后,limit被设置为之前position的值(即写入的数据量),position被重置为0。读完数据后,调用clear()会清空整个缓冲区(position=0, limit=capacity),为下次写入做准备;而compact()则会将未读的数据移动到缓冲区起始处,然后将position设在这些数据之后,limit设为capacity,适合处理“半包”数据。在Tars的源码中,你会频繁看到flip()compact()的身影。

2.2 核心引擎:Selector(选择器)与多路复用

非阻塞通道解决了线程被单个连接阻塞的问题,但带来了新的挑战:如何高效地管理成百上千个非阻塞通道?难道要启动一个无限循环,不停地遍历所有通道,询问它们“有数据吗?”(即忙等待,Busy Waiting)?这显然会浪费大量CPU资源。

Selector(选择器)就是解决这个问题的答案。Selector允许一个单独的线程监视多个Channel的IO事件(如连接就绪、读就绪、写就绪)。这是IO多路复用(I/O Multiplexing)技术在Java中的实现。

其工作流程可以概括为:

  1. 注册:将一个或多个通道注册到一个Selector上,并指定你感兴趣的事件(SelectionKey.OP_ACCEPT,OP_CONNECT,OP_READ,OP_WRITE)。
  2. 选择:调用Selector的select()方法。这个方法会阻塞,直到至少有一个注册的通道在你感兴趣的事件上就绪。它也可以设置超时(select(long timeout)),或者立即返回(selectNow())。
  3. 处理select()方法返回后,可以通过selectedKeys()方法获取一个已就绪的SelectionKey集合。每一个SelectionKey代表一个通道和其就绪事件的绑定。遍历这个集合,根据key的状态(isAcceptable(),isReadable()等)进行相应的IO操作。
  4. 取消:处理完毕后,需要将处理过的SelectionKey从已选择键集中移除(通常通过迭代器的remove()方法),否则下次select()时它还会出现。

SelectionKey对象本身是一个信息丰富的载体:

  • interestOps(): 获取你感兴趣的事件集合。
  • readyOps(): 获取通道已准备就绪的事件集合。
  • channel(): 获取关联的Channel。
  • selector(): 获取关联的Selector。
  • attachment(): 一个非常有用的附加对象。你可以将任何对象(比如一个会话Session、一个协议解码器)附着到SelectionKey上,在处理事件时直接取出使用,避免了通过Channel去查找的麻烦。Tars框架大量使用了这个特性来传递Session对象。

注意事项:OP_WRITE事件的使用。这是一个容易误解的事件。在绝大多数情况下,一个通道的写缓冲区都是可写的,因此OP_WRITE几乎总是就绪的。如果你注册了OP_WRITE事件,select()方法可能会立即返回并疯狂地通知你“可写”,导致CPU空转。正确的做法是:仅在尝试写入但一次未能写完所有数据(即发生“写半包”)时,才注册OP_WRITE兴趣集。当可写事件触发,你将剩余数据写入后,应立即取消对OP_WRITE的关注。Netty和Tars都遵循这一原则。

3. Tars的网络模型:多Reactor多线程的工程实现

理解了NIO的基础,我们就可以开始解剖Tars的网络层了。Tars-Java 1.7.2版本采用的是一种经典的多Reactor多线程模型。这个模型是对Doug Lea在《Scalable IO in Java》中提出的模式的实践,旨在平衡连接处理、IO读写和业务逻辑执行的资源,达到高并发和高吞吐量的目标。

3.1 模型总览与核心类关系

在Tars中,SelectorManager是整个网络层的总管。它的核心是管理一组Reactor线程。每个Reactor线程内部都运行着一个Selector(多路复用器),负责监听注册到它上面的所有Channel的IO事件。

这里有一个Tars实现的特殊点:它的Reactor线程不仅负责监听事件(如OP_ACCEPT,OP_READ),事件触发后的IO操作(如channel.read(),channel.write())也由同一个Reactor线程完成。这与有些模型(如Netty)将IO读写也剥离到Worker线程池的做法不同。Tars的Reactor线程更像是一个“IO工作者”。

那么,耗时的业务逻辑处理在哪里进行呢?答案是业务线程池。当Reactor线程完成数据包的读取和协议解码,得到一个完整的请求(Request)或响应(Response)对象后,会将其封装成一个任务(WorkThread),提交给一个独立的ThreadPoolExecutor(业务线程池)去执行。这样就实现了IO处理与业务计算的分离。

核心类的协作关系可以简化为下图(文字描述):

  • SelectorManager: 持有Reactor数组和业务ThreadPool
  • Reactor: 继承Thread,内部运行Selector循环。持有Acceptor(如TCPAcceptor)用于处理具体事件。
  • Acceptor: 根据事件类型(OP_ACCEPT/OP_READ等)调用Session的相应方法。
  • Session(如TCPSession): 封装了一个网络连接(SocketChannel)的状态、读写缓冲区(IoBuffer)和消息队列。是IO读写的实际执行者。
  • WorkThread: 一个Runnable任务,内部持有解码后的RequestResponse,在业务线程池中执行,最终调用服务端的具体方法或处理客户端响应。

3.2 服务端启动与监听流程源码追踪

让我们从服务端的启动入口开始,看看一个Tars服务是如何在NIO基础上建立起来的。关键代码位于ServantAdapterbind方法中。

第一步:创建SelectorManager。这是网络引擎的初始化。线程池大小的计算策略是processors > 8 ? 4 + (processors * 5 / 8) : processors + 1,其中processors是JVM可用的处理器核心数。这个公式旨在根据CPU核心数合理分配Reactor线程数量,在核心数较多时(>8),避免线程数线性增长,寻求一个性能平衡点。线程名前缀被设置为”server-tcp-reactor”

第二步:启动Reactor线程。调用selectorManager.start(),这会创建并启动所有Reactor线程,每个线程进入我们将在3.4节分析的run()循环。

第三步:开启监听。创建ServerSocketChannel,绑定到指定的IP和端口。这里有一个重要参数:backlog,在代码中硬编码为1024。这个参数指定了TCP连接请求队列的最大长度。当服务器繁忙不能立即接受新连接时,新连接会在这个队列中等待。设置一个合理的值(如1024)有助于应对瞬间的连接洪峰。

第四步:注册ACCEPT事件。ServerSocketChannel配置为非阻塞模式,并注册到SelectorManager的第一个Reactor线程(索引0)的Selector上,关注OP_ACCEPT事件。这意味着这个Reactor线程将专门负责接受新的客户端连接。

// 代码片段示意,非完整源码 serverChannel.configureBlocking(false); selectorManager.getReactor(0).registerChannel(serverChannel, SelectionKey.OP_ACCEPT);

至此,服务端进入监听状态,等待客户端连接。

3.3 客户端发起请求的链路解析

当客户端通过Communicator调用服务时,网络层的初始化同样围绕SelectorManager展开。

第一步:创建代理与通信器。通过Communicator.stringToProxy()获取服务代理,背后会创建ObjectProxy(对象代理)和ServantClient(服务客户端)。

第二步:获取或创建SelectorManager。在初始化ServantClient时,会通过ClientPoolManager.getSelectorManager()获取一个SelectorManager实例。如果是首次调用,则会创建。注意,客户端的SelectorManager的线程池大小默认是2(selectorPoolSize),线程名前缀是”servant-proxy-“加上一个唯一ID。这意味着所有客户端的出站连接,默认共享一个由两个Reactor线程管理的Selector组。

第三步:启动Reactor线程。和服务端一样,客户端的SelectorManager也会启动其管理的所有Reactor线程。

当具体请求发生时,ServantClient会选择一个Reactor线程,将代表连接的SocketChannel(配置为非阻塞)注册上去,关注OP_CONNECT事件,然后发起异步连接。连接建立后,兴趣集会被修改为OP_READ,以监听服务器返回的响应。

3.4 Reactor线程的事件循环:心脏的跳动

Reactor.run()方法是整个网络模型驱动力的来源。它是一个经典的NIO事件循环,但加入了Tars特有的任务队列管理。我们来逐行分析其核心流程(对应源码中的代码5):

  1. selector.select(): 线程在此阻塞,等待注册的Channel上有IO事件发生。这是性能的关键,线程在此休眠,不消耗CPU。
  2. processRegister(): 处理注册队列。因为Channel.register(selector, …)方法本身是阻塞的,且可能不是线程安全的(如果其他线程尝试注册)。Tars的解决方案是:将需要注册的Channel任务先放入一个队列,由Reactor线程本人在其事件循环中统一、安全地处理。这是一种常见的模式。
  3. 遍历selectedKeys(): 获取所有就绪的事件键集,并进行迭代处理。这里有一个至关重要的操作:iter.remove()必须将处理过的SelectionKey从已选择键集中移除,否则下次循环它还会被选中,导致重复处理。
  4. 更新会话时间:如果SelectionKey上附加了Session对象,则更新其最后操作时间。这用于后续的连接保活和超时管理。Tars有一个独立的SessionManager线程,每30秒扫描一次所有会话,关闭超过60秒未活动的空闲连接。
  5. dispatchEvent(key): 这是事件分发的核心。根据SelectionKey的就绪事件类型,调用对应的Acceptor(如TCPAcceptor)的handleXXXEvent方法。
  6. processUnRegister(): 与注册队列类似,处理需要注销的Channel队列,将其从Selector上取消注册。

这个循环周而复始,驱动着整个网络IO的运转。

3.5 IO事件的分发与处理逻辑

事件的分发由Acceptor(如TCPAcceptor)完成。我们来看最重要的三种事件处理:

1. 处理OP_ACCEPT(服务端)当监听端口的ServerSocketChannel就绪时,意味着有新的客户端连接到来。

  • 调用server.accept()接受连接,获得代表这个新连接的SocketChannel
  • 为此连接创建一个TCPSession对象,用于管理这个连接的生命周期、缓冲区和状态。此时会话状态设为SERVER_CONNECTED
  • 将新创建的Session注册到全局的SessionManager中进行管理。这里会检查当前总连接数是否超过配置的maxconns,如果超过则直接关闭连接,这是一种简单的连接数保护。
  • 关键的一步:将这个新创建的SocketChannel(以及附着的Session)注册到下一个Reactor线程的Selector上,关注OP_READ事件。这里使用了selectorManager.nextReactor()方法进行轮询分配。这样做的目的是将新连接的读写负载均匀地分摊到所有Reactor线程上,避免第一个Reactor线程(负责Accept)成为瓶颈。这是实现“多Reactor”的关键。

2. 处理OP_CONNECT(客户端)当客户端发起的异步连接建立成功时触发。

  • 获取客户端的SocketChannel和附着的Session
  • 调用channel.finishConnect()完成连接过程。
  • 将兴趣操作集从OP_CONNECT改为OP_READ,因为连接已建立,接下来就是等待读取服务器返回的响应。
  • Session状态更新为CLIENT_CONNECTED

3. 处理OP_READ / OP_WRITE这两种事件的处理相对直接,都委托给了Session对象。

  • handleReadEvent: 调用session.read()方法,从Channel中读取数据到Session内部的缓冲区,并进行后续的解码。
  • handleWriteEvent: 调用session.doWrite()方法,将Session内部写队列中的数据写入Channel。

4. 数据流转的枢纽:Session的读写处理细节

Session(特别是TCPSession)是连接状态和数据缓冲的实际管理者。理解了它的读写逻辑,就理解了Tars如何处理TCP的粘包/拆包以及如何与业务线程交互。

4.1 读事件处理:从字节流到协议对象

session.read()方法是读事件处理的入口。它主要做两件事:从物理链路读取数据,然后根据会话角色(客户端/服务端)进行逻辑处理。

物理读取 (readChannel)

  1. 创建一个临时的2KB大小的ByteBuffer(代码中为1024 * 2)。
  2. 循环调用((SocketChannel) channel).read(tempBuffer),将Channel中的数据读入临时Buffer。这里使用循环是为了尽可能一次多读。
  3. 每次读满一个临时Buffer后,调用flip()切换到读模式,然后将数据追加到Session的成员变量readBuffer(一个自定义的IoBuffer,可自动扩容)中。
  4. 读取完成后,返回读取的字节数或错误码。

逻辑处理根据session.status判断当前是客户端还是服务端:

  • 客户端 (CLIENT_CONNECTED): 调用readResponse()。它从readBuffer中复制数据(duplicate().flip())到一个临时Buffer进行协议解码。Tars使用自己的TarsCodec解码器,尝试从二进制数据中解析出一个完整的Response对象。如果解析成功,就创建一个WorkThread任务,丢给业务线程池去处理这个响应(例如唤醒等待的调用线程)。如果数据不足以构成一个完整响应包(拆包),则重置Buffer,等待下次读事件。
  • 服务端 (SERVER_CONNECTED): 调用readRequest()。流程与客户端类似,解码出Request对象后,提交给业务线程池。线程池的WorkThread会最终调用服务端本地的方法实现。这里有一个重要的保护机制:如果业务线程池已满,抛出了RejectedExecutionException,框架会调用processor.overload()方法,向客户端返回一个SERVEROVERLOAD(服务器过载)的错误码,而不是任由请求堆积导致雪崩。

避坑技巧:粘包与拆包的处理。这是所有基于流的协议(如TCP)都必须面对的问题。Tars的解决方式体现在解码器decodeRequest/decodeResponse中。Tars协议在报文头部包含了数据包长度字段。解码时,先检查readBuffer中剩余的数据是否足够解析出头部和长度。如果不够(拆包),就返回null,等待下次数据到来。如果足够,就根据长度字段读取指定字节的数据体。如果数据体也完整读取,就成功构造一个请求/响应对象;如果数据体不完整(也属于拆包),同样返回null。这种基于长度字段的定长解码方式是处理TCP粘包最常用、最高效的方法之一。

4.2 写事件处理:异步化的发送队列

写操作的设计体现了异步和非阻塞的思想。在Tars中,发送数据并不是直接调用channel.write()

写入流程无论是客户端发送请求,还是服务端发送响应,最终都会调用session.write(IoBuffer buffer)方法。

  1. 该方法首先尝试将待发送数据(已编码为ByteBuffer)放入Session内部的一个LinkedBlockingQueue中。这个队列是有界的,默认大小是8192(8K)。
  2. 如果队列已满,写入会立即失败并抛出IOException。这起到了背压(Backpressure)作用,防止生产者速度过快压垮消费者。
  3. 如果入队成功,则修改关联的SelectionKey的兴趣集,为其添加OP_WRITE关注。然后立即调用key.selector().wakeup()唤醒可能阻塞在select()上的Reactor线程。这是为了尽快触发写就绪事件,将数据发送出去。

写出流程当Channel的写缓冲区可写(OP_WRITE事件触发)时,TCPAcceptor.handleWriteEvent会调用session.doWrite()

  1. doWrite()方法会从上述队列中取出ByteBuffer。
  2. 循环调用channel.write(buffer),尝试将数据写入操作系统内核的发送缓冲区。
  3. 如果一次没有写完(写半包),会将剩余的ByteBuffer重新放回队列(或缓存起来),并保持对OP_WRITE的关注,等待下次可写事件。
  4. 如果全部写完,则会取消对OP_WRITE的关注key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE)),避免不必要的空转。

这种“队列缓存 + 事件驱动”的写模型,将同步的写操作转化为异步的、由事件触发的操作,非常契合NIO的非阻塞哲学。

5. 从NIO到Netty:演进、对比与实战启示

在最新的Tars-Java master分支中,网络层已经从Java原生NIO迁移到了Netty。这是一个符合技术发展趋势的选择。Netty在NIO的基础上,提供了更高级、更完善的抽象,例如:

  • 更强大的ByteBuf:替代ByteBuffer,提供更灵活的读写API、池化内存管理,性能更好。
  • Pipeline和Handler机制:将协议编解码、业务处理等逻辑组织成清晰的职责链,大大提升了代码的可维护性和可扩展性。
  • 更完善的生命周期管理和异常处理
  • 丰富的内置编解码器和工具类

那么,为什么我们还要花如此大的精力去研究基于原生NIO的实现呢?

第一,掌握原理是应对复杂问题的根基。Netty再强大,其底层依然是Java NIO(在大多数传输层上)。当你遇到深层次的网络问题、需要定制极其底层的协议、或者追求极致的性能调优时,对NIO原理的深刻理解是无可替代的。它让你能看懂Netty在做什么,甚至能理解其某些设计选择的初衷。

第二,Tars 1.7.2的NIO实现是一个优秀的教学案例。它没有使用任何第三方网络库,完整地展示了一个生产级RPC框架如何用原生NIO构建多Reactor多线程模型。从Selector的管理、Session的设计、到粘包拆包的处理、读写事件的异步化,每一个环节都清晰可见。阅读这份源码,就像在观摩一位经验丰富的架构师如何用基础工具搭建一座坚固的大厦。

第三,在资源受限或追求极致轻量的场景下,原生NIO仍有价值。虽然Netty是主流,但其本身也有一定的体积和复杂度。在某些嵌入式环境或对启动速度、内存占用有极端要求的场景,一个精心打磨的原生NIO实现可能更合适。

个人在实际学习和使用中的体会是:先通过类似Tars NIO实现这样的代码,把NIO的“筋骨”摸清楚,理解事件循环、缓冲区、多路复用这些核心概念是如何协同工作的。然后再去学习Netty,你会发现自己是在“俯视”它,能清晰地看到Netty的各个组件(EventLoop, Channel, Pipeline)是如何对应和优化了原生NIO的那些粗糙部分。这个过程,会让你的网络编程知识体系变得异常扎实。

最后,如果你打算在自己的项目中借鉴或参考Tars的NIO设计,有几个点需要特别注意:

  1. 线程安全:注意registerunregister队列的并发访问,Tars使用了synchronized块进行保护。在更复杂的场景下,可能需要考虑更高效的无锁队列。
  2. 资源释放:确保在所有路径上(正常关闭、异常关闭)都正确关闭Channel、取消SelectionKey并从Selector中注销。Tars在disConnectWithException等方法中有相关处理,但自己实现时需要格外小心内存和连接泄漏。
  3. 性能调优:Buffer的大小(如Tars中读临时Buffer的2KB)、写队列的长度(8K)、Reactor线程的数量,都需要根据实际业务流量和硬件配置进行测试和调整。
  4. 错误处理:网络环境是不稳定的。Tars在dispatchEvent的外层包裹了try-catch Throwable,并在异常时调用disConnectWithException来清理连接。你的实现也必须具备完善的异常处理和连接恢复机制。

通过这次对Tars-Java 1.7.2网络模块的源码分析,我们不仅看到了一个高性能RPC框架的网络层是如何构建的,更完成了一次对Java NIO编程的深度巡礼。从Channel/Buffer的基础,到Selector的多路复用,再到Reactor模型的工程化实践,最后到具体的数据读写和协议处理,这条链路清晰地展示了一个理论如何一步步落地为稳定可靠的代码。无论你未来是使用Netty还是其他网络库,这份对底层原理和设计模式的理解,都将是你技术工具箱里最宝贵的财富之一。

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

汉森软件冲刺港股:年营收6亿 净利1.4亿 已获IPO备案

雷递网 雷建平 5月15日深圳市汉森软件股份有限公司(简称:“汉森软件”)日前更新招股书,准备在港交所上市。汉森软件已获IPO备案,拿到了上市的钥匙,同期一并拿到备案的企业还包括南京海纳医药科技股份有限公…

作者头像 李华
网站建设 2026/5/16 15:35:07

开源直播推流工具clawstage:模块化设计与核心实现解析

1. 项目概述:从“ClawStage”看开源直播推流工具的设计哲学最近在折腾直播推流方案时,我偶然发现了HooRii-OT团队在GitHub上开源的项目“clawstage”。这个项目名字挺有意思,“claw”是爪子,“stage”是舞台,合起来有种…

作者头像 李华
网站建设 2026/5/16 15:32:53

RDMA UD通信避坑指南:手把手教你理解与配置Address Handle (AH)

RDMA UD通信避坑指南:手把手教你理解与配置Address Handle (AH) 在分布式计算和存储系统中,RDMA(远程直接内存访问)技术因其极低的延迟和CPU开销而备受青睐。其中不可靠数据报(UD)服务类型因其无连接特性&…

作者头像 李华
网站建设 2026/5/16 15:32:03

SAP生产订单成本‘还原’实战:从标准成本到实际费用,一步步拆解差异来源(附物料分类账提示)

SAP生产订单成本差异深度解析:从标准成本到实际费用的全链路追踪 在制造业财务管理的日常工作中,生产订单成本差异分析是每月结账后的必修课。当财务总监皱着眉头询问"为什么A产品线成本超支了15%"时,仅靠标准报表上的几个差异数字…

作者头像 李华
网站建设 2026/5/16 15:31:05

【Java用法】jar包运行后显示 没有主清单属性

jar包运行后显示 没有主清单属性一、问题现象二、问题分析三、解决方案3.1 添加 spring-boot-maven-plugin 插件3.2 修改 spring-boot 父级依赖3.3 配置IDEA开发工具一、问题现象 jar包运行后显示 没有主清单属性!如下图所示: 前些天发现了一个特别好用…

作者头像 李华