Netty框架之粘包、拆包

12 篇文章 1 订阅

1. 概述

在网络编程中,数据总是以字节的形式在流动,我们在实际开发中,总是要知道一段消息从哪开始从哪结束。解决好这个问题实际上就解决了TCP的粘包和拆包问题。

2. TCP粘包/拆包

TCP协议的字节流是有序而无实际意义的二进制,我们实际开发中给数据限定的业务意义对字节流来说是透明的,对底层TCP协议来说我们所谓的业务数据可能是一个块,也可能是分多个块传输,所以出现了粘包和拆包的问题,即粘包和拆包是依据业务数据进行的字节流的数据处理。

2.1 出现原因

出现原因一般有以下几种:

  • 写入的多发出去的少
  • 超出TCP协议的最大分段大小

2.2 解决办法

基于TCP协议的可靠性和有序性,实际中使用的办法有以下几种:

  • 消息定长
    消息长度是固定的,指定具体长度的字节值。
  • 回车换行结束符
    在消息末尾添加回车换行符。
  • 添加消息头
    在消息头中包含消息总长度或消息体长度。
  • 自定义协议格式
    依据实际业务指定比较复杂的协议格式。

3. Netty解码方案

3.1 消息定长

Netty提供一个定长解码器,按照定义的长度对消息自动解码。

3.1.1 FixedLengthFrameDecoder

按照定义长度对缓冲区消息自动分隔,比如缓冲区接收’A’ ‘BC’ ‘DEFG’ 'HI’四个包,假如解码器定长为3,那么消息解码为:‘ABC’ ‘DEF’ 'GHI’三个完整消息。

  • decode方法
protected Object decode(
            @SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception {
    // buffer可读字节小于定长返回为null
    if (in.readableBytes() < frameLength) {
        return null;
    } else {
        // buffer可读字节大于等于定长直接按照定义长度获取切片
        return in.readRetainedSlice(frameLength);
    }
}

实践
客户端发送’1234567890123456789012345678901234567890’数据,如果解码器指定长度为10,那么将会解码为’1234567890’ ‘1234567890’ ‘1234567890’ '1234567890’四个完整消息。

3.2 回车换行结束符、指定分隔符

3.2.1 LineBasedFrameDecoder

一个以行为单位分割的解码器,一行算是一个完整的消息。行尾标志既可以是“\n”也可以是“\r\n”。
使用该解码器的时候,字节流应使用UTF-8或者ASCII编码,字节被转换为字符后,会和低范围的ASCII码比较,UTF-8编码使用2到4字节表示中文,不会使用低范围[0…0x7F]字节值来表示多字节码点,所以该解码器完全支持UTF-8编码的字节流。

  • findEndOfLine方法分析
/**
 * 返回行尾标识符的索引值
 * 找不到行尾标识符返回-1
 */
private int findEndOfLine(final ByteBuf buffer) {
    // 获取buffer可读字节长度
    int totalLength = buffer.readableBytes();
    // offset记录最后一次扫描位置
    // 从buffer.readerIndex() + offset位置开始,到buffer.readerIndex() + offset + totalLength - offset -1结束,查找'\n'
    int i = buffer.forEachByte(buffer.readerIndex() + offset, totalLength - offset, ByteProcessor.FIND_LF);
    // 如果找到'\n'位置
    if (i >= 0) {
        offset = 0;
        // 判断是否存在'\r'字符,如果存在i-1
        if (i > 0 && buffer.getByte(i - 1) == '\r') {
            i--;
        }
    } else {
        // 没找到'\n'位置,记录最后一次扫描索引位置并返回-1
        offset = totalLength;
    }
    return i;
}
  • decode方法分析
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
    // 查找行尾标识,如果找不到eol=-1
    final int eol = findEndOfLine(buffer);
    if (!discarding) {
        // 解码的数据包没有超过缓冲区最大字节
        // 找到行尾标识符位置
        if (eol >= 0) {
            final ByteBuf frame;
            // buffer中某一行的字节长度
            final int length = eol - buffer.readerIndex();
            // 分隔符的长度,'\r\n'结尾为2,'\n'结尾为1
            final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;

            // ***超过maxLength抛出异常***(1)
            if (length > maxLength) {
                buffer.readerIndex(eol + delimLength);
                fail(ctx, length);
                return null;
            }
            // 解码时是否去掉行尾标识符
            if (stripDelimiter) {
                // 读取整行数据
                frame = buffer.readRetainedSlice(length);
                // 跳过行尾标识符的长度,buffer的读索引增加delimLength
                buffer.skipBytes(delimLength);
            } else {
                // 读取包含行尾标识符的整行数据
                frame = buffer.readRetainedSlice(length + delimLength);
            }
            // 返回整包数据
            return frame;
        } else {
            // 没找到行尾标识符
            final int length = buffer.readableBytes();
            // 缓冲区可读字节长度大于解码器最大长度需要做丢弃处理
            if (length > maxLength) {
                // 丢弃字节数赋值,等于当前可读的字节长度
                discardedBytes = length;
                // 缓冲区读索引值改变
                buffer.readerIndex(buffer.writerIndex());
                // 丢弃标识为true
                discarding = true;
                offset = 0;
                if (failFast) {
                    fail(ctx, "over " + discardedBytes);
                }
            }
            return null;
        }
    } else {
        // 缓冲区丢弃处理,因为缓冲区中可读字节大于maxLength
        if (eol >= 0) {
            // 如果新的buffer找到了行尾字符,那么新的buffer里面直接修改索引值
            final int length = discardedBytes + eol - buffer.readerIndex();
            final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
            // 缓冲区丢弃
            buffer.readerIndex(eol + delimLength);
            // 变量重置
            discardedBytes = 0;
            discarding = false;
            if (!failFast) {
                fail(ctx, length);
            }
        } else {
            // 如果还是找不到行尾结束符,继续做丢弃处理
            discardedBytes += buffer.readableBytes();
            buffer.readerIndex(buffer.writerIndex());
            // We skip everything in the buffer, we need to set the offset to 0 again.
            offset = 0;
        }
        return null;
    }
}

注释说明:超过maxLength抛出异常(1)
我通过客户端模拟数据包多次发送一个完整的消息,只要不超过maxLength,不管将消息拆解成几部分,都能完美的处理粘包问题。如果一个完整的消息超过了maxLength,不管发送几次要么直接走该注释部分代码,要么被丢弃。

3.2.2 DelimiterBasedFrameDecoder

通过特定分隔符处理消息的一种解码器,尤其是用来处理以’NUL (0x00)’,’\r’和’\n’为分隔符的消息。另外可以按需自定义分隔符(可多个不同),如果在缓冲区找到多个分隔符,将按照最短原则处理。
如果使用’\r’和’\n’为分隔符,那么解码时会使用LineBasedFrameDecoder 进行处理。

  • DelimiterBasedFrameDecoder构造器分析
public DelimiterBasedFrameDecoder(
            int maxFrameLength, boolean stripDelimiter, boolean failFast, ByteBuf... delimiters) {
    // 检查maxFrameLength必须大于0
    validateMaxFrameLength(maxFrameLength);
    // 检查delimiters既不为null也不为empty
    ObjectUtil.checkNonEmpty(delimiters, "delimiters");
    // 如果用'\r'或者'\n'结尾直接调用LineBasedFrameDecoder
    if (isLineBased(delimiters) && !isSubclass()) {
        lineBasedDecoder = new LineBasedFrameDecoder(maxFrameLength, stripDelimiter, failFast);
        this.delimiters = null;
    } else {
        // 如果指定多个分隔符,由于分隔符可以是多个,每个分隔符可以是多字符,所以需要循环获取分隔符切片
        this.delimiters = new ByteBuf[delimiters.length];
        for (int i = 0; i < delimiters.length; i ++) {
            ByteBuf d = delimiters[i];
            validateDelimiter(d);
            this.delimiters[i] = d.slice(d.readerIndex(), d.readableBytes());
        }
        lineBasedDecoder = null;
    }
    this.maxFrameLength = maxFrameLength;
    this.stripDelimiter = stripDelimiter;
    this.failFast = failFast;
}
  • decode方法分析
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
    // 如过使用'\r'和'\n'分隔
    if (lineBasedDecoder != null) {
        return lineBasedDecoder.decode(ctx, buffer);
    }
    // 
    int minFrameLength = Integer.MAX_VALUE;
    ByteBuf minDelim = null;
    // 多个分隔符最短原则逻辑处理(1),此处最为关键
    /** 
    * 例如:Hello world!&Hello#Hel&
    */
    for (ByteBuf delim: delimiters) {
        int frameLength = indexOf(buffer, delim);
        if (frameLength >= 0 && frameLength < minFrameLength) {
            minFrameLength = frameLength;
            minDelim = delim;
        }
    }

    if (minDelim != null) {
        int minDelimLength = minDelim.capacity();
        ByteBuf frame;

        if (discardingTooLongFrame) {
            // We've just finished discarding a very large frame.
            // Go back to the initial state.
            discardingTooLongFrame = false;
            buffer.skipBytes(minFrameLength + minDelimLength);

            int tooLongFrameLength = this.tooLongFrameLength;
            this.tooLongFrameLength = 0;
            if (!failFast) {
                fail(tooLongFrameLength);
            }
            return null;
        }

        if (minFrameLength > maxFrameLength) {
            // Discard read frame.
            buffer.skipBytes(minFrameLength + minDelimLength);
            fail(minFrameLength);
            return null;
        }

        if (stripDelimiter) {
            frame = buffer.readRetainedSlice(minFrameLength);
            buffer.skipBytes(minDelimLength);
        } else {
            frame = buffer.readRetainedSlice(minFrameLength + minDelimLength);
        }

        return frame;
    } else {
        if (!discardingTooLongFrame) {
            if (buffer.readableBytes() > maxFrameLength) {
                // Discard the content of the buffer until a delimiter is found.
                tooLongFrameLength = buffer.readableBytes();
                buffer.skipBytes(buffer.readableBytes());
                discardingTooLongFrame = true;
                if (failFast) {
                    fail(tooLongFrameLength);
                }
            }
        } else {
            // Still discarding the buffer since a delimiter is not found.
            tooLongFrameLength += buffer.readableBytes();
            buffer.skipBytes(buffer.readableBytes());
        }
        return null;
    }
}

decode方法分析注释说明:多个分隔符最短原则逻辑处理(1)
我通过客户端发送’Hello world!&Hello#Hel&’,分隔符指定为’&‘和’#’,第一次找分隔符按照最短原则,应该’Hello world!‘是一个完整消息,再找’Hello’是一个完整消息。因为存在多个分隔符,所以需要按照分隔符循环来找缓冲区中的内容,所以需要遵循这个’the shortest frame’(最短原则)。

3.3 添加消息头

根据消息头指定的报文长度动态的分隔消息。

3.3.1 LengthFieldBasedFrameDecoder

Netty提供的该解码器特别适用于含有一个整形header表示整个消息长度的字节序列。此解码器含有多个参数,可以使用这些参数根据实际需要组合,以便于解码整个消息。

相关参数自我理解如下
lengthFieldOffset:理解为header容量相关变量,指定0表示lengthFieldLength占header高位。指定N(非0)表示占N+lengthFieldLength字节的后lengthFieldLength位。
lengthFieldLength:header字节长度,支持1、2、3、4、8字节
lengthAdjustment:当header长度表示整段信息的长度时,即包含自己所占字节长度,需要指定一下该值纠正一下。例如客户端传递‘0x000E HELLO, WORLD’时,服务端需要使用此参数纠偏,指定-2;如果客户端传递‘0x00000F HELLO, WORLD’时,指定-3。
initialBytesToStrip:切割header的字节数

  • 关键代码分析

案例参数:
服务端new LengthFieldBasedFrameDecoder(1024, 0, 2, -2, 2)入参
客户端new LengthFieldPrepender(2, true)入参,发送’HELLO, WORLD’
字节序列为:00 0e 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44 |…HELLO, WORLD |
00 0e两字节表示长度(14),包含header本身

protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
    ......
    // 获取数据包长度
    long frameLength = getUnadjustedFrameLength(in, actualLengthFieldOffset, lengthFieldLength, byteOrder);
    ......
    // 修正数据包长度
    frameLength += lengthAdjustment + lengthFieldEndOffset;
    ......
    // never overflows because it's less than maxFrameLength
    int frameLengthInt = (int) frameLength;
    // 可读字节小于数据包长度,返回null
    if (in.readableBytes() < frameLengthInt) {
        return null;
    }
    ......
    /**
    * 用案例参数,in为14字节,initialBytesToStrip为2,执行下面语句后
    * in缓冲区读索引变为2
    */ 
    in.skipBytes(initialBytesToStrip);
    // readerIndex = 2
    int readerIndex = in.readerIndex();
    // actualFrameLength = 12
    int actualFrameLength = frameLengthInt - initialBytesToStrip;
    // 提取frame,frame为12字节的数据
    ByteBuf frame = extractFrame(ctx, in, readerIndex, actualFrameLength);
    // in设置读索引,值为14
    in.readerIndex(readerIndex + actualFrameLength);
    // 返回内容
    return frame;
}
  • demo日志
17:27:31.074 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] READ: 14B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 00 0e 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44       |..HELLO, WORLD  |
+--------+-------------------------------------------------+----------------+
17:27:31.092 [nioEventLoopGroup-3-1] INFO com.ll.length.LengthHandler - this is 1 times receive client [HELLO, WORLD]
17:27:31.093 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] WRITE: 12B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44             |HELLO, WORLD    |
+--------+-------------------------------------------------+----------------+
17:27:31.094 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] FLUSH
17:27:31.095 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] READ COMPLETE

3.3.2 LengthFieldPrepender

在消息前添加消息长度的编码器,以"HELLO, WORLD"(12B)为例,编码器长度指定为2,则"HELLO, WORLD"前加2字节的长度,变成14B发送。

相关参数自我理解如下
lengthFieldLength:报文长度所占的字节数,仅支持1、2、3、4和8。
lengthIncludesLengthFieldLength:true或者false,默认false。如果为true,长度包含header字节长度(假如2字节)。以"HELLO, WORLD"为例,如果为true,长度为0x000E(14B);如果为false,长度为0x000C(12B)。
lengthAdjustment:the compensation value to add to the value of the length field(目前理解存在偏差,后期持续关注

  • 关键代码分析
/**
* 注意大小端的问题;
* 只支持1、2、3、4、8字节长度;
*/
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
    int length = msg.readableBytes() + lengthAdjustment;
    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());
}
  • demo日志
17:27:31.037 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] WRITE: 2B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 00 0e                                           |..              |
+--------+-------------------------------------------------+----------------+
17:27:31.039 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] WRITE: 12B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44             |HELLO, WORLD    |
+--------+-------------------------------------------------+----------------+
17:27:31.039 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] FLUSH
17:27:31.097 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] READ: 12B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44             |HELLO, WORLD    |
+--------+-------------------------------------------------+----------------+
17:27:31.098 [nioEventLoopGroup-2-1] INFO com.ll.length.LengthClientHandler - this is 1 times receive server [HELLO, WORLD]
17:27:31.098 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] READ COMPLETE
17:27:31.098 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] FLUSH

3.4 自定义协议

举例如下(需要自己编写解码器,暂不详述):

格式:LEN(6位)+DATA
LEN:DATA的长度。若长度项不足位数,则左侧添0。
DATA:数据报文内容,采用UTF-8编码。
说明:数据包长度不含本身所占的6字节,如000006e4bda0,表明数据包长度为6,但报文长度为12。

3.5 附添加消息头demo源码

  • LengthServer
public class LengthServer {
	public static final Logger log = LoggerFactory.getLogger(LengthServer.class);
    private final String ip = "127.0.0.1";
    private final String port = "8001";

    public void init(){
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        ServerBootstrap bs = new ServerBootstrap();
        bs.group(bossGroup, workerGroup)
          .channel(NioServerSocketChannel.class)
          .option(ChannelOption.SO_BACKLOG, 1024)
          .childHandler(new ChannelInitializer<Channel>(){
            @Override
            protected void initChannel(Channel ch) throws Exception {
                final ChannelPipeline pipeline = ch.pipeline();
                pipeline.addLast(new LoggingHandler(LogLevel.INFO));
                pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, -2, 2));
                pipeline.addLast(new StringDecoder());
                pipeline.addLast(new LengthHandler());
            }
        });
        try {
            ChannelFuture channelFuture = bs.bind(ip, Integer.parseInt(port)).sync();
            log.info("Netty Server 启动成功! Ip: " + channelFuture.channel().localAddress().toString() + " ! ");

            channelFuture.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}
  • LengthHandler
public class LengthHandler extends ChannelInboundHandlerAdapter {
	
	public static final Logger log = LoggerFactory.getLogger(LengthHandler.class);
	int counter = 0;
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		String body = (String)msg;
		log.info("this is " + ++counter + " times receive client [" + body + "]");
		ByteBuf echo = Unpooled.copiedBuffer(body.getBytes());
		ctx.writeAndFlush(echo);
	}
	@Override
	public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
		ctx.close();
	}
}
  • LengthClient
public class LengthClient {
	public static final Logger log = LoggerFactory.getLogger(LengthClient.class);

	public void connect(int port, String host) throws InterruptedException {
		EventLoopGroup group = new NioEventLoopGroup();
		
		Bootstrap bootstrap = new Bootstrap();
		bootstrap.group(group)
		    .channel(NioSocketChannel.class)
		    .option(ChannelOption.TCP_NODELAY, true)
		    .handler(new ChannelInitializer<NioSocketChannel>() {
		    	@Override
	            protected void initChannel(NioSocketChannel ch) throws Exception {
	                final ChannelPipeline pipeline = ch.pipeline();
	                pipeline.addLast(new LoggingHandler(LogLevel.INFO));
	                // true表示header长度包含本身,既整个消息长度
	                pipeline.addLast(new LengthFieldPrepender(2, true));
	                pipeline.addLast(new StringDecoder());
	                pipeline.addLast(new LengthClientHandler());
	            }
		    });
		
		ChannelFuture future = bootstrap.connect(host, port).sync();
		
		future.channel().closeFuture().sync();
	}
	
	public static void main(String[] args) throws InterruptedException {
		new LengthClient().connect(8001, "127.0.0.1");
	}
}
  • LengthClientHandler
public class LengthClientHandler extends ChannelInboundHandlerAdapter {
	public static final Logger log = LoggerFactory.getLogger(LengthClientHandler.class);
	int counter = 0;
	String req = "HELLO, WORLD";
	@Override
	public void channelActive(ChannelHandlerContext ctx) throws Exception {
		ctx.writeAndFlush(Unpooled.copiedBuffer(req.getBytes()));
	}
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		log.info("this is " + ++counter + " times receive server [" + msg + "]");
	}
	@Override
	public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
		ctx.flush();
	}
	@Override
	public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
		ctx.close();
	}	
}

4. 参考

<<Netty权威指南(第2版)>> 李林锋/著
<<Netty实战>> 何品/译
netty-all-4.1.55.Final.jar 源码

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jinwen5290

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值