Netty源码(十一)之LengthFieldBasedFrameDecoder/LengthFieldPrepender

1.前文

前两篇博客我介绍了三种解码器,分别是基于固定长度的解码器(FixedLengthFrameDecoder),基于行的解码器(LineBasedFrameDecoder),基于分割符解码器(DelimiterBasedFrameDecoder)今天我们这篇博客介绍最后一种解码器,就是基于长度的解码器(LengthFieldBasedFrameDecoder)

2.协议的简介

由于大多数的协议,协议头中会携带长度的字段,用于标示消息体或整包的长度,例如SMPP、HTTP协议等。由于基于长度解码需求的通用性,Netty提供了LengthFieldBasedFrameDecoder/LengthFieldPrepender,自动屏蔽TCP底层的拆包和粘包的问题,只需要传入正确的参数,即可轻松的解决“读半包”问题。

发送方使用LengthFieldPrepender给实际内容Content进行编码添加报文头Length字段,接收方使用LengthFieldBasedFrameDecoder进行解码。协议格式如下所示:

+---------+---------+
|  Length | Content |
+---------+---------+  

Length字段:

  • 表示Content部分的字节数,例如Length值为100,那么意味着Content部分占用的字节数就是100。

  • Length字段本身是个整数,也要占用字节,一般会使用固定的字节数表示。例如我们指定使用2个字节(有符号)表示length,那么可以表示的最大值为32767(约等于32K),也就是说,Content部分占用的字节数,最大不能超过32767。当然,Length字段存储的是Content字段的真实长度

Content字段:

  • 是我们要处理的真实二进制数据。在发送Content内容之前,首先需要获取其真实长度,添加在内容二进制流之前,然后再发送。Length占用的字节数+Content占用的字节数,就是我们总共要发送的字节。

事实上,我们可以把Length部分看做报文头,报文头包含了解析报文体(Content字段)的相关元数据,例如Length报文头表示的元数据就是Content部分占用的字节数。当然,LengthFieldBasedFrameDecoder并没有限制我们只能添加Length报文头,我们可以在Length字段前或后,加上一些其他的报文头,此时协议格式如下所示:

+---------+---------+---------+---------+
|.........|  Length |.........| Content |
+---------+---------+---------+---------+  

不过对于LengthFieldBasedFrameDecoder而言,其关心的只是Length字段。因此当我们在构造一个LengthFieldBasedFrameDecoder时,最主要的就是告诉其如何处理Length字段。

3.LengthFieldPrepender

当我们打开LengthFieldPrepender的源码的时候,发现最终调用的调用的构造函数如下:

public LengthFieldPrepender(
  ByteOrder byteOrder, int lengthFieldLength,
  int lengthAdjustment, boolean lengthIncludesLengthFieldLength) {
  if (lengthFieldLength != 1 && lengthFieldLength != 2 &&
      lengthFieldLength != 3 && lengthFieldLength != 4 &&
      lengthFieldLength != 8) {
    throw new IllegalArgumentException(
      "lengthFieldLength must be either 1, 2, 3, 4, or 8: " +
      lengthFieldLength);
  }
  ObjectUtil.checkNotNull(byteOrder, "byteOrder");
  this.byteOrder = byteOrder;
  this.lengthFieldLength = lengthFieldLength;
  this.lengthIncludesLengthFieldLength = lengthIncludesLengthFieldLength;
  this.lengthAdjustment = lengthAdjustment;
}

参数解释如下:

//表示length字段本身占用的字节数使用是大端还是小端编码
private final ByteOrder byteOrder;
//表示Length字段本身占用的字节数,只可以指定1,2,3,4,8
private final int lengthFieldLength;
//表示Length字段本身占用的字节数是否包含在Length字段表示的值中
private final boolean lengthIncludesLengthFieldLength;
//表示Length字段调整值
private final int lengthAdjustment;

我们再来看下对应的核心的方法,就是对应的encode方法,具体的代码如下:

@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
  //计算可读的字节数同时加上length字段的调整值
  int length = msg.readableBytes() + lengthAdjustment;
  //如果为true,就加上length字段的占用的字节数
  if (lengthIncludesLengthFieldLength) {
    length += lengthFieldLength;
  }
  //校验数据
  checkPositiveOrZero(length, "length");
  switch (lengthFieldLength) {
    case 1:
      if (length >= 256) {
        throw new IllegalArgumentException(
          "length does not fit into a byte: " + length);
      }
      out.add(ctx.alloc().buffer(1).order(byteOrder).writeByte((byte) length));
      break;
    case 2:
      if (length >= 65536) {
        throw new IllegalArgumentException(
          "length does not fit into a short integer: " + length);
      }
      out.add(ctx.alloc().buffer(2).order(byteOrder).writeShort((short) length));
      break;
    case 3:
      if (length >= 16777216) {
        throw new IllegalArgumentException(
          "length does not fit into a medium integer: " + length);
      }
      out.add(ctx.alloc().buffer(3).order(byteOrder).writeMedium(length));
      break;
    case 4:
      out.add(ctx.alloc().buffer(4).order(byteOrder).writeInt(length));
      break;
    case 8:
      out.add(ctx.alloc().buffer(8).order(byteOrder).writeLong(length));
      break;
    default:
      throw new Error("should not reach here");
  }
  out.add(msg.retain());
}

上面的代码,走来计算length,length的值等于读到的字节数据加上lengthAdjustment的值,如果lengthIncludesLengthFieldLength为true,就需要加上lengthFieldLength的值,如果为false,就不需要加上。**记住:如果不指定默认为false。**这个时候就调用对应的方法写入长度。这儿由于TCP的结构中数据只占用4个字节,所以当lengthFieldLength的值为大于等于4的时候,就不用判断length的值有没有超过lengthFieldLength指定的最大的存储的最大字节数。

举例:对于以下包含12个字节的报文

+----------------+
| "HELLO, WORLD" |
+----------------+

假设我们指定Length字段占用2个字节,lengthIncludesLengthFieldLength指定为false,即不包含本身占用的字节,那么Length字段的为0x000C(即12)

+--------+----------------+
+ 0x000C | "HELLO, WORLD" |
+--------+----------------+

如果我们指定lengthIncludesLengthFieldLength为true,那么Length字段的值为:0x000E(即14)=Length(2)+Content字段(12)

+--------+----------------+
+ 0x000E | "HELLO, WORLD" |
+--------+----------------+

关于lengthAdjustment字段的含义,参考下面的LengthFieldBasedFrameDecoder。

LengthFieldPrepender尤其值得说明的一点是,其提供了实现零拷贝的另一种思路(实际上编码过程,是零拷贝的一个重要应用场景)。

  • 在Netty中我们可以使用ByteBufAllocator.directBuffer()创建直接缓冲区实例,从而避免数据从堆内存(用户空间)向直接内存(内核空间)的拷贝,这是系统层面的零拷贝。
  • 也可以使用CompositeByteBuf把两个ByteBuf合并在一起,例如一个存放报文头,另一个存放报文体。而不是创建一个更大的ByteBuf合并在一起,这是应用层面的零拷贝。

而LengthFieldPrepender,由于需要在原来的二进制数据之前添加一个Length字段,因此就需要对二者进行合并发送。但是LengthFieldPrepender并没有采用CompositeByteBuf,具体如上面的LengthFieldPrepender中encode方法中代码。

可以看到,LengthFieldPrepender实际上是先把Length字段(报文头)添加到List中,再把msg本身(报文体)添加到List中。而在发送数据时,LengthFieldPrepender的父类MessageToMessageEncoder会按照List中的元素下标按照顺序发送,因此相当于间接的把Length字段添加到了msg之前。从而避免了创建一个更大的ByteBuf将Length字段和msg内容合并到一起。作为开发者的我们,在编写编码器的时候,这种一种重要的实现零拷贝的参考思路。

4.LengthFieldBasedFrameDecoder

当我们打开源码的时候,发现最终调用的构造函数如下:

public LengthFieldBasedFrameDecoder(
  ByteOrder byteOrder, int maxFrameLength, int lengthFieldOffset, int lengthFieldLength,
  int lengthAdjustment, int initialBytesToStrip, boolean failFast) {
  if (byteOrder == null) {
    throw new NullPointerException("byteOrder");
  }
  checkPositive(maxFrameLength, "maxFrameLength");
  checkPositiveOrZero(lengthFieldOffset, "lengthFieldOffset");
  checkPositiveOrZero(initialBytesToStrip, "initialBytesToStrip");
  if (lengthFieldOffset > maxFrameLength - lengthFieldLength) {
    throw new IllegalArgumentException(
      "maxFrameLength (" + maxFrameLength + ") " +
      "must be equal to or greater than " +
      "lengthFieldOffset (" + lengthFieldOffset + ") + " +
      "lengthFieldLength (" + lengthFieldLength + ").");
  }
  this.byteOrder = byteOrder;
  this.maxFrameLength = maxFrameLength;
  this.lengthFieldOffset = lengthFieldOffset;
  this.lengthFieldLength = lengthFieldLength;
  this.lengthAdjustment = lengthAdjustment;
  lengthFieldEndOffset = lengthFieldOffset + lengthFieldLength;
  this.initialBytesToStrip = initialBytesToStrip;
  this.failFast = failFast;
}

最终参数解释如下:

byteOrder:

  • 表示协议中Length字段的字节是大端还是小端

maxFrameLength:

  • 表示协议中Content字段的最大长度,如果超出,则抛出TooLongFrameException异常。

lengthFieldOffest:

  • 表示Length字段的偏移量,即在读取一个二进制流时,跳过指定长度个字节之后的才是Length字段。如果Length字段之前没有其他的报文头,指定为0即可。如果Length字段之前还有其他报文头,则需要跳过之前的报文头的字节数。

lengthFieldLength:

  • 表示Length字段占用的字节数。指定为多少,需要看实际需求,不同的字节数,限制了Content字段的最大长度。
  • 如果lengthFieldLength是1个字节,那么限制为128bytes;
  • 如果lengthFieldLength是2个字节,那么限制为32767(约等于32K);
  • 如果lengthFieldLength是3个字节,那么限制为8388608(约等于8M);
  • 如果lengthFieldLength是4个字节,那么限制为2147483648(约等于2G)。
  • lengthFieldLength与maxFrameLength并不冲突。例如我们现在希望限制报文Content字段的最大的长度为32M。显然,我们看到了上面的四种情况,没有任何一个值,能刚好限制Content字段的最大值刚为32M。那么我们只能指定lengthFieldLength为4个字节,其最大限制2G是大于32M,因此肯定支持。但是如果Content字段长度真的是2G,server端接收到这么大的数据,如果都放在内存中,很容易造成内存溢出。为了避免这种情况,我们就可以指定maxFrameLength字段,来精确的指定Content部分最大字节数,显然,其值应该小于lengthFieldLength指定的字节数最大可以表示的值。

lengthAdjustment:

  • length字段补偿值。对于绝大部分协议来说,Length字段的值表示的都是Content字段占用的字节数。但是也有一些协议,Length字段表示的本身占用的字节数+Content字段占用的字节数。由于Netty中在解析Length字段的值时,默认是认为只表示Content字段的长度,因此解析可能会失败,所以进行补偿。在后面的例子中会进行演示。
  • 主要用于处理Length字段前后还有其他报文的情况。具体作用请看后面的案例分析。

initialBytesToStrip:

  • 解码后跳过的初始字节数,表示获取完一个完整的数据报文之后,忽略前面指定个数的字节。例如报文头只有Length字段,占用两个字节,在解码后,我们可以指定跳过2个字节。这样封装到ByteBuf中的内容,就只包含Content字段的字节内容不包含Length字段占用的字节

failFast:

  • 如果为true,则表示读取到Length字段时,如果其值超过maxFrameLength,就立马抛出一个 TooLongFrameException,而为false表示只有当真正读取完长度域的值表示的字节之后,才会抛出 TooLongFrameException,默认情况下设置为true,建议不要修改,否则可能会造成内存溢出。

    3.1报文只包含Length字段和Content字段

    报文只包含Length字段和Content字段时,协议格式如下:

    +--------+----------+
    | Length |  Content |
    +--------+----------+
    

    假设Length字段占用2个字节,其值为0x000C,意味着Content字段长度为12个字节,假设其内容为”HELLO, WORLD”。下面演示指定不同解析参数时,解码后的效果。

    案例1:

    • lengthFieldOffset = 0 //因为报文以Length字段开始,不需要跳过任何字节,所以offset为0

    • lengthFieldLength = 2 //因为我们规定Length字段占用字节数为2,所以这个字段值传入的是2

    • lengthAdjustment = 0 //这里Length字段值不需要补偿,因此设置为0

    • initialBytesToStrip = 0 //不跳过初始字节,意味着解码后的ByteBuf中,包含Length+Content所有内容

      解码前 (14 bytes)                 解码后 (14 bytes)
      +--------+----------------+      +--------+----------------+
      | Length | Actual Content |----->| Length | Actual Content |
      | 0x000C | "HELLO, WORLD" |      | 0x000C | "HELLO, WORLD" |
      +--------+----------------+      +--------+----------------+
      

    案例2:

    • lengthFieldOffset = 0 //参见案例1

    • lengthFieldLength = 2 //参见案例1

    • lengthAdjustment = 0 //参见案例1

    • initialBytesToStrip = 2 //这里跳过2个初始字节,也就是Length字段占用的字节数,意味着解码后的ByteBuf中,只包含Content字段

      解码前 (14 bytes)                 解码后 (14 bytes)
      +--------+----------------+      +----------------+
      | Length | Actual Content |----->| Actual Content |
      | 0x000C | "HELLO, WORLD" |      | "HELLO, WORLD" |
      +--------+----------------+      +----------------+
      

    案例3:

    • lengthFieldOffset = 0 // 参见案例1

    • lengthFieldLength = 2 // 参见案例1

    • lengthAdjustment = -2 // Length字段补偿值指定为-2

    • initialBytesToStrip = 0 // 参见案例1

      解码前 (14 bytes)                 解码后 (14 bytes)
      +--------+----------------+      +--------+----------------+
      | Length | Actual Content |----->| Length | Actual Content |
      | 0x000E | "HELLO, WORLD" |      | 0x000E | "HELLO, WORLD" |
      +--------+----------------+      +--------+----------------+
      

      这个案例需要进行一下特殊说明,其Length字段值表示:Length字段本身占用的字节数+Content字节数。所以我们看到解码前,其值为0x000E(14),而不是0x000C(12)。而真实Content字段内容只有2个字节,因此我们需要用:Length字段值0x000E(14),减去lengthAdjustment指定的值(-2),表示的才是Content字段真实长度。

    3.2 报文头包含Length字段以外的其他字段,同时包含Content字段

    通常情况下,一个协议的报文头除了Length字段,还会包含一些其他字段,例如协议的版本号,采用的序列化协议,是否进行了压缩,甚至还会包含一些预留的头字段,以便未来扩展。这些字段可能位于Length之前,也可能位于Length之后,此时的报文协议格式如下所示:

    +---------+--------+----------+----------+
    |........ | Length |  ....... |  Content |
    +---------+--------+----------+----------+
    

    当然,对于LengthFieldBasedFrameDecoder来说,其只关心Length字段。按照Length字段的值解析出一个完整的报文放入ByteBuf中,也就是说,LengthFieldBasedFrameDecoder只负责粘包、半包的处理,而ByteBuf中的实际内容解析,则交由后续的解码器进行处理。

    下面依然通过案例进行说明:

    案例4:

    • 这个案例中,在Length字段之前,还包含了一个Header字段,其占用2个字节,Length字段占用3个字节。

    • lengthFieldOffset = 2 // 需要跳过Header字段占用的2个字节,才是Length字段

    • lengthFieldLength = 3 //Length字段占用3个字节

    • lengthAdjustment = 0 //由于Length字段的值为12,表示的是Content字段长度,因此不需要调整

    • initialBytesToStrip = 0 //解码后,不裁剪字节

      解码前 (17 bytes)                              解码后 (17 bytes)
      +----------+----------+----------------+      +----------+----------+----------------+
      | Header   |  Length  | Actual Content |----->| Header   |  Length  | Actual Content |
      |  0xCAFE  | 0x00000C | "HELLO, WORLD" |      |  0xCAFE  | 0x00000C | "HELLO, WORLD" |
      +----------+----------+----------------+      +----------+----------+----------------+
      

    案例5:

    • 在这个案例中,Header字段位于Length字段之后

    • lengthFieldOffset = 0 // 由于一开始就是Length字段,因此不需要跳过

    • lengthFieldLength = 3 // Length字段占用3个字节,其值为0x000C,表示Content字段长度

    • lengthAdjustment = 2 // 由于Length字段之后,还有Header字段,因此需要+2个字节,读取Header+Content的内容

    • initialBytesToStrip = 0 //解码后,不裁剪字节

      解码前 (17 bytes)                              解码后 (17 bytes)
      +----------+----------+----------------+      +----------+----------+----------------+
      |  Length  | Header   | Actual Content |----->|  Length  | Header   | Actual Content |
      | 0x00000C |  0xCAFE  | "HELLO, WORLD" |      | 0x00000C |  0xCAFE  | "HELLO, WORLD" |
      +----------+----------+----------------+      +----------+----------+----------------+
      

    案例6:

    • 这个案例中,Length字段前后各有一个报文头字段HDR1、HDR2,各占1个字节

    • lengthFieldOffset = 1 //跳过HDR1占用的1个字节读取Length

    • lengthFieldLength = 2 //Length字段占用2个字段,其值为0x000C(12),表示Content字段长度

    • lengthAdjustment = 1 //由于Length字段之后,还有HDR2字段,因此需要+1个字节,读取HDR2+Content的内容

    • initialBytesToStrip = 3 //解码后,跳过前3个字节

      解码前 (16 bytes)                               解码后 (13 bytes)
      +------+--------+------+----------------+      +------+----------------+
      | HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
      | 0xCA | 0x000C | 0xFE | "HELLO, WORLD" |      | 0xFE | "HELLO, WORLD" |
      +------+--------+------+----------------+      +------+----------------+
      

    案例7:

    • 这个案例中,Length字段前后各有一个报文头字段HDR1、HDR2,各占1个字节。Length占用2个字节,表示的是整个报文的总长度。

    • lengthFieldOffset = 1 //跳过HDR1占用的1个字节读取Length

    • lengthFieldLength = 2 //Length字段占用2个字段,其值为0x0010(16),表示HDR1+Length+HDR2+Content长度

    • lengthAdjustment = -3 //由于Length表示的是整个报文的长度,减去HDR1+Length占用的3个字节后,读取HDR2+Content长度

    • initialBytesToStrip = 3 //解码后,跳过前3个字节

    解码前 (16 bytes)                               解码后 (13 bytes)
    +------+--------+------+----------------+      +------+----------------+
    | HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
    | 0xCA | 0x0010 | 0xFE | "HELLO, WORLD" |      | 0xFE | "HELLO, WORLD" |
    +------+--------+------+----------------+      +------+----------------+
    

    3.3源码解析

    我们主要看的是的decode方法的代码,具体的代码如下:

    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
      if (discardingTooLongFrame) {
        discardingTooLongFrame(in);
      }
      //lengthFieldEndOffset = lengthFieldOffset + lengthFieldLength;
      //读取到数据比长度的偏移量和长度占的字节还要少
      if (in.readableBytes() < lengthFieldEndOffset) {
        return null;
      }
      //读指针加上长度的偏移量,读指针一开始就是0
      int actualLengthFieldOffset = in.readerIndex() + lengthFieldOffset;
      //计算本次要读的字节数
      long frameLength = getUnadjustedFrameLength(in, actualLengthFieldOffset, lengthFieldLength, byteOrder);
      //本次要读的字节数小于0
      if (frameLength < 0) {
        failOnNegativeLengthField(in, frameLength, lengthFieldEndOffset);
      }
      //frameLength = lengthFieldOffset + lengthFieldLength + lengthAdjustment
      frameLength += lengthAdjustment + lengthFieldEndOffset;
      if (frameLength < lengthFieldEndOffset) {
        failOnFrameLengthLessThanLengthFieldEndOffset(in, frameLength, lengthFieldEndOffset);
      }
      //读到的字节数大于最大读取的字节数
      if (frameLength > maxFrameLength) {
        exceededFrameLength(in, frameLength);
        return null;
      }
      // never overflows because it's less than maxFrameLength
      int frameLengthInt = (int) frameLength;
      //如果读取的到的字节数小于本次要读的字节数的
      if (in.readableBytes() < frameLengthInt) {
        return null;
      }
      //本次跳过的字节数大于本次要读的字节数
      if (initialBytesToStrip > frameLengthInt) {
        failOnFrameLengthLessThanInitialBytesToStrip(in, frameLength, initialBytesToStrip);
      }
      //跳过本次要读的字节数
      in.skipBytes(initialBytesToStrip);
      // extract frame
      //获取读指针
      int readerIndex = in.readerIndex();
      //本次读取的字节数-跳过的字节数
      int actualFrameLength = frameLengthInt - initialBytesToStrip;
      //读取指定的字节数
      ByteBuf frame = extractFrame(ctx, in, readerIndex, actualFrameLength);
      //移动读指针
      in.readerIndex(readerIndex + actualFrameLength);
      return frame;
    }
    

    上面的代码走来先判断是否是丢弃模式,如果是丢弃模式,直接调用discardingTooLongFrame(in);方法,正常的情况下,走来不可能是丢弃模式。具体的代码如下:

    private void discardingTooLongFrame(ByteBuf in) {
      //获取上次不够丢字节数
      long bytesToDiscard = this.bytesToDiscard;
      //读取到的字节数和上次不够丢丢字节数进行比较取小的,主要是为了如果本次读取到的字节比上次要丢弃的字节数还要少,这儿会存起来,下次继续丢
      int localBytesToDiscard = (int) Math.min(bytesToDiscard, in.readableBytes());
      //直接丢弃上面获取的字节数
      in.skipBytes(localBytesToDiscard);
      //减去本次丢弃的字节数
      bytesToDiscard -= localBytesToDiscard;
      this.bytesToDiscard = bytesToDiscard;
      //抛出异常
      failIfNecessary(false);
    }
    

    本次跳过的字节数大于本次总的要读的字节数(没有去除长度的偏移量和长度的占有的字节数还有调整的头节点的字节数),就会执行 failOnFrameLengthLessThanLengthFieldEndOffset(in, frameLength, lengthFieldEndOffset);方法,这个方法主要是抛出异常。同时跳过frameLength长度。

    如果本次总的要读取的字节数大于最大读取的字节数,会执行exceededFrameLength(in, frameLength);方法,具体的代码如下:

    private void exceededFrameLength(ByteBuf in, long frameLength) {
      //总的读取的字节数-能够读到的字节数
      long discard = frameLength - in.readableBytes();
      tooLongFrameLength = frameLength;
      //小于0表示可以跳过当次字节数,主要怕这儿的跳过的字节数大于可以读的字节数,就会出异常
      if (discard < 0) {
        // buffer contains more bytes then the frameLength so we can discard all now
        in.skipBytes((int) frameLength);
      } else {
        // Enter the discard mode and discard everything received so far.
        //设置为丢弃模式
        discardingTooLongFrame = true;
        //这次丢弃的数据不够,直接存起来,下次再丢弃
        bytesToDiscard = discard;
        //直接跳过本次能够读到的字节数
        in.skipBytes(in.readableBytes());
      }
      failIfNecessary(true);
    }
    

    最后我们再来看下最后一个方法failIfNecessary(true);具体的代码如下:

    private void failIfNecessary(boolean firstDetectionOfTooLongFrame) {
      //正好丢弃完
      if (bytesToDiscard == 0) {
        // Reset to the initial state and tell the handlers that
        // the frame was too large.
        //获取上次读取的因为大于最大能够读取的字节数的总的字节数
        long tooLongFrameLength = this.tooLongFrameLength;
        //置为0
        this.tooLongFrameLength = 0;
        //将丢弃模式设置为false
        discardingTooLongFrame = false;
        //这个firstDetectionOfTooLongFrame是传进来判断是否抛出异常
        if (!failFast || firstDetectionOfTooLongFrame) {
          fail(tooLongFrameLength);
        }
      } else {
        // Keep discarding and notify handlers if necessary.
        if (failFast && firstDetectionOfTooLongFrame) {
          fail(tooLongFrameLength);
        }
      }
    }
    
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值