Netty4框架原理

EventLoopGroup与Reactor

一个netty程序启动,至少要指定1个EventLoopGroup(如果是NIO,通常是指NioEventLoopGroup),那么这个NioEventLoopGroup在netty扮演什么角色?
netty是Reactor模型的一个实现,我们就从Reactor线程模型开始。
Reactor线程模型有3种:单线程模型、多线程模型、主从线程模型。那么3种线程模型与NioEventLoopGroup又有什么关系呢?其实,不同的设置NioEventLoopGroup的方式就对应了不同的Reactor的线程模型。
1、单线程模型,eg:

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
ServerBootStrap server = new ServerBootStrap();
server.group(bossGroup);

注意,我们实例化了一个NioEventLoopGroup,然后server.group(bossGroup)设置了服务端EventLoopGroup。
有人会有疑问,记得在启动服务端的netty程序时,需要设置bossGroup和workGroup,为何这里只设置一个bossGroup?
其实原因很简单,ServerBootStrap重写了group方法;

public ServerBootStrap group(EventLoopGroup group){
    return group(group,group);
}

因此当传入一个group时,那么bossGroup和workGroup就是同一个NioEventLoopGroup了。并且这个NioEventLoopGroup线程池数量只设置了1个线程,也就是说Netty中的Acceptor和后续的所有客户端连接的IO操作都是在一个线程中处理的。那么对应到Reactor的线程模型中,我们设置new NioEventLoopGroup(1)时,就相当于Reactor的单线程模型;
2、多线程模型,eg:

EventLoopGroup bossGroup = new NioEventLoopGroup(128);
ServerBootStrap server = new ServerBootStrap();
server.group(bossGroup);

从代码可以看出bossGroup的参数设置大于1的数,其实就是Reactor多线程模型。

3、主从线程模型

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workGroup = new NioEventLoopGroup();
ServerBootStrap server = new ServerBootStrap();
server.group(bossGroup,workGroup);

bossGroup为主线程,而workGroup中的线程是CPU核心数乘以2,因此对应到Reactor线程模型中,我们知道,这样设置NioEventLoopGroup其实就是Reactor主从线程模型。

Channel与ChannelPipeline

每一个新创建的 Channel 都将会被分配一个新的 ChannelPipeline。这项关联是永久性 的;Channel 既不能附加另外一个 ChannelPipeline,也不能分离其当前的。在 Netty 组件 的生命周期中,这是一项固定的操作,不需要开发人员的任何干预。
在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应, 它们的组成关系如下:
在这里插入图片描述
通过上图我们可以看到, 一个 Channel 包含了一个 ChannelPipeline, 而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表. 这个链表的头是 HeadContext, 链表的尾是 TailContext, 并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler.

上面的图示给了我们一个对 ChannelPipeline 的直观认识, 但是实际上 Netty 实现的 Channel 是否真的是这样的呢? 我们继续用源码说话.

一个 Channel 的初始化的基本过程, 如下:
下面的代码是 AbstractChannel 构造器:

protected AbstractChannel(Channel parent) {
    this.parent = parent;
    unsafe = newUnsafe();
    pipeline = new DefaultChannelPipeline(this);
}

AbstractChannel 有一个 pipeline 字段, 在构造器中会初始化它为 DefaultChannelPipeline的实例. 这里的代码就印证了一点:每个 Channel 都有一个 ChannelPipeline。
接着我们跟踪一下 DefaultChannelPipeline 的初始化过程。

ChannelPipeline 的初始化

如果你认为ChannelPipeline是一个拦截流经Channel的入站和出站事件的ChannelHandler实例链,那么就很容易看出这些 ChannelHandler 之间的交互是如何组成一个应用 程序数据和事件处理逻辑的核心的。
在这里插入图片描述
图 6-3 展示了一个典型的同时具有入站和出站 ChannelHandler 的 ChannelPipeline 的布 局,并且印证了我们之前的关于 ChannelPipeline 主要由一系列的 ChannelHandler 所组成的 说法。
ChannelPipeline 还提供了通过 ChannelPipeline 本身传播事件的方法。如果一个入站 事件被触发,它将被从 ChannelPipeline 的头部开始一直被传播到 Channel Pipeline 的尾端。
在图 6-3 中,一个出站 I/O 事件将从 ChannelPipeline 的最右边开始,然后向左传播。
官方的图是:

I/O Request  via Channel or  ChannelHandlerContext
                                                  |
+---------------------------------------------------+---------------+
|                           ChannelPipeline         |               |
|                                                  \|/              |
|    +---------------------+            +-----------+----------+    |
|    | Inbound Handler  N  |            | Outbound Handler  1  |    |
|    +----------+----------+            +-----------+----------+    |
|              /|\                                  |               |
|               |                                  \|/              |
|    +----------+----------+            +-----------+----------+    |
|    | Inbound Handler N-1 |            | Outbound Handler  2  |    |
|    +----------+----------+            +-----------+----------+    |
|              /|\                                  .               |
|               .                                   .               |
| ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
|        [ method call]                       [method call]         |
|               .                                   .               |
|               .                                  \|/              |
|    +----------+----------+            +-----------+----------+    |
|    | Inbound Handler  2  |            | Outbound Handler M-1 |    |
|    +----------+----------+            +-----------+----------+    |
|              /|\                                  |               |
|               |                                  \|/              |
|    +----------+----------+            +-----------+----------+    |
|    | Inbound Handler  1  |            | Outbound Handler  M  |    |
|    +----------+----------+            +-----------+----------+    |
|              /|\                                  |               |
+---------------+-----------------------------------+---------------+
              |                                  \|/
+---------------+-----------------------------------+---------------+
|               |                                   |               |
|       [ Socket.read() ]                    [ Socket.write() ]     |
|                                                                   |
|  Netty Internal I/O Threads (Transport Implementation)            |
+-------------------------------------------------------------------+

从上图可以看出, inbound 事件和 outbound 事件的流向是不一样的, inbound 事件的流行是从下至上, 而 outbound 刚好相反, 是从上到下.
并且 inbound 的传递方式是通过调用相应的 ChannelHandlerContext.fireIN_EVT() 方法, 而 outbound 方法的的传递方式是通过调用 ChannelHandlerContext.OUT_EVT() 方法.
在 ChannelPipeline 传播事件时,它会测试 ChannelPipeline 中的下一个 ChannelHandler 的类型是否和事件的运动方向相匹配。如果不匹配,ChannelPipeline 将跳过该 ChannelHandler 并前进到下一个,直到它找到和该事件所期望的方向相匹配的为止。这一点可以看AbstractChannelHandlerContext.findContextInbound()或者findContextOutbound()方法中的

private AbstractChannelHandlerContext findContextInbound() {
        AbstractChannelHandlerContext ctx = this;
        do {
            ctx = ctx.next;
        } while (!ctx.inbound);
        return ctx;
    }

    private AbstractChannelHandlerContext findContextOutbound() {
        AbstractChannelHandlerContext ctx = this;
        do {
            ctx = ctx.prev;
        } while (!ctx.outbound);
        return ctx;
    }

回到ChannelPipeline,DefaultChannelPipeline是ChannelPipeline的默认实现类,来分析下它的源码
ChannelPipeline中的成员信息

final class DefaultChannelPipeline implements ChannelPipeline {
    private static final WeakHashMap<Class<?>, String>[] nameCaches = new WeakHashMap[Runtime.getRuntime().availableProcessors()];

    final AbstractChannel channel;

    final DefaultChannelHandlerContext head;
    final DefaultChannelHandlerContext tail;

    private final Map<String, DefaultChannelHandlerContext> name2ctx = new HashMap<String, DefaultChannelHandlerContext>(4);

    final Map<EventExecutorGroup, ChannelHandlerInvoker> childInvokers = new IdentityHashMap<EventExecutorGroup, ChannelHandlerInvoker>();
    //...
}

DefaultChannelPipeline 构造函数

public DefaultChannelPipeline(AbstractChannel channel) {
    if (channel == null) {
        throw new NullPointerException("channel");
    }
    this.channel = channel;

    tail = new TailContext(this);
    head = new HeadContext(this);

    head.next = tail;
    tail.prev = head;
}

在 DefaultChannelPipeline 构造器中, 首先将与之关联的 Channel 保存到字段 channel 中, 然后实例化两个 ChannelHandlerContext, 一个是 HeadContext 实例 head, 另一个是 TailContext 实例 tail. 接着将 head 和 tail 互相指向, 构成一个双向链表.
特别注意到, 我们在开始的示意图中, head 和 tail 并没有包含 ChannelHandler, 这是因为 HeadContext 和 TailContext 继承于 AbstractChannelHandlerContext 的同时也实现了 ChannelHandler 接口了, 因此它们有 Context 和 Handler 的双重属性

ChannelHandler修改ChannelPipeline布局能力

ChannelHandler 可以通过添加、删除或者替换其他的 ChannelHandler 来实时地修改 ChannelPipeline 的布局。(它也可以将它自己从 ChannelPipeline 中移除。)这是 ChannelHandler 最重要的能力之一

方法描述
AddFirstaddBefore / addAfteraddLast将一个ChannelHandler 添加到ChannelPipeline 中
remove将一个ChannelHandler 从ChannelPipeline 中移除
replace将 ChannelPipeline 中的一个 ChannelHandler 替换为另一个 ChannelHandler

回顾一下, 在实例化一个 Channel 时, 会伴随着一个 ChannelPipeline 的实例化, 并且此 Channel 会与这个 ChannelPipeline 相互关联, 这一点可以通过NioSocketChannel 的父类 AbstractChannel 的构造器上面AbstractChannel(Channel parent) 予以佐证。
当实例化一个 Channel(这里以 EchoClient 为例, 那么 Channel 就是 NioSocketChannel), 其 pipeline 字段就是我们新创建的 DefaultChannelPipeline 对象, 那么我们就来看一下 DefaultChannelPipeline 的构造方法。
DefaultChannelPipeline(AbstractChannel channel) ,可以看到, 在 DefaultChannelPipeline 的构造方法中, 将传入的 channel 赋值给字段 this.channel, 接着又实例化了两个特殊的字段: tail 与 head.

tail 与 head两个字段是一个双向链表的头和尾. 其实在 DefaultChannelPipeline 中, 维护了一个以 AbstractChannelHandlerContext 为节点的双向链表,这个链表是 Netty 实现 Pipeline 机制的关键

ChannelPipeline中的触发事件

ChannelPipeline 的 API 公开了用于调用入站和出站操作的附加方法。下表列出了入 站操作,用于通知 ChannelInboundHandler 在 ChannelPipeline 中所发生的事件。
  在这里插入图片描述
在出站这边,处理事件将会导致底层的套接字上发生一系列的动作。表 6-9 列出了 ChannelPipeline API 的出站操作。

在这里插入图片描述
总结一下:

ChannelPipeline 保存了与 Channel 相关联的 ChannelHandler;
ChannelPipeline 可以根据需要,通过添加或者删除 ChannelHandler 来动态地修改;
ChannelPipeline 有着丰富的 API 用以被调用,以响应入站和出站事件。

ChannelHandlerContext接口head 和 tail (ChannelHandler&ChannelHandlerContext)

ChannelHandlerContext 代表了 ChannelHandler 和 ChannelPipeline 之间的关联,每当有 ChannelHandler 添加到 ChannelPipeline 中时,都会创建 ChannelHandlerContext。
ChannelHandlerContext 的主要功能是管理它所关联的 ChannelHandler 和在 同一个 ChannelPipeline 中的其他 ChannelHandler 之间的交互。
在这里插入图片描述
当使用 ChannelHandlerContext 的 API 的时候,请牢记以下两点

 ChannelHandlerContext 和 ChannelHandler 之间的关联(绑定)是永远不会改变的,所以缓存对它的引用是安全的;

 如同我们在本节开头所解释的一样,相对于其他类的同名方法,ChannelHandlerContext的方法将产生更短的事件流,应该尽可能地利用这个特性来获得最大的性能。

使用 ChannelHandlerContext

图 6-4 展示了 ChannelHandlerContext、Channel 和 ChannelPipeline 之间的关系:
在这里插入图片描述
通过示例说明调用Channel 上的 write()方法将会导致写入事件从尾端到头部地流经 ChannelPipeline。

eg1:从 ChannelHandlerContext 访问 Channel

//获取到与 ChannelHandlerContext相关联的 Channel 的引用
ChannelHandlerContext ctx = ..;
Channel channel = ctx.channel();
//通过 Channel 写入缓冲区
channel.write(Unpooled.copiedBuffer("Netty in Action", CharsetUtil.UTF_8));

eg2:通过 ChannelHandlerContext 访问 ChannelPipeline

//获取到与 ChannelHandlerContext相关联的 ChannelPipeline 的引用
ChannelHandlerContext ctx = ..;
ChannelPipeline pipeline = ctx.pipeline();
//通过 ChannelPipeline写入缓冲区
pipeline.write(Unpooled.copiedBuffer("Netty in Action", CharsetUtil.UTF_8));

重要的是要注意到,虽然被调用的 Channel 或 ChannelPipeline 上的 write()方法将一直传播事件通过整个 ChannelPipeline,但是在 ChannelHandler 的级别上,事件从一个 ChannelHandler到下一个 ChannelHandler 的移动是由 ChannelHandlerContext 上的调用完成的。

在这里插入图片描述

为什么会想要从 ChannelPipeline 中的某个特定点开始传播事件呢?

 为了减少将事件传经对它不感兴趣的 ChannelHandler 所带来的开销。
 为了避免将事件传经那些可能会对它感兴趣的 ChannelHandler。

要想调用从某个特定的 ChannelHandler 开始的处理过程,必须获取到在(ChannelPipeline)该 ChannelHandler 之前的 ChannelHandler 所关联的 ChannelHandlerContext。这个 ChannelHandlerContext 将调用和它所关联的 ChannelHandler 之后的 ChannelHandler。
eg:

//获取到 ChannelHandlerContext的引用
ChannelHandlerContext ctx = ..;
//write()方法将把缓冲区数据发送到下一个 ChannelHandler
ctx.write(Unpooled.copiedBuffer("Netty in Action", CharsetUtil.UTF_8));

在这里插入图片描述

自定义ChannelHandler添加过程

在这里插入图片描述
我们命名插入的是一个ChannelInitializer实例,为什么在ChannelPipeline中的双向链表却是一个ChannelHandlerContext呢?

给channelHandler命名

Pipeline.addXXX都有一个重载方法,例如addLast()它有一个重载的版本是:

channelPipeline addLast(String name , ChannelHandler handler);

第一个参数指定handler的名字(更准确的说是ChannelHandlerContext的名字,说成handler名字便于理解)。那么handler名字有什么用呢?如果不设置名字,默认名字是怎样的呢?

channelHandler默认命名规则

默认命名规则很简单,就是反射获取handler的simpleName加上“#0”,因此我们自定义ChatClientHandler的名字就是“ChatClientHandler#0”。

Pipeline的事件传播机制

AbstractChannelHandlerContext中有inbound和outbound两个Boolean变量,分别用于标识Context所对应的handler类型,即:
1、inbound为true,表示对应的ChannelHandler是ChannelInboundHandler的子类。
2、outbound为true,表示对应的ChannelHandler是ChannelOutboundHandler的子类。
Netty的传播事件有2种:Inbound事件和Outbound事件。

Outbound事件传播方式

outbound事件都是请求时间(request Event),即请求某件事件的发生,然后通过Outbound事件进行通知。
outbound事件的传播方向是tail->customContext->head
以outbound事件为例,分析一下outbound事件的传播机制。
首先,当用户调用了BootStrap的connect()方法时,就会触发一个connect请求事件。
我们发现AbstractChannel的connect()其实调用了DefaultChannelPipeline的connect()方法:

public ChannelFuture connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) {
    return pipeline.connect(remoteAddress, localAddress, promise);
}

而pipeline.connect()方法实现如下:

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

可以看到,当outbound事件(这里是connect事件)传递到Pipeline后,它其实以tail为起点开始传播的。
tail.connect()其实是调用了AbstractChannelHandlerContext的connect()方法:

在这里插入图片描述
findContextOutbound()的作用就是以当前Context为起点向Pipeline中context双向链表的前端寻找第一个outbound属性为true的context(即关联ChannelOutboundHandler的context),然后返回
findContextOutbound()代码如下:

private AbstractChannelHandlerContext findContextOutbound() {
    AbstractChannelHandlerContext ctx = this;
    do {
        ctx = ctx.prev;
    } while (!ctx.outbound);
    return ctx;
}

当我们找到一个outbound的Context后,就调用它的invokeConnect()方法,这个方法中会调用Context关联的ChannelHandler的connect()方法。

    private void invokeConnect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) {
        if (invokeHandler()) {
            try {
                ((ChannelOutboundHandler) handler()).connect(this, remoteAddress, localAddress, promise);
            } catch (Throwable t) {
                notifyOutboundHandlerException(t, promise);
            }
        } else {
            connect(remoteAddress, localAddress, promise);
        }
    }

如果用户没有重写ChannelHandler的connect()方法,那么会调用ChannelOutboundHandlerAdapter的connect()实现:

/**
 * Calls {@link ChannelHandlerContext#connect(SocketAddress, SocketAddress, ChannelPromise)} to forward
 * to the next {@link ChannelOutboundHandler} in the {@link ChannelPipeline}.
 *
 * Sub-classes may override this method to change behavior.
 */
@Override
public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress,
        SocketAddress localAddress, ChannelPromise promise) throws Exception {
    ctx.connect(remoteAddress, localAddress, promise);
}

ChannelOutboundHandlerAdapter的connect()仅仅调用了ctx.connect(),而这个调用又回到了这样的循环里,知道connect事件传递到DefaultChannelPipeline的双向链表的头结点,即head中。为什么会传递到head中呢?回想一下,head实现了ChannelOutboundHandler,因此它的outbound属性是true。
因此最终connect()事件是在head中被处理。
head的connect()事件处理逻辑如下:

  @Override
        public void connect(
                ChannelHandlerContext ctx,
                SocketAddress remoteAddress, SocketAddress localAddress,
                ChannelPromise promise) throws Exception {
            unsafe.connect(remoteAddress, localAddress, promise);
        }

到这里整个connect()请求事件就结束了。如图:
在这里插入图片描述
我们仅仅以 connect()请求事件为例,分析了 outbound 事件的传播过程,但是其实所有的 outbound 的事件传播都遵循着一样的传播规律,小伙伴们可以试着分析一下其他的 outbound 事件,体会一下它们的传播过程。

Inbound事件传播方式

 Inbound事件和Outbound事件的处理过程类似,只是传播方向不同。
 Inbound事件是一个通知事件,即某事件已经发生了,然后通过Inbound事件进行通知。Inbound通常发生在Channel的状态改变或者IO事件就绪。
 Inbound事件的传播方向是head->customContext->tail
 上面分析了connect()这个outbound事件,那么接着分析connect()事件后会发生什么Inbound事件。最终找到了Outbound和Inbound事件之间的联系。当connect()这个Outbound传播到unsafe后,其实在AbstractNiounsafe的connect()方法中进行处理的:

在这里插入图片描述
在AbstractNioUnsafe的connect()方法中 ,首先调用doConnect()方法进行实际上的Socket连接,当连接上后会调用 fulfillConnectPromise(promise, wasActive)方法

private void fulfillConnectPromise(ChannelPromise promise, boolean wasActive) {
            if (promise == null) {
                // Closed via cancellation and the promise has been notified already.
                return;
            }
            boolean active = isActive();
            boolean promiseSet = promise.trySuccess();
            if (!wasActive && active) {
                pipeline().fireChannelActive();
            }
            if (!promiseSet) {
                close(voidPromise());
            }
        }

在fulfillConnectPromise()方法中,会通过pipeline().fireChannelActive()方法将通道激活的消息(即socket连接成功)发送出去。而这里,当调用Pipeline.fireXXX后,就是Inbound事件的起点。因此当调用pipeline().fireChannelActive()后,就产生一个ChannelActive Inbound事件,我们就看一下这个Inbound事件是怎么传播的?

public final ChannelPipeline fireChannelActive() {
    AbstractChannelHandlerContext.invokeChannelActive(head);
    return this;
}

果然在fireChannelActive()方法中,调用的是invokeChannelActive(head).因此可以证明Inbound事件在Pipeline中传输的起点是head。那么在invokeChannelActive(head)中又做了什么?

static void invokeChannelActive(final AbstractChannelHandlerContext next) {
    EventExecutor executor = next.executor();
    if (executor.inEventLoop()) {
        next.invokeChannelActive();
    } else {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                next.invokeChannelActive();
            }
        });
    }
}

这里是一个循环调用,当ChannelActive()消息传递到tail后,会将消息传递到对应的ChannelHandler中处理,而tail的handler()返回的就是tail本身。
因此channelActive Inbound事件最终是在tail中处理的,我们看一下它的处理方法:

public void channelActive(ChannelHandlerContext ctx) throws Exception{
}

tailContext的channelActive()方法时空的。如果大家自行查看TailContext的Inbound处理方法时就会发现,他们的实现都是空的。可见如果是Inbound,当用户没有实现自定义的处理器时,那么默认是不处理的。下图描述了Inbound事件的传输过程:

在这里插入图片描述

Pipeline事件传播总结

outbound事件总结:
1、outbound事件是请求事件(eg:由connect()发起一个请求,并最终由unsafe处理这个请求)
2、Outbound事件的发起者是Channel
3、Outbound事件的处理着unsafe
4、outbound事件在Pipeline的传输方向是tail->head
5、在channelHandler中处理事件时。如果这个Handler不是最后一个Handler,则需要调用ctx的方法(如:ctx.connect()方法)将此事件继续传播下去。如果不这样做,那么此事件的传播会提前终止。
Inbound事件总结:
1、Inbound事件时通知事件,当某件事情已经就绪后,通知上层。
2、Inbound事件发起者是unsafe
3、Inbound事件的处理着是Channel,如果用户没有实现自定义的处理方法,那么Inbound事件默认的处理着是TailContext;并且处理方法是空实现。
4、Inbound事件在Pipeline中传输方向是head->tail
5、在channelHandler中处理事件时。如果这个Handler不是最后一个Handler,则需要调用ctx.fireXXX()方法(如:ctx.fireChannelActive()方法)将此事件继续传播下去。如果不这样做,那么此事件的传播会提前终止。

channel生命周期

Netty有一个简单但强大的状态模型,并完美映射到ChannelInboundHandler的各个方法。下面是Channel生命周期的不同的状态:

状态描述
channelUnregistered()Channel已创建,还未注册到一个EventLoop上
channelRegistered()Channel已经注册到一个EventLoop上
channelActiveChannel是活跃的状态(链接到某个远端),可以收发数据
channelInActiveChannel未连接到远端

ChannelHandler常用API

ChannelHandler提供了在其生命周期内添加或从ChannelPipeline中删除的方法。

状态描述
handlerAdded()ChannelHandler添加到实际上下文中准备 处理事件
handlerRemoved()将ChannelHandler从实际上下文中删除,不再处理事件
exceptionCaught()处理抛出异常

异步结果Future

java.util.concurrent.Future是Java提供的接口,表示异步执行的状态,Future的get方法会判断任务是否执行完成。如果完成就返回结果,否则就阻塞线程,直到任务完成。
Netty扩展了Java的Future,最主要的改进就是增加了监听器Listener接口,通过监听器可以让异步执行更加有效率,不需要通过get来等待异步执行结束,而是通过监听器回调来精确的控制异步执行结束的时间点。

异步执行Promise

promise接口也扩展了Future接口,它表示一种可写的Future,就是可以设置异步执行的结果。
ChannelPromise接口扩展Promise和ChannelFuture,绑定了channel,即可写异步结构,又具备监听者功能,是Netty实际编程使用的表示异步执行的接口。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值