“王大錘の非誠勿擾” —— Spring IoC / DI 思想詳述

本文參考地址:

《spring Ioc/DI的理解》
《關於Spring IOC (DI-依賴注入)你需要知道的一切》
《一、IOC和DI的概念》
《深入理解IoC/DI》
《spring IOC篇二:xml的核心邏輯處理》

**溫馨提示:**前方內容會引起認真怪和女權者些許不適,請出門左手邊右拐。


一. 王大錘的相親市場

我叫王大錘,是個碼農,我們這個行業號稱“人傻錢多速來”,不信?呵呵呵呵呵呵呵……

我的職業是碼農,工作內容是 new 一個對象,日常聊天是如何找一個對象,睡覺是做夢如何 new 一個白富美對象陪我走上人生巔峯。總之,我沒有對象。

公司的同事連順看我工作繁忙無暇撩妹,同時又日漸飢渴難耐,最後還是建議我去婚介公司碰碰運氣,也許有個好運氣,或者找個盤接一下,再不濟也能遇見一羣飢渴男一起回家組隊打 Dota。於是我走到了春天婚介公司,踏上了登上人生巔峯之路。

我叫王大錘,是個單身狗

大錘進入了春天婚介公司之後,主要辦了三件事:

  1. 進入春天婚介公司
  2. 按照婚介公司要求,填寫個人用戶簡歷
  3. 婚介公司告訴大錘,等待我們下一次的聯誼事宜:到時候會用很多本公司用戶參加,每個用戶都有自己的個人條件,屆時可進行配對或組隊;

大錘想了想還有點小激動,然後就回了公司,打開了自己的 Markdown,開始寫起了一篇控制反轉 (IoC)依賴注入 (DI) 相關的教程。

王大錘的人生巔峯之路

二. Spring IoC / DI 的簡單理解

《spring Ioc/DI的理解》 一文中,作者用人力資源局的例子方便讀者的理解。所以筆者按照自己對 IoC / DI 的理解,也編了一個王大錘婚介公司之旅的故事,用段子的方式寫出來,以期加深對自己和讀者的印象。

2.1 IoC 與 DI 的定義

首先,筆者需要明確說明 IoC 與 DI 的定義。

控制反轉 (Inversion of Control,即 IoC) 是一種設計思想。在 Java 開發中,IoC 意味着將你設計好的對象交給容器控制,而不是傳統的在你的對象內部直接控制。同時 IoC 也是面向對象編程中的一種設計原則,可以用來減低計算機代碼之間的耦合度。
對於 IoC,它最常用的一種手段叫做依賴注入 (Dependency Injection,即 DI)。DI 通過控制反轉,對象在被創建的時候,由一個調控系統內所有對象的外界實體,將其所依賴的對象的引用傳遞給它。

上述定義也許比較難懂,所以筆者講了開頭的故事。故事中的春天婚介公司,就是我們經常使用的經典的 Spring IoC 容器
將上面段子的名詞與 Spring 內容一一對應,則如下所示:

  • 婚介公司 —— Spring IoC 容器
  • 用戶徵婚簡歷 —— Spring beans
  • 一次聯誼活動 —— xml 配置文件
  • 月老 —— 開發者(我)

2.2 IoC 的三個經典問題

對於 IoC 有三個經典問題:**誰控制誰?控制了什麼?怎麼實現了反轉?**很多博客裏都進行了一個回答,筆者也按照自己的故事模式進行一個回答。

  1. “誰控制誰?”IoC / DI 容器控制應用程序
    • 其實可以把 IoC 當做一個存儲對象的容器,我們在開發中形成的對象都可以交給 Spring IoC 容器做一個統一的規範管理;
    • 我們在開發中形成的對象可以用一個 Spring bean 來表示,所以可以聯想一下,Spring IoC 容器就是婚介公司,每個對象都向這個婚介公司投遞了徵婚簡歷,所有用戶簡歷全部由婚介公司調配。所以 IoC 容器就充當了一個婚姻介紹的角色;
  2. 控制了什麼?:IoC / DI 容器控制對象本身的創建、實例化,以及控制對象之間的依賴關係
    • 開發之中的對象已經全部交由 IoC 容器來管理了,那我們在獲取對象的時候,就得由 IoC 容器來給我們提供;
    • 例:我們想要和一個妹子配對:
      • 平常情況下,需要主動自己上去撩妹自己要(在實際工程中,即調用目標對象的 get API );
      • 現在既然我和妹子都是婚介公司的用戶(都註冊了 bean),婚介公司控制了我們對於對象的獲取,那麼就得通過婚介公司來把妹子給你(在實際工程中,即調用 Spring 的 getBean 方法,中間的 BeanDefinition 等細節內容暫且不表);
  3. 怎麼實現了反轉?:主要體現在控制權的反轉。因爲現在應用程序不能主動去獲取外部資源了,而是被動等待 IoC / DI 容器給它注入它所需要的資源,所以稱之爲反轉。
    • 例:依舊用上面的例子:我們想要和一個妹子配對:
      • 平常情況下,我們直接去找妹子要過來,這種事情是我們自己去做的,控制權在我們手裏(實際工程中,就是在 classA 中需要一個 classB 的實例,所以就在 classA 中直接 new 了一個 classB 的實例來使用);
      • 現在既然我和妹子都是婚介公司的用戶,那麼向婚介公司要求介紹這個妹子,讓婚介公司把妹子交給我們(實際工程中,就是通知 Spring IoC 容器“我需要 classB 的實例,你需要給我弄一個,然後把這個實例傳給 classA”);
    • 這樣一對比,就發現創建權與控制權都從開發者身上轉移到了 Spring IoC 容器上,即實現了控制的反轉;

2.3 DI 的三個經典問題

同樣,DI 也存在三個經典問題:誰依賴誰?誰注入了誰?注入了什麼?

  1. “誰依賴誰?”應用程序依賴於 IoC 容器
    • 上面也提到了我們找婚介公司介紹妹子配對的流程,可以看出我們用戶是依賴於婚介公司的,也就是應用程序依賴於 IoC 容器;
  2. “誰注入了誰?”:IoC 容器把對象注入於應用程序
    • 依舊是我們找婚介公司介紹妹子配對的流程,婚介公司把同爲用戶的妹子給了我們,就相當於 IoC 容器將對象注入到了應用程序之中;
    • 這種我們需要了對象,IoC 將對象給我們的過程,就是依賴注入。
  3. “注入了什麼?”:注入應用程序需要的外部資源,比如有依賴關係的對象;
    • 婚介公司把同爲用戶的妹子給了我們,就相當於 IoC 容器將對象注入到了應用程序之中;

此時,筆者可以通過一個類比,來把依賴注入的關係進行說明:

  1. 一個 xml 配置文件中,定義了若干 Spring beans
    • 即在一次聯誼活動中,會有很多用戶參加;
  2. 對於這些 Spring beans,就是定義 bean 時各種各樣的屬性定義;
    • 對應於這些用戶,就是說每一個用戶都有自己的個人條件;
    • 所謂個人條件,就是身長八尺,容貌甚偉,有房有車,Q大H好,醫卜星象門門會,鋼琴攝影樣樣通之類的;
  3. 根據開發者在 xml 配置文件中的定義,形成依賴關係。對於一個 bean 的依賴,可以依賴於一個 bean,也可以依賴於多個 bean;例如 bean 定義的 xml 配置文件中,會有類似於 p 命名空間的屬性注入 (p:name=“qixiaoxia”),或者是 ref 依賴關係注入 (pcbrand-ref=MacBookPro) 之類的配置關係,通過這些配置形成了依賴關係;
    • 對於這次聯誼活動,月老牽線,一金童一玉女成功配對,喜結連理,從今以後過上沒羞沒臊的生活;當然也可能某個老司機勾搭上了多個用戶,形成了一個只屬於自己的小團伙,從此開啓了它的 S8 征戰之旅 (RNG IG 加油衝鴨!!!);

:在《深入理解IoC/DI》中作者用一問一答的形式闡述了控制、依賴、注入等關係,以及與 IoC/DI 相關內容。本文有類似借鑑。

三. DI 的實現原理解析

控制反轉 IoC 與依賴注入 DI 之間的關係,控制反轉是目的,依賴注入是實現控制反轉的手段。前面也提到,如果在傳統模式中,A 類依賴於 B 類,就是在 A 類中 new 一個 B 類,或者是用 A 類的 set 方法將 B 類實例的引用注入 A 類。
但是 IoC 將生成類的方式把傳統模式反了過來,即開發人員不需要調用 new,而是在需要類的時候,由框架注入,由 DI 實現。即控制對象生成的權利,從自己轉移給了框架(即 Spring),或者比較淺顯的理解爲轉移給了 Spring 的 xml 配置文件。

筆者爲了模擬 DI 依賴注入的實現過程,按照文中的相親市場寫了一個簡單的 demo,用來測試依賴注入在源碼層面的實現。

測試源碼筆者已經上傳到筆者的 Github 上,地址:spring/DI

在 demo 中筆者設置了兩個類:

  • 用戶 User
  • 興趣愛好 Hobby

其中的 User 類中有三個屬性:

  • String name: 姓名
  • Hobby hobby: 興趣愛好
  • User partner: 伴侶

每個用戶都有自己的姓名,一個愛好,還有配對完畢的伴侶。筆者的 bean xml 配置文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
              http://www.springframework.org/schema/beans/spring-beans.xsd"
       xmlns:p="http://www.springframework.org/schema/p">

    <bean id="qixiaoxia" class="com.grq.spring.DI.User"
          p:name="qixiaoxia"
          p:hobby-ref="qixiaoxiaHobby"
          p:partner-ref="girlFriend"/>
    <bean id="girlFriend" class="com.grq.spring.DI.User"
          p:name="nsy"
          p:partner-ref="qixiaoxia"/>
    <bean id="qixiaoxiaHobby" class="com.grq.spring.DI.Hobby"
          p:name="piano" p:level="Lv.8"/>
</beans>

Hobby 類定義源碼:

public class Hobby {
    private String name;
    private String level;
    // get, set, 構造函數略
    // ...
    @Override
    public String toString() {
        return "Hobby{" +
                "name='" + name + '\'' +
                ", level='" + level + '\'' +
                '}';
    }
}

User 類定義源碼:

public class User {
    private String name;
    private Hobby hobby;
    private User partner;
    // set, get, 構造函數略
    // ...
    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", hobby=" + hobby +
                ", partner=\'" + partner.getName() + "\'}";
    }
}

測試用的 main 方法也很簡單:

public class DITest {
    public static void main(String[] args) {
        BeanFactory factory = new XmlBeanFactory(new ClassPathResource("DITest.xml"));
        User user = (User) factory.getBean("qixiaoxia");
        System.out.println(user);
    }
}

從 bean xml 配置文件解析內容的方法入口是 XmlBeanDefinitionReader # loadBeanDefinitions 方法,源碼及註釋如下:

    public int loadBeanDefinitions(EncodedResource encodedResource) throws BeanDefinitionStoreException {
        // 斷言要解析的XML文件配置存在,不能爲空
        Assert.notNull(encodedResource, "EncodedResource must not be null");
        // 向日志系統輸出日誌系統,輸出 XML bean 的加載源
        if (logger.isInfoEnabled()) {
            logger.info("Loading XML bean definitions from " + encodedResource.getResource());
        }
        // 獲取當前線程裏的 ThreadLocal 裏的變量集合
        Set<EncodedResource> currentResources = this.resourcesCurrentlyBeingLoaded.get();
        if (currentResources == null) {
            //如果爲空的情況下,重新申請一下 HashSet 集合
            currentResources = new HashSet<EncodedResource>(4);
            this.resourcesCurrentlyBeingLoaded.set(currentResources);
        }
        // 將 encodeResource 填加到當前線程的局部變量集合中
        if (!currentResources.add(encodedResource)) {
            throw new BeanDefinitionStoreException(
                    "Detected cyclic loading of " + encodedResource + " - check your import definitions!");
        }
        try {
            InputStream inputStream = encodedResource.getResource().getInputStream();
            try {
                InputSource inputSource = new InputSource(inputStream);
                // 如果設置了編譯方式,對輸入流進行編碼的設置
                if (encodedResource.getEncoding() != null) {
                    inputSource.setEncoding(encodedResource.getEncoding());
                }
                //========================================
                // 真正的從指定的 XML 文件中加載 Bean 的定義的關鍵方法
                //========================================
                return doLoadBeanDefinitions(inputSource, encodedResource.getResource());
            }
            finally {
                inputStream.close();
            }
        }
        catch (IOException ex) {
            throw new BeanDefinitionStoreException(
                    "IOException parsing XML document from " + encodedResource.getResource(), ex);
        }
        finally {
            // 釋放內存空間
            currentResources.remove(encodedResource);
            if (currentResources.isEmpty()) {
                this.resourcesCurrentlyBeingLoaded.remove();
            }
        }
    }

該方法中,最關鍵的方法是 doLoadBeanDefinitions 方法,它真正的從指定的 XML 文件中加載了 Bean 的定義。源碼及註釋如下:

    protected int doLoadBeanDefinitions(InputSource inputSource, Resource resource)
            throws BeanDefinitionStoreException {
        try {
            // 獲取 XML 的驗證方式,加載 XML 文件得到對應的 Document
            Document doc = doLoadDocument(inputSource, resource);
            // 根據返回的 Dcoument 註冊 Bean 信息
            return registerBeanDefinitions(doc, resource);
        }
        // 若干 catch 方法省略
        // .........................................
    }

doLoadBeanDefinitions 方法由兩個方法組成,一個是 doLoadDocument() 方法,其中獲取 XML 的驗證方式(如確定文件爲 DTD 或者 XSD 文件格式等相關信息),並將 xml 文檔信息放入 Document 實例對象中。該方法可在《spring IOC篇二:xml的核心邏輯處理》中查閱。

registerBeanDefinitions 是註冊 bean 的內容,其中依賴注入的過程就是在該部分進行的。源碼及註釋如下:

    public int registerBeanDefinitions(Document doc, Resource resource) throws BeanDefinitionStoreException {
        // 使用 DefaultBeanDefinitionDocumentReader 實例化 BeanDefinitionDocumentReader 對象
        BeanDefinitionDocumentReader documentReader = createBeanDefinitionDocumentReader();
        // 記錄統計前 BeanDefinition 的加載個數
        int countBefore = getRegistry().getBeanDefinitionCount();
        // 加載以及註冊 Bean
        // 這裏使用到了單一職責原則,將邏輯處理委託給單一的類進行處理,這個邏輯處理類就是 BeanDefinitionDocumentReader 對象
        documentReader.registerBeanDefinitions(doc, createReaderContext(resource));
        // 統計本次加載 Beanfinition 的個數
        return getRegistry().getBeanDefinitionCount() - countBefore;
    }

registerBeanDefinitions 是一個接口方法,它的具體實現是在 DefaultBeanDefinitionDocumentReader 中實現的:

    @Override
    public void registerBeanDefinitions(Document doc, XmlReaderContext readerContext) {
        this.readerContext = readerContext;
        this.logger.debug("Loading bean definitions");
        Element root = doc.getDocumentElement();
        // 核心方法
        this.doRegisterBeanDefinitions(root);
    }

最後 doRegisterBeanDefinitions 方法纔是實際解析 xml 文件內容的核心方法。

    protected void doRegisterBeanDefinitions(Element root) {
        BeanDefinitionParserDelegate parent = this.delegate;
        this.delegate = createDelegate(getReaderContext(), root, parent);
        //======================
        // 處理 profile 屬性
        //======================
        if (this.delegate.isDefaultNamespace(root)) {
            String profileSpec = root.getAttribute(PROFILE_ATTRIBUTE);
            if (StringUtils.hasText(profileSpec)) {
                String[] specifiedProfiles = StringUtils.tokenizeToStringArray(
                        profileSpec, BeanDefinitionParserDelegate.MULTI_VALUE_ATTRIBUTE_DELIMITERS);
                if (!getReaderContext().getEnvironment().acceptsProfiles(specifiedProfiles)) {
                    return;
                }
            }
        }
        // 空代碼留給子類去實現模板設計模式
        // 繼承 DefaultBeanDefinitionDocumentReader 的子類在 XML 解析前做一些處理,可以實現此方法
        preProcessXml(root);
        
        //==============================
        // 解析除了 profile 以外的默認屬性
        //==============================
        parseBeanDefinitions(root, this.delegate);
        
        // 空代碼留給子類去實現模板設計模式
        // 繼承 DefaultBeanDefinitionDocumentReader 的子類在 XML 解析後做一些處理,可以實現此方法
        postProcessXml(root);
        this.delegate = parent;
    }

在 doRegisterBeanDefinitions 方法中,主要作用有三個:

  • 處理了根節點 root 的 profile 屬性;
    • 在該例程中,並沒有使用到 profile 屬性。
  • 核心方法:調用 parseBeanDefinitions 方法,解析 bean 的基礎屬性。
  • 在解析 bean 基礎屬性的上下文處進行預處理 preProcessXml, 後處理 postProcessXml,但兩個方法在該類中並沒有實際實現,而是採用了模板設計模式,留給繼承的子類,實現覆蓋該方法;

核心方法 parseBeanDefinitions 源碼如下:

    // 從 XML 文件解析 Bean 的定義
    protected void parseBeanDefinitions(Element root, BeanDefinitionParserDelegate delegate) {
        if (delegate.isDefaultNamespace(root)) {
            // 獲取根節點的子節點列表
            NodeList nl = root.getChildNodes();
            // 遍歷子節點列表
            for(int i = 0; i < nl.getLength(); ++i) {
                Node node = nl.item(i);
                if (node instanceof Element) {
                    Element ele = (Element)node;
                    if (delegate.isDefaultNamespace(ele)) {
                        // 解析當前節點
                        this.parseDefaultElement(ele, delegate);
                    } else {
                        delegate.parseCustomElement(ele);
                    }
                }
            }
        } else {
            delegate.parseCustomElement(root);
        }
    }

    // 解析 Bean 默認元素
    private void parseDefaultElement(Element ele, BeanDefinitionParserDelegate delegate) {
        if (delegate.nodeNameEquals(ele, "import")) {
            this.importBeanDefinitionResource(ele);
        } else if (delegate.nodeNameEquals(ele, "alias")) {
            this.processAliasRegistration(ele);
        } else if (delegate.nodeNameEquals(ele, "bean")) {
            this.processBeanDefinition(ele, delegate);
        } else if (delegate.nodeNameEquals(ele, "beans")) {
            this.doRegisterBeanDefinitions(ele);
        }
    }

在覈心方法 parseBeanDefinitions 中,解析了 import, alias, bean, beans 四種標籤。我們的 bean xml 文件基本都是 <bean> 標籤,所以其中最核心的方法就是 processBeanDefinition 方法。processBeanDefinition 源碼如下:

    protected void processBeanDefinition(Element ele, BeanDefinitionParserDelegate delegate) {
        // Bean 定義持有者
        BeanDefinitionHolder bdHolder = delegate.parseBeanDefinitionElement(ele);
        if (bdHolder != null) {
            // 裝飾 Bean 定義,爲各個 bean 添加屬性信息,其中包含依賴關係的添加
            bdHolder = delegate.decorateBeanDefinitionIfRequired(ele, bdHolder);

            try {
                BeanDefinitionReaderUtils.registerBeanDefinition(bdHolder, this.getReaderContext().getRegistry());
            } catch (BeanDefinitionStoreException var5) {
                this.getReaderContext().error("Failed to register bean definition with name '" + bdHolder.getBeanName() + "'", ele, var5);
            }

            this.getReaderContext().fireComponentRegistered(new BeanComponentDefinition(bdHolder));
        }
    }

processBeanDefinition 方法源碼中,decorateBeanDefinitionIfRequired 方法將 xml 配置文件中各個 bean 的屬性“裝飾”到該 bean 的定義中,在該方法中實現了 bean 之間的依賴注入。源碼如下:

    public BeanDefinitionHolder decorateBeanDefinitionIfRequired(Element ele, BeanDefinitionHolder definitionHolder, BeanDefinition containingBd) {
        BeanDefinitionHolder finalDefinition = definitionHolder;
        // 獲取當前 bean 的所有屬性值
        NamedNodeMap attributes = ele.getAttributes();

        // 遍歷所有屬性值
        for(int i = 0; i < attributes.getLength(); ++i) {
            Node node = attributes.item(i);
            //===============================================
            // 關鍵方法,對當前屬性值進行裝飾(包含依賴關係注入的步驟)
            //===============================================
            finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
        }

        NodeList children = ele.getChildNodes();

        for(int i = 0; i < children.getLength(); ++i) {
            Node node = children.item(i);
            if (node.getNodeType() == 1) {
                finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
            }
        }

        return finalDefinition;
    }

decorateBeanDefinitionIfRequired 方法中遍歷部分的關鍵方法中,再向下可進入到裝飾屬性的內容實現方法 decorate。decorate 方法將一個 bean 在 xml 文件中的屬性定義賦值進入 BeanDefinition 中,該過程中當然也包含了 DI 依賴注入。decorate 方法是在 SimplePropertyNamespaceHandler 中實現的,源碼如下所示:

    public BeanDefinitionHolder decorate(Node node, BeanDefinitionHolder definition, ParserContext parserContext) {
        if (node instanceof Attr) {
            Attr attr = (Attr)node;
            // 屬性名
            String propertyName = parserContext.getDelegate().getLocalName(attr);
            // 屬性值
            String propertyValue = attr.getValue();
            // 屬性值集合
            MutablePropertyValues pvs = definition.getBeanDefinition().getPropertyValues();
            // 如果屬性值集合中已經包含了當前屬性名,則報出錯誤
            if (pvs.contains(propertyName)) {
                parserContext.getReaderContext().error("Property '" + propertyName + "' is already defined using both <property> and inline syntax. Only one approach may be used per property.", attr);
            }

            //===================================
            // 依賴注入實現:
            //   如果屬性名是以 "-ref" 結尾的,則將該屬性設置爲被依賴的 bean,即 RuntimeBeanReference
            //===================================
            if (propertyName.endsWith("-ref")) {
                propertyName = propertyName.substring(0, propertyName.length() - "-ref".length());
                // 將該屬性設置爲被依賴的 bean,即 RuntimeBeanReference,添加進入屬性值集合中
                pvs.add(Conventions.attributeNameToPropertyName(propertyName), new RuntimeBeanReference(propertyValue));
            } else {
                pvs.add(Conventions.attributeNameToPropertyName(propertyName), propertyValue);
            }
        }

        return definition;
    }

:關於 RuntimeBeanReference 的內容,可以在文章《Spring Bean 的解析 RuntimeBeanReference》一文中進行查閱瞭解。

對於例程中名爲 “qixiaoxia” 的 bean 進行調試,在 decorateBeanDefinitionIfRequired 方法中循環遍歷 decorate 方法之前的 beandefinition 變量的值如下圖所示:

decorateBeanDefinitionIfRequired 遍歷之前的屬性值

經過 decorateBeanDefinitionIfRequired 方法循環賦值之前,如上圖所示,propertyValueList 爲空集。但在循環賦值後,結果如下圖所示:

decorateBeanDefinitionIfRequired 遍歷之後的屬性值
propertyValueList 加入了三個值,這三個值與 xml 配置文件中 “qixiaoxia” bean 的定義相同,而且包含了其中以 “-ref” 爲屬性名的兩個屬性。可以對比 bean 的定義,以及上圖中循環後的 beandefinition 值的結果:

<bean id="qixiaoxia" class="com.grq.spring.DI.User"
      p:name="qixiaoxia"
      p:hobby-ref="qixiaoxiaHobby"
      p:partner-ref="girlFriend"/>

這樣就將 bean 之間的依賴關係編輯完畢。往後將各個 beanDefinition 存入 Map 中並註冊,繼續運行 registerBeanDefinition 方法,即可完成 bean 從 xml 配置文件加載的操作。

至此,DI 依賴注入的源碼分析完畢。

四. 後記

第一次當標題黨,心裏還有點小激動呢 ~ 筆者用相親爲主題講了如何理解 IoC 和 DI,但標題黨不能白當,親還是要相的,萬一哪個有趣又美麗小姐姐看上我了呢?

筆者之自戀,有詩爲證:

鋼琴吉他 KTV,
攝影健身吹牛逼。
JAVA Python 還有 C,
有趣靈魂顏值帝。

最後獻上自拍一張,拜個晚年,各位中秋快樂 ~

自拍(手動滑稽)

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