Netty学习笔记(4)——Selector

 

1. Selector用来干什么

    Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。Selector是NIO实现的核心。简单来讲,Selector会不断地轮询注册在其上的Channel,如果某个Channel上面有新的TCP连接接入、读和写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。

285763-20180111113704754-1240119073.png

2. 为什么使用Selector

    前面已经说过,如果一个线程只能同时持有或者管理一个IO(指网络IO),那么一旦在高并发情况下,会造成极大的系统性能浪费,如果有一千个IO操作,那么就对应着一千个线程,而这1000个线程之间的上下文切换将会是非常恐怖的性能消耗,而且实际并发量可能远大于1000个线程,每个线程也都需要占用很多内存资源。虽然现代的操作系统和CPU在多任务方面表现的越来越好,所以多线程的开销随着时间的推移,变得越来越小了。实际上,如果一个CPU有多个内核,不使用多任务可能是在浪费CPU能力。但是我们必须考虑大量线程快速切换造成的性能浪费。

 

3. Selector的使用

选择器提供选择执行已经就绪的任务的能力,从底层来看,Selector提供了询问通道是否已经准备好执行每个I/O操作的能力。Selector 允许单线程处理多个Channel,三个实现组件Selector、SelectableChannel和SelectionKey:

选择器(Selector)

Selector选择器类管理着一个被注册的通道集合的信息和它们的就绪状态。通道是和选择器一起被注册的,并且使用选择器来更新通道的就绪状态。当这么做的时候,可以选择将被激发的线程挂起,直到有就绪的的通道。

可选择通道(SelectableChannel)

SelectableChannel这个抽象类提供了实现通道的可选择性所需要的公共方法。它是所有支持就绪检查的通道类的父类。因为FileChannel类没有继承SelectableChannel因此是不是可选通道,而所有socket通道都是可选择的,包括从管道(Pipe)对象的中获得的通道。SelectableChannel可以被注册到Selector对象上,同时可以指定对那个选择器而言,那种操作是感兴趣的。一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次。

选择键(SelectionKey)

选择键封装了特定的通道特定的选择器的注册关系。选择键对象被SelectableChannel.register()返回并提供一个表示这种注册关系的标记。选择键包含了两个比特集(以整数的形式进行编码),指示了该注册关系所关心的通道操作,以及通道已经准备好的操作。

d24f7ad279060a8b426f1c17b499849fff1.jpg

3.1 Selector的创建

通过调用Selector.open()方法创建一个Selector,通过Selector的静态方法open()创建,如下:

Selector selector = Selector.open();

3.2 将channel注册到Selector中

为了将Channel和Selector配合使用,必须将channel注册到selector上。通过SelectableChannel.register()方法来实现,如下:

channel.configureBlocking(false);
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);

与Selector一起使用时,Channel必须处于非阻塞模式下这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以

注意register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:

  1. Connect:某个channel成功连接到另一个服务器称为“连接就绪”
  2. Accept:ServersocketChannel备好接收新进入的连接称为“接收就绪”
  3. Read:一个有数据可读的通道可以说是“读就绪”。
  4. Write:等待写数据的通道可以说是“写就绪”。

这四种事件用SelectionKey的四个常量来表示:

  1. SelectionKey.OP_CONNECT
  2. SelectionKey.OP_ACCEPT
  3. SelectionKey.OP_READ
  4. SelectionKey.OP_WRITE

如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

Selector的原理是实际上就是使用了观察者模式,通过将Channel注册到Selector之中,一旦Channel的状态变化都会将这个变化状态通知给Selector之中,具体可以学习了解观察者模式 

SelectionKey

当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。这个对象包含了一些你感兴趣的属性:

  • interest集合
  • ready集合
  • Channel
  • Selector
  • 附加的对象(可选)

interest集合

interest集合是你所选择的感兴趣的事件集合。可以通过SelectionKey读写interest集合,像这样:

int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;

可以看到,用“位与”操作interest 集合和给定的SelectionKey常量,可以确定某个确定的事件是否在interest 集合中。

ready集合

ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先访问这个ready set。Selection将在下一小节进行解释。可以这样访问ready集合:

int readySet = selectionKey.readyOps();

可以用像检测interest集合那样的方法,来检测channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:

selectionKey.isAcceptable();//等价于selectionKey.readyOps()&SelectionKey.OP_ACCEPT
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

需要注意的是,通过相关的选择键的readyOps()方法返回的就绪状态指示只是一个提示,底层的通道在任何时候都会不断改变,而其他线程也可能在通道上执行操作并影响到它的就绪状态。另外,我们不能直接修改ready集合。

取出SelectionKey所关联的Selector和Channel

从SelectionKey访问Channel和Selector很简单。如下:

Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();

关于取消SelectionKey对象

我们可以通过SelectionKey对象的cancel()方法来取消特定的注册关系。该方法调用之后,该SelectionKey对象将会被”拷贝”至已取消键的集合中,该键此时已经失效,但是该注册关系并不会立刻终结。在下一次select()时,已取消键的集合中的元素会被清除,相应的注册关系也真正终结。

为SelectionKey绑定附加对象

可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

还可以在用register()方法向Selector注册Channel的时候附加对象。如:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

需要注意的是如果附加的对象不再使用,一定要人为清除,因为垃圾回收器不会回收该对象,若不清除的话会成内存泄漏。

一个单独的通道可被注册到多个选择器中,有些时候我们需要通过isRegistered()方法来检查一个通道是否已经被注册到任何一个选择器上。 通常来说,我们并不会这么做。

3.3、通过Selector选择通道

我们可以通过 Selector.select()方法获取对某件事件准备好了的 Channel, 即如果我们在注册 Channel 时, 对其的可写事件感兴趣, 那么当 select()返回时, 我们就可以获取 Channel 了.

注意, select()方法返回的值表示有多少个 Channel 可操作.

我们知道选择器维护注册过的通道的集合,并且这种注册关系都被封装在SelectionKey当中。接下来我们简单的了解一下Selector维护的三种类型SelectionKey集合:

已注册的键的集合(Registered key set)

所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过keys()方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引发java.lang.UnsupportedOperationException。

已选择的键的集合(Selected key set)

已注册的键的集合的子集。这个集合的每个成员都是被选择器(在前一个选择操作中)判断为已经准备好的通道,并且包含于键的interest集合中的操作。这个集合通过selectedKeys()方法返回(并有可能是空的)。 
不要将已选择的键的集合与ready集合弄混了。这是一个键的集合,每个键都关联一个已经准备好至少一种操作的通道。每个键都有一个内嵌的ready集合,指示了所关联的通道已经准备好的操作。键可以直接从这个集合中移除,但不能添加。试图向已选择的键的集合中添加元素将抛出java.lang.UnsupportedOperationException。

已取消的键的集合(Cancelled key set)

已注册的键的集合的子集,这个集合包含了cancel()方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。

在刚初始化的Selector对象中,这三个集合都是空的。通过Selector的select()方法可以选择已经准备就绪的通道(这些通道包含你感兴趣的的事件)。比如你对读就绪的通道感兴趣,那么select()方法就会返回读事件已经就绪的那些通道。下面是Selector几个重载的select()方法: 

  • select():阻塞到至少有一个通道在你注册的事件上就绪了。 
  • select(long timeout):和select()一样,但最长阻塞事件为timeout毫秒。 
  • selectNow():非阻塞,只要有通道就绪就立刻返回。

select()方法返回的int值表示有多少通道已经就绪。亦即,自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。

selectedKeys()

一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:

Set selectedKeys = selector.selectedKeys();

当像Selector注册Channel时,Channel.register()方法会返回一个SelectionKey 对象。这个对象代表了注册到该Selector的通道。可以通过SelectionKey的selectedKeySet()方法访问这些对象。

可以遍历这个已选择的键集合来访问就绪的通道。如下:

Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }
    keyIterator.remove();
}

这个循环遍历已选择键集中的每个键,并检测各个键所对应的通道的就绪事件。

注意, 在每次迭代时, 我们都调用 "keyIterator.remove()" 将这个 key 从迭代器中删除, 因为 select() 方法仅仅是简单地将就绪的 IO 操作放到 selectedKeys 集合中, 因此如果我们从 selectedKeys 获取到一个 key, 但是没有将它删除, 那么下一次 select 时, 这个 key 所对应的 IO 事件还在 selectedKeys 中.
例如此时我们收到 OP_ACCEPT 通知, 然后我们进行相关处理, 但是并没有将这个 Key 从 SelectedKeys 中删除, 那么下一次 select() 返回时 我们还可以在 SelectedKeys 中获取到 OP_ACCEPT 的 key.
注意, 我们可以动态更改 SekectedKeys 中的 key 的 interest set. 例如在 OP_ACCEPT 中, 我们可以将 interest set 更新为 OP_READ, 这样 Selector 就会将这个 Channel 的 读 IO 就绪事件包含进来了。

SelectionKey.channel()方法返回的通道需要转型成你要处理的类型,如ServerSocketChannel或SocketChannel等。

关于Selector执行选择的过程

我们知道调用select()方法进行选择通道,现在我们再来深入一下选择的过程,也就是select()执行过程。当select()被调用时将执行以下几步:

  1. 首先检查已取消键集合,也就是通过cancle()取消的键。如果该集合不为空,则清空该集合里的键,同时该集合中每个取消的键也将从已注册键集合和已选择键集合中移除。(一个键被取消时,并不会立刻从集合中移除,而是将该键“拷贝”至已取消键集合中,这种取消策略就是我们常提到的“延迟取消”。)
  2. 再次检查已注册键集合(准确说是该集合中每个键的interest集合)。系统底层会依次询问每个已经注册的通道是否准备好选择器所感兴趣的某种操作,一旦发现某个通道已经就绪了,则会首先判断该通道是否已经存在在已选择键集合当中,如果已经存在,则更新该通道在已注册键集合中对应的键的ready集合,如果不存在,则首先清空该通道的对应的键的ready集合,然后重设ready集合,最后将该键存至已注册键集合中。这里需要明白,当更新ready集合时,在上次select()中已经就绪的操作不会被删除,也就是ready集合中的元素是累积的,比如在第一次的selector对某个通道的read和write操作感兴趣,在第一次执行select()时,该通道的read操作就绪,此时该通道对应的键中的ready集合存有read元素,在第二次执行select()时,该通道的write操作也就绪了,此时该通道对应的ready集合中将同时有read和write元素。

深入已注册键集合的管理

到现在我们已经知道一个通道的的键是如何被添加到已选择键集合中的,下面我们来继续了解对已选择键集合的管理 。首先要记住:选择器不会主动删除被添加到已选择键集合中的键,而且被添加到已选择键集合中的键的ready集合只能被设置,而不能被清理。如果我们希望清空已选择键集合中某个键的ready集合该怎么办?我们知道一个键在新加入已选择键集合之前会首先置空该键的ready集合,这样的话我们可以人为的将某个键从已注册键集合中移除最终实现置空某个键的ready集合。被移除的键如果在下一次的select()中再次就绪,它将会重新被添加到已选择的键的集合中。这就是为什么要在每次迭代的末尾调用keyIterator.remove()

停止选择

选择器执行选择的过程,系统底层会依次询问每个通道是否已经就绪,这个过程可能会造成调用线程进入阻塞状态,那么我们有以下三种方式可以唤醒在select()方法中阻塞的线程。

  1. 通过调用Selector对象的wakeup()方法让处在阻塞状态的select()方法立刻返回 
    该方法使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有进行中的选择操作,那么下一次对select()方法的一次调用将立即返回。

  2. 通过close()方法关闭Selector** 
    该方法使得任何一个在选择操作中阻塞的线程都被唤醒(类似wakeup()),同时使得注册到该Selector的所有Channel被注销,所有的键将被取消,但是Channel本身并不会关闭。

  3. 调用interrupt() 
    调用该方法会使睡眠的线程抛出InterruptException异常,捕获该异常并在调用wakeup()

上面有些人看到“系统底层会依次询问每个通道”时可能在想如果已选择键非常多是,会不会耗时较长?答案是肯定的。但是我想说的是通常你可以选择忽略该过程,至于为什么,后面再说。

 

Selector 的基本使用流程

  1. 通过 Selector.open() 打开一个 Selector.

  2. 将 Channel 注册到 Selector 中, 并设置需要监听的事件(interest set)

  3. 循环体内:

    • 调用 select() 方法,判断是否有准备好了的Channel,并且将准备就绪的通道添加至已选择通道集合中

    • 调用 selector.selectedKeys() 获取已选择通道集合的keys

    • 迭代每个 selected key:

      • 从 selected key 中获取 对应的 Channel 和附加信息(如果有的话)

      • 判断是哪些 IO 事件已经就绪了, 然后处理它们. 如果是 OP_ACCEPT 事件, 则调用 "SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept()" 获取 SocketChannel, 并将它设置为 非阻塞的, 然后将这个 Channel 注册到 Selector 中.

      • 根据需要更改 selected key 的监听事件.

      • 将已经处理过的 key 从 selected keys 集合中删除.

 

4. 利用Selector、Channel和Buffer编写一个服务器简单实例

/**
 * 
 * @ClassName:NIOServer
 * @Description:通过NIO技术实现的服务端
 * @author: 
 * @date:2019年10月14日
 */
public class NIOServer {
    private int size = 1024;//Buffer缓冲区大小
    private ServerSocketChannel ServerChannel;//服务端的ServerSocketChannel
    private Selector selector;
    private final int port = 8998;//监听端口
    private int remoteClientNum=0;//用来统计已经建立连接的客户端数量

    /**
     * 构造方法,同时也是服务端的初始化方法
     */
    public NIOServer() {
        try {
            initChannel();//初始化服务端
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(-1);
        }
    }
    
    /**
     * 
     * @Title:initChannel
     * @Description:服务器初始化
     * @author: 
     * @date:2019年10月14日
     * @param:@throws Exception
     * @return:void
     * @throws:
     */
    public void initChannel() throws Exception {
        ServerChannel = ServerSocketChannel.open();
        ServerChannel.configureBlocking(false);
        ServerChannel.bind(new InetSocketAddress(port));
        System.out.println("listener on port:" + port);
        selector = Selector.open();
        ServerChannel.register(selector, SelectionKey.OP_ACCEPT);
        
    }
    
    /**
     * 
     * @Title:listener
     * @Description:开启服务监听,相当于服务器的启动方法
     * @author: 
     * @date:2019年10月14日
     * @param:@throws Exception
     * @return:void
     * @throws:
     */
    private void listener() throws Exception {
        while (true) {
            int n = selector.select();//获取准备就绪的Channel数
            if (n == 0) {
                continue;
            }
            /*
             * 迭代遍历
             */
            Iterator<SelectionKey> ite = selector.selectedKeys().iterator();
            while (ite.hasNext()) {
                SelectionKey key = ite.next();
                //如果有一个ServerSocketChannel为Accept状态,那么就获取该Channel并建立连接
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel channel = server.accept();
                    //注册建立TCP连接的SocketChannel,并注册到selector中
                    registerChannel(selector, channel, SelectionKey.OP_READ);
                    remoteClientNum++;
                    System.out.println("online client num="+remoteClientNum);
                    replyClient(channel);//回复客户端一个消息,表示建立连接
                }
                //如果该有个SocketChannel处于数据读取就绪状态,那么就进行数据读取
                if (key.isReadable()) {
                    readDataFromSocket(key);
                }

                ite.remove();//将该SelectionKey从已选择SelectionKey集合中移除
            }

        }
    }
    /**
     * 
     * @Title:readDataFromSocket
     * @Description:读取从客户端发送过来的数据
     * @author: 
     * @date:2019年10月14日
     * @param:@param key
     * @param:@throws Exception
     * @return:void
     * @throws:
     */
    protected void readDataFromSocket(SelectionKey key) throws Exception {
        SocketChannel socketChannel = (SocketChannel) key.channel();
        //创建缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(size);
        byteBuffer.order(ByteOrder.BIG_ENDIAN);//设置字节在内存中的存储顺序方式
        int count;//获取读取到Buffer中的字节数
        byteBuffer.clear();//首先进行一次清理初始化,防止Buffer中存在旧数据
        //循环读取数据
        while ((count = socketChannel.read(byteBuffer)) > 0) {
            byteBuffer.flip(); // 切换buffer为读模式
            // 将接收到的数据全部发送给客户端
            while (byteBuffer.hasRemaining()) {
                socketChannel.write(byteBuffer);
            }
            byteBuffer.clear(); // 清空buffer
        }
        
        //如果读取到的count为-1就表示数据读取结束
        if (count < 0) {
            socketChannel.close();//不一定要关闭连接
        }
    }
    
    /**
     * 
     * @Title:replyClient
     * @Description:给客户端发送数据
     * @author:
     * @date:2019年10月14日
     * @param:@param channel
     * @param:@throws IOException
     * @return:void
     * @throws:
     */
    private void replyClient(SocketChannel channel) throws IOException {
        //创建缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(size);
        byteBuffer.order(ByteOrder.BIG_ENDIAN);//设置字节在内存中的存储顺序方式
        byteBuffer.clear();
        byteBuffer.put("已与服务端建立连接".getBytes("utf-8"));
        byteBuffer.flip();
        channel.write(byteBuffer);
    }
    
    /**
     * 
     * @Title:registerChannel
     * @Description:注册SocketChannel
     * @author: 
     * @date:2019年10月14日
     * @param:@param selector
     * @param:@param channel
     * @param:@param ops
     * @param:@throws Exception
     * @return:void
     * @throws:
     */
    private void registerChannel(Selector selector, SocketChannel channel, int ops) throws Exception {
        if (channel == null) {
            return;
        }
        channel.configureBlocking(false);
        channel.register(selector, ops);
    }


    public static void main(String[] args) {
        //启动服务器
        try {
            new NIOServer().listener();
        } catch (Exception e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

对于客户端的实现,可以采用NIO实现网络通信也可以通过传统IO实现IO通信,因为客户端不会像服务端一样需要处理非常多的网络IO请求。

/**
 * 
 * @ClassName:NIOClient
 * @Description:通过NIO技术实现的客户端
 * @author: 
 * @date:2019年10月14日
 */
public class NIOClient {

    private int size = 1024;
    private SocketChannel socketChannel;
    /**
     * 
     * @Title:connectServer
     * @Description:连接至服务端
     * @author: 
     * @date:2019年10月14日
     * @param:@throws IOException
     * @return:void
     * @throws:
     */
    public void connectServer() throws IOException {
        socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 8998));
        
        receive();
    }
    /**
     * 
     * @Title:receive
     * @Description:接收数据
     * @author: 
     * @date:2019年10月14日
     * @param:@throws IOException
     * @return:void
     * @throws:
     */
    private void receive() throws IOException {
        //创建缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(size);
        byteBuffer.order(ByteOrder.BIG_ENDIAN);//设置字节在内存中的存储顺序方式
        LinkedList<Byte> data = new LinkedList<Byte>();//用来接收完整数据
        
        while (true) {
            int count;
            byteBuffer.clear();
            //一般来说单次socketChannel.read()方法读取的数据都不是完整的,
            //所以需要再利用一个缓冲区来获取到完整数据后,在进行解析数据
            while ((count = socketChannel.read(byteBuffer)) > 0) {
                byteBuffer.flip();
                //注意,由于socketChannel.read()是非阻塞方法,每次读取的数据长度不定
                while (byteBuffer.hasRemaining()) {
                    //将所有数据全部保存在缓存中,
                    data.add(byteBuffer.get());
                }
                byteBuffer.clear();
                
            }
            
            //数据转换,解析数据
            byte[] bytes = new byte[data.size()];
            for(int i = 0; i < data.size(); i++) {
                bytes[i] = data.get(i);
            }
            String message = new String(bytes, "utf-8");
            System.out.println(message);
        }
    }
    
    /**
     * 
     * @Title:send
     * @Description:发送数据
     * @author: 
     * @date:2019年10月14日
     * @param:@param data
     * @param:@throws IOException
     * @return:void
     * @throws:
     */
    private void send(byte[] data) throws IOException {
        //创建缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(size);
        byteBuffer.order(ByteOrder.BIG_ENDIAN);//设置字节在内存中的存储顺序方式
        byteBuffer.clear();
        byteBuffer.put(data);
        byteBuffer.flip();
        socketChannel.write(byteBuffer);
    }

    public static void main(String[] args) throws IOException {
        //启动客户端,连接服务器
        new NIOClient().connectServer();
    }
}

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值