【Netty入门03】 Netty编解码器

一:什么是编解码器

只要是需要跟其他节点传输数据的应用程序都需要定义如何解析两个节点之间传输的原始数据,以及如何将其转换成目标程序所需要的数据格式(例如由二进制转换为Java对象)。这种转换逻辑就是交由编解码器处理。编解码器由编码器和解码器组成,解码器负责将消息从字节或其他序列形式转成指定的消息对象,而编码器则将消息对象转成字节或其他序列形式以便在网络上传输。

二:netty中的编解码器

Netty中的编解码器分为三大类,分别是编码器、解码器以及编解码器。这三类都是实现了ChannelHandler,使用时也是添加到ChannelPipeline中。

1:编码器

Netty中提供了两个编码器基类

1. MessageToByteEncoder:将消息编码为字节(也就是由对象转换成字节)

2. MessageToMessageEncoder:将消息编码为消息(也就是由对象转换成对象)

这两个编码器类的父类都为ChannelOutboundHandlerAdapter,也就是出站处理器。负责处理出站的数据。他们都定义了一个名为encode的抽象方法,用于对消息进行编码,我们使用时只需要实现这个encode方法即可。

MessageToByteEncoder

MessageToByteEncoder用于将消息转换为字节数据,以便在网络中传输。

由于MessageToByteEncoder用于处理出站消息(因为继承ChannelOutboundHandlerAdapter),所以它的实现逻辑都在write方法中。 下面是MessageToByteEncoder类的write方法的源码。

 @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        ByteBuf buf = null;
        try {
            //1. 判断msg是不是当前encoder所选择的泛型
            if (acceptOutboundMessage(msg)) {
                @SuppressWarnings("unchecked")
                I cast = (I) msg;
                //2. 分配一个buffer, 通过preferDirect参数决定是分配堆内存还是直接内存, 默认preferDirect为true
                buf = allocateBuffer(ctx, cast, preferDirect);
                try {
                    //3. 调用我们自己实现的encode方法对msg进行编码
                    encode(ctx, cast, buf);
                } finally {
                    //释放消息, 减少内存压力
                    ReferenceCountUtil.release(cast);
                }

                //4. 判断buf经过encode方法后是否有数据, 有数据则将buf中的数据传递给下一个handler
                if (buf.isReadable()) {
                    ctx.write(buf, promise);
                } else {
                    //buf没有数据, 直接释放掉, 并传一个空的buf给下一个handler
                    buf.release();
                    ctx.write(Unpooled.EMPTY_BUFFER, promise);
                }
                buf = null;
            } else {
                ctx.write(msg, promise);
            }
        } catch (EncoderException e) {
            throw e;
        } catch (Throwable e) {
            throw new EncoderException(e);
        } finally {
            if (buf != null) {
                buf.release();
            }
        }
    }

从上面的源码中我们可以看到,MessageToByteEncoder的逻辑为:

1. 首先判断该msg类型是否是当前类匹配的泛型,如果匹配的话才进行当前Encoder的处理,不匹配则直接调用下一个handler的write方法  

2. 如果是匹配的,则分配一个buf并且调用我们自己实现的encode方法进行编码。  

3. 判断buf是不是可读的(也就是判断buf里面是否写入数据),如果是可读的,则将buf中的数据传给下一个handler;如果是不可读的,则传一个空的buf给下一个handler

MessageToMessageDecoder

MessageToMessageDecoder用于将消息转换为消息,用于不同对象之间的转换。可以用在例如

MessageToMessageDecoder跟上面的MessageToByteEncoder一样,实现逻辑也是在write方法中。

源码如下

@Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        CodecOutputList out = null;
        try {
            //1. 判断msg是不是当前encoder所选择的泛型
            if (acceptOutboundMessage(msg)) {
                //2. 创建一个list
                out = CodecOutputList.newInstance();
                @SuppressWarnings("unchecked")
                I cast = (I) msg;
                try {
                    //3. 调用我们自己实现的encode方法, 将转换后的消息添加到list中
                    encode(ctx, cast, out);
                } catch (Throwable th) {
                    ReferenceCountUtil.safeRelease(cast);
                    PlatformDependent.throwException(th);
                }
                ReferenceCountUtil.release(cast);

                if (out.isEmpty()) {
                    throw new EncoderException(
                            StringUtil.simpleClassName(this) + " must produce at least one message.");
                }
            } else {
                ctx.write(msg, promise);
            }
        } catch (EncoderException e) {
            throw e;
        } catch (Throwable t) {
            throw new EncoderException(t);
        } finally {
            //4. 调用下一个handler, 由于list可能会有多个消息, 因此调用的逻辑也是不同的
            if (out != null) {
                try {
                    final int sizeMinusOne = out.size() - 1;
                    //如果只有一个消息, 那么直接调用write方法即可
                    if (sizeMinusOne == 0) {
                        ctx.write(out.getUnsafe(0), promise);
                    } else if (sizeMinusOne > 0) {
                        //list中包含了多个消息, 需要多次调用write方法
                        //这里做了一个判断, 如果promise是voidPromise, 则证明不需要写入操作结果信息, 直接调用write方法即可。
                        // 这样做的好处是减少内存占用跟减少gc压力。
                        if (promise == ctx.voidPromise()) {
                            writeVoidPromise(ctx, out);
                        } else {
//                          //如果需要返回结果, 则需要通过combiner将操作结果汇集起来, 以便在所有写入操作都完成后,完成promise对象。
                            writePromiseCombiner(ctx, out, promise);
                        }
                    }
                } finally {
                    out.recycle();
                }
            }
        }
    }

    private static void writeVoidPromise(ChannelHandlerContext ctx, CodecOutputList out) {
        final ChannelPromise voidPromise = ctx.voidPromise();
        for (int i = 0; i < out.size(); i++) {
            ctx.write(out.getUnsafe(i), voidPromise);
        }
    }

    private static void writePromiseCombiner(ChannelHandlerContext ctx, CodecOutputList out, ChannelPromise promise) {
        final PromiseCombiner combiner = new PromiseCombiner(ctx.executor());
        for (int i = 0; i < out.size(); i++) {
            combiner.add(ctx.write(out.getUnsafe(i)));
        }
        combiner.finish(promise);
    }

可以看出MessageToMessageEncoder的源码与MessageToByteEncoder很相似,区别点只在于; 1.存储消息的对象由buf转换为list。

2.由于encode后可能会有多个消息被添加到list中,因此调用下一个handler的write方法的方式也不一样,分成了三个分支:

  1. 如果只有一个消息, 那么直接调用write方法即可
  2. 有多个消息,但是promise是voidPromise,也就是不需要操作结果信息,直接遍历list调用write 方法即可
  3. 有多个消息,并且需要返回操作结果信息,则需要通过combiner将操作结果汇集起来,通过promise返回操作结果信息
2:解码器

Netty中提供了两个解码器基类

1. MessageToMessageDecoder:将消息解码为消息(也就是由对象转换成对象)

2. ByteToMessageDecoder:将字节解码为消息(也就是由字节转换成对象)

这两个解码器类的父类都为ChannelInboundHandlerAdapter,也就是入站处理器。负责处理入站站的数据。他们都定义了一个名为decode的抽象方法,用于对消息进行解码,我们使用时只需要实现这个decode方法即可。可以看到解码器跟编码器很相似,它们两的区别在于编码器是处理发送出去的数据,而解码器是处理接收的数据。

MessageToMessageDecoder

MessageToMessageDecoder用于将消息转换为消息。

由于MessageToMessageDecoder用于处理入站消息(因为继承ChannelInboundHandlerAdapter),所以它的实现逻辑都在channelRead方法中。

下面是MessageToMessageDecoder类的channelRead方法的源码。

@Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        CodecOutputList out = CodecOutputList.newInstance();
        try {
            //1. 判断msg是不是当前decoder所选择的泛型
            if (acceptInboundMessage(msg)) {
                @SuppressWarnings("unchecked")
                I cast = (I) msg;
                try {
                    //2. 调用我们自己实现的decode方法, 将转换后的消息添加到list中
                    decode(ctx, cast, out);
                } finally {
                    ReferenceCountUtil.release(cast);
                }
            } else {
                out.add(msg);
            }
        } catch (DecoderException e) {
            throw e;
        } catch (Exception e) {
            throw new DecoderException(e);
        } finally {
            try {
                //3. 遍历list, 调用下一个handler的channelRead方法
                int size = out.size();
                for (int i = 0; i < size; i++) {
                    ctx.fireChannelRead(out.getUnsafe(i));
                }
            } finally {
                out.recycle();
            }
        }
    }

从上面的源码可以看到, MessageToMessageDecoder与MessageToMessageEncoder的逻辑很相似,都是通过调用子类实现的抽象方法(decode or encode),  并将处理后的消息添加到list中。随后调用下一个handler的channelRead方法或write方法。这个类的逻辑并不复杂,接下来的ByteToMessageDecoder才是这几个编解码器的难点。

ByteToMessageDecoder​​​​​​​

ByteToMessageDecoder用于将字节转换为消息。

上一小节的结尾我们说了ByteToMessageDecoder是几个编解码器中的难点。如果我用文字说明的话大家可能很难理解,因此还是直接贴源码并配合上注释给大家讲解。毕竟talk is cheap,show me the code。

@Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if (msg instanceof ByteBuf) {
            selfFiredChannelRead = true;
            CodecOutputList out = CodecOutputList.newInstance();
            try {
                first = cumulation == null;
                //1.通过累积器将msg与前面未处理的数据(如果有的话)累加成一个buf
                cumulation = cumulator.cumulate(ctx.alloc(),
                        first ? Unpooled.EMPTY_BUFFER : cumulation, (ByteBuf) msg);
                //2.在这个方法里面调用decode方法进行解码
                callDecode(ctx, cumulation, out);
            } catch (DecoderException e) {
                throw e;
            } catch (Exception e) {
                throw new DecoderException(e);
            } finally {
                try {
                    //3.判断累加器是否还有数据, 如果没有可读数据的话, 就把buf给释放掉, 以免造成oom
                    if (cumulation != null && !cumulation.isReadable()) {
                        numReads = 0;
                        try {
                            cumulation.release();
                        } catch (IllegalReferenceCountException e) {
                            //noinspection ThrowFromFinallyBlock
                            throw new IllegalReferenceCountException(
                                    getClass().getSimpleName() + "#decode() might have released its input buffer, " +
                                            "or passed it down the pipeline without a retain() call, " +
                                            "which is not allowed.", e);
                        }
                        cumulation = null;
                    } else if (++numReads >= discardAfterReads) {
                        //如果cumulation一直都是有数据的, 那么则不会走第一个if分支,如果cumulation数据一直保存到numReads大于discardAfterReads(默认16)
                        //那么则会丢弃部门数据, 以防止造成oom
                        numReads = 0;
                        discardSomeReadBytes();
                    }

                    int size = out.size();
                    firedChannelRead |= out.insertSinceRecycled();
                    //4. 调用下一个handler的channelRead方法
                    fireChannelRead(ctx, out, size);
                } finally {
                    out.recycle();
                }
            }
        } else {
            ctx.fireChannelRead(msg);
        }
    }

我们还是从channelRead方法开始看起,从上面的源码中可以看到。 msg是ByteBuf的话才会对其进行解码,msg首先需要经过cumulator的cumulate方法后对数据进行累加。

为什么需要这个步骤呢?

要解答这个问题我们需要先了解一下网络数据传输的一个“半包”问题,在网络传输中,通常网络链路上传输设备的接口MTU(最大传输单元)都是1500字节。 这就导致如果我们发送的数据超过1500字节,那么机器会自动帮我们把数据进行分片,也就是是把数据拆开进行发送,这就导致了一个问题, 那就是我们当前这一次接收到的数据可能不是完整的数据,我们需要将其缓存起来等到下一个包到来,将两个包的数据结合起来才是一个完整的数据。 而在Netty的ByteTOMessageDecoder组件已经帮我们做了这个缓存操作,也就是通过cumulator的cumulate方法。 我们需要做的就是在decode方法中判断传送而来的数据是不是一个完整的消息,如果不是的话,则不进行处理,等待后面的消息到达。

接下来我们来看看cumulator的cumulate逻辑。

    public interface Cumulator {
        ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in);
    }

可以看到cumulator是一个接口,并且只有一个cumulate方法。

在Netty中实现了两个实现类,分别是MERGE_CUMULATOR和COMPOSITE_CUMULATOR。MERGE_CUMULATOR通过内存复制合并两个buf实现两个buf的累加。COMPOSITE_CUMULATOR通过类似链表的方式组合两个buf实现两个buf的累加。ByteToMessageDecoder默认使用了MERGE_CUMULATOR作为累积器。因此我们看一下MERGE_CUMULATOR源码,如果想了解COMPOSITE_CUMULATOR原理可以自行去看对应的源码。

public static final Cumulator MERGE_CUMULATOR = new Cumulator() {
        @Override
        public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
            //如果cumulation跟in是一样的,则表明出现了双重引用,需要释放其中一个引用,避免oom
            if (cumulation == in) {
                in.release();
                return cumulation;
            }
            //cumulation没有数据并且in为连续的(也就是in的数据在一整块内存, 而不是组合起来的)就直接返回in
            if (!cumulation.isReadable() && in.isContiguous()) {
                cumulation.release();
                return in;
            }
            try {
                final int required = in.readableBytes();
                //判断in的数据能否写入到cumulation中, 如果不行的话, 会从新分配一个cumulation将旧的cumulation跟in的数据都写入新的cumulation中.
                if (required > cumulation.maxWritableBytes() ||
                    required > cumulation.maxFastWritableBytes() && cumulation.refCnt() > 1 ||
                    cumulation.isReadOnly()) {
                    return expandCumulation(alloc, cumulation, in);
                }
                //in的数据能写入cumulation中, 直接写入即可
                cumulation.writeBytes(in, in.readerIndex(), required);
                in.readerIndex(in.writerIndex());
                cumulation.release(10);
                int readerIndex = in.readerIndex();
                cumulation.writeByte(readerIndex);
                return cumulation;
            } finally {
                in.release();
            }
        }
    };
    

可以看到MERGE_CUMULATOR的实现逻辑与前面说的一样,是通过内存复制将in buffer合并到cumulation中。只是前面做了许多判断,以避免无谓的内存复制。并且在最后将in写到cumulation中。

接下来我们来看一下callDecode方法。

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        try {
            while (in.isReadable()) {
                final int outSize = out.size();

                if (outSize > 0) {
                    //调用下一个handler的channelRead方法,
                    fireChannelRead(ctx, out, outSize);
                    out.clear();
                    //如果当前的handler被移除掉了, 那么in buffer可能已经被释放了, 在去操作buffer则不安全, 直接就退出循环.
                    if (ctx.isRemoved()) {
                        break;
                    }
                }

                int oldInputLength = in.readableBytes();
                //真正调用decode方法
                decodeRemovalReentryProtection(ctx, in, out);
                
                //同样是判断当前handler是不是被移除掉了
                if (ctx.isRemoved()) {
                    break;
                }

                //如果out为空, 并且oldInputLength等于in.readableBytes(),
                // 则证明in并未被读取数据, 在decode方法中判断了当前in的数据不是完整的(也就是"半包"问题).
                // 那么就直接跳出循环,等后续的包来了再做处理
                if (out.isEmpty()) {
                    if (oldInputLength == in.readableBytes()) {
                        break;
                    } else {
                        continue;
                    }
                }

                //out里面已经有消息了, 但是in的数据还没被读取.
                //因此在这里抛出一个Exception来提醒程序员
                if (oldInputLength == in.readableBytes()) {
                    throw new DecoderException(
                            StringUtil.simpleClassName(getClass()) +
                                    ".decode() did not read anything but decoded a message.");
                }

                if (isSingleDecode()) {
                    break;
                }
            }
        } catch (DecoderException e) {
            throw e;
        } catch (Exception cause) {
            throw new DecoderException(cause);
        }
    }

    final void decodeRemovalReentryProtection(ChannelHandlerContext ctx, ByteBuf in, List<Object> out)
            throws Exception {
        //decodeState用于标记在调用decode方法的过程中, 当前handler是否被移除
        decodeState = STATE_CALLING_CHILD_DECODE;
        try {
            //调用我们自己实现的decode方法
            decode(ctx, in, out);
        } finally {
            //decodeState等于STATE_CALLING_CHILD_DECODE则表明当前handler已被移除. 在handlerRemoved方法中将in的数据传给下一个handler
            boolean removePending = decodeState == STATE_HANDLER_REMOVED_PENDING;
            decodeState = STATE_INIT;
            if (removePending) {
                fireChannelRead(ctx, out, out.size());
                out.clear();
                handlerRemoved(ctx);
            }
        }
    }

在callDecode方法中,使用了while循环,只要in中的数据一直没被读取完的话就会一直循环。 可以看到while循环中第一步先判断out是否有数据,如果有数据则调用下一个handler的channelRad方法。这里的out数据则是上一个循环中产生的消息。

第二步先记录未调用decode方法之前的readableBytes,以便在后续调用decode方法之后判断in的状态。

第三步在调用decode方法之后,通过第二步保存的readableBytes与现在in的readableBytes进行比较,以此来判断是否发生了“半包”问题

总结: 

从上面的源码与讲解可以看到, 不管是源码量还是讲解的长度都比前面三个编解码器多了很多。 ByteTOMessageDecoder这么复杂的原因主要是为了解决网络传输问题中的粘包、半包等问题、通过cumulation缓存接收的数据以解决半包问题。 在decode方法中可以通过协商好自定义协议的方式来解决粘包问题。

3:编解码器

Netty中提供了两个编解码器基类

1. ByteToMessageCodec:可以在单个类中实现Message编码成Buffer与Buffer解码成Message, 也就是ByteToMessageDecoder和MessageToByteEncoder的结合。

2. MessageToMessageCodec:可以在单个类中实现Message编码成Message与Message解码成Message, 也就是MessageToMessageDecoder和MessageToMessageEncoder的结合。

上面这两个实际上都是通过对应的Decoder跟Encoder结合起来实现的,因此感兴趣的同学可以自行去观看源码。

这两个类的主要用于需要对消息同时进行编解码或者编解码时需要同一个资源的情况下使用。

三:Netty编解码器实战

我们可以通过实现ByteToMessageDecoder来实现自己的decoder。我们在这里实现一个功能为以 \n 作为数据分隔符以解决粘包、半包问题,并将拆分出来的buf转换为String以传递给下一个handler。
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.util.Attribute;
import io.netty.util.AttributeKey;
import io.netty.util.CharsetUtil;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class StringLineDelimiterDecoder extends ByteToMessageDecoder {
    public static final byte SEPARATOR = (byte)'\n';
    @Override
    protected void decode(ChannelHandlerContext context, ByteBuf byteBuf, List<Object> out) throws Exception {
        int delimiterIndex = byteBuf.indexOf(byteBuf.readerIndex(), byteBuf.writerIndex(), SEPARATOR);

        //当前接收到的数据没有包含分隔符, 证明数据包不完整, 等待后续的数据
        if (delimiterIndex == -1){
            return;
        }
        ByteBuf sliceBuf = byteBuf.readSlice(delimiterIndex - byteBuf.readerIndex() + 1);
        String message = sliceBuf.toString(CharsetUtil.UTF_8);

        out.add(message);
    }
}
StringLineDelimiterDecoder 实现逻辑为:
以 \n 作为数据的分隔符,如果当前接收的数据不包含 \n,那么直接返回,等待后续的数据再进行操作。
如果当前了\n,那么则通过readSlice方法用于从 ByteBuf 中读取delimiterIndex之前的数据并返回一个子缓冲区(sub-buffer),并将返回的子缓冲区转换为String并添加到out中。
服务端:
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.util.concurrent.DefaultEventExecutorGroup;
 
public class NettyServer {
    public static void main(String[] args) {
        //EventLoopGroup相当于线程池, 实际上也实现了线程池
        //bossGroup是用来处理连接的
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        //workerGroup是用来处理io的
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        serverBootstrap
                .group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                //添加我们自定义的业务handler
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) {
                        ch.pipeline()
                                //设置读空闲5秒
                                .addLast(new IdleStateHandler(5, 0, 0))
                                //在IdleStateHandler的后一个加入HeartBeatHandler
                                .addLast(new HeartBeatHandler())
                                .addLast(new StringLineDelimiterDecoder())
                                .addLast(new DefaultEventExecutorGroup(10), new FirstServerHandler());
                    }
                });
        //绑定本地的8002端口
        serverBootstrap.bind(8002);
    }
}

客户端:

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.timeout.IdleStateHandler;

import java.nio.charset.StandardCharsets;
import java.util.Scanner;

import static entrance.core.Constant.SEPARATOR;

public class NettyClient {
    public static void main(String[] args) throws InterruptedException {
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap();
        bootstrap
                // 1.指定线程模型
                .group(workerGroup)
                // 2.指定 IO 类型为 NIO
                .channel(NioSocketChannel.class)
                // 3.添加自定义handler,
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    public void initChannel(SocketChannel ch) {
                        ch.pipeline()
                                .addLast(new IdleStateHandler(5, 0, 0))
                                .addLast(new HeartBeatHandler())
                                .addLast(new FirstClientHandler());
                    }
                });
        // 4.建立连接
        ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 8002).addListener(future -> {
            if (future.isSuccess()) {
                System.out.println("连接成功!");
            } else {
                System.err.println("连接失败!");
            }
        });
        Channel channel = channelFuture.channel();
        //5. 通过控制台手动输入数据
        Scanner scanner = new Scanner(System.in);
        while (true) {
            //手动输入数据
            String message = scanner.nextLine();
            byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
            ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(messageBytes.length + 1);
            buffer.writeBytes(messageBytes);
            //在数据的最后写一个 \n 作为分隔符
            buffer.writeByte(SEPARATOR);
            channel.writeAndFlush(buffer);
        }
    }
}

这个客户端发送的数据如果超过1024个字节就会被拆包,因此我们想要测试StringLineDelimiterDecoder时,只需要发送的数据超过1024个字节就可以。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值