Tomcat 9 源碼解析 -- StandardContext

 

StandardContext 類介紹

 

StandardContext 和其他 Container 一樣,也是重寫了 startInternal 方法。由於涉及到 webapp 的啓動流程,需要很多準備工作,比如使用 WebResourceRoot 加載資源文件、利用 Loader 加載 class、使用 JarScanner 掃描 jar 包,等等。因此StandardContext 的啓動邏輯比較複雜,這裏描述下幾個重要的步驟: 
1. 創建工作目錄,比如$CATALINA_HOME\work\Catalina\localhost\examples;實例化 ContextServlet,應用程序拿到的是 ApplicationContext的外觀模式 
2. 實例化 WebResourceRoot,默認實現類是 StandardRoot,用於讀取 webapp 的文件資源 
3. 實例化 Loader 對象,Loader 是 tomcat 對於 ClassLoader 的封裝,用於支持在運行期間熱加載 class 
4. 發出  CONFIGURE_START_EVENT  事件,ContextConfig 會處理該事件,主要目的是從 webapp 中讀取 servlet 相關的 Listener、Servlet、Filter 等 
5. 實例化 Sesssion 管理器,默認使用 StandardManager 
6. 調用 listenerStart,實例化 servlet 相關的各種 Listener,並且調用 
    ServletContextListener 
7. 處理 Filter 
8. 加載 Servlet

 

核心方法 爲   startInternal()

 

Tomcat的生命週期機制告訴我們,一個組件的啓動過程應該關注它的start方法,這個start方法是典型的模板方法設計模式。LifecycleBase是所有組件都繼承的抽象類,該類提供了生命週期相關的通用方法,start()方法也可以在LifecycleBase中找到。

觀察start方法,在該方法中定義了組件啓動的應進行的操作,又留出一個抽象方法startInternal()方法供子類實現組件自身的操作。

所以來看 StandContext 的 startInternal() 方法。
 

@Override
    protected synchronized void startInternal() throws LifecycleException {

    	LogPropertiesTest.debug("14、StandardContext :  執行  startInternal() 方法,    執行類 :"+this.getClass());
    	
        if(log.isDebugEnabled())
            log.debug("Starting " + getBaseName());

        // 1.發佈正在啓動的JMX通知,這樣可以通過NotificationListener來監聽Web應用的啓動。
        // Send j2ee.state.starting notification
        if (this.getObjectName() != null) {
            Notification notification = new Notification("j2ee.state.starting",
                    this.getObjectName(), sequenceNumber.getAndIncrement());
            broadcaster.sendNotification(notification);
        }

        setConfigured(false);
        boolean ok = true;

        // Currently this is effectively a NO-OP but needs to be called to
        // ensure the NamingResources follows the correct lifecycle
        // 2.啓動當前維護的JNDI資源。
        if (namingResources != null) {
            namingResources.start();
        }

        
        // 3.初始化臨時工作目錄,即設置的workDir,默認爲$CATALINA-BASE/work/<Engine名稱>/<Host名稱>/<Context名稱>。
        // Post work directory
        postWorkDirectory();

        
        // 4.初始化當前Context使用的WebResouceRoot並啓動。WebResouceRoot維護了Web應用所以的資源集合
        // (Class文件、Jar包以及其他資源文件),主要用於類加載器和按照路徑查找資源文件。
        // Add missing components as necessary
        if (getResources() == null) {   // (1) Required by Loader
            if (log.isDebugEnabled())
                log.debug("Configuring default Resources");

            try {
                setResources(new StandardRoot(this));
            } catch (IllegalArgumentException e) {
                log.error(sm.getString("standardContext.resourcesInit"), e);
                ok = false;
            }
        }
        if (ok) {
        	// WebResourceRoot #createWebResourceSet ==》 "/WEB-INF/classes/META-INF/resources"
            resourcesStart();
        }

        // 5.創建Web應用類加載器webappLoader,webappLoader繼承自LifecycleMBeanBase,在其啓動後會去創建Web應用類加載器(ParallelWebappClassLoader)。
        if (getLoader() == null) {
            WebappLoader webappLoader = new WebappLoader(getParentClassLoader());
            webappLoader.setDelegate(getDelegate());
            setLoader(webappLoader);
        }

        // 6.如果沒有設置Cookie處理器,默認爲Rfc6265CookieProcessor。
        if (cookieProcessor == null) {
            cookieProcessor = new Rfc6265CookieProcessor();
        }

        // 7.設置字符集映射,用於根據Locale獲取字符集編碼。
        getCharsetMapper();

        // Validate required extensions
        // 8.web應用的依賴檢測。
        boolean dependencyCheck = true;
        try {
            dependencyCheck = ExtensionValidator.validateApplication
                (getResources(), this);
        } catch (IOException ioe) {
            log.error(sm.getString("standardContext.extensionValidationError"), ioe);
            dependencyCheck = false;
        }

        if (!dependencyCheck) {
            // do not make application available if dependency check fails
            ok = false;
        }

        // Reading the "catalina.useNaming" environment variable
        String useNamingProperty = System.getProperty("catalina.useNaming");
        if ((useNamingProperty != null)
            && (useNamingProperty.equals("false"))) {
            useNaming = false;
        }

        // 9.NamingContextListener註冊
        if (ok && isUseNaming()) {
            if (getNamingContextListener() == null) {
                NamingContextListener ncl = new NamingContextListener();
                ncl.setName(getNamingContextName());
                ncl.setExceptionOnFailedWrite(getJndiExceptionOnFailedWrite());
                addLifecycleListener(ncl);
                setNamingContextListener(ncl);
            }
        }

        // Standard container startup
        if (log.isDebugEnabled())
            log.debug("Processing standard container startup");


        // Binding thread
        ClassLoader oldCCL = bindThread();

        try {
            if (ok) {
                // Start our subordinate components, if any
            	// 10.啓動Web應用類加載器,此時真正創建出ParallelWebappClassLoader實例。
                Loader loader = getLoader();
                if (loader instanceof Lifecycle) {
                    ((Lifecycle) loader).start();
                }

                // since the loader just started, the webapp classloader is now
                // created.
                setClassLoaderProperty("clearReferencesRmiTargets",
                        getClearReferencesRmiTargets());
                setClassLoaderProperty("clearReferencesStopThreads",
                        getClearReferencesStopThreads());
                setClassLoaderProperty("clearReferencesStopTimerThreads",
                        getClearReferencesStopTimerThreads());
                setClassLoaderProperty("clearReferencesHttpClientKeepAliveThread",
                        getClearReferencesHttpClientKeepAliveThread());
                setClassLoaderProperty("clearReferencesObjectStreamClassCaches",
                        getClearReferencesObjectStreamClassCaches());

                // By calling unbindThread and bindThread in a row, we setup the
                // current Thread CCL to be the webapp classloader
                unbindThread(oldCCL);
                oldCCL = bindThread();

                // Initialize logger again. Other components might have used it
                // too early, so it should be reset.
                logger = null;
                getLogger();

                // 11.啓動安全組件。
                Realm realm = getRealmInternal();
                if(null != realm) {
                    if (realm instanceof Lifecycle) {
                        ((Lifecycle) realm).start();
                    }

                    // Place the CredentialHandler into the ServletContext so
                    // applications can have access to it. Wrap it in a "safe"
                    // handler so application's can't modify it.
                    CredentialHandler safeHandler = new CredentialHandler() {
                        @Override
                        public boolean matches(String inputCredentials, String storedCredentials) {
                            return getRealmInternal().getCredentialHandler().matches(inputCredentials, storedCredentials);
                        }

                        @Override
                        public String mutate(String inputCredentials) {
                            return getRealmInternal().getCredentialHandler().mutate(inputCredentials);
                        }
                    };
                    context.setAttribute(Globals.CREDENTIAL_HANDLER, safeHandler);
                }

                // Notify our interested LifecycleListeners   ContextConfig#webConfig()
                /**
                 
                 ContextConfig 它是一個 LifycycleListener,它在 Context 啓動過程中是承擔了一個非常重要的角色。StandardContext 會發出 CONFIGURE_START_EVENT 事件,而 ContextConfig 會處理該事件,主要目的是通過 web.xml 或者 Servlet3.0 的註解配置,讀取 Servlet 相關的配置信息,比如 Filter、Servlet、Listener 等,其核心邏輯在 ContextConfig#webConfig() 方法中實現。下面,我們對 ContextConfig 進行詳細分析
                 
                 CONFIGURE_START_EVENT = "configure_start";
                 */
                // 12.發佈CONFIGURE_START_EVENT事件,ContextConfig 監聽該事件以完成 Servlet 的創建。
                fireLifecycleEvent(Lifecycle.CONFIGURE_START_EVENT, null);

                // Start our child containers, if not already started
                // 13.啓動Context子節點Wrapper。
                for (Container child : findChildren()) {
                    if (!child.getState().isAvailable()) {
                        child.start();
                    }
                }

                // Start the Valves in our pipeline (including the basic),
                // if any
                // 14.啓動Context的pipeline。
                if (pipeline instanceof Lifecycle) {
                    ((Lifecycle) pipeline).start();
                }

                // Acquire clustered manager
                // 15.創建會話管理器。
                Manager contextManager = null;
                Manager manager = getManager();
                if (manager == null) {
                    if (log.isDebugEnabled()) {
                        log.debug(sm.getString("standardContext.cluster.noManager",
                                Boolean.valueOf((getCluster() != null)),
                                Boolean.valueOf(distributable)));
                    }
                    if ( (getCluster() != null) && distributable) {
                        try {
                            contextManager = getCluster().createManager(getName());
                        } catch (Exception ex) {
                            log.error("standardContext.clusterFail", ex);
                            ok = false;
                        }
                    } else {
                        contextManager = new StandardManager();
                    }
                }

                // Configure default manager if none was specified
                if (contextManager != null) {
                    if (log.isDebugEnabled()) {
                        log.debug(sm.getString("standardContext.manager",
                                contextManager.getClass().getName()));
                    }
                    setManager(contextManager);
                }

                if (manager!=null && (getCluster() != null) && distributable) {
                    //let the cluster know that there is a context that is distributable
                    //and that it has its own manager
                    getCluster().registerManager(manager);
                }
            }

            if (!getConfigured()) {
                log.error(sm.getString("standardContext.configurationFail"));
                ok = false;
            }

            // We put the resources into the servlet context
            // 16.將Context的Web資源集合添加到ServletContext。
            if (ok)
                getServletContext().setAttribute
                    (Globals.RESOURCES_ATTR, getResources());

            // 17.創建實例管理器instanceManager,用於創建對象實例,如Servlet、Filter等。
            if (ok ) {
                if (getInstanceManager() == null) {
                    javax.naming.Context context = null;
                    if (isUseNaming() && getNamingContextListener() != null) {
                        context = getNamingContextListener().getEnvContext();
                    }
                    Map<String, Map<String, String>> injectionMap = buildInjectionMap(
                            getIgnoreAnnotations() ? new NamingResourcesImpl(): getNamingResources());
                    setInstanceManager(new DefaultInstanceManager(context,
                            injectionMap, this, this.getClass().getClassLoader()));
                }
                getServletContext().setAttribute(
                        InstanceManager.class.getName(), getInstanceManager());
                InstanceManagerBindings.bind(getLoader().getClassLoader(), getInstanceManager());
            }

            // Create context attributes that will be required
            // 18.將Jar包掃描器添加到ServletContext。
            if (ok) {
                getServletContext().setAttribute(
                        JarScanner.class.getName(), getJarScanner());
            }

            // Set up the context init params
            //  19.合併參數。   指定 ServletContext 的相關參數
            mergeParameters();

            // 在初始化 Servlet、Listener 之前,便會先調用 ServletContainerInitializer,進行額外的初始化處理。
        	//  注意:ServletContainerInitializer 需要的是 Class 對象,而不是具體的實例對象,這個時候 servlet 相關的 Listener 
        	//  並沒有被實例化,因此不會產生矛盾
            // Call ServletContainerInitializers
            // 調用 ServletContainerInitializer#onStartup()
            for (Map.Entry<ServletContainerInitializer, Set<Class<?>>> entry :
                initializers.entrySet()) {
                try {
                	
                	
                	Set<Class<?>> value = entry.getValue();
					/*
					 * for (Class<?> class1 : value) { LogPropertiesTest.
					 * debug("--ServletContainerInitializer--------------------14、StandardContext :  執行  startInternal() 方法,    class1 :"
					 * +class1.getClass()); }
					 */
                	
                	
                	//  20.啓動添加到Context的ServletContainerInitializer。
                    entry.getKey().onStartup(entry.getValue(),
                            getServletContext());
                } catch (ServletException e) {
                    log.error(sm.getString("standardContext.sciFail"), e);
                    ok = false;
                    break;
                }
            }

            // Configure and call application event listeners
            // 21.實例化應用類監聽器ApplicationListener。
            if (ok) {
                if (!listenerStart()) {
                    log.error(sm.getString("standardContext.listenerFail"));
                    ok = false;
                }
            }

            // Check constraints for uncovered HTTP methods
            // Needs to be after SCIs and listeners as they may programmatically
            // change constraints
            if (ok) {
                checkConstraintsForUncoveredMethods(findConstraints());
            }

            try {
                // Start manager
            	// 22.啓動會話管理器。
                Manager manager = getManager();
                if (manager instanceof Lifecycle) {
                    ((Lifecycle) manager).start();
                }
            } catch(Exception e) {
                log.error(sm.getString("standardContext.managerFail"), e);
                ok = false;
            }

            // Configure and call application filters
            // 23.實例化FilterConfig、Filter並調用Filter.init()。
            if (ok) {
                if (!filterStart()) {
                    log.error(sm.getString("standardContext.filterFail"));
                    ok = false;
                }
            }

            // Load and initialize all "load on startup" servlets
            // 24.對於loadOnStartup大於等於0的Wrapper,調用Wrapper.load(),該方法負責實例化Servlet,並調用Servlet.init()進行初始化。
            if (ok) {
                if (!loadOnStartup(findChildren())){
                    log.error(sm.getString("standardContext.servletFail"));
                    ok = false;
                }
            }

            // Start ContainerBackgroundProcessor thread
            // 25.啓動後臺定時處理程序,只有backgroundProcessorDelay>0才啓動,用於監控守護文件的變更。
            super.threadStart();
        } finally {
            // Unbinding thread
            unbindThread(oldCCL);
        }

        // Set available status depending upon startup success
        if (ok) {
            if (log.isDebugEnabled())
                log.debug("Starting completed");
        } else {
            log.error(sm.getString("standardContext.startFailed", getName()));
        }

        startTime=System.currentTimeMillis();

        // Send j2ee.state.running notification
        // 26.發佈正在運行的JMX通知
        if (ok && (this.getObjectName() != null)) {
            Notification notification =
                new Notification("j2ee.state.running", this.getObjectName(),
                                 sequenceNumber.getAndIncrement());
            broadcaster.sendNotification(notification);
        }

        // The WebResources implementation caches references to JAR files. On
        // some platforms these references may lock the JAR files. Since web
        // application start is likely to have read from lots of JARs, trigger
        // a clean-up now.
        // 27.釋放資源,如關閉jar文件。
        getResources().gc();

        // 28.設置Context狀態。
        // Reinitializing if something went wrong
        if (!ok) {
            setState(LifecycleState.FAILED);
        } else {
            setState(LifecycleState.STARTING);
        }
        // StandContext啓動很複雜,涉及很多知識面
    }

 

 

ContextConfig

 

首先我們看一下  StandardContext 類中的ContextConfig是何時創建的:


1、Bootstrap.load(String[])

2、Catalina #load()

Digester digester = createStartDigester();

3、Catalina # createStartDigester()

digester.addRuleSet(new HostRuleSet("Server/Service/Engine/"));

4、Digester #addRuleSet(RuleSet ruleSet)

public void addRuleSet(RuleSet ruleSet) {    // ruleSet  ==  org.apache.catalina.startup.HostRuleSet@687080dc
        ruleSet.addRuleInstances(this);   // this == org.apache.tomcat.util.digester.Digester@38bc8ab5
    }

5、HostRuleSet #addRuleInstances(Digester digester)

//  prefix == Server/Service/Engine/

digester.addRule(prefix + "Host",
                         new LifecycleListenerRule
                         ("org.apache.catalina.startup.HostConfig",
                          "hostConfigClass"));
        digester.addSetNext(prefix + "Host",
                            "addChild",  
                            "org.apache.catalina.Container");

       //   通過  Digester  創建  HostConfig, 然後調用 StandardHost 對象的addChild方法,將HostConfig對象添加到StandardHost,

     // 也就是添加到StandardHost父類LifecycleBase中的 屬性集合中      private final List<LifecycleListener> lifecycleListeners =

     // new CopyOnWriteArrayList<>();

 

6、StandardHost #start()

7、StandardHost #startInternal()

super.startInternal();

8、StandardHost #fireLifecycleEvent(String type, Object data)

protected void fireLifecycleEvent(String type, Object data) {
        LifecycleEvent event = new LifecycleEvent(this, type, data);
        for (LifecycleListener listener : lifecycleListeners) {
            listener.lifecycleEvent(event);    // listener =  org.apache.catalina.startup.HostConfig@729d991e
        }
    }

9、 HostConfig #lifecycleEvent(LifecycleEvent event)

} else if (event.getType().equals(Lifecycle.START_EVENT)) {
            start();

10、 HostConfig #start()

if (host.getDeployOnStartup())
            deployApps();

11、 HostConfig #deployApps()

protected void deployApps() {

        File appBase = host.getAppBaseFile();
        File configBase = host.getConfigBaseFile();
        String[] filteredAppPaths = filterAppPaths(appBase.list());
        // Deploy XML descriptors from configBase
        deployDescriptors(configBase, configBase.list());
        // Deploy WARs
        deployWARs(appBase, filteredAppPaths);
        // Deploy expanded folders
        deployDirectories(appBase, filteredAppPaths);

    }

12、HostConfig #deployDirectories(File appBase, String[] files)

if (dir.isDirectory()) {
                ContextName cn = new ContextName(files[i], false);

                if (isServiced(cn.getName()) || deploymentExists(cn.getName()))
                    continue;

               //  ExecutorService es = host.getStartStopExecutor();

                results.add(es.submit(new DeployDirectory(this, cn, dir)));
            }

13、HostConfig #deployDirectory(ContextName cn, File dir)

            Class<?> clazz = Class.forName(host.getConfigClass());     //   host.getConfigClass()  == private String configClass =
                                                                                                           //                       "org.apache.catalina.startup.ContextConfig";
            LifecycleListener listener = (LifecycleListener) clazz.getConstructor().newInstance();
            context.addLifecycleListener(listener);  //  listener == org.apache.catalina.startup.ContextConfig@3e11f9e9

至此  HostConfig被添加到 StandardContext對象中。

 


ContextConfig是創建Context時默認添的一個生命週期監聽器。它監聽6個事件,其中三個和Context啓動關係密切:AFTER_INIT_EVENT、BEFORE_START_EVENT、CONFIGURE_START_EVENT。

ContextConfig的lifecycleEvent()方法:

 

StandardContext #fireLifecycleEvent(String type, Object data)

protected void fireLifecycleEvent(String type, Object data) {
        LifecycleEvent event = new LifecycleEvent(this, type, data);
        for (LifecycleListener listener : lifecycleListeners) {
            listener.lifecycleEvent(event);
        }
    }

ContextConfig#lifecycleEvent()方法:

 @Override
    public void lifecycleEvent(LifecycleEvent event) {

        // Identify the context we are associated with
        try {
            context = (Context) event.getLifecycle();
        } catch (ClassCastException e) {
            log.error(sm.getString("contextConfig.cce", event.getLifecycle()), e);
            return;
        }

        // Process the event that has occurred
        if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
            configureStart();
        } else if (event.getType().equals(Lifecycle.BEFORE_START_EVENT)) {
            beforeStart();
        } else if (event.getType().equals(Lifecycle.AFTER_START_EVENT)) {
            // Restore docBase for management tools
            if (originalDocBase != null) {
                context.setDocBase(originalDocBase);
            }
        } else if (event.getType().equals(Lifecycle.CONFIGURE_STOP_EVENT)) {
            configureStop();
        } else if (event.getType().equals(Lifecycle.AFTER_INIT_EVENT)) {
            init();
        } else if (event.getType().equals(Lifecycle.AFTER_DESTROY_EVENT)) {
            destroy();
        }

    }

AFTER_INIT_EVENT  事件
嚴格說,該事件屬於Context事件初始化階段,主要用於Context屬性的配置工作。

根據前面講的,再來回顧一下Context的創建,有以下來源:

解析server.xml中的Context元素。
通過HostConfig部署Web應用時,解析Web應用(或者WAR包)根目錄下的META-INF/context.xml文件。如果不存在,則自動創建一個默認的Context對象,只設置name,path,docBase等幾個屬性。
通諾HostConfig部署Web應用時,解析$CATALINA-BASE/conf/Catalina/localhost目錄下的Context部署文件描述符創建。
除了Context創建時的屬性配置,Tomcat提供的默認配置也要一併添加到Context實例中,AFTER_INIT_EVENT事件就是要完成這部分工作的。

來看該事件觸發時執行的init()方法:
 

/**
     * Process a "init" event for this Context.
     
     	1、處理Context的兩個默認配置文件:conf/context.xml和/conf/[enginename]/[hostname]/context.xml.default,解析到context中;
		2、對war包進行校驗:主要是校驗目錄結構(是否有WEB-INF目錄,是否有classes目錄和META-INF目錄等)
		3、對於沒有解壓的文件還會將其解壓:是在ExpandWar類的expand方法中完成的	
     
     */
    protected synchronized void init() {
        // Called from StandardContext.init()

        Digester contextDigester = createContextDigester();
        contextDigester.getParser();

        if (log.isDebugEnabled()) {
            log.debug(sm.getString("contextConfig.init"));
        }
        context.setConfigured(false);
        ok = true;

        contextConfig(contextDigester);
    }

init首先會創建createContextDigester創建解析規則,點進去看可以發現會回到之前講Server解析時提到的ContextRuleSet,只不過這時傳進去的create參數值爲false。

不多說,重點來看contextConfig()方法

protected void contextConfig(Digester digester) {

        String defaultContextXml = null;

        // Open the default context.xml file, if it exists
        if (context instanceof StandardContext) {
            defaultContextXml = ((StandardContext)context).getDefaultContextXml();
        }
        // set the default if we don't have any overrides
        if (defaultContextXml == null) {
            defaultContextXml = Constants.DefaultContextXml;
        }

        if (!context.getOverride()) {
            File defaultContextFile = new File(defaultContextXml);
            if (!defaultContextFile.isAbsolute()) {
                defaultContextFile =
                        new File(context.getCatalinaBase(), defaultContextXml);
            }
            if (defaultContextFile.exists()) {
                try {
                    URL defaultContextUrl = defaultContextFile.toURI().toURL();
                    processContextConfig(digester, defaultContextUrl);
                } catch (MalformedURLException e) {
                    log.error(sm.getString(
                            "contextConfig.badUrl", defaultContextFile), e);
                }
            }

            File hostContextFile = new File(getHostConfigBase(), Constants.HostContextXml);
            if (hostContextFile.exists()) {
                try {
                    URL hostContextUrl = hostContextFile.toURI().toURL();
                    processContextConfig(digester, hostContextUrl);
                } catch (MalformedURLException e) {
                    log.error(sm.getString(
                            "contextConfig.badUrl", hostContextFile), e);
                }
            }
        }
        if (context.getConfigFile() != null) {
            processContextConfig(digester, context.getConfigFile());
        }

    }

看到解析的過程如下:

1.如果Context的override屬性爲false(默認配置):

  1.1 如果存在defaultContextXml即conf/context.xml(Catalina容器級默認配置文件),那麼解析該文件,更新Context實例屬性。

  1.2 如果存在hostContextXml即$CATALINA-BASE/conf/Catalina/localhost/context.xml.default文件(Host級的默認配置),則解析該文件,更新Context實例屬性。

2.如果context的configFile不爲空(即$CATALINA-BASE/conf/Catalina/localhost下的Context部署描述文件或者Web應用根目錄下的META-INF/context.xml文件),那麼解析該文件,更新Context實例屬性。

看到這會發現configFile其實被解析了兩遍,在創建Context時會先解析一遍,這裏再被解析一遍,這是什麼原因呢?

因爲這裏會解析conf/context.xml和context.xml.default文件,配置默認屬性,如果之前創建Context時已經配置了某個屬性,而這個屬性又在conf/context.xml和context.xml.default中存在,顯然這時會被覆蓋,想要配置Context級別的屬性不被覆蓋,所以這時再解析一遍。

根據上述,可以得出結論:

Tomcat中Context屬性的優先級爲:configFile > $CATALINA-BASE/conf/Catalina/localhost/context.xml.default > conf/context.xml,即Web應用配置優先級最高,Host級別配置次之,Catalina容器級別最低。
 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章