netty从入门到放弃—消息的粘包和拆包

何为拆包和粘包


在我们计算机使用网络的过程中,计算机可能同时存在多个网络程序,网路上又有无数的网络应用在占用带宽,这就导致了我们的消息在底层的消息发送中,它很有可能是不完整的,在解释拆包和粘包之前,我们先来跑一段程序,代码的只要逻辑是客户端连续给服务端发送100条消息,看看服务端每次接收的结果,主要代码如下:

客户端:

bootstrap.group(nioEventLoopGroup)
//                .option(ChannelOption.SO_BACKLOG, 1024)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到服务器回复 [{}]", msg);
                                }

                                @Override
                                public void channelActive(ChannelHandlerContext ctx) {
                                    for (int i= 0; i < 100; i++) {
                                        String lineStr = "你好欢迎来netty到的世界!";
                                        log.info("发送消息长度: {}", lineStr.getBytes().length);
                                        ctx.channel().writeAndFlush(lineStr);
                                    }
                                }
                            });
                            ch.pipeline().addLast(new StringEncoder());
                        }
                    }).connect("127.0.0.1", 8888);

服务端

serverBootstrap.group(bossGroup, workerGroup)
        .option(ChannelOption.SO_BACKLOG, 1024)
        .childOption(ChannelOption.TCP_NODELAY, true)
        .childOption(ChannelOption.SO_KEEPALIVE, true)
        .channel(NioServerSocketChannel.class)
        .childHandler(new ChannelInitializer<NioSocketChannel>() {
            @Override
            protected void initChannel(NioSocketChannel ch) {
                ch.pipeline().addLast(new StringDecoder());
                ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                    @Override
                    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
                        log.info("收到客户端消息:[{}]", msg);
                    }
                });
            }
        });
ChannelFuture channelFuture = serverBootstrap.bind(8888).sync();
channelFuture.channel().closeFuture().sync();

打印结果:

...
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!你好欢迎来netty到的世]
 收到客户端消息:[界!你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!]
 收到客户端消息:[你好欢迎来netty到的世界!你好欢迎来netty到的世]
 收到客户端消息:[界!]
 ...

粘包和半包产生的原因


粘包: 多个数据包被放在一起发送,好像被"粘"在了一起
半包: 正常的数据包被拆开了,只有完整包的部分数据
拆包: 正常的数据包被拆成了半包,或者需要把粘包拆成正常数据包的过程

虽然我们在应用中是一个一个包是发送的,但是计算机底层只认tcp协议,在正常情况下我们发送数据包,应该是一个包接一个包的发送,但底层的TCP协议还是按照字节流发送数据包,数据包进入底层缓冲池中,tcp定时发送,假如缓冲池满了,底层会马上发送数据包,一旦出现这种情况下,数据包只能被拆开,或者被拆开的部分跟之前的正常数据包"粘"在一起发送,那么服务端就接收到被"粘"在一起的数据包。如果我们要数据正常使用,服务端就需要把接收到的粘包拆开,得到正常的数据包以及多余的半包,半包需要等待下一次发送过来的半包,根据协议拼接回原来正常的数据包。

在这里插入图片描述

处理粘包和半包的方案


  1. 客户端发送固定长度的包,服务端解析获取到固定长度时再根据协议解析数据,流程如下
    在这里插入图片描述
    发送的数据不是一次性发送约定的长度,可能是一次发送大于约定长度,也可能分为几次发送才发送到约定长度,像下图这样

在这里插入图片描述

  1. 根据特定的分隔符来解析数据,也即读取到某个分隔符时确定该数据包已经读取完整,思路如1

  2. 把数据包分为消息头和消息体进行读取,消息头固定长度,里面有一个字段记录了接下来的消息体的长度,然后再读取到固定的消息体就为一条完整的消息

netty内置的拆包方案


1 固定长度的拆包器 FixedLengthFrameDecoder

这个拆包器对应我们讲的第一种拆包方案,都是读取到固定长度的数据时才开始解析,如果对应的需求的消息协议非常简单,可以使用这个拆包器实现

客户端

@Slf4j
public class NettyClientStr {
    public static void main(String[] args) {
        Bootstrap bootstrap = new Bootstrap();
        NioEventLoopGroup nioEventLoopGroup = new NioEventLoopGroup();

        try {
            bootstrap.group(nioEventLoopGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到服务器回复 [{}]", msg);
                                }

                                @Override
                                public void channelActive(ChannelHandlerContext ctx) {
                                    for (int i= 0; i < 1000; i++) {
                                        String fixedLengthStr = "你好,欢迎来到netty的世界!";
                                        ctx.channel().writeAndFlush(fixedLengthStr);
                                    }
                                }
                            });
                            ch.pipeline().addLast(new StringEncoder());
                        }
                    }).connect("127.0.0.1", 8888).addListener(
                    future -> {
                        if (future.isSuccess()) {
                            log.info("链接服务器成功");
                        } else {
                            log.info("链接服务器失败");
                            System.exit(0);
                        }
                    }
            ).channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            nioEventLoopGroup.shutdownGracefully();
        }
    }
}

服务端

@Slf4j
public class NettyServerFixedLengthFrameDecoder {

    public static void main(String[] args) {
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        NioEventLoopGroup bossGroup = new NioEventLoopGroup();
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            serverBootstrap.group(bossGroup, workerGroup)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            ch.pipeline().addLast(new FixedLengthFrameDecoder(35));
                            ch.pipeline().addLast(new StringDecoder());
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {
                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到客户端消息:[{}]", msg);
                                }
                            });
                        }
                    });
            ChannelFuture channelFuture = serverBootstrap.bind(8888).sync();
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 优雅关闭
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

客户端在启动时,向服务端重复发送1000条一样的消息,服务端收到并且解析,可以看到,这1000条消息都能被服务端正确的打印,服务端已经能够正常的解析到数据了,通过读取固定长度的消息包解决了粘包或者半包的问题

2 分隔符拆包器 DelimiterBasedFrameDecoder

对应上面第二种方式解码,netty内置了分隔符拆包器,可以自定义分隔符,当服务端接收到约定的分隔符时,就认为一条消息已经读取完毕了,对应的代码如下

客户端

@Slf4j
public class NettyClientStr {
    public static void main(String[] args) {
        Bootstrap bootstrap = new Bootstrap();
        NioEventLoopGroup nioEventLoopGroup = new NioEventLoopGroup();

        try {
            bootstrap.group(nioEventLoopGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到服务器回复 [{}]", msg);
                                }

                                @Override
                                public void channelActive(ChannelHandlerContext ctx) {
                                    for (int i= 0; i < 1000; i++) {
                                        String delimiterStr = "你好,欢迎来到[demo]netty的世界![demo]";
                                        ctx.channel().writeAndFlush(delimiterStr);
                                    }
                                }
                            });
                            ch.pipeline().addLast(new StringEncoder());
                        }
                    }).connect("127.0.0.1", 8888).addListener(
                    future -> {
                        if (future.isSuccess()) {
                            log.info("链接服务器成功");
                        } else {
                            log.info("链接服务器失败");
                            System.exit(0);
                        }
                    }
            ).channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            nioEventLoopGroup.shutdownGracefully();
        }
    }
}

服务端

@Slf4j
public class NettyServerDelimiterBasedFrameDecoder {

    public static void main(String[] args) {
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        NioEventLoopGroup bossGroup = new NioEventLoopGroup();
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            serverBootstrap.group(bossGroup, workerGroup)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(Integer.MAX_VALUE, Unpooled.copiedBuffer("[demo]".getBytes())));
                            ch.pipeline().addLast(new StringDecoder());
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到客户端消息:[{}]", msg);
                                }
                            });
                        }
                    });
            ChannelFuture channelFuture = serverBootstrap.bind(8888).sync();
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 优雅关闭
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

根据分隔符"[demo]"进行分隔消息,服务端也都解析到了这1000次发送的消息

3 行拆包器 LineBasedFrameDecoder

行拆包器也是一种分隔符拆包器,只不过它约定了读取到换行符时就认为这是一条消息的结束

客户端

@Slf4j
public class NettyClientStr {
    public static void main(String[] args) {
        Bootstrap bootstrap = new Bootstrap();
        NioEventLoopGroup nioEventLoopGroup = new NioEventLoopGroup();

        try {
            bootstrap.group(nioEventLoopGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到服务器回复 [{}]", msg);
                                }

                                @Override
                                public void channelActive(ChannelHandlerContext ctx) {
                                    for (int i= 0; i < 1000; i++) {
                                        String lineStr = "你\n好\n欢\n迎\n来\n到\n的\n世\n界!";
                                        ctx.channel().writeAndFlush(lineStr);
                                    }
                                }
                            });
                            ch.pipeline().addLast(new StringEncoder());
                        }
                    }).connect("127.0.0.1", 8888).addListener(
                    future -> {
                        if (future.isSuccess()) {
                            log.info("链接服务器成功");
                        } else {
                            log.info("链接服务器失败");
                            System.exit(0);
                        }
                    }
            ).channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            nioEventLoopGroup.shutdownGracefully();
        }
    }
}

服务端

@Slf4j
public class NettyServerLineBasedFrameDecoder {

    public static void main(String[] args) {
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        NioEventLoopGroup bossGroup = new NioEventLoopGroup();
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            serverBootstrap.group(bossGroup, workerGroup)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            ch.pipeline().addLast(new LineBasedFrameDecoder(Integer.MAX_VALUE));
                            ch.pipeline().addLast(new StringDecoder());
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<String>() {

                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, String msg) {
                                    log.info("收到客户端消息:[{}]", msg);
                                }
                            });
                        }
                    });
            ChannelFuture channelFuture = serverBootstrap.bind(8888).sync();
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 优雅关闭
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

4 基于长度域拆包器 LengthFieldBasedFrameDecoder

这个拆包器是使用得最多的一个消息处理器,它能够处理各种通用的消息协议,下面来看看该类的构造函数以及每个构造函数的参数

public LengthFieldBasedFrameDecoder(
        ByteOrder byteOrder, int maxFrameLength, int lengthFieldOffset, int lengthFieldLength,
        int lengthAdjustment, int initialBytesToStrip, boolean failFast) {
    ...
}

我们自定义消息协议的编解码,基本上都是按照以上几个参数进行的

  1. maxFrameLength - 解析的最大帧长度,也即接收到客户端消息时,消息头和消息体长度所允许的最大长度;
  2. lengthFieldOffset - 长度域所在数据包字节数组中的下标,也就是说表示消息体长度的字段起始位置所在下下标;
  3. lengthFieldLength - 表示长度域的长度,也就是说长度域在消息头中所占的字节数;
  4. lengthAdjustment - 长度域的偏移量矫正。 如果长度域的值,除了包含有效数据域的长度外,还包含了其他域(如长度域自身)长度,那么,就需要进行矫正。它们之间的算法为:byteLength(发送的数据包长度) = lengthFieldLengthValue(长度域的值) + lengthFieldOffset(长度字段偏移量) + lengthFieldLength(长度域的长度) + lengthAdjustment
  5. initialBytesToStrip - 接收到的数据包,去除前initialBytesToStrip位
  6. failFast - true: 读取到长度域超过maxFrameLength,就抛出一个 TooLongFrameException。false: 只有真正读取完长度域的值表示的字节之后,才会抛出 TooLongFrameException,默认情况下设置为true,建议不要修改,否则可能会造成内存溢出;
  7. byteOrder - 数据存储采用大端模式还是小端模式,默认采用大端模式,没有特殊的需求,建议不要修改。

下面举例说明每个参数的用法

客户端发送的内容为 “你好,欢迎来到netty的世界”

场景一

数据包大小: 35Byte = 2Byte(一个short大小的数据长度域) + 33Byte(数据包长度)
在这里插入图片描述
由看图可以看出,解码后,数据格式以及长度没有发生变化,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=0

lengthFieldLength: 约定了为一个short类型的长度, lengthFieldLength=2(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 35 - 33 - 0 - 2 = 0

initialBytesToStrip: 由于解码前后没有改变数据包长度,此处为0

场景二

解码后去除长度域,只保留消息体(33Byte)
在这里插入图片描述
由看图可以看出,解码后,只保留了具体的消息体数据,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=0

lengthFieldLength: 约定了为一个short类型的长度, lengthFieldLength=2(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 35 - 33 - 0 - 2 = 0

initialBytesToStrip: 由于解码后丢失了2个byte的长度,所以此处为2

场景三

解码前后数据包不变,但是长度域中多存了两个字节

在这里插入图片描述
由看图可以看出,解码后,保留了完整数据包,但是长度域中被添加了一个多余的长度,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=0

lengthFieldLength: 约定了为一个short类型的长度, lengthFieldLength=2(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 35 - 35 - 0 - 2 = -2

initialBytesToStrip: 由于解码前后没有改变数据包长度,此处为0

场景四

解码前后数据包大小不变,数据包前面是一个固定大小的消息头

在这里插入图片描述
由看图可以看出,增加了一个没有使用到的消息头,解码后,保留了完整数据包,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=2

lengthFieldLength: 约定了为一个int类型的长度, lengthFieldLength=4(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 39 - 33 - 2 - 4 = 0

initialBytesToStrip: 由于解码前后没有改变数据包长度,此处为0

场景五

解码前后数据包大小不变,但是消息头在长度域和消息体中间

在这里插入图片描述
由看图可以看出,消息头在长度域和消息体之间,消息头占2个byte的长度,解码后,保留了完整数据包,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=0

lengthFieldLength: 约定了为一个int类型的长度, lengthFieldLength=4(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 39 - 33 - 0 - 4 = 2

initialBytesToStrip: 由于解码前后没有改变数据包长度,此处为0

场景六

数据包前面是消息头1,中间是长度域,再后面是消息头2,接下来才是消息体,解码后只保留消息头2和消息体
在这里插入图片描述
由看图可以看出,解码后,去除了消息头1和长度域,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=1

lengthFieldLength: 约定了为一个int类型的长度, lengthFieldLength=2(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 37 - 33 - 1 - 2 = 1

initialBytesToStrip: 由于解码后去除了消息头1(1byte)和长度域(2byte),此处为3

场景七

数据包结构跟场景六是一样的,唯一的区别是此处的长度域的值是整个数据包的长度
在这里插入图片描述
由看图可以看出,解码后,去除了消息头1和长度域,那么,LengthFieldBasedFrameDecoder 的参数值为

maxFrameLength: 大于该数据包长度的任意值,这里给了Integer.MAX_VALUE的长度

lengthFieldOffset: 长度域的起始位置就在消息的头部,offset=1

lengthFieldLength: 约定了为一个int类型的长度, lengthFieldLength=2(byte)

lengthAdjustment: byteLength - lengthFieldLengthValue - lengthFieldOffset - lengthFieldLength = 37 - 37 - 1 - 2 = -3

initialBytesToStrip: 由于解码后去除了消息头1(1byte)和长度域(2byte),此处为3

源码地址

Netty中的TCP粘包拆包问题是由于底层的TCP协议无法理解上层的业务数据而导致的。为了解决这个问题,Netty提供了几种解决方案。其中,常用的解决方案有四种[1]: 1. 固定长度的拆包器(FixedLengthFrameDecoder):将每个应用层数据包拆分成固定长度的大小。这种拆包器适用于应用层数据包长度固定的情况。 2. 行拆包器(LineBasedFrameDecoder):将每个应用层数据包以换行符作为分隔符进行分割拆分。这种拆包器适用于应用层数据包以换行符作为结束符的情况。 3. 分隔符拆包器(DelimiterBasedFrameDecoder):将每个应用层数据包通过自定义的分隔符进行分割拆分。这种拆包器适用于应用层数据包以特定分隔符作为结束标志的情况。 4. 基于数据包长度的拆包器(LengthFieldBasedFrameDecoder):将应用层数据包的长度作为接收端应用层数据包的拆分依据。根据应用层协议中包含的数据包长度进行拆包。这种拆包器适用于应用层协议中包含数据包长度的情况。 除了使用这些拆包器,还可以根据业界主流协议的解决方案来解决粘包拆包问题[3]: 1. 消息长度固定:累计读取到长度和为定长LEN的报文后,就认为读取到了一个完整的信息。 2. 使用特殊的分隔符:将换行符或其他特殊的分隔符作为消息的结束标志。 3. 在消息头中定义长度字段:通过在消息头中定义长度字段来标识消息的总长度。 综上所述,Netty提供了多种解决方案来解决TCP粘包拆包问题,可以根据具体的业务需求选择合适的解决方案[1][3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值