[关闭]
@adamhand 2019-04-06T10:22:42.000000Z 字数 17079 阅读 1389

netty源码阅读--客户端启动


netty版本

Bootstrap

Bootstrp用于netty客户端和服务端的初始化。下面从一个例子开始阅读源码。代码路径为:netty-4.1\example\src\main\java\io\netty\example\echo

客户端

下面是客户端启动的关键代码:

  1. EventLoopGroup group = new NioEventLoopGroup();
  2. try {
  3. Bootstrap b = new Bootstrap();
  4. b.group(group)
  5. .channel(NioSocketChannel.class)
  6. .option(ChannelOption.TCP_NODELAY, true)
  7. .handler(new ChannelInitializer<SocketChannel>() {
  8. @Override
  9. public void initChannel(SocketChannel ch) throws Exception {
  10. ChannelPipeline p = ch.pipeline();
  11. if (sslCtx != null) {
  12. p.addLast(sslCtx.newHandler(ch.alloc(), HOST, PORT));
  13. }
  14. //p.addLast(new LoggingHandler(LogLevel.INFO));
  15. p.addLast(new EchoClientHandler());
  16. }
  17. });
  18. // Start the client.
  19. ChannelFuture f = b.connect(HOST, PORT).sync();
  20. // Wait until the connection is closed.
  21. f.channel().closeFuture().sync();
  22. } finally {
  23. // Shut down the event loop to terminate all threads.
  24. group.shutdownGracefully();
  25. }

客户端初始化的基本过程如下:

Channel建立过程

在 Netty 中, Channel 是一个 Socket 的抽象, 它为用户提供了关于 Socket 状态以及对 Socket 的读写等操作。这里新建的是一个 NioSocketChannel,它的继承关系图如下图所示:



下面看一下channel()方法:

  1. public B channel(Class<? extends C> channelClass) {
  2. if (channelClass == null) {
  3. throw new NullPointerException("channelClass");
  4. }
  5. return channelFactory(new ReflectiveChannelFactory<C>(channelClass));
  6. }

这个方法返回了一个ReflectiveChannelFactory类型的ChannelFactory,由名字可以看出,这是一个工厂类,用于生产Channel,ReflectiveChannelFactory中用于产生Channel的函数为newChannel,它的逻辑如下:

  1. public T newChannel() {
  2. try {
  3. return constructor.newInstance();
  4. } catch (Throwable t) {
  5. throw new ChannelException("Unable to create Channel from class " + constructor.getDeclaringClass(), t);
  6. }
  7. }

不过此时这个方法还没有调用,而是等到下面使用BootStrap.connet()方法的时候才会调用,这里只是生成了一个Channel工厂。

当调用BootStrap.connet()方法时,才真正开始实例化一个Channel,它的引用链为:

  1. Bootstrap.connect -> Bootstrap.doResolveAndConnect -> AbstractBootstrap.initAndRegister

initAndRegister的关键语句为:

  1. channel = channelFactory.newChannel();
  2. init(channel);
  3. ChannelFuture regFuture = config().group().register(channel);

newChannel()方法就是刚才所说的实例化Channel的方法,它的关键逻辑如下:

  1. return constructor.newInstance();

这里的constructor是Constructor的一个实例,Constructor的作用是调用相应类的构造方法对对象进行初始化,constructor的实例代码如下:

  1. private final Constructor<? extends T> constructor;

而在b.group(group).channel(NioSocketChannel.class)时传入的是NioSocketChannel.class,所以,这里其实是调用的NioSocketChannel的构造方法进行的初始化,该构造方法如下:

  1. public NioSocketChannel() {
  2. this(DEFAULT_SELECTOR_PROVIDER);
  3. }

该构造方法依次调用以下构造方法1、2、3,在构造方法1中会进行newSocket操作,在构造方法3中显式调用了父类AbstractNioByteChannel的构造函数4。构造函数4会继续调用父类AbstractNioChannel的构造函数5。然后继续调用父类AbstractChannel的构造函数6。构造函数6中进行了两个比较重要的操作:unsafe = newUnsafe();pipeline = newChannelPipeline();,这个在后面详细看。

newSocket()操作是先于父类构造函数执行的,也就是说,执行顺序是:

  1. //构造方法1
  2. public NioSocketChannel(SelectorProvider provider) {
  3. this(newSocket(provider));
  4. }
  5. //构造方法2
  6. public NioSocketChannel(SocketChannel socket) {
  7. this(null, socket);
  8. }
  9. //构造方法3
  10. public NioSocketChannel(Channel parent, SocketChannel socket) {
  11. super(parent, socket);
  12. config = new NioSocketChannelConfig(this, socket.socket());
  13. }
  14. //构造函数4
  15. protected AbstractNioByteChannel(Channel parent, SelectableChannel ch) {
  16. super(parent, ch, SelectionKey.OP_READ);
  17. }
  18. //构造函数5
  19. protected AbstractNioChannel(Channel parent, SelectableChannel ch, int readInterestOp) {
  20. super(parent);
  21. this.ch = ch;
  22. this.readInterestOp = readInterestOp;
  23. //配置 Java NIO SocketChannel 为非阻塞的
  24. ch.configureBlocking(false);
  25. ...
  26. }
  27. //构造函数6
  28. protected AbstractChannel(Channel parent) {
  29. this.parent = parent;
  30. id = newId();
  31. unsafe = newUnsafe();
  32. pipeline = newChannelPipeline();
  33. }

下面看一下newSocket()的执行过程。它的引用链为:

  1. NioSocketChannel.newSocket() -> SelectorProviderImpl.openSocketChannel() -> SocketChannelImpl.SocketChannelImpl() -> Net.socket() -> Net.socket(ProtocolFamily var0, boolean var1) -> IOUtil.newFD() -> FileDescriptor var1 = new FileDescriptor();

可以看到,最终是绑定了一个FileDescriptor。

到这里, 一个完整的 NioSocketChannel 就初始化完成了, 可以稍微总结一下构造一个 NioSocketChannel 所需要做的工作:

Unsafe字段的初始化

刚才说到,AbstractChannel()构造函数中对Unsafe字段进行了初始化:

  1. unsafe = newUnsafe();

Unsafe接口的逻辑如下,它封装了对 Java 底层 Socket 的操作, 因此Unsafe实际上是沟通 Netty 上层和 Java 底层的重要的桥梁。

  1. interface Unsafe {
  2. SocketAddress localAddress();
  3. SocketAddress remoteAddress();
  4. void register(EventLoop eventLoop, ChannelPromise promise);
  5. void bind(SocketAddress localAddress, ChannelPromise promise);
  6. void connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise);
  7. void disconnect(ChannelPromise promise);
  8. void close(ChannelPromise promise);
  9. void closeForcibly();
  10. void deregister(ChannelPromise promise);
  11. void beginRead();
  12. void write(Object msg, ChannelPromise promise);
  13. void flush();
  14. ChannelPromise voidPromise();
  15. ChannelOutboundBuffer outboundBuffer();
  16. }

newUnsafe()函数的逻辑如下,它返回一个 NioSocketChannelUnsafe 实例。

  1. protected AbstractNioUnsafe newUnsafe() {
  2. return new NioSocketChannelUnsafe();
  3. }

NioSocketChannelUnsafe的继承关系如下:



pipeline的初始化

每初始化一个Channel,都会伴随着初始化一个pipeline。在AbstractChannel中对pipeline进行了初始化:

  1. pipeline = newChannelPipeline();

DefaultChannelPipeline 构造器如下:

  1. protected DefaultChannelPipeline(Channel channel) {
  2. this.channel = ObjectUtil.checkNotNull(channel, "channel");
  3. succeededFuture = new SucceededChannelFuture(channel, null);
  4. voidPromise = new VoidChannelPromise(channel, true);
  5. tail = new TailContext(this);
  6. head = new HeadContext(this);
  7. head.next = tail;
  8. tail.prev = head;
  9. }

调用 DefaultChannelPipeline 的构造器, 传入了一个 channel, 而这个 channel 其实就是刚才实例化的 NioSocketChannel, DefaultChannelPipeline 会将这个 NioSocketChannel 对象保存在channel 字段中. DefaultChannelPipeline 中, 还有两个特殊的字段, 即 head 和 tail, 而这两个字段是一个双向链表的头和尾。 其实在 DefaultChannelPipeline 中, 维护了一个以 AbstractChannelHandlerContext 为节点的双向链表, 这个链表是 Netty 实现 Pipeline 机制的关键。

TailContext 的继承层次结构如下所示:



HeadContext 的继承层次结构如下所示:



NioEventLoopGroup的初始化

NioEventLoopGroup的继承关系图如下:



NioEventLoopGroup一共有几个构造函数:

  1. // 1
  2. public NioEventLoopGroup() {
  3. this(0);
  4. }
  5. public NioEventLoopGroup(int nThreads) {
  6. this(nThreads, (Executor) null);
  7. }
  8. public NioEventLoopGroup(int nThreads, Executor executor) {
  9. this(nThreads, executor, SelectorProvider.provider());
  10. }
  11. public NioEventLoopGroup(
  12. int nThreads, Executor executor, final SelectorProvider selectorProvider) {
  13. this(nThreads, executor, selectorProvider, DefaultSelectStrategyFactory.INSTANCE);
  14. }
  15. public NioEventLoopGroup(int nThreads, Executor executor, final SelectorProvider selectorProvider,final SelectStrategyFactory selectStrategyFactory) {
  16. super(nThreads, executor, selectorProvider, selectStrategyFactory, RejectedExecutionHandlers.reject());
  17. }

这几个构造函数最后调用到父类的构造函数,如下所示:

  1. // 2
  2. protected MultithreadEventLoopGroup(int nThreads, ThreadFactory threadFactory, Object... args) {
  3. super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, threadFactory, args);
  4. }

可以看到,这里会对nTreads做一个判断,如果nThreads为0,就创建DEFAULT_EVENT_LOOP_THREADS个线程。而在构造函数1中可以看到,如果在创建NioEventLoopGroup时不指定线程数,传入的就是0。也就是说,如果不传入参数,默认创建DEFAULT_EVENT_LOOP_THREADS个线程,DEFAULT_EVENT_LOOP_THREADS会在静态语句块中被初始化,为当前CPU内核数的两倍,如下:

  1. static {
  2. DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt(
  3. "io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));
  4. if (logger.isDebugEnabled()) {
  5. logger.debug("-Dio.netty.eventLoopThreads: {}", DEFAULT_EVENT_LOOP_THREADS);
  6. }
  7. }

构造函数2又会继续调用父类的构造函数:

  1. protected MultithreadEventExecutorGroup(int nThreads, Executor executor,
  2. EventExecutorChooserFactory chooserFactory, Object... args) { ... }

这个构造器代码比较长,可以分为三部分:

第一部分

第一部分对应的代码如下:

  1. if (executor == null) {
  2. executor = new ThreadPerTaskExecutor(newDefaultThreadFactory());
  3. }

ThreadPerTaskExecutor的作用是每次执行任务之前都会创建一个线程实体。它的构造器如下:

  1. public ThreadPerTaskExecutor(ThreadFactory threadFactory) {
  2. ...
  3. this.threadFactory = threadFactory;
  4. }
  5. @Override
  6. public void execute(Runnable command) {
  7. threadFactory.newThread(command).start();
  8. }

可以看到,会根据创建来的线程工厂ThreadFactory来新建线程并启动。这里比较重点的是newThread()方法。这个方法在DefalutThreadFactory类中,关键代码下:

  1. public Thread newThread(Runnable r) {
  2. Thread t = newThread(FastThreadLocalRunnable.wrap(r), prefix + nextId.incrementAndGet());
  3. ...
  4. }

继续跟进newThread方法:

  1. protected Thread newThread(Runnable r, String name) {
  2. return new FastThreadLocalThread(threadGroup, r, name);
  3. }

这里的FastThreadLocalThread继承自Java的Thread,对ThreadLocal中的操作做了优化,并且自己包装了一个ThreadLocalMap。

另外,还需要知道一点,即NioEventLoop的命名规则为:nioEventLoop-xx-yy。其中,xx为EventLoopGroup的编号,yy为EventLoop在Group中的编号。这一点是从newDefaultThreadFactory()方法中得知的。该方法的逻辑如下:

  1. protected ThreadFactory newDefaultThreadFactory() {
  2. return new DefaultThreadFactory(getClass());
  3. }

沿着DefaultThreadFactory的构造函数调用链一路追踪,直到下面的构造函数:

  1. public DefaultThreadFactory(Class<?> poolType, boolean daemon, int priority) {
  2. this(toPoolName(poolType), daemon, priority);
  3. }

这里的poolName()方法的作用就是将NioEventLoop名字的首字母变为小写。继续跟进,发现下面的构造函数:

  1. public DefaultThreadFactory(String poolName, boolean daemon, int priority, ThreadGroup threadGroup) {
  2. ...
  3. prefix = poolName + '-' + poolId.incrementAndGet() + '-';
  4. ...
  5. }

这里就清楚了,是将poolName做了自增操作,并加上了下划线。

第二部分

回到MultithreadEventExecutorGroup()构造函数,看第二部分newChild()的操作。

这一部分其实主要做了两件事:

从newChild()方法出发,经过如下引用链:

  1. NioEventLoopGroup.new Child() -> NioEventLoop.NioEventLoop()

在NioEventLoop()构造函数中有两句句比较重要的逻辑:

  1. NioEventLoop(NioEventLoopGroup parent, Executor executor, SelectorProvider selectorProvider,
  2. SelectStrategy strategy, RejectedExecutionHandler rejectedExecutionHandler) {
  3. super(parent, executor, false, DEFAULT_MAX_PENDING_TASKS, rejectedExecutionHandler);
  4. final SelectorTuple selectorTuple = openSelector();
  5. selector = selectorTuple.selector;
  6. }

其中selector = selectorTuple.selector;的意思就是创建一个selector。openSelector()中的逻辑比较简单,主要是通过SelectorProvider来打开一个Selector。

沿着super看向父类的构造函数,在SingleThreadEventExecutor中有重要逻辑如下:

  1. taskQueue = newTaskQueue(this.maxPendingTasks);

进入NioEventLoop中的newTaskQueue方法看一下,如下:

  1. protected Queue<Runnable> newTaskQueue(int maxPendingTasks) {
  2. // This event loop never calls takeTask()
  3. return maxPendingTasks == Integer.MAX_VALUE ? PlatformDependent.<Runnable>newMpscQueue()
  4. : PlatformDependent.<Runnable>newMpscQueue(maxPendingTasks);
  5. }

通过newMpscQueue方法建立了MpscQueue(mpsc-multiple producers (different threads) and a singl consumer (one thread))。MpscQueue是netty实现的线程安全的队列,与JDK通过锁实现的BlockingQueue不同,MpscLinkedQueue是一种针对Netty中NIO任务设计的一种队列。这里先不深入去探究。

第三部分

看一下创建选择器的代码:

  1. chooser = chooserFactory.newChooser(children);

这段代码的意思就是创建选择器,选择器的作用就是当创建连接的之后,选择哪个EventLoop与其绑定。它的逻辑很简单,就是通过next()方法在上面创建了的EventLoop数组中寻找EventLoop进行绑定,即每来一个连接都会找下一个EventLoop进行绑定。但是netty对next()方法做了一个优化,点进去看newChooser()的实现:

  1. public EventExecutorChooser newChooser(EventExecutor[] executors) {
  2. if (isPowerOfTwo(executors.length)) {
  3. return new PowerOfTwoEventExecutorChooser(executors);
  4. } else {
  5. return new GenericEventExecutorChooser(executors);
  6. }
  7. }

这里判断executors即EventLoop的数量是否为2的幂次方,如果是的话,会调用PowerOfTwoEventExecutorChooser,否则会调用普通的GenericEventExecutorChooser方法。PowerOfTwoEventExecutorChooser方法对next()做了优化,如下所示:

  1. public EventExecutor next() {
  2. return executors[idx.getAndIncrement() & executors.length - 1];
  3. }

idx++ & executors.length - 1。而GenericEventExecutorChooser的next()方法如下:

  1. public EventExecutor next() {
  2. return executors[Math.abs(idx.getAndIncrement() % executors.length)];
  3. }

idx++ % executors.length。可以看到,PowerOfTwoEventExecutorChooser方法将除法运算替换为取模运算,运算速度提高了。

channel 的注册过程

回到AbstractBootstrap.initAndRegister()方法,这个方法会不仅会创建channel,还会将channel注册到EventLoopGroup上,如下代码:

  1. ChannelFuture regFuture = config().group().register(channel);

追踪register的调用链,发现最终调用了unsafe的register()方法,如下:

  1. AbstractBootstrap.initAndRegister ->
  2. MultithreadEventLoopGroup.register ->
  3. SingleThreadEventLoop.register ->
  4. AbstractUnsafe.register

AbstractUnsafe.register的关键逻辑如下:

  1. public final void register(EventLoop eventLoop, final ChannelPromise promise) {
  2. AbstractChannel.this.eventLoop = eventLoop;
  3. register0(promise);
  4. }

首先将eventLoop 赋值给 Channel 的 eventLoop 属性,接着调用register0()方法,register0()方法又会调用AbstractNioChannel.doRegister:

  1. protected void doRegister() throws Exception {
  2. ...
  3. selectionKey = javaChannel().register(eventLoop().unwrappedSelector(), 0, this);
  4. ...
  5. }

javaChannel()返回的是一个 Java NIO SocketChannel, 之后将这个 SocketChannel 注册到与 eventLoop 关联的 selector 上了。

总结一下 Channel 的注册过程:

handler 的添加过程

hander的添加代码如下:

  1. handler(new ChannelInitializer<SocketChannel>() {
  2. @Override
  3. public void initChannel(SocketChannel ch) throws Exception {
  4. ChannelPipeline p = ch.pipeline();
  5. if (sslCtx != null) {
  6. p.addLast(sslCtx.newHandler(ch.alloc(), HOST, PORT));
  7. }
  8. //p.addLast(new LoggingHandler(LogLevel.INFO));
  9. p.addLast(new EchoClientHandler());
  10. }
  11. })

ChannelInitializer 是一个抽象类, 它有一个抽象的方法 initChannel, 添加Channel的时候需要实现这个方法, 并在这个方法中添加的自定义的 handler 。initChannel 会在ChannelInitializer.channelRegistered 方法中被调用。

channelRegistered方法源码如下:

  1. public final void channelRegistered(ChannelHandlerContext ctx) throws Exception {
  2. if (initChannel(ctx)) {
  3. ctx.pipeline().fireChannelRegistered();
  4. removeState(ctx);
  5. } else {
  6. ctx.fireChannelRegistered();
  7. }
  8. }

从上面的源码中可以看到, 在 channelRegistered 方法中, 会调用 initChannel 方法, 将自定义的 handler 添加到 ChannelPipeline 中, 然后调用 ctx.pipeline().remove(this) 将自己从 ChannelPipeline 中删除。 上面的分析过程, 可以用如下图片展示:

一开始, ChannelPipeline 中只有三个 handler, head, tail 和自定义添加的 ChannelInitializer。



接着 initChannel 方法调用后, 添加了自定义的 handler:



最后将 ChannelInitializer 删除:



客户端连接分析

下面看看一下客户端是怎么发起TCP连接的。

首先, 客户端通过调用 Bootstrap 的 connect 方法进行连接。引用链为:

  1. BootStrap.connect() -> BootStrap.doResolveAndConnect() -> BootStrap.doResolveAndConnect0() -> BootStrap.doConnect()

BootStrap.doConnect()关键代码如下:

  1. private static void doConnect(
  2. final SocketAddress remoteAddress, final SocketAddress localAddress, final ChannelPromise connectPromise) {
  3. final Channel channel = connectPromise.channel();
  4. channel.eventLoop().execute(new Runnable() {
  5. @Override
  6. public void run() {
  7. if (localAddress == null) {
  8. channel.connect(remoteAddress, connectPromise);
  9. } else {
  10. channel.connect(remoteAddress, localAddress, connectPromise);
  11. }
  12. connectPromise.addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
  13. }
  14. });
  15. }

在 doConnect 中, 会在 event loop 线程中调用 Channel 的 connect 方法, 而这个 Channel 的具体类型是NioSocketChannel。

进行跟踪到 channel.connect 中, 发现它调用的是 DefaultChannelPipeline#connect, 而, pipeline 的 connect 代码如下:

  1. public final ChannelFuture connect(SocketAddress remoteAddress, ChannelPromise promise) {
  2. return tail.connect(remoteAddress, promise);
  3. }

继续追踪,进入AbstractChannelHandlerContext.connect,代码如下:

  1. public ChannelFuture connect(
  2. final SocketAddress remoteAddress, final SocketAddress localAddress, final ChannelPromise promise) {
  3. if (remoteAddress == null) {
  4. throw new NullPointerException("remoteAddress");
  5. }
  6. if (isNotValidPromise(promise, false)) {
  7. return promise;
  8. }
  9. final AbstractChannelHandlerContext next = findContextOutbound();
  10. EventExecutor executor = next.executor();
  11. if (executor.inEventLoop()) {
  12. next.invokeConnect(remoteAddress, localAddress, promise);
  13. } else {
  14. safeExecute(executor, new Runnable() {
  15. @Override
  16. public void run() {
  17. next.invokeConnect(remoteAddress, localAddress, promise);
  18. }
  19. }, promise, null);
  20. }
  21. return promise;
  22. }

上面的代码中有一个关键的地方, 即 final AbstractChannelHandlerContext next = findContextOutbound(), 这里调用 findContextOutbound 方法, 从 DefaultChannelPipeline 内的双向链表的 tail 开始, 不断向前寻找第一个 outbound 为 true 的 AbstractChannelHandlerContext, 然后调用它的 invokeConnect 方法, 其代码如下:

  1. private void invokeConnect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) {
  2. // 忽略 try 块
  3. ((ChannelOutboundHandler) handler()).connect(this, remoteAddress, localAddress, promise);
  4. }

而第一个 outbound 为 true 的 AbstractChannelHandlerContext就是HeadContext。接着跟踪到 HeadContext.connect, 其代码如下:

  1. @Override
  2. public void connect(
  3. ChannelHandlerContext ctx,
  4. SocketAddress remoteAddress, SocketAddress localAddress,
  5. ChannelPromise promise) throws Exception {
  6. unsafe.connect(remoteAddress, localAddress, promise);
  7. }

这个 connect 方法很简单, 仅仅调用了 unsafe 的 connect 方法。而 unsafe 是 pipeline.channel().unsafe() 返回的, 而 Channel 的 unsafe 字段, 在这个例子中,其实是 AbstractNioByteChannel.NioByteUnsafe 内部类。进行跟踪 NioByteUnsafe -> AbstractNioUnsafe.connect:

  1. @Override
  2. public final void connect(
  3. final SocketAddress remoteAddress, final SocketAddress localAddress, final ChannelPromise promise) {
  4. boolean wasActive = isActive();
  5. if (doConnect(remoteAddress, localAddress)) {
  6. fulfillConnectPromise(promise, wasActive);
  7. } else {
  8. ...
  9. }
  10. }

AbstractNioUnsafe.connect 的实现如上代码所示, 在这个 connect 方法中, 调用了 doConnect 方法, 注意, 这个方法并不是 AbstractNioUnsafe 的方法, 而是 AbstractNioChannel 的抽象方法. doConnect 方法是在 NioSocketChannel 中实现的, 因此进入到 NioSocketChannel.doConnect 中:

  1. @Override
  2. protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
  3. if (localAddress != null) {
  4. javaChannel().socket().bind(localAddress);
  5. }
  6. boolean success = false;
  7. try {
  8. boolean connected = javaChannel().connect(remoteAddress);
  9. if (!connected) {
  10. selectionKey().interestOps(SelectionKey.OP_CONNECT);
  11. }
  12. success = true;
  13. return connected;
  14. } finally {
  15. if (!success) {
  16. doClose();
  17. }
  18. }
  19. }

上面的代码首先是获取 Java NIO SocketChannel, 即从 NioSocketChannel.newSocket 返回的 SocketChannel 对象; 然后是调用 SocketChannel.connect 方法完成 Java NIO 层面上的 Socket 的连接。

最后, 上面的代码流程可以用如下时序图直观地展示:



参考

netty
Netty 源码分析之 一 揭开 Bootstrap 神秘的红盖头 (客户端)
慕课网闪电侠netty源码分析

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注