深入理解 Tomcat(六)源碼剖析Tomcat 啟動過程----生命周期和容器組件

好了,今天我們繼續分析 tomcat 源碼, 這是第六篇了, 上一篇我們一邊 debug 一邊研究了 tomcat 的類加載體系, 我覺得效果還不錯, 樓主感覺對 tomcat 的類加載體系的理解又加深了一點. 所以, 我們今天還是按照之前的方式來繼續看源碼, 一邊 debug, 一邊看, 今天我們分析的是tomcat 中2個非常重要的組件-------生命周期和容器. tomcat 龐大的架構, 他是如何管理每個對象的呢? 我們在深入理解 Tomcat (二) 從宏觀上理解 Tomcat 組件及架構中說過一段:

基于JMX Tomcat會為每個組件進行注冊過程,通過Registry管理起來,而Registry是基于JMX來實現的,因此在看組件的init和start過程實際上就是初始化MBean和觸發MBean的start方法,會大量看到形如: Registry.getRegistry(null, null).invoke(mbeans, "init", false); Registry.getRegistry(null, null).invoke(mbeans, "start", false); 這樣的代碼,這實際上就是通過JMX管理各種組件的行為和生命期。

當時大家可能還不是很理解這句話, 覺得這是在扯淡, 聽不懂. 好吧, 今天我們就用代碼說話, 看看 JMX 到底怎么管理 tomcat 的 組件.

1. 什么是 JMX?

我們之前說過:

JMX 即 Java Management Extensions(JMX 規范), 是用來對 tomcat 進行管理的. tomcat 中的實現是 commons modeler 庫, Catalina 使用這個庫來編寫托管 Bean 的工作. 托管 Bean 就是用來管理 Catalina 中其他對象的 Bean.

簡單來說: 就是一個可以為Java應用程序或系統植入遠程管理功能的框架。

既然是框架, 肯定要有架構圖:


這里對上圖中三個分層進行介紹:

  • Probe Level:負責資源的檢測(獲取信息),包含MBeans,通常也叫做Instrumentation Level。MX管理構件(MBean)分為四種形式,分別是標準管理構件(Standard MBean)、動態管理構件(Dynamic MBean)、開放管理構件(Open Mbean)和模型管理構件(Model MBean)。

  • Agent Level:即MBeanServer,是JMX的核心,負責連接Mbeans和應用程序。

  • Remote Management Level:通過connectors和adaptors來遠程操作MBeanServer,常用的控制臺,例如JConsole、VisualVM(等會我們就要用這個)等。

2. 我們看看生命周期組件接口是如何設計的:

這是一張 IDEA 生成的簡單的 StandardHost(Host 容器的標準實現) 的 UML類圖, 基本上, tomcat 的容器類都是這樣的繼承結構.

因此我們就可以直接看下面這張圖:


這里對上圖中涉及的主要類作個簡單介紹:

  1. Lifecycle:定義了容器生命周期、容器狀態轉換及容器狀態遷移事件的監聽器注冊和移除等主要接口;

  2. LifecycleBase:作為Lifecycle接口的抽象實現類,運用抽象模板模式將所有容器的生命周期及狀態轉換銜接起來,此外還提供了生成LifecycleEvent事件的接口;

  3. LifecycleSupport:提供有關LifecycleEvent事件的監聽器注冊、移除,并且使用經典的監聽器模式,實現事件生成后觸達監聽器的實現;

  4. MBeanRegistration:Java JMX框架提供的注冊MBean的接口,引入此接口是為了便于使用JMX提供的管理功能;

  5. LifecycleMBeanBase:Tomcat提供的對MBeanRegistration的抽象實現類,運用抽象模板模式將所有容器統一注冊到JMX;

  6. 此外,ContainerBase、StandardServer、StandardService、WebappLoader、Connector、StandardContext、StandardEngine、StandardHost、StandardWrapper等容器都繼承了LifecycleMBeanBase,因此這些容器都具有了同樣的生命周期并可以通過JMX進行管理。

3. 再看看我們的容器結構

我們之前說, 如果從宏觀上講容器, 畫畫圖, 講講就好了, 就可以在腦海里形成一個映象, 今天, 我們要好好的講講容器, 從代碼層面去理解他們. 這樣一來, 也順便把我們的容器組件也講了, 等于又講了生命周期組件, 還有容器組件. 一舉兩得. 哈哈哈. 好吧, 不扯了, 回來, 我們繼續講容器. 還是先來一張圖吧:

從上圖中我們可以看到: StandardServer、StandardService、Connector、StandardContext這些容器,彼此之間都有父子關系,每個容器都可能包含零個或者多個子容器,這些子容器可能存在不同類型或者相同類型的多個. 所以他們都包含的關系, 如果讓你來設計這些容器的生命周期, 你會用什么設計模式呢?

4. 容器初始化, 開始 Debug

首先我們啟動 main 方法:


    public static void main(String args[]) {
        try {
            // 命令
            String command = "start";
            // 如果命令行中輸入了參數
            if (args.length > 0) {
                // 命令 = 最后一個命令
                command = args[args.length - 1];
            }
            // 如果命令是啟動
            if (command.equals("startd")) {
                args[args.length - 1] = "start";
                daemon.load(args);
                daemon.start();
            }
            // 如果命令是停止了
            else if (command.equals("stopd")) {
                args[args.length - 1] = "stop";
                daemon.stop();
            }
            // 如果命令是啟動
            else if (command.equals("start")) {
                daemon.setAwait(true);// bootstrap 和 Catalina 一脈相連, 這里設置, 方法內部設置 Catalina 實例setAwait方法
                daemon.load(args);// args 為 空,方法內部調用 Catalina 的 load 方法.
                daemon.start();// 相同, 反射調用 Catalina 的 start 方法 ,至此,啟動結束
            } else if (command.equals("stop")) {
                daemon.stopServer(args);
            } else if (command.equals("configtest")) {
                daemon.load(args);
                if (null==daemon.getServer()) {
                    System.exit(1);
                }
                System.exit(0);
            } else {
                log.warn("Bootstrap: command \"" + command + "\" does not exist.");
            }
        } catch (Throwable t) {
            // Unwrap the Exception for clearer error reporting
            if (t instanceof InvocationTargetException &&
                    t.getCause() != null) {
                t = t.getCause();
            }
            handleThrowable(t);
            t.printStackTrace();
            System.exit(1);
        }
    }

熟悉這個方法或者看過我們上篇文章的同學都知道, 我已經把類加載那部分代碼去除了, 因為我們今天不研究類加載. 所以 ,我們看邏輯, 首先, 判斷命令是什么, 我們現在的命令肯定是 start 啊, 所以進入 else if 塊, 調用 load 方法 , 進入 load 方法, 可以看到, 該方法實際上就是 Catalina 類的 load 方法, 那么我們進入 Catalina 類的 load 方法看看(方法很長, 樓主去除了和今天的模塊無關的代碼):

public void load() {
        // Start the new server
        try {
            getServer().init();
        } catch (LifecycleException e) {
            if (Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE")) {
                throw new java.lang.Error(e);
            } else {
                log.error("Catalina.start", e);
            }
        }
 }

可以看到, 這里有一個我們今天感興趣的方法, getServer.init(), 這個方法看名字是啟動 Server 的初始化, 而 Server 是我們上面圖中最外層的容器. 因此, 我們去看看該方法, 也就是LifecycleBase.init() 方法. 該方法是一個模板方法, 只是定義了一個算法的骨架, 將一些細節算法延遲到了子類中. 看, 我們又學到了一個設計模式. 我們看看該方法:

 @Override
    public final synchronized void init() throws LifecycleException {
        // 1
        if (!state.equals(LifecycleState.NEW)) {
            invalidTransition(Lifecycle.BEFORE_INIT_EVENT);
        }
        // 2
        setStateInternal(LifecycleState.INITIALIZING, null, false);

        try {
            // 模板方法
            /**
             * 采用模板方法模式來對所有支持生命周期管理的組件的生命周期各個階段進行了總體管理,
             * 每個需要生命周期管理的組件只需要繼承這個基類,
             * 然后覆蓋對應的鉤子方法即可完成相應的聲明周期階段的管理工作
             */
            initInternal();
        } catch (Throwable t) {
            ExceptionUtils.handleThrowable(t);
            setStateInternal(LifecycleState.FAILED, null, false);
            throw new LifecycleException(
                    sm.getString("lifecycleBase.initFail",toString()), t);
        }

        // 3
        setStateInternal(LifecycleState.INITIALIZED, null, false);
    }

我們看看該方法, 這應該就是容器啟動的邏輯了, 先前我們定義了那么多狀態, 現在用上了. 首先判斷該方法的狀態, 如果不是 NEW, 則拋出異常, 否則則設置狀態為 INITIALIZING, 然后調用一個抽象方法 initInternal , 該方法由子類具體實現. 執行完則修改狀態為 INITIALIZED. 這里應該是使用了狀態模式. 依賴狀態時,同步該方法, 防止并發錯誤. tomcat 可以的.

5. 那么我們來看看 StandardServer 是如何實現 initInternal 方法的:

    @Override
    protected void initInternal() throws LifecycleException {
        
        super.initInternal();

        // Register global String cache
        // Note although the cache is global, if there are multiple Servers
        // present in the JVM (may happen when embedding) then the same cache
        // will be registered under multiple names
        onameStringCache = register(new StringCache(), "type=StringCache");

        // Register the MBeanFactory
        MBeanFactory factory = new MBeanFactory();
        factory.setContainer(this);
        onameMBeanFactory = register(factory, "type=MBeanFactory");
        
        // Register the naming resources
        globalNamingResources.init();
        
        // Populate the extension validator with JARs from common and shared
        // class loaders
        if (getCatalina() != null) {
            ClassLoader cl = getCatalina().getParentClassLoader();
            // Walk the class loader hierarchy. Stop at the system class loader.
            // This will add the shared (if present) and common class loaders
            while (cl != null && cl != ClassLoader.getSystemClassLoader()) {
                if (cl instanceof URLClassLoader) {
                    URL[] urls = ((URLClassLoader) cl).getURLs();
                    for (URL url : urls) {
                        if (url.getProtocol().equals("file")) {
                            try {
                                File f = new File (url.toURI());
                                if (f.isFile() &&
                                        f.getName().endsWith(".jar")) {
                                    ExtensionValidator.addSystemResource(f);
                                }
                            } catch (URISyntaxException e) {
                                // Ignore
                            } catch (IOException e) {
                                // Ignore
                            }
                        }
                    }
                }
                cl = cl.getParent();
            }
        }
        // Initialize our defined Services
        for (int i = 0; i < services.length; i++) {
            services[i].init();
        }
    }

6. LifecycleMBeanBase.initInternal() 實現

首先調用父類的 super.initInternal() 方法,此initInternal方法用于將容器托管到JMX,便于運維管理:

    @Override
    protected void initInternal() throws LifecycleException {
       
        // If oname is not null then registration has already happened via
        // preRegister().
        if (oname == null) {
            mserver = Registry.getRegistry(null, null).getMBeanServer();
            oname = register(this, getObjectNameKeyProperties());
        }
    }

7. LifecycleMBeanBase.register 方法實現

LifecycleMBeanBase 會調用自身的 register 方法, 該方法會將容器注冊到 MBeanServer:

    protected final ObjectName register(Object obj,
            String objectNameKeyProperties) {
        
        // Construct an object name with the right domain
        StringBuilder name = new StringBuilder(getDomain());
        name.append(':');
        name.append(objectNameKeyProperties);

        ObjectName on = null;

        try {
            on = new ObjectName(name.toString());
            // 核心實現:registerComponent
            Registry.getRegistry(null, null).registerComponent(obj, on, null);
        } catch (MalformedObjectNameException e) {
            log.warn(sm.getString("lifecycleMBeanBase.registerFail", obj, name),
                    e);
        } catch (Exception e) {
            log.warn(sm.getString("lifecycleMBeanBase.registerFail", obj, name),
                    e);
        }

        return on;
    }
    

該方法內部核心方法是 Registry. registerComponent, 在org.apache.catalina.util 包下, 我們看看該方法實現。

8. Registry.registerComponent 方法實現

  public void registerComponent(Object bean, ObjectName oname, String type)
           throws Exception
    {
        if( log.isDebugEnabled() ) {
            log.debug( "Managed= "+ oname);
        }

        if( bean ==null ) {
            log.error("Null component " + oname );
            return;
        }

        try {
            if( type==null ) {
                type=bean.getClass().getName();
            }

            ManagedBean managed = findManagedBean(bean.getClass(), type);

            // The real mbean is created and registered
            DynamicMBean mbean = managed.createMBean(bean);

            if(  getMBeanServer().isRegistered( oname )) {
                if( log.isDebugEnabled()) {
                    log.debug("Unregistering existing component " + oname );
                }
                getMBeanServer().unregisterMBean( oname );
            }

            getMBeanServer().registerMBean( mbean, oname);
        } catch( Exception ex) {
            log.error("Error registering " + oname, ex );
            throw ex;
        }
    }

該方法會為當前容器創建一個 DynamicMBean , 并且注冊到MBeanServer。調用 MBeanServer.registerMBean() 方法。而 MBeanServer 在 javax.management, 也就是 rt.jar 中,該包由 java 的 BootStrap 啟動類加載器加載。

注冊進MBeanServer 的 key 是什么呢? 相信細心的同學會注意到 LifecycleMBeanBase.getObjectNameKeyProperties 和 LifecycleMBeanBase.getDomain 方法 和
LifecycleMBeanBase.getDomainInternal 方法, 這三個方法由具體子類實現,會生成一個專屬于容器的key。格式為:Catalina:type=Server, 這是 Server 容器的 key, debug 可以看出來:

9. JMX 如何管理 組件?

至此, 我們已經知道 Tomcat 是如何將容器注冊到 MBeanServer 中的。 那么注冊到 MBeanServer 中后是什么樣子呢?我們看圖:

這是 JDK 自帶的 JvisualVM 工具, 添加了 MBeans 插件, 就可以遠程操作容器中的 組件了, 可以看到 Service 容器暴漏了很多接口, 用于運維人員管理容器和組件。

10. 回到 StandardServer.initInternal 方法

好了, 我們回到 StandardServer.initInternal 方法, 回到我們夢最開始的地方,super.initInternal 方法就是將容器注冊到 JMX 中。 那下面的邏輯是做什么的呢? 在執行完父類的 super.initInternal 的方法后, 該方法又注冊個兩個 JMX 。然后尋啟動子容器的 init 方法:

    // Initialize our defined Services
        for (int i = 0; i < services.length; i++) {
            services[i].init();
        }

而子容器的 init 方法和 Server 的 init 方法的邏輯基本一致,所以不再贅述。

11. 執行完 getServer().init() 方法后做什么------容器啟動

Bootstrap 的 load 方法調用了 Catalina 的 load 方法 ,該方法調用了Server 的init方法,執行完初始化過程,當然就是要執行 start 方法了, 那么如何執行呢?

Bootstrap 調用了 Catalina 的 start 方法,該方法也同樣執行了 Server 的 start 方法, 該方法的具體實現也在LifecycleBase 中:

@Override
    public final synchronized void start() throws LifecycleException {
        
        if (LifecycleState.STARTING_PREP.equals(state) ||
                LifecycleState.STARTING.equals(state) ||
                LifecycleState.STARTED.equals(state)) {
            
            if (log.isDebugEnabled()) {
                Exception e = new LifecycleException();
                log.debug(sm.getString("lifecycleBase.alreadyStarted",
                        toString()), e);
            } else if (log.isInfoEnabled()) {
                log.info(sm.getString("lifecycleBase.alreadyStarted",
                        toString()));
            }
            
            return;
        }
        
        if (state.equals(LifecycleState.NEW)) {
            init();
        } else if (state.equals(LifecycleState.FAILED)){
            stop();
        } else if (!state.equals(LifecycleState.INITIALIZED) &&
                !state.equals(LifecycleState.STOPPED)) {
            invalidTransition(Lifecycle.BEFORE_START_EVENT);
        }

        setStateInternal(LifecycleState.STARTING_PREP, null, false);

        try {
            startInternal();
        } catch (Throwable t) {
            ExceptionUtils.handleThrowable(t);
            setStateInternal(LifecycleState.FAILED, null, false);
            throw new LifecycleException(
                    sm.getString("lifecycleBase.startFail",toString()), t);
        }

        if (state.equals(LifecycleState.FAILED) ||
                state.equals(LifecycleState.MUST_STOP)) {
            stop();
        } else {
            // Shouldn't be necessary but acts as a check that sub-classes are
            // doing what they are supposed to.
            if (!state.equals(LifecycleState.STARTING)) {
                invalidTransition(Lifecycle.AFTER_START_EVENT);
            }
            
            setStateInternal(LifecycleState.STARTED, null, false);
        }
    }

12. StandardServer.startInternal 啟動容器方法實現

可以看到該方法對狀態的判斷特別多,我們感興趣的是 try 塊中的 startInternal() 方法, 同樣, 該方法也是個抽象方法,需要子類去具體實現自己的啟動邏輯。我們看看Server 的啟動邏輯:

 @Override
    protected void startInternal() throws LifecycleException {

        fireLifecycleEvent(CONFIGURE_START_EVENT, null);
        setState(LifecycleState.STARTING);//將自身狀態更改為LifecycleState.STARTING;
        globalNamingResources.start();
        // Start our defined Services
        synchronized (services) {
            for (int i = 0; i < services.length; i++) {
                services[i].start();// 啟動所有子容器
            }
        }
    }

13. LifecycleSupport.fireLifecycleEvent()方法實現

該方法首先執行自己的fireLifecycleEvent方法, 該方法內部是LifecycleSupport.fireLifecycleEvent()方法, 我們進入該方法看個究竟:

public void fireLifecycleEvent(String type, Object data) {
        // 事件監聽,觀察者模式的另一種方式
        LifecycleEvent event = new LifecycleEvent(lifecycle, type, data);
        LifecycleListener interested[] = listeners;// 監聽器數組 關注 事件(啟動或者關閉事件)
        // 循環通知所有生命周期時間偵聽器????
        for (int i = 0; i < interested.length; i++)
            // 每個監聽器都有自己的邏輯
            interested[i].lifecycleEvent(event);
    }

該方法很簡單, 樓主沒有刪一行代碼, 首先, 創建一個事件對象, 然通知所有的監聽器發生了該事件.并做響應.那么 Server 有哪些監聽器呢?

這些監聽器將根據這個事件的類型做出響應.

14. 我們回到 startInternal 方法, 啟動所有容器

事件監聽結束之后, 調用 setState(LifecycleState.STARTING); 表明狀態時開始中, 并且循環啟動子容器, 這里的 Server 啟動的是Service 數組, 循環啟動他們的 start 方法. 以此類推. 啟動所有的容器:

    synchronized (services) {
            for (int i = 0; i < services.length; i++) {
                services[i].start();// 啟動所有子容器
            }
        }

現在我們關注的是Server 容器, 因此, Server 會啟動 services 數組中的所有 Service 組件。該方法就完成了通知所有監聽器發送了啟動事件,然后使用觀察者模式,啟動所有子容器,然后子容器繼續遞歸啟動。最后修改自己的狀態并告訴監聽器。

15. 總結

其實樓主在啃代碼最深的感觸就是設計模式, 真的很牛逼,不知道同學們發現了幾個設計模式,樓主在本篇文章中發現了狀態模式, 觀察者模式,模板方法, 事件監聽,代理模式。真的收益良多。不枉樓主每天看代碼。

還有就是對 Tomcat 生命周期組件的總結。我們再看看我們的類圖:

tomcat 的主要容器都繼承了 LifecycleMBeanBase 抽象類,該類中關于 init 和 start 兩個模板方法。定義了主要算法骨架,而方法中又都有抽象方法,需要子類自己去實現。而 LifecycleBase 中又定義了如何實現事件監聽代理,LifecycleBase 依賴 LifecycleSupport 去完成真正的事件監聽。對了,監聽器是如何添加進 LifecycleSupport 的呢?LifecycleSupport 中含有
addLifecycleListener 方法。該方法也是被LifecycleBase代理的。而每個容器下面的子容器也是使用相同的邏輯完成初始化和啟動。父容器和子容器使用了聚合的方式設計。

可以說, tomcat的容器的生命周期組件設計是非常牛逼的。我們閱讀源碼不僅能了解他的設計原理,也能同大師交流,學會更多。

好了, 今天的深入理解 Tomcat(六)源碼剖析Tomcat 啟動過程----生命周期和容器組件就到這里,謝謝大家的耐心,再這個世界,耐心和堅持是無比珍貴的。尤其是程序員。

good luck !!!!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,835評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,676評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,730評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,118評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,873評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,266評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,330評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,482評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,036評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,846評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,025評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,575評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,279評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,684評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,953評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,751評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,016評論 2 375

推薦閱讀更多精彩內容