【分布式】ZooKeeper源码分析:Watcher机制
概要
本文内容主要介绍了ZooKeeper中Watcher机制,我们会先对Watcher做个简单介绍,然后再学习它的源码,最后会通过具体实例学会使用Watcher。主要内容如下:
转载请注明出处:https://www.cnblogs.com/fancyfairy/p/13531706.html
第1部分 Watcher介绍
第1.1部分 Watcher简介
ZooKeeper常用来作为数据发布/订阅中心,发布/订阅系统一般有两种模式:推和拉模式。推模式中,服务端主动将数据更新发送给所有订阅的客户端,拉模式则是由客户端主动发起请求来获取最新的数据,通常会采用定时轮询的方式。而ZooKeeper采用了推拉结合的方式:客户端像服务端注册自己需要关注的节点,一旦节点数据发生了变更,那么服务端就会像客服端发送Watcher事件通知,但是这个事件通知并不会包含具体的变更数据,客户端接收到这个消息通知之后,需要主动向服务端发起请求来获取最新的数据。

第1.2部分 Watcher特性
- 一次性
无论是客户端还是服务端,一旦一个Watcher被触发,ZooKeeper就会从相应存储中删除,在使用上需要反复注册
- 客户端串行执行
客户端Watcher的执行是一个串行同步的过程,为我们保证了顺序,开发的时候需要注意避免一个Watcher的逻辑阻塞整个客户端Watcher的回调
- 轻量
Watcher的通知非常简单,只包含通知状态、通知事件和通知路径,并不会包含数据变更,需要客户端主动获取数据。客户端注册的时候也不会将Watcher对象传给服务端,只是通过boolean类型属性进行标记,服务端会保存连接的ServerCnxn对象
第2部分 Watcher源码解析(基于ZooKeeper3.4.6)
第2.1部分 Watcher接口
// Watcher接口
public interface Watcher {
/**
* 定义了事件对应的不同状态
*/
public interface Event {
// 事件通知状态
public enum KeeperState {
// ...
}
/**
* 事件类型
*/
public enum EventType {
// ...
}
}
// 事件的回调方法
abstract public void process(WatchedEvent event);
}
Watcher是一个标准的事件处理器,包含了KeeperState和EventType两个枚举类,以及一个事件的回调方法。回调方法的定义很简单,包含了三个基本属性:KeeperState(通知状态)、EventType(事件类型)和path(节点路径)
public class WatchedEvent {
final private KeeperState keeperState;
final private EventType eventType;
private String path;
public WatchedEvent(WatcherEvent eventMessage) {
keeperState = KeeperState.fromInt(eventMessage.getState());
eventType = EventType.fromInt(eventMessage.getType());
path = eventMessage.getPath();
}
public WatcherEvent getWrapper() {
return new WatcherEvent(eventType.getIntValue(),
keeperState.getIntValue(),
path);
}
}
WatchedEvent只会在服务端或者客户端内部使用,实际在网络间传输的是WatcherEvent,我们可以看到这个类实现了序列化的接口Record,它的定义和WatchedEvent一摸一样,只是一个用的枚举类一个用的code码。
public class WatcherEvent implements Record {
private int type;
private int state;
private String path;
}
服务端生成WatchedEvent事件后,会调用getWrapper方法把自己转化成可以网络传输的WatchedEvent事件,客户端收到这个对象后,会反序列化后还原成一个WatchedEvent事件,并传递给process方法处理。无论节点发生了什么事件即使是数据变更,客户端也只能收到KeeperState(通知状态)、EventType(事件类型)和path(节点路径)这三个信息。
第2.2部分 工作过程
ZooKeeper的工作过程总的来说分为三步:客户端注册Watcher、服务端处理Watcher和客户端回调Watcher。其内部关键组件结构如下图:

2.2.1 客户端注册Watcher
客户端注册Watcher有以下几种方式:
(1) 创建ZooKeeper对象实例时,在构造方法中传入:
public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher);
(2) 获取数据时注册Watcher:
public byte[] getData(final String path, Watcher watcher, Stat stat);
(3) 判断节点是否存在时注册Watcher:
void exists(final String path, Watcher watcher, StatCallback cb, Object ctx);
(4) 获取子节点时注册Watcher:
public List<String> getChildren(final String path, Watcher watcher);
对于getDate/exists/getChildren几个方法,首先会将请求标记设置是否使用Watcher监听,同时会将Watcher的注册信息和节点路径封装成WatchRegistration对象,用于暂时保存路径和Watcher的对应关系。重点关注request.setWatch(watcher != null);,后面的介绍中会发现,watcher对象并不会真正的传递给服务端,客户端只告诉服务端是否订阅某个节点,但是具体订阅什么事件服务端是不知道的。
public byte[] getData(final String path, Watcher watcher, Stat stat)
throws KeeperException, InterruptedException
{
...
WatchRegistration wcb = null;
if (watcher != null) {
wcb = new DataWatchRegistration(watcher, clientPath);
}
final String serverPath = prependChroot(clientPath);
RequestHeader h = new RequestHeader();
h.setType(ZooDefs.OpCode.getData);
GetDataRequest request = new GetDataRequest();
request.setPath(serverPath);
request.setWatch(watcher != null);
GetDataResponse response = new GetDataResponse();
ReplyHeader r = cnxn.submitRequest(h, request, response, wcb);
...
}
通过submitRequest请求后,会封装成Packet对象(这部分详细介绍内容可以查看Packet相关内容)用于通信。
public ReplyHeader submitRequest(RequestHeader h, Record request,
Record response, WatchRegistration watchRegistration)
throws InterruptedException {
...
Packet packet = queuePacket(h, r, request, response, null, null, null,
null, watchRegistration);
...
}
Packet queuePacket(RequestHeader h, ReplyHeader r, Record request,
Record response, AsyncCallback cb, String clientPath,
String serverPath, Object ctx, WatchRegistration watchRegistration)
{
...
synchronized (outgoingQueue) {
packet = new Packet(h, r, request, response, watchRegistration);
...
outgoingQueue.add(packet);
...
}
...
}
随后,ZooKeeper客户端就会向服务端发送这个请求,同时会由客户端的sendThread线程的readResponse方法接收来自服务端的响应。服务端成功响应后(响应头中),客户端就会从Packet中取出对应的Watcher并注册到ZKWatchManager中去:
private void finishPacket(Packet p) {
if (p.watchRegistration != null) {
p.watchRegistration.register(p.replyHeader.getErr());
}
...
}
protected Map<String, Set<Watcher>> getWatches(int rc) {
// 这里用了策略模式,不同的WatchRegistration返回的watcherMap不同
return watchManager.dataWatches;
}
public void register(int rc) {
if (shouldAddWatch(rc)) {
Map<String, Set<Watcher>> watches = getWatches(rc);
synchronized(watches) {
Set<Watcher> watchers = watches.get(clientPath);
if (watchers == null) {
watchers = new HashSet<Watcher>();
watches.put(clientPath, watchers);
}
watchers.add(watcher);
}
}
}
private static class ZKWatchManager implements ClientWatchManager {
// Watches分为三个HashMap存储
private final Map<String, Set<Watcher>> dataWatches =
new HashMap<String, Set<Watcher>>();
private final Map<String, Set<Watcher>> existWatches =
new HashMap<String, Set<Watcher>>();
private final Map<String, Set<Watcher>> childWatches =
new HashMap<String, Set<Watcher>>();
private volatile Watcher defaultWatcher;
}
在register方法中,客户端会将之前赞数保存的Watcher对象转交给ZKWatcherManager,并最终保存到dataWatches中去。在这个过程中,需要注意几个点:
Watcher对象并不会真正的传输到服务端
如果客户端注册的所有Watcher都被传递到服务端,那么服务端肯定会出现内存紧张或其他性能问题,所以Zookeeper在设计的时候,并没有真正的将WatchRegistration对象完全序列化到网络传输的字节数组中。
从以上代码中我们可以看到,Packet中真正用于网络传输的属性只有requestHeader和request
public void createBB() {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
BinaryOutputArchive boa = BinaryOutputArchive.getArchive(baos);
boa.writeInt(-1, "len"); // We'll fill this in later
if (requestHeader != null) {
requestHeader.serialize(boa, "header");
}
if (request instanceof ConnectRequest) {
request.serialize(boa, "connect");
// append "am-I-allowed-to-be-readonly" flag
boa.writeBool(readOnly, "readOnly");
} else if (request != null) {
request.serialize(boa, "request");
}
...
}
2.2.2 服务端处理Watcher
服务端接收到客户端的Watcher后的处理逻辑分为,在FinalRequestProcessor.processRequest中会判断当前请求是否注册Watcher,以getData为例:
case OpCode.getData: {
...
byte b[] = zks.getZKDatabase().getData(getDataRequest.getPath(), stat,
getDataRequest.getWatch() ? cnxn : null);
rsp = new GetDataResponse(b, stat);
break;
}
可以看到,服务端会根据请求中watch属性为true的时候传入当前ServerCnxn,同时将数据节点路径和ServerCnxn对象存储到WatchManager中的watchTable和watch2Paths中。
- watchTable:从数据节点路径的粒来托管Watcher
- Watch2Paths:从Watcher的粒度控制事件触发需要触发的数据节点
public byte[] getData(String path, Stat stat, Watcher watcher) {
return dataTree.getData(path, stat, watcher);
}
public byte[] getData(String path, Stat stat, Watcher watcher) {
...
if (watcher != null) {
dataWatches.addWatch(path, watcher);
}
return n.data;
}
public synchronized void addWatch(String path, Watcher watcher) {
HashSet<Watcher> list = watchTable.get(path);
if (list == null) {
// don't waste memory if there are few watches on a node
// rehash when the 4th entry is added, doubling size thereafter
// seems like a good compromise
list = new HashSet<Watcher>(4);
watchTable.put(path, list);
}
list.add(watcher);
HashSet<String> paths = watch2Paths.get(watcher);
if (paths == null) {
// cnxns typically have many watches, so use default cap here
paths = new HashSet<String>();
watch2Paths.put(watcher, paths);
}
paths.add(path);
}
WatchManager在服务端会由DataTree托管两个WatchManager,分别是dataWatches和childWatches,本例中是getData接口,所以会存储在dataWatches中。
服务端如何触发Watcher的呢
每个事件的触发条件都是固定的,比如NodeDataChanged事件的触发条件是Watcher监听的对应数据节点的数据内容发生了变更,所以在setData的时候会触发对应事件:
public Stat setData(String path, byte data[], int version, long zxid,
long time) throws KeeperException.NoNodeException {
// ...
dataWatches.triggerWatch(path, EventType.NodeDataChanged);
return s;
}
在更新完节点数据后,通过调用WatchManager的triggerWatch方法触发相关事件:
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);
// ...
for (Watcher w : watchers) {
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;
}
触发过程分为三步:封装WatchedEvent(按照具体事件类型、通知状态和路径)、查询路径对应的wathers(查询到则在watchTable和watch2Paths中删除)、根据Watcher依次调用process方法。从这里可以看到,Watcher是一次性的,触发一次即失效。process是通过ServerCnxn中process接口来调用的,Zookeeper中ServerCnxn的实现有两种:NIOServerCnxn(默认实现)和NettyServerCnxn,在和客户端建立连接的时候会进行初始化,它是客户端和服务端之间的连接接口,并且实现了Watcher接口,因此无论哪种实现方式都实现了Watcher中的process方法。
我们以NIOServerCnxn中的实现为例,服务端将内部封装的WatchedEvent包装成WatcherEvent用于网络传输序列化,然后向客户端发送通知。process本身并不处理很多复杂的逻辑,只是发送通知,真正复杂的逻辑都在客户端处理了。
@Override
synchronized public void process(WatchedEvent event) {
...
// Convert WatchedEvent to a type that can be sent over the wire
WatcherEvent e = event.getWrapper();
sendResponse(h, e, "notification");
}
2.2.3 客户端回调Watcher
客户端会服务端发过来的消息是统一处理的, 根据响应头的类型判断属于什么操作,-1代表这是一个通知。通过反序列化成WatchedEvent之后交给事件线程EventThread处理。
class SendThread extends Thread {
void readResponse(ByteBuffer incomingBuffer) throws IOException {
// ...
if (replyHdr.getXid() == -1) {
// -1 means notification
WatcherEvent event = new WatcherEvent();
event.deserialize(bbia, "response");
// ...
WatchedEvent we = new WatchedEvent(event);
// ...
eventThread.queueEvent( we );
return;
}
}
}
EventThread事件线程是ZooKeeper客户端专门用来处理服务端通知的线程,其处理逻辑为:
public void queueEvent(WatchedEvent event) {
if (event.getType() == EventType.None
&& sessionState == event.getState()) {
return;
}
sessionState = event.getState();
// materialize the watchers based on the event
WatcherSetEventPair pair = new WatcherSetEventPair(
watcher.materialize(event.getState(), event.getType(),
event.getPath()),
event);
// queue the pair (watch set & event) for later processing
waitingEvents.add(pair);
}
客户端会从ZKWatchManager中取出所有相关的Watcher,客户端在识别出事件类型后,会从存储的watches中去除对应的watcher,此处Watcher机制也是一次性的,即触发后就失效。
public Set<Watcher> materialize(Watcher.Event.KeeperState state,
Watcher.Event.EventType type,
String clientPath) {
Set<Watcher> result = new HashSet<Watcher>();
switch (type) {
case None:
// ...
case NodeDataChanged:
case NodeCreated:
synchronized (dataWatches) {
addTo(dataWatches.remove(clientPath), result);
}
synchronized (existWatches) {
addTo(existWatches.remove(clientPath), result);
}
break;
case NodeChildrenChanged:
synchronized (childWatches) {
addTo(childWatches.remove(clientPath), result);
}
break;
case NodeDeleted:
// ...
default:
// ...
}
return result;
}
final private void addTo(Set<Watcher> from, Set<Watcher> to) {
if (from != null) {
to.addAll(from);
}
}
拿到相关Watcher后,会放入waitingEvent队列中去,EventThread的run方法会不断对该队列进行处理,Watcher回调在客户端串行执行,在每个Watcher处理中会调用process方法,真正的调用处理逻辑实现客户端的回调。
public void run() {
try {
isRunning = true;
while (true) {
Object event = waitingEvents.take();
if (event == eventOfDeath) {
wasKilled = true;
} else {
processEvent(event);
}
if (wasKilled)
synchronized (waitingEvents) {
if (waitingEvents.isEmpty()) {
isRunning = false;
break;
}
}
}
// ...
}
private void processEvent(Object event) {
try {
if (event instanceof WatcherSetEventPair) {
// each watcher will process the event
WatcherSetEventPair pair = (WatcherSetEventPair) event;
for (Watcher watcher : pair.watchers) {
try {
watcher.process(pair.event);
} catch (Throwable t) {
LOG.error("Error while calling watcher ", t);
}
}
// ...
}
}
}
第三部分 Watcher应用示例
public class ZooKeeperWatcherTest implements Watcher {
public static final Logger LOG = LoggerFactory.getLogger(ZooKeeperWatcherTest.class);
private static final int SESSION_TIMEOUT = 10000;
private ZooKeeper zk = null;
private CountDownLatch connectedSemaphore = new CountDownLatch(1);
/**
* 连接Zookeeper
* @param connectString Zookeeper服务地址
*/
public void connectionZookeeper(String connectString) {
connectionZookeeper(connectString, SESSION_TIMEOUT);
}
public void connectionZookeeper(String connectString, int sessionTimeout) {
this.releaseConnection();
try {
// ZK客户端允许我们将ZK服务器的所有地址都配置在这里
zk = new ZooKeeper(connectString, sessionTimeout, this);
// 使用CountDownLatch.await()的线程(当前线程)阻塞直到所有其它拥有CountDownLatch的线程执行完毕(countDown()结果为0)
connectedSemaphore.await();
} catch (InterruptedException e) {
LOG.error("连接创建失败,发生 InterruptedException , e " + e.getMessage(), e);
} catch (IOException e) {
LOG.error("连接创建失败,发生 IOException , e " + e.getMessage(), e);
}
}
/**
* <p>创建zNode节点, String create(path<节点路径>, data[]<节点内容>, List(ACL访问控制列表), CreateMode<zNode创建类型>) </p><br/>
* <pre>
* 节点创建类型(CreateMode)
* 1、PERSISTENT:持久化节点
* 2、PERSISTENT_SEQUENTIAL:顺序自动编号持久化节点,这种节点会根据当前已存在的节点数自动加 1
* 3、EPHEMERAL:临时节点客户端,session超时这类节点就会被自动删除
* 4、EPHEMERAL_SEQUENTIAL:临时自动编号节点
* </pre>
* @param path zNode节点路径
* @param data zNode数据内容
* @return 创建成功返回true, 反之返回false.
*/
public boolean createPath(String path, String data) {
try {
String zkPath = this.zk.create(path, data.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
LOG.info("节点创建成功, Path: " + zkPath + ", content: " + data);
return true;
} catch (KeeperException e) {
LOG.error("节点创建失败, 发生KeeperException! path: " + path + ", data:" + data + ", errMsg:" + e.getMessage(), e);
} catch (InterruptedException e) {
LOG.error(
"节点创建失败, 发生 InterruptedException! path: " + path + ", data:" + data + ", errMsg:" + e.getMessage(),
e);
}
return false;
}
/**
* <p>删除一个zMode节点, void delete(path<节点路径>, stat<数据版本号>)</p><br/>
* <pre>
* 说明
* 1、版本号不一致,无法进行数据删除操作.
* 2、如果版本号与znode的版本号不一致,将无法删除,是一种乐观加锁机制;如果将版本号设置为-1,不会去检测版本,直接删除.
* </pre>
* @param path zNode节点路径
* @return 删除成功返回true, 反之返回false.
*/
public boolean deletePath(String path) {
try {
this.zk.delete(path, -1);
LOG.info("节点删除成功, Path: " + path);
return true;
} catch (KeeperException e) {
LOG.error("节点删除失败, 发生KeeperException! path: " + path + ", errMsg:" + e.getMessage(), e);
} catch (InterruptedException e) {
LOG.error("节点删除失败, 发生 InterruptedException! path: " + path + ", errMsg:" + e.getMessage(), e);
}
return false;
}
/**
* <p>更新指定节点数据内容, Stat setData(path<节点路径>, data[]<节点内容>, stat<数据版本号>)</p>
* <pre>
* 设置某个znode上的数据时如果为-1,跳过版本检查
* </pre>
* @param path zNode节点路径
* @param data zNode数据内容
* @return 更新成功返回true, 返回返回false
*/
public boolean writeData(String path, String data) {
try {
Stat stat = this.zk.setData(path, data.getBytes(), -1);
LOG.info("更新数据成功, path:" + path + ", stat: " + stat);
return true;
} catch (KeeperException e) {
LOG.error("更新数据失败, 发生KeeperException! path: " + path + ", data:" + data + ", errMsg:" + e.getMessage(), e);
} catch (InterruptedException e) {
LOG.error("更新数据失败, 发生InterruptedException! path: " + path + ", data:" + data + ", errMsg:" + e.getMessage(),
e);
}
return false;
}
/**
* <p>读取指定节点数据内容,byte[] getData(path<节点路径>, watcher<监视器>, stat<数据版本号>)</p>
* @param path zNode节点路径
* @return 节点存储的值, 有值返回, 无值返回null
*/
public String readData(String path) {
String data = null;
try {
data = new String(this.zk.getData(path, false, null));
LOG.info("读取数据成功, path:" + path + ", content:" + data);
} catch (KeeperException e) {
LOG.error("读取数据失败,发生KeeperException! path: " + path + ", errMsg:" + e.getMessage(), e);
} catch (InterruptedException e) {
LOG.error("读取数据失败,发生InterruptedException! path: " + path + ", errMsg:" + e.getMessage(), e);
}
return data;
}
/**
* <p>获取某个节点下的所有子节点,List getChildren(path<节点路径>, watcher<监视器>)该方法有多个重载</p>
* @param path zNode节点路径
* @return 子节点路径集合 说明,这里返回的值为节点名
* <pre>
* eg.
* /node
* /node/child1
* /node/child2
* getChild( "node" )户的集合中的值为["child1","child2"]
* </pre>
*
*
*
* @throws KeeperException
* @throws InterruptedException
*/
public List<String> getChild(String path) {
try {
List<String> list = this.zk.getChildren(path, false);
if (list.isEmpty()) {
LOG.info("中没有节点" + path);
}
return list;
} catch (KeeperException e) {
LOG.error("读取子节点数据失败,发生KeeperException! path: " + path + ", errMsg:" + e.getMessage(), e);
} catch (InterruptedException e) {
LOG.error("读取子节点数据失败,发生InterruptedException! path: " + path + ", errMsg:" + e.getMessage(), e);
}
return null;
}
/**
* <p>判断某个zNode节点是否存在, Stat exists(path<节点路径>, watch<并设置是否监控这个目录节点,这里的 watcher 是在创建 ZooKeeper 实例时指定的 watcher>)</p>
* @param path zNode节点路径
* @return 存在返回true, 反之返回false
*/
public boolean isExists(String path) {
try {
Stat stat = this.zk.exists(path, false);
return null != stat;
} catch (KeeperException e) {
LOG.error("读取数据失败,发生KeeperException! path: " + path + ", errMsg:" + e.getMessage(), e);
} catch (InterruptedException e) {
LOG.error("读取数据失败,发生InterruptedException! path: " + path + ", errMsg:" + e.getMessage(), e);
}
return false;
}
/**
* Watcher Server,处理收到的变更
* @param watchedEvent
*/
public void process(WatchedEvent watchedEvent) {
LOG.info("收到事件通知:" + watchedEvent.getState());
if (Event.KeeperState.SyncConnected == watchedEvent.getState()) {
connectedSemaphore.countDown();
}
}
/**
* 关闭ZK连接
*/
public void releaseConnection() {
if (null != zk) {
try {
this.zk.close();
} catch (InterruptedException e) {
LOG.error("release connection error ," + e.getMessage(), e);
}
}
}
public static void main(String[] args) {
// 定义父子类节点路径
String rootPath = "/blog/watcher";
String child1Path = rootPath + "/nodeChildren1";
String child2Path = rootPath + "/nodeChildren2";
ZooKeeperWatcherTest zkWatchAPI = new ZooKeeperWatcherTest();
// 连接zk服务器
zkWatchAPI.connectionZookeeper("127.0.0.1:2181");
// 创建节点数据
if (zkWatchAPI.createPath(rootPath, "<父>节点数据")) {
System.out.println("节点[" + rootPath + "]数据内容[" + zkWatchAPI.readData(rootPath) + "]");
}
// 创建子节点, 读取 + 删除
if (zkWatchAPI.createPath(child1Path, "<父-子(1)>节点数据")) {
System.out.println("节点[" + child1Path + "]数据内容[" + zkWatchAPI.readData(child1Path) + "]");
zkWatchAPI.deletePath(child1Path);
System.out.println("节点[" + child1Path + "]删除值后[" + zkWatchAPI.readData(child1Path) + "]");
}
// 创建子节点, 读取 + 修改
if (zkWatchAPI.createPath(child2Path, "<父-子(2)>节点数据")) {
System.out.println("节点[" + child2Path + "]数据内容[" + zkWatchAPI.readData(child2Path) + "]");
zkWatchAPI.writeData(child2Path, "<父-子(2)>节点数据,更新后的数据");
System.out.println("节点[" + child2Path + "]数据内容更新后[" + zkWatchAPI.readData(child2Path) + "]");
}
// 获取子节点
List<String> childPaths = zkWatchAPI.getChild(rootPath);
if (null != childPaths) {
System.out.println("节点[" + rootPath + "]下的子节点数[" + childPaths.size() + "]");
for (String childPath : childPaths) {
System.out.println(" |--节点名[" + childPath + "]");
}
}
// 判断节点是否存在
System.out.println("检测节点[" + rootPath + "]是否存在:" + zkWatchAPI.isExists(rootPath));
System.out.println("检测节点[" + child1Path + "]是否存在:" + zkWatchAPI.isExists(child1Path));
System.out.println("检测节点[" + child2Path + "]是否存在:" + zkWatchAPI.isExists(child2Path));
zkWatchAPI.releaseConnection();
}
}
注:示例来自https://www.cnblogs.com/dennisit/p/4340746.html


浙公网安备 33010602011771号