图文分析ByteBuf是什么

ByteBuf是什么

ByteBuf是Netty中非常重要的一个组件,他就像物流公司的运输工具:卡车,火车,甚至是飞机。而物流公司靠什么盈利,就是靠运输货物,可想而知ByteBuf在Netty中是多么的重要。没有了ByteBuf,Netty就失去了灵魂,其他所有的都将变得毫无意义。

ByteBuf是由Byte和Buffer两个词组合成的一个词,但是因为JDK中已经有了一个ByteBuffer,并且使用非常复杂,API及其不友好,可谓是千夫所指。为了扭转ByteBuffer在大家心目中的形象,Netty重新设计了一个ByteBuffer,即 ByteBuf

从字面上我们可以知道 ByteBuf 是处理字节的,并且还有一种缓冲的能力。

ByteBuf在官方中是这样定义的:

A random and sequential accessible sequence of zero or more bytes (octets).
This interface provides an abstract view for one or more primitive byte
arrays ({@code byte[]}) and {@linkplain ByteBuffer NIO buffers}.

就是说 ByteBuf 是一个字节序列,可以随机或连续存取零到多个字节。他提供了一个统一的抽象,通过 ByteBuf 可以操作基础的字节数组和ByteBuffer缓冲区。

需要注意的是这里说的 interface 是不准确的,因为Trustin Lee在2013/7/8将ByteBuffer从接口改成了抽象类,具体的原因不得而知。

ByteBuf的结构

ByteBuf比JDK中原生的ByteBuffer好的原因是前者的设计比后者优秀,ByteBuf有读和写两个指针,而ByteBuffer只有一个指针,需要通过flip()方法在读和写之间进行模式切换,需要操作的越多往往犯错的概率就越大。ByteBuf将读和写进行了分离,使用者不用再关心现在是读还是写的模式,可以把更多的精力用在具体的业务上。

官方定义中指出,ByteBuf主要是通过两个指针进行数据的读和写,分别是 readerIndexwriterIndex ,并且整个ByteBuf被这两个指针最多分成三个部分,分别是可丢弃部分可读部分可写部分,可以用一张图直观的描述ByteBuf的结构,如下图所示:

image-byte-buf-structure.png

可能有人注意到了我说ByteBuf最多被分成三个部分,那是因为某些情况下可能只有一到两部分:

  • 刚初始化的时候

image-byte-buf-inited.png

刚初始化的时候,读写指针都是0,所有的内容都是可写部分,此时还没有可读部分和可丢弃部分。

  • 刚写完数据后

image-byte-buf-writed.png

刚写完一些数据后,读指针仍然是0,写指针向后移动了n,这里的n就是写入的字节数。

  • 读完一部分数据并丢弃之后

image-byte-buf-after-discard.png

写入完数据之后,紧接着读取一部分数据,然后立刻丢弃掉,此时ByteBuf的结构就会变成跟第二步中的一样。因为丢弃的动作会将读指针向左移动到0的位置,写指针向左移动的距离=原来读指针的值

ByteBuf的读写操作

写操作

ByteBuf中定义了两类方法可以往ByteBuf中写入内容:writeXX()setXX()

具体的setXX()类的方法可以用下面的一张表格来描述:

方法名描述
setByte(int index, int value)将指定位置上的内容修改为指定的byte的值
高24位上的内容将被丢弃
setBoolean(int index, boolean value)将指定位置上的内容修改为指定的boolean的值
setBytes(int index,byte src)将指定的字节内容
可以从byte[],ByteBuf,ByteBuffer,InputStream,Channel等中获取
转移到指定的位置
setChar*(int index, int value)将指定位置上的内容修改为指定的character的UTF-16编码下2-byte的值
高16位上的内容将被丢弃
setShort*(int index, int value)将指定位置上的内容修改为指定的integer的低16-bit的值
高16位上的内容将被丢弃
setMidium*(int index, int value)将指定位置上的内容修改为指定的integer的中间24-bit的值
大多数重要的内容将被丢弃
setInt*(int index, int value)将指定位置上的内容修改为指定的32-bit的integer的值
setFloat*(int index, float value)将指定位置上的内容修改为指定的32-bit的float的值
setDouble*(int index, double value)将指定位置上的内容修改为指定的64-bit的float的值
setLong*(int index, long value)将指定位置上的内容修改为指定的64-bit的long的值
setZero(int index, int length)将从指定位置index开始之后的length个长度的值设置为0x00

我们知道java中一个int占4个字节,即32bit,一个short占2个字节,一个int可以拆成2个short,所以就会存在当写入一个short时,参数用int来传值时,高16位的内容会被丢弃。这是因为一个int被拆成了两个short,而写入一个short到指定的位置时,那么另一个short就被丢弃了,且是高16位的这个short。

有的人注意到了上面好多方法后面都有*,这是表示这些方法还有一种兄弟方法,如setInt对应的是setIntLE,这表示以小端字节序的方式写入内容。简单来说一般网络传输采用大端字节序,另外我们人类写字节的顺序也是大端字节序,而计算机处理字节的顺序一般是小端字节序(但是也不绝对,计算机从低电平开始读取字节时效率更高),具体什么是大端字节序,什么是小端字节序不是本篇文章深入研究的范围,大家可以自行查阅有关资料。

PS:需要注意的是如果写入的位置index小于0,或者index加上写入内容的值超过capcity的话,会抛出 IndexOutOfBoundsException,所以就存在两个比较重要的方法:isWritable()isReadable(),他们将返回当前ByteBuf中是否还有足够的空间可以写和可以读

具体的writeXX()方法与上面的setXX()方法类似,不同的是writeXX()方法会更新写指针,即向ByteBuf中写入具体的内容后,writeIndex会向后移动与写入的内容字节数长度相同的距离。

读操作

跟写操作一样,ByteBuf的读操作也有两种方法,分别是getXX()和readXX()。

读操作包含的具体方法与写操作也是一一对应的,具体的可以把上面的那张表格中的set改为get,并且将第二个value参数移除即可,例如:getShort(int index)getInt(int index)等等。

与getXX()方法相关的另一类方法就是readXX()方法了,与get方法不同的是,read方法会更改读指针的值。

ByteBuf的种类

我们知道ByteBuf在4.x的版本中是一个抽象类,他有很多的抽象子类以及各种实现类。

画了一个简单的ByteBuf的各个实现类之间的关系,其中蓝色的类是被弃用的。

image-byte-buf-hierarchy.png

上图只是简单的列举的一些常用的ByteBuf类,如果你想知道ByteBuf所有的实现类,那么可以在IDEA中选

则ByteBuf类之后,然后在菜单 navigate 中点击 Type Hierarchy 或用快捷键:control+H,即可打开ByteBuf的类层次结构图,具体的层级结构如下图所示:

image-byte-buf-type-hierarchy.png

本篇文章只简单的让大家对于ByteBuf的种类有个大概的了解,具体的每一种ByteBuf的作用我将在后续的章节中进行介绍。

ByteBuf的使用

有一点我们需要知道的是,ByteBuf的jar包,是可以单独使用的。比如某个项目中有一个场景,需要处理某个自定义的协议,那么我们在解析协议时,就可以将接收到的将字节内容写入一个ByteBuf,然后从ByteBuf中慢慢的将内容读取出来。下面让我们用一个例子简单的了解下ByteBuf的使用。

ByteBuf的创建

要想使用ByteBuf,首先肯定是要创建一个ByteBuf,更确切的说法就是要申请一块内存,后续可以在这块内存中执行写入数据读取数据等等一系列的操作。

那么如何创建一个ByteBuf呢?Netty中设计了一个专门负责分配ByteBuf的接口:ByteBufAllocator。该接口有一个抽象子类和两个实现类,分别对应了用来分配池化的ByteBuf和非池化的ByteBuf。
具体的层级关系如下图所示:

image-byte-buf-allocator-hierarchy.png

有了Allocator之后,Netty又为我们提供了两个工具类:Pooled、Unpooled,分类用来分配池化的和未池化的ByteBuf,进一步简化了创建ByteBuf的步骤,只需要调用这两个工具类的静态方法即可。

不同的创建方法

我们以Unpooled类为例,查看Unpooled的源码可以发现,他为我们提供了许多创建ByteBuf的方法,但最终都是以下这几种,只是参数不一样而已:

// 在堆上分配一个ByteBuf,并指定初始容量和最大容量
public static ByteBuf buffer(int initialCapacity, int maxCapacity) {
    return ALLOC.heapBuffer(initialCapacity, maxCapacity);
}
// 在堆外分配一个ByteBuf,并指定初始容量和最大容量
public static ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
    return ALLOC.directBuffer(initialCapacity, maxCapacity);
}
// 使用包装的方式,将一个byte[]包装成一个ByteBuf后返回
public static ByteBuf wrappedBuffer(byte[] array) {
    if (array.length == 0) {
        return EMPTY_BUFFER;
    }
    return new UnpooledHeapByteBuf(ALLOC, array, array.length);
}
// 返回一个组合ByteBuf,并指定组合的个数
public static CompositeByteBuf compositeBuffer(int maxNumComponents){
    return new CompositeByteBuf(ALLOC, false, maxNumComponents);
}

其中包装方法除了上述这个方法之外,还有一些其他常用的包装方法,比如参数是一个ByteBuf的包装方法,比如参数是一个原生的ByteBuffer的包装方法,比如指定一个内存地址和大小的包装方法等等。
另外还有一些copy*开头的方法,实际是调用了buffer(int initialCapacity, int maxCapacity)或directBuffer(int initialCapacity, int maxCapacity)方法,然后将具体的内容write进生成的ByteBuf中返回。
以上所有的这些方法都实际通过一个叫ALLOC的静态变量进行了调用,来实现具体的ByteBuf的创建,而这个ALLOC实际是一个ByteBufAllocator:

private static final ByteBufAllocator 
ALLOC = UnpooledByteBufAllocator.DEFAULT;

ByteBufAllocator是一个专门负责ByteBuf分配的接口,对应的Unpooled实现类就是UnpooledByteBufAllocator。在UnpooledByteBufAllocator类中可以看到UnpooledByteBufAllocator.DEFAULT变量是一个final类型的静态变量

/**
 * Default instance which uses leak-detection for direct buffers.
 * 默认的UnpooledByteBufAllocator实例,并且会对堆外内存进行泄漏检测
 */
public static final UnpooledByteBufAllocator 
DEFAULT = new UnpooledByteBufAllocator(PlatformDependent.directBufferPreferred());
涉及的设计模式

ByteBuf和ByteBufAllocator之间是一种相辅相成的关系,ByteBufAllocator用来创建一个ByteBuf,而ByteBuf亦可以返回创建他的Allocator。ByteBuf和ByteBufAllocator之间是一种 抽象工厂模式,具体可以用一张图描述如下:

image-allocator-and-byte-buf.png

下面我来用一个实际的例子来说明ByteBuf的使用,并通过观察在不同阶段ByteBuf的读写指针的值和ByteBuf的容量变化来更加深入的了解ByteBuf的设计,为了方便,我会用非池化的分配器来创建ByteBuf。

使用示例

我构造了一个demo,来演示在ByteBuf中插入数据、读取数据、清空读写指针、数据清零、扩容等等方法,具体的代码如下:

private static void simpleUse(){
    // 1.创建一个非池化的ByteBuf,大小为10个字节
    ByteBuf buf = Unpooled.buffer(10);
    System.out.println("原始ByteBuf为====================>"+buf.toString());
    System.out.println("1.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");

    // 2.写入一段内容
    byte[] bytes = {1,2,3,4,5};
    buf.writeBytes(bytes);
    System.out.println("写入的bytes为====================>"+Arrays.toString(bytes));
    System.out.println("写入一段内容后ByteBuf为===========>"+buf.toString());
    System.out.println("2.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");

    // 3.读取一段内容
    byte b1 = buf.readByte();
    byte b2 = buf.readByte();
    System.out.println("读取的bytes为====================>"+Arrays.toString(new byte[]{b1,b2}));
    System.out.println("读取一段内容后ByteBuf为===========>"+buf.toString());
    System.out.println("3.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");

    // 4.将读取的内容丢弃
    buf.discardReadBytes();
    System.out.println("将读取的内容丢弃后ByteBuf为========>"+buf.toString());
    System.out.println("4.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");

    // 5.清空读写指针
    buf.clear();
    System.out.println("将读写指针清空后ByteBuf为==========>"+buf.toString());
    System.out.println("5.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");

    // 6.再次写入一段内容,比第一段内容少
    byte[] bytes2 = {1,2,3};
    buf.writeBytes(bytes2);
    System.out.println("写入的bytes为====================>"+Arrays.toString(bytes2));
    System.out.println("写入一段内容后ByteBuf为===========>"+buf.toString());
    System.out.println("6.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");

    // 7.将ByteBuf清零
    buf.setZero(0,buf.capacity());
    System.out.println("将内容清零后ByteBuf为==============>"+buf.toString());
    System.out.println("7.ByteBuf中的内容为================>"+Arrays.toString(buf.array())+"\n");

    // 8.再次写入一段超过容量的内容
    byte[] bytes3 = {1,2,3,4,5,6,7,8,9,10,11};
    buf.writeBytes(bytes3);
    System.out.println("写入的bytes为====================>"+Arrays.toString(bytes3));
    System.out.println("写入一段内容后ByteBuf为===========>"+buf.toString());
    System.out.println("8.ByteBuf中的内容为===============>"+Arrays.toString(buf.array())+"\n");
}

执行结果如下:

原始ByteBuf为====================>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 0, cap: 10)
1.ByteBuf中的内容为===============>[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

写入的bytes为====================>[1, 2, 3, 4, 5]
写入一段内容后ByteBuf为===========>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 5, cap: 10)
2.ByteBuf中的内容为===============>[1, 2, 3, 4, 5, 0, 0, 0, 0, 0]

读取的bytes为====================>[1, 2]
读取一段内容后ByteBuf为===========>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 2, widx: 5, cap: 10)
3.ByteBuf中的内容为===============>[1, 2, 3, 4, 5, 0, 0, 0, 0, 0]

将读取的内容丢弃后ByteBuf为========>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 3, cap: 10)
4.ByteBuf中的内容为===============>[3, 4, 5, 4, 5, 0, 0, 0, 0, 0]

将读写指针清空后ByteBuf为==========>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 0, cap: 10)
5.ByteBuf中的内容为===============>[3, 4, 5, 4, 5, 0, 0, 0, 0, 0]

写入的bytes为====================>[1, 2, 3]
写入一段内容后ByteBuf为===========>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 3, cap: 10)
6.ByteBuf中的内容为===============>[1, 2, 3, 4, 5, 0, 0, 0, 0, 0]

将内容清零后ByteBuf为==============>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 3, cap: 10)
7.ByteBuf中的内容为================>[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

写入的bytes为====================>[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
写入一段内容后ByteBuf为===========>UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 14, cap: 64)
8.ByteBuf中的内容为===============>[0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

执行过程分析

下面让我们来仔细的研究下执行的过程,并分析下为什么会产生这样的执行结果。

  • 1.初始化一个大小为10的ByteBuf

image-byte-buf-use-1.png

刚初始化的ByteBuf对象,容量为10,读写指针都为0,且每个字节的值都为0,并且这些字节都是“可写”的,我们用红色来表示。

  • 2.写入一段内容

image-byte-buf-use-2.png

当写入一段内容后(这里写入的是5个字节),写指针向后移动了5个字节,写指针的值变成了5,而读指针没有发生变化还是0,但是读指针和写指针之间的字节现在变成了“可读”的状态了,我们用紫色来表示。

  • 3.读取一段内容

image-byte-buf-use-3.png

接着我们有读取了2个字节的内容,这时读指针向后移动了2个字节,读指针的值变成了2,写指针不变,此时0和读指针之间的内容变成了“可丢弃”的状态了,我们用粉色来表示。

  • 4.将读取的内容丢弃

image-byte-buf-use-4.png

紧接着,我们将刚刚读取完的2个字节丢弃掉,这时ByteBuf把读指针与写指针之间的内容(即 345 三个字节)移动到了0的位置,并且将读指针更新为0,写指针更新为原来写指针的值减去原来读指针的值。但是需要注意的是,第4和第5个字节的位置上,还保留的原本的内容,只是这两个字节由原来的“可读”变成了现在的“可写”。

  • 5.将读写指针清空

image-byte-buf-use-5.png

然后,我们执行了一个 clear 方法,将读写指针同时都置为0了,此时所有的字节都变成“可写”了,但是需要注意的是,clear方法只是更改的读写指针的值,每个位置上原本的字节内容并没有发生改变。

  • 6.再次写入一段较少的内容

image-byte-buf-use-6.png

然后再次写入一段内容后,读指针不变,写指针向后移动了具体的字节数,这里是向后移动了三个字节。且写入的这三个字节变成了“可读”状态。

  • 7.将ByteBuf中的内容清零

image-byte-buf-use-7.png

清零(setZero)和清空(clear)的方法是两个概念完全不同的方法,“清零”是把指定位置上的字节的值设置为0,除此之外不改变任何的值,所以读写指针的值和字节的“可读写”状态与上次保持一致,而“清空”则只是将读写指针都置为0,并且所有字节都变成了“可写”状态。

  • 8.写入一段超过容量的内容

image-byte-buf-use-8.png

最后我们往ByteBuf中写入超过ByteBuf容量的内容,这里是写入了11个字节,此时ByteBuf原本的容量不足以写入这些内容了,所以ByteBuf发生了扩容。其实只要写入的字节数超过可写字节数,就会发生扩容了。

扩容分析

那么扩容是怎么扩的呢,为什么容量从10扩容到64呢?我们从源码中找答案。

扩容肯定发生在写入字节的时候,让我们找到 writeBytes(byte[] bytes) 方法,具体如下:

@Override
public ByteBuf writeBytes(byte[] src) {
    writeBytes(src, 0, src.length);
    return this;
}
@Override
public ByteBuf writeBytes(byte[] src, int srcIndex, int length) {
    // 该方法检查是否有足够的可写空间,是否需要进行扩容
    ensureWritable(length);
    setBytes(writerIndex, src, srcIndex, length);
    writerIndex += length;
    return this;
}

在进入 ensureWritable(length) 方法内部查看,具体的代码如下:

@Override
public ByteBuf ensureWritable(int minWritableBytes) {
    if (minWritableBytes < 0) {
        throw new IllegalArgumentException(String.format(
            "minWritableBytes: %d (expected: >= 0)", minWritableBytes));
    }
    ensureWritable0(minWritableBytes);
    return this;
}

final void ensureWritable0(int minWritableBytes) {
    // 检查该ByteBuf对象的引用计数是否为0,保证该对象在写入之前是可访问的
    ensureAccessible();
    if (minWritableBytes <= writableBytes()) {
        return;
    }
    if (minWritableBytes > maxCapacity - writerIndex) {
        throw new IndexOutOfBoundsException(String.format(
            "writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s",
            writerIndex, minWritableBytes, maxCapacity, this));
    }
    // Normalize the current capacity to the power of 2.
    // 计算新的容量,即为当前容量扩容至2的幂次方大小
    int newCapacity = alloc().calculateNewCapacity(writerIndex + minWritableBytes, maxCapacity);
    // Adjust to the new capacity.
    // 设置扩容后的容量
    capacity(newCapacity);
}

从上面的代码中可以很清楚的看出来,计算新的容量的方法是调用的 ByteBufAllocator 的 calculateNewCapacity() 方法,继续跟进去该方法,这里的 ByteBufAllocator 的实现类是 AbstractByteBufAllocator ,具体的代码如下:

@Override
public int calculateNewCapacity(int minNewCapacity, int maxCapacity) {
    if (minNewCapacity < 0) {
        throw new IllegalArgumentException("minNewCapacity: " + minNewCapacity + " (expected: 0+)");
    }
    if (minNewCapacity > maxCapacity) {
        throw new IllegalArgumentException(String.format(
            "minNewCapacity: %d (expected: not greater than maxCapacity(%d)",
            minNewCapacity, maxCapacity));
    }
    // 扩容的阈值,4兆字节大小
    final int threshold = CALCULATE_THRESHOLD; // 4 MiB page
    if (minNewCapacity == threshold) {
        return threshold;
    }
    // If over threshold, do not double but just increase by threshold.
    // 如果要扩容后新的容量大于扩容的阈值,那么扩容的方式改为用新的容量加上阈值,
    // 否则将新容量改为双倍大小进行扩容
    if (minNewCapacity > threshold) {
        int newCapacity = minNewCapacity / threshold * threshold;
        if (newCapacity > maxCapacity - threshold) {
            newCapacity = maxCapacity;
        } else {
            newCapacity += threshold;
        }
        return newCapacity;
    }

    // Not over threshold. Double up to 4 MiB, starting from 64.
    // 如果要扩容后新的容量小于4兆字节,则从64字节开始扩容,每次双倍扩容,
    // 直到小于指定的新容量位置
    int newCapacity = 64;
    while (newCapacity < minNewCapacity) {
        newCapacity <<= 1;
    }

    return Math.min(newCapacity, maxCapacity);
}

到这里就很清楚了,每次扩容时,有一个阈值t(4MB),计划扩容的大小为c,扩容后的值为n。

扩容的规则可以用下面的逻辑表示:

  • 如果c
  • 如果c>t,则n=c/t*t+t

得出的结论

  • ByteBuf有读和写两个指针,用来标记“可读”、“可写”、“可丢弃”的字节
  • 调用write*方法写入数据后,写指针将会向后移动
  • 调用read*方法读取数据后,读指针将会向后移动
  • 写入数据或读取数据时会检查是否有足够多的空间可以写入和是否有数据可以读取
  • 写入数据之前,会进行容量检查,当剩余可写的容量小于需要写入的容量时,需要执行扩容操作
  • 扩容时有一个4MB的阈值,需要扩容的容量小于阈值或大于阈值所对应的扩容逻辑不同
  • clear等修改读写指针的方法,只会更改读写指针的值,并不会影响ByteBuf中已有的内容
  • setZero等修改字节值的方法,只会修改对应字节的值,不会影响读写指针的值以及字节的可读写状态
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值