Netty用户指南

关注微信公众号(瓠悠笑软件部落),大家一起学习,一起摸鱼。
huyouxiao.com

The Problem

现在我们使用通用应用程序或库来相互通信。例如,我们经常使用HTTP客户端库从Web服务器检索信息并通过Web服务调用远程过程调用。但是,通用协议或其实现有时不能很好地扩展。这就像我们不使用通用HTTP服务器来交换大量文件,电子邮件和近实时消息(如财务信息和多人游戏数据)。所需要的是高度优化的协议实现,专用于特殊目的。例如,您可能希望实现针对基于AJAX的聊天应用程序,媒体流或大文件传输进行优化的HTTP服务器。您甚至可以设计并实施一个完全根据您的需求量身定制的全新协议。另一个不可避免的情况是,您必须处理传统的专有协议,以确保与旧系统的互操作性。在这种情况下,重要的是我们能够多快地实现该协议,同时不牺牲最终应用程序的稳定性和性能。

The Solution

Netty项目旨在为可维护的高性能·高可扩展性协议服务器和客户端的快速开发提供异步事件驱动的网络应用程序框架和工具。

换句话说,Netty是一个NIO客户端服务器框架,可以快速轻松地开发协议服务器和客户端等网络应用程序。它极大地简化并简化了TCP和UDP套接字服务器开发等网络编程。

“快速简便”并不意味着最终的应用程序会受到可维护性或性能问题的影响。 Netty经过精心设计,具有丰富的协议实施经验,如FTP,SMTP,HTTP以及各种基于二进制和文本的遗留协议。因此,Netty成功地找到了一种在不妥协的情况下实现易于开发,性能,稳定性和灵活性的方法。

一些用户可能已经找到了声称具有相同优势的其他网络应用程序框架,您可能想问一下是什么让Netty与它们如此不同。答案是建立在它上面的哲学。从第一天开始,Netty旨在为您提供API和实施方面最舒适的体验。这不是有形的东西,但你会意识到,当你阅读本指南并与Netty一起玩时,这种理念将使你的生活更轻松。

Getting Started

本章通过简单的示例浏览Netty的核心结构,以便您快速入门。 当您在本章末尾时,您将能够立即在Netty上编写客户端和服务器。

如果您喜欢自上而下的方法来学习某些东西,您可能希望从第2章“架构概述”开始,然后再回到这里。

Before Getting Started

本章中运行示例的最低要求仅为两个; 最新版本的Netty和JDK 1.6或更高版本。 最新版本的Netty可在项目下载页面中找到。 要下载正确版本的JDK,请参阅您首选的JDK供应商的网站。

在阅读时,您可能对本章介绍的类有更多疑问。 如果您想了解更多相关信息,请参阅API参考。 为方便起见,本文档中的所有类名都链接到在线API参考。 另外,请不要犹豫与Netty项目社区联系,如果有任何不正确的信息,语法和拼写错误,以及您是否有改进文档的好主意,请告诉我们。

Writing a Discard Server

世界上最简单的协议不是’Hello,World!’ 而是丢弃 (Discard)。 它是一种在没有任何响应的情况下丢弃任何接收数据的协议。
要实现DISCARD协议,您唯一需要做的就是忽略所有收到的数据。 让我们直接从处理程序实现开始,它处理由Netty生成的I / O事件。

package io.netty.example.discard;

import io.netty.buffer.ByteBuf;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;

/**
 * Handles a server-side channel.
 */
public class DiscardServerHandler extends ChannelInboundHandlerAdapter { // (1)

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) { // (2)
        // Discard the received data silently.
        ((ByteBuf) msg).release(); // (3)
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4)
        // Close the connection when an exception is raised.
        cause.printStackTrace();
        ctx.close();
    }
}
  1. DiscardServerHandler 继承了 ChannelInboundHandlerAdapter,它是 ChannelInboundHandler 的一个实现。
    ChannelInboundHandler提供了可以覆盖的各种事件处理程序方法。目前,只需扩展ChannelInboundHandlerAdapter而不是自己实现处理程序接口。
    DiscardServerHandler类继承关系:
    DiscardServerHandler类继承关系
    ChannelInboundHandler接口方法:
    ChannelInboundHandler接口方法
    ChannelHandler接口方法:
    ChannelHandler接口方法
  2. 我们在这里覆盖 channelRead() 事件处理程序方法。 每当从客户端接收到新数据时,都会使用收到的消息调用此方法。 在此示例中,接收消息的类型是ByteBuf。
  3. 要实现DISCARD协议,处理程序必须忽略收到的消息。 ByteBuf是一个引用计数对象,必须通过 release() 方法显式释放。 请记住,处理程序有责任释放传递给处理程序的任何引用计数对象。 通常,channelRead() 处理程序方法的实现方式如下:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    try {
        // Do something with msg
    } finally {
        ReferenceCountUtil.release(msg);
    }
}
  1. 当由于I / O错误或由处理事件时抛出的异常引起的处理程序实现而由Netty引发异常时,使用Throwable调用exceptionCaught() 事件处理程序方法。 在大多数情况下,应该记录捕获的异常并在此处关闭其关联的通道,尽管此方法的实现可能会有所不同,具体取决于您要处理异常情况的操作。 例如,您可能希望在关闭连接之前发送带有错误代码的响应消息。

到现在为止还挺好。 我们已经实现了DISCARD服务器的前半部分。 现在剩下的就是编写使用 DiscardServerHandler 启动服务器的 main() 方法。

package io.netty.example.discard;
    
import io.netty.bootstrap.ServerBootstrap;

import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
    
/**
 * Discards any incoming data.
 */
public class DiscardServer {
    
    private int port;
    
    public DiscardServer(int port) {
        this.port = port;
    }
    
    public void run() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup(); // (1)
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap(); // (2)
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class) // (3)
             .childHandler(new ChannelInitializer<SocketChannel>() { // (4)
                 @Override
                 public void initChannel(SocketChannel ch) throws Exception {
                     ch.pipeline().addLast(new DiscardServerHandler());
                 }
             })
             .option(ChannelOption.SO_BACKLOG, 128)          // (5)
             .childOption(ChannelOption.SO_KEEPALIVE, true); // (6)
    
            // Bind and start to accept incoming connections.
            ChannelFuture f = b.bind(port).sync(); // (7)
    
            // Wait until the server socket is closed.
            // In this example, this does not happen, but you can do that to gracefully
            // shut down your server.
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
    
    public static void main(String[] args) throws Exception {
        int port = 8080;
        if (args.length > 0) {
            port = Integer.parseInt(args[0]);
        }

        new DiscardServer(port).run();
    }
}
  1. NioEventLoopGroup 是一个处理 I/O 操作的多线程事件循环。 Netty 为不同类型的传输提供各种 EventLoopGroup 实现。 我们在此示例中实现了服务器端应用程序,因此将使用两个 NioEventLoopGroup 。 第一个,通常称为“老板(Boss)”,接受传入连接。 第二个,通常称为“工人(Worker)”,一旦老板接受连接并将接受的连接注册到工作人员,就处理被接受连接的流量。 使用了多少个线程以及它们如何映射到创建的 Channels 取决于 EventLoopGroup 实现,甚至可以通过构造函数进行配置。
    NioEventLoopGroup继承结构:
    NioEventLoopGroup 继承结构
    EventLoopGroup接口方法:
    EventLoopGroup接口方法

  2. ServerBootstrap是一个设置服务器的帮助程序类。 您可以直接使用Channel设置服务器。 但请注意,这是一个繁琐的过程,在大多数情况下您不需要这样做。

  3. 在这里,我们指定使用 NioServerSocketChannel 类,该类用于实例化新 Channel 以接受传入连接。
    NioServerSocketChannel

  4. 此处指定的处理程序将始终由新接受的Channel评估。 ChannelInitializer是一个特殊的处理程序,旨在帮助用户配置新的Channel。 您最有可能希望通过添加一些处理程序(如DiscardServerHandler)来配置新Channel的ChannelPipeline,以实现您的网络应用程序。 随着应用程序变得复杂,您可能会向管道添加更多处理程序,并最终将此匿名类提取到顶级类中。

  5. 您还可以设置特定于 Channel 实现的参数。 我们正在编写TCP / IP服务器,因此我们可以设置套接字选项,如tcpNoDelay 和 keepAlive。 请参阅 ChannelOption 的apidocs和特定的ChannelConfig实现,以获得有关受支持的ChannelOptions的概述。

  6. 你注意到 option() 和 childOption() 吗? option() 用于接受传入连接的 NioServerSocketChannel。 childOption() 用于parent ServerChannel 接受的 Channels,在这种情况下是 NioServerSocketChannel。

  7. 我们现在准备好了。 剩下的就是绑定到端口并启动服务器。 在这里,我们绑定到机器中所有NIC(网络接口卡)的端口8080。 您现在可以根据需要多次调用 bind() 方法(使用不同的绑定地址。)

恭喜! 您刚刚在Netty上完成了第一台服务器。

Looking into the Received Data

现在我们已经编写了第一台服务器,我们需要测试它是否真的有效。 测试它的最简单方法是使用telnet命令。 例如,您可以在命令行中输入telnet localhost 8080并键入内容。

但是,我们可以说服务器工作正常吗? 我们无法真正知道,因为它是一个丢弃服务器。 你根本不会得到任何回应。 为了证明它确实有效,让我们修改服务器以打印它收到的内容。

我们已经知道每当收到数据时都会调用 channelRead() 方法。 让我们将一些代码放入 DiscardServerHandler 的channelRead() 方法中:

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf in = (ByteBuf) msg;
    try {
        while (in.isReadable()) { // (1)
            System.out.print((char) in.readByte());
            System.out.flush();
        }
    } finally {
        ReferenceCountUtil.release(msg); // (2)
    }
}
  1. 这个低效的循环实际上可以简化为:System.out.println(in.toString(io.netty.util.CharsetUtil.US_ASCII))
  2. 或者,你可以在这里做 in.release() 。
    如果再次运行telnet命令,您将看到服务器打印已收到的内容。
    丢弃服务器的完整源代码位于发行版的io.netty.example.discard包中。

Writing an Echo Server

到目前为止,我们一直在使用数据而没有响应。 但是,服务器通常应该响应请求。 让我们学习如何通过实现ECHO协议向客户端写入响应消息,其中任何接收的数据都被发回。

与我们在前面部分中实现的丢弃服务器的唯一区别在于,它将接收到的数据发回,而不是将接收到的数据打印到控制台。 因此,再次修改channelRead()方法就足够了:

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ctx.write(msg); // (1)
        ctx.flush(); // (2)
    }

  1. ChannelHandlerContext对象提供各种操作,使您能够触发各种 I/O 事件和操作。 在这里,我们调用 write(Object) 来逐字写入接收到的消息。 请注意,我们没有发布收到的消息,这与我们在DISCARD示例中的操作不同。 这是因为Netty在写入线路时会为您发布。
  2. ctx.write(Object) 不会将消息写入线路。 它在内部缓冲,然后通过 ctx.flush() 刷新到线路。 或者,您可以调用ctx.writeAndFlush(msg)以简洁起见。

如果再次运行telnet命令,您将看到服务器发送回发送给它的任何内容。
echo服务器的完整源代码位于发行版的io.netty.example.echo包中。

Writing a Time Server

本节中要实现的协议是TIME协议。 它与前面的示例的不同之处在于,它发送包含32位整数的消息,而不接收任何请求,并在发送消息后关闭连接。 在此示例中,您将学习如何构造和发送消息,以及在完成时关闭连接。

因为我们将忽略任何接收的数据,但是一旦建立连接就发送消息,这次我们不能使用 channelRead() 方法。 相反,我们应该覆盖 channelActive() 方法。 以下是实施:

package io.netty.example.time;

public class TimeServerHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(final ChannelHandlerContext ctx) { // (1)
        final ByteBuf time = ctx.alloc().buffer(4); // (2)
        time.writeInt((int) (System.currentTimeMillis() / 1000L + 2208988800L));
        
        final ChannelFuture f = ctx.writeAndFlush(time); // (3)
        f.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) {
                assert f == future;
                ctx.close();
            }
        }); // (4)
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}
  1. 如上所述,当建立连接并准备生成流量时,将调用 channelActive() 方法。 让我们写一个32位整数来表示这个方法中的当前时间。

  2. 要发送新消息,我们需要分配一个包含消息的新缓冲区。 我们要写一个32位整数,因此我们需要一个容量至少为4个字节的ByteBuf。 通过 ChannelHandlerContext.alloc() 获取当前的 ByteBufAllocator 并分配一个新的缓冲区。

  3. 像往常一样,我们编写构造的消息。
    但等等,翻转的地方在哪里?在NIO中发送消息之前,我们不习惯调用java.nio.ByteBuffer.flip()吗? ByteBuf没有这样的方法,因为它有两个指针;一个用于读操作,另一个用于写操作。当您在读取器索引未更改时向ByteBuf写入内容时,写入器索引会增加。 reader索引和writer索引分别表示消息的开始和结束位置。
    相比之下,NIO缓冲区没有提供一种干净的方法来确定消息内容的开始和结束位置,而无需调用flip方法。当您忘记翻转缓冲区时,您将遇到麻烦,因为不会发送任何数据或不正确的数据。在Netty中不会发生这样的错误,因为我们对不同的操作类型有不同的指针。你会发现它让你的生活变得更加轻松,因为你已经习惯了 - 没有翻身的生活!
    另一点需要注意的是ChannelHandlerContext.write()(和writeAndFlush())方法返回一个ChannelFuture。 ChannelFuture表示尚未发生的I / O操作。这意味着,任何请求的操作可能尚未执行,因为所有操作在Netty中都是异步的。例如,以下代码可能会在发送消息之前关闭连接:

Channel ch = ...;
ch.writeAndFlush(message);
ch.close();

因此,您需要在完成ChannelFuture之后调用 close() 方法,该方法由 write() 方法返回,并在写入操作完成时通知其侦听器。 请注意,close() 也可能不会立即关闭连接,并返回ChannelFuture。
当写请求完成后我们如何得到通知? 这就像向返回的ChannelFuture添加ChannelFutureListener一样简单。 在这里,我们创建了一个新的匿名ChannelFutureListener,它在操作完成时关闭Channel。

  1. 当写请求完成后我们如何得到通知? 这就像向返回的ChannelFuture添加ChannelFutureListener一样简单。 在这里,我们创建了一个新的匿名ChannelFutureListener,它在操作完成时关闭Channel。

或者,您可以使用预定义的侦听器简化代码:

f.addListener(ChannelFutureListener.CLOSE);

要测试我们的时间服务器是否按预期工作,您可以使用UNIX rdate命令:

$ rdate -o <port> -p <host>

其中是您在main()方法中指定的端口号,通常是localhost。

Writing a Time Client

与 DISCARD 和 ECHO 服务器不同,我们需要TIME协议的客户端,因为人类无法将32位二进制数据转换为日历上的日期。 在本节中,我们将讨论如何确保服务器正常工作并学习如何使用Netty编写客户端。

Netty中服务器和客户端之间最大和唯一的区别是使用了不同的Bootstrap和Channel实现。 请看下面的代码:

package io.netty.example.time;

public class TimeClient {
    public static void main(String[] args) throws Exception {
        String host = args[0];
        int port = Integer.parseInt(args[1]);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        
        try {
            Bootstrap b = new Bootstrap(); // (1)
            b.group(workerGroup); // (2)
            b.channel(NioSocketChannel.class); // (3)
            b.option(ChannelOption.SO_KEEPALIVE, true); // (4)
            b.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                public void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new TimeClientHandler());
                }
            });
            
            // Start the client.
            ChannelFuture f = b.connect(host, port).sync(); // (5)

            // Wait until the connection is closed.
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
        }
    }
}
  1. Bootstrap 与 ServerBootstrap 类似,不同之处在于它适用于非服务器通道,例如客户端或无连接通道。
  2. 如果只指定一个EventLoopGroup,它将同时用作boss组和worker组。 不过,老板工作者不会用于客户端。
  3. NioSocketChannel 用于创建客户端通道,而不是 NioServerSocketChannel。
  4. 请注意,我们不像在 ServerBootstrap 中那样使用 childOption() ,因为客户端 SocketChannel 没有父服务器。
  5. 我们应该调用 connect() 方法而不是 bind() 方法。
    如您所见,它与服务器端代码没有什么不同。 ChannelHandler实现怎么样? 它应该从服务器接收一个32位整数,将其转换为人类可读的格式,打印翻译的时间,并关闭连接:
ackage io.netty.example.time;

import java.util.Date;

public class TimeClientHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf m = (ByteBuf) msg; // (1)
        try {
            long currentTimeMillis = (m.readUnsignedInt() - 2208988800L) * 1000L;
            System.out.println(new Date(currentTimeMillis));
            ctx.close();
        } finally {
            m.release();
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}
  1. 在TCP / IP中,Netty将从对等方发送的数据读入ByteBuf。
    它看起来非常简单,与服务器端示例没有任何不同。 但是,此处理程序有时会拒绝提升IndexOutOfBoundsException。 我们将在下一节讨论为什么会这样。

Dealing with a Stream-based Transport

One Small Caveat of Socket Buffer

在基于流的传输(例如TCP / IP)中,接收的数据存储在套接字接收缓冲区中。 不幸的是,基于流的传输的缓冲区不是数据包队列而是字节队列。 这意味着,即使您将两条消息作为两个独立的数据包发送,操作系统也不会将它们视为两条消息,而只是一堆字节。 因此,无法保证您所阅读的内容正是您的远程同行所写的内容。 例如,假设操作系统的TCP / IP堆栈已收到三个数据包:
收到三个数据包
由于基于流的协议的这种一般属性,在您的应用程序中以下面的碎片形式读取它们的可能性很高:
读取三个数据包
因此,接收部份,无论是服务器端还是客户端,都应该将接收到的数据碎片整理成应用程序逻辑可以容易理解的一个或多个有意义的帧。 在上述示例的情况下,接收的数据应如下框架:
有意义的帧

The First Solution

现在让我们回到TIME客户端示例。 我们这里有同样的问题。 32位整数是非常少量的数据,并且不太可能经常被分段。 然而,问题在于它可能是碎片化的,并且随着流量的增加,碎片化的可能性将增加。

简单的解决方案是创建一个内部累积缓冲区,并等待所有4个字节都被接收到内部缓冲区。 以下是修复此问题的修改后的TimeClientHandler实现:

package io.netty.example.time;

import java.util.Date;

public class TimeClientHandler extends ChannelInboundHandlerAdapter {
    private ByteBuf buf;
    
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) {
        buf = ctx.alloc().buffer(4); // (1)
    }
    
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) {
        buf.release(); // (1)
        buf = null;
    }
    
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf m = (ByteBuf) msg;
        buf.writeBytes(m); // (2)
        m.release();
        
        if (buf.readableBytes() >= 4) { // (3)
            long currentTimeMillis = (buf.readUnsignedInt() - 2208988800L) * 1000L;
            System.out.println(new Date(currentTimeMillis));
            ctx.close();
        }
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}
  1. ChannelHandler有两个生命周期监听器方法:handlerAdded() 和handlerRemoved()。 您可以执行任意(de)初始化任务,只要它不会长时间阻塞。
  2. 首先,所有收到的数据应累积到buf中。
  3. 然后,处理程序必须检查buf是否有足够的数据,在此示例中为4个字节,然后继续执行实际的业务逻辑。 否则,当更多数据到达时,Netty将再次调用 channelRead() 方法,最终将累计所有4个字节。
The Second Solution

虽然第一个解决方案已经解决了TIME客户端的问题,但修改后的处理程序看起来并不干净。 想象一个更复杂的协议,它由多个字段组成,例如可变长度字段。 您的 ChannelInboundHandler 实现将很快变得无法维护。

您可能已经注意到,可以向 ChannelPipeline 添加多个 ChannelHandler,因此,您可以将一个单片 ChannelHandler 拆分为多个模块化ChannelHandler,以降低应用程序的复杂性。 例如,您可以将TimeClientHandler拆分为两个处理程序:

  • TimeDecoder处理碎片问题,以及
  • 最初的简单版本的TimeClientHandler。
    幸运的是,Netty提供了一个可扩展的类,可以帮助您编写第一个开箱即用的类:
package io.netty.example.time;

public class TimeDecoder extends ByteToMessageDecoder { // (1)
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // (2)
        if (in.readableBytes() < 4) {
            return; // (3)
        }
        
        out.add(in.readBytes(4)); // (4)
    }
}
  1. ByteToMessageDecoder是ChannelInboundHandler的一个实现,可以很容易地处理碎片问题。
  2. 每当收到新数据时,ByteToMessageDecoder都会使用内部维护的累积缓冲区调用 decode() 方法。
  3. decode() 可以决定在累积缓冲区中没有足够数据的地方添加任何内容。 当收到更多数据时,ByteToMessageDecoder将再次调用decode()。
  4. 如果 decode() 将对象添加到out,则意味着解码器成功解码了一条消息。 ByteToMessageDecoder将丢弃累积缓冲区的读取部分。 请记住,您不需要解码多条消息。 ByteToMessageDecoder将继续调用 decode() 方法,直到它不添加任何内容。
    既然我们有另一个要插入ChannelPipeline的处理程序,我们应该修改TimeClient中的ChannelInitializer实现:
b.handler(new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch) throws Exception {
        ch.pipeline().addLast(new TimeDecoder(), new TimeClientHandler());
    }
});

如果您是一个喜欢冒险的人,您可能想尝试ReplayingDecoder,它可以进一步简化解码器。 您需要参考API参考以获取更多信息。

public class TimeDecoder extends ReplayingDecoder<Void> {
    @Override
    protected void decode(
            ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        out.add(in.readBytes(4));
    }
}

此外,Netty提供开箱即用的解码器,使您能够非常轻松地实现大多数协议,并帮助您避免最终导致单片不可维护的处理程序实现。 有关更多详细示例,请参阅以下软件包:

  • io.netty.example.factorial for a binary protocol, and
  • io.netty.example.telnet for a text line-based protocol.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值