Netty4实战第四章:Transports

 本章主要内容

  • Transports
  • NIO, OIO, Local, Embedded
  • 示例
  • API
  网络应用最重要的任务之一就是传输数据。传输的方式是各种各样的,但是获取那些被转移的数据都是一样的:字节。传输方式抽象了数据的转移。你需要知道的就是你收发了什么样的字节。不用多做什么,也不能少做。
  如果你有过使用Java开发网络应用的经验,你可能会遇到要把阻塞API换成非阻塞API或者反过来。这个时候你会发现,想要完成这种转换很困难,因为Java的非阻塞API和阻塞API的基类和代码结构都差别很大。
  Netty的传输实现提供了统一的API,所以遇到上面那种情况会很容易实现。你可以保持你的代码的通用性,而不用去依赖某些特殊的实现。当年从一种传输方式换到另一种,不会需要你重构你的代码。如果你使用JDK的API开发的网络应用需要更换传输方式时,你应该知道你有多少代码需要重构了。不要把时间浪费在那上面,把时间用在更多产的东西上面。总之一句话,赶快来使用Netty。
  这一章主要内容是看看统一的API是什么样的,怎么使用它们。这里会比较Netty和JDK的API然后告诉你为什么Netty的API更容易做到一些事情。这里还将介绍Netty中的各种传输方式的实现以及什么场景使用什么。学完这个之后,你就会知道你的应用应该选择哪一种传输最好了。
  学习这一章只要有Java的基础知识就好了。如果有网络框架或网络应用的经验更好,不过没有也没关系。
  我们现在来看看啊现实世界的传输是如何工作的。

一、Transport

  为了学习传输是怎么工作的,我们这里从一个简单的应用开始,这个应用逻辑是接收客户端连接然后发送"Hi"给客户端。发送完成之后就断开连接。这是个简单的例子,每一步的详细实现这里不会深入讨论。

1.1、使用JDK的IO和NIO

  首先我们先不使用Netty的API,只使用JDK的。下面先使用JDK阻塞IO实现这个应用。

package com.nan.netty.transport;

import java.io.IOException;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;

public class PlainOioServer {

    public void serve(int port) throws IOException {
        //绑定端口
        final ServerSocket socket = new ServerSocket(port);
        try {
            while (true) {
                //接收新连接
                final Socket clientSocket = socket.accept();
                System.out.println("Accepted connection from " + clientSocket);
                //开启新线程处理连接
                new Thread(() -> {
                    OutputStream out;
                    try {
                        out = clientSocket.getOutputStream();
                        //向客户端发送数据
                        out.write("Hi!\r\n".getBytes(Charset.forName("UTF-8")));
                        out.flush();
                        //发送完成后关闭连接
                        clientSocket.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                        try {
                            clientSocket.close();
                        } catch (IOException ex) {
                        }
                    }
                }).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 主执行方法
     */
    public static void main(String[] args) throws IOException {
        new PlainOioServer().serve(9999);
    }
}

  然后下面我们再实现一个客户端,逻辑很简单,就是连接服务端,并读取服务端发来的数据。

package com.nan.netty.transport;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;

public class PlainOioClient {

    public void client(int port) throws IOException {

        //连接到服务端
        final Socket socket = new Socket("localhost", port);
        //读取服务端返回的数据
        BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        String line = reader.readLine();
        reader.close();
        socket.close();
        System.out.println("Received from server: " + line);

    }

    /**
     * 主执行方法
     */
    public static void main(String[] args) throws IOException {
        new PlainOioClient().client(9999);
    }
}

  先启动服务端,再启动客户端,可以看到客户端正确得到了服务端发送的数据。不过,随着用户量的提升,你会角色上面的代码扩展性太差。为了提高性能,你可能会打算使用异步API去实现上面的应用,但是你会发现API完全不一样。所以你不得不大量重构以前的代码。下面我们使用NIO实现一下。

package com.nan.netty.transport;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class PlainNioServer {

    public void serve(int port) throws IOException {

        System.out.println("Listening for connections on port " + port);

        ServerSocketChannel serverChannel;
        Selector selector;
        serverChannel = ServerSocketChannel.open();
        ServerSocket ss = serverChannel.socket();
        InetSocketAddress address = new InetSocketAddress(port);
        //绑定端口
        ss.bind(address);
        serverChannel.configureBlocking(false);
        //打开选择器
        selector = Selector.open();
        //注册选择器用来接收新连接
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes());
        while (true) {
            try {
                //阻塞代码只到触发了事件
                selector.select();
            } catch (IOException ex) {
                ex.printStackTrace();
                break;
            }
            //获取触发事件所有的SelectionKey
            Set<SelectionKey> readyKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = readyKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                try {
                    //检查事件是否有效
                    if (key.isAcceptable()) {
                        ServerSocketChannel server = (ServerSocketChannel)
                                key.channel();
                        SocketChannel client = server.accept();
                        System.out.println("Accepted connection from " + client);
                        client.configureBlocking(false);
                        //有效的客户端连接注册读写事件
                        client.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, msg.duplicate());
                    }
                    //检查连接写事件是否准备好
                    if (key.isWritable()) {
                        SocketChannel client = (SocketChannel) key.channel();
                        ByteBuffer buffer = (ByteBuffer) key.attachment();
                        while (buffer.hasRemaining()) {
                            //向客户端写数据,只到写完为止
                            if (client.write(buffer) == 0) {
                                break;
                            }
                        }
                        //关闭连接
                        client.close();
                    }
                } catch (IOException ex) {
                    key.cancel();
                    try {
                        key.channel().close();
                    } catch (IOException cex) {
                    }
                }
            }
        }
    }

    /**
     * 主执行方法
     */
    public static void main(String[] args) throws IOException {
        new PlainNioServer().serve(9999);
    }
}

  可以看到,虽然大家都是Java,可这API也差别太大了。而且上面只是很简单的应用,如果是很复杂的应用,重构难度更难以想象啊。

  现在我们再用Netty实现上面的应用。

1.2、使用Netty的IO和NIO

  首先还是实现阻塞IO版本的应用,比过这一次是使用Netty框架,代码如下。

package com.nan.netty.transport;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.oio.OioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.oio.OioServerSocketChannel;

import java.net.InetSocketAddress;
import java.nio.charset.Charset;

class NettyOioServer {

    public void serve(int port) throws Exception {
        final ByteBuf buf = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8")));
        EventLoopGroup group = new OioEventLoopGroup();
        try {
            //创建服务端启动器
            ServerBootstrap b = new ServerBootstrap();
            //使用OioEventLoopGroup,阻塞IO Old IO
            b.group(group)
                    .channel(OioServerSocketChannel.class)
                    .localAddress(new InetSocketAddress(port))
                    //新连接回调ChannelInitializer
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline().addLast(
                                    //添加ChannelHandler处理事件
                                    new ChannelInboundHandlerAdapter() {
                                        @Override
                                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                                            //向客户端发送数据,发送完成之后关闭连接
                                            ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE);
                                        }
                                    });
                        }
                    });
            //绑定服务器端口地址
            ChannelFuture f = b.bind().sync();
            f.channel().closeFuture().sync();
        } finally {
            //释放资源
            group.shutdownGracefully().sync();
        }
    }

    /**
     * 主执行方法
     */
    public static void main(String[] args) throws Exception {
        new NettyOioServer().serve(9999);
    }
}

  你可能会注意到,与JDK的API相比,Netty的代码非常紧凑。不过这也只是小优点之一。
  现在让我们改成非阻塞的应用。

1.3、异步实现

  上一小节使用额是Netty的阻塞IO实现的,下面的代码使用非阻塞IO实现。

package com.nan.netty.transport;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

import java.net.InetSocketAddress;
import java.nio.charset.Charset;

class NettyNioServer {

    public void serve(int port) throws Exception {
        final ByteBuf buf = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8")));
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            //创建服务端启动器
            ServerBootstrap b = new ServerBootstrap();
            //使用OioEventLoopGroup,阻塞IO Old IO
            b.group(group)
                    .channel(NioServerSocketChannel.class)
                    .localAddress(new InetSocketAddress(port))
                    //新连接回调ChannelInitializer
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline().addLast(
                                    //添加ChannelHandler处理事件
                                    new ChannelInboundHandlerAdapter() {
                                        @Override
                                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                                            //向客户端发送数据,发送完成之后关闭连接
                                            ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE);
                                        }
                                    });
                        }
                    });
            //绑定服务器端口地址
            ChannelFuture f = b.bind().sync();
            f.channel().closeFuture().sync();
        } finally {
            //释放资源
            group.shutdownGracefully().sync();
        }
    }

    /**
     * 主执行方法
     */
    public static void main(String[] args) throws Exception {
        new NettyNioServer().serve(9999);
    }
}

  乍一看,好像就是改了个类名称。仔细一看,其实就改了2个地方,一个是EventLoopGroup使用了Nio的实现,第二个就是Channel使用的NioServerSocketChannel。这样就从BIO模式换成了NIO,So easy。

  因为Netty传输的实现都是相同的API,所以具体实现用什么影响都不是太大。通过Channel、ChannelPipeline、ChannelHandler

这些接口暴露出来统一的API。
  现在我们来更深入的学习Netty的传输API。

二、传输API

  发送数据操作的传输API的核心是Channel接口。

  看看Channel接口的设计层次。


  从上图可以看出,Channel会分配ChannelPipeline和ChannelConfig给它。ChannelConfig存储着整个Channel的配置信息并且允许更新他们。一般传输都是有自己的特殊配置并且只有传输没有其他实现。为了达到目的可能就是一个ChannelConfig的子类型。

  • 转换数据格式
  • 通知异常
  • 通知Channel可用或不可用状态
  • 通知Channel从EventLoop注册或注销
  • 通知用户自定义事件
  ChannelHandler的实例都在ChannelPipeline中,它们是顺序执行的。ChannelPipeline类似Servlet里的过滤器Chain。更多关于ChannelHandler的细节我们后面讨论。
  你可以在任何时候修改ChannelPipeline,也就是说你可以需要的时候向ChannelPipeline中添加或删除ChannelHandler。所以用Netty可以编写高度灵活的应用。
  除了访问指定的ChannelPipeline和ChannelConfig,你也可以直接操作Channel。Channel提供了很多方法,下表列出来的是比较重要的。

  ChannelPipeline持有所有ChannelHandler的实例,这些实例用在通过Channel进出的数据上。ChannelHandler的实习允许你处理数据或传输数据。ChannelHandler也是Netty核心之一,后面有一章会专门讲到它的。

  目前我们已经知道ChannelHandler可以做以下任务:

方法名

描述

eventLoop()

返回指定给Channel的EventLoop

pipeline()

返回指定给Channel的ChannelPipeline

isActive()

channel是否激活状态,意思是连接是否正常

localAddress()

返回绑定本地的SocketAddress

remoteAddress()

返回连接的远程的SocketAddress

write()

向对端些数据,数据传输经过ChannelPipeline


  等下你会学到怎么使用这些功能。要记得开发的时候面向接口,使用接口提供的统一的API,这样你的应用的灵活度就会很高,当尝试不同的实现方式,代码就不用大量重构了。

  当你发送数据的时候会调用Channel.write()方法,下面给个伪代码例子。

        Channel channel = ...
        //创建数据ByteBuf
        ByteBuf buf = Unpooled.copiedBuffer("your data", CharsetUtil.UTF_8);
        //写数据
        ChannelFuture cf = channel.write(buf);
        //为了操作完成后获取通知,添加ChannelFutureListener
        cf.addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) {
                //写操作成功完成
                if (future.isSuccess()) {
                    System.out.println(“Write successful“);
                } else {
                    //写操作未成功完成
                    System.err.println(“Write error“);
                    future.cause().printStacktrace();
                }
            }
        });

  请牢记,Channel是线程安全的,也就是说在不同的线程使用它是安全的。它的所有方法都可以用在多线程环境中。因此持有它的引用并在需要的时候发送数据给对端是安全的,即使是多线程的环境。下面的伪代码展示了在多线程环境中使用它。

        final Channel channel = ...
        //创建数据ByteBuf
        final ByteBuf buf = Unpooled.copiedBuffer("your data", CharsetUtil.UTF_8);
        //创建写数据Runnable
        Runnable writer = () -> channel.write(buf.duplicate());
        //创建一个线程池
        Executor executor = Executors.newChachedThreadPool();
        //在一个线程执行写数据操作
        executor.execute(writer);
        //在另一个线程执行写数据操作
        executor.execute(writer);

同样,write()方法保证写数据顺序是和你传数据顺序一致。其他方法可以参考Netty javadocs。

了解这些接口不仅重要,而且有助于了解传输实现的不同。这些知识已经准备好了。下一章我们就来看看Netty提供了哪些传输实现以及他们的行为。

三、所有Transport

  Netty已经提供了很多可以使用的Transport实现。这些实现没有支持所有的协议,也就是说使用什么Transport得看你应用使用了什么协议。这一章我们将要学习哪个Transport实现了哪个协议。

  下表展示了Netty默认提供了的Transport。

名称

描述

NIO

io.netty.channel.socket.oio

基于java.nio.channels,使用选择器

OIO

io.netty.channel.socket.oio

java.net package为基础,使用阻塞流

Local

io.netty.channel.local

用于同一个JVM上的应用通讯

Embedded

io.netty.channel.embedded

嵌入式传输实现,主要是用来测试ChannelHandler的,不需要真实网络环境


  现在,我们来深入了解最重要的传输实现NIO。

3.1、NIO-非阻塞IO

  NIO是使用最多的传输方式。它基于Java1.4 NIO的选择器提供了所有IO操作的异步实现。

  使用Netty的NIO,我们可以通过注册监听器获取Channel状态改变的通知。主要有以下几种:

  • 接收一个新的Channel并且Channel准备OK
  • Channel连接成功
  • Channel收到数据并准备好读操作
  • Channel可以发送数据
  Netty NIO的实现负责响应这些状态变化之后充值它们并且状态再次变化时获得通知。这里是通过一个线程检测状态的更新,任何一个状态变化,都会去派发通知。
  这里也可以只去监听一个事件类型而忽略其他类型。
  下表列出来了可监听的事件类型,这些都是定义在SelectionKey类中的。

名称描述
OP_ACCEPT
接收一个新连接并且创建一个Channel后获得通知
OP_CONNECT
连接完成获得通知
OP_READ
可以从Channel读数据获得通知
OP_WRITE
可以向Channel写数据获得通知。大部分情况下这是
没问题的,但如果操作系统的缓冲区被填满了就不能
再写数据了。这种情况一般是你写的太快而对端不能
及时处理时发生。

  Netty的NIO也是使用这种模型收发数据,但是它暴露给的用户是自己的API,基本上隐藏了内部的实现。就像前面说的,Netty提供给了用户统一的API并且隐藏了实现细节。下图展示了选择器的大致流程。


  #1:一个新Channel注册到选择器上
  #2:选择器处理所有状态变更
  #3:已经注册的所有Channel
  #4:Selector.select()会阻塞,只到有状态变化或者超时
  #5:有Channel状态变化
  #6:处理状态变化
  #7:选择器操作线程执行其他任务
  这种传输方式在处理事件时可能会有一些延迟,导致吞吐量可能会比OIO低。这是因为选择器工作方式导致的,它需要花费时间去通知状态变更。当然这种延迟也只是毫秒级的。可能听起来不像延迟,但如果你的网络应用在千兆网速的环境使用它就会累加起来。
  有一个功能只有NIO传输提供,就是“零文件复制”。这个功能允许你快速有效的传输文件系统中的内容。这个功能不用将内容从内核空间复制到用户空间,就可以把文件系统的内容传输到网络堆栈中。
  不过需要注意的是,不是所有操作系统都支持这个功能。要根据操作系统的文档看它是否支持这个功能。还有,如果你对数据进行加密/解密操作,这个功能的好处你就享受不到了。因为你需要将内容放到用户空间进行操作,所以只有传输文件原始数据才能真正用到这个功能。不过,你可以先加密,再使用网络应用传输加过密的文件内容。
  一般使用到这个功能的就是大文件下载的FTP或HTTP服务。
  下一小节我们将会讨论OIO传输,它提供了阻塞IO的传输方式

3.2、OIO-Old blocking I/O

  OIO是Netty的一个折中方案。它也提供了统一的API,但实际是上它不是异步的,因为它使用的是java.net包里面的的阻塞API。乍一看,这种传输方式好像没什么用处,但是它也有它的使用场景。后面会介绍它的几个使用场景,不过现在先看一个比较特殊的。

  假如你需要重构一些老的系统,里面使用了很多阻塞方法(例如JDBC)。这些代码可能并不能换成非阻塞方式。这个时候,你就可以先使用OIO的传输方式,然后再慢慢进化到真正的非阻塞方式。我们来看看它是怎么工作的。

  因为OIO传输是基于java.net包里面的类实现的,所以它的实现逻辑和我们之前写的BIO代码差不多。

  我们使用BIO写服务端代码时,一个线程用来接收新连接,然后每一个新连接进来就会开启一个新线程去编写相关的逻辑代码。这就需要连接上的IO操作都是阻塞的。如果多个连接使用同一个线程,很明显一个连接的IO操作会阻塞所有共享这个线程的连接。

  了解了这些操作可能会阻塞代码,你可能会担心Netty使用的这些API实现的OIO也会有阻塞问题。这里Netty使用了Socket上的一个配置SO_TIMEOUT。这个参数指定了一个IO操作在多少毫秒后还没完成就算超时。如果IO操作超时了就会抛出一个超时异常SocketTimeoutException。Netty捕获这个异常,然后忽略它继续工作。下一个EventLoop执行的时候,再次尝试IO操作。不幸的是,目前只能这样处理,这样处理的问题就是捕获SocketTimeoutException是有代价的,代价就是要写到异常栈中。下图展示了这部分的主要逻辑。


  开启线程处理连接,比如读,read()方法会阻塞只到读到数据或出现超时,读到数据处理然后执行业务逻辑,没有读到数据然后执行业务逻辑后重新再去执行read()方法。
  现在你已经学习了Netty中最常用的两种传输方式,不过还有其他方式,我们也来了解一下。

3.3、Local-虚拟机中的传输

  Netty中提供了一种叫Local的传输。这种传输方式主要是用在同一个JVM中之间的连接,API依然是Netty提供的统一API。这种传输方式和NIO一样是完全异步的。

  每个Channel使用唯一的SocketAddress注册到虚拟机中。这个SocketAddress可以通过客户端连接。服务只要运行它就一直注册着。如果Channel关闭了,虚拟机就会注销这个SocketAddress,客户端也就不能连接了。

  使用Local传输方式和使用其他传输没什么太大区别。不过有一点需要注意,Local传输方式的服务端和客户端必须运行在同一个JVM中,也就是说不能像NIO或OIO那样,一个进程启动服务端,一个进程启动客户端。这看起来是个限制,不过你仔细想想,本来就应该是这样。因为这种传输方式并没有真正去绑定IP和端口,也就是没有去使用真是的网络,所以它并不能像NIO和OIO那样工作。

3.3、Embedded-嵌入式传输方式

  Netty还提供了一种嵌入式传输方式。和其他传输方式比较,这个压根就没有进行传输。不过既然Netty提供了这个,到底有傻子用呢?

  简单来说,这个玩意主要是用来帮助你测试你的ChannelHandler编写的业务逻辑。因为很多东西Netty都帮我们封装好了,开发者主要是实现ChannelHandler,但是你测试ChannelHandler的时候走网络就有些浪费资源了。它的另外一个用处就是嵌入到ChannelHandler中,然后重用这些ChannleHandler并且不需要事件继承实现。

  下面我们讨论一下什么场景使用什么样的传输方式

3.3、选择使用哪种传输

  了解完所有的传输之后,我们可能会迷茫什么时候该什么哪个。就像前面说的,不是所有的传输方式都实现了所有协议。比如NIO和OIO支持TCP、UDP和SCTP,但是Local和嵌入式的没有支持任何协议。

  下面列一下常用的经验,可以帮助我们在什么场景选择什么协议。

  • 小并发量场景
    如果你的应用并发量比较小的时候,建议使用OIO传输。因为并发量比较小,你不用担心JVM的线程量不够处理连接,虚拟      机资源不会是问题。不过怎么判别并发量是否小呢?一般来说,只要没超过1000个并发量,都可以任务并发量小,可以使      用OIO传输方式。
  • 大并发量场景
    如果并发量超过1000了,建议使用NIO的传输方式了。因为NIO使用一个线程可以处理多个连接,而OIO每个连接对应一个线      程,JVM线程数是有限的。当然如果并发量非常大,超过一台机器的上限了,那只能考虑集群了,这种情况下线程不是问题      了,硬件和系统资源是问题了。
  • 低延迟场景
    如果你的应用要求延迟比较低,应该首先考虑OIO传输。因为OIO的延迟是比NIO低,NIO的线程处理了一些IO操作,而OIO不      需要。不过NIO的延迟也就比OIO多个毫秒级别。
  • 基于阻塞代码
    如果你要重构一个基于阻塞代码的旧项目,首先建议使用OIO改写,这样项目的主要逻辑不会变更,而且使用了Netty的        API。后期如果需要继续提高性能,再慢慢重构成NIO的方式。就像前面说的,Netty OIO修改成NIO是非常简单的事情。
  • 同一个JVM中
    这个就比较明显了,优先使用Local传输方式,毕竟在同一个JVM中,不需要浪费真实网络资源。
  • 测试ChannelHandler
    这个比上一个还明显,虽然每种传输方式都可以测试你的ChannelHandler。不过嵌入式传输就是为此而生的。

四、总结

  这一章的主要内容,就是学习Netty的Transport,以及它提供了哪些传输。然后我们详细了解了每个传输,并且总结了一下什么场景选择什么传输方式。后面的章节我们还会介绍如果实现一个自己的传输方式。

  下一章,我们会学习ByteBuf和MessageList,这两个东西是Netty传输数据时的数据容器。我们会学习怎么使用它们以及怎么利用它们实现性能最优。

 

  

  


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值