Zookeeper 源码解读系列,集群模式(六)

前言

我们在之前的博客里面已经讲完了Zookeeper的集群模式的启动和启动后接收数据的流程。那么我们本篇将开启Zookeeper的最后一个大的内容:Zookeeper的选举机制。自然本篇也会被收录到【Zookeeper 源码解读系列目录】中。

领导者选举算法理论

选举领导者的标准

在现实中我们进行选举一般会有两个因素影响选票的票数:关系和实力。关系好的自然会争取到更多的票数。如果候选人关系这一层面差不多,那就要拼实力了,谁的实力强,谁一般会当选。但是到了服务器集群选Leader的时候,就没有关系可以依靠了,都是机器谁还跟谁关系好咋地。所以就只能按照谁的实力强谁上任。那么就有一个问题了,怎么定义一个服务器谁的节点能力比较强呢?在Zookeeper里面有一个可以量化的东西—数据。Zookeeper中从众多机器中选举出一个领导者的唯一标准就是:Leader一定是数据内容最新的那一台机器,数据越新能力越强。数据新旧标准是什么呢,Zookeeper里面有下面几个规则:

  1. Epoch最大的就是Leader,多数情况Epoch是一样的,所以Epoch在大多数情况下不能作为依据。
  2. 其次zxid也就是事务id越大就意味着数据越新,即zxid大的就是第一标准。
  3. 如果zxid是一样的,那么myid(sid)最大的是就是最可能被选中的。
集群启动的时候,领导者选举的过程

我们早就已经说过,Zookeeper认为集群里面有多少机器,是根据我们在配置文件里配置了多少台决定的。假设有两台机器A和B需要进行投票,那么首先他们之间是黑盒子,也就是不知道对方的数据是不是更新一些,否则也不需要投票了。但是都不知道的情况下要怎么办呢?Zookeeper这里的逻辑,就会让两台机器都先给自己投一票,并且把这个票放进自己的投票箱里。这个票包含两个信息:我投的那台服务器的sid这个sid对应的那台服务器的zxid是多大,我们就记作<sid,zxid>。然后把选票发到对方的投票箱里,那么就可以比较了。如果发现自己收到zxid比自己投票的大,就会更改自己的投票为zxid大的那一张选票,然后再发出去。如果收到比自己投的小的,直接丢弃就好了。如果说zxid是一样的怎么办?我们看投票<sid,zxid>,因为投票里包含sid,所以各个机器收到投票以后就知道是投票要选的是哪个机器,那么根据原则直接判断sid大的当选就可以了。这里过半机制又是再哪里呢?举个例子,A机器投给自己一票,收到B机器一票,当然B机器也有这个过程。A判断B的选票大于自己,那么就更改自己的选票并发送出去,而B机器收到的A的票是小的,所以抛弃。此时,A中就保留了两张选票,内容是一样的都是<B,zxid-B>。而B机器呢,此时有一张自己的选票,抛弃了A首次发来的选票,再加上A更改后发出的选票,也是两张<B,zxid-B>。如果有C机器,就重复这个过程,直到大家的投票箱子里的票数超过了配置文件中的一半为止,这样就选出了Leader。

那么集群启动的时候,各个机器会经历这样一个过程:

  1. 每个机器先投自己一票
  2. 把票发给对方,各个机器接到不同的机器的票数以后,存到投票箱里,在投票箱里对比数据,数据优势的一方会更改掉数据弱势的一方的票,然后再重新发出去优势票。
  3. 优势的机器收到重发发出的票以后,会统计自己收到的票,超过半数成为leader。
集群中有机器挂掉以后,Zookeeper会进行的操作
  1. 如果挂掉的是leader,剩下机器会重新选举一个新的leader。
  2. 如果挂掉follower,剩下的机器未必会重新选举。因为只要挂掉的follower的数量不超过半数,集群就仍然认为是可用的,因此不需要选举。这里说下,如果挂的follower超过半数了,集群就挂了,也无所谓选举不选举了。

集群启动时,领导者选举源码的流程

介绍完大体的理论以后,我们的探究过程其实也就是按照这个顺序来的。这里依然一步一步的走,所以我们就从启动开始入手。

----入口----> QuorumPeerMain.main();
----转到----> QuorumPeerMain.initializeAndRun(args);
----转到----> QuorumPeerMain.runFromConfig(config);
----转到----> quorumPeer.start();

首先我们还是先启动,进入QuorumPeerMain.main(),进入加载类main.initializeAndRun(args),进入集群模式runFromConfig(config),略过初始化参数,找到并进入quorumPeer.start()

public synchronized void start() {
    loadDataBase();
    cnxnFactory.start(); 
    startLeaderElection(); //领导者选举方法
    super.start(); 
}

然后就看到了我们今天要讲解的领导者选举方法startLeaderElection(),进入:

synchronized public void startLeaderElection() {
	 try {
	    //首先生成投票
	  	currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch());
  	} catch(IOException e) {
	  	/**Exceptions**/
  	}
    for (QuorumServer p : getView().values()) {//寻找本机地址
        if (p.id == myid) {
            myQuorumAddr = p.addr;
            break;
        }
    }
    if (myQuorumAddr == null) {//本机没有找到,报异常
        throw new RuntimeException("My id " + myid + " not in the peer list");
    }
    if (electionType == 0) {//判断选举类型
        try {
            udpSocket = new DatagramSocket(myQuorumAddr.getPort());
            responder = new ResponderThread();
            responder.start();
        } catch (SocketException e) {
            throw new RuntimeException(e);
        }
    }
    this.electionAlg = createElectionAlgorithm(electionType); //创建选举算法
}

Vote类

首先我们就看到了一句话currentVote = new Vote(myid, getLastLoggedZxid(), getCurrentEpoch())这里就是先给自己投票。经过上面的原理分析,我们知道每台服务器都有一个自己的投票箱,因为不可能再创建一个投票服务器,而且投票箱里存放的应该是当前自己的投票和别人的选票。那么我们总结三个重要的概念,投票箱、投票、选票。回来这句话这个myid是自然就是自己的myid(sid),我们进入Vote看下关键的类里面的属性:

public Vote(long id,  long zxid,  long peerEpoch) {
    this.version = 0x0;
    this.id = id; //myid
    this.zxid = zxid; //事务id
    this.electionEpoch = -1; //领导者选举的轮数,本机记录
    this.peerEpoch = peerEpoch; //选举周期,届号
    this.state = ServerState.LOOKING; //选举状态
}

我们解释下几个选举用到的参数

参数名作用
this.id服务器的sid,也就是myid
this.zxid事务id
this.electionEpoch记录领导者选举的轮数,服务器内自己记录的。这个数值越大,投票的权重越大,对传入的低数值epoch有否决权
this.peerEpoch当前选举周期,届号,传递进来的参数。
this.state选举状态,这个我们之前简单说过,以后还会用到。

我们看过Vote这个类以后,接着往下,for循环是寻找本机地址的地方,端口就是我们配置的第一个port 2887这里的端口时进行服务器同步的。再往下本机验证,如果本机地址没有找到报异常。接着就是判断选举类型,其实到了Zookeeper目前的版本,这个electionType只有唯一值3了。那么这个if逻辑肯定进不去。最后找到创建选举算法的方法createElectionAlgorithm(electionType)进入:

protected Election createElectionAlgorithm(int electionAlgorithm){
    Election le=null;
    switch (electionAlgorithm) {
    case 0 - 2: /**过期的选举算法,略**/break;
    case 3:
        //初始化负责各个服务器之间的底层leader选举过程中的网络通信
        qcm = createCnxnManager();
        QuorumCnxManager.Listener listener = qcm.listener;
        if(listener != null){
            listener.start();
            le = new FastLeaderElection(this, qcm); //硕果仅存的算法类
        } else {
            LOG.error("Null listener when initializing cnx manager");
        }
        break;
    default:
        assert false;
    }
    return le;
}

发现Zookeeper其实之前有好多中选举的机制,但是这些在当前版本已经是过期的了。那么现在所留下来的也就只有唯一的一种FastLeaderElection(this, qcm),那么我们就进入这个硕果仅存的类里去:

public FastLeaderElection(QuorumPeer self, QuorumCnxManager manager){
    this.stop = false;
    this.manager = manager;
    starter(self, manager);
}

private void starter(QuorumPeer self, QuorumCnxManager manager) {
    this.self = self;
    proposedLeader = -1;
    proposedZxid = -1;
    sendqueue = new LinkedBlockingQueue<ToSend>();
    recvqueue = new LinkedBlockingQueue<Notification>();
    this.messenger = new Messenger(manager);
}

我们直接走到FastLeaderElection类里面发现,这里就是new了一个类,做的都是各种初始化,并不是真正做算法的地方。那么这时候我们就意识到了QuorumPeer类中startLeaderElection()看起来像是选举方法。但是其实这个方法只是进行领导者选举,确定服务器角色,在针对不同的服务器角色初始化,并不是领导者选举逻辑所在的地方,被虚晃了一枪。

虽然被虚晃一枪,但是这里跟领导者选举肯定有撇不清楚地关系,那我们就仔细看下electionType=3这个分支里面到底卖的什么药。看之前我们要知道一点选举是通过socket连接进行的。既然这里没有线索,我们唯一没有看的就是连接的部分,所以我们去看下看case 3: qcm = createCnxnManager();里面做了什么:

public QuorumCnxManager createCnxnManager() {
    return new QuorumCnxManager(this.getId(),
                                this.getView(),
                                this.authServer,
                                this.authLearner,
                                this.tickTime * this.syncLimit,
                                this.getQuorumListenOnAllIPs(),
                                this.quorumCnxnThreadsSize,
                                this.isQuorumSaslAuthEnabled());
}

QuorumCnxManager做底层传输

进入后发现这里也只是实例化了一个QuorumCnxManager类,这个类就是负责底层传输的,所以我们需要解析一下这个类里面的重要属性,那么就接着往里走:

    public QuorumCnxManager(final long mySid,
                            Map<Long,QuorumPeer.QuorumServer> view,
                            QuorumAuthServer authServer,
                            QuorumAuthLearner authLearner,
                            int socketTimeout,
                            boolean listenOnAllIPs,
                            int quorumCnxnThreadsSize,
                            boolean quorumSaslAuthEnabled) {
        this(mySid, view, authServer, authLearner, socketTimeout, listenOnAllIPs,
                quorumCnxnThreadsSize, quorumSaslAuthEnabled, new ConcurrentHashMap<Long, SendWorker>());
    }

再进入this(***)QuorumCnxManager类的构造方法里面去:

public QuorumCnxManager(final long mySid,
                        Map<Long,QuorumPeer.QuorumServer> view,
                        QuorumAuthServer authServer,
                        QuorumAuthLearner authLearner,
                        int socketTimeout,
                        boolean listenOnAllIPs,
                        int quorumCnxnThreadsSize,
                        boolean quorumSaslAuthEnabled,
                        ConcurrentHashMap<Long, SendWorker> senderWorkerMap) {
    this.senderWorkerMap = senderWorkerMap; // 重要属性
    this.recvQueue = new ArrayBlockingQueue<Message>(RECV_CAPACITY); // 重要属性
    this.queueSendMap = new ConcurrentHashMap<Long, ArrayBlockingQueue<ByteBuffer>>(); // 重要属性
    this.lastMessageSent = new ConcurrentHashMap<Long, ByteBuffer>(); // 重要属性
    String cnxToValue = System.getProperty("zookeeper.cnxTimeout");
    if(cnxToValue != null){
        this.cnxTO = Integer.parseInt(cnxToValue);
    }

    this.mySid = mySid;
    this.socketTimeout = socketTimeout;
    this.view = view;
    this.listenOnAllIPs = listenOnAllIPs;

    initializeAuth(mySid, authServer, authLearner, quorumCnxnThreadsSize,
            quorumSaslAuthEnabled);
    // 重要属性
    listener = new Listener();
}
  1. ConcurrentHashMap<Long, SendWorker> senderWorkerMap
    格式Map:< serverId(myid) : SendWorker >
    Long:存的其他服务器的编号serverId,也就是myid。
    SendWorker:负责发送数据的类。
    说明:也就是说这个map其实就是保存了当前服务器去向其他服务器发送的数据的SendWorker。
  2. ConcurrentHashMap<Long, ArrayBlockingQueue< ByteBuffer >>queueSendMap
    格式Map:< myid : Queue >
    Long:存的其他服务器的编号serverId,也就是myid
    ArrayBlockingQueue:是一个数据队列, 是要向其他服务器发送的数据的队列
    说明:所以这个map保存的就是,当前的服务器要向其他服务器发送的数据的队列。SendWorker是一个线程,这个线程就负责去ArrayBlockingQueue这个队列里取出数据发送出去,这里是不是和之前同步数据的地方很像,只不过这里就是发送的选票。
  3. ArrayBlockingQueue< Message > recvQueue
    格式队列Queue
    Message:保存消息的类。
    说明:保存本台服务器接收到的消息。
  4. ConcurrentHashMap<Long, ByteBuffer> lastMessageSent;
    格式Map:< myid : ByteBuffer >
    Long:存的其他服务器的编号serverId,也就是myid。
    ByteBuffer:存的就是最新发出去的消息。
    说明:所以这个map保存的就是,本服务器发送给每台服务器最新的消息,这里也是用了一个map去存的,这里存的只有最新发送出去的消息。

请大家牢牢记住这几个属性,下面的解析离不开他们。

Listener类启动TCP-Socket

再往下又碰见一个很关键的属性listener = new Listener();,我们点进去发现这个Listener是一个QuorumCnxManager的内部类,而且还是一个线程类,这里就不贴代码了,大家记住这是个线程。这里只是new了一个实例,并没有启动,没什么可说的了。那么到这里代码就走完了,跳回case 3里接着看:

case 3:
    qcm = createCnxnManager();//初始化负责各个服务器之间的底层leader选举过程中的网络通信
    QuorumCnxManager.Listener listener = qcm.listener; //取出listener
    if(listener != null){
        listener.start(); //启动listener线程
        le = new FastLeaderElection(this, qcm); //硕果仅存的算法类
    } else {
        LOG.error("Null listener when initializing cnx manager");
    }
    break;

往下走,发现刚刚实例化的Listener被取出来了QuorumCnxManager.Listener listener = qcm.listener;,然后如果这个取出来的listener实例对象不是null,就listener.start();启动这个线程。根据这一个线索,下面就开始运行Listener.run()方法了,我们去去看下这个run()方法中做了什么:

public void run() {
    int numRetries = 0;
    InetSocketAddress addr;
    while((!shutdown) && (numRetries < 3)){ //numRetries<3,这就是为什么最多尝试三次的原因
        try {
            ss = new ServerSocket();//TCP Socket
            ss.setReuseAddress(true);
            if (listenOnAllIPs) { //过滤地址
                int port = view.get(QuorumCnxManager.this.mySid).electionAddr.getPort();
                addr = new InetSocketAddress(port);
            } else {
                addr = view.get(QuorumCnxManager.this.mySid).electionAddr;
            }
            LOG.info("My election bind port: " + addr.toString());
            setName(view.get(QuorumCnxManager.this.mySid)
                    .electionAddr.toString());
            ss.bind(addr);//绑定地址 
            while (!shutdown) {
                Socket client = ss.accept();//这里会被阻塞,等待其他的服务器来连接
                setSockOpts(client);
                LOG.info("Received connection request " + client.getRemoteSocketAddress());
                if (quorumSaslAuthEnabled) {
                    receiveConnectionAsync(client);
                } else {//一旦通了就会走到这里来
                    receiveConnection(client);
                }
                numRetries = 0;
            }
        } catch (IOException e) {
            /**Exceptions**/
        }
    }
    /**连接尝试超过三次没有成功,就到这里不再尝试,代码不涉及主逻辑,略**/
}

进入后发现了while循环里面Socket被实例化了ss = new ServerSocket();,但是注意这里使用的是java中的Socket,是TCP而不是NIO。经过一些过滤的逻辑走到了ss.bind(addr);绑定地址,这个地址electionAddr的端口就是我们配置的第二个端口3887,下面又一个while循环,这里的Socket client = ss.accept();会被阻塞,等待其他的服务器来连接。如果有连接进来,并且通过以后就会走到下面receiveConnection(client),注意这里面的while几乎也是一个while(true),那么就可以断定这里其实就是一个监听器不断监听连接。而且每一个socket都会调用一次这个方法。说到这里,接下来就进去receiveConnection(client)看下里面写了什么:

    public void receiveConnection(final Socket sock) {
        DataInputStream din = null;
        try {
            //取出数据
            din = new DataInputStream(
                    new BufferedInputStream(sock.getInputStream()));
            //进行连接的方法
            handleConnection(sock, din);
        } catch (IOException e) {
            LOG.error("Exception handling connection, addr: {}, closing server connection",
                     sock.getRemoteSocketAddress());
            closeSocket(sock);
        }
    }

进入后看到,首先取出数据din = new DataInputStream(***),然后调用handleConnection(sock, din)进行连接,接着点进去看看是怎么进行连接的:

private void handleConnection(Socket sock, DataInputStream din) throws IOException {
    Long sid = null;
    try {
        sid = din.readLong(); //拿到sid
        if (sid < 0) { // sid<0不合法
        	/**不合法,略**/
        }
        if (sid == QuorumPeer.OBSERVER_ID) {
            /**发现是观察者,略**/
        }
    } catch (IOException e) {
        /**Exceptions**/
    }
    LOG.debug("Authenticating learner server.id: {}", sid);
    authServer.authenticate(sock, din);
    //如果我的sid大于对方
    if (sid < this.mySid) {
        SendWorker sw = senderWorkerMap.get(sid);
        if (sw != null) {
            sw.finish();
        }
        LOG.debug("Create new connection to server: " + sid);
        closeSocket(sock);//关掉外部传来的socket
        connectOne(sid);//去连接对方
    } else {//如果对方的sid大于我
        SendWorker sw = new SendWorker(sock, sid); //准备发送工具
        RecvWorker rw = new RecvWorker(sock, din, sid, sw); //准备接收工具
        sw.setRecv(rw);
        SendWorker vsw = senderWorkerMap.get(sid);
        if(vsw != null)//如果这里不是null,说明这里已经发送过了,就不用再发送了
            vsw.finish(); 
        senderWorkerMap.put(sid, sw);//往map里放入数据
        queueSendMap.putIfAbsent(sid, new ArrayBlockingQueue<ByteBuffer>(SEND_CAPACITY));
        sw.start();
        rw.start();
        return;
    }
}

初识SendWorker 和 RecvWorker

进入后第一步还是要拿到sid,接着里面有一个很长的针对sid的判断。首先if (sid < 0)sid小于0肯定不合法。然后如果是观察者if (sid == QuorumPeer.OBSERVER_ID),也先略过。重点看if (sid < this.mySid)这里:这个判断里面sid是对方连接过来的服务器传递进来的myid,在之前通过sid = din.readLong();拿到的。后面this.mySid这个很明显就是自己的sid,那么为什么这里要进行一个sid的大小比较呢?其实这里是针对连接进行的一个优化:

比如1号服务器要连接2号服务器,那么返回来2号也要链接1号服务器。如果不加以限制,那么这两条服务器就会启动两条socket相互连接,这样明显就是多余了。所以按照myid的大小进行了一个连接的规则,也就是说领导者选举里面小的sid连接大的sid是不允许的。

那么如果"我"sid大于对方,进入这个if以后就发现,这里会先closeSocket(sock);关掉外部传来的socket,然后主动去connectOne(sid);去连接对方。反之,如果对方的sid大于"我",那么进入else。先new一个SendWorker sw,然后再new一个RecvWorker rw。再后面我们就看到了SendWorker vsw = senderWorkerMap.get(sid)被从一个map里面按照sid取出来。接着if(vsw != null)这里是一个验证,如果这里不是null,说明这里已经连接发送过了,所以vsw.finish(); 关掉。如果取出来的有数据,就把这个sidSendWorker对象放到senderWorkerMap这个map里面。从这里也能看出来为什么上面的判空通过了,就得关闭。因为如果是首次链接,map里一定是null;如果不是首次连接,map里一定有对应的sid的值。为了不重复连接,连接已经存在的sid,就要关闭。接着再往queueSendMap这个队列里放一份数据。然后又启动这两个线程sw.start();rw.start();。那么一直看笔者博客的小伙伴肯定明白了,接下来就是要去这两个线程里面了,一个一个来,先去SendWorker.run()中看看里面写了什么:

public void run() {
    threadCnt.incrementAndGet();
    try {
        ArrayBlockingQueue<ByteBuffer> bq = queueSendMap.get(sid);//拿出数据
        //如果队列里面没有数据
        if (bq == null || isSendQueueEmpty(bq)) {
           ByteBuffer b = lastMessageSent.get(sid);
           if (b != null) {
               LOG.debug("Attempting to send lastMessage to sid=" + sid);
               send(b);
           }
        }
    } catch (IOException e) {
        /**Exceptions**/
    }
    //如果里面有数据
    try {
        while (running && !shutdown && sock != null) {
            ByteBuffer b = null;
            try {
                ArrayBlockingQueue<ByteBuffer> bq = queueSendMap.get(sid);
                if (bq != null) {
                    b = pollSendQueue(bq, 1000, TimeUnit.MILLISECONDS);
                } else {
                    LOG.error("No queue of incoming messages for " + "server " + sid);
                    break;
                }
                if(b != null){
                    lastMessageSent.put(sid, b);
                    send(b);
                }
            } catch (InterruptedException e) {
                LOG.warn("Interrupted while waiting for message on queue", e);
            }
        }
    } catch (Exception e) {
        /**Log4j**/
    }
    this.finish();
    LOG.warn("Send worker leaving thread");
}

首先看到bq = queueSendMap.get(sid)数据被拿出来了。然后经过一个if逻辑:如果里面没有数据if (bq == null || isSendQueueEmpty(bq))成立,那么就把数据从lastMessageSent.get(sid)拿出来,再转化为ByteBuffer b字节缓存,最后字节数据全部发送出去send(b)。如果里面有数据,那么就在这个while循环里不断的把队列bq = queueSendMap.get(sid);map里面拿出来,然后从拿出的队列里拿出数据转换为b = pollSendQueue(bq, 1000, TimeUnit.MILLISECONDS)字节缓存,最后用send(b)把字节数据发送出去。到这里SendWorker.start()运行完了,它做了这些事情。我们先不管它为什么要这么做,我们接着看RecvWorker.start()这个线程又干了什么事情,所以走到RecvWorker.run()

public void run() {
    threadCnt.incrementAndGet();
    try {
        while (running && !shutdown && sock != null) {
            int length = din.readInt();
            /**length验证,略**/
            byte[] msgArray = new byte[length];
            din.readFully(msgArray, 0, length);//拿出socket数据
            ByteBuffer message = ByteBuffer.wrap(msgArray);//包装成ByteBuffer
            addToRecvQueue(new Message(message.duplicate(), sid));//添加到RecvQueue中
        }
    } catch (Exception e) {
        /**Log4j**/
    } finally {
        LOG.warn("Interrupting SendWorker");
        sw.finish();
        if (sock != null) {
            closeSocket(sock);
        }
    }
}

这里的代码少一些,从名字上看这个应该是接受数据的。果然这个接收的线程首先从socket里面读出数据din.readFully(msgArray, 0, length);。然后把内容包装成字节缓存的实例ByteBuffer message = ByteBuffer.wrap(msgArray)。再调用addToRecvQueue(new Message(message.duplicate(), sid))包装字节数据成Message类实例放到recvQueue里面。addToRecvQueue(***)这个方法里面并没有什么逻辑,只是一个判空然后add一个message到队列里。

public void addToRecvQueue(Message msg) {
    synchronized(recvQLock) {
        if (recvQueue.remainingCapacity() == 0) {
            try {
                recvQueue.remove();
            } catch (NoSuchElementException ne) {
                 LOG.debug("Trying to remove from an empty " +  "recvQueue. Ignoring exception " + ne);
            }
        }
        try {
            recvQueue.add(msg); //数据都放到了这个队列里
        } catch (IllegalStateException ie) {
            LOG.error("Unable to insert element in the recvQueue " + ie);
        }
    }
}

不知道大家看到这里有没有什么感觉,其实重新梳理一下,应该有这样一个逻辑:

queueSendMap(存发送命令)--取走-->SendWorker线程(这是Map) --Socket发送到-->其他
recvQueue(存收到命令)<--存入--RecvWorker线程(这也是个Map)<--Socket发送到--其他

总结

所以这部分的重点就在于SendWorkerRecvWorker这两个线程的逻辑,而且这两个类都是属于QuorumCnxManager这个传输层的类。通过分析我们其实就又整出来了一来一回两个数据链条。直观的看一下这一对儿链条,我们其实可以两个问题:1. queueSendMap这里的数据从何而来,2. recvQueue收到的数据是谁来使用的?我们往下面的解读也是围绕着这两个问题走的。那么我们会在下一篇博客继续解读这些内容。那么本章讲的总结为一张图:
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值