ZK客户端Curator使用详解

开源
zookeeper不是为高可用性设计的,但它使用ZAB协议达到了极高的一致性,所以是个CP系统。所以它经常被选作注册中心、配置中心、分布式锁等场景。

 zookeeper不是为高可用性设计的,但它使用ZAB协议达到了极高的一致性,所以是个CP系统。所以它经常被选作注册中心、配置中心、分布式锁等场景。

[[323374]]

它的性能是非常有限的,而且API并不是那么好用。xjjdog倾向于使用基于Raft协议的Etcd或者Consul,它们更加轻量级一些。

Curator是netflix公司开源的一套zookeeper客户端,目前是Apache的顶级项目。与Zookeeper提供的原生客户端相比,Curator的抽象层次更高,简化了Zookeeper客户端的开发量。Curator解决了很多zookeeper客户端非常底层的细节开发工作,包括连接重连、反复注册wathcer和NodeExistsException 异常等。

Curator由一系列的模块构成,对于一般开发者而言,常用的是curator-framework和curator-recipes,下面对此依次介绍。

1.maven依赖

最新版本的curator 4.3.0支持zookeeper 3.4.x和3.5,但是需要注意curator传递进来的依赖,需要和实际服务器端使用的版本相符,以我们目前使用的zookeeper 3.4.6为例。

  1. <dependency> 
  2.     <groupId>org.apache.curator</groupId> 
  3.     <artifactId>curator-framework</artifactId> 
  4.     <version>4.3.0</version> 
  5.     <exclusions> 
  6.         <exclusion> 
  7.             <groupId>org.apache.zookeeper</groupId> 
  8.             <artifactId>zookeeper</artifactId> 
  9.         </exclusion> 
  10.     </exclusions> 
  11. </dependency> 
  12. <dependency> 
  13.     <groupId>org.apache.curator</groupId> 
  14.     <artifactId>curator-recipes</artifactId> 
  15.     <version>4.3.0</version> 
  16.     <exclusions> 
  17.         <exclusion> 
  18.             <groupId>org.apache.zookeeper</groupId> 
  19.             <artifactId>zookeeper</artifactId> 
  20.         </exclusion> 
  21.     </exclusions> 
  22. </dependency> 
  23. <dependency> 
  24.     <groupId>org.apache.zookeeper</groupId> 
  25.     <artifactId>zookeeper</artifactId> 
  26.     <version>3.4.6</version> 
  27. </dependency> 

2.curator-framework

下面是一些常见的zk相关的操作。

  1. public static CuratorFramework getClient() { 
  2.     return CuratorFrameworkFactory.builder() 
  3.             .connectString("127.0.0.1:2181"
  4.             .retryPolicy(new ExponentialBackoffRetry(1000, 3)) 
  5.             .connectionTimeoutMs(15 * 1000) //连接超时时间,默认15秒 
  6.             .sessionTimeoutMs(60 * 1000) //会话超时时间,默认60秒 
  7.             .namespace("arch") //设置命名空间 
  8.             .build(); 
  9.   
  10. public static void create(final CuratorFramework client, final String path, final byte[] payload) throws Exception { 
  11.     client.create().creatingParentsIfNeeded().forPath(path, payload); 
  12.   
  13. public static void createEphemeral(final CuratorFramework client, final String path, final byte[] payload) throws Exception { 
  14.     client.create().withMode(CreateMode.EPHEMERAL).forPath(path, payload); 
  15.   
  16. public static String createEphemeralSequential(final CuratorFramework client, final String path, final byte[] payload) throws Exception { 
  17.     return client.create().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, payload); 
  18.   
  19. public static void setData(final CuratorFramework client, final String path, final byte[] payload) throws Exception { 
  20.     client.setData().forPath(path, payload); 
  21.   
  22. public static void delete(final CuratorFramework client, final String path) throws Exception { 
  23.     client.delete().deletingChildrenIfNeeded().forPath(path); 
  24.   
  25. public static void guaranteedDelete(final CuratorFramework client, final String path) throws Exception { 
  26.     client.delete().guaranteed().forPath(path); 
  27.   
  28. public static String getData(final CuratorFramework client, final String path) throws Exception { 
  29.     return new String(client.getData().forPath(path)); 
  30.   
  31. public static List<String> getChildren(final CuratorFramework client, final String path) throws Exception { 
  32.     return client.getChildren().forPath(path); 

3.curator-recipescurator-recipes

提供了一些zk的典型使用场景的参考。下面主要介绍一下开发中常用的组件。

事件监听

zookeeper原生支持通过注册watcher来进行事件监听,但是其使用不是特别方便,需要开发人员自己反复注册watcher,比较繁琐。

Curator引入Cache来实现对zookeeper服务端事务的监听。Cache是Curator中对事件监听的包装,其对事件的监听其实可以近似看作是一个本地缓存视图和远程Zookeeper视图的对比过程。同时,Curator能够自动为开发人员处理反复注册监听,从而大大简化原生api开发的繁琐过程。

1)Node Cache

  1. public static void nodeCache() throws Exception { 
  2.     final String path = "/nodeCache"
  3.     final CuratorFramework client = getClient(); 
  4.     client.start(); 
  5.   
  6.     delete(client, path); 
  7.     create(client, path, "cache".getBytes()); 
  8.   
  9.     final NodeCache nodeCache = new NodeCache(client, path); 
  10.     nodeCache.start(true); 
  11.     nodeCache.getListenable() 
  12.             .addListener(() -> System.out.println("node data change, new data is " + new String(nodeCache.getCurrentData().getData()))); 
  13.   
  14.     setData(client, path, "cache1".getBytes()); 
  15.     setData(client, path, "cache2".getBytes()); 
  16.   
  17.     Thread.sleep(1000); 
  18.   
  19.     client.close(); 

NodeCache可以监听指定的节点,注册监听器后,节点的变化会通知相应的监听器

2)Path Cache

Path Cache 用来监听ZNode的子节点事件,包括added、updateed、removed,Path Cache会同步子节点的状态,产生的事件会传递给注册的PathChildrenCacheListener。

  1. public static void pathChildrenCache() throws Exception { 
  2.         final String path = "/pathChildrenCache"
  3.         final CuratorFramework client = getClient(); 
  4.         client.start(); 
  5.   
  6.         final PathChildrenCache cache = new PathChildrenCache(client, path, true); 
  7.         cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT); 
  8.     cache.getListenable().addListener((client1, event) -> { 
  9.             switch (event.getType()) { 
  10.                 case CHILD_ADDED: 
  11.                     System.out.println("CHILD_ADDED:" + event.getData().getPath()); 
  12.                     break; 
  13.                 case CHILD_REMOVED: 
  14.                     System.out.println("CHILD_REMOVED:" + event.getData().getPath()); 
  15.                     break; 
  16.                 case CHILD_UPDATED: 
  17.                     System.out.println("CHILD_UPDATED:" + event.getData().getPath()); 
  18.                     break; 
  19.                 case CONNECTION_LOST: 
  20.                     System.out.println("CONNECTION_LOST:" + event.getData().getPath()); 
  21.                     break; 
  22.                 case CONNECTION_RECONNECTED: 
  23.                     System.out.println("CONNECTION_RECONNECTED:" + event.getData().getPath()); 
  24.                     break; 
  25.                 case CONNECTION_SUSPENDED: 
  26.                     System.out.println("CONNECTION_SUSPENDED:" + event.getData().getPath()); 
  27.                     break; 
  28.                 case INITIALIZED: 
  29.                     System.out.println("INITIALIZED:" + event.getData().getPath()); 
  30.                     break; 
  31.                 default
  32.                     break; 
  33.             } 
  34.         }); 
  35.   
  36. //        client.create().withMode(CreateMode.PERSISTENT).forPath(path); 
  37.         Thread.sleep(1000); 
  38.   
  39.         client.create().withMode(CreateMode.PERSISTENT).forPath(path + "/c1"); 
  40.         Thread.sleep(1000); 
  41.   
  42.         client.delete().forPath(path + "/c1"); 
  43.         Thread.sleep(1000); 
  44.   
  45.         client.delete().forPath(path); //监听节点本身的变化不会通知 
  46.         Thread.sleep(1000); 
  47.   
  48.         client.close(); 
  49.     } 

3)Tree Cache

Path Cache和Node Cache的“合体”,监视路径下的创建、更新、删除事件,并缓存路径下所有孩子结点的数据。

  1. public static void treeCache() throws Exception { 
  2.     final String path = "/treeChildrenCache"
  3.     final CuratorFramework client = getClient(); 
  4.     client.start(); 
  5.   
  6.     final TreeCache cache = new TreeCache(client, path); 
  7.     cache.start(); 
  8.   
  9.     cache.getListenable().addListener((client1, event) -> { 
  10.         switch (event.getType()){ 
  11.             case NODE_ADDED: 
  12.                 System.out.println("NODE_ADDED:" + event.getData().getPath()); 
  13.                 break; 
  14.             case NODE_REMOVED: 
  15.                 System.out.println("NODE_REMOVED:" + event.getData().getPath()); 
  16.                 break; 
  17.             case NODE_UPDATED: 
  18.                 System.out.println("NODE_UPDATED:" + event.getData().getPath()); 
  19.                 break; 
  20.             case CONNECTION_LOST: 
  21.                 System.out.println("CONNECTION_LOST:" + event.getData().getPath()); 
  22.                 break; 
  23.             case CONNECTION_RECONNECTED: 
  24.                 System.out.println("CONNECTION_RECONNECTED:" + event.getData().getPath()); 
  25.                 break; 
  26.             case CONNECTION_SUSPENDED: 
  27.                 System.out.println("CONNECTION_SUSPENDED:" + event.getData().getPath()); 
  28.                 break; 
  29.             case INITIALIZED: 
  30.                 System.out.println("INITIALIZED:" + event.getData().getPath()); 
  31.                 break; 
  32.             default
  33.                 break; 
  34.         } 
  35.     }); 
  36.   
  37.     client.create().withMode(CreateMode.PERSISTENT).forPath(path); 
  38.     Thread.sleep(1000); 
  39.   
  40.     client.create().withMode(CreateMode.PERSISTENT).forPath(path + "/c1"); 
  41.     Thread.sleep(1000); 
  42.   
  43.     setData(client, path, "test".getBytes()); 
  44.     Thread.sleep(1000); 
  45.   
  46.     client.delete().forPath(path + "/c1"); 
  47.     Thread.sleep(1000); 
  48.   
  49.     client.delete().forPath(path); 
  50.     Thread.sleep(1000); 
  51.   
  52.     client.close(); 

选举

curator提供了两种方式,分别是Leader Latch和Leader Election。

1)Leader Latch

随机从候选着中选出一台作为leader,选中之后除非调用close()释放leadship,否则其他的后选择无法成为leader

  1. public class LeaderLatchTest { 
  2.   
  3.     private static final String PATH = "/demo/leader"
  4.   
  5.     public static void main(String[] args) { 
  6.         List<LeaderLatch> latchList = new ArrayList<>(); 
  7.         List<CuratorFramework> clients = new ArrayList<>(); 
  8.         try { 
  9.             for (int i = 0; i < 10; i++) { 
  10.                 CuratorFramework client = getClient(); 
  11.                 client.start(); 
  12.                 clients.add(client); 
  13.   
  14.                 final LeaderLatch leaderLatch = new LeaderLatch(client, PATH, "client#" + i); 
  15.                 leaderLatch.addListener(new LeaderLatchListener() { 
  16.                     @Override 
  17.                     public void isLeader() { 
  18.                         System.out.println(leaderLatch.getId() + ":I am leader. I am doing jobs!"); 
  19.                     } 
  20.   
  21.                     @Override 
  22.                     public void notLeader() { 
  23.                         System.out.println(leaderLatch.getId() + ":I am not leader. I will do nothing!"); 
  24.                     } 
  25.                 }); 
  26.                 latchList.add(leaderLatch); 
  27.                 leaderLatch.start(); 
  28.             } 
  29.             Thread.sleep(1000 * 60); 
  30.         } catch (Exception e) { 
  31.             e.printStackTrace(); 
  32.         } finally { 
  33.             for (CuratorFramework client : clients) { 
  34.                 CloseableUtils.closeQuietly(client); 
  35.             } 
  36.   
  37.             for (LeaderLatch leaderLatch : latchList) { 
  38.                 CloseableUtils.closeQuietly(leaderLatch); 
  39.             } 
  40.         } 
  41.     } 
  42.   
  43.     public static CuratorFramework getClient() { 
  44.         return CuratorFrameworkFactory.builder() 
  45.                 .connectString("127.0.0.1:2181"
  46.                 .retryPolicy(new ExponentialBackoffRetry(1000, 3)) 
  47.                 .connectionTimeoutMs(15 * 1000) //连接超时时间,默认15秒 
  48.                 .sessionTimeoutMs(60 * 1000) //会话超时时间,默认60秒 
  49.                 .namespace("arch") //设置命名空间 
  50.                 .build(); 
  51.     } 
  52.   

2)Leader Election

通过LeaderSelectorListener可以对领导权进行控制, 在适当的时候释放领导权,这样每个节点都有可能获得领导权。而LeaderLatch则一直持有leadership, 除非调用close方法,否则它不会释放领导权。

  1. public class LeaderSelectorTest { 
  2.     private static final String PATH = "/demo/leader"
  3.   
  4.     public static void main(String[] args) { 
  5.         List<LeaderSelector> selectors = new ArrayList<>(); 
  6.         List<CuratorFramework> clients = new ArrayList<>(); 
  7.         try { 
  8.             for (int i = 0; i < 10; i++) { 
  9.                 CuratorFramework client = getClient(); 
  10.                 client.start(); 
  11.                 clients.add(client); 
  12.   
  13.                 final String name = "client#" + i; 
  14.                 LeaderSelector leaderSelector = new LeaderSelector(client, PATH, new LeaderSelectorListenerAdapter() { 
  15.                     @Override 
  16.                     public void takeLeadership(CuratorFramework client) throws Exception { 
  17.                         System.out.println(name + ":I am leader."); 
  18.                         Thread.sleep(2000); 
  19.                     } 
  20.                 }); 
  21.   
  22.                 leaderSelector.autoRequeue(); 
  23.                 leaderSelector.start(); 
  24.                 selectors.add(leaderSelector); 
  25.             } 
  26.             Thread.sleep(Integer.MAX_VALUE); 
  27.         } catch (Exception e) { 
  28.             e.printStackTrace(); 
  29.         } finally { 
  30.             for (CuratorFramework client : clients) { 
  31.                 CloseableUtils.closeQuietly(client); 
  32.             } 
  33.   
  34.             for (LeaderSelector selector : selectors) { 
  35.                 CloseableUtils.closeQuietly(selector); 
  36.             } 
  37.   
  38.         } 
  39.     } 
  40.   
  41.     public static CuratorFramework getClient() { 
  42.         return CuratorFrameworkFactory.builder() 
  43.                 .connectString("127.0.0.1:2181"
  44.                 .retryPolicy(new ExponentialBackoffRetry(1000, 3)) 
  45.                 .connectionTimeoutMs(15 * 1000) //连接超时时间,默认15秒 
  46.                 .sessionTimeoutMs(60 * 1000) //会话超时时间,默认60秒 
  47.                 .namespace("arch") //设置命名空间 
  48.                 .build(); 
  49.     } 
  50.   

分布式锁

1)可重入锁Shared Reentrant Lock

Shared意味着锁是全局可见的, 客户端都可以请求锁。Reentrant和JDK的ReentrantLock类似, 意味着同一个客户端在拥有锁的同时,可以多次获取,不会被阻塞。它是由类InterProcessMutex来实现。它的构造函数为:

  1. public InterProcessMutex(CuratorFramework client, String path) 

通过acquire获得锁,并提供超时机制:

  1. /** 
  2. * Acquire the mutex - blocking until it's available. Note: the same thread can call acquire 
  3. * re-entrantly. Each call to acquire must be balanced by a call to release() 
  4. */ 
  5. public void acquire(); 
  6.   
  7. /** 
  8. * Acquire the mutex - blocks until it's available or the given time expires. Note: the same thread can 
  9. * call acquire re-entrantly. Each call to acquire that returns true must be balanced by a call to release() 
  10. * Parameters: 
  11. time - time to wait 
  12. * unit - time unit 
  13. Returns
  14. true if the mutex was acquired, false if not 
  15. */ 
  16. public boolean acquire(long time, TimeUnit unit); 

通过release()方法释放锁。InterProcessMutex 实例可以重用。Revoking ZooKeeper recipes wiki定义了可协商的撤销机制。为了撤销mutex, 调用下面的方法:

  1. /** 
  2. * 将锁设为可撤销的. 当别的进程或线程想让你释放锁时Listener会被调用。 
  3. * Parameters: 
  4. * listener - the listener 
  5. */ 
  6. public void makeRevocable(RevocationListener<T> listener) 

2)不可重入锁Shared Lock

使用InterProcessSemaphoreMutex,调用方法类似,区别在于该锁是不可重入的,在同一个线程中不可重入

3)可重入读写锁Shared Reentrant Read Write Lock

类似JDK的ReentrantReadWriteLock. 一个读写锁管理一对相关的锁。一个负责读操作,另外一个负责写操作。读操作在写锁没被使用时可同时由多个进程使用,而写锁使用时不允许读 (阻塞)。此锁是可重入的。一个拥有写锁的线程可重入读锁,但是读锁却不能进入写锁。这也意味着写锁可以降级成读锁, 比如请求写锁 —>读锁 —->释放写锁。从读锁升级成写锁是不成的。主要由两个类实现:

  1. InterProcessReadWriteLock 
  2. InterProcessLock 

4)信号量Shared Semaphore

一个计数的信号量类似JDK的Semaphore。JDK中Semaphore维护的一组许可(permits),而Cubator中称之为租约(Lease)。注意,所有的实例必须使用相同的numberOfLeases值。调用acquire会返回一个租约对象。客户端必须在finally中close这些租约对象,否则这些租约会丢失掉。但是, 但是,如果客户端session由于某种原因比如crash丢掉, 那么这些客户端持有的租约会自动close, 这样其它客户端可以继续使用这些租约。租约还可以通过下面的方式返还:

  1. public void returnAll(Collection<Lease> leases) 
  2. public void returnLease(Lease lease) 

注意一次你可以请求多个租约,如果Semaphore当前的租约不够,则请求线程会被阻塞。同时还提供了超时的重载方法:

  1. public Lease acquire() 
  2. public Collection<Lease> acquire(int qty) 
  3. public Lease acquire(long time, TimeUnit unit) 
  4. public Collection<Lease> acquire(int qty, long time, TimeUnit unit) 

主要类有:

  1. InterProcessSemaphoreV2 
  2. Lease 
  3. SharedCountReader 

5)多锁对象Multi Shared Lock

Multi Shared Lock是一个锁的容器。当调用acquire, 所有的锁都会被acquire,如果请求失败,所有的锁都会被release。同样调用release时所有的锁都被release(失败被忽略)。基本上,它就是组锁的代表,在它上面的请求释放操作都会传递给它包含的所有的锁。主要涉及两个类:

  1. InterProcessMultiLock 
  2. InterProcessLock 

它的构造函数需要包含的锁的集合,或者一组ZooKeeper的path。

  1. public InterProcessMultiLock(List<InterProcessLock> locks) 
  2. public InterProcessMultiLock(CuratorFramework client, List<String> paths) 

栅栏

barrier1)DistributedBarrier构造函数中barrierPath参数用来确定一个栅栏,只要barrierPath参数相同(路径相同)就是同一个栅栏。通常情况下栅栏的使用如下:

1.主导client设置一个栅栏

2.其他客户端就会调用waitOnBarrier()等待栅栏移除,程序处理线程阻塞

3.主导client移除栅栏,其他客户端的处理程序就会同时继续运行。

DistributedBarrier类的主要方法如下:

setBarrier() - 设置栅栏

waitOnBarrier() - 等待栅栏移除

removeBarrier() - 移除栅栏

2)双栅栏Double Barrier

双栅栏允许客户端在计算的开始和结束时同步。当足够的进程加入到双栅栏时,进程开始计算,当计算完成时,离开栅栏。双栅栏类是DistributedDoubleBarrier DistributedDoubleBarrier类实现了双栅栏的功能。它的构造函数如下:

  1. // client - the client 
  2. // barrierPath - path to use 
  3. // memberQty - the number of members in the barrier 
  4. public DistributedDoubleBarrier(CuratorFramework client, String barrierPath, int memberQty) 

memberQty是成员数量,当enter方法被调用时,成员被阻塞,直到所有的成员都调用了enter。当leave方法被调用时,它也阻塞调用线程,直到所有的成员都调用了leave。

注意:参数memberQty的值只是一个阈值,而不是一个限制值。当等待栅栏的数量大于或等于这个值栅栏就会打开!

与栅栏(DistributedBarrier)一样,双栅栏的barrierPath参数也是用来确定是否是同一个栅栏的,双栅栏的使用情况如下:

1.从多个客户端在同一个路径上创建双栅栏(DistributedDoubleBarrier),然后调用enter()方法,等待栅栏数量达到memberQty时就可以进入栅栏。

2.栅栏数量达到memberQty,多个客户端同时停止阻塞继续运行,直到执行leave()方法,等待memberQty个数量的栅栏同时阻塞到leave()方法中。

3.memberQty个数量的栅栏同时阻塞到leave()方法中,多个客户端的leave()方法停止阻塞,继续运行。

DistributedDoubleBarrier类的主要方法如下:enter()、enter(long maxWait, TimeUnit unit) - 等待同时进入栅栏

leave()、leave(long maxWait, TimeUnit unit) - 等待同时离开栅栏

异常处理:DistributedDoubleBarrier会监控连接状态,当连接断掉时enter()和leave方法会抛出异常。

计数器

Counters利用ZooKeeper可以实现一个集群共享的计数器。只要使用相同的path就可以得到最新的计数器值, 这是由ZooKeeper的一致性保证的。Curator有两个计数器, 一个是用int来计数,一个用long来计数。

1)SharedCount

这个类使用int类型来计数。主要涉及三个类。

  1. * SharedCount 
  2. * SharedCountReader 
  3. * SharedCountListener 

SharedCount代表计数器, 可以为它增加一个SharedCountListener,当计数器改变时此Listener可以监听到改变的事件,而SharedCountReader可以读取到最新的值, 包括字面值和带版本信息的值VersionedValue。

2)DistributedAtomicLong

除了计数的范围比SharedCount大了之外, 它首先尝试使用乐观锁的方式设置计数器, 如果不成功(比如期间计数器已经被其它client更新了), 它使用InterProcessMutex方式来更新计数值。此计数器有一系列的操作:

  • get(): 获取当前值
  • increment():加一
  • decrement(): 减一
  • add():增加特定的值
  • subtract(): 减去特定的值
  • trySet(): 尝试设置计数值
  • forceSet(): 强制设置计数值

你必须检查返回结果的succeeded(), 它代表此操作是否成功。如果操作成功, preValue()代表操作前的值, postValue()代表操作后的值。

End

Curator抽象和简化了很多复杂的zookeeper操作,是zk使用者的福音。而要彻底的幸福,那就是不再使用它。

我不知道其他人把zk放在一个什么位置,但在我接触paxos协议之后,就很难对它产生浓厚的兴趣。一般在技术选型的时候,它会躺在我的备选列表最后,我甚至根本无法掌握源代码里那些晦涩难懂的逻辑。

但工程建设从来不以我们的喜好来进行衡量。从来如此。

作者简介:小姐姐味道 (xjjdog),一个不允许程序员走弯路的公众号。聚焦基础架构和Linux。十年架构,日百亿流量,与你探讨高并发世界,给你不一样的味道

责任编辑:武晓燕 来源: 小姐姐味道
相关推荐

2010-05-12 15:46:51

Subversion客

2010-06-01 14:11:11

TortoiseSVN

2011-08-17 10:10:59

2009-03-04 10:27:50

客户端组件桌面虚拟化Xendesktop

2010-04-08 15:35:13

Oracle 简易客户

2012-10-11 17:02:02

IBMdw

2011-03-21 14:53:36

Nagios监控Linux

2011-04-06 14:24:20

Nagios监控Linux

2009-07-24 17:31:56

ASP.NET AJA

2010-05-31 15:55:42

2010-03-18 16:49:43

Java Socket

2013-06-08 09:59:15

VMwarevSphere Web

2012-01-13 10:29:37

ibmdw

2010-06-01 13:54:42

TortoiseSVN

2010-05-26 09:26:43

Cassandra

2011-04-06 14:24:27

Nagios监控Linux

2009-08-21 16:14:52

服务端与客户端通信

2021-04-30 08:19:32

SpringCloud客户端负载Ribbo

2010-03-18 15:44:22

VSTS 2010VS 2010

2021-09-22 15:46:29

虚拟桌面瘦客户端胖客户端
点赞
收藏

51CTO技术栈公众号