我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)

发布于 2022-7-19 10:39
浏览
0收藏

2. Netty 对 TCP 连接正常关闭的处理

   private void closeOnRead(ChannelPipeline pipeline) {
           //判断服务端连接接收方向是否关闭,这里肯定是没有关闭的
           if (!isInputShutdown0()) {
                if (isAllowHalfClosure(config())) {
                      .....省略TCP连接半关闭处理逻辑.......
                } else {
                    //如果不支持半关闭,则服务端直接调用close方法向客户端发送fin,结束close_wait状态进如last_ack状态
                    close(voidPromise());
                }
            } else {
                    .....省略TCP连接半关闭处理逻辑.......
            }
        }

众所周知 TCP 是一个面向连接的、可靠的、基于字节流的全双工传输层通信协议,既然它是全双工的,那就意味着 TCP 连接同时有一个读通道和写通道。

我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区

 image.png


这里的 isInputShutdown0 方法是用来判断 TCP 连接上的读通道是否关闭,那么在当前情况下,服务端的读通道肯定还没有关闭,因为目前 Netty 还没有调用任何关闭连接的系统调用。

    @Override
    protected boolean isInputShutdown0() {
        return isInputShutdown();
    }

    @Override
    public boolean isInputShutdown() {
        return javaChannel().socket().isInputShutdown() || !isActive();
    }

至于这里为什么要对读通道是否关闭进行判断,笔者会在本文 TCP  连接半关闭相关处理章节为大家详细解释。

 

由于本小节介绍的是 TCP 连接正常关闭的场景,并不是半关闭,所以这里的 isAllowHalfClosure = false 。Reactor 线程进入 close 方法,执行真正的关闭流程。

 

2.1 close 方法发起 TCP 连接关闭流程

public abstract class AbstractChannel extends DefaultAttributeMap implements Channel {

      @Override
      public void close(final ChannelPromise promise) {
            assertEventLoop();

            ClosedChannelException closedChannelException =
                    StacklessClosedChannelException.newInstance(AbstractChannel.class, "close(ChannelPromise)");

            close(promise, closedChannelException, closedChannelException, false);
      }

      private void close(final ChannelPromise promise, final Throwable cause,
                           final ClosedChannelException closeCause, final boolean notify) {

                      .........省略...........

      }

}

这里正是 netty 关闭 channel 的核心逻辑所在,而关闭 channel 的行为又分为主动关闭和被动关闭,如本例中,客户端主动调用 ctx.channel().close() 发起关闭流程为主动关闭方,而服务端则是被动关闭方。

 

而主动关闭方和被动关闭方在这里的传参是不一样的,我们先来看被动关闭方也就是本例中服务端在调用 close 方法的传参。

   

   @Override
        public void close(final ChannelPromise promise) {
            assertEventLoop();

            ClosedChannelException closedChannelException =
                    StacklessClosedChannelException.newInstance(AbstractChannel.class, "close(ChannelPromise)");
            close(promise, closedChannelException, closedChannelException, false);
        }

ChannelPromise promise:服务端作为被动关闭方,这里传入的 ChannelPromise 类型为 VoidChannelPromise ,表示调用方对处理结果并不关心,VoidChannelPromise 不可添加 Listener ,不可修改操作结果状态。

public final class VoidChannelPromise extends AbstractFuture<Void> implements ChannelPromise {

    @Override
    public VoidChannelPromise addListener(GenericFutureListener<? extends Future<? super Void>> listener) {
        fail();
        return this;
    }

    @Override
    public boolean isDone() {
        return false;
    }

   @Override
    public boolean setUncancellable() {
        return true;
    }

    @Override
    public VoidChannelPromise setFailure(Throwable cause) {
        fireException0(cause);
        return this;
    }

    @Override
    public boolean trySuccess() {
        return false;
    }
   
}

而作为主动关闭方的客户端则需要监听 Channel 关闭的结果,所以这里传递的 ChannelPromise 参数为 DefaultChannelPromise 。

     

 ChannelFuture channelFuture = ctx.channel().close();
        channelFuture.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                  ...........省略.......
            }
        });

 

@Override
    public ChannelFuture close() {
        return close(newPromise());
    }

    @Override
    public ChannelPromise newPromise() {
        return new DefaultChannelPromise(channel(), executor());
    }

Throwable cause:当 Channel 关闭之后,需要清理 Channel 写入缓冲队列 ChannelOutboundBuffer 中的待发送数据,这里会将异常 cause 传递给用户的 writePromise ,通知用户 Channel 已经关闭,write 操作失败。这里传入的异常类型为 StacklessClosedChannelException 。
我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区

write事件传播流程.png


如图中所示,当用户调用 ctx.writeAndFlush(msg) 发送数据时,由于是异步发送 Netty 会在图中的第 2 步直接返回一个  ChannelFuture 给用户,发送成功或者发送失败都会通知这个 ChannelFuture 。如果在数据发送之前连接就关闭了,那么 Netty 就会把 StacklessClosedChannelException 异常通知给用户持有的这个 ChannelFuture。相关数据的发送细节,感兴趣的读者可以在回顾下笔者的 一文搞懂 Netty 发送数据全流程 这篇文章。

 

ClosedChannelException closeCause:这个参数和 Throwable cause 参数的作用差不多,都是用于在连接关闭的时候如果此时还有待发送数据未发送。就通知用户这里在参数中指定的异常。唯一不同的是 Throwable cause 负责通知给 Channel 发送数据缓冲队列 ChannelOutboundBuffer 中的 flushedEntry 队列。ClosedChannelException closeCause 负责通知给 ChannelOutboundBuffer 中的 unflushedEntry 队列。
我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区

ChannelOutboundBuffer结构.png


这里大家只需要理解个大概,稍微有个印象就行,笔者后面还会详细介绍。


boolean notify:由于在关闭 Channel 之后,会清理 Channel 对应的发送缓冲队列

ChannelOutboundBuffer 中存储的待发送数据,同时也会释放其中用于存储待发送数据用的

ByteBuffer ,当 ChannelOutboundBuffer 中的内存占用低于低水位线的时候,会触发

ChannelWritabilityChanged 事件。这里的参数 boolean notify 决定是否触发

ChannelWritabilityChanged 事件,由于当前是关闭操作,所以 notify = false ,不需要触发

ChannelWritabilityChanged 事件。


在介绍完 close 方法的各个参数之后,接下来我们来看一下具体的关闭逻辑:

 

2.1.1 连接关闭之前的校验工作
   

// channel的关闭流程是否已经开始
      private boolean closeInitiated;

      // 关闭channel操作的指定future,来判断关闭流程进度 每个channel对应一个CloseFuture
      // 连接关闭之后,netty 会通知这个CloseFuture
      private final CloseFuture closeFuture = new CloseFuture(this);

      private void close(final ChannelPromise promise, final Throwable cause,
                           final ClosedChannelException closeCause, final boolean notify) {
            if (!promise.setUncancellable()) {
                //关闭操作如果被取消则直接返回
                return;
            }

            if (closeInitiated) {
                //如果此时channel已经开始关闭流程,则进入这里
                if (closeFuture.isDone()) {               
                    //如果channel已经关闭 则设置promise为success,如果promise是voidPromise类型则会跳过
                    safeSetSuccess(promise);
                } else if (!(promise instanceof VoidChannelPromise)) { 
                    //如果promise不是voidPromise,则会在关闭完成后 通过closeFuture设置promise success
                    closeFuture.addListener(new ChannelFutureListener() {
                        @Override
                        public void operationComplete(ChannelFuture future) throws Exception {
                            promise.setSuccess();
                        }
                    });
                }
                // 直接返回,防止重复关闭
                return;
            }
  
            //当前channel现在开始进入正在关闭状态
            closeInitiated = true;

            .......关闭channel.........
        }

Netty 这里使用一个 boolean closeInitiated 变量来防止 Reactor 线程来重复执行关闭流程,因为 Channel 的关闭操作可以在多个业务线程中发起,这样就会导致多个业务线程向 Reactor 线程提交多个关闭 Channel 的任务。

 

除此之外,Netty 还为每一个 Channel 创建了一个 CloseFuture closeFuture,用来表示 Channel 关闭的相关进度状态。当 Channel 完成关闭后,Netty 会设置 closeFuture 为 success 状态,并通知 closeFuture 上注册的 listener 。

 

如果 closeInitiated == true 说明当前 Channel 的关闭操作已经开始,如果有多个业务线程先后提交过来多个关闭任务,Reactor 线程则会首先通过 closeFuture.isDone() 判断当前 Channel 是否已经完成关闭 ,如果 Channel 已经关闭,则会在 closeFuture 上注册的 listener 中设置关闭任务对应的 Promie 为 success ,进而通知到业务线程。

     protected final void safeSetSuccess(ChannelPromise promise) {
            if (!(promise instanceof VoidChannelPromise) && !promise.trySuccess()) {
                logger.warn("Failed to mark a promise as success because it is done already: {}", promise);
            }
    }

从这里也可以看出 VoidChannelPromise 表示一个空的 Promise ,不能对其设置 success 或者 fail , 更不能对其添加 listener 。一般用于不关心操作结果的场景。

 

如果此时 Channel 的关闭流程虽然已经开始但还未完成的情况下,则将关闭任务对应 Promise (在业务线程中持有)的通知动作封装成 ChannelFutureListener  并添加到 closeFuture 中。当 Channel 关闭后,closeFuture 会被设置为 success ,并通知其中注册的 ChannelFutureListener 。我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区

image.png


2.1.2 Channel关闭前的准备工作

        private void close(final ChannelPromise promise, final Throwable cause,
                           final ClosedChannelException closeCause, final boolean notify) {
            
            ...........省略连接关闭之前的校验工作........

            //当前channel是否active,这里肯定是active的
            final boolean wasActive = isActive();
            final ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            //将channel对应的写缓冲区channelOutboundBuffer设置为null 表示channel要关闭了,不允许继续发送数据
            //此时如果还在write数据,则直接释放bytebuffer,并立马 fail 相关writeFuture 并抛出newClosedChannelException异常
            //此时如果执行flush,则会直接返回
            this.outboundBuffer = null; 
            //如果开启了SO_LINGER,则需要先将channel从reactor中取消掉。避免reactor线程空转浪费cpu
            Executor closeExecutor = prepareToClose();

            .............省略关闭Channel逻辑流程.......
        }

通过 isActive() 获取 Channel 的状态 boolean wasActive ,由于此时我们还没有关闭 Channel,所以 Channel 现在的状态肯定是 active 的。之所以在关闭流程的一开始就获取 Channel 是否 active 的状态,是因为当我们关闭 Channel 之后,需要通过这个状态来判断 channel 是否是第一次从 active 变为 inactive ,如果是第一次,则会触发 ChannelInactive 事件在 Channel 对应的 pipeline 中传播。

 

在 Channel 关闭之前,还会将 Channel 对应的写入缓冲队列 ChannelOutboundBuffer 设置为 null ,表示 Channel 即将要关闭了,不允许业务线程在继续发送数据。

 

一文搞懂 Netty 发送数据全流程 一文中我们提到过,如果 Channel 准备关闭的时候,用户还在向 Channel 写入数据,则直接释放 bytebuffer ,并立马 fail 掉相关 ChannelPromise 并抛出 newClosedChannelException 异常。

   

  @Override
        public final void write(Object msg, ChannelPromise promise) {
            assertEventLoop();
            //获取当前channel对应的待写入数据缓冲队列(支持用户异步写入的核心关键)
            ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            // outboundBuffer == null说明channel准备关闭了,直接标记发送失败。
            if (outboundBuffer == null) {
                try {
                    ReferenceCountUtil.release(msg);
                } finally {
                    safeSetFailure(promise,
                            newClosedChannelException(initialCloseCause, "write(Object, ChannelPromise)"));
                }
                return;
            }

            .............省略.........
         }

如果此时用户还在执行 Channel 的 flush 操作发送数据,那么发送流程直接会 return 掉,停止发送数据。

     

@Override
        public final void flush() {
            assertEventLoop();

            ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
            //channel以关闭
            if (outboundBuffer == null) {
                return;
            }

            .........省略........
       }

2.1.3 针对 SO_LINGER 选项的处理
     

 @Override
        protected Executor prepareToClose() {
            try {
                if (javaChannel().isOpen() && config().getSoLinger() > 0) {
                    //在设置SO_LINGER后,channel会延时关闭,在延时期间我们仍然可以进行读写,这样会导致io线程eventloop不断的循环浪费cpu资源
                    //所以需要在延时关闭期间 将channel注册的事件全部取消。
                    doDeregister();

                    /**
                     * 设置了SO_LINGER,不管是阻塞socket还是非阻塞socket,在关闭的时候都会发生阻塞,所以这里不能使用Reactor线程来
                     * 执行关闭任务,否则Reactor线程就会被阻塞。
                     * */
                    return GlobalEventExecutor.INSTANCE;
                }
            } catch (Throwable ignore) {
            }
            //在没有设置SO_LINGER的情况下,可以使用Reactor线程来执行关闭任务
            return null;
        }
    }

要理解这段逻辑,首先我们需要理解 SO_LINGER 这个 Socket 选项,他会影响 Socket 的关闭行为。

 

在默认情况下,当我们调用 Socket 的 close 方法后 ,close 方法会立即返回,剩下的事情会交给内核协议栈帮助我们处理,如果此时 Socket 对应的发送缓冲区还有数据待发送,接下来内核协议栈会将 Socket 发送缓冲区的数据发送出去,随后会向对端发送 FIN 包关闭连接。注意:此时应用程序是无法感知到这些数据是否已经发送到对端的,因为应用程序在调用 close 方法后就立马返回了,剩下的这些都是内核在替我们完成。接着主动关闭方就进入了 TCP 四次挥手的关闭流程最后进入TIME_WAIT状态。

我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区

 TCP连接关闭.png


而 SO_LINGER 选项会控制调用 close 方法关闭 Socket 的行为。

  struct linger {
      int l_onoff;   // linger active
      int l_linger;  // how many seconds to linger for
  };

l_onoff :表示是否开启 SO_LINGER 选项。0 表示关闭。默认情况下是关闭的。


int l_linger:如果开启了 SO_LINGER 选项,则该参数表示应用程序调用 close 方法后需要阻塞等待多长时间。单位为秒。

 

这两个参数的不同组合会影响到 Socket 的关闭行为:

 

l_onoff = 0 时 l_linger 的值会被忽略,属于我们上边讲述的默认关闭行为。


l_onoff = 1,l_linger > 0:这种情况下,应用程序调用 close 方法后就不会立马返回,无论 Socket 是阻塞模式还是非阻塞模式,应用程序都会阻塞在这里。直到以下两个条件其中之一发生,才会解除阻塞返回。随后进行正常的四次挥手关闭流程。

 

    ◆当 Socket 发送缓冲区的数据全部发送出去,并等到对端 ACK 后,close 方法返回。

 

    ◆应用程序在 close 方法上的阻塞时间到达 l_linger 设置的值后,close 方法返回。

我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区 so_linger关闭.png


 •l_onoff = 1,l_linger = 0:这种情况下,当应用程序调用 close 方法后会立即返回,随后内核直接清空 Socket 的发送缓冲区,并向对端发送 RST 包,主动关闭方直接跳过四次挥手进入 CLOSE 状态,注意这种情况下是不会有 TIME_WAIT 状态的。
我为Netty贡献源码|且看Nett如何应对TCP连接的正常关闭(二)-开源基础软件社区

RST关闭连接.png


Netty 也提供了 SO_LINGER 选项的设置,由于一般关闭连接的行为都是由客户端发起,我们以 Netty 客户端代码为例说明:

public final class EchoClient {

        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.SO_LINGER, 2)
              ..........省略........
        }
}
public class DefaultSocketChannelConfig extends DefaultChannelConfig
                                        implements SocketChannelConfig {

    @Override
    public SocketChannelConfig setSoLinger(int soLinger) {
        try {
            if (soLinger < 0) {
                javaSocket.setSoLinger(false, 0);
            } else {
                javaSocket.setSoLinger(true, soLinger);
            }
        } catch (SocketException e) {
            throw new ChannelException(e);
        }
        return this;
    }

}

默认情况下 SO_LINGER 选项是关闭的,在 JDK 底层设置 SO_LINGER 选项的方法 setSoLinger  中,参数 on 对应 l_onoff ,参数 linger 对应 l_linger ,单位为秒。

public void setSoLinger(boolean on, int linger) throws SocketException 

 
当我们理解了 SO_LINGER 选项的工作原理及其应用之后,现在回过头来在看 prepareToClose 方法的逻辑就很容易理解了。

     

@Override
        protected Executor prepareToClose() {
            try {
                if (javaChannel().isOpen() && config().getSoLinger() > 0) {
                    //在设置SO_LINGER后,channel会延时关闭,在延时期间我们仍然可以进行读写,这样会导致io线程eventloop不断的循环浪费cpu资源
                    //所以需要在延时关闭期间 将channel注册的事件全部取消。
                    doDeregister();

                    /**
                     * 设置了SO_LINGER,不管是阻塞socket还是非阻塞socket,在关闭的时候都会发生阻塞,所以这里不能使用Reactor线程来
                     * 执行关闭任务,否则Reactor线程就会被阻塞。
                     * */
                    return GlobalEventExecutor.INSTANCE;
                }
            } catch (Throwable ignore) {
            }
            //在没有设置SO_LINGER的情况下,可以使用Reactor线程来执行关闭任务
            return null;
        }

首先我们来关注下 prepareToClose 方法的返回值,它会返回一个 Executor  ,这个 Executor 用于执行真正的 Channel 关闭任务。

 

大家这里可能会有疑问,Channel 上的 IO 操作之前不都是由 Reactor 线程负责执行吗?为什么这里需要用一个单独的 Executor 来执行呢?

 

原因就是如果我们设置了 SO_LINGER 选项 config().getSoLinger() > 0 ,如果继续采用 Reactor 线程执行 Channel 关闭的动作,那么在这种情况下底层Socket 的 close 方法会阻塞 Reactor 线程,直到 Socket 发送缓冲区中的数据全部发送出去并收到对端 ACK ,或者 linger 指定的超时时间到达。

 

由于 Reactor 线程负责多个 Channel 上的 IO 处理,如果被阻塞在这里,就会影响其他 Channel 上的 IO 处理,降低吞吐。所以当我们设置了 SO_LINGER 选项时,就不能使用 Reactor 线程来执行 Channel 关闭的动作,而是用GlobalEventExecutor.INSTANCE来负责执行 Channel 的关闭动作。

 

如果我们没有设置 SO_LINGER 选项,底层 Socket 的 close 方法会立即返回并不会阻塞,所以这种情况下,依然会使用 Reactor 线程来执行 Channel 的关闭动作。

 

prepareToClose 方法这种情况下会返回 null ,表示默认采用 Reactor 线程来执行 Channel 的关闭。

 

这里还有一个重要的点需要和大家强调的是,当我们设置了 SO_LINGER 选项之后,Channel 的关闭动作会被阻塞并延时关闭,在延时关闭期间,Reactor 线程依然可以响应 OP_READ 事件和 OP_WRITE 事件,这可能会导致 Reactor 线程不断的自旋循环浪费 CPU 资源,所以基于这个原因,netty 这里需要将 Channel 从 Reactor 上注销掉。这样 Reactor 线程就不会在响应 Channel 上的 IO 事件了。

 

2.1.4 doDeregister 注销 Channel

public abstract class AbstractNioChannel extends AbstractChannel {

   //channel注册到Selector后获得的SelectKey
    volatile SelectionKey selectionKey;

    @Override
    protected void doDeregister() throws Exception {
        eventLoop().cancel(selectionKey());
    }

    protected SelectionKey selectionKey() {
        assert selectionKey != null;
        return selectionKey;
    }
}
public final class NioEventLoop extends SingleThreadEventLoop {
    //记录socketChannel从Selector上注销的个数 达到256个 则需要将无效selectKey从SelectedKeys集合中清除掉
    private int cancelledKeys;

    private static final int CLEANUP_INTERVAL = 256;
    /**
     * 将socketChannel从selector中注销 取消监听IO事件
     * */
    void cancel(SelectionKey key) {
        key.cancel();
        cancelledKeys ++;
        // 当从selector中注销的socketChannel数量达到256个,设置needsToSelectAgain为true
        // 在io.netty.channel.nio.NioEventLoop.processSelectedKeysPlain 中重新做一次轮询,将失效的selectKey移除,
        // 以保证selectKeySet的有效性
        if (cancelledKeys >= CLEANUP_INTERVAL) {
            cancelledKeys = 0;
            needsToSelectAgain = true;
        }
    }

}

Channel 在向 Reactor 中的 Selector 注册成功后,会得到一个 SelectionKey 。这个 SelectionKey 可以理解成 Channel 在 Selector 中的模型。

 

当 Channel 需要将自己从 Selector 中注销掉时,直接可以通过调用对应的 SelectionKey#cancel 方法。此时调用 SelectionKey#isValid 将会返回 false 。

 

SelectionKey#cancel 方法调用后,Selector 会将要取消的这个 SelectionKey 加入到 Selector 中的 cancelledKeys 集合中。

public abstract class AbstractSelector extends Selector {

    private final Set<SelectionKey> cancelledKeys = new HashSet<SelectionKey>();

    void cancel(SelectionKey k) {                      
        synchronized (cancelledKeys) {
            cancelledKeys.add(k);
        }
    }
}

随后在 Selector 的下一次轮询过程中,会将 cancelledKeys 集合中的 SelectionKey 从 Selector 中所有的 KeySet 中移除。这里的 KeySet 包括Selector用于存放 IO 就绪 SelectionKey 的 selectedKeys 集合,以及用于存放所有在 Selector 上注册的 Channel 对应 SelectionKey 的 keys 集合。

public abstract class SelectorImpl extends AbstractSelector {

    protected Set<SelectionKey> selectedKeys = new HashSet();
    protected HashSet<SelectionKey> keys = new HashSet();
    
     .....................省略...............
}

这里需要注意的是当我们调用 SelectionKey#cancel 方法后,该 SelectionKey 并不会立马从 Selector 中删除,只不过此时调用 SelectionKey#isValid 方法会返回 false 。需要等到下次轮询 selector.selectNow() 的时候,被取消掉的 SelectionKey 才会从 Selector 中被删除掉。

 

当在本次轮询期间,假如有大量的 Channel 从 Selector 中注销,就绪集合 selectedKeys 中依然会保存这些 Channel 对应 SelectionKey 直到下次轮询。那么当然会影响本次轮询结果 selectedKeys 的有效性,增加了许多不必要的遍历开销。

 

所以 netty 在 NioEventLoop#cancel 方法中做了一个优化来保证 Selector 中的 IO 就绪集合 selectedKeys 的有效性,当 Selector 中注销的 Channel 数量 cancelledKeys 超过 CLEANUP_INTERVAL = 256 个时,就会将 needsToSelectAgain 标志设置为 true 。

 

 private void processSelectedKeysOptimized() {
        for (int i = 0; i < selectedKeys.size; ++i) {

            ......循环处理Selector中的IO就绪集合selectedKeys.....

            if (needsToSelectAgain) {
                selectedKeys.reset(i + 1);
                selectAgain();
                i = -1;
            }
        }
    }

当 Reactor 线程在循环遍历处理 Selector 中的 IO 活跃 Channel 时,如果 needsToSelectAgain = true ,那么就会立马执行一次 selector.selectNow() ,目的就是为了清除 Selector 中已经注销的 Selectionkey ,从而保证IO就绪集合 selectedKeys 的有效性。

 

 private void selectAgain() {
        needsToSelectAgain = false;
        try {
            selector.selectNow();
        } catch (Throwable t) {
            logger.warn("Failed to update SelectionKeys.", t);
        }
    }

标签
已于2022-7-19 10:39:32修改
收藏
回复
举报
回复
添加资源
添加资源将有机会获得更多曝光,你也可以直接关联已上传资源 去关联
    相关推荐