Netty 中细说数据容器-ByteBuf


原博文,点击这里

1. 简介

字节是网络数据的基本单位。 Java NIO 提供了 ByteBuffer 作为字节容器,但是这个类使用起来过于复杂,而且也有些繁琐。Netty使用了即易于使用又具备良好性能的ByteBuf来替代ByteBuffer。

本文将对ByteBuffer做一个简单的总结。

相关的一些方法:
channelActive():当通道就绪就会触发该方法
channelRead0 :读取客户端数据

对于netty中的ByteToMessageDecoder的ByteBuf数据的读取

下面这种方式只能在ByteToMessageDecoder里面使用

 protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
		byte[] buffer = new byte[msg.readableBytes()];
        msg.readBytes(buffer);

        //将buffer转成字符串
        String message = new String(buffer, Charset.forName("utf-8"));

        System.out.println("服务器接收到数据 " + message);
  }

对于netty中的ReplayingDecoder的ByteBuf数据的读取

ReplayingDecoder继承了ByteToMessageDecoder,并且ReplayingDecoder中是ReplayingDecoderByteBufByteBuf进行二次封装。不能调用readableBytes()方法,调用之后,会出现如下错误。所以直接进行读取即可

当数据不够时会抛出一类特殊的错误,然后ReplayingDecoder会重置readerIndex并且再次调用decode方法。

为了提高处理复杂消息的性能,ReplayingDecoder提供了checkpoint机制。此方法会将下次decode对buffer开始解码的位置置为当前读指针位置。

在这里插入图片描述

1.常见的自定 义格式为:长度+内容的方式

在这里插入图片描述
对于readLong还是read
就可以直接按照下面的方式进行读取

public class MyMessageDecoder extends ReplayingDecoder<Void> {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        Thread.sleep(2000);
        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//设置日期格式
        System.out.println(df.format(new Date()) +"服务端-解码器-被调用");
        System.out.println();

        //需要将得到二进制字节码-> MessageProtocol 数据包(对象)
        int length = in.readInt();
        byte[] content = new byte[length];
        in.readBytes(content);
        String s = new String(content, Charset.forName("utf-8"));

        //封装成 MessageProtocol 对象,放入 out, 传递下一个handler业务处理
        MessageProtocol messageProtocol = new MessageProtocol();
        messageProtocol.setLen(length);
        messageProtocol.setContent(content);

        out.add(messageProtocol);

    }
}

2. 运作方式与使用模式

2.1 运作方式

因为所有的网络通信都涉及字节序列的移动, 所以高效易用的数据结构明显是必不可少的。
让我们来看看Netty是如何高效的实现这个需求的。

ByteBuf 维护了两个不同的索引:一个用于读取,一个用于写入。当你从 ByteBuf 读取时,它的 readerIndex 将会被递增已经被读取的字节数。同样地,当你写入 ByteBuf 时,它的writerIndex 也会被递增。

如果读取字节直到 readerIndex 达到
和 writerIndex 同样的值时你将会到达“可以读取的”数据的末尾。 如果试图读取超出该点的数据将会触发一个 IndexOutOfBoundsException。

在ByteBuf中,名称以 read 或者 write 开头的 ByteBuf 方法,将会推进其对应的索引,而名称以 set 或
者 get 开头的操作则不会。

对于get和set方法,需要传入一个相对索引的位置。当操作基于相对位置的数据超过capacity时,就会引发IndexOutOfBoundsException,例如:capacity是100,当执行byteBuf.setInt(97,100)时,由于int占4个字节(4+97>100),就会触发IndexOutOfBoundsException。

2.2 使用模式

使用ByteBuf的时候,可以选择数据的存放模式,常见的模式如下:

2.2.1 堆缓冲区

最常用的 ByteBuf 模式是将数据存储在 JVM 的堆空间中。这种模式被称为支撑数组
(backing array),它能在没有使用池化的情况下提供快速的分配和释放,非常适合于有遗留的数据需要处理的情况。

public static void main(String args[]) {
        ByteBuf heapBuf = Unpooled.copiedBuffer("heap space",
                CharsetUtil.UTF_8);
        if (heapBuf.hasArray()) { //检查 ByteBuf 是否有一个支撑数组.当 hasArray()方法返回 false 时,
            // 尝试访问支撑数组将触发一个 UnsupportedOperationException。这个模式类似于 JDK 的 ByteBuffer 的用法
            byte[] array = heapBuf.array();
            int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
            int length = heapBuf.readableBytes();
            System.out.println(Arrays.toString(array));
            System.out.println(offset);
            System.out.println(length);
        } else {
            System.out.println("No Heap Array");
        }
    }

当 hasArray()方法返回 false 时,尝试访问支撑数组将触发一个 Unsupported
OperationException。这个模式类似于 JDK 的 ByteBuffer 的用法

2.2.2 直接缓冲区

直接缓冲区是另外一种 ByteBuf 模式.
直接缓冲区的内容并不是驻留在Java的堆上,而是在本地内存中。Java堆上的数据在每次调用本地 I/O 操作之前(或者之后)需要将缓冲区的内容复
制到一个中间缓冲区(或者从中间缓冲区把内容复制到缓冲区),而本地内存避开了这个操作。 这也就解释了为何直接缓冲区对于网络数据传输是理想的选择。如果你的数据包含在一个在堆上分配的缓冲区中,那么事实上,在通过套接字发送它之前, JVM将会在内部把你的缓冲区复制到一个直接缓冲区中。

直接缓冲区的主要缺点是,相对于基于堆的缓冲区,它们的分配和释放都较为昂贵。如果你正在处理遗留代码,你也可能会遇到另外一个缺点:因为数据不是在堆上, 所以你不得不进行一次复制。显然,与使用堆缓冲区相比,这涉及的工作更多。因此,如果事先知道容器中的数据将会被作为数组来访问,你可能更愿意使用堆内存。

直接缓冲区的使用代码样例如下:

public static void main(String args[]) {
        ByteBuf directBuf = Unpooled.directBuffer(100);
        directBuf.writeBytes("direct buffer".getBytes());
        if (!directBuf.hasArray()) {//检查 ByteBuf 是否由数组支撑。如果不是,则这是一个直接缓冲区
            int length = directBuf.readableBytes();
            byte[] array = new byte[length];//分配一个新的数组来保存具有该长度的字节数据
            directBuf.getBytes(directBuf.readerIndex(), array);//将字节复制到该数组
            System.out.println(Arrays.toString(array));
            System.out.println(length);
        }
    }
2.2.3 复合缓冲区

复合缓冲区为多个 ByteBuf 提供一个聚合视图,可以根据需要添加或者删除 ByteBuf 实例。实现该功能的类为CompositeByteBuf,它提供了一
个将多个缓冲区表示为单个合并缓冲区的虚拟表示.

CompositeByteBuf 中的 ByteBuf实例可能同时包含直接内存分配和非直接内存分配。
如果其中只有一个实例,那么对 CompositeByteBuf 上的 hasArray()方法的调用将返回该组件上的 hasArray()方法的值;否则它将返回 false。

为了举例说明,让我们考虑一下一个由两部分——头部和主体——组成的将通过 HTTP 协议传输的消息。这两部分由应用程序的不同模块产生,将会在消息被发送的时候组装。该应用程序可以选择为多个消息重用相同的消息主体。当这种情况发生时,对于每个消息都将会创建一个新的头部。
因为我们不想为每个消息都重新分配这两个缓冲区,所以使用CompositeByteBuf 是一个完美的选择。 它在消除了没必要的复制的同时,暴露了通用的 ByteBuf API。

样例代码如下:

/**
 * Netty 通过一个 ByteBuf 子类——CompositeByteBuf——实现了组合模式,它提供了一
 个将多个缓冲区表示为单个合并缓冲区的虚拟表示
 */
public class CompositeBuf {
    public static void main(String args[]){
        CompositeByteBuf messageBuf = Unpooled.compositeBuffer();
        ByteBuf headerBuf = Unpooled.copiedBuffer("head", CharsetUtil.UTF_8); // can be backing or direct
        ByteBuf bodyBuf = Unpooled.copiedBuffer("body", CharsetUtil.UTF_8); // can be backing or direct
        messageBuf.addComponents(headerBuf, bodyBuf);
        System.out.println("Remove Head Before------------------");
        printCompositeBuffer(messageBuf);
        for (ByteBuf buf : messageBuf) {
            System.out.println(buf.toString(CharsetUtil.UTF_8));
        }
        messageBuf.removeComponent(0); // remove the header
        System.out.println("Remove Head After------------------");
        printCompositeBuffer(messageBuf);
        for (ByteBuf buf : messageBuf) {
            System.out.println(buf.toString(CharsetUtil.UTF_8));
        }
    }

    public static void printCompositeBuffer(CompositeByteBuf compBuf){
        int length = compBuf.readableBytes();
        byte[] array = new byte[length];
        compBuf.getBytes(compBuf.readerIndex(), array);
        System.out.println (Arrays.toString(array));
        System.out.println (length);
    }
}

3. 支持的操作

ByteBuf 提供了许多超出基本读、写操作的方法用于修改它的数据,本章节做一个简单的介绍。

3.1 随机访问索引

如同在普通的 Java 字节数组中一样, ByteBuf 的索引是从零开始的:第一个字节的索引是0,最后一个字节的索引总是 capacity() - 1,可以通过index进行访问:

    public static void main(String args[]) {
        ByteBuf directBuf = Unpooled.directBuffer(100);
        directBuf.writeBytes("direct buffer".getBytes());
        System.out.println(directBuf.getByte(5));
    }

3.2 顺序访问索引

ByteBuf 同时具有读索引和写索引,这两个索引将内存区块分成了三块:

这里写图片描述

3.2.1 可丢弃的字节

可丢弃字节的分段包含了已经被读过的字节。通过调用 discardReadBytes()方法,可以丢弃它们并回收空间。这个分段的初始大小为 0,存储在 readerIndex 中,会随着 read 操作的执行而增加。

下图展示的缓冲区上调用discardReadBytes()方法后的结果。可以看
到, 可丢弃字节分段中的空间已经变为可写的了。注意,在调用discardReadBytes()之后,对可写分段的内容并没有任何的保证。
这里写图片描述

频繁地调用 discardReadBytes()方法可以确保可写分段的最大化,但是这将极有可能会导致内存复制,因为可读字节(图中标记为 CONTENT 的部分)必须被移动到缓冲区的开始位置。我们建议只在有真正需要的时候才这样做, 例如,当内存非常宝贵的时候。

3.2.2 可读字节

ByteBuf 的可读字节分段存储了实际数据。新分配的、包装的或者复制的缓冲区的默认的
readerIndex 值为 0。任何名称以 read 或者 skip 开头的操作都将检索或者跳过位于当前
readerIndex 的数据,并且将它增加已读字节数。

如果尝试在缓冲区的可读字节数已经耗尽时从中读取数据,那么将会引发一个 IndexOutOfBoundsException。

3.2.3 可写字节

可写字节分段是指一个拥有未定义内容的、写入就绪的内存区域。新分配的缓冲区的
writerIndex 的默认值为 0。任何名称以 write 开头的操作都将从当前的 writerIndex 处
开始写数据,并将它增加已经写入的字节数。如果写操作的目标也是 ByteBuf,并且没有指定源索引的值,则源缓冲区的 readerIndex 也同样会被增加相同的大小。

如果尝试往目标写入超过目标容量的数据,将会引发一个IndexOutOfBoundException。

3.2.4 例子

下面是一个关于可丢弃字节,可读字节,可写字节的综合例子:

        ByteBuf directBuf = Unpooled.directBuffer(100);
        directBuf.writeBytes("direct buffer".getBytes());
        System.out.println("可写字节容量:"+directBuf.writableBytes());
        System.out.println("初始化可读字节:"+directBuf.readableBytes());
        System.out.println("初始化可丢弃字节:"+directBuf.readerIndex()+"\n");
        directBuf.readBytes(2);
        System.out.println("读取两个字节"+"\n");
        System.out.println("读取后可写字节容量:"+directBuf.writableBytes());
        System.out.println("读取后可读字节:"+directBuf.readableBytes());
        System.out.println("读取后可丢弃字节:"+directBuf.readerIndex()+"\n");
        directBuf.discardReadBytes();
        System.out.println("执行discardReadBytes后可写字节容量:"+directBuf.writableBytes());
        System.out.println("执行discardReadBytes后可读字节:"+directBuf.readableBytes());
        System.out.println("执行discardReadBytes后可丢弃字节:"+directBuf.readerIndex());

输出为:

可写字节容量:87
初始化可读字节:13
初始化可丢弃字节:0

读取两个字节

读取后可写字节容量:87
读取后可读字节:11
读取后可丢弃字节:2

执行discardReadBytes后可写字节容量:89
执行discardReadBytes后可读字节:11
执行discardReadBytes后可丢弃字节:0

3.3 索引管理

索引管理的相关操作如下:

  • 可以通过调用 markReaderIndex()、 markWriterIndex()、 resetWriterIndex()
    和 resetReaderIndex()来标记和重置 ByteBuf 的 readerIndex 和 writerIndex。
  • 可以通过调用 readerIndex(int)或者 writerIndex(int)来将索引移动到指定位置。
    试图将任何一个索引设置到一个无效的位置都将导致一个 IndexOutOfBoundsException。
  • 可以通过调用 clear()方法来将 readerIndex 和 writerIndex 都设置为 0。调用 clear()比调用 discardReadBytes()轻量得多,因为它将只是重置索引而不会复
    制任何的内存

示例如下:

        ByteBuf directBuf = Unpooled.directBuffer(100);
        directBuf.writeBytes("direct buffer".getBytes());
        System.out.println("初始化可读字节:"+directBuf.readableBytes());
        directBuf.markReaderIndex();
        System.out.println("执行markReaderIndex"+"\n");//标记读索引
        directBuf.readBytes(2);
        System.out.println("读取两个字节"+"\n");
        System.out.println("读取后可读字节:"+directBuf.readableBytes());
        directBuf.resetReaderIndex();//恢复读索引
        System.out.println("执行resetReaderIndex后可读字节:"+directBuf.readableBytes());
        directBuf.clear();
        System.out.println("执行clear后可读字节:"+directBuf.readableBytes());
        directBuf.readBytes(2);//可读字节变为0,此时再读取会抛出IndexOutOfBoundsException

输出如下:

初始化可读字节:13
执行markReaderIndex

读取两个字节

读取后可读字节:11
执行resetReaderIndex后可读字节:13
执行clear后可读字节:0
Exception in thread "main" java.lang.IndexOutOfBoundsException: readerIndex(0) + length(2) exceeds writerIndex(0): UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf(ridx: 0, widx: 0, cap: 100)
    at io.netty.buffer.AbstractByteBuf.checkReadableBytes0(AbstractByteBuf.java:1403)
    at io.netty.buffer.AbstractByteBuf.checkReadableBytes(AbstractByteBuf.java:1390)
    at io.netty.buffer.AbstractByteBuf.readBytes(AbstractByteBuf.java:843)
    at com.eric.bytebuf.OperationSample.indexManage(OperationSample.java:32)
    at com.eric.bytebuf.OperationSample.main(OperationSample.java:16)

3.4 查找操作

在ByteBuf中有多种可以用来确定指定值的索引的方法,常见方法如下:

  • indexOf(int,int,byte)
  • byteBuf.forEachByte(ByteBufProcessor.FIND_CR):ByteBufProcessor针对一些常见的值定义了许多便利的方法,例如:ByteBufProcessor.FIND_NUL,ByteBufProcessor.FIND_CR等

简单的例子:

ByteBuf directBuf = Unpooled.directBuffer(100);
directBuf.writeBytes("direct buffer".getBytes());
System.out.println(directBuf.indexOf(0,directBuf.readableBytes(), (byte) 'u'));

3.5 派生缓冲区

派生缓冲区为 ByteBuf 提供了以专门的方式来呈现其内容的视图。这类视图是通过以下方
法被创建的:

- duplicate()- slice();
- slice(int, int)- Unpooled.unmodifiableBuffer()- order(ByteOrder)- readSlice(int)

上述这些方法都将返回一个新的 ByteBuf 实例,它具有自己的读索引、写索引和标记
索引。 但是其内部存储和原始对象是共享的。该种方式创建成本很低廉,但是这也意味着,如果你修改了它的内容,也同时修改了其对应的源实例,所以要小心。

如果需要一个现有缓冲区的真实副本,请使用 copy()或者 copy(int, int)方
法。不同于派生缓冲区,由这个调用所返回的 ByteBuf 拥有独立的数据副本。

3.6 读/写操作

正如前文所提到过的,有两种类别的读/写操作:

  • get()和 set()操作,从给定的索引开始,并且保持索引不变;
  • read()和 write()操作, 从给定的索引开始,并且会根据已经访问过的字节数对索
    引进行调整。

4. ByteBufHolder

我们经常发现,除了实际的数据负载之外,我们还需要存储各种属性值。 HTTP 响应便是一
个很好的例子,除了表示为字节的内容,还包括状态码、 cookie 等。
为了处理这种常见的用例, Netty 提供了 ByteBufHolder。 ByteBufHolder 也为 Netty 的高级特性提供了支持,如缓冲区池化,其中可以从池中借用 ByteBuf, 并且在需要时自动释放。

ByteBufHolder 只有几种用于访问底层数据和引用计数的方法:

  • content():返回由这个 ByteBufHolder 所持有的 ByteBuf
  • copy():返回这个 ByteBufHolder 的一个深拷贝,包括一个其所包含的 ByteBuf 的非共享拷贝
  • duplicate():返回这个 ByteBufHolder 的一个浅拷贝,包括一个其所包含的 ByteBuf 的共享拷贝.

系统默认自带了一系列的ByteBufHolder,以MemoryFileUpload为例,该类通过封装将filename,contentType,contentTransferEncoding属性与对应的file进行关联。

5. ByteBuf分配

本章节主要对ByteBuf的分配方式进行总结

5.1 使用ByteBufAllocator接口分配

另外一篇重要的参考博文,点击这里
为了降低分配和释放内存的开销, Netty 通过 interface ByteBufAllocator 实现了
(ByteBuf 的)池化,它可以用来分配我们所描述过的任意类型的 ByteBuf 实例(基于堆缓冲区的,基于直接缓冲区的,基于复合缓冲区的)。

可以通过 Channel(每个都可以有一个不同的 ByteBufAllocator 实例)或者绑定到
ChannelHandler 的 ChannelHandlerContext可以获取到一个 ByteBufAllocator 的引用,Netty提供了两种ByteBufAllocator的实现:

  • PooledByteBufAllocator:池化了ByteBuf的实例以提高性能并最大限度地减少内存碎片
  • UnpooledByteBufAllocator:不池化ByteBuf实例,并且在每次它被调用时都会返回一个新的实例

虽然Netty默认使用了PooledByteBufAllocator,但这可以很容易地通过ChannelConfig.setAllocator(ByteBufAllocator) 或者在引导应用程序时指定一个不同类型的ByteBufAllocator分配器。

ByteBufAllocator使用示例代码如下:

        ByteBufAllocator allocator= new PooledByteBufAllocator();
        ByteBuf directByteBuf = allocator.directBuffer();
        directByteBuf.writeBytes("Get Instance from ByteBufAllocator".getBytes());
        byte[] dst=new byte[directByteBuf.readableBytes()];
        directByteBuf.readBytes(dst);
        System.out.println(new String(dst));

5.2 使用Unpooled缓冲区分配

可能某些情况下,你未能获取一个到 ByteBufAllocator 的引用。对于这种情况, Netty 提供了一个简单的称为 Unpooled 的工具类, 它提供了静态的辅助方法来创建未池化的 ByteBuf实例.提供的方法如下:

  • buffer:返回一个未池化的基于堆内存存储的ByteBuf
  • directBuffer:返回一个未池化的基于直接内存存储的 ByteBuf
  • wrappedBuffer:返回一个包装了给定数据的 ByteBuf
  • copiedBuffer:返回一个复制了给定数据的 ByteBuf

简单示例如下:

        ByteBuf directBuf = Unpooled.directBuffer(100);
        directBuf.writeBytes("direct buffer".getBytes());
        System.out.println(directBuf.indexOf(0,directBuf.readableBytes(), (byte) 'u'));

Unpooled 类还使得 ByteBuf 同样可用于那些并不需要 Netty 的其他组件的非网络项目,
使得其能得益于高性能的可扩展的缓冲区 API。

5.3 使用ByteBufUtil工具类

ByteBufUtil 提供了用于操作 ByteBuf 的静态的辅助方法。常用方法如hexdump(),equals()等

6. 引用计数

引用计数是一种通过在某个对象所持有的资源不再被其他对象引用时释放该对象所持有的资源来优化内存使用和性能的技术。很多地方都用了该计数,例如JVM的垃圾回收机制。 Netty 在第 4 版中为 ByteBuf 和 ByteBufHolder 引入了引用计数技术,它们都实现了 interface ReferenceCounted

用计数背后的想法并不是特别的复杂;它主要涉及跟踪到某个特定对象的活动引用的数
量。一个 ReferenceCounted 实现的实例将通常以活动的引用计数为 1 作为开始。只要引用计数大于 0,就能保证对象不会被释放。当活动引用的数量减少到 0 时,该实例就会被释放。注意,虽然释放的确切语义可能是特定于实现的,但是至少已经释放的对象应该不可再用了。

引用计数对于池化实现(如 PooledByteBufAllocator)来说是至关重要的,它降低了
内存分配的开销。

7. ByteBuf的优点

本文最后来总结下ByteBuf的优点,首先从上文可以看出Netty 的数据处理 主要通过ByteBuf 和ByteBufHolder,ByteBuf优点可总结为:

  • 它可以被用户自定义的缓冲区类型扩展;
  • 通过内置的复合缓冲区类型实现了透明的零拷贝;
  • 容量可以按需增长(类似于 JDK 的 StringBuilder);
  • 在读和写这两种模式之间切换不需要调用 ByteBuffer 的 flip()方法;
  • 读和写使用了不同的索引;
  • 支持方法的链式调用;
  • 支持引用计数;
  • 支持池化。
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Netty-WebSocket-Spring-Boot-Starter是一个用于将Websocket集成到Spring Boot应用程序的库。它使用Netty作为底层框架,提供了一种快速和可靠的方式来处理异步通信。 这个库提供了一种简单的方法来创建Websocket端点,只需要使用注释和POJO类即可。在这些端点上可以添加动态的事件处理程序,以处理连接、断开连接和消息事件等。 此外,Netty-WebSocket-Spring-Boot-Starter还包括了一些安全性的特性,如基于令牌的授权和XSS保护,可以帮助您保持您的Websocket应用程序安全。 总的来说,Netty-WebSocket-Spring-Boot-Starter提供了一种快速和易于使用的方式来构建Websocket应用程序,使得它成为应用程序开发人员的有用工具。 ### 回答2: netty-websocket-spring-boot-starter 是一个开源的 Java Web 开发工具包,主要基于 Netty 框架实现了 WebSocket 协议的支持,同时集成了 Spring Boot 框架,使得开发者可以更加方便地搭建 WebSocket 服务器。 该工具包提供了 WebSocketServer 配置类,通过在 Spring Boot 的启动配置类调用 WebSocketServer 配置类,即可启动 WebSocket 服务器。同时,该工具包还提供了多种配置参数,如端口号、URI 路径、SSL 配置、认证配置等等,可以根据业务需求进行自定义配置。 此外,该工具包还提供了一些可扩展的接口和抽象类,如 WebSocketHandler、ChannelHandlerAdapter 等,可以通过继承和实现这些接口和抽象类来实现业务逻辑的处理和拓展。 总的来说,netty-websocket-spring-boot-starter 提供了一个高效、简单、易用的 WebSocket 服务器开发框架,可以减少开发者的开发成本和工作量。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值