最近使用了zookeeper-client-c,网上查了查资料,这里记录一下。

背景与目的

Zookeeper开发过程中遇到一些常见问题,为了后续开发不犯同样的错误,总结一下此类问题,并进行分析和解决。

适合人员

主要适合zookeeper开发、测试及运维相关人员。

下图给出了zookeeper-client-c会话一个简要的状态转移图(基于3.4.6的c client代码,没有考虑zk ACL层):

上图中几个状态的说明如下:

  • not_connected 最开始,未和服务器进行通讯的时候
  • connecting 客户端正在开始和zookeeper集群服务中的某一台服务器进行连接
  • associating 客户端已经和某个服务器建立tcp连接,在等待服务器根据客户端上传的会话信息进行”匹配”
  • connected 客户端已和服务器联系上,此时可以和服务器发送读写请求
  • expired 会话已经超时不可用,客户端此时应关闭本会话,自杀或者重新发起连接

 

1、zookeeper-client-c状态

可以查看zookeeper-client-c客户端代码,直接看源码:

https://github.com/apache/zookeeper/blob/master/zookeeper-client/zookeeper-client-c/include/zookeeper.h

enum ZOO_ERRORS {
  ZOK = 0, /*!< Everything is OK */

  /** System and server-side errors.
   * This is never thrown by the server, it shouldn't be used other than
   * to indicate a range. Specifically error codes greater than this
   * value, but lesser than {@link #ZAPIERROR}, are system errors. */
  ZSYSTEMERROR = -1,
  ZRUNTIMEINCONSISTENCY = -2, /*!< A runtime inconsistency was found */
  ZDATAINCONSISTENCY = -3, /*!< A data inconsistency was found */
  ZCONNECTIONLOSS = -4, /*!< Connection to the server has been lost */
  ZMARSHALLINGERROR = -5, /*!< Error while marshalling or unmarshalling data */
  ZUNIMPLEMENTED = -6, /*!< Operation is unimplemented */
  ZOPERATIONTIMEOUT = -7, /*!< Operation timeout */
  ZBADARGUMENTS = -8, /*!< Invalid arguments */
  ZINVALIDSTATE = -9, /*!< Invliad zhandle state */
  ZNEWCONFIGNOQUORUM = -13, /*!< No quorum of new config is connected and
                                 up-to-date with the leader of last commmitted
                                 config - try invoking reconfiguration after new
                                 servers are connected and synced */
  ZRECONFIGINPROGRESS = -14, /*!< Reconfiguration requested while another
                                  reconfiguration is currently in progress. This
                                  is currently not supported. Please retry. */
  ZSSLCONNECTIONERROR = -15, /*!< The SSL connection Error */

  /** API errors.
   * This is never thrown by the server, it shouldn't be used other than
   * to indicate a range. Specifically error codes greater than this
   * value are API errors (while values less than this indicate a
   * {@link #ZSYSTEMERROR}).
   */
  ZAPIERROR = -100,
  ZNONODE = -101, /*!< Node does not exist */
  ZNOAUTH = -102, /*!< Not authenticated */
  ZBADVERSION = -103, /*!< Version conflict */
  ZNOCHILDRENFOREPHEMERALS = -108, /*!< Ephemeral nodes may not have children */
  ZNODEEXISTS = -110, /*!< The node already exists */
  ZNOTEMPTY = -111, /*!< The node has children */
  ZSESSIONEXPIRED = -112, /*!< The session has been expired by the server */
  ZINVALIDCALLBACK = -113, /*!< Invalid callback specified */
  ZINVALIDACL = -114, /*!< Invalid ACL specified */
  ZAUTHFAILED = -115, /*!< Client authentication failed */
  ZCLOSING = -116, /*!< ZooKeeper is closing */
  ZNOTHING = -117, /*!< (not error) no server responses to process */
  ZSESSIONMOVED = -118, /*!<session moved to another server, so operation is ignored */
  ZNOTREADONLY = -119, /*!< state-changing request is passed to read-only server */
  ZEPHEMERALONLOCALSESSION = -120, /*!< Attempt to create ephemeral node on a local session */
  ZNOWATCHER = -121, /*!< The watcher couldn't be found */
  ZRECONFIGDISABLED = -123, /*!< Attempts to perform a reconfiguration operation when reconfiguration feature is disabled */
  ZSESSIONCLOSEDREQUIRESASLAUTH = -124, /*!< The session has been closed by server because server requires client to do SASL authentication, but client is not configured with SASL authentication or configuted with SASL but failed (i.e. wrong credential used.). */
  ZTHROTTLEDOP = -127 /*!< Operation was throttled and not executed at all. please, retry! */
};

 

要注意的是,watcher是后台线程,因此对某些和主线程共享的变量,需要添加互斥锁

zookeeper的客户端必须做成事件通知机制,多线程确实是一个比较简单的方案

1.1 watcher函数原型

void watcher(zhandle_t *zzh, int type, int state, const char *path, void *watcherCtx)

1.2 Watch事件类型(type)

ZOO_CREATED_EVENT(value=1):节点创建事件,需要watch一个不存在的节点,当节点被创建时触发,此watch通过zoo_exists()设置
ZOO_DELETED_EVENT(value=2):节点删除事件,此watch通过zoo_exists()或zoo_get()设置
ZOO_CHANGED_EVENT(value=3):节点数据改变事件,此watch通过zoo_exists()或zoo_get()设置
ZOO_CHILD_EVENT(value=4):子节点列表改变事件,此watch通过zoo_get_children()或zoo_get_children2()设置
ZOO_SESSION_EVENT(value=-1):会话事件,客户端与服务端断开或重连时触发
ZOO_NOTWATCHING_EVENT(value=-2):watch移除事件,服务端出于某些原因不再为客户端watch节点时触发

1.3 watcher事件状态(state)

state=-112     会话超时状态 ZOO_EXPIRED_SESSION_STATE
state= -113 认证失败状态 ZOO_AUTH_FAILED_STATE
state= 1        连接建立中 ZOO_CONNECTING_STATE
state= 2       (暂时不清楚如何理解这个状态,ZOO_ASSOCIATING_STATE)
state= 3        连接已建立状态 ZOO_CONNECTED_STATE
state= 999    无连接状态

1.4 watcher的原理   

      在zookeeper_init中设置watcher,当zookeeper client与server会话建立后,触发watcher,当 watcher 的state = 3 (ZOO_CONNECTED_STATE), type = -1(ZOO_SESSION_EVENT)时,确认 会话成功建立,此时zookeeper client 初始化成功,可进行后续操作。

     当watcher链接不上zk后,watcher会收到一次type=-1,state=1的事件,直到watcher能连上zk后,watcher会收到一次type=ZOO_SESSION_EVENT,state=ZOO_EXPIRED_SESSION_STATE的事件,如果确认能连上,则watcher会再次收到一次type=-1(ZOO_SESSION_EVENT),state=3(ZOO_CONNECTED_STATE)的事件。

1.5 如何正确设置和获取watcher

     Watcher 设置是开发中最常见的,需要搞清楚watcher的一些基本特征,对于exists、getdata、getchild对于节点的不同操作会收到不同的 watcher信息。对父节点的变更以及孙节点的变更都不会触发watcher,而对watcher本身节点以及子节点的变更会触发watcher,具体参照下表。

操作方法触发watcherwatcher statewatcher typewatcher path
Create当前节点getdata××××
getchildren34
exists××××
set当前节点getdata33
getchildren××××
exists33
delete当前节点getdata32
getchildren32
exists32
create子节点getdata××××
getchildren34
exists××××
set子节点getdata××××
getchildren××××
exists××××
delete子节点getdata××××
getchildren34
exists××××
恢复连接getdata1-1×
getchildren1-1×
exists1-1×
恢复连接session未超时getdata-112-1×
getchildren-112-1×
exists-112-1×
恢复连接session超时getdata3-1×
getchildren3-1×
exists3-1×

注:

  1. state = 2 表示删除事件;
  2. state = 3表示节点数据变更;
  3. state =4表示子节点事件;
  4. state = -1表示session事件。 
  • type = -112表示session失效;
  • type = 1表示session建立中;
  • tpye = = 3表示session建立成功。

×表示否,√表示是。

 

另外一个表:

 

1.6 全局监视器回调函数

void zk_watcher_g(zhandle_t *zh, int type, int state, const char* path, void *watcherCtx) {
  int ret;

  const clientid_t *zk_clientid;
  /* 声明节点路径 */
  if(path == NULL || strlen(path) == 0) {
    path = "/xyz";
  }

  /* 当前event type是会话 */
  if(type == ZOO_SESSION_EVENT) {
    if(state == ZOO_EXPIRED_SESSION_STATE) {
      printf("[%s %d] zookeeper session expired\n", __FUNCTION__, __LINE__);
    }
    /* 会话状态是已连接 */
    else if(state == ZOO_CONNECTED_STATE) {
      /* 获取客户端的 session id,只有在客户端的当前连接状态有效时才可以 */
      zk_clientid = zoo_client_id(zh);
      
      /* 调用异步api */
      ret = zoo_aexists(zh, path, TRUE, zk_stat_completion, path);
      
      ret = zoo_aget(zh, path, TRUE, zk_data_completion, "get param");
      
      ret = zoo_aget_children2(zh, path, TRUE, zk_strings_stat_completion, "get children param");
    }
  }
  /* 当前event type是节点创建事件 */
  else if (type == ZOO_CREATED_EVENT) {
    ...
  }
  /* 当前event type是节点删除事件 */
  else if (type == ZOO_DELETED_EVENT) {
    ...
  }
  /* 当前event type是节点数据改变事件 */
  else if (type == ZOO_CHANGED_EVENT) {
    ...
  }
  /* 当前event type是子节点事件 */
  else if (type == ZOO_CHILD_EVENT) {
    ...
  }
}

 

2、问题与解决

2.1 watcher监听不到zk后续数据的解决方法

     根据watcher的原理得知,如果watcher检测不到zk的数据变化时,肯定是因为watcher连接失败后, 没有重新注册watcher,解决方法很简单,也就是当type=ZOO_SESSION_EVENT,同时state=ZOO_EXPIRED_SESSION_STATE时,重新注册下watcher即可。

2.2 关于zookeeper_init函数的使用

问题描述:

开发人员在调用zookeeper_init函数时,若返回一个非空句柄zhandle_t  *zh,则认为初始化成功,这样可能会导致后续操作失败。

问题分析:

zhandle_t  *zookeeper_init(const char *host, watcher_fn fn, int recv_timeout,const   clientid_t *clientid, void *context, int flags) 函 数     返回一个zookeeper客户端与服务器通信的句柄,通常我们仅仅根据返回句柄情况来判断zookeeper 客户端与zookeeper服务器是否 建立连接。如果句柄为空则认为是失败,非空则成功。其实不然,zookeeper_init创建与ZooKeeper服务端通信的句柄以及对应于此句柄的会话,而会话的创建是一个异步的过程,仅当会话建立成功,zookeeper_init才返回一个可用句柄。

问题解决:

如何正确判断zookeepr_init初始化成功,可通过以下三种方式
1、判断句柄的state是否为ZOO_CONNECTED_STATE状态,通过zoo_state(zh)判断状态值是否为ZOO_CONNECTED_STATE。

void ensureConnected()
{
    pthread_mutex_lock(&lock);
    while (zoo_state(zh)!=ZOO_CONNECTED_STATE)
    {
        pthread_cond_wait(&cond, &lock);
    }
    pthread_mutex_unlock(&lock);
}


2、 在zookeeper_init中设置watcher,当zookeeper client与server会话建立后,触发watcher,当 watcher 的state = 3 (ZOO_CONNECTED_STATE), type = -1(ZOO_SESSION_EVENT)时,确认 会话成功建立,此时zookeeper client 初始化成功,可进行后续操作。

3、业务上可以做保证,调用zookeeper_init返回句柄zh,通过该句柄尝试做zoo_exists()或zoo_get_data()等操作,根据操作结果来判断是否初始化成功。

2.3 如何解决session失效问题

问题描述:

session失效,导致注册的watcher全部丢失。

问题分析:

如果zookeeper client与server在协商的超时时间内仍没有建立连接,当client与server再次建立连接时,由于session失效了,所有watcher已经被服务器端删除,从而导致所有的watcher需要重新注册。
session 失效,zookeeper client与server重连后所有watcher都会收到两次触发,第一次 wathetr state = 1,type = -1(state = 1表示正在连接中,type = -1 表示session事件);第二次 watcher state = -112,type = -1(state = -112表示session失效)。

问题解决:

可以通过以下两种方法解决session失效问题
1、获取触发session失效watcher后,业务重新注册所有的watcher。
2、不能根本解决,但是可以减小session失效的概率。通过zookeeper client 与server设置更长的session超时时间。(参考下一问题)

2.4 为什么zookeeper_init设置recv_timeout较长却没有效果

问题描述:

zookeeper_init设置recv_timeout 100000ms,但客户端与服务端断开连接30s就session失效了。

问题分析:

关于session超时时间的确定:zookeeper_init中设置的超时时间并非真正的session超时时间,session超时时间需要 server与client协商,业务通过zoo_recv_timeout(zhandle_t* zh)获取server与client协商后的超时时间。服务端: minSessionTimeout (默认值为:tickTime * 2) ,  maxSessionTimeout(默认值为:tickTime * 20), ticktime的默认值为2000ms。所以session范围为4s ~ 40s 。客户端:  sessionTimeout, 无默认值,创建实例时设置recv_timeout 值。经常会认为创建zookeeper客户端时设置了sessionTimeout为100s,而没有改变server端的配置,默认值是 不会生效的。 原因: 客户端的zookeeper实例在创建连接时,将sessionTimeout参数发送给了服务端,服务端会根据对应的  minSession/maxSession Timeout的设置,强制修改sessionTimeout参数,也就是修改为4s~40s 返回的参数。所以服务端不一定会以客户端的sessionTImeout做为session expire管理的时间。

问题解决:

增加zookeeper_init recv_timeout大小的同时,需要配置tickTime的值。
tickTime设置是在 conf/zoo.cfg 文件中

# The number of milliseconds of each
ticktickTime=2000  (默认)
注: tickTime 心跳基本时间单位毫秒,ZK基本上所有的时间都是这个时间的整数倍。

2.5 zoo_get_children内存泄露问题

问题描述:

调用zoo_get_children函数出现内存泄露问题。

问题分析:

通过查看代码发现问题在于 ZOOAPI int zoo_get_children(zhandle_t *zh, const char *path, int watch,struct String_vector *strings), 该API中String_vector *strings结构体定义如下:

struct String_vector
{
    int32_t count;
    char * *data;
};

Zookeeper API将getchildren结果通过String_vector结构体返回时,malloc分配内存,将子节点所有目录存放在data中,而释放内存需要有客户端来做处理。

问题解决:

调用zoo_get_children(zh, path, watch, strings);需要通过调用zookeper提供的释放内存的方法:deallocate_String_vector(strings)。

int deallocate_String_vector(struct String_vector *v){
    if (v->data)
    {
        int32_t i;
        for(i = 0; i < v->count; i++)
        {
            deallocate_String(&v->data[i]);
        }
        free(v->data);
        v->data = 0;
    }
    return 0;
}

2.6  zookeeper连接数问题

问题描述:

zookeeper服务器都运行正常,而客户端连接异常。

问题分析:

这是由于zookeeper client连接数已经超过了zookeeper server获取的配置最大连接数。所以导致zookeeper client连接失败。

解决方法:

修改zookeeper安装目录下 conf/zoo.cfg文件。将maxClientCnxns参数改成更大的值。

 

3、zookeeper服务器安装相关问题

问题一:是否可以只装一个zookeeper服务器。

问题解答:

可以安装,此时没有leader,follow,此时zookeeper server状态为standalone。

./zkServer.sh status
JMX enabled by default
Using config: /home/bbs/zookeeper-3.4.5/bin/../conf/zoo.cfg
Mode: standalone

关于zoo.cfg配置:

tickTime=2000initLimit=10
syncLimit=5
dataDir=../data
clientPort=4181

 问题二:开发没有足够机器,一台机子上是否装三个zookeeper服务器集群。

问题解答:

这种安装模式只能说是一种伪集群模式。三个zookeeper服务器都安装在同一个服务器(platform)上,需保证clientPort不相同。
将zookeeper安装包分别解压在三个目录server1,server2,server3下,配置文件zoo.cfg
Server1配置文件 zoo.cfg,server1在data目录下增加文件myid内容为1。

dataDir=../datadata
LogDir=../dataLog
clientPort=5181
server.1=platform:5888:6888
server.2= platform:5889:6889
server.3= platform:5890:6890

Server2配置文件 zoo.cfg,server1在data目录下增加文件myid内容为2。

dataDir=../datadata
LogDir=../dataLog
clientPort=6181
server.1=platform:5888:6888
server.2= platform:5889:6889
server.3= platform:5890:6890

Server3配置文件 zoo.cfg,server1在data目录下增加文件myid内容为3。

dataDir=../datadata
LogDir=../dataLog
clientPort=7181
server.1=platform:5888:6888
server.2= platform:5889:6889
server.3= platform:5890:6890

 

4、一个小小的例子

       那就看一下ZooKeeper最常用的场景——服务的发布和发现作用。

       在这里,我们模拟一个S端创建并且监听某一个路径;然后任意数目的C端启动后,会自动在前面约定的路径下建立(顺序、临时)节点,同时将自己服务提供IP:Port地址信息保存在数据域里面,形成一个服务发布的效果;服务端读取并设置该路径的watch event,遍历所有的C端,读取他们的数据域并打印出客户端的地址信息。通过这种方式,C端可以启动任意多个实例,而且不用硬编码约定的服务端口,双方唯一需要事先约定的就是zookeeper的znode路径就足够了。

4.1 初始化连接

       初始化连接就是创建zhandle_t的过程,因为这是一个异步过程,同时后续所有的操作都需要基于这个zhandle_t作为参数,所以初始化的过程是在一个while中不断检测进行的。

       下面的回调函数watcher不仅仅在创建的时候使用,整个过程中会话的状态发生改变,这个回调函数都会被自动调用,我们需要根据对应的state做出相应的处理。还有就是,各种不同的时间可以共用同一个回调函数,他们通过type参数进行区分。

int init(){
    handle = zookeeper_init("127.0.0.1:2181", watcher, 15000, NULL, 0, 0);
    if(!handle) return -1;
    
    while(connected != 1) { ::sleep(1); }
}

void watcher(zhandle_t*zkh,inttype,intstate,const char*path,void* context){
    if (type == ZOO_SESSION_EVENT) {
        if(state == ZOO_CONNECTED_STATE) {
            connected = 1;
        } else if(state == ZOO_CONNECTING_STATE) {
            if(connected == 1)
                std::cout << __func__ << ": disconnected..." << std::endl;
            connected = 0;
        }else if(state == ZOO_EXPIRED_SESSION_STATE) {
            expired = 1;
            connected = 0;
            zookeeper_close(zkh);
        } else {
            connected = 0;
            std::cout << __func__ << ": unknown state:" << state << std::endl;
        }
    } else if (type == ZOO_CHILD_EVENT) {
        if(strcmp(path, srv_path) != 0)
            std::cout << __func__ << ": error path info:" << path << std::endl;
        
        std::cout << __func__ << ": children event detected!" << std::endl;
        show_info();  // 注意,需要再次安插watch event
    }
}

       上面需要额外注意的就是ZOO_CHILD_EVENT,在其事件响应的最后通过调用show_info(),实际是重新安装了节点的watch event。

  1. 我们知道,ZooKeeper的watch event是one-shot的,如果事件被激活,则需要手动再次设置watch event,才能继续得到后续的事件通知。
  2. 同时,我们知道在某些情况下ZooKeeper的event是可能被丢失掉的,所以在实际使用的时候,还需要设置一个定时器,周期性的对节点进行扫描并安插watch event才比较稳妥。

4.2 创建服务路径节点

      然后就是服务端创建事先约定的路径节点。这是一个普通(持久、非序列)的znode,回调函数中rc状态码为ZOK表示创建成功,出错则可以不断重新尝试创建操作。

void create_path(const char* path,const char* value){
    zoo_acreate(handle, path, value, strlen(value), &ZOO_OPEN_ACL_UNSAFE, 0, create_path_callback, NULL);
}

void create_path_callback(intrc,const char*value,const void*data){
    switch (rc) {
        case ZCONNECTIONLOSS:
            create_path(value, (const char *) data);
            break;
    
        case ZOK:
            std::cout << __func__ << ": created node:" << value << std::endl;
            break;
    
        case ZNODEEXISTS:
            std::cout << __func__ << ": node already exists" << std::endl;
            break;
    
        default:
            std::cout << __func__  << ": something went wrong..." << rc << std::endl;
            break;
    }
}

2.3 发布服务

       下面是重头戏了,我们这里需要实现的效果就是服务的自动发布(而不需要事先约定路径、端口号等各种信息),客户端绑定本地的任意端口号,然后通过getsockname得到实际绑定的地址信息,并后续将其发布到自己创建的临时节点的数据项里面。

       相比于传统硬编码约定端口号的方式,可以避免SO_REUSEADDR类似的问题,而且可以多实例部署,简单方便。

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = 0;  /* bind() will choose a random port*/

bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr));
//listen(sockfd,5);

socklen_t len = sizeof(serv_addr);
getsockname(sockfd, (struct sockaddr *)&serv_addr, &len);
char buffer[100];
inet_ntop(AF_INET, &serv_addr.sin_addr, buffer, sizeof(buffer));

char msg[1024] = {0,};
sprintf(msg, "%s:%d", buffer, serv_addr.sin_port);

create_eph_seq_path( (std::string(srv_path) + "/srv_provider_").c_str(), msg);

       下面的操作是在指定的目录下面完成建立临时、序列节点,其实跟上面持久节点创建除了参数不同之外,都是一样的操作流程。这里节点的数据项,是上面获取的IP:Port的地址信息。

void create_eph_seq_path(const char* path,const char* value){
    zoo_acreate(handle, path, value, strlen(value), &ZOO_OPEN_ACL_UNSAFE, ZOO_EPHEMERAL|ZOO_SEQUENCE, create_eph_seq_path_callback, NULL);
}

void create_eph_seq_path_callback(intrc,const char*value,const void*data){
    switch (rc) {
        case ZCONNECTIONLOSS:
            create_eph_seq_path(value, (const char *) data);
            break;
    
        case ZOK:
            std::cout << __func__ << ": created eph_sql node:" << value << std::endl;
            break;
    
        case ZNODEEXISTS:
            std::cout << __func__ << ": node already exists" << std::endl;
            break;
    
        default:
            std::cout << __func__ << ": something went wrong..." << rc << std::endl;
            break;
    }
}

4.4 服务获取

       这里是服务发现的部分。其操作就是从指定目录下面获取其所有的子节点,对于每一个存在的节点都是一个活着的service provider,可以从其数据域中提取其地址信息,然后进行实际的服务调用,业务相关的东西就不演示了。

void show_info(){
    zoo_awget_children(handle, srv_path, watcher, NULL, info_get_callback, NULL);
}

void info_get_callback(intrc,const structString_vector *strings,const void*data){
    switch(rc) {
        case ZCONNECTIONLOSS:
        case ZOPERATIONTIMEOUT:
            show_info();
            break;
        
        case ZOK:
            for( int i=0; i<strings->count; i++ ) {
                std::cout << "=>" << strings->data[i] << std::endl;
                char buff[128] = {0,};
                int len = sizeof(buff);
                zoo_get(handle, (std::string(srv_path) + "/" + strings->data[i]).c_str(), 0, buff, &len, NULL);
                std::cout << buff << std::endl;
            }
            break;
            
        default:
            std::cout << __func__ << ": something went wrong..." << rc << std::endl;
            break; 
    }
}

      在使用的过程中,发现增加服务的时候,ZOO_CHILD_EVENT很快就得到响应了,但是删除服务的时候,这个事件总是会延迟几秒钟才得到响应。究竟是因为ZooKeeper本身就这个德行,还是我的姿势不对?因为事件感知的越迟,对线上的业务影响就会越严重。Anyway,反正按照上面的接口和模式,算是说搭建一个分布式系统的服务还是挺简单的吧!

      例子的代码在server.cpp和client.cpp,编译后直接链接zookeeper_mt就可以运行测试了。

 

https://www.rubydoc.info/github/zk-ruby/zookeeper/Zookeeper/Constants

https://blog.flowlore.com/passages/c-feng-zhuang-zookeeper-c-api-trashed-2/

http://shengofsun.github.io/zookeeper/

https://www.ctolib.com/topics-124157.html

https://blog.csdn.net/emeitu/article/details/51568548

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐