Zookeeper 源码解读系列, 单机模式(二)

前情提要

上一篇【Zookeeper 源码解读系列, 单机模式(一)】我们介绍了在单机模式下ZK是如何连接,如何初始化的,然后我们遗留一个问题:什么时候取出outgoingQueue里的数据呢?以及我们为了展示连接的过程而在SendThread.run()中略过了一大部分的代码。所以这一篇就是重点讲解连接之后做了什么,既然如此我们还是回到SendThread.run()里面接着探究连接之后做什么。本篇也会被收录到【Zookeeper 源码解读系列目录】中。

探究SendThread

上篇我们讲到客户端是要等待服务端返回的结果的,那么我们现在要做的就是解决问题:什么时候消费outgoingQueue这个保存packet的队列呢?带着这个问题,我们再来看SendThread.run(),这个类早就初始化完毕,而且已经start(),所以我们就可以接着执行后面的语句了。这里可能会有同学问,不是说被阻塞掉了等待服务端返回的吗,怎么就又运行了。其实是这样的,多线程编程,我们要看它阻塞的是什么,这里很明显阻塞住的是main.run(),而不是SendTread.run(),这个务必要区分出来,后面还会有不少同步执行的线程,这个如果理不清楚的话,看代码是比较难懂的,Sendthread.run()在阻塞之前就已经运行了,是没有关系的,那我们看代码Sendthread.run(),我们直接到if (state.isConnected())这里面,如果连接成功了,会怎么样。

public void run() {
    /**初始化,略**/
    while (state.isAlive()) {
        try {
            /**连接,已经讲过略过**/
            if (state.isConnected()) {  //如果连接成功了
                if (zooKeeperSaslClient != null) {
                    /**Sasl验证模式,不是主逻辑,略过**/
                }
                to = readTimeout - clientCnxnSocket.getIdleRecv();//to读取超时的变量
            } else {
                to = connectTimeout - clientCnxnSocket.getIdleRecv();//to连接超时的变量
            }
            if (to <= 0) {//如果超时了
                String warnInfo;
                warnInfo = "Client session timed out, have not heard from server in "
                   + clientCnxnSocket.getIdleRecv() + "ms" + " for sessionid 0x"
                            + Long.toHexString(sessionId);
                LOG.warn(warnInfo);
                //会报SessionTimeoutException的异常,被外面的try捕获
                throw new SessionTimeoutException(warnInfo);
            }
            if (state.isConnected()) {      //没有超时,连接成功
                /**暂时略,后面讲这部分**/
            }
            if (state == States.CONNECTEDREADONLY) {
                /**只读模式,略过**/
            }
            //这个方法就是发送数据用的
            clientCnxnSocket.doTransport(to, pendingQueue, outgoingQueue, ClientCnxn.this);
        } catch (Throwable e) {
        	/**catch异常,略,后面单独贴出来**/
        }
    }
    cleanup();
    clientCnxnSocket.close();
    if (state.isAlive()) {
        eventThread.queueEvent(new WatchedEvent(Event.EventType.None,
                Event.KeeperState.Disconnected, null));
    }
    ZooTrace.logTraceMessage(LOG, ZooTrace.getTextTraceLevel(),
            "SendThread exited loop for session: 0x"
                   + Long.toHexString(getSessionId()));
}

进入if (state.isConnected())语句以后,我们先忽略掉Sasl认证问题因为这部分不是主逻辑,看到了一个变量to,这个是干嘛的呢?这个变量就是判断是不是连接超时了。getIdleRecv()这个方法是计算距离上次读取数据的时间,那么这句话就很好理解了readTimeout - clientCnxnSocket.getIdleRecv()拿我们 “初始化的超时时间” - “已经读取数据的时间” 算出来一个是否超时的标记,如果计算出来的to<0,那就是读取数据超时了,如果to>0,说明数据正常读取了。紧接着的else里面的to也是一样,如果没有连接成功,那么to = connectTimeout - clientCnxnSocket.getIdleRecv()也会算一下是不是连接超时了,如果计算出来的to<0,就说明连接超时了,如果to>0,说明连接没有问题。如果小于0就会抛出SessionTimeoutException异常,然后又下面的catch捕获处理。

重试机制

我们都知道Zookeeper有重试机制,那它是怎么做的呢,我们走到catch{**}块里:

try {
    /**略**/
} catch (Throwable e) {
    if (closing) {
        /**如果Closing,break跳出**/
    } else {
        if (e instanceof SessionExpiredException) {
            LOG.info(e.getMessage() + ", closing socket connection");
        } else if (e instanceof SessionTimeoutException) {
        	//超时异常打一个log,继续走最外面的while语句,这里就是重试机制
            LOG.info(e.getMessage() + RETRY_CONN_MSG);
        } else if (e instanceof EndOfStreamException) {
            LOG.info(e.getMessage() + RETRY_CONN_MSG);
        } else if (e instanceof RWServerFoundException) {
            LOG.info(e.getMessage());
        } else if (e instanceof SocketException) {
            LOG.info("Socket error occurred: {}: {}", serverAddress, e.getMessage());
        } else {
            LOG.warn("Session 0x{} for server {}, unexpected error{}", ***);
        }
        cleanup();
        if (state.isAlive()) {
            eventThread.queueEvent(new WatchedEvent(
                    Event.EventType.None,
                    Event.KeeperState.Disconnected,
                    null));
        }
        clientCnxnSocket.updateNow();
        clientCnxnSocket.updateLastSendAndHeard();
    }
}

可以看到,所有的Exception其实都没有中断程序,只是打了一个log,然后就会跳回到最外层的while (state.isAlive())继续循环,尝试进行第二次连接,然后在连接的部分,由于之前已经连结果一次,所以isFirstConnect已经被修改为false,就会进入下面这个sleep里,睡眠一下就可以避免不停的去重试,没错这一系列操作就是Zookeeper的重试机制。

/****/
if(!isFirstConnect){
    try {//随机一个睡眠时间,避免不停的去重试
        Thread.sleep(r.nextInt(1000));
    } catch (InterruptedException e) {
        LOG.warn("Unexpected exception", e);
    }
}
/****/

总结下重试机制:在SendThread里面通过不断地while循环,使用try-catch会把异常捕捉到,记录log之后,跳出进入下次循环进行再次连接。

outgoingQueue

过了这一小点,我们回到主流程里,连接失败的流程已经说过了,那么后面就开始介绍连接成功了会怎么样:

public void run() {
    /**初始化,略**/
    while (state.isAlive()) {
        try {
            /**连接,已经讲过略过**/
            if (state.isConnected()) {  //如果连接成功了
               /**略过**/
            }
            if (to <= 0) {
                /**如果超时了,会报SessionTimeoutException的异常,被外面的try捕获**/
            }
            if (state.isConnected()) {      //没有超时,连接成功
                int timeToNextPing = readTimeout / 2 - clientCnxnSocket.getIdleSend() - 
                		((clientCnxnSocket.getIdleSend() > 1000) ? 1000 : 0);
                if (timeToNextPing <= 0 || clientCnxnSocket.getIdleSend() > MAX_SEND_PING_INTERVAL) {
                    sendPing();//发送ping给服务端
                    clientCnxnSocket.updateLastSend();
                } else {
                    if (timeToNextPing < to) {
                        to = timeToNextPing;
                    }
                }
            }
            /**只读模式,略过**/
            //这个方法就是发送数据用的
            clientCnxnSocket.doTransport(to, pendingQueue, outgoingQueue, ClientCnxn.this);
        } catch (Throwable e) {
        	/**catch异常,略**/
        }
    }
    cleanup();
    clientCnxnSocket.close();
    if (state.isAlive()) {
        eventThread.queueEvent(new WatchedEvent(Event.EventType.None,
                Event.KeeperState.Disconnected, null));
    }
    /**LOG模式,略过**/
}

首先我们看到的就是sendPing(),这里就是在一直发送ping给服务器,我们的客户端会一直发送ping出去,发送ping的过程也是在整个while里面做的,因为只要连接成功整个while就相当于while(true),所以每次都会走到这个逻辑里面,就会不断地发送ping出去,有兴趣的同学可以点进去看下,其实这里发送ping的代码和输入的命令行(比如create命令)一模一样,也是new了一个RequestHeader h = new RequestHeader(-2, OpCode.ping);只不过这次的参数变了而已,然后调用queuePacket(h, null, null, null, null, null, null, null, null);加到了outgoingQueue里。

既然我们的ping也发送出去了,现在要干什么?我们先逻辑上分析一下,到了这里我们的连接已经建立成功了以及我们想发送的数据都在outgoingQueue里面,那么下一步很容易就想到:现在要从其中取数据,通过socket发送发出。所以说终于到了我们的重点发送数据clientCnxnSocket.doTransport(to, pendingQueue, outgoingQueue, ClientCnxn.this);,从参数上看,这个方法也用到了我们一直再说的outgoingQueue,但是这里又出现了一个pendingQueue,这个queue也非常重要,我们慢慢分析。点进入以后发现是一个接口,我们去它是实现方法里看ClientCnxnSocketNIO.doTransport(***),好熟悉是不是,我们又回到了这个NIO的类里面来了:

void doTransport(int waitTimeOut, List<Packet> pendingQueue, LinkedList<Packet> outgoingQueue,
                 ClientCnxn cnxn)
        throws ***Exception {
    selector.select(waitTimeOut);
    Set<SelectionKey> selected;
    synchronized (this) {
        selected = selector.selectedKeys();
    }
    updateNow();
    //NIO selector的逻辑
    for (SelectionKey k : selected) {
        SocketChannel sc = ((SocketChannel) k.channel());
        if ((k.readyOps() & SelectionKey.OP_CONNECT) != 0) {
            if (sc.finishConnect()) {//查看哪个channel连接成功了,一旦发现一个连接成功的则进入if
                updateLastSendAndHeard();//更新连接时间
                sendThread.primeConnection();//发送连接,进入
            }
        } else if ((k.readyOps() & (SelectionKey.OP_READ | SelectionKey.OP_WRITE)) != 0) {
        	//如果连接成功,可以OP_READ读,可以写OP_WRITE
            doIO(pendingQueue, outgoingQueue, cnxn);
        }
    }
    if (sendThread.getZkState().isConnected()) {
        synchronized(outgoingQueue) {
            if (findSendablePacket(outgoingQueue,
                    cnxn.sendThread.clientTunneledAuthenticationInProgress()) != null) {
                enableWrite();
            }
        }
    }
    selected.clear();
}

进入这个代码以后首先就看到一个for循环,这循环就是在实现NIO这个思想的Selector。因为Zookeeper使用了NIO的思想,所以其实这里的连接其实不是像我们想的一样立即就可以连接上的,他只是给连接绑定了一个事件,可以理解为异步的,我如果需要连接,只需要读取一个事件就可以了,这个事件就是在我们上一篇中讲的ClientCnxnSocketNIO.registerAndConnect()中注册的:

void registerAndConnect(SocketChannel sock, InetSocketAddress addr) 
    throws IOException {
        sockKey = sock.register(selector, SelectionKey.OP_CONNECT);//注册事件
        boolean immediateConnect = sock.connect(addr);//有没有立刻成功
        if (immediateConnect) {//成功了此处是true,执行连接
            sendThread.primeConnection();
        }
    }

所以如果说,我当时没有立刻处理这个事件,那么我就相当于没有连接成功,那immediateConnect就是false ,这也导致了 sendThread.primeConnection();就不会被执行,如果没有被执行,我们后面的逻辑就没办法走通了,所以回到doTransport(***)这个方法里面,这个条件就会走到if ((k.readyOps() & SelectionKey.OP_CONNECT) != 0)这个语句块里,去检查哪个channel连接成功了,一旦发现一个连接成功的则进入if (sc.finishConnect())再次连接一下。

doIO方法 与 outgoingQueue

如果说我们立刻连接成功了,并且发现既可以OP_READ读,也可以写OP_WRITE,那么就会跳过直接执行doIO(pendingQueue, outgoingQueue, cnxn),这里先简单说明下这个方法,顾名思义就是做IO操作的,参数outgoingQueue存要执行的命令,pendingQueue存等待服务器响应的命令,而且要注意,每一个连接有一对queue,不是一个,是一对,上代码:

void doIO(List<Packet> pendingQueue, LinkedList<Packet> outgoingQueue, ClientCnxn cnxn)
  throws ***Exception {
    SocketChannel sock = (SocketChannel) sockKey.channel();
    if (sock == null) {
        throw new IOException("Socket is null!");
    }
    if (sockKey.isReadable()) {//如果读就绪
        /**暂时略过,稍后讲解**/
    }
    if (sockKey.isWritable()) {//写就绪
        synchronized(outgoingQueue) {
            //取出outgoingQueue里的数据存入packet中
            Packet p = findSendablePacket(outgoingQueue,
                    cnxn.sendThread.clientTunneledAuthenticationInProgress());
            if (p != null) {
                updateLastSend();
                if (p.bb == null) {//BB:ByteBuffer
                    /**判空,略过**/
                    p.createBB();//创建ByteBuffer,NIO中的buffer概念
                }
                sock.write(p.bb);//发送数据给服务端
                if (!p.bb.hasRemaining()) {//如果BB中取不到了,也就是没有剩余的数据了,
                    sentCount++;
                    outgoingQueue.removeFirstOccurrence(p);//取出的命令移除
                    if (p.requestHeader != null
                            && p.requestHeader.getType() != OpCode.ping
                            && p.requestHeader.getType() != OpCode.auth) {
                        synchronized (pendingQueue) {
                            pendingQueue.add(p);//需要等结果存到pendingQueue
                        }
                    }
                }
            }
            if (outgoingQueue.isEmpty()) {
            	/**空验证,略**/
            }
        }
    }
}

我们到这个方法以后,发现里面有两个判断if (sockKey.isReadable())if (sockKey.isWritable())。前一个是读就绪,sockKey.isReadable()==true那么就能从channel里面读取数据了,就相当于服务端给我数据了我可以读了。后面一个是写就绪,sockKey.isWritable()==true那么就能向channel里面写数据了,就相当于给服务端发送数据了。那么我们的outgoingQueue肯定是就是发送数据了,所以我们就先来看写就绪,转到if (sockKey.isWritable())这里来,立刻就可以发现用到了outgoingQueue,而且还是被synchronized锁住了。按照我们上面的逻辑这里就应该要取出数据发送了,往下看果不然findSendablePacket(***)这个方法传入了outgoingQueue而且把返回参数封装成了packet,那么我们进入看下:

private Packet findSendablePacket(LinkedList<Packet> outgoingQueue,
                                  boolean clientTunneledAuthenticationInProgress) {
    synchronized (outgoingQueue) {
        if (outgoingQueue.isEmpty()) {//空就返回空
            return null;
        }
        if (outgoingQueue.getFirst().bb != null 
            || !clientTunneledAuthenticationInProgress) {
            //不是空取出第一个返回
            return outgoingQueue.getFirst();
        }
        /**SASL相关的逻辑,略过**/
    }
}

果然这里就是从outgoingQueue里取出了数据,如果是isEmptyreturn null,如果不是就返回第一个数据出去,这就符合我们刚刚分析的逻辑了。出去以后包装成packet,继续走doIO(***)的 流程,如果拿出来的数据不是null,就p.createBB()创建ByteBuffer(这个就NIO思想里面Buffer的概念),然后通过sock.write(p.bb)发送socket数据出去,这里就相当于把取出的packet写到了socket的buffer里面去,NIO的思想就是写数据写到buffer里,读数据也同样从buffer里面读,这里以后遇到类似的写法就不再详细解释了。往下走if (!p.bb.hasRemaining())是不是packet里面的数据都已经发送出去了,如果packet里面没有剩余的数据都已经发送完了,进入这个if里,把取出的数据移除outgoingQueue.removeFirstOccurrence(p);,这里必须要有移除,如果不移除,那我们在上面一直getFirst()取的全都是重复的数据了。再往下走又到了一个if判断如果requestHeader不是null,并且不是ping类型(getType() != OpCode.ping),同时也不是验证类型(getType() != OpCode.auth),那么就把这个packet加入到pendingQueue里面。到这里我们已经解决了outgoingQueue是干嘛的,怎么又出来了一个pendingQueue,这个是干嘛的呢?

pendingQueue

其实我们分析到现在,outgoingQueue是一个一个发送给服务端的指令queue,那么pending从名字上来看肯定就是等待什么东西的,逻辑上看pendingQueue可能是等待服务端返回指令结果的queue,而且这两个里面存的都是packet。其实pendingQueue就是存放已经发送出去了但是还没有结果的packect,所以进一步推断pendingQueue就是在读结果的时候用的。这里就要到我们刚才没有讲解的读就绪的那里了:

void doIO(List<Packet> pendingQueue, LinkedList<Packet> outgoingQueue, ClientCnxn cnxn)
  throws InterruptedException, IOException {
    /**拿到socket,已经贴过,略**/
    if (sockKey.isReadable()) {//如果读就绪
        int rc = sock.read(incomingBuffer);//从buffer里面读出来
        if (rc < 0) {
            /**读出来的数据不合法,抛异常,略**/
        }
        if (!incomingBuffer.hasRemaining()) {//如果数据已经全部取到了
            incomingBuffer.flip();
            if (incomingBuffer == lenBuffer) {
                recvCount++;
                readLength();
            } else if (!initialized) {//连接有没有初始化
                readConnectResult();//读取连接请求的结果
                enableRead();
                if (findSendablePacket(outgoingQueue,
                        cnxn.sendThread.clientTunneledAuthenticationInProgress()) != null) {
                    enableWrite();
                }
                lenBuffer.clear();
                incomingBuffer = lenBuffer;
                updateLastHeard();
                initialized = true;
            } else {//如果初始化已经完成
                //读取响应
                sendThread.readResponse(incomingBuffer);//解析服务端发来的数据流
                lenBuffer.clear();
                incomingBuffer = lenBuffer;
                updateLastHeard();
            }
        }
    }
    if (sockKey.isWritable()) {
    	/**写就绪,已经讲过,略**/
    }
}

我们开始分析,首先incomingBuffer就是从NIO-channel中读取出来的数据,到下面如果说数据都取到了if (!incomingBuffer.hasRemaining()) 进入这个判断往下走,发现又要判断初始化else if (!initialized)这里的初始化是什么呢?这个其实是判断连接有没有初始化,我们知道NIO是个异步的,连接请求也只是一个事件,这就里是在看我又没有读到这个连接的事件,有没有初始化我当前的客户端。这个连接的请求是在哪发给客户端的呢?是在primeConnection()这个方法里,在这个方法最后有一句outgoingQueue.addFirst(new Packet(null, null, conReq, null, null, readOnly));有没有觉得很熟悉,没错,这个方法把连接请求作为一个事件,放在outgoingQueue里发送连接请求给服务端。然后SendThread这里如果发现客户端还没有initialized,那么只要去把连接请求的结果读取出来readConnectResult()就可以了,有兴趣的同学可以点进入看下,这里就不做赘述了。

那么我们接着走,如果初始化已经完成,走到下面的else里,读取响应并且解析服务端发来的数据流sendThread.readResponse(incomingBuffer);,我们发现还是sendThread线程,说明这个线程是接收发送两用,并不只是发送,那我们进入readResponse(incomingBuffer)看下这里面又是怎么写的:

void readResponse(ByteBuffer incomingBuffer) throws IOException {
    ByteBufferInputStream bbis = new ByteBufferInputStream(
            incomingBuffer);//序列化服务端的返回结果
    BinaryInputArchive bbia = BinaryInputArchive.getArchive(bbis);
    ReplyHeader replyHdr = new ReplyHeader();
    //反序列化构建ReplyHeader,由xid决定执行哪个结果,此处拿到的已经是服务端返回的数据了
    replyHdr.deserialize(bbia, "header");
    if (replyHdr.getXid() == -2) {//xid=-2,ping命令
        /** -2 is the xid for pings**/
    }
    if (replyHdr.getXid() == -4) {//xid=-4,auth命令,验证命令               
         /** -2 is the xid for AuthPacket**/
    }
    if (replyHdr.getXid() == -1) {//xid=-1,WatcherEvent的逻辑,执行事件
        /** -1 means notification**/
    }
    /** SASL authentication 略过 **/
    //如果上述xid没有配置上,那么说明这个命令是执行命令,比如create,delete等等
    Packet packet;
    synchronized (pendingQueue) {
        if (pendingQueue.size() == 0) {//本地没有数据,说明服务端发错数据了报错
			/**抛出IOException 略过**/
        }
        packet = pendingQueue.remove();//取出pendingQueue中的packet
    }
    try {
        //如果这个本地的xid不等于返回的xid,报错
        if (packet.requestHeader.getXid() != replyHdr.getXid()) {
        	/**抛出IOException 略过**/
        }
        //如果一样,就要从响应中读取数据,给本地数据赋值
        packet.replyHeader.setXid(replyHdr.getXid());
        packet.replyHeader.setErr(replyHdr.getErr());
        packet.replyHeader.setZxid(replyHdr.getZxid());
        if (replyHdr.getZxid() > 0) {
            lastZxid = replyHdr.getZxid();
        }
        if (packet.response != null && replyHdr.getErr() == 0) {
            //取出服务端的response给本地packet赋值,此时packet就包含了响应的数据
            packet.response.deserialize(bbia, "response");
        }
        /**打log 略过**/
    } finally {
        finishPacket(packet);
    }
}

进入以后首先从buffer里面读取出服务端返回的消息,然后解析成ReplyHeader,这个里面存放的就是相应返回的数据,这里我们其实就碰到了一个很重要的属性xid,这里我们先不详细的说,接着就有判断这个拿出来的返回是什么类型的命令,xid=-2,ping命令;xid=-4,auth命令,验证命令;xid=-1,WatcherEvent的逻辑,执行订阅的事件等等。如果说拿出来的xid没有匹配上,那么就可以认为这个是从命令行那里输入进来的命令了。往下走,首先取出pendingQueue里面的数据packet = pendingQueue.remove();包装成packet,然后如果说,当前从pendingQueue中取出的packet和服务端响应返回replyHdr里面的packet的xid不一样if (packet.requestHeader.getXid() != replyHdr.getXid()),那么就抛出异常。如果是一样的话,就要从响应中读取数据了,接下来连续的set()方法就是再给本地数据赋值,首先从replyHdr里面get()到数据,然后原封不动的set()packet里面。这里要提醒一点,这个packet是我们很早之前就已经生成好了,在哪里呢,就是在Zookeeper这个原生客户端被初始化的时候就已经生成好了,然后转移到了pendingQueue里面,现在只是从pendingQueue里面取出来给赋值而已。然后下面可以看到服务端返回的数据bbia也被反序列化放到了本地的packetresponse里面packet.response.deserialize(bbia, "response");,此时这个本地的packet也就包含了从客户端发来的响应数据了。全部走完以后,最总会走到finally语句块中,我们进入finishPacket(packet)看看里面的代码:

private void finishPacket(Packet p) {
    //此时这个p已经是服务端传回来的packet
    if (p.watchRegistration != null) {
       /**事件注册,暂时略**/
    }
    if (p.cb == null) {
        synchronized (p) {
            p.finished = true;
            p.notifyAll();  //唤醒
        }
    } else {/**eventThread相关,暂时略,下一篇会详细讲解**/
    	 p.finished = true;
         eventThread.queuePacket(p);
    }
}

看看我们发现了什么p.notifyAll();唤醒,这个时候各位可还记最开始有一个阻塞?如果能想起来的同学,肯定会恍然大悟,其实到了这里整个客户端的逻辑就已经通了。我们之前说过Zookeeper在ClientCnxn.submitRequest(***)提交请求以后是会阻塞的,阻塞的正是packet:packet.wait();,那么我们在这里拿到结果回来以后,在这里就被唤醒了。

总结

到这里我们客户端发送接收代码的讲解其实已经结束了,接下来就开始要看服务端怎么处理这些来自客户端发送的数据了,总结一下zk客户端启动,键入命令后做了什么:

启动ZK
  1. Zookeeper:初始化socket
  2. Zookeeper:命令->Request->Packet->outgoingQueue
  3. ClientCnxnSocketNIO.SendThread启动:
    SendThread线程run():
    while(true){
    1. 如果socket没有连接,就回去连接,和重试机制,ping等等
    2. 如果socket链接成功,服务端会发送一个ConnectRequest,接受服务器返回的ConnectResponse(Event, None)返回一个event=None的事件 doTransport()
    3. 从outgoingQueue取packet,通过Socket发送出去,阻塞,同时如果说有需要等待结果的packet放到pendingQueue里 doIO()-写就绪
    4. 从服务端收到数据,唤醒doIO()-读就绪
      }
SendThread流程图

而SendThread就做了这么一系列的事情,下面的图是一个SendThread完整的流程图,大部分都已经在本篇讲解,有一些以后会在下一篇【Zookeeper 源码解读系列, 单机模式(三)】涉及到:
Sendthread流程图

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值