Spring源碼閱讀之非默認標籤解析成BeanDefinition定義流程

上一篇文章我們講解了 Spring 中<bean>標籤是怎麼被解析成 BeanDefinition 的。而如果不是<bean><import><beans><alias>這四類的其他標籤又是怎麼被解析成BeanDefinition的呢?比如本篇要講的 context 相關的標籤是怎麼被解析成BeanDefinition的。比如常用的<context:component-scan>標籤,再比如<context:property-placeholder>標籤是怎麼被處理的。本文以<context:component-scan>標籤爲例講解,我提供了測試例子。感興趣的同學把<context:property-placeholder>標籤的解析流程看看,基本流程差不多。接下來我們就來觀摩一下這些標籤被解析的全流程吧。

  1. 在文章開始前,還是老樣子,先提供一個測試的例子。方便同學們看完講解之後自己去 Debug 源碼。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:context="http://www.springframework.org/schema/context"
       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
        http://www.springframework.org/schema/context
        https://www.springframework.org/schema/context/spring-context.xsd">
    <!-- 首先,我們先在applicationContext.xml文件裏面加上context標籤 -->
    <!-- 同時我們在base-package指定的路徑下創建兩個類,分別是接口UserService,和其對應的實現類UserServiceImpl -->
    <!-- 這裏給大家留個小思考,就是我們的文件頭裏面的內容,爲啥會有這麼一大堆東西?我在下篇自定義標籤中來揭曉答案 -->
    <context:component-scan base-package="com.xzhao.service"/>
</beans>

  1. 提供好測試用例之後,我們就來看看其源碼是怎麼做的。直接來到上篇文章的開頭,即DefaultBeanDefinitionDocumentReader.parseBeanDefinitions()方法。因爲<context:component-scan>標籤不是DefaultNameSpace的,所以會走delegate.parseCustomElement()方法。那我們就來看看對於這種element它是怎麼被解析的。

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)) {
            parseDefaultElement(ele, delegate);
          }
          else {
            // '<context:component-scan>'會走這裏。
            // 當然 '<context:property-placeholder>'標籤也是走這裏。
            delegate.parseCustomElement(ele);
          }
        }
      }
    }
    else {
      delegate.parseCustomElement(root);
    }
}

  1. 進去之後,解析element的實現邏輯如下。

public BeanDefinition parseCustomElement(Element ele, @Nullable BeanDefinition containingBd) {
    // Step1:根據element獲取對應的namespaceuri,
    // 根據這個我們才能獲得其相應的handle,即具體的解析處理器
    // 其實在這裏返回的值長這個樣子:http://www.springframework.org/schema/context
    // 相信如果仔細的同學應該不會這玩意兒陌生
    String namespaceUri = getNamespaceURI(ele);
    if (namespaceUri == null) {
      return null;
    }
    // Step2:這裏會首先拿到一個命名空間解析器,即NamespaceResolver對象
    // 然後利用NamespaceResolve對象去解析NamespaceUri,從而就得到了一個非常重要的對象
    // 命名空間處理器 NamespaceHandler 對象,其實正是這個對象去把element對象解析成BeanDefinition定義的。
    NamespaceHandler handler = this.readerContext.getNamespaceHandlerResolver().resolve(namespaceUri);
    if (handler == null) {
      error("Unable to locate Spring NamespaceHandler for XML schema namespace [" + namespaceUri + "]", ele);
      return null;
    }
    // Step3:利用的得到的NamespaceHandler去調用其parse方法,
    // 即可獲得 '<context:component-scan>'標籤對應的BeanDefinition對象了
    return handler.parse(ele, new ParserContext(this.readerContext, this, containingBd));
}

  1. 接下來大家應該會想,那麼多不同的標籤,我們應該找那個解析器來解析這個標籤呢?所以問題來了,怎麼獲得解析器?在獲得解析器之前首先要先拿到一個叫命名空間解析器(namespaceHandlerResolve),這個概念好像不太好理解,我截了個圖,大概就長這個樣子。如下:

有了命名空間解析器之後,我們來看看是怎麼獲得命名空間處理器的。

public NamespaceHandler resolve(String namespaceUri) {
    // Step1:獲取所有的handlerMappings,即上圖紅框裏面的內容
    // 其中key爲一個http鏈接,比如:http://www.springframework.org/context
    // 這個key也就是namespaceUri了,而對應的value其實是一個handler,仔細看看其實就是一個class的全路徑名字
    Map<String, Object> handlerMappings = getHandlerMappings();
    // Step2:獲取我們指定的handler名字,如果取到不到則直接返回null
    Object handlerOrClassName = handlerMappings.get(namespaceUri);
    if (handlerOrClassName == null) {
      return null;
    }
    // Step3:由於'<context:component-scan>'標籤對應的namespaceUri是 http://www.springframework.org/context
    // 所以獲取到的一定是className,即是一個字符串對象。
    // 如果在一次解析的時候可能已經存在的是其對應的實例對象了,因爲每解析完一個標籤,則會將當前key的value替換成對象
    // 所以就會在此處直接返回
    else if (handlerOrClassName instanceof NamespaceHandler) {
      return (NamespaceHandler) handlerOrClassName;
    }
    else {
      // Step4:所以將className直接強轉爲String
      String className = (String) handlerOrClassName;
      try {
        // Step4.1:利用反射機制獲取到className對應的Class對象
        Class<?> handlerClass = ClassUtils.forName(className, this.classLoader);
        // Step4.2:檢查handlerClass是否是實現了NameSpaceHandler接口,從接口的繼承樹可以看出
        // 所以的handler都是直接或間接的實現了NamespaceHandler接口,從而實現了 init() 、parse() 、decorate() 幾個非常重要的方法
        if (!NamespaceHandler.class.isAssignableFrom(handlerClass)) {
          throw new FatalBeanException("Class [" + className + "] for namespace [" + namespaceUri +
              "] does not implement the [" + NamespaceHandler.class.getName() + "] interface");
        }
        // Step4.3:根據Class實例化出對應的namespaceHandler對象,即獲取到了我們想要的命名空間解析器了
        NamespaceHandler namespaceHandler = (NamespaceHandler) BeanUtils.instantiateClass(handlerClass);
        // Step4.4:執行初始化操作,這一步非常非常重要,
        // 點到 namespaceHandler 對應的Handler類裏面,我們會發現一些非常眼熟的東西
        // 比如此處對應的handler類是ContextNamespaceHandler類。
        // 一個小常識,一般標籤以什麼開頭,其對應的handler就是標籤開頭命名的
        namespaceHandler.init();
        // Step4.5:將初始化完成的handler對象放到handlerMapppings裏面
        // 這樣做是爲了當在有context標籤需要解析的時候,則直接從該map裏面獲取即可,而不需要在重複創建了,相當於充當了一個本地緩存的角色,加速解析工作
        handlerMappings.put(namespaceUri, namespaceHandler);
        // Step4.6:將創建好的namespaceHandler對象返回
        return namespaceHandler;
      }
      catch (ClassNotFoundException ex) {
        throw new FatalBeanException("Could not find NamespaceHandler class [" + className +
            "] for namespace [" + namespaceUri + "]", ex);
      }
      catch (LinkageError err) {
        throw new FatalBeanException("Unresolvable class definition for NamespaceHandler class [" +
            className + "] for namespace [" + namespaceUri + "]", err);
      }
    }
}

// 這裏簡單看下 ContextNamespaceHandler類的init方法幹了啥
// 其實非常簡單,就是將標籤以 <context:xxx> 開頭的各種標籤進行一個註冊操作
// 這裏面大家應該比較熟悉的標籤 'property-placeholder','component-scan'
public class ContextNamespaceHandler extends NamespaceHandlerSupport {

  @Override
  public void init() {
    registerBeanDefinitionParser("property-placeholder", new PropertyPlaceholderBeanDefinitionParser());
    registerBeanDefinitionParser("property-override", new PropertyOverrideBeanDefinitionParser());
    registerBeanDefinitionParser("annotation-config", new AnnotationConfigBeanDefinitionParser());
    registerBeanDefinitionParser("component-scan", new ComponentScanBeanDefinitionParser());
    registerBeanDefinitionParser("load-time-weaver", new LoadTimeWeaverBeanDefinitionParser());
    registerBeanDefinitionParser("spring-configured", new SpringConfiguredBeanDefinitionParser());
    registerBeanDefinitionParser("mbean-export", new MBeanExportBeanDefinitionParser());
    registerBeanDefinitionParser("mbean-server", new MBeanServerBeanDefinitionParser());
  }

}

// 註冊操作也比較簡單,就是將標籤對應的真正解析器放到其父類(NamespaceHandlerSupport類)的parse map中,方便後面開始具體的解析工作
// 從代碼也可以看出,'<context:component-scan>'標籤對應的真正解析器其實是 ComponentScanBeanDefinitionParser
protected final void registerBeanDefinitionParser(String elementName, BeanDefinitionParser parser) {
    this.parsers.put(elementName, parser);
}

  1. 到此,我們拿到了命名空間解析器了,接下來看看具體的解析工作是怎麼做的。

// 上面我們講到,將相關的解析器全部註冊到了NamespaceHandlerSupport類的parse map裏面
// 所以解析工作也肯定是先調用NamespaceHandlerSupport的parse方法獲取到對應的解析器
// 然後在調用具體解析器的parse方法執行解析工作獲得BeanDefinition定義
public BeanDefinition parse(Element element, ParserContext parserContext) {
    // Step1:獲取解析器對象,具體怎麼找的,就不展開了,很簡單,
    // 想想就是無非先拿到標籤的名字,然後從parse map裏面get得到對應的解析器
    // 我們重點關注下具體的解析工作,因爲所有實現都是在具體的解析類的方法裏面實現的
    BeanDefinitionParser parser = findParserForElement(element, parserContext);
    // Step2:根據解析器對象調用其對應的parse方法,執行具體的解析工作,
    // 從而得到BeanDefinition定義對象
    return (parser != null ? parser.parse(element, parserContext) : null);
}

  1. 我們來看看<context:component-scan>標籤對應的解析器,即ComponentScanBeanDefinitionParse類的parse方法的實現。這裏有個小技巧,一般標籤名叫什麼,相應的 parse 類的名字也是與其對應的,大家閱讀別的標籤的源碼的時候可以注意下,方便找到對應的解析類。

public BeanDefinition parse(Element element, ParserContext parserContext) {
    // Step1:獲取 'base-package' 屬性對應的值
    String basePackage = element.getAttribute(BASE_PACKAGE_ATTRIBUTE);
    // Step2:將獲取到的basePackage做了一些字符串的處理轉換工作,比如常見的那種佔位符
    // 說白了就是字符串的一些替換操作,得到一個spring認爲的標準的字符串對象
    // 感興趣的朋友,自行了解相應的內容,這裏不做深入探討
    basePackage = parserContext.getReaderContext().getEnvironment().resolvePlaceholders(basePackage);
    // Step3:字符串切割,這裏我做了個測試,按照規則寫了一個字符串,可以切割出多個子串
    // 我猜想他的意思應該是base-package可以同時指定多個包路徑,
    // 之前沒有做過同時指定多個包路徑的操作,大佬們見笑了
    String[] basePackages = StringUtils.tokenizeToStringArray(basePackage,
        ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);

    // Actually scan for bean definitions and register them.
    // Step4:註釋寫的也比較明朗,就是首先獲得一個scanner,即掃描器,
    // 然後拿着掃描器去挨個掃描指定包下的類,從而得到多個BeanDefinition對象
    ClassPathBeanDefinitionScanner scanner = configureScanner(parserContext, element);
    Set<BeanDefinitionHolder> beanDefinitions = scanner.doScan(basePackages);
    // Step5:得到了多個BeanDefinition定義,又來到了比較熟悉的步驟,註冊這些BeanDefinition定義到BeanFactory裏面
    // 到此,我們關於 '<context:component-scan>'標籤解析成對應的BeanDefinition對象也就講完了
    registerComponents(parserContext.getReaderContext(), beanDefinitions, element);

    return null;
}

  1. 接着來看看掃描器是怎麼被創建出來的,其實這個裏面就是對配置文件的相關屬性進行解析賦值操作。

protected ClassPathBeanDefinitionScanner configureScanner(ParserContext parserContext, Element element) {
    // Step1:判斷當前scanner是否使用默認的過濾器
    boolean useDefaultFilters = true;
    if (element.hasAttribute(USE_DEFAULT_FILTERS_ATTRIBUTE)) {
      useDefaultFilters = Boolean.valueOf(element.getAttribute(USE_DEFAULT_FILTERS_ATTRIBUTE));
    }

    // Delegate bean definition registration to scanner class.
    // Step2:將BeanDefinition的構建委託給掃描器去創建
    // 創建一個scanner類,使用的是 ClassPathBeanDefinitionScanner 作爲掃描器
    ClassPathBeanDefinitionScanner scanner = createScanner(parserContext.getReaderContext(), useDefaultFilters);
    // Step3:設置BeanDefinition的一些默認屬性
    scanner.setBeanDefinitionDefaults(parserContext.getDelegate().getBeanDefinitionDefaults());
    scanner.setAutowireCandidatePatterns(parserContext.getDelegate().getAutowireCandidatePatterns());
    // Step4:配置w恩建如果指定了 'resource-pattern',則設置
    if (element.hasAttribute(RESOURCE_PATTERN_ATTRIBUTE)) {
      scanner.setResourcePattern(element.getAttribute(RESOURCE_PATTERN_ATTRIBUTE));
    }

    try {
      // Step5:解析 'name-generator' 屬性
      parseBeanNameGenerator(element, scanner);
    }
    catch (Exception ex) {
      parserContext.getReaderContext().error(ex.getMessage(), parserContext.extractSource(element), ex.getCause());
    }

    try {
      // Step6:解析 'scope-resolver' 屬性
      parseScope(element, scanner);
    }
    catch (Exception ex) {
      parserContext.getReaderContext().error(ex.getMessage(), parserContext.extractSource(element), ex.getCause());
    }
    // Step7:解析 'include-filter' 屬性
    parseTypeFilters(element, scanner, parserContext);
    // Step8:返回初始化好的scanner對象,其實這個方法裏面是對標籤相關屬性的解析和設置操作
    return scanner;
}

  1. 得到ClassPathBeanDefinitionScanner掃描器之後,開始使用掃描器去執行真正的掃描工作了,我們來看下具體實現。

protected Set<BeanDefinitionHolder> doScan(String... basePackages) {
    Assert.notEmpty(basePackages, "At least one base package must be specified");
    // Step1:用於存放掃描到的類對應的BeanDefinition定義
    Set<BeanDefinitionHolder> beanDefinitions = new LinkedHashSet<>();
    // Step2:遍歷所有指定的掃描包
    for (String basePackage : basePackages) {
      // Step2.1:查詢當前掃描包下的所有候選BeanDefinition定義
      Set<BeanDefinition> candidates = findCandidateComponents(basePackage);
      // Step2.2:遍歷每個候選的BeanDefinition
      for (BeanDefinition candidate : candidates) {
        // Step2.2.1:獲取scope的元數據信息,我們通常用的有 'signleton' 和 'property'。
        // 而 'signleton'也是默認值。
        ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate);
        candidate.setScope(scopeMetadata.getScopeName());
        // Step2.2.2:獲取候選的BeanDefinition對應的beanName
        String beanName = this.beanNameGenerator.generateBeanName(candidate, this.registry);
        // Step2.2.3:如果當前的候選BeanDefinition實現了AbstractBeanDefinition,
        // 則執行BeanDefinition的後置處理操作
        if (candidate instanceof AbstractBeanDefinition) {
          postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName);
        }
        // Step2.2.4:如果候選的BeanDefinition實現了AnnotatedBeanDefinitio,
        // 則執行註解的通用邏輯,通常對BeanDefinition設置一些常用參數,
        // 比如是否是懶加載,是否有 depends-on(依賴),role(角色),description(描述)這四個屬性
        if (candidate instanceof AnnotatedBeanDefinition) {
          AnnotationConfigUtils.processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate);
        }
        // Step2.2.5:檢查候選的BeanDefinition
        if (checkCandidate(beanName, candidate)) {
          // Step2.2.5.1:將BeanDefinition封裝成BeanDefinitionHolder對象
          BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName);
          // Step2.2.5.2:獲取代理的BeanDefinitionHolder對象
          definitionHolder =
              AnnotationConfigUtils.applyScopedProxyMode(scopeMetadata, definitionHolder, this.registry);
          beanDefinitions.add(definitionHolder);
          // Step2.2.5.3:將最終的BeanDefinition註冊到BeanFactory中,完成BeanDefinition定義的解析工作
          registerBeanDefinition(definitionHolder, this.registry);
        }
      }
    }
    return beanDefinitions;
}

  1. 上面這個方法裏面幾個比較重要的方法我們來重點看下具體實現。

  • indCandidateComponents: 獲取指定包下所有的候選BeanDefinition對象

  • postProcessBeanDefinition: BeanDefinition對象的後置操作

  • checkCandidate: 檢查候選的BeanDefinition是否可以被註冊到BeanFactory

// 首先來看第一個方法的,如何獲取指定包路徑下的候選BeanDefinition定義
public Set<BeanDefinition> findCandidateComponents(String basePackage) {
    // Step1:componentsIndex不爲null  && 索引如果有includeFilters,
    // 則根據type去獲取BeanDefinition定義,沒有遇到過這種使用方式。
    // 所以這個地方請求大神賜教
    if (this.componentsIndex != null && indexSupportsIncludeFilters()) {
      return addCandidateComponentsFromIndex(this.componentsIndex, basePackage);
    }
    else {
      // Step2:我們通常的使用方式是這種
      return scanCandidateComponents(basePackage);
    }
}


private Set<BeanDefinition> scanCandidateComponents(String basePackage) {
    Set<BeanDefinition> candidates = new LinkedHashSet<>();
    try {
      // Step1:獲取要掃描的包下的那些類,比如根據我們的配置可以得到:
      // classpath*:com/xzhao/service/**/*.class
      // 指定的service下的任何子包和其下面的class
      String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
          resolveBasePackage(basePackage) + '/' + this.resourcePattern;
      // Step2:根據指定的classpath,獲取對應下面的所有resource
      Resource[] resources = getResourcePatternResolver().getResources(packageSearchPath);
      boolean traceEnabled = logger.isTraceEnabled();
      boolean debugEnabled = logger.isDebugEnabled();
      // Step3:遍歷resource,根據resource創建BeanDefinition定義,即候選的BeanDefinition
      for (Resource resource : resources) {
        if (traceEnabled) {
          logger.trace("Scanning " + resource);
        }
        if (resource.isReadable()) {
          try {
            // Step3.1:獲取resource對應的元數據讀取器
            // matadataReader包含了三部分內容,分別是resource,簡單理解就是class的位置
            // 第二部分是class的元數據,主要包含了classLoader,className(com.xzhao.service.UserService)等等信息
            // 第三部分是註解的元數據,和class的元數據差不多
            MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource);
            // step3.2:檢查當前元數據是否可以創建BeanDefinition定義,
            // 主要是過濾掉不包含(即exclude)的class,返回false
            // 保留include的class,返回true
            if (isCandidateComponent(metadataReader)) {
              // Step3.2.1:創建BeanDefinition定義,ScannedGenericBeanDefinition 間接實現了BeanDefinition接口,
              // 這裏創建是一個掃描的BeanDefinition定義
              ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
              sbd.setResource(resource);
              sbd.setSource(resource);
              // Step3.2.2:檢測候選bean是否合法,
              // 即候選bean(是否是合法的 && 一個具體的bean(感覺可以歷程就是是否可以實例化出bean實例)) || (是一個抽象的 && 同時存在註解方法))
              if (isCandidateComponent(sbd)) {
                if (debugEnabled) {
                  logger.debug("Identified candidate component class: " + resource);
                }
                // Step3.2.2.1:檢測合法,就作爲一個候選的BeanDefinition定義。
                candidates.add(sbd);
              }
              else {
                if (debugEnabled) {
                  logger.debug("Ignored because not a concrete top-level class: " + resource);
                }
              }
            }
            else {
              if (traceEnabled) {
                logger.trace("Ignored because not matching any filter: " + resource);
              }
            }
          }
          catch (Throwable ex) {
            throw new BeanDefinitionStoreException(
                "Failed to read candidate component class: " + resource, ex);
          }
        }
        else {
          if (traceEnabled) {
            logger.trace("Ignored because not readable: " + resource);
          }
        }
      }
    }
    catch (IOException ex) {
      throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
    }
    return candidates;
}
// 在接着看第二方法,這個方法就比較簡單了,就這麼簡單,大家可以稍微鬆口氣
// 它的作用就是給設置一些通用的屬性
protected void postProcessBeanDefinition(AbstractBeanDefinition beanDefinition, String beanName) {
    beanDefinition.applyDefaults(this.beanDefinitionDefaults);
if (this.autowireCandidatePatterns != null) {
      beanDefinition.setAutowireCandidate(PatternMatchUtils.simpleMatch(this.autowireCandidatePatterns, beanName));
    }
}
// 在看最後一個方法,該方法用於最後一步檢測當前的BeanDefinition是否合法可以被註冊到BeanFactory裏面
protected boolean checkCandidate(String beanName, BeanDefinition beanDefinition) throws IllegalStateException {
    // Step1:當前BeanDefinition是否已經在BeanFactory裏面註冊過,如果沒有則可以正常註冊
if (!this.registry.containsBeanDefinition(beanName)) {
return true;
    }
    // Step2:如果已經在BeanFactory裏面註冊了,
    // 則獲取已經註冊了的BeanDefinition定義,然後嘗試獲取存在的BeanDefinition的父BeanDefinition
    BeanDefinition existingDef = this.registry.getBeanDefinition(beanName);
    BeanDefinition originatingDef = existingDef.getOriginatingBeanDefinition();
if (originatingDef != null) {
      existingDef = originatingDef;
    }
    // Step3:拿已經存在的BeanDefinition和將要註冊的BeanDefinition比較
    // 已存在的實現了ScannedGenericBeanDefinition則可以覆蓋
    // 或者已存在的BeanDefinition的source != 新的BeanDefinition的source,則也可以創建
    // 或者已存在的BeanDefinition != 新的BeanDefinition,則也可以創建
    // 以上三種情況下,可以將新創建的BeanDefinition註冊到BeanFactory裏面
    if (isCompatible(beanDefinition, existingDef)) {
return false;
    }
throw new ConflictingBeanDefinitionException("Annotation-specified bean name '" + beanName +
"' for bean class [" + beanDefinition.getBeanClassName() + "] conflicts with existing, " +
"non-compatible bean definition of same name and class [" + existingDef.getBeanClassName() + "]");
}

總結

至此,我們就完成了<context:component-scan>標籤掃描類得到BeanDefinitions定義,並將得到的BeanDefinitions依次註冊到BeanFactory裏面。我們只需要在被註冊的類上設置@Component @Service @Controller @Repository這種註解就可以被<component-scan>標籤掃描到。

總結一下就是先拿到指定的包路徑,然後讀取該路徑下的所有被聲明瞭註解的類,然後拿到這些類的元數據,再然後根據這些元數據創建出ScannedGenericBeanDefinition對象,最後將符合條件的ScannedGenericBeanDefinition註冊到BeanFactory裏面。就完成了改標籤的工作。最後,本篇文章篇幅較長,需要慢慢理解其中的邏輯,不明白的地方就多看幾遍在理解。到此我們知道了 spring 提供的自定義的標籤是如何解析成BeanDefinition定義的。

下一篇我們來介紹如何自己定義一個自定義的標籤,希望大家和我一起堅持下去。


歡迎關注我,共同學習

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