前情提要
上一篇【Zookeeper 源码解读系列, 单机模式(五)】EventThread这个线程在客户端方面的流程,其中包含了监听事件是怎么注册和触发的,这一篇我们接着继续后续的内容,一起来看一看当服务端接收到一个事件以后,服务端的处理流程是什么样的,以及服务端又是如何与客户端沟通的。上一篇我们已经透露过,本篇为了讲解的更清楚,我们就以Set操作作为一个例子讲解,所以这篇文章也是说是Zookeeper的Set命令是怎么触发一个事件并且返回给客户端的。此外,本篇一开始会带着大家过一遍Zookeeper在服务端程序执行的流程,但是不会做过多讲解。因为这部分的内容已经在本系列的【单机模式(三)】和【单机模式(四)】里面悉数讲过,这里就不再重复讲解。这部分内容会快速的过去,如果有读者不明白,请移步到这两篇文章里看详细的代码解读。本篇也会被收录到【Zookeeper 源码解读系列目录】中。
服务端读取数据的流程
我们本次要讲解的就是有关服务端的逻辑了,既然触及到服务端的代码了,我们先要找到服务端真正核心的代码doIO()
这个方法。我们一直再说处理收发数据的类都是NIO的类,因为服务端也是开一个socket然后有连接进来以后不停的从socket里面读取数据,所以doIO()
这个方法就是最核心的一块,如果不太明白的话可以去看笔者之前的博客,这里就不再赘述。我们去NIOServerCnxnFactory.run()
里找到doIO()
方法:
public void run() {
while (!ss.socket().isClosed()) {
try { /**略**/
for (SelectionKey k : selectedList) {
if ((k.readyOps() & SelectionKey.OP_ACCEPT) != 0) {
/**略**/
} else if ((k.readyOps() & (SelectionKey.OP_READ | SelectionKey.OP_WRITE)) != 0) {
NIOServerCnxn c = (NIOServerCnxn) k.attachment();
c.doIO(k); //和客户端一样开始doIO
} else { /**略**/ }
}
selected.clear();
} catch (***Exception e) { /**略**/ }
}
/**略**/
}
我们还是把不相关的代码忽略掉,直接进入c.doIO(k);
:
void doIO(SelectionKey k) throws InterruptedException {
try {/**略**/
if (k.isReadable()) {//读取准备完毕,读取客户端发来的数据
/**略**/
if (incomingBuffer.remaining() == 0) {
/**略**/
if (isPayload) {
readPayload();//正式加载数据
}
/**略**/
}
}
} catch (CancelledKeyException e) { /**略**/ }
}
如果要读取数据最终会走到readPayload();
这个方法里:
private void readPayload() throws IOException, InterruptedException {
/**略**/
if (incomingBuffer.remaining() == 0) { //如果全部读完了,可以请求了
/**略**/
if (!initialized) {
readConnectRequest();
} else {
readRequest(); //已经连接成功的话,就会到这里
}
/**略**/
}
}
然后走到readRequest();
,开始读取请求:
private void readRequest() throws IOException {
zkServer.processPacket(this, incomingBuffer);
}
进入以后在zkServer.processPacket(this, incomingBuffer);
这里处理包,之前我们说过,服务端实例就是在这里开始处理收到的packet,incomingBuffer就是socket里面的数据,那么我们接着往里面走:
public void processPacket(ServerCnxn cnxn, ByteBuffer incomingBuffer) throws IOException {
/**略**/
if (h.getType() == OpCode.auth) {//处理addauth命令
/**略**/
} else {//不是auth
if (h.getType() == OpCode.sasl) {//也不是sasl命令
/**略**/
}
else {
//直接从socket里面拿出一个request
Request si = new Request(cnxn, cnxn.getSessionId(), h.getXid(),
h.getType(), incomingBuffer, cnxn.getAuthInfo());
si.setOwner(ServerCnxn.me);
submitRequest(si);//拿出后,提交request
}
}
cnxn.incrOutstandingRequests(h);
}
我们要看的是set
命令,所以跳过auth
,跳过sasl
,直接到else
里面,这里就直接从socket
里面拿出了request(si)
,然后submitRequest(si);
提交这个si,接着往里面走:
public void submitRequest(Request si) {
if (firstProcessor == null) {
/**略**/
}
try {
/**略**/
if (validpacket) {
/**略**/
firstProcessor.processRequest(si); //存入submittedRequests队列中
/**略**/
}
/**略**/
} catch (MissingSessionException e) {
/**略**/
}
}
进到这里以后可以看到,这里就到了我们之前【单机模式(四)】讲服务端处理器链的逻辑了,同样会把在【单机模式(四)】里面讲过的类似的代码略去。那么我们就直接找到firstProcessor.processRequest(si);
这个方法的实现方法:PrepRequestProcessor.processRequest(Request request)
,看过之前博客的读者应该还记得这个方法里没有逻辑,只有一句话,作用就是把传入的客户端请求si
添加到了一个队列里submittedRequests.add(request);
。那么我们就得去找谁用了这个submittedRequests
队列,所以就找到了PrepRequestProcessor.run()
方法里:
public void run() {
try {
while (true) {
Request request = submittedRequests.take();//取出请求
/**略**/
pRequest(request);//执行请求
}
} catch (RequestProcessorException e) {
/**略**/
}
LOG.info("PrepRequestProcessor exited loop!");
}
到了PrepRequestProcessor.run()
方法以后,首先取出submittedRequests.take();
队列里面的request,经过验证以后调用pRequest(request);
方法,在这个方法里处理命令,所以我们继续进入:
protected void pRequest(Request request) throws RequestProcessorException {
request.hdr = null;
request.txn = null;
try {//首先还是判断是什么命令
switch (request.type) {
case OpCode.create:
/**略**/
case OpCode.setData: //来到set的分支里来
SetDataRequest setDataRequest = new SetDataRequest();
pRequest2Txn(request.type, zks.getNextZxid(), request, setDataRequest, true);
break;
case OpCode.****:
/**其他的case略**/
}
} catch (KeeperException e) {
/**略**/
}
request.zxid = zks.getZxid();
nextProcessor.processRequest(request);//存入queuedRequests队列中
}
进入以后首先经过switch
的分拣判断是什么命令,这次我们要去的地方就不是create
,也不是getData
,而是setData
。所以我们到case OpCode.setData:
的分支里来,在这里验证并生成事务文件(进行持久化并且打快照),这里和create
没太大的区别,我们也跳过。那么就走到了最后nextProcessor.processRequest(request);
,这个方法的作用几乎是一样的,就是为了添加请求到队列里,那么我们的nextProcessor
也就是SyncRequestProcessor
调用的这个方法就把请求添加queuedRequests.add(request);
到了队列queuedRequests
里面去。同理我们就又要去SyncRequestProcessor.run()
方法里找到这个处理器怎么处理这个请求的:
public void run() {
try {
int logCount = 0;
setRandRoll(r.nextInt(snapCount/2));
while (true) {
/**略**/
if (si != null) {
if (zks.getZKDatabase().append(si)) {
/**打快照,略**/
} else if (toFlush.isEmpty()) {
if (nextProcessor != null) {
nextProcessor.processRequest(si); //再次交给finalProcessr处理
/**略**/
}
continue;
}
/**略**/
}
}
/**持久化,略**/
} catch (Throwable t) {
/**略**/
}
LOG.info("SyncRequestProcessor exited!");
}
进入以后,我们发现了请求在一个while (true)
里面处理,经过各种验证、持久化、打快照等等步骤以后,最后又把请求递交出去了nextProcessor.processRequest(si)
,这个nextProcessor
就是Zookeeper
单机模式下处理器链的最后一个环FinalRequestProcessor
,所以我们就要去找FinalRequestProcessor.processRequest(si)
的内容:
public void processRequest(Request request) {
/**Log 略**/
ProcessTxnResult rc = null;
synchronized (zks.outstandingChanges) {
while (!zks.outstandingChanges.isEmpty()
&& zks.outstandingChanges.get(0).zxid <= request.zxid) {
ChangeRecord cr = zks.outstandingChanges.remove(0);
/**略**/
}
if (request.hdr != null) {
TxnHeader hdr = request.hdr;
Record txn = request.txn;
rc = zks.processTxn(hdr, txn);//更新内存
}
/**略**/
}
/**暂时略,后面还会讲**/
}
既然是最后一环,想一下就肯定知道这里面不需要再添加到某个队列里了。之前已经详细的分析过FinalProcessor
将在这里更新内存以及触发事件,进入后经过一系列的取出操作我们找到这样一行zks.processTxn(hdr, txn)
进入,前面已经做过持久化了,这个方法就是更新内存用的:
public ProcessTxnResult processTxn(TxnHeader hdr, Record txn) {
ProcessTxnResult rc;
int opCode = hdr.getType();
long sessionId = hdr.getClientId();
rc = getZKDatabase().processTxn(hdr, txn);//走进这里来
/**略**/
return rc;
}
这里并没有什么,直接进入正题我们去看getZKDatabase().processTxn(hdr, txn)
这个方法是怎么更新内存的:
public ProcessTxnResult processTxn(TxnHeader hdr, Record txn) {
return dataTree.processTxn(hdr, txn);
}
进入后发现是DataTree
这个类的对象调用了processTxn()
方法,还记得我们最初讲的存储结构是什么样子的吗?这里给大家提醒一下:Database.Datatree.DataNode
,那么我们预期更改内存一定是用DataNode
这个类做的,我们一会儿看这个猜想对不对。现在先到Datatree
中去dataTree.processTxn(hdr, txn)
中看DataTree
怎么更新的内存:
public ProcessTxnResult processTxn(TxnHeader header, Record txn)
{
ProcessTxnResult rc = new ProcessTxnResult();
try {
rc.clientId = header.getClientId();
rc.cxid = header.getCxid();
rc.zxid = header.getZxid();
rc.type = header.getType();
rc.err = 0;
rc.multiResult = null;
switch (header.getType()) {
case OpCode.create:
/**create的内容,略**/
case OpCode.setData:
SetDataTxn setDataTxn = (SetDataTxn) txn;
rc.path = setDataTxn.getPath();
rc.stat = setData(setDataTxn.getPath(), setDataTxn
.getData(), setDataTxn.getVersion(), header
.getZxid(), header.getTime());
break;
case OpCode.****:
/**其他的case略**/
break;
}
} catch (***Exception e) {
/**略**/
}
/**略**/
return rc;
}
首先还是先拿出来传递的内容,像clientId、cxid、zxid、ype
等等,然后还是先要用switch
匹配到我们需要的set
命令所以找到case OpCode.setData:
,那么这里就找到了设置数据的方法setData(***)
,可以看到这里把路径、数据、版本、Zxid
等等信息都作为参数传递了进去,所以我们进去看里面拿到这些数据做了什么:
public Stat setData(String path, byte data[], int version, long zxid,
long time) throws KeeperException.NoNodeException {
Stat s = new Stat();
DataNode n = nodes.get(path); //拿到node的信息
if (n == null) {
throw new KeeperException.NoNodeException();
}
byte lastdata[] = null;
synchronized (n) { //修改内存的数据
lastdata = n.data;
n.data = data;
n.stat.setMtime(time);
n.stat.setMzxid(zxid);
n.stat.setVersion(version);
n.copyStat(s);
}
String lastPrefix;
if((lastPrefix = getMaxPrefixWithQuota(path)) != null) {
this.updateBytes(lastPrefix, (data == null ? 0 : data.length)
- (lastdata == null ? 0 : lastdata.length));
}
dataWatches.triggerWatch(path, EventType.NodeDataChanged); //触发事件
return s;
}
进入以后,我们看到第一步就是用DataNode
去获取当前路径path
的节点信息n = nodes.get(path);
,这说明我们刚才的猜想是对的。再然后synchronized (n) { **** }
这里面是再做什么?就是在更新node
的内容,这里也就是Zookeeper
在修改内存中的数据。改完内存数据,再往下走发现了一个重点dataWatches.triggerWatch(path, EventType.NodeDataChanged)
,这是在做什么?我们看名字triggerWatch
,故名思及就是在触发事件。看到这里才大白天下,此时才刚刚触发事件,触发这个传入的node(path)
的NodeDataChanged
事件。
triggerWatch触发事件
到这里同学们想一下,这个触发事件应该要做什么事情?那我们先推理一下,服务端收到数据更新完内存以后要做什么呢?是不是要去通知各个客户端”我“已经更新完了。所以我们能想到的第一件事情,是不是就是进行网络传输呢?那我们点进去看里面到底做了什么事情,是不是和我们猜想的一样:
public Set<Watcher> triggerWatch(String path, EventType type) {
return triggerWatch(path, type, null);
}
发现这里没东西,那就接着到triggerWatch(path, type, null);
里面去:
public Set<Watcher> triggerWatch(String path, EventType type, Set<Watcher> supress) {
WatchedEvent e = new WatchedEvent(type, KeeperState.SyncConnected, path);
HashSet<Watcher> watchers;
synchronized (this) {
watchers = watchTable.remove(path); //取出路径对应的watcher
if (watchers == null || watchers.isEmpty()) {
if (LOG.isTraceEnabled()) {
/**打印Log**/
}
return null;
}
for (Watcher w : watchers) { //取出watcher对应的路径
HashSet<String> paths = watch2Paths.get(w);
if (paths != null) {
paths.remove(path);
}
}
}
for (Watcher w : watchers) {
if (supress != null && supress.contains(w)) {
continue;
}
w.process(e);//执行
}
return watchers;
}
一路进入triggerWatch(***)
,我们发现了这样一句话watchers = watchTable.remove(path);
,这里watchTable
调用了remove(path)
那么这里一定是服务端保存的某些东西。从这里取出watchers
,经过一系列的确定path
的操作在最后的for
循环里面,调用w.process(e)
这里执行。而这个process(e)
方法可以我们自己写代码实现的,也可以通过服务端注册的process()
方法,是不是能够调用这个process()
方法和执行的逻辑有关。那么其实到这里,一个set
语句的流程就基本完成了。那么这里就有一个很大的问题了,不是说好的要发给客户端吗?你怎么还没有发给客户端set
流程就基本结束了呢?并不是我们猜错了,而是还有一个东西没有说到,流程走到这里不得不暂时中断一下,这个没有说的东西就是watchTable
,那么这个东西有什么用呢?
一个重要的Map:WatchTable
我们先看一下这个Map:
HashMap<String, HashSet<Watcher>> watchTable = new HashMap<String, HashSet<Watcher>>();
我们刚刚说过watchTable
调用了remove(path)
它一定是服务端保存的一些服务端Watcher的信息之类的内容。那么这个Map
的Key
既然是String
类型的,那就肯定是节点的名字。它的Value(HashSet<Watcher>)
难道和客户端一样存的也是一个Watcher
的实现类吗?之前我们讲过,服务端是不需要知道Watcher
的具体实现的,只要知道是不是有Watcher即可,那这不就又和以前的内容冲突了吗?那么我们就得探究watchTable
是什么时候存的以及里面存的到底是什么。默认情况下set
执行以后会调用一个getdata(**)
去显示出来,而且我们曾经讲过当我们使用getdata(**)
的时候会调用服务端把node
的这些数据存一下,那么我们要去看一下服务端在getData(**)
的时候是怎么来处理这个watcher
的。一样的处理链逻辑找到PrepRequestProcessor.run()
中的pRequest(**)
方法:
protected void pRequest(Request request) throws RequestProcessorException {
request.hdr = null;
request.txn = null;
try {
switch (request.type) {
case OpCode.***:
/**略**/
break;
case OpCode.getData: //get命令不需要记录事务,所以只是checkSession
zks.sessionTracker.checkSession(request.sessionId,
request.getOwner());
break;
default:
/**略**/
break;
}
} catch (KeeperException e) {
/**略**/
}
request.zxid = zks.getZxid();
nextProcessor.processRequest(request); //直接转给next
}
找到case OpCode.getData
分支,我们发现第一个处理器什么都没做,只是检验了一下session
,然后就直接转到了下一个处理器SyncRequestProcessor
里面,但是我们之前说过SyncRequestProcessor
是持久化类就不多说了,所以很快就会走到FinalRequestProcessor.processRequest(si)
这里来:
public void processRequest(Request request) {
/**略**/
try {
/**略**/
switch (request.type) {
case OpCode.***: {
/**略**/
break;
}
case OpCode.getData: {
lastOp = "GETD";
GetDataRequest getDataRequest = new GetDataRequest();
ByteBufferInputStream.byteBuffer2Record(request.request,
getDataRequest);
DataNode n = zks.getZKDatabase().getNode(getDataRequest.getPath());
if (n == null) {
throw new KeeperException.NoNodeException();
}
PrepRequestProcessor.checkACL(zks, zks.getZKDatabase().aclForNode(n),
ZooDefs.Perms.READ,
request.authInfo);
Stat stat = new Stat();
byte b[] = zks.getZKDatabase().getData(getDataRequest.getPath(), stat,
getDataRequest.getWatch() ? cnxn : null); //传入cnxn,还是null
rsp = new GetDataResponse(b, stat); //构造response
break;
}
}
} catch (***Exception e) {
/**略**/
}
/**略**/
try {
cnxn.sendResponse(hdr, rsp, "response");
if (closeSession) {
cnxn.sendCloseSession();
}
} catch (IOException e) {
LOG.error("FIXMSG",e);
}
}
进入方法以后,把不相关的代码清除,在方法里直接找到case OpCode.getData:
由于getData()
不需要更新Zookeeper
内存里的东西,所以直接构造了一个response
返回rsp = new GetDataResponse(b, stat);
出去了。但是在返回之前先构造了一个byte
数组b[] = zks.getZKDatabase().getData(getDataRequest.getPath(), stat, getDataRequest.getWatch() ? cnxn : null);
,这个方法里首先传进去客户端请求的路径,状态和Watcher
,但是这里传入的是一个判断getDataRequest.getWatch() ? cnxn : null
,这个是什么意思呢?直观的解读就是:拿到了true
,那么我们取cnxn(ServerCnxn)
这个对象作为参数,否则使用nulll
作为参数。那么大家看到这里能不能想到什么,我们上一篇讲客户端的时候是不是说到了传递Watcher
的时候,传的参数不是Watcher
的内容,而是一个true
或者false
,当时客户端的代码是request.setWatch(watcher != null);
。而这里的getDataRequest.getWatch()
拿到的就是true
或者false
的数据,所以这里真正想要说的其实是:只要是设置了监听器,不管是我们设置的,还是系统设置的,只要有一个监听器,那么我们用cnxn(ServerCnxn)
这个对象作为参数。那么我们点进去getData(***)方法看看这里应该要传进去什么东西:
public byte[] getData(String path, Stat stat, Watcher watcher)
throws KeeperException.NoNodeException {
return dataTree.getData(path, stat, watcher);
}
我们看到这第三个参数,传入的应该是Watcher
,但是我们传入的其实是固定的ServerCnxn
类对象,否则就传入的是null
。那么为什么ServerCnxn
类对象能够作为参数传递进来呢。我们知道ServerCnxn
类是服务端连接的类,连接没了这个类对象也会消失。而且这个类在声明的时候是实现了Stats, Watcher
两个接口,是这两个类的实现类。相当于服务端抽象出来的监听器,不管客户端定义传来了什么样的监听器,服务端最终都用ServerCnxn
类去当作监听器注册,如下:
//ServerCnxn这个类同时也是抽象类
public abstract class ServerCnxn implements Stats, Watcher{ /**略**/ }
那么我们进入dataTree.getData(path, stat, watcher);
方法看下里面又写了什么:
public byte[] getData(String path, Stat stat, Watcher watcher)
throws KeeperException.NoNodeException {
DataNode n = nodes.get(path);
if (n == null) {
throw new KeeperException.NoNodeException();
}
synchronized (n) {
n.copyStat(stat);
if (watcher != null) {
dataWatches.addWatch(path, watcher); //发现dataWatches
}
return n.data;
}
}
进入发现又跑回了DataTree
类,我们发现里面有dataWatches.addWatch(path, watcher);
,dataWatches
是WatchManager
的对象,顾名思义这是一个监听器管理的类。我们观察到这里传入的watcher
监听器被加到了WatchManager
类对象里了,那么我们就进入addWatch(path, watcher)
看看里面又写了什么:
public synchronized void addWatch(String path, Watcher watcher) {
HashSet<Watcher> list = watchTable.get(path);
if (list == null) {
list = new HashSet<Watcher>(4);
watchTable.put(path, list); //把path对应的watcher的list放入watchTable
}
list.add(watcher);
HashSet<String> paths = watch2Paths.get(watcher);
if (paths == null) {
paths = new HashSet<String>();
watch2Paths.put(watcher, paths); //把watcher对应的path的list放进watch2Paths
}
paths.add(path);
}
进入后我们终于看到了watchTable
,进一步分析,服务器接到的watcher
就是在这里被加入这个map
的。到这里我们这一小节提出的问题watchTable
里的数据出处终于找到了。其实看map的泛型也很好猜到watchTable
其实存的就是一个路径对应的多个watcher
,而下面watch2Paths
存的就是一个watcher
对应多个路径的情况。
自此我们可以得出结论:服务端注册watch的时候注册的是一个ServerCnxn
对象,然后对应的节点也会被存起来作为一个map
返回出去。就是说只要客户端只要给node
注册了一个事件,服务端都会给这个节点注册并保存这样的一个MAP:map<NodeName:ServerCnxn(Watch)>
信息,这个map
存的就是节点和对应的ServerCnxn
,而ServerCnxn
就可以看作一个Watcher
,然后交给客户端执行process()
方法以达到WatchEvent
执行的目的。
执行事件
既然我们已经说清楚了watchTable
那么就可以回到我们的set
命令的流程了,那么我们接着triggerWatch(path, type, null);
这个方法往下走。这里断开的有点远了,给大家一个提示,这个方法是WatchManager
里面的方法,我们是从DataTree.setData(***)
这里进来的:
public Set<Watcher> triggerWatch(String path, EventType type, Set<Watcher> supress) {
WatchedEvent e = new WatchedEvent(type, KeeperState.SyncConnected, path);
HashSet<Watcher> watchers;
synchronized (this) {
watchers = watchTable.remove(path); //取出路径对应的watcher
if (watchers == null || watchers.isEmpty()) {
if (LOG.isTraceEnabled()) {
/**打印Log**/
}
return null;
}
for (Watcher w : watchers) { //取出watcher对应的路径
HashSet<String> paths = watch2Paths.get(w);
if (paths != null) {
paths.remove(path);
}
}
}
for (Watcher w : watchers) {
if (supress != null && supress.contains(w)) {
continue;
}
w.process(e);//执行
}
return watchers;
}
那么我们走到w.process(e);
执行方法这里,但是我们看这个参数w
其实是Watcher
类的对象。通过我们上面的分析,w
在服务器这边其实就是ServerCnxn
这个类的对象。那么Watcher.process(e);
这个抽象方法就必然是在ServerCnxn.process(e)
里面实现的。所以我们就应该去ServerCnxn
的process()
实现方法里面去看下写了什么:
public abstract void process(WatchedEvent event);
结果发现这也是一个抽象方法,它的实现方法是在NIOServerCnxn
里面,那我们接着找到NIOServerCnxn.process()
:
synchronized public void process(WatchedEvent event) {
ReplyHeader h = new ReplyHeader(-1, -1L, 0);
if (LOG.isTraceEnabled()) {
ZooTrace.logTraceMessage(LOG, ZooTrace.EVENT_DELIVERY_TRACE_MASK,
"Deliver event " + event + " to 0x"
+ Long.toHexString(this.sessionId)
+ " through " + this);
}
WatcherEvent e = event.getWrapper();
//把数据发送给客户端
sendResponse(h, e, "notification");
}
进来以后,看到最后一行代码没有sendResponse(h, e, "notification");
,这个方法就是最终的发送方法,于是我们就完成了一个事件触发。这个sendResponse(***)
肯定会写道我们的socket里面去,这个方法在以前的博客里面已经说过多次而且很多地方都会使用这个方法,就不再多说了。到这里,一个完整的Set
命令流程就结束了,那我们的事件触发的讲解也结束了。
总结
讲完服务端对事件的接收和触发以后,我们大致可以说这一系列的操作就是用客户端和服务端一起通过NIO的逻辑实现的。那么我们下面对事件触发做一个总结。
触发事件的全部流程
客户端触发事件的时候,首先要注册节点名字,watcher
实现类等等。这些信息会保存在一个Pair< Node: List<new Watch()>>
对象里面,去对应一个具体的watcher
实现类。这个注册的动作在客户端,但是发生在服务端返回信息之后,才进行注册的。服务端收到请求后,也会注册但是服务端会把节点名字,以及watcher
这些信息保存在一个map
里,而且watcher
注册的是一个NIOServerCnxn
对象。然后服务端会在触发事件,调用NIOServerCnxn.process()
方法,这个process()
方法就是把这个节点名字,事件类型这些信息通过socket
发送给客户端,客户端接收到以后就直接去pair
去找对应的node
,读取并移除(remove
)绑定的事件,然后触发这个事件,这也是为什么绑定的事件只能被触发一次。
本篇讲完以后,下一篇将会介绍Close Session的内容。