『Naocs 2x』(八) Nacos Config 配置

前言

Nacos 2.x 中抛弃了长轮询模式,改用长连接进行配置同步。

这次就来探探 Nacos 配置中心是如何与Spring Boot 同步变更的。

前置知识

我们在 《『Naocs 2.x』(三) Nacos 服务注册逻辑及通信过程 》的 GRPC调用过程一小节中,分析过 Nacos Server 与 Nacos Client 的请求与响应过程。

简单地回顾一下,就是根据 Request的具体类型不同, Nacoe Server 获取到对应的RequestHandler,进行业务处理,最后把处理结果封装为Response返回。

我们来看一下 Nacos Cofnig 中 Request 的层级结构 ( 只保留了与此节强相关的子类 ) :

image-20211113171317751

  • ConfigBatchListenRequest

Nacos Client 向 Nacos Service ,请求监听一批配置。

  • ConfigQueryRequest

Nacos Client 向 Nacos Service,查询配置内容。

ps: 截图少了这个,是AbstractConfigRequest的子类。

  • ConfigChangeNotifyRequest

Nacos Server 向 Nacos Client ,推送变更配置内容的 Key。

同步配置初始化流程

NacosConfigManager

我们从 NacosConfigManager 说起,一看名字就知道,这个类绝逼持有某些重要的东西。

NacosConfigAutoConfiguration配置类中:

1
2
3
4
typescript复制代码@Bean
public NacosConfigManager nacosConfigManager( NacosConfigProperties nacosConfigProperties) {
   return new NacosConfigManager(nacosConfigProperties);
}

下面进入 NacosConfigManager 中:

NacosConfigManager 持有:ConfigService(配置相关操作)、NacosConfigProperties(Spring Boot 对配置中心的配置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码public class NacosConfigManager {
   private static ConfigService service = null;
   private NacosConfigProperties nacosConfigProperties;

   public NacosConfigManager(NacosConfigProperties nacosConfigProperties) {
       this.nacosConfigProperties = nacosConfigProperties;
       createConfigService(nacosConfigProperties);
  }

   static ConfigService createConfigService(
           NacosConfigProperties nacosConfigProperties) {
       if (Objects.isNull(service)) {
           // 加锁防止创建了多个NacosConfigManager
           // 可能是为了防止使用者手动创建此类
           synchronized (NacosConfigManager.class) {
               try {
                   if (Objects.isNull(service)) {
                       // 这里是通过反射构造函数创建了 NacosService 的子类
                       // NacosConfigService(Properties properties)
                       service = NacosFactory.createConfigService(
                               nacosConfigProperties.assembleConfigServiceProperties());
                  }
              }
               // …………
          }
      }
       return service;
  }
   // …………
}

NacosConfigService 构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scss复制代码// NacosConfigService # NacosConfigService(Properties properties)
public NacosConfigService(Properties properties) throws NacosException {
   ValidatorUtils.checkInitParam(properties);
   // 初始化 命名空间,放到 properties 中。
   initNamespace(properties);
   // 设置请求过滤器
   this.configFilterChainManager = new ConfigFilterChainManager(properties);
   // 设置服务器名称列表的线程任务
   ServerListManager serverListManager = new ServerListManager(properties);
   serverListManager.start();
   // 重头戏,创建ClientWorker
   this.worker = new ClientWorker(this.configFilterChainManager, serverListManager, properties);
   // will be deleted in 2.0 later versions
   agent = new ServerHttpAgent(serverListManager);

}

ClientWorker 构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码public ClientWorker(final ConfigFilterChainManager configFilterChainManager, ServerListManager serverListManager,final Properties properties) throws NacosException {

   this.configFilterChainManager = configFilterChainManager;
   init(properties);
   // 创建 Grpc 请求类
   agent = new ConfigRpcTransportClient(properties, serverListManager);
   // (重要)设置线程任务。该线程任务用于同步配置。
   ScheduledExecutorService executorService = Executors
          .newScheduledThreadPool(ThreadUtils.getSuitableThreadCount(1), r -> {
               Thread t = new Thread(r);
               t.setName("com.alibaba.nacos.client.Worker");
               t.setDaemon(true);
               return t;
          });
   agent.setExecutor(executorService);
   agent.start();

}

ConfigRpcTransportClient

ConfigRpcTransportClient 的父类为ConfigTransportClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码// ConfigTransportClient
public void start() throws NacosException {
   // .......

   // 执行内部任务
   startInternal();
}

// ConfigRpcTransportClient
// 这个方式启动一线程任务,通过 wthile(true) 方式一直循环。
@Override
public void startInternal() throws NacosException {
   executor.schedule(new Runnable() {
       @Override
       public void run() {
           while (true) {
               try {
                   listenExecutebell.poll(5L, TimeUnit.SECONDS);
                   executeConfigListen();
              }
               // …………
          }
      }
  }, 0L, TimeUnit.MILLISECONDS);
}

@Override
public void notifyListenConfig() {
   listenExecutebell.offer(bellItem);
}
  • listenExecutebell.poll(5L, TimeUnit.SECONDS);

获取队列头部元素,如果获取不到则等待5s。Nacos 通过这种方式来控制循环间隔。

这里需要特别注意,Nacos 通过调用notifyListenConfig()向 listenExecutebell 设置元素的方式,来立即执行executeConfigListen()方法。

notifyListenConfig() 方法我们在后面还会见到。

到此处同步配置的初始化流程就完成了。我们继续看同步配置的过程。

客户端同步配置

同步配置的逻辑,主要在executeConfigListen();方法中,这段方法比较长。我们分开来看。

CacheData执行判断与分组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
arduino复制代码// 5 minutes to check all listen cache keys.
// 5 分钟执行一次全量同步。
private static final long ALL_SYNC_INTERNAL = 5 * 60 * 1000L;

// 准备两个组:有监听组和无监听组
Map<String, List<CacheData>> listenCachesMap = new HashMap<String, List<CacheData>>(16);
Map<String, List<CacheData>> removeListenCachesMap = new HashMap<String, List<CacheData>>(16);
// 判断是否到全量同步时间
long now = System.currentTimeMillis();
boolean needAllSync = now - lastAllSyncTime >= ALL_SYNC_INTERNAL;

// 遍历本地 CacheDataMap。CacheData 保存了配置基本信息,配置的监听器等基础信息。
for (CacheData cache : cacheMap.get().values()) {
   synchronized (cache) {
       //check local listeners consistent.
       // 首先判断,该 cacheData 是否需要检查。也就是如果为 false,必定进行检查。
       // 1.添加listener.default为false;需要检查。
       // 2.接收配置更改通知,设置为false;需要检查。
       // 3.last listener被移除,设置为false;需要检查
       if (cache.isSyncWithServer()) {
           // 执行 CacheData.Md5 与 Listener.md5的比对与设定
           // 如果不相同,则进行监听器的回调。
           cache.checkListenerMd5();
           // 如果还不需要全量同步,就跳过这个 cacheData.
           if (!needAllSync) {
               continue;
          }
      }

       if (!CollectionUtils.isEmpty(cache.getListeners())) {
           // 有监听器的放入 listenCachesMap
      } else if (CollectionUtils.isEmpty(cache.getListeners())) {
           // 没有监听器的放入 removeListenCachesMap
  }
}

处理有监听器的 CacheData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
scss复制代码// 标志是否有更改的配置
boolean hasChangedKeys = false;
for (Map.Entry<String, List<CacheData>> entry : listenCachesMap.entrySet()) {
   String taskId = entry.getKey();
   List<CacheData> listenCaches = entry.getValue();
   // 构建监听器请求
   ConfigBatchListenRequest configChangeListenRequest = buildConfigRequest(listenCaches);
   configChangeListenRequest.setListen(true);
   try {
       // 初始化RpcClient
       RpcClient rpcClient = ensureRpcClient(taskId);
       // 发送请求向 Nacos Server 添加配置变化监听器。
       // 服务端将返回有变化的dataId,group,tenant
       ConfigChangeBatchListenResponse configChangeBatchListenResponse = (ConfigChangeBatchListenResponse) requestProxy(
               rpcClient, configChangeListenRequest);
       if (configChangeBatchListenResponse != null && configChangeBatchListenResponse.isSuccess()) {
           Set<String> changeKeys = new HashSet<String>();
           // 处理有变化的配置
           if (!CollectionUtils.isEmpty(configChangeBatchListenResponse.getChangedConfigs())) {
               hasChangedKeys = true;
               for (ConfigChangeBatchListenResponse.ConfigContext changeConfig : configChangeBatchListenResponse
                      .getChangedConfigs()) {
                   String changeKey = GroupKey
                          .getKeyTenant(changeConfig.getDataId(), changeConfig.getGroup(),
                                   changeConfig.getTenant());
                   changeKeys.add(changeKey);
                   boolean isInitializing = cacheMap.get().get(changeKey).isInitializing();
                   // 刷新上下文
                   // 此处将请求 Nacos Server ,获取最新配置内容,并触发 Listener 的回调。
                   refreshContentAndCheck(changeKey, !isInitializing);
              }

          }

           //handler content configs
           for (CacheData cacheData : listenCaches) {
               String groupKey = GroupKey
                      .getKeyTenant(cacheData.dataId, cacheData.group, cacheData.getTenant());
               // 如果返回的 changeKeys 中,未包含此 groupKey。则说明此内容未发生变化。
               if (!changeKeys.contains(groupKey)) {
                   //sync:cache data md5 = server md5 && cache data md5 = all listeners md5.
                   synchronized (cacheData) {
                       if (!cacheData.getListeners().isEmpty()) {
                           // 则将同步标志设为 true
                           cacheData.setSyncWithServer(true);
                           continue;
                      }
                  }
              }
               // 将初始化状态设置 false
               cacheData.setInitializing(false);
          }

      }
  } catch (Exception e) {}
}

处理无监听器的 CacheData

无监听器的 CacheData 就是,从 Nacos Client 与 Nacos Server 中移除掉原有的监听器。

结尾处理

1
2
3
4
5
6
7
8
scss复制代码if (needAllSync) {
   lastAllSyncTime = now;
}
// If has changed keys,notify re sync md5.
// 如果有改变的配置,则立即进行一次同步配置过程。
if (hasChangedKeys) {
   notifyListenConfig();
}

客户端接收服务端推送

当 Nacos Config 配置发生变更时,Nacos Server 会主动通知 Nacos Client。

Nacos Client 在向 Nacos Server 发送请求前,会初始化 Nacos Rpc Client,执行的方法是ConfigRpcTransportClient # ensureRpcClient(String taskId)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
scss复制代码private RpcClient ensureRpcClient(String taskId) throws NacosException {
   synchronized (ClientWorker.this) {
       Map<String, String> labels = getLabels();
       Map<String, String> newLabels = new HashMap<String, String>(labels);
       newLabels.put("taskId", taskId);
       RpcClient rpcClient = RpcClientFactory
              .createClient(uuid + "_config-" + taskId, getConnectionType(), newLabels);
       if (rpcClient.isWaitInitiated()) {
           // 初始化处理器,在处理初始化了对 ConfigChangeNotifyRequest 的处理逻辑。
           initRpcClientHandler(rpcClient);
           rpcClient.setTenant(getTenant());
           rpcClient.clientAbilities(initAbilities());
           rpcClient.start();
      }
       return rpcClient;
  }
}

// ConfigRpcTransportClient # initRpcClientHandler
// 初始化ConfigChangeNotifyRequest处理逻辑如下
rpcClientInner.registerServerRequestHandler((request) -> {
   if (request instanceof ConfigChangeNotifyRequest) {
       ConfigChangeNotifyRequest configChangeNotifyRequest = (ConfigChangeNotifyRequest) request;
       // ......
       String groupKey = GroupKey
              .getKeyTenant(configChangeNotifyRequest.getDataId(), configChangeNotifyRequest.getGroup(),
                       configChangeNotifyRequest.getTenant());

       // 获取 CacheData
       CacheData cacheData = cacheMap.get().get(groupKey);
       if (cacheData != null) {
           synchronized (cacheData) {
               // 设置服务器同步标志
               cacheData.getLastModifiedTs().set(System.currentTimeMillis());
               cacheData.setSyncWithServer(false);
               // 立即触发该CacheData的同步配置操作
               notifyListenConfig();
          }

      }
       return new ConfigChangeNotifyResponse();
  }
   return null;
});

服务端变更通知

入口

配置变更,是在 Nacos Service 的 Web 页面进行操作的,调用POST /v1/cs/configs接口。

该接口主要逻辑:

  • 更新配置内容
  • 发送配置变更事件
1
2
3
sql复制代码persistService.insertOrUpdateTag(configInfo, tag, srcIp, srcUser, time, false);
ConfigChangePublisher.notifyConfigChange(
       new ConfigDataChangeEvent(false, dataId, group, tenant, tag, time.getTime()));

ConfigDataChangeEvent 监听器

AsyncNotifyService 在初始化时,向事件通知中心添加了监听器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typescript复制代码NotifyCenter.registerSubscriber(new Subscriber() {

   @Override
   public void onEvent(Event event) {
       // Generate ConfigDataChangeEvent concurrently
       if (event instanceof ConfigDataChangeEvent) {
           ConfigDataChangeEvent evt = (ConfigDataChangeEvent) event;
           // ......
           // In fact, any type of queue here can be
           Queue<NotifySingleRpcTask> rpcQueue = new LinkedList<NotifySingleRpcTask>();
           // ....省略代码:把参数包装为 NotifySingleRpcTask 添加到 rpcQueue
           // 把 rpcQueue 包装为 AsyncRpcTask
           if (!rpcQueue.isEmpty()) {
               ConfigExecutor.executeAsyncNotify(new AsyncRpcTask(rpcQueue));
          }

      }
  }

   @Override
   public Class<? extends Event> subscribeType() {
       return ConfigDataChangeEvent.class;
  }
});

AsyncRpcTask异步任务

AsyncRpcTask #run()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scss复制代码@Override
public void run() {
   while (!queue.isEmpty()) {
       NotifySingleRpcTask task = queue.poll();
       ConfigChangeClusterSyncRequest syncRequest = new ConfigChangeClusterSyncRequest();
       // ... 代码省略:组装 syncRequest 参数。
       if (memberManager.getSelf().equals(member)) {
           if (syncRequest.isBeta()) {
               dumpService.dump(syncRequest.getDataId(), syncRequest.getGroup(), syncRequest.getTenant(),
                       syncRequest.getLastModified(), NetUtils.localIP(), true);
          } else {
               // EmbeddedDumpService.dump()
               dumpService.dump(syncRequest.getDataId(), syncRequest.getGroup(), syncRequest.getTenant(),
                       syncRequest.getTag(), syncRequest.getLastModified(), NetUtils.localIP());
          }
           continue;
      }
     // 以下为 nacos 集群通知,暂时忽略
     // ...
  }
}

接下来继续看 dumpService.dump()

1
2
3
4
5
6
7
8
vbnet复制代码// 这里只做了一件事,就是提交异步任务 DumpTask
public void dump(String dataId, String group, String tenant, String tag, long lastModified, String handleIp,
           boolean isBeta) {
   String groupKey = GroupKey2.getKey(dataId, group, tenant);
   String taskKey = String.join("+", dataId, group, tenant, String.valueOf(isBeta), tag);
   dumpTaskMgr.addTask(taskKey, new DumpTask(groupKey, tag, lastModified, handleIp, isBeta));
   DUMP_LOG.info("[dump-task] add task. groupKey={}, taskKey={}", groupKey, taskKey);
}

DumpTask异步任务

该异步任务由 TaskManager执行,其在EmbeddedDumpService初始化时,被创建。

实际由TaskManager的父类NacosDelayTaskExecuteEngine执行processTasks()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
scss复制代码protected void processTasks() {
       Collection<Object> keys = getAllTaskKeys();
       for (Object taskKey : keys) {
           AbstractDelayTask task = removeTask(taskKey);
           // ....
           NacosTaskProcessor processor = getProcessor(taskKey);
           // ....
           try {
               // ReAdd task if process failed
               if (!processor.process(task)) {
                   retryFailedTask(taskKey, task);
              }
          } catch (Throwable e) {
               getEngineLog().error("Nacos task execute error : " + e.toString(), e);
               retryFailedTask(taskKey, task);
          }
      }
  }

实际上就是根据 taskKey 取到对应的NacosTaskProcessor执行process()方法。

此处 DumpTask 对应的是 DumpProcessor

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码public boolean process(NacosTask task) {
   final PersistService persistService = dumpService.getPersistService();
   DumpTask dumpTask = (DumpTask) task;
   // ... 省略代码:对 dumpTask 参数赋值

   // 构建 ConfigDumpEvent 事件
   ConfigDumpEvent.ConfigDumpEventBuilder build = ConfigDumpEvent.builder().namespaceId(tenant).dataId(dataId)
          .group(group).isBeta(isBeta).tag(tag).lastModifiedTs(lastModified).handleIp(handleIp);

   // ... 省略代码:对 build 参数赋值
   return DumpConfigHandler.configDump(build.build());
}

继续进入DumpConfigHandler.configDump(build.build())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
arduino复制代码public static boolean configDump(ConfigDumpEvent event) {
   final String dataId = event.getDataId();
   final String group = event.getGroup();
   final String namespaceId = event.getNamespaceId();
   final String content = event.getContent();
   final String type = event.getType();
   final long lastModified = event.getLastModifiedTs();
   // .... 省略代码
   if (StringUtils.isBlank(event.getTag())) {
       // ... 省略代码
       boolean result;
       if (!event.isRemove()) {
           // 保存配置文件并更新缓存中的 md5 值
           result = ConfigCacheService.dump(dataId, group, namespaceId, content, lastModified, type);
           // ...
      } // ... 省略 else
       return result;
  }
}

继续进入ConfigCacheService.dump():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typescript复制代码public static boolean dump(String dataId, String group, String tenant, String content, long lastModifiedTs,
       String type) {
   // ...
   try {
       final String md5 = MD5Utils.md5Hex(content, Constants.ENCODE);

       if (md5.equals(ConfigCacheService.getContentMd5(groupKey))) {
           DUMP_LOG.warn("[dump-ignore] ignore to save cache file. groupKey={}, md5={}, lastModifiedOld={}, "
                           + "lastModifiedNew={}", groupKey, md5, ConfigCacheService.getLastModifiedTs(groupKey),
                   lastModifiedTs);
      } else if (!PropertyUtil.isDirectRead()) {
           DiskUtil.saveToDisk(dataId, group, tenant, content);
      }
       updateMd5(groupKey, md5, lastModifiedTs);
       return true;
  } // ...

public static void updateMd5(String groupKey, String md5, long lastModifiedTs) {
   CacheItem cache = makeSure(groupKey);
   if (cache.md5 == null || !cache.md5.equals(md5)) {
       cache.md5 = md5;
       cache.lastModifiedTs = lastModifiedTs;
       // 发布 LocalDataChangeEvent 事件
       NotifyCenter.publishEvent(new LocalDataChangeEvent(groupKey));
  }
}

LocalDataChangeEvent 监听器

RpcConfigChangeNotifier 是 LocalDataChangeEvent 的监听器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
ini复制代码@Override
public void onEvent(LocalDataChangeEvent event) {
   String groupKey = event.groupKey;
   boolean isBeta = event.isBeta;
   List<String> betaIps = event.betaIps;
   String[] strings = GroupKey.parseKey(groupKey);
   String dataId = strings[0];
   String group = strings[1];
   String tenant = strings.length > 2 ? strings[2] : "";
   String tag = event.tag;
   configDataChanged(groupKey, dataId, group, tenant, isBeta, betaIps, tag);
}

public void configDataChanged(String groupKey, String dataId, String group, String tenant, boolean isBeta,
       List<String> betaIps, String tag) {
   // 获取变更配置对应的客户端
   Set<String> listeners = configChangeListenContext.getListeners(groupKey);
   // ....
   int notifyClientCount = 0;
   for (final String client : listeners) {
       // 根据客户端获取连接
       Connection connection = connectionManager.getConnection(client);
       // ...
       // 构造请求
       ConfigChangeNotifyRequest notifyRequest = ConfigChangeNotifyRequest.build(dataId, group, tenant);
       // 构造任务
       RpcPushTask rpcPushRetryTask = new RpcPushTask(notifyRequest, 50, client, clientIp,
               connection.getMetaInfo().getAppName());
       // 发送请求
       push(rpcPushRetryTask);
       notifyClientCount++;
  }
   Loggers.REMOTE_PUSH.info("push [{}] clients ,groupKey=[{}]", notifyClientCount, groupKey);
}

private void push(RpcPushTask retryTask) {
   ConfigChangeNotifyRequest notifyRequest = retryTask.notifyRequest;
   if (retryTask.isOverTimes()) {
       // 请求超时,移除该连接
       connectionManager.unregister(retryTask.connectionId);
  } else if (connectionManager.getConnection(retryTask.connectionId) != null) {
       // first time :delay 0s; sencond time:delay 2s ;third time :delay 4s
       // 重试机制
       ConfigExecutor.getClientConfigNotifierServiceExecutor()
              .schedule(retryTask, retryTask.tryTimes * 2, TimeUnit.SECONDS);
  } else {
       // client is already offline,ingnore task.
  }
}

发送请求

发送请求的逻辑在RpcPushTask # run()中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typescript复制代码public void run() {
   tryTimes++;
   if (!tpsMonitorManager.applyTpsForClientIp(POINT_CONFIG_PUSH, connectionId, clientIp)) {
       // 如果 tps 受限,自旋等待 tps 控制放开。
       push(this);
  } else {
       // 发送请求
       rpcPushService.pushWithCallback(connectionId, notifyRequest, new AbstractPushCallBack(3000L) {
           @Override
           public void onSuccess() {
               tpsMonitorManager.applyTpsForClientIp(POINT_CONFIG_PUSH_SUCCESS, connectionId, clientIp);
          }

           @Override
           public void onFail(Throwable e) {
               tpsMonitorManager.applyTpsForClientIp(POINT_CONFIG_PUSH_FAIL, connectionId, clientIp);
               Loggers.REMOTE_PUSH.warn("Push fail", e);
               push(RpcPushTask.this);
          }

      }, ConfigExecutor.getClientConfigNotifierServiceExecutor());
  }
}

小结

Nacos 2.x 中弃用了 长轮询 模式,采用 长连接 模式。

  • Nacos Config Client 每 5 分钟进行一次全量比对。
  • Nacos Config Server 有配置发生变化时,发布LocalDataChangeEvent,监听器监听到该事件,即开始向 Nacos Config Client 发送 ConfigChangeNotifyRequest。Nacos Config Client 感到到有配置发生变化,向 Nacos Config Server 发送 ConfigQueryRequest 请求最新配置内容。

Nacos 中大量使用了异步任务与事件机制,初次来看理解有点难度。这篇笔记内容前前后后花费了好几天时间,真让人头疼。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%