首頁>技術>

配置檔案想必大家都很熟悉,無論什麼架構 都離不開配置,雖然spring boot已經大大簡化了配置,但如果服務很多 環境也好幾個,管理配置起來還是很麻煩,並且每次改完配置都需要重啟服務,nacos config出現就解決了這些問題,它把配置統一放到服務進行管理,客戶端這邊進行有需要的獲取,可以實時對配置進行修改和釋出

如何使用nacos config

首先需要引入nacos config jar包

<dependency>    <groupId>com.alibaba.cloud</groupId>    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>    <version>2.2.1.RELEASE</version></dependency>

在nacos控制檯提前配置需要的配置檔案

配置檔案格式支援text、json、xml、yaml、html、properties,注意spring boot啟動支援的配置檔案格式只能為yaml或properties格式,其它格式的配置檔案需要後續我們自己寫程式碼去獲取

我們來看db.properties也是就資料庫配置

data id就是對應配置檔案id,group為分組,配置內容就是properties格式的

再來看bootstrap.properties如何引用這個配置檔案

spring.application.name=nacos-configserver.port=20200#名稱空間spring.cloud.nacos.config.namespace=${nacos_register_namingspace:0ca74337-8f42-49c3-aec9-32f268a937c4}#組名spring.cloud.nacos.config.group=${spring.application.name}#檔案格式spring.cloud.nacos.config.file-extension=properties#nacos server地址spring.cloud.nacos.config.server-addr=localhost:8848#載入配置檔案spring.cloud.nacos.config.ext-config[0].data-id=nacos.propertiesspring.cloud.nacos.config.ext-config[1].data-id=db.propertiesspring.cloud.nacos.config.ext-config[2].data-id=mybatis-plus.properties

注意 載入配置檔案的分組名預設為DEFAULT_GROUP,如需指定分組 需要再指定

spring.cloud.nacos.config.ext-config[0].data-id=nacos.propertiesspring.cloud.nacos.config.ext-config[0].group=${spring.cloud.nacos.config.group}
#或者spring.cloud.nacos.config.ext-config[1].data-id=undertow.propertiesspring.cloud.nacos.config.ext-config[1].group=MY_DEFAULT

在這裡解釋下namespace和group的概念,namespace可以用來解決不同環境的問題,group是來管理配置分組的,它們的關係如下圖

spring boot啟動容器如何載入nacos config配置檔案

這個配置作用是spring在啟動之間準備上下文時會啟用這個配置 來匯入nacos相關配置檔案,為後續容器啟動做準備

來看NacosConfigBootstrapConfiguration這個配置類

NacosConfigProperties:對應我們上面在bootstrap.properties中對應的配置資訊

NacosConfigManager: 持有NacosConfigProperties和ConfigService,ConfigService用來查詢 釋出配置的相關介面

NacosPropertySourceLocator:它實現了PropertySourceLocator ,spring boot啟動時呼叫PropertySourceLocator.locate(env)用來載入配置資訊,下面來看相關原始碼

/******************************************NacosPropertySourceLocator******************************************/public PropertySource<?> locate(Environment env) {    ConfigService configService = this.nacosConfigProperties.configServiceInstance();    if (null == configService) {        log.warn("no instance of config service found, can't load config from nacos");        return null;    } else {        long timeout = (long)this.nacosConfigProperties.getTimeout();        this.nacosPropertySourceBuilder = new NacosPropertySourceBuilder(configService, timeout);        String name = this.nacosConfigProperties.getName();        String dataIdPrefix = this.nacosConfigProperties.getPrefix();        if (StringUtils.isEmpty(dataIdPrefix)) {            dataIdPrefix = name;        }        if (StringUtils.isEmpty(dataIdPrefix)) {            dataIdPrefix = env.getProperty("spring.application.name");        }        CompositePropertySource composite = new CompositePropertySource("NACOS");        // 載入共享的配置檔案 不同指定分組 預設DEFAULT_GROUP,對應配置spring.cloud.nacos.config.sharedDataids=shared_1.properties,shared_2.properties        this.loadSharedConfiguration(composite);        // 對應spring.cloud.nacos.config.ext-config[0].data-id=nacos.properties的配置        this.loadExtConfiguration(composite);        // 載入當前應用配置        this.loadApplicationConfiguration(composite, dataIdPrefix, this.nacosConfigProperties, env);        return composite;    }}// 看一個載入實現即可 流程都差不多 具體實現在NacosPropertySourceBuilder.loadNacosData()方法完成/******************************************具體實現在NacosPropertySourceBuilder******************************************/private Properties loadNacosData(String dataId, String group, String fileExtension) {        String data = null;        try {            // 向nacos server拉取配置檔案            data = this.configService.getConfig(dataId, group, this.timeout);            if (!StringUtils.isEmpty(data)) {                log.info(String.format("Loading nacos data, dataId: '%s', group: '%s'", dataId, group));                // spring boot配置當然只支援properties和yaml檔案格式                if (fileExtension.equalsIgnoreCase("properties")) {                    Properties properties = new Properties();                    properties.load(new StringReader(data));                    return properties;                }                if (fileExtension.equalsIgnoreCase("yaml") || fileExtension.equalsIgnoreCase("yml")) {                    YamlPropertiesFactoryBean yamlFactory = new YamlPropertiesFactoryBean();                    yamlFactory.setResources(new Resource[]{new ByteArrayResource(data.getBytes())});                    return yamlFactory.getObject();                }            }        } catch (NacosException var6) {            log.error("get data from Nacos error,dataId:{}, ", dataId, var6);        } catch (Exception var7) {            log.error("parse data from Nacos error,dataId:{},data:{},", new Object[]{dataId, data, var7});        }        return EMPTY_PROPERTIES;    }

至此我們在nacos上配置的properties和yaml檔案都載入到spring配置檔案中來了,後面可透過context.Environment.getProperty(propertyName)來獲取相關配置資訊

配置如何隨spring boot載入進來我們說完了,接來下來看修改完配置後如何實時重新整理

nacos config動態重新整理

當nacos config更新後,根據配置中的refresh屬性來判斷是否重新整理配置,配置如下

spring.cloud.nacos.config.ext-config[0].refresh=true

首先sprin.factories 配置了EnableAutoConfiguration=NacosConfigAutoConfiguration,NacosConfigAutoConfiguration配置類會注入一個NacosContextRefresher,它首先監聽了ApplicationReadyEvent,然後註冊一個nacos listener用來監聽nacos config配置修改後釋出一個spring refreshEvent用來重新整理配置和應用

public class NacosContextRefresher implements ApplicationListener<ApplicationReadyEvent>, ApplicationContextAwarepublic void onApplicationEvent(ApplicationReadyEvent event) {    // 只註冊一次    if (this.ready.compareAndSet(false, true)) {        this.registerNacosListenersForApplications();    }}    private void registerNacosListenersForApplications() {    if (this.refreshProperties.isEnabled()) {        Iterator var1 = NacosPropertySourceRepository.getAll().iterator();        while(var1.hasNext()) {            NacosPropertySource nacosPropertySource = (NacosPropertySource)var1.next();            // 對應剛才所說的配置 需要配置檔案是否需要重新整理            if (nacosPropertySource.isRefreshable()) {                String dataId = nacosPropertySource.getDataId();                // 註冊nacos監聽器                this.registerNacosListener(nacosPropertySource.getGroup(), dataId);            }        }    }}    private void registerNacosListener(final String group, final String dataId) {    Listener listener = (Listener)this.listenerMap.computeIfAbsent(dataId, (i) -> {        return new Listener() {            public void receiveConfigInfo(String configInfo) {                NacosContextRefresher.refreshCountIncrement();                String md5 = "";                if (!StringUtils.isEmpty(configInfo)) {                    try {                        MessageDigest md = MessageDigest.getInstance("MD5");                        md5 = (new BigInteger(1, md.digest(configInfo.getBytes("UTF-8")))).toString(16);                    } catch (UnsupportedEncodingException | NoSuchAlgorithmException var4) {                        NacosContextRefresher.log.warn("[Nacos] unable to get md5 for dataId: " + dataId, var4);                    }                }                // 新增重新整理記錄                NacosContextRefresher.this.refreshHistory.add(dataId, md5);                // 釋出一個spring refreshEvent事件 對應監聽器為RefreshEventListener 該監聽器會完成配置的更新應用                NacosContextRefresher.this.applicationContext.publishEvent(new RefreshEvent(this, (Object)null, "Refresh Nacos config"));                if (NacosContextRefresher.log.isDebugEnabled()) {                    NacosContextRefresher.log.debug("Refresh Nacos config group " + group + ",dataId" + dataId);                }            }            public Executor getExecutor() {                return null;            }        };    });    try {        this.configService.addListener(dataId, group, listener);    } catch (NacosException var5) {        var5.printStackTrace();    }}

我們說完了nacos config動態重新整理,那麼肯定有對應的動態監聽,nacos config會監聽nacos server上配置的更新狀態

nacos config動態監聽

一般來說客戶端和服務端資料互動無非就兩種方式

pull:客戶端主動從伺服器拉取資料

push: 由服務端主動向客戶端推送資料

這兩種模式優缺點各不一樣,pull模式需要考慮的是什麼時候向服務端拉取資料 可能會存在資料延遲問題,而push模式需要客戶端和服務端維護一個長連線 如果客戶端較多會給服務端造成壓力 但它的實時性會更好

nacos採用的是pull模式,但它作了最佳化 可以看做是pull+push,客戶端會輪詢向服務端發出一個長連線請求,這個長連線最多30s就會超時,服務端收到客戶端的請求會先判斷當前是否有配置更新,有則立即返回

如果沒有服務端會將這個請求拿住“hold”29.5s加入佇列,最後0.5s再檢測配置檔案無論有沒有更新都進行正常返回,但等待的29.5s期間有配置更新可以提前結束並返回,下面會在原始碼中講解具體怎麼處理的

nacos client處理

動態監聽的發起是在ConfigService的實現類NacosConfigService的構造方法中,它是對外nacos config api介面,在之前載入配置檔案和NacosContextRefresher構造方法中都會獲取或建立

這裡都會先判斷是否已經建立了ConfigServer,沒有則例項化一個NacosConfigService,來看它的建構函式

/***************************************** NacosConfigService *****************************************/public NacosConfigService(Properties properties) throws NacosException {    String encodeTmp = properties.getProperty(PropertyKeyConst.ENCODE);    if (StringUtils.isBlank(encodeTmp)) {        encode = Constants.ENCODE;    } else {        encode = encodeTmp.trim();    }    initNamespace(properties);    // 用來向nacos server發起請求的代理,這裡用到了裝飾模式    agent = new MetricsHttpAgent(new ServerHttpAgent(properties));    agent.start();    // 客戶端的一個工作類,agent作為它的構造傳參 可猜想到裡面肯定會做一些遠端呼叫    worker = new ClientWorker(agent, configFilterChainManager, properties);}/***************************************** ClientWorker *****************************************/public ClientWorker(final HttpAgent agent, final ConfigFilterChainManager configFilterChainManager, final Properties properties) {    this.agent = agent;    this.configFilterChainManager = configFilterChainManager;    // Initialize the timeout parameter    init(properties);    // 這個執行緒池只有一個核心執行緒 用來執行checkConfigInfo()方法    executor = Executors.newScheduledThreadPool(1, new ThreadFactory() {        @Override        public Thread newThread(Runnable r) {            Thread t = new Thread(r);            t.setName("com.alibaba.nacos.client.Worker." + agent.getName());            t.setDaemon(true);            return t;        }    });    // 其它需要執行執行緒的地方都交給這個執行緒池來處理    executorService = Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors(), new ThreadFactory() {        @Override        public Thread newThread(Runnable r) {            Thread t = new Thread(r);            t.setName("com.alibaba.nacos.client.Worker.longPolling." + agent.getName());            t.setDaemon(true);            return t;        }    });        // 執行一個呼叫checkConfigInfo()方法的週期性任務,每10ms執行一次,首次執行延遲1ms後執行    executor.scheduleWithFixedDelay(new Runnable() {        @Override        public void run() {            try {                checkConfigInfo();            } catch (Throwable e) {                LOGGER.error("[" + agent.getName() + "] [sub-check] rotate check error", e);            }        }    }, 1L, 10L, TimeUnit.MILLISECONDS);}

NacosConfigService構造方法主要建立一個agent 它是用來向nacos server發出請求的,然後又建立了一個clientwoker,它的構造方法建立了兩個執行緒池,第一個執行緒池只有一個核心執行緒,它會執行一個週期性任務只用來呼叫checkconfiginfo()方法,第二個執行緒是後續由需要執行執行緒的地方都交給它來執行,下面重點來看checkconfiginfo()方法

public void checkConfigInfo() {    // 分任務    int listenerSize = cacheMap.get().size();    // 向上取整為批數    int longingTaskCount = (int) Math.ceil(listenerSize / ParamUtil.getPerTaskConfigSize());    if (longingTaskCount > currentLongingTaskCount) {        for (int i = (int) currentLongingTaskCount; i < longingTaskCount; i++) {            executorService.execute(new LongPollingRunnable(i));        }        currentLongingTaskCount = longingTaskCount;    }}
AtomicReference<Map<String, CacheData>> cacheMap = new AtomicReference<Map<String, CacheData>>(    new HashMap<String, CacheData>());

cacheMap:快取著需要重新整理的配置,它是在呼叫ConfigService 新增監聽器方式時會放入,可以自定義監聽配置重新整理

// 新增一個config監聽器,用來監聽dataId為ErrorCode,group為DEFAULT_GROUP的configconfigService.addListener("ErrorCode","DEFAULT_GROUP",new Listener() {    @Override    public Executor getExecutor() {        return null;    }    @Override    public void receiveConfigInfo(String s) { //當配置更新時會呼叫監聽器該方法        Map<String, Map<String, String>> map = JSON.parseObject(s, Map.class);        // 根據自己的業務需要來處理    }});

這裡採用了一個策略:將cacheMap中的數量以3000分一個組,分別建立一個LongPollingRunnable用來監聽配置更新,這個LongPollingRunnable就是我們之前所說的長連線任務,來看這個長連線任務

class LongPollingRunnable implements Runnable {    private int taskId;    public LongPollingRunnable(int taskId) {        this.taskId = taskId;    }    @Override    public void run() {        List<CacheData> cacheDatas = new ArrayList<CacheData>();        List<String> inInitializingCacheList = new ArrayList<String>();        try {            // check failover config            for (CacheData cacheData : cacheMap.get().values()) {                if (cacheData.getTaskId() == taskId) {                    cacheDatas.add(cacheData);                    try {                        // 1、檢查本地配置                        checkLocalConfig(cacheData);                        if (cacheData.isUseLocalConfigInfo()) {                            cacheData.checkListenerMd5();                        }                    } catch (Exception e) {                        LOGGER.error("get local config info error", e);                    }                }            }            // 2、向nacos server發出一個長連線 30s超時,返回nacos server有更新過的dataIds            List<String> changedGroupKeys = checkUpdateDataIds(cacheDatas, inInitializingCacheList);            LOGGER.info("get changedGroupKeys:" + changedGroupKeys);            for (String groupKey : changedGroupKeys) {                String[] key = GroupKey.parseKey(groupKey);                String dataId = key[0];                String group = key[1];                String tenant = null;                if (key.length == 3) {                    tenant = key[2];                }                try {                    // 3、向nacos server請求獲取config最新內容                    String[] ct = getServerConfig(dataId, group, tenant, 3000L);                    CacheData cache = cacheMap.get().get(GroupKey.getKeyTenant(dataId, group, tenant));                    cache.setContent(ct[0]);                    if (null != ct[1]) {                        cache.setType(ct[1]);                    }                }             }            // 4、對有變化的config呼叫對應監聽器去處理            for (CacheData cacheData : cacheDatas) {                if (!cacheData.isInitializing() || inInitializingCacheList                    .contains(GroupKey.getKeyTenant(cacheData.dataId, cacheData.group, cacheData.tenant))) {                    cacheData.checkListenerMd5();                    cacheData.setInitializing(false);                }            }            inInitializingCacheList.clear();            // 繼續輪詢            executorService.execute(this);        } catch (Throwable e) {            // 發生異常延遲執行            executorService.schedule(this, taskPenaltyTime, TimeUnit.MILLISECONDS);        }    }}

這個長輪詢主要做了4個步驟

檢查本地配置,如果存在本地配置,並且與快取中的本地配置版本不一樣,把本地配置內容更新到快取,並觸發事件,這塊原始碼比較簡單,讀者跟到原始碼一讀編制向nacos server發出一個長連線,30s超時,nacos server會返回有變化的dataIds根據變化的dataId,從服務端拉取最新的配置內容然後更新到快取中對有變化的配置 觸發事件監聽器來處理

講完了nacos client處理流程,再來看服務端這邊怎麼處理這個長連線的

nacos server處理

服務端長連線介面是/config/listener,對應原始碼包為config

/****************************************** ConfigController ******************************************/@PostMapping("/listener")@Secured(action = ActionTypes.READ, parser = ConfigResourceParser.class)public void listener(HttpServletRequest request, HttpServletResponse response)        throws ServletException, IOException {    request.setAttribute("org.apache.catalina.ASYNC_SUPPORTED", true);    String probeModify = request.getParameter("Listening-Configs");    if (StringUtils.isBlank(probeModify)) {        throw new IllegalArgumentException("invalid probeModify");    }        probeModify = URLDecoder.decode(probeModify, Constants.ENCODE);    // 需要檢查更新的config資訊    Map<String, String> clientMd5Map;    try {        clientMd5Map = MD5Util.getClientMd5Map(probeModify);    } catch (Throwable e) {        throw new IllegalArgumentException("invalid probeModify");    }        // 長連線處理    inner.doPollingConfig(request, response, clientMd5Map, probeModify.length());}/****************************************** ConfigServletInner ******************************************/public String doPollingConfig(HttpServletRequest request, HttpServletResponse response,            Map<String, String> clientMd5Map, int probeRequestSize) throws IOException {        // 判斷是否支援長輪詢    if (LongPollingService.isSupportLongPolling(request)) {        // 長輪詢處理        longPollingService.addLongPollingClient(request, response, clientMd5Map, probeRequestSize);        return HttpServletResponse.SC_OK + "";    }        // 不支援長輪詢,直接與當前配置作比較,返回有變更的配置    List<String> changedGroups = MD5Util.compareMd5(request, response, clientMd5Map);        // Compatible with short polling result.    String oldResult = MD5Util.compareMd5OldResult(changedGroups);    String newResult = MD5Util.compareMd5ResultString(changedGroups);        /*    * 省略    * 會響應變更的配置資訊    */    return HttpServletResponse.SC_OK + "";}/****************************************** LongPollingService ******************************************/public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,            int probeRequestSize) {            String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);    String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);    String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);    String tag = req.getHeader("Vipserver-Tag");        // 服務端這邊最多處理時長29.5s,需要留0.5s來返回,以免客戶端那邊超時    int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);    // Add delay time for LoadBalance, and one response is returned 500 ms in advance to avoid client timeout.    long timeout = Math.max(10000, Long.parseLong(str) - delayTime);    if (isFixedPolling()) {        timeout = Math.max(10000, getFixedPollingInterval());        // Do nothing but set fix polling timeout.    } else {        // 不支援長輪詢 本地對比返回        long start = System.currentTimeMillis();        List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);        if (changedGroups.size() > 0) {            generateResponse(req, rsp, changedGroups);            // log....            return;        } else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) {            // log....            return;        }    }    String ip = RequestUtil.getRemoteIp(req);        // 將http響應交給非同步執行緒,返回一個非同步響應上下文, 當配置更新後可以主動呼叫及時返回,不用非等待29.5s    final AsyncContext asyncContext = req.startAsync();        // AsyncContext.setTimeout() is incorrect, Control by oneself    asyncContext.setTimeout(0L);    // 執行客戶端長連線任務,    ConfigExecutor.executeLongPolling(            new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));}/****************************************** ClientLongPolling ******************************************/class ClientLongPolling implements Runnable {            @Override    public void run() {        // 提交一個任務,延遲29.5s執行        asyncTimeoutFuture = ConfigExecutor.scheduleLongPolling(new Runnable() {            @Override            public void run() {                try {                    getRetainIps().put(ClientLongPolling.this.ip, System.currentTimeMillis());                                        // Delete subsciber's relations.                    allSubs.remove(ClientLongPolling.this);                                        if (isFixedPolling()) {                        // 檢查變更配置 並相應                        List<String> changedGroups = MD5Util                                .compareMd5((HttpServletRequest) asyncContext.getRequest(),                                        (HttpServletResponse) asyncContext.getResponse(), clientMd5Map);                        if (changedGroups.size() > 0) {                            sendResponse(changedGroups);                        } else {                            sendResponse(null);                        }                    } else {                        sendResponse(null);                    }                } catch (Throwable t) {                    LogUtil.DEFAULT_LOG.error("long polling error:" + t.getMessage(), t.getCause());                }                            }                    }, timeoutTime, TimeUnit.MILLISECONDS);                allSubs.add(this);    }}
final Queue<ClientLongPolling> allSubs

上面大部分地方都比較好懂,主要解釋下ClientLongPolling作用,它首先會提交一個任務,無論配置有沒有更新 最終都會進行響應,延遲29.5s執行,然後會把自己新增到一個佇列中,之前說過,服務端這邊配置有更新後 會找出正在等待配置更新的長連線任務,提前結束這個任務並返回,

來看這一步是怎麼處理的

public LongPollingService() {    allSubs = new ConcurrentLinkedQueue<ClientLongPolling>();        ConfigExecutor.scheduleLongPolling(new StatTask(), 0L, 10L, TimeUnit.SECONDS);        // Register LocalDataChangeEvent to NotifyCenter.    NotifyCenter.registerToPublisher(LocalDataChangeEvent.class, NotifyCenter.ringBufferSize);        // Register A Subscriber to subscribe LocalDataChangeEvent.    NotifyCenter.registerSubscriber(new Subscriber() {                @Override        public void onEvent(Event event) {            if (isFixedPolling()) {                // Ignore.            } else {                if (event instanceof LocalDataChangeEvent) {                    LocalDataChangeEvent evt = (LocalDataChangeEvent) event;                    ConfigExecutor.executeLongPolling(new DataChangeTask(evt.groupKey, evt.isBeta, evt.betaIps));                }            }        }                @Override        public Class<? extends Event> subscribeType() {            return LocalDataChangeEvent.class;        }    });    }class DataChangeTask implements Runnable {            @Override    public void run() {        try {            ConfigCacheService.getContentBetaMd5(groupKey);            // 找出等在該配置的長連線,然後進行提前返回            for (Iterator<ClientLongPolling> iter = allSubs.iterator(); iter.hasNext(); ) {                ClientLongPolling clientSub = iter.next();                if (clientSub.clientMd5Map.containsKey(groupKey)) {                    // If published tag is not in the beta list, then it skipped.                    if (isBeta && !CollectionUtils.contains(betaIps, clientSub.ip)) {                        continue;                    }                                        // If published tag is not in the tag list, then it skipped.                    if (StringUtils.isNotBlank(tag) && !tag.equals(clientSub.tag)) {                        continue;                    }                                        getRetainIps().put(clientSub.ip, System.currentTimeMillis());                    iter.remove(); // Delete subscribers' relationships.                    clientSub.sendResponse(Arrays.asList(groupKey));                }            }        } catch (Throwable t) {            LogUtil.DEFAULT_LOG.error("data change error: {}", ExceptionUtil.getStackTrace(t));        }    }}

LongPollingService建構函式中,會註冊一個訂閱,用來監聽LocalDataChangeEvent,當發生該事件時,會執行一個數據變更任務,這個任務就是找出等在配置的長連線,提前返回

我們在nacos控制檯修改一個配置檔案進行釋出,會呼叫ConfigController.publishConfig介面,但這個介面釋出的是ConfigDataChangeEvent事件,大意了。。。LocalDataChangeEvent事件釋出在ConfigCacheService,這裡怎麼呼叫的我就不深追,留給有興趣的讀者

至此nacos config動態監聽、重新整理就串聯起來了,nacos的相關原始碼都比較好理解,跟著原始碼追進去就一目瞭然了

12
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 第44節 DocumentType、文件片段及Attr節點