【Dubbo原始碼閱讀系列】之 Dubbo XML 配置載入

豆子都一樣發表於2019-01-19

今天我們來談談 Dubbo XML 配置相關內容。關於這部分內容我打算分為以下幾個部分進行介紹:

  • Dubbo XML
  • Spring 自定義 XML 標籤解析
  • Dubbo 自定義 XML 標籤解析
  • DubboBeanDefinitionParser.parse()
  • End

Dubbo XML

在本小節開始前我們先來看下 Dubbo XML 配置檔案示例:

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

    <!-- provider's application name, used for tracing dependency relationship -->
    <dubbo:application name="demo-provider"/>

    <!-- use multicast registry center to export service -->
    <!--<dubbo:registry address="multicast://224.5.6.7:1234"/>-->
    <dubbo:registry address="zookeeper://10.14.22.68:2181"/>

    <!-- use dubbo protocol to export service on port 20880 -->
    <dubbo:protocol name="dubbo" port="20880"/>

    <!-- service implementation, as same as regular local bean -->
    <bean id="demoService" class="org.apache.dubbo.demo.provider.DemoServiceImpl"/>

    <!-- declare the service interface to be exported -->
    <dubbo:service interface="org.apache.dubbo.demo.DemoService" ref="demoService"/>
</beans>
複製程式碼

在這段配置檔案中有一些以 dubbo 開頭的 xml 標籤,直覺告訴我們這種標籤和 dubbo 密切相關。那麼這些標籤的用途是什麼?又是如何被識別的呢? 我們結合 Spring 自定義 xml 標籤實現相關內容來聊聊 Dubbo 是如何定義並載入這些自定義標籤的。

Spring 自定義 XML 標籤解析

Dubbo 中的自定義 XML 標籤實際上是依賴於 Spring 解析自定義標籤的功能實現的。網上關於 Spring 解析自定義 XML 標籤的文章也比較多,這裡我們僅介紹下實現相關功能需要的檔案,給大家一個直觀的印象,不去深入的對 Spring 自定義標籤實現作詳細分析。

  1. 定義 xsd 檔案
    XSD(XML Schemas Definition) 即 XML 結構定義。我們通過 XSD 檔案不僅可以定義新的元素和屬性,同時也使用它對我們的 XML 檔案規範進行約束。 在 Dubbo 專案中可以找類似實現:dubbo.xsd
  2. spring.schemas
    該配置檔案約定了自定義名稱空間和 xsd 檔案之間的對映關係,用於 spring 容器感知我們自定義的 xsd 檔案位置。
http\://dubbo.apache.org/schema/dubbo/dubbo.xsd=META-INF/dubbo.xsd
http\://code.alibabatech.com/schema/dubbo/dubbo.xsd=META-INF/compat/dubbo.xsd
複製程式碼
  1. spring.handlers
    該配置檔案約定了自定義名稱空間和 NamespaceHandler 類之間的對映關係。 NamespaceHandler 類用於註冊自定義標籤解析器。
http\://dubbo.apache.org/schema/dubbo=org.apache.dubbo.config.spring.schema.DubboNamespaceHandler
http\://code.alibabatech.com/schema/dubbo=org.apache.dubbo.config.spring.schema.DubboNamespaceHandler
複製程式碼
  1. 名稱空間處理器
    名稱空間處理器主要用來註冊 BeanDefinitionParser 解析器。對應上面 spring.handlers 檔案中的 DubboNamespaceHandler
public class DubboNamespaceHandler extends NamespaceHandlerSupport {
    @Override
    public void init() {
        registerBeanDefinitionParser("application", new DubboBeanDefinitionParser(ApplicationConfig.class, true));
        // 省略...
        registerBeanDefinitionParser("annotation", new AnnotationBeanDefinitionParser());
    }
}
複製程式碼
  1. BeanDefinitionParser 解析器
    實現 BeanDefinitionParser 介面中的 parse 方法,用於自定義標籤的解析。Dubbo 中對應 DubboBeanDefinitionParser 類。

Dubbo 解析自定義 XML 標籤

終於進入到本文的重頭戲環節了。在介紹 Dubbo 自定義 XML 標籤解析前,先放一張圖幫助大家理解以下 Spring 是如何從 XML 檔案中解析並載入 Bean 的。

【Dubbo原始碼閱讀系列】之 Dubbo XML 配置載入
上圖言盡於 handler.parse() 方法,如果你仔細看了上文,對 parse() 應該是有印象的。
沒錯,在前一小結的第五點我們介紹了 DubboBeanDefinitionParser 類。該類有個方法就叫 parse()。那麼這個 parse() 方法有什麼用? Spring 是如何感知到我就要呼叫 DubboBeanDefinitionParser 類中的 parse() 方法的呢?我們帶著這兩個問題接著往下看。

BeanDefinitionParserDelegate

上面圖的流程比較長,我們先著重看下 BeanDefinitionParserDelegate 類中的幾個關鍵方法。

BeanDefinitionParserDelegate.java
public BeanDefinition parseCustomElement(Element ele, BeanDefinition containingBd) {
    // 獲取當前 element 的 namespaceURI
    // 比如 dubbo.xsd 中的為 http://dubbo.apache.org/schema/dubbo
    String namespaceUri = this.getNamespaceURI(ele);
    // 根據 URI 獲取對應的 NamespaceHandler
    NamespaceHandler handler = this.readerContext.getNamespaceHandlerResolver().resolve(namespaceUri);
    if (handler == null) {
        this.error("Unable to locate Spring NamespaceHandler for XML schema namespace [" + namespaceUri + "]", ele);
        return null;
    } else {
        return handler.parse(ele, new ParserContext(this.readerContext, this, containingBd));
    }
}
複製程式碼

這個方法幹了三件事

  1. 獲取 element 元素的 namespaceURI,並據此獲取對應的 NamespaceHandler 物件。Dubbo 自定義標籤(比如 Dubbo:provider) namespaceUri 的值為 http://dubbo.apache.org/schema/dubbo;
  2. 根據 step1 獲取到的 namespaceUri ,獲取對應的 NamespaceHandler 物件。這裡會呼叫 DefaultNamespaceHandlerResolver 類的 resolve() 方法,我們下面會分析;
  3. 呼叫 handler 的 parse 方法,我們自定以的 handler 會繼承 NamespaceHandlerSupport 類,所以這裡呼叫的其實是 NamespaceHandlerSupport 類的 parse() 方法,後文分析;

一圖勝千言
在詳細分析 step2 和 step3 中涉及的 resolver()parse() 方法前,先放一張時序圖讓大家有個基本概念:

【Dubbo原始碼閱讀系列】之 Dubbo XML 配置載入

DefaultNamespaceHandlerResolver.java
public NamespaceHandler resolve(String namespaceUri) {
    Map<String, Object> handlerMappings = this.getHandlerMappings();
    // 以 namespaceUri 為 Key 獲取對應的 handlerOrClassName
    Object handlerOrClassName = handlerMappings.get(namespaceUri);
    if (handlerOrClassName == null) {
        return null;
    } else if (handlerOrClassName instanceof NamespaceHandler) {
        return (NamespaceHandler)handlerOrClassName;
    } else {
        // 如果不為空且不為 NamespaceHandler 的例項,轉換為 String 型別
        // DubboNamespaceHandler 執行的便是這段邏輯
        String className = (String)handlerOrClassName;

        try {
            Class<?> handlerClass = ClassUtils.forName(className, this.classLoader);
            // handlerClass 是否為 NamespaceHandler 的實現類,若不是則丟擲異常
            if (!NamespaceHandler.class.isAssignableFrom(handlerClass)) {
                throw new FatalBeanException("Class [" + className + "] for namespace [" + namespaceUri + "] does not implement the [" + NamespaceHandler.class.getName() + "] interface");
            } else {
                // 初始化 handlerClass
                NamespaceHandler namespaceHandler = (NamespaceHandler)BeanUtils.instantiateClass(handlerClass);
                // 執行 handlerClass類的 init() 方法
                namespaceHandler.init();
                handlerMappings.put(namespaceUri, namespaceHandler);
                return namespaceHandler;
            }
        } catch (ClassNotFoundException var7) {
            throw new FatalBeanException("NamespaceHandler class [" + className + "] for namespace [" + namespaceUri + "] not found", var7);
        } catch (LinkageError var8) {
            throw new FatalBeanException("Invalid NamespaceHandler class [" + className + "] for namespace [" + namespaceUri + "]: problem with handler class file or dependent class", var8);
        }
    }
}
複製程式碼

resolve() 方法用途是根據方法引數中的 namespaceUri 獲取對應的 NamespaceHandler 物件。這裡會先嚐試以 namespaceUri 為 key 去 handlerMappings 集合中取物件。 如果 handlerOrClassName 不為 null 且不為 NamespaceHandler 的例項。那麼嘗試將 handlerOrClassName 作為 className 並呼叫 BeanUtils.instantiateClass() 方法初始化一個 NamespaceHandler 例項。初始化後,呼叫其 init() 方法。這個 init() 方法比較重要,我們接著往下看。

DubboNamespaceHandler
public void init() {
    registerBeanDefinitionParser("application", new DubboBeanDefinitionParser(ApplicationConfig.class, true));
    registerBeanDefinitionParser("module", new DubboBeanDefinitionParser(ModuleConfig.class, true));
    registerBeanDefinitionParser("registry", new DubboBeanDefinitionParser(RegistryConfig.class, true));
    registerBeanDefinitionParser("monitor", new DubboBeanDefinitionParser(MonitorConfig.class, true));
    registerBeanDefinitionParser("provider", new DubboBeanDefinitionParser(ProviderConfig.class, true));
    registerBeanDefinitionParser("consumer", new DubboBeanDefinitionParser(ConsumerConfig.class, true));
    registerBeanDefinitionParser("protocol", new DubboBeanDefinitionParser(ProtocolConfig.class, true));
    registerBeanDefinitionParser("service", new DubboBeanDefinitionParser(ServiceBean.class, true));
    registerBeanDefinitionParser("reference", new DubboBeanDefinitionParser(ReferenceBean.class, false));
    registerBeanDefinitionParser("annotation", new AnnotationBeanDefinitionParser());
}

NamespaceHandlerSupport
private final Map<String, BeanDefinitionParser> parsers = new HashMap();
protected final void registerBeanDefinitionParser(String elementName, BeanDefinitionParser parser) {
    this.parsers.put(elementName, parser);
}
複製程式碼

DubboNamespaceHandler 類中的 init() 方法乾的事情特別簡單,就是新建 DubboBeanDefinitionParser 物件並將其放入 NamespaceHandlerSupport 類的 parsers 集合中。我們再回顧一下 parseCustomElement() 方法。

BeanDefinitionParserDelegate.java
public BeanDefinition parseCustomElement(Element ele, BeanDefinition containingBd) {
    // 省略...
    return handler.parse(ele, new ParserContext(this.readerContext, this, containingBd));
    // 省略...
}
複製程式碼

這裡會呼叫 NamespaceHandlerSupport 類的 parse() 方法。我們繼續跟蹤一下。

public BeanDefinition parse(Element element, ParserContext parserContext) {
    return this.findParserForElement(element, parserContext).parse(element, parserContext);
}
private BeanDefinitionParser findParserForElement(Element element, ParserContext parserContext) {
    String localName = parserContext.getDelegate().getLocalName(element);
    BeanDefinitionParser parser = (BeanDefinitionParser)this.parsers.get(localName);
    if (parser == null) {
        parserContext.getReaderContext().fatal("Cannot locate BeanDefinitionParser for element [" + localName + "]", element);
    }

    return parser;
}
複製程式碼

看到這裡大家有沒有一絲豁然開朗的感覺?之前的 resolve() 方法實際上就是根據當前 element 的 namespaceURI 獲取對應的 NamespaceHandler 物件(對於 Dubbo 來說是 DubboNamespaceHandler), 然後呼叫 DubboNamespaceHandler 中的 init() 方法新建 DubboBeanDefinitionParser 物件並註冊到 NamespaceHandlerSupport 類的 parsers 集合中。 然後 parser 方法會根據當前 element 物件從 parsers 集合中獲取合適的 BeanDefinitionParser 物件。對於 Dubbo 元素來說,實際上最後執行的是 DubboBeanDefinitionParser 的 parse() 方法。

DubboBeanDefinitionParser.parse()

最後我們再來看看 Dubbo 解析 XML 檔案的詳細實現吧。如果對具體實現沒有興趣可直接直接跳過。

private static BeanDefinition parse(Element element, ParserContext parserContext, Class<?> beanClass, boolean required) {
        RootBeanDefinition beanDefinition = new RootBeanDefinition();
        beanDefinition.setBeanClass(beanClass);
        beanDefinition.setLazyInit(false);
        String id = element.getAttribute("id");
        // DubboBeanDefinitionParser 構造方法中有對 required 值進行初始化;
        // DubboNamespaceHandler 類中的 init 方法會建立並註冊 DubboBeanDefinitionParser 類
        if ((id == null || id.length() == 0) && required) {
            String generatedBeanName = element.getAttribute("name");
            if (generatedBeanName == null || generatedBeanName.length() == 0) {
                if (ProtocolConfig.class.equals(beanClass)) {
                    generatedBeanName = "dubbo";
                } else {
                    // name 屬性為空且不為 ProtocolConfig 型別,取 interface 值
                    generatedBeanName = element.getAttribute("interface");
                }
            }
            if (generatedBeanName == null || generatedBeanName.length() == 0) {
                // 獲取 beanClass 的全限定類名
                generatedBeanName = beanClass.getName();
            }
            id = generatedBeanName;
            int counter = 2;
            while (parserContext.getRegistry().containsBeanDefinition(id)) {
                id = generatedBeanName + (counter++);
            }
        }
        if (id != null && id.length() > 0) {
            if (parserContext.getRegistry().containsBeanDefinition(id)) {
                throw new IllegalStateException("Duplicate spring bean id " + id);
            }
            // 註冊 beanDefinition
            parserContext.getRegistry().registerBeanDefinition(id, beanDefinition);
            // 為 beanDefinition 新增 id 屬性
            beanDefinition.getPropertyValues().addPropertyValue("id", id);
        }
        
        // 如果當前 beanClass 型別為 ProtocolConfig
        // 遍歷已經註冊過的 bean 物件,如果 bean 物件含有 protocol 屬性
        // protocol 屬性值為 ProtocolConfig 例項且 name 和當前 id 值一致,為當前 beanClass 物件新增 protocl 屬性
        if (ProtocolConfig.class.equals(beanClass)) {
            for (String name : parserContext.getRegistry().getBeanDefinitionNames()) {
                BeanDefinition definition = parserContext.getRegistry().getBeanDefinition(name);
                PropertyValue property = definition.getPropertyValues().getPropertyValue("protocol");
                if (property != null) {
                    Object value = property.getValue();
                    if (value instanceof ProtocolConfig && id.equals(((ProtocolConfig) value).getName())) {
                        definition.getPropertyValues().addPropertyValue("protocol", new RuntimeBeanReference(id));
                    }
                }
            }
        } else if (ServiceBean.class.equals(beanClass)) {
            // 如果當前元素包含 class 屬性,呼叫 ReflectUtils.forName() 方法載入類物件
            // 呼叫 parseProperties 解析其他屬性設定到 classDefinition 物件中
            // 最後設定 beanDefinition 的 ref 屬性為 BeanDefinitionHolder 包裝類
            String className = element.getAttribute("class");
            if (className != null && className.length() > 0) {
                RootBeanDefinition classDefinition = new RootBeanDefinition();
                classDefinition.setBeanClass(ReflectUtils.forName(className));
                classDefinition.setLazyInit(false);
                parseProperties(element.getChildNodes(), classDefinition);
                beanDefinition.getPropertyValues().addPropertyValue("ref", new BeanDefinitionHolder(classDefinition, id + "Impl"));
            }
        } else if (ProviderConfig.class.equals(beanClass)) {
            parseNested(element, parserContext, ServiceBean.class, true, "service", "provider", id, beanDefinition);
        } else if (ConsumerConfig.class.equals(beanClass)) {
            parseNested(element, parserContext, ReferenceBean.class, false, "reference", "consumer", id, beanDefinition);
        }
        Set<String> props = new HashSet<String>();
        ManagedMap parameters = null;
        for (Method setter : beanClass.getMethods()) {
            String name = setter.getName();
            if (name.length() > 3 && name.startsWith("set")
                    && Modifier.isPublic(setter.getModifiers())
                    && setter.getParameterTypes().length == 1) {
                Class<?> type = setter.getParameterTypes()[0];
                String propertyName = name.substring(3, 4).toLowerCase() + name.substring(4);
                String property = StringUtils.camelToSplitName(propertyName, "-");
                props.add(property);
                Method getter = null;
                try {
                    getter = beanClass.getMethod("get" + name.substring(3), new Class<?>[0]);
                } catch (NoSuchMethodException e) {
                    try {
                        getter = beanClass.getMethod("is" + name.substring(3), new Class<?>[0]);
                    } catch (NoSuchMethodException e2) {
                    }
                }
                if (getter == null
                        || !Modifier.isPublic(getter.getModifiers())
                        || !type.equals(getter.getReturnType())) {
                    continue;
                }
                if ("parameters".equals(property)) {
                    parameters = parseParameters(element.getChildNodes(), beanDefinition);
                } else if ("methods".equals(property)) {
                    parseMethods(id, element.getChildNodes(), beanDefinition, parserContext);
                } else if ("arguments".equals(property)) {
                    parseArguments(id, element.getChildNodes(), beanDefinition, parserContext);
                } else {
                    String value = element.getAttribute(property);
                    if (value != null) {
                        value = value.trim();
                        if (value.length() > 0) {
                        // 如果屬性為 registry,且 registry 屬性的值為"N/A",標識不會註冊到任何註冊中心
                        // 新建 RegistryConfig 並將其設定為 beanDefinition 的 registry 屬性
                            if ("registry".equals(property) && RegistryConfig.NO_AVAILABLE.equalsIgnoreCase(value)) {
                                RegistryConfig registryConfig = new RegistryConfig();
                                registryConfig.setAddress(RegistryConfig.NO_AVAILABLE);
                                beanDefinition.getPropertyValues().addPropertyValue(property, registryConfig);
                            } else if ("registry".equals(property) && value.indexOf(',') != -1) {
                                // 多註冊中心解析
                                parseMultiRef("registries", value, beanDefinition, parserContext);
                            } else if ("provider".equals(property) && value.indexOf(',') != -1) {
                                parseMultiRef("providers", value, beanDefinition, parserContext);
                            } else if ("protocol".equals(property) && value.indexOf(',') != -1) {
                                // 多協議
                                parseMultiRef("protocols", value, beanDefinition, parserContext);
                            } else {
                                Object reference;
                                if (isPrimitive(type)) {
                                    // type 為方法引數,type 型別是否為基本型別
                                    if ("async".equals(property) && "false".equals(value)
                                            || "timeout".equals(property) && "0".equals(value)
                                            || "delay".equals(property) && "0".equals(value)
                                            || "version".equals(property) && "0.0.0".equals(value)
                                            || "stat".equals(property) && "-1".equals(value)
                                            || "reliable".equals(property) && "false".equals(value)) {
                                        // 新老版本 xsd 相容性處理
                                        // backward compatibility for the default value in old version's xsd
                                        value = null;
                                    }
                                    reference = value;
                                } else if ("protocol".equals(property)
                                        && ExtensionLoader.getExtensionLoader(Protocol.class).hasExtension(value)
                                        && (!parserContext.getRegistry().containsBeanDefinition(value)
                                        || !ProtocolConfig.class.getName().equals(parserContext.getRegistry().getBeanDefinition(value).getBeanClassName()))) {
                                    // 如果 protocol 屬性值有對應的擴充套件實現,而且沒有被註冊到 spring 登錄檔中
                                    // 或者 spring 登錄檔中對應的 bean 的型別不為 ProtocolConfig.class
                                    if ("dubbo:provider".equals(element.getTagName())) {
                                        logger.warn("Recommended replace <dubbo:provider protocol=\"" + value + "\" ... /> to <dubbo:protocol name=\"" + value + "\" ... />");
                                    }
                                    // backward compatibility
                                    ProtocolConfig protocol = new ProtocolConfig();
                                    protocol.setName(value);
                                    reference = protocol;
                                } else if ("onreturn".equals(property)) {
                                    int index = value.lastIndexOf(".");
                                    String returnRef = value.substring(0, index);
                                    String returnMethod = value.substring(index + 1);
                                    reference = new RuntimeBeanReference(returnRef);
                                    beanDefinition.getPropertyValues().addPropertyValue("onreturnMethod", returnMethod);
                                } else if ("onthrow".equals(property)) {
                                    int index = value.lastIndexOf(".");
                                    String throwRef = value.substring(0, index);
                                    String throwMethod = value.substring(index + 1);
                                    reference = new RuntimeBeanReference(throwRef);
                                    beanDefinition.getPropertyValues().addPropertyValue("onthrowMethod", throwMethod);
                                } else if ("oninvoke".equals(property)) {
                                    int index = value.lastIndexOf(".");
                                    String invokeRef = value.substring(0, index);
                                    String invokeRefMethod = value.substring(index + 1);
                                    reference = new RuntimeBeanReference(invokeRef);
                                    beanDefinition.getPropertyValues().addPropertyValue("oninvokeMethod", invokeRefMethod);
                                } else {
                                    // 如果 ref 屬性值已經被註冊到 spring 登錄檔中
                                    if ("ref".equals(property) && parserContext.getRegistry().containsBeanDefinition(value)) {
                                        BeanDefinition refBean = parserContext.getRegistry().getBeanDefinition(value);
                                        // 非單例丟擲異常
                                        if (!refBean.isSingleton()) {
                                            throw new IllegalStateException("The exported service ref " + value + " must be singleton! Please set the " + value + " bean scope to singleton, eg: <bean id=\"" + value + "\" scope=\"singleton\" ...>");
                                        }
                                    }
                                    reference = new RuntimeBeanReference(value);
                                }
                                beanDefinition.getPropertyValues().addPropertyValue(propertyName, reference);
                            }
                        }
                    }
                }
            }
        }
        NamedNodeMap attributes = element.getAttributes();
        int len = attributes.getLength();
        for (int i = 0; i < len; i++) {
            Node node = attributes.item(i);
            String name = node.getLocalName();
            if (!props.contains(name)) {
                if (parameters == null) {
                    parameters = new ManagedMap();
                }
                String value = node.getNodeValue();
                parameters.put(name, new TypedStringValue(value, String.class));
            }
        }
        if (parameters != null) {
            beanDefinition.getPropertyValues().addPropertyValue("parameters", parameters);
        }
        return beanDefinition;
    }
複製程式碼

上面這一大段關於配置的解析的程式碼需要大家自己結合實際的程式碼進行除錯才能更好的理解。我在理解 Dubbo XML 解析的時候,也是耐著性子一遍一遍的來。
關於 ProtocolConfig 和 protocol 載入先後順序的問題最後再集合一個小例子總結下吧:

    dubbo-demo-provider.xml
    <dubbo:protocol name="dubbo" port="20880"/>
複製程式碼
  1. 當我們先解析了 ProtocolConfig 元素時,我們會遍歷所有已經註冊 spring 登錄檔中 bean。如果 bean 物件存在 protocol 屬性且與 name 和當前 ProtolConfig id 匹配,則會新建 RuntimeBeanReference 物件覆蓋 protocol 屬性。對於上面這行配置,最後會新建一個擁有 name 和 port 的 beanDefinition 物件。
  2. 先解析了 protocol 元素,ProtocolConfig 未被解析。此時我們在 spring 登錄檔中找不到對應的 ProtocolConfig bean。此時我們將需要新建一個 ProtocolConfig 並將其 name 屬性 設定為當前屬性值。最後將其設定為 beanDefinition 物件的 protocol 屬性。後面載入到了 ProtocolConfig 元素時,會替換 protocol 的值。

End

Dubbo 對於自定義 XML 標籤的定義和解析實際上藉助了 Spring 框架對自定義 XML 標籤的支援。本篇水文雖然又臭又長,但是對於理解 Dubbo 的初始化過程還是很重要的。後面我們會介紹關於 Dubbo 服務暴露相關內容。

本BLOG上原創文章未經本人許可,不得用於商業用途及傳統媒體。網路媒體轉載請註明出處,否則屬於侵權行為。https://juejin.im/post/5c1753b65188250850604ebe

相關文章