Netty之Channel相关

概述

为了快速开发可维护高性能的服务端客户端的异步、事件驱动的网络应用框架。

为什么不用Netty5

  1. 使用 ForkJoinPool,增加了代码的复杂度,但是对性能的改善却不明显。
  2. 使用AIO,但对性能的改善却不明显。为什么呢?
    1. 在Linux系统上,AIO的底层实现仍使用Epoll,没有很好实现AIO,处理回调结果速度跟不上处理需求,而且被JDK封装了一层不容易深度优化,因此在性能上没有明显的优势。
    2. AIO还有个缺点是接收数据需要预先分配缓存, 而不是NIO那种需要接收时才需要分配缓存, 所以对连接数量非常大但流量小的情况, 内存浪费很多。

Channel

在这里插入图片描述

它代表一个到实体(如一个硬件设备、一个文件、一个网络套接字或者一个能够执行一个或者多个不同的I/O操作的程序组件)的开放连接,如读操作和写操作
目前,可以把Channel 看作是传入(入站)或者传出(出站)数据的载体。因此,它可以被打开或者被关闭,连接或者断开连接。
在这里插入图片描述

Channel 的生命周期状态

ChannelUnregistered :Channel 已经被创建,但还未注册到EventLoop
ChannelRegistered :Channel 已经被注册到了EventLoop
ChannelActive :Channel 处于活动状态(已经连接到它的远程节点)。它现在可以接收和发送数据了
ChannelInactive :Channel 没有连接到远程节点
当这些状态发生改变时,将会生成对应的事件。这些事件将会被转发给ChannelPipeline 中的ChannelHandler,其可以随后对它们做出响应。

重要的几个 Channel方法

eventLoop: 返回分配给Channel 的EventLoop
pipeline: 返回分配给Channel 的ChannelPipeline
isActive: 如果Channel 是活动的,则返回true。活动的意义可能依赖于底层的传输。例如,一个Socket 传输一旦连接到了远程节点便是活动的,而一个Datagram 传输一旦被打开便是活动的。
localAddress: 返回本地的SokcetAddress
remoteAddress: 返回远程的SocketAddress
write: 将数据写到远程节点。这个数据将被传递给ChannelPipeline,并且排队直到它被冲刷
flush: 将之前已写的数据冲刷到底层传输,如一个Socket
writeAndFlush: 一个简便的方法,等同于调用write()并接着调用flush()

事件和ChannelHandler

Netty 使用不同的事件来通知我们状态的改变或者是操作的状态。这使得我们能够基于已经发生的事件来触发适当的动作。
Netty事件是按照它们与入站或出站数据流的相关性进行分类的。

  • 可能由入站数据或者相关的状态更改而触发的事件包括:
    连接已被激活或者连接失活;数据读取;用户事件;错误事件。
  • 出站事件是未来将会触发的某个动作的操作结果,这些动作包括:
    打开或者关闭到远程节点的连接;将数据写到或者冲刷到套接字。

每个事件都可以被分发给ChannelHandler 类中的某个用户实现的方法。
可以认为每个ChannelHandler 的实例都类似于一种为了响应特定事件而被执行的回调。
Netty 提供了大量预定义的可以开箱即用的ChannelHandler 实现,包括用于各种协议(如HTTP 和SSL/TLS)的ChannelHandler。

ChannelHandler

在这里插入图片描述
从应用程序开发人员的角度来看,Netty 最主要的组件是ChannelHandler,它充当了所有处理入站和出站数据的应用程序逻辑的容器。ChannelHandler 的方法是由网络事件触发的。事实上,ChannelHandler 可专门用于几乎任何类型的动作,例如将数据从一种格式转换为另外一种格式,例如各种编解码,或者处理转换过程中所抛出的异常。
举例来说,ChannelInboundHandler 是一个你将会经常实现的子接口。这种类型的ChannelHandler 接收入站事件和数据,这些数据随后将会被你的应用程序的业务逻辑所处理。当你要给连接的客户端发送响应时,也可以从ChannelInboundHandler 直接冲刷数据然后输出到对端。应用程序的业务逻辑通常实现在一个或者多个ChannelInboundHandler 中。
这种类型的ChannelHandler 接收入站事件和数据,这些数据随后将会被应用程序的业务逻辑所处理。

ChannelHandler 的生命周期

接口 ChannelHandler 定义的生命周期操作,在ChannelHandler被添加到ChannelPipeline 中或者被从ChannelPipeline 中移除时会调用这些操作。这些方法中的每一个都接受一个ChannelHandlerContext 参数。
在这里插入图片描述

  • handlerAdded 当把ChannelHandler 添加到ChannelPipeline 中时被调用
  • handlerRemoved 当从ChannelPipeline 中移除ChannelHandler 时被调用
  • exceptionCaught 当处理过程中在ChannelPipeline 中有错误产生时被调用

可以看到exceptionCaught已经被废除了,源码如下:
在这里插入图片描述
有源码可知,只有它的子接口ChannelInboundHandler有这个方法了。(其实ChannelHandlerAdapter实现了这个方法)

Netty 定义了下面两个重要的ChannelHandler 子接口:

  • ChannelInboundHandler——处理入站数据以及各种状态变化。
  • ChannelOutboundHandler——处理出站数据并且允许拦截所有的操作。

ChannelHandler有3个重要的子类型:编码器、解码器和 SimpleChannelInboundHandler< T >。

ChannelInboundHandler

下面列出了接口 ChannelInboundHandler 的生命周期方法。这些方法将会在数据被接收时或者与其对应的Channel 状态发生改变时被调用。正如我们前面所提到的,这些方法和 Channel 的生命周期密切相关。
在这里插入图片描述

  • channelRegistered:当Channel 已经注册到它的EventLoop 并且能够处理I/O 时被调用

  • channelUnregistered:当Channel 从它的EventLoop 注销并且无法处理任何I/O 时被调用

  • channelActive:当Channel 处于活动状态时被调用,Channel 已经连接/绑定并且已经就绪

  • channelInactive:当Channel 离开活动状态并且不再连接它的远程节点时被调用

  • channelRead:当从Channel 读取数据时被调用

  • channelReadComplete:当Channel上的一个读操作完成时被调用。如果是服务器,则读完一次TCP缓冲区的内容触发一次

  • channelWritabilityChanged:当Channel 的可写状态发生改变时被调用。可以通过调用Channel 的isWritable()方法来检测Channel 的可写性。与可写性相关的阈值可以通过Channel.config().setWriteHighWaterMark()和Channel.config().setWriteLowWaterMark()方法来设置

  • exceptionCaught 当处理过程中在ChannelPipeline 中有错误产生时被调用

  • userEventTriggered:当 ChannelnboundHandler.fireUserEventTriggered() 方法被调用时被调用,因为一个POJO被传经了 ChannelPipeline

channelRead和channelReadComplete

现在假设有一个JavaBean从客户端发送到服务端,遵循TCP/IP协议。物理层上肯定还是被转化为了01串的。这个JavaBean有1024个字节,而TCP缓冲区只有512个字节。那么服务端读到JavaBean会执行几次channelRead和channelReadComplete呢?

答案是channelRead执行1次,channelReadComplete执行2次。
因为channelReadComplete是读完一次TCP缓冲区里的01串就执行一次。
而channelRead是读取到了01串可以转换为JavaBean的时候才会执行一次。

那么假如JavaBean有500个字节,而TCP缓冲区只有512个字节。那么服务端会先出发channelRead和channelReadComplete中的哪一个呢?

答案是会先触发channelRead,因为读到组成JavaBean需要的500个。而channelReadComplete的触发得等TCP缓冲区的内容全部读完。

释放消息资源

当某个 ChannelInboundHandler 的实现重写 channelRead() 方法时,它将负责显式地释放与池化的 ByteBuf 实例相关的内存。Netty 为此提供了一个实用方法 ReferenceCountUtil.release()(方法一)。
示例:
在这里插入图片描述
Netty 将实用 WARN 级别的日志消息记录未释放的资源。一个更加简单的方式是使用 SimpleChannelInboundHandler(方法二)。
示例:
在这里插入图片描述
由于 SimpleChannelInboundHandler 会自动释放资源,所以不应该存储指向任何消息的引用供将来使用。

方法三:用 ctx.fireChannelRead() 替换 ReferenceCountUtil.release()。fireChannelRead 能触发对下一个ChannelInboundHandler 上的 channelRead() 方法。pipeline的两端其实有自己的handler,当入站请求传入最后的handler时,会自动释放。

ChannelOutboundHandler

出站操作和数据将由ChannelOutboundHandler 处理。它的方法将被Channel、Channel-
Pipeline 以及ChannelHandlerContext 调用。
在这里插入图片描述

所有由ChannelOutboundHandler 本身所定义的方法:

  • bind:当请求将Channel 绑定到本地地址时被调用
  • connect:当请求将Channel 连接到远程节点时被调用
  • disconnect:当请求将Channel 从远程节点断开时被调用
  • close: 当请求关闭Channel 时被调用
  • deregister:当请求将Channel 从它的EventLoop 注销时被调用
  • read:当请求从Channel 读取更多的数据时被调用
  • flush:当请求通过Channel 将入队数据冲刷到远程节点时被调用
  • write: 当请求通过Channel 将数据写到远程节点时被调用

释放资源

ChannelOutboundHandler 一般不需要自己手动释放资源。
如果进行了write()操作并丢弃了一个消息,那么也要释放消息。
在这里插入图片描述
注意:要通知ChannelPromise 数据已被处理,否则 ChannelFutureListener 收不到某个消息已经被处理了的通知的情况。

ChannelHandlerAdapter

有一些适配器类可以将编写自定义的ChannelHandler 所需要的工作降到最低限度,因为它们提供了定义在对应接口中的所有方法的默认实现。因为你有时会忽略那些不感兴趣的事件,所以Netty提供了抽象基类ChannelInboundHandlerAdapter 和ChannelOutboundHandlerAdapter。
在这里插入图片描述
你可以使用ChannelInboundHandlerAdapter 和ChannelOutboundHandlerAdapter类作为自己的ChannelHandler 的起始点。这两个适配器分别提供了ChannelInboundHandler和ChannelOutboundHandler 的基本实现。通过扩展抽象类ChannelHandlerAdapter,它们获得了它们共同的超接口ChannelHandler 的方法。
在这里插入图片描述
ChannelHandlerAdapter 还提供了实用方法isSharable()。如果其对应的实现被标注为Sharable,那么这个方法将返回true,表示它可以被添加到多个ChannelPipeline。

ChannelPipeline

ChannelPipeline 提供了ChannelHandler 链的容器,并定义了用于在该链上传播入站和出站事件流的API。
当ChannelHandler 被添加到ChannelPipeline 时,它将会被分配一个ChannelHandlerContext,其代表了ChannelHandler 和ChannelPipeline 之间的绑定。
在这里插入图片描述
当Channel 被创建时,它将会被自动地分配一个新的ChannelPipeline。这项关联是永久性的;Channel 既不能附加另外一个ChannelPipeline,也不能分离其当前的。在Netty 组件的生命周期中,这是一项固定的操作,不需要开发人员的任何干预。
使得事件流经ChannelPipeline 是ChannelHandler 的工作,它们是在应用程序的初始化或者引导阶段被安装的。这些对象接收事件、执行它们所实现的处理逻辑,并将数据传递给链中的下一个ChannelHandler。它们的执行顺序是由它们被添加的顺序所决定的。

入站和出站ChannelHandler 可以被安装到同一个ChannelPipeline中。如果一个消息或者任何其他的入站事件被读取,那么它会从ChannelPipeline 的头部开始流动,最终,数据将会到达ChannelPipeline 的尾端,届时,所有处理就都结束了。
数据的出站运动(即正在被写的数据)在概念上也是一样的。在这种情况下,数据将从ChannelOutboundHandler 链的尾端开始流动,直到它到达链的头部为止。在这之后,出站数据将会到达网络传输层,这里显示为Socket。通常情况下,这将触发一个写操作。
在这里插入图片描述
物理视图上看是一个,从逻辑视图上看是两个。那么站在逻辑视图的角度,分属出站和入站不同的Handler ,是无所谓顺序的。而同属一个方向的Handler则是有顺序的,因为上一个Handler处理的结果往往是下一个Handler的要求的输入。
将图 中的处理器(ChannelHandler)从左到右进行编号,那么入站事件按顺序看到的ChannelHandler 将是1,2,4,而出站事件按顺序看到的ChannelHandler 将是5,3。

常用方法

  • addFirst、addBefore、addAfter、addLast:将一个ChannelHandler 添加到ChannelPipeline 中
  • remove:将一个ChannelHandler 从ChannelPipeline 中移除
  • replace:将ChannelPipeline 中的一个ChannelHandler 替换为另一个ChannelHandler
  • get:通过类型或者名称返回ChannelHandler
  • context:返回和ChannelHandler 绑定的ChannelHandlerContext
  • names:返回ChannelPipeline 中所有ChannelHandler 的名称

ChannelHandlerContext

通过使用作为参数传递到每个方法的ChannelHandlerContext,事件可以被传递给当前ChannelHandler 链中的下一个ChannelHandler。虽然这个对象可以被用于获取底层的Channel,但是它主要还是被用于写出站数据

ChannelHandlerContext 代表了ChannelHandler 和ChannelPipeline 之间的关联,每当有ChannelHandler 添加到ChannelPipeline 中时,都会创建ChannelHandlerContext。ChannelHandlerContext 的主要功能是管理它所关联的ChannelHandler 和在同一个ChannelPipeline 中的其他ChannelHandler 之间的交互。

ChannelHandlerContext 有很多的方法,其中一些方法也存在于Channel 和ChannelPipeline 本身上,但是有一点重要的不同。如果调用Channel 或者ChannelPipeline 上的这些方法,它们将沿着整个ChannelPipeline 进行传播。而调用位于ChannelHandlerContext上的相同方法,则将从当前所关联的ChannelHandler 开始,并且只会传播给位于该ChannelPipeline 中的下一个)能够处理该事件的ChannelHandler。
在这里插入图片描述

在这里插入图片描述

  • alloc:返回和这个实例相关联的Channel 所配置的ByteBufAllocator
  • channel:返回绑定到这个实例的Channel
  • executor:返回调度事件的EventExecutor
  • fireChannelActive:触发对下一个ChannelInboundHandler 上的channelActive()方法(已连接)的调用
  • fireChannelInactive:触发对下一个ChannelInboundHandler 上的channelInactive()方法(已关闭)的调用
  • fireChannelRead:触发对下一个ChannelInboundHandler 上的channelRead()方法(已接收的消息)的调用
  • fireChannelReadComplete:触发对下一个ChannelInboundHandler 上的channelReadComplete()方法的调用
  • fireChannelRegistered:触发对下一个ChannelInboundHandler 上的fireChannelRegistered()方法的调用
  • fireChannelUnregistered:触发对下一个ChannelInboundHandler 上的fireChannelUnregistered()方法的调用
  • fireChannelWritabilityChanged:触发对下一个ChannelInboundHandler 上的fireChannelWritabilityChanged()方法的调用
  • fireExceptionCaught:触发对下一个ChannelInboundHandler 上的fireExceptionCaught(Throwable)方法的调用
  • fireUserEventTriggered:触发对下一个ChannelInboundHandler 上的fireUserEventTriggered(Object evt)方法的调用
  • handler:返回绑定到这个实例的ChannelHandler
  • isRemoved:如果所关联的ChannelHandler 已经被从ChannelPipeline中移除则返回true
  • name:返回这个实例的唯一名称
  • pipeline 返回这个实例所关联的ChannelPipeline
  • read:将数据从Channel读取到第一个入站缓冲区,如果读取成功则触发一个channelRead事件,并(在最后一个消息被读取完成后)通知ChannelInboundHandler 的channelReadComplete。

ChannelHandlerContext 和ChannelHandler 之间的关联(绑定)是永远不会改变的,所以缓存对它的引用是安全的。
如同我们在本节开头所解释的一样,相对于其他类的同名方法,ChannelHandlerContext的方法将产生更短的事件流,应该尽可能地利用这个特性来获得最大的性能。

回调和ChannelFuture

Netty中所有的I/O操作都是异步的,因此一个操作可能不会立即返回,我们需要一种用于在之后的某个时间点确定其结果的方法。为此,Netty 提供了ChannelFuture 接口,其addListener()方法注册了一个ChannelFutureListener,以便在某个操作完成时(无论是否成功)得到通知。

Netty 在内部使用了回调来处理事件。当一个回调被触发时,相关的事件可以被一个ChannelHandler 的实现处理。

JDK 预置了interface java.util.concurrent.Future,但是其所提供的实现,只允许手动检查对应的操作是否已经完成,或者一直阻塞直到它完成,这是非常繁琐的。
所以Netty提供了它自己的实现——ChannelFuture,用于在执行异步操作的时候使用。
在这里插入图片描述
在这里插入图片描述

每个Netty 的出站I/O 操作都将返回一个ChannelFuture。
在这里插入图片描述

可以将ChannelFuture 看作是将来要执行的操作的结果的占位符。它究竟什么时候被执行则可能取决于若干的因素,因此不可能准确地预测,但是可以肯定的是它将会被执行。

EventLoop和EventLoopGroup

在这里插入图片描述

回想一下我们在NIO中是如何处理我们关心的事件的?在一个while循环中select出事件,然后依次处理每种事件。我们可以把它称为事件循环,这就是EventLoop。
在这里插入图片描述
EventLoop 定义了Netty 的核心抽象,用于处理网络连接的生命周期中所发生的事件。
io.netty.util.concurrent 包构建在JDK 的java.util.concurrent 包上。而io.netty.channel 包中的类,为了与Channel 的事件进行交互,扩展了这些接口/类。一个EventLoop 将由一个永远都不会改变的Thread 驱动,同时任务(Runnable 或者Callable)可以直接提交给EventLoop 实现,以立即执行或者调度执行。

根据配置和可用核心的不同,可能会创建多个EventLoop 实例用以优化资源的使用,并且单个EventLoop 可能会被指派用于服务多个Channel。
Netty的EventLoop在继承了ScheduledExecutorService的同时,只定义了一个方法,parent()。在Netty 4 中,所有的I/O操作和事件都由已经被分配给了EventLoop的那个Thread来处理。
在这里插入图片描述

任务调度

偶尔,你将需要调度一个任务以便稍后(延迟)执行或者周期性地执行。例如,你可能想要注册一个在客户端已经连接了5 分钟之后触发的任务。一个常见的用例是,发送心跳消息到远程节点,以检查连接是否仍然还活着。如果没有响应,你便知道可以关闭该Channel 了。

线程管理

在这里插入图片描述
上面这幅图,来自《Netty 实战》,个人在读了源码之后,认为这并不正确。源码如下:

@Override
public void execute(Runnable task) {
    if (task == null) {
        throw new NullPointerException("task");
    }
	
	//正在调用execute的线程和SingleThreadEventExecutor里的变量Thread是不是同一个
    boolean inEventLoop = inEventLoop();
    //添加到taskQueue队列,默认大小16,默认拒绝策略:抛出异常
    addTask(task);
    if (!inEventLoop) {
    	//新建线程,死循环执行队列里的任务 见3.3.1
        startThread();
        if (isShutdown() && removeTask(task)) {
            reject();
        }
    }
    
	//addTaskWakesUp: 添加任务后,任务是否会自动导致线程唤醒
	//wakesUpForTask(task): return !(task instanceof NonWakeupRunnable);
    if (!addTaskWakesUp && wakesUpForTask(task)) {
    	//唤醒线程 见3.3.2
        wakeup(inEventLoop);
    }
}

无论如何,先放入队列。

第一次执行这个方法是没有绑定线程的,会调用 startThread(),新建线程,以后这个 EventLoop 都用这个线程,然后死循环执行任务。

以后的任务仍是放入队列,就不用管了。

更多详细内容,请看Netty 源码阅读笔记(3) NioEventLoop

线程的分配

服务于Channel 的I/O 和事件的EventLoop 则包含在EventLoopGroup 中。
异步传输实现只使用了少量的EventLoop(以及和它们相关联的Thread),而且在当前的线程模型中,它们可能会被多个Channel 所共享。这使得可以通过尽可能少量的Thread 来支撑大量的Channel,而不是每个Channel 分配一个Thread。EventLoopGroup 负责为每个新创建的Channel 分配一个EventLoop。在当前实现中,使用顺序循环(round-robin)的方式进行分配以获取一个均衡的分布,并且相同的EventLoop可能会被分配给多个Channel。
一旦一个Channel 被分配给一个EventLoop,它将在它的整个生命周期中都使用这个EventLoop(以及相关联的Thread)。请牢记这一点,因为它可以使你从担忧你的ChannelHandler 实现中的线程安全和同步问题中解脱出来。
在这里插入图片描述
需要注意,EventLoop 的分配方式对ThreadLocal 的使用的影响。因为一个EventLoop 通常会被用于支撑多个Channel,所以对于所有相关联的Channel 来说,ThreadLocal 都将是一样的。这使得它对于实现状态追踪等功能来说是个糟糕的选择。然而,在一些无状态的上下文中,它仍然可以被用于在多个Channel 之间共享一些重度的或者代价昂贵的对象,甚至是事件。

选择合适的内置通信传输模式

  • NIO io.netty.channel.socket.nio 使用java.nio.channels 包作为基础——基于选择器的方式
  • Epoll io.netty.channel.epoll 由 JNI 驱动的 epoll()和非阻塞 IO。这个传输支持只有在Linux 上可用的多种特性,如SO_REUSEPORT,比NIO 传输更快,而且是完全非阻塞的。将NioEventLoopGroup替换为EpollEventLoopGroup , 并且将NioServerSocketChannel.class 替换为EpollServerSocketChannel.class 即可。
  • OIO io.netty.channel.socket.oio 使用java.net 包作为基础——使用阻塞流
  • Local io.netty.channel.local 可以在VM 内部通过管道进行通信的本地传输
  • Embedded io.netty.channel.embedded Embedded 传输,允许使用ChannelHandler 而又不需要一个真正的基于网络的传输。在测试ChannelHandler 实现时非常有用

ChannelOption

在这里插入图片描述
ChannelOption的各种属性在套接字选项中都有对应。常用如下:

  • SO_BACKLOG ☆
    对应TCP/IP协议listen函数中的backlog参数,函数listen(int socketfd,int backlog)用来初始化服务端可连接队列,
    服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接,多个客户端来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,backlog参数指定了队列的大小。
    当我们客户端请求并发量大的时候应调大此参数。

  • SO_SNDBUF和SO_RCVBUF ☆
    分别对应于套接字选项中的SO_SNDBUF和SO_RCVBUF这两个参数,用于操作接收缓冲区和发送缓冲区的大小。接收缓冲区用于保存网络协议站内收到的数据,直到应用程序读取成功,发送缓冲区用于保存发送数据,直到发送成功。

  • SO_REUSEADDR
    对应于套接字选项中的SO_REUSEADDR,这个参数表示允许重复使用本地地址和端口。
    比如,某个服务器进程占用了TCP的80端口进行监听,此时再次监听该端口就会返回错误,使用该参数就可以解决问题,该参数允许共用该端口,这个在服务器程序中比较常使用,比如某个进程非正常退出,该程序占用的端口可能要被占用一段时间才能允许其他进程使用,而且程序死掉以后,内核一需要一定的时间才能够释放此端口,不设置SO_REUSEADDR就无法正常使用该端口。

  • SO_KEEPALIVE
    对应于套接字选项中的SO_KEEPALIVE,该参数用于设置TCP连接,当设置该选项以后,连接会测试链接的状态,这个选项用于可能长时间没有数据交流的连接。当设置该选项以后,如果在两小时内没有数据的通信时,TCP会自动发送一个活动探测数据报文。基本无用。

  • SO_LINGER
    对应于套接字选项中的SO_LINGER,Linux内核默认的处理方式是当用户调用close方法的时候,函数返回,在可能的情况下,尽量发送数据,不一定保证会发生剩余的数据,造成了数据的不确定性,使用SO_LINGER可以阻塞close的调用时间,直到数据完全发送。

  • TCP_NODELAY☆
    对应于套接字选项中的TCP_NODELAY,该参数的使用与Nagle算法有关,Nagle算法是将小的数据包组装为更大的帧然后进行发送,而不是输入一次发送一次,因此在数据包不足的时候会等待其他数据的到了,组装成大的数据包进行发送,虽然该方式有效提高网络的有效负载,但是却造成了延时,而该参数的作用就是禁止使用Nagle算法,使用于小数据即时传输。

  • TCP_CORK☆
    该选项是需要等到发送的数据量最大的时候,一次性发送数据,适用于文件传输。

©️2020 CSDN 皮肤主题: 岁月 设计师: pinMode 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值