【MyBatis源碼分析】Configuration加載(上篇)


config.xml解析為org.w3c.dom.Document

本文首先來簡單看一下MyBatis中將config.xml解析為org.w3c.dom.Document的流程,代碼為上文的這部分:

1 static {
2     try {
3         reader = Resources.getResourceAsReader("mybatis/config.xml");
4         ssf = new SqlSessionFactoryBuilder().build(reader);
5     } 
6     catch (IOException e) {
7         e.printStackTrace();
8     }
9 }

第3行的代碼實現為:

1 public static Reader getResourceAsReader(String resource) throws IOException {
2     Reader reader;
3     if (charset == null) {
4       reader = new InputStreamReader(getResourceAsStream(resource));
5     } else {
6       reader = new InputStreamReader(getResourceAsStream(resource), charset);
7     }
8     return reader;
9 }

相當於就是將輸入的路徑轉換為一個字符輸入流並返回。

接着繼續看靜態塊第4行的代碼,new SqlSessionFactoryBuilder().build(reader),把代碼定位到SqlSessionFactoryBuilder類的builder方法,這里使用了多態,直接跟到build方法:

 1 public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
 2     try {
 3       XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
 4       return build(parser.parse());
 5     } catch (Exception e) {
 6       throw ExceptionFactory.wrapException("Error building SqlSession.", e);
 7     } finally {
 8       ErrorContext.instance().reset();
 9       try {
10         reader.close();
11       } catch (IOException e) {
12         // Intentionally ignore. Prefer previous error.
13       }
14     }
15 }

解析config.xml的代碼在第3行XMLConfigBuilder類的構造方法中,看一下XMLConfigBuilder類的構造方法做了什么:

 1 public XMLConfigBuilder(Reader reader, String environment, Properties props) {
 2     this(new XPathParser(reader, true, props, new XMLMapperEntityResolver()), environment, props);
 3 }

這里的關鍵是第二行代碼的第一個參數XPathParser,看一下實例化XPathParser類的代碼:

1 public XPathParser(Reader reader, boolean validation, Properties variables, EntityResolver entityResolver) {
2     commonConstructor(validation, variables, entityResolver);
3     this.document = createDocument(new InputSource(reader));
4 }

第2行的代碼commonConstructor方法沒什么好看的,將validation、variables、entityResolver設置到XPathParser類的參數中而已,順便再實例化一個javax.xml.xpath.XPath出來,XPath用於在XML文檔中通過元素和屬性進行導航,並對元素和屬性進行遍歷。

接着看第3行的createDocument方法:

 1 private Document createDocument(InputSource inputSource) {
 2     // important: this must only be called AFTER common constructor
 3     try {
 4       DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
 5       factory.setValidating(validation);
 6 
 7       factory.setNamespaceAware(false);
 8       factory.setIgnoringComments(true);
 9       factory.setIgnoringElementContentWhitespace(false);
10       factory.setCoalescing(false);
11       factory.setExpandEntityReferences(true);
12 
13       DocumentBuilder builder = factory.newDocumentBuilder();
14       builder.setEntityResolver(entityResolver);
15       builder.setErrorHandler(new ErrorHandler() {
16         @Override
17         public void error(SAXParseException exception) throws SAXException {
18           throw exception;
19         }
20 
21         @Override
22         public void fatalError(SAXParseException exception) throws SAXException {
23           throw exception;
24         }
25 
26         @Override
27         public void warning(SAXParseException exception) throws SAXException {
28         }
29       });
30       return builder.parse(inputSource);
31     } catch (Exception e) {
32       throw new BuilderException("Error creating document instance.  Cause: " + e, e);
33     }
34 }

看一下第5行~第11行的代碼設置DocumentBuilderFactory中參數的含義:

  • setValidating表示是否驗證xml文件,這個驗證是DTD驗證
  • setNamespaceAware表示是否支持xml命名空間
  • setIgnoringComments表示是否忽略注釋
  • setIgnoringElementContentWhitespace表示是否忽略元素中的空白
  • setCoalescing表示是否將CDATA節點轉換為Text節點,並將其附加到相鄰(如果有)的Text節點
  • setExpandEntityReferences表示是否擴展實體引用節點

第13行的代碼由設置的參數從DocumentBuilderFactory中獲取一個DocumentBuilder實例DocumentBuilderImpl,並由第14行的代碼設置一個實體解析器,由第15行~第29行的代碼設置一個錯誤處理器。

最后看一下第30行的代碼parse方法:

 1 public Document parse(InputSource is) throws SAXException, IOException {
 2         if (is == null) {
 3             throw new IllegalArgumentException(
 4                 DOMMessageFormatter.formatMessage(DOMMessageFormatter.DOM_DOMAIN,
 5                 "jaxp-null-input-source", null));
 6         }
 7         if (fSchemaValidator != null) {
 8             if (fSchemaValidationManager != null) {
 9                 fSchemaValidationManager.reset();
10                 fUnparsedEntityHandler.reset();
11             }
12             resetSchemaValidator();
13         }
14         domParser.parse(is);
15         Document doc = domParser.getDocument();
16         domParser.dropDocumentReferences();
17         return doc;
18 }

看過Spring配置文件解析源碼的朋友應該對這一段代碼比較熟悉,一樣的,使用DocumentBuilder將解析InputSource成org.w3c.dom.Document並將Document存儲到XPathParser中。

 

Document轉換為Configuration

前面的代碼將config.xml轉換為了org.w3c.dom.Document,下一步就是將org.w3c.dom.Document中的內容轉換為Java對象了,其中最主要的一個對象就是org.apache.ibatis.session.Configuration,還是回到之前的SqlSessionFactoryBuilder的build方法:

 1 public SqlSessionFactory build(Reader reader, String environment, Properties properties) {
 2     try {
 3       XMLConfigBuilder parser = new XMLConfigBuilder(reader, environment, properties);
 4       return build(parser.parse());
 5     } catch (Exception e) {
 6       throw ExceptionFactory.wrapException("Error building SqlSession.", e);
 7     } finally {
 8       ErrorContext.instance().reset();
 9       try {
10         reader.close();
11       } catch (IOException e) {
12         // Intentionally ignore. Prefer previous error.
13       }
14     }
15 }

先看一下第4行的parse方法,parse方法是XMLConfigBuilder中的,之前重點分析了它的屬性XPathParser,看一下XMLConfigBuilder的parse方法是如何實現的:

1 public Configuration parse() {
2     if (parsed) {
3       throw new BuilderException("Each XMLConfigBuilder can only be used once.");
4     }
5     parsed = true;
6     parseConfiguration(parser.evalNode("/configuration"));
7     return configuration;
8 }

這里看一下第6行,可以使用XPathParser的evalNode方法解析標簽,后面解析標簽會大量用到此方法,此方法將標簽解析為XNode,像config.xml(可見上一篇文章的示例)解析完之后的XNode,toString()方法輸出的內容是這樣的:

<configuration>
<properties resource="properties/db.properties"/>
<settings>
<setting name="cacheEnabled" value="true"/>
<setting name="lazyLoadingEnabled" value="true"/>
<setting name="useGeneratedKeys" value="true"/>
</settings>
<typeAliases>
<typeAlias alias="Mail" type="org.xrq.mybatis.pojo.Mail"/>
</typeAliases>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="${driveClass}"/>
<property name="url" value="${url}"/>
<property name="username" value="${userName}"/>
<property name="password" value="${password}"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mybatis/mail.xml"/>
</mappers>
</configuration>

可見xml文件中<configuration>中所有內容都已經被成功解析並放在XNode中了,剩下的只要調用XNode的方法獲取自己想要的內容即可。

最后掃一眼parseConfiguration方法,之所以說掃一眼,因為之后要分析里面的一些常用的和重點的內容,這里只是列一下代碼而已:

 1 private void parseConfiguration(XNode root) {
 2     try {
 3       Properties settings = settingsAsPropertiess(root.evalNode("settings"));
 4       //issue #117 read properties first
 5       propertiesElement(root.evalNode("properties"));
 6       loadCustomVfs(settings);
 7       typeAliasesElement(root.evalNode("typeAliases"));
 8       pluginElement(root.evalNode("plugins"));
 9       objectFactoryElement(root.evalNode("objectFactory"));
10       objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
11       reflectorFactoryElement(root.evalNode("reflectorFactory"));
12       settingsElement(settings);
13       // read it after objectFactory and objectWrapperFactory issue #631
14       environmentsElement(root.evalNode("environments"));
15       databaseIdProviderElement(root.evalNode("databaseIdProvider"));
16       typeHandlerElement(root.evalNode("typeHandlers"));
17       mapperElement(root.evalNode("mappers"));
18     } catch (Exception e) {
19       throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
20     }
21 }

這里就是逐個解析<configuration>標簽下的子標簽,並將數據設置到對應的屬性中,這里要一個一個看一下。

 

settings解析

首先看settingsAsPropertiess(root.evalNode("settings"))這句代碼,顯而易見這句話獲取了<configuration>下的<settings>節點。跟一下代碼的實現:

 1 private Properties settingsAsPropertiess(XNode context) {
 2     if (context == null) {
 3       return new Properties();
 4     }
 5     Properties props = context.getChildrenAsProperties();
 6     // Check that all settings are known to the configuration class
 7     MetaClass metaConfig = MetaClass.forClass(Configuration.class, localReflectorFactory);
 8     for (Object key : props.keySet()) {
 9       if (!metaConfig.hasSetter(String.valueOf(key))) {
10         throw new BuilderException("The setting " + key + " is not known.  Make sure you spelled it correctly (case sensitive).");
11       }
12     }
13     return props;
14 }

第5行將節點解析成鍵值對的形式(Properties是Hashtable的子類),看一下props的toString方法打印的內容:

{useGeneratedKeys=true, lazyLoadingEnabled=true, cacheEnabled=true}

可見settings里面的數據已經被解析成了Properties了。之后還有一步,<settings>標簽下的每個<setting>中的name屬性不是隨便填寫的,都是MyBatis支持的配置,因此需要對Properties里面的Key做一個校驗,校驗的代碼就是第7行至第12行的代碼,其中有一個name不是MyBatis支持的就會拋出異常,MyBatis初始化整體失敗。

至於具體校驗的是哪些Key,這就要跟一下第7行的代碼了,首先是MetaClass.forClass(Configuration.class, localReflectorFactory),第二個實參是XMLConfigBuilder里面直接new出來的,它的實際類型為DefaultReflectorFactory,看一下forClass方法實現:

 1 public static MetaClass forClass(Class<?> type, ReflectorFactory reflectorFactory) {
 2     return new MetaClass(type, reflectorFactory);
 3 }

看一下new MetaClass做了什么事:

1 private MetaClass(Class<?> type, ReflectorFactory reflectorFactory) {
2     this.reflectorFactory = reflectorFactory;
3     this.reflector = reflectorFactory.findForClass(type);
4 }

顯而易見,繼續跟一下第3行的代碼DefaultRelectorFactory的findForClass方法:

 1 public Reflector findForClass(Class<?> type) {
 2     if (classCacheEnabled) {
 3             // synchronized (type) removed see issue #461
 4       Reflector cached = reflectorMap.get(type);
 5       if (cached == null) {
 6         cached = new Reflector(type);
 7         reflectorMap.put(type, cached);
 8       }
 9       return cached;
10     } else {
11       return new Reflector(type);
12     }
13 }

不管怎么樣都會執行new Reflector(type)這一句代碼,看一下此時做了什么事,注意傳入的參數是Configuration的class對象:

 1 public Reflector(Class<?> clazz) {
 2     type = clazz;
 3     addDefaultConstructor(clazz);
 4     addGetMethods(clazz);
 5     addSetMethods(clazz);
 6     addFields(clazz);
 7     readablePropertyNames = getMethods.keySet().toArray(new String[getMethods.keySet().size()]);
 8     writeablePropertyNames = setMethods.keySet().toArray(new String[setMethods.keySet().size()]);
 9     for (String propName : readablePropertyNames) {
10       caseInsensitivePropertyMap.put(propName.toUpperCase(Locale.ENGLISH), propName);
11     }
12     for (String propName : writeablePropertyNames) {
13       caseInsensitivePropertyMap.put(propName.toUpperCase(Locale.ENGLISH), propName);
14     }
15 }

這么多方法至於具體要看哪個,要注意的是之前XMLConfigBuilder里面對於Key的判斷是"!metaConfig.hasSetter(String.valueOf(key))",代碼的意思是判斷的是否Key有set方法,那么顯而易見這里要繼續跟第5行的addSetMethods方法:

 1 private void addSetMethods(Class<?> cls) {
 2     Map<String, List<Method>> conflictingSetters = new HashMap<String, List<Method>>();
 3     Method[] methods = getClassMethods(cls);
 4     for (Method method : methods) {
 5       String name = method.getName();
 6       if (name.startsWith("set") && name.length() > 3) {
 7         if (method.getParameterTypes().length == 1) {
 8           name = PropertyNamer.methodToProperty(name);
 9           addMethodConflict(conflictingSetters, name, method);
10         }
11       }
12     }
13     resolveSetterConflicts(conflictingSetters);
14 }

到這里應該很明顯了,結論就是:<setting>的name屬性對應的值,必須在Configuration類有相應的Setter,比如設置了一個屬性useGenerateKeys方法,那么必須在Configuration類中有setUseGenerateKeys方法才行

順便說一下第13行有一個resolveSetterConflicts方法,其作用是:Setter有可能在類中被重載導致有多個,此時取Setter中方法參數只有一個且參數類型與Getter一致的Setter

 

properties解析

接着看一下propertiesElement(root.evalNode("properties"))方法,這句讀取的是<configuration>下的<properties>節點,代碼實現為:

 1 private void propertiesElement(XNode context) throws Exception {
 2     if (context != null) {
 3       Properties defaults = context.getChildrenAsProperties();
 4       String resource = context.getStringAttribute("resource");
 5       String url = context.getStringAttribute("url");
 6       if (resource != null && url != null) {
 7         throw new BuilderException("The properties element cannot specify both a URL and a resource based property file reference.  Please specify one or the other.");
 8       }
 9       if (resource != null) {
10         defaults.putAll(Resources.getResourceAsProperties(resource));
11       } else if (url != null) {
12         defaults.putAll(Resources.getUrlAsProperties(url));
13       }
14       Properties vars = configuration.getVariables();
15       if (vars != null) {
16         defaults.putAll(vars);
17       }
18       parser.setVariables(defaults);
19       configuration.setVariables(defaults);
20     }
21 }

看到第4行~第7行的代碼指定了MyBatis的<properties>標簽下不能同時指定"resource"屬性和"url"屬性。

接着第9行~第13行的代碼將.properties資源解析為Properties類,最后將Properties類設置到XPathParser和Configuration的variables屬性中,variables是一個Propreties變量。

 

類型別名解析

跳過loadCustomVfs(settings)直接看typeAliasesElement(root.evalNode("typeAliases"))這行,因為前者我也沒看懂干什么用的,后者是用於定義類型的別名的,解析的是<configuration>下的<typeAliases>標簽,用過MyBatis的應該很熟悉。看一下源碼實現:

 1 private void typeAliasesElement(XNode parent) {
 2     if (parent != null) {
 3       for (XNode child : parent.getChildren()) {
 4         if ("package".equals(child.getName())) {
 5           String typeAliasPackage = child.getStringAttribute("name");
 6           configuration.getTypeAliasRegistry().registerAliases(typeAliasPackage);
 7         } else {
 8           String alias = child.getStringAttribute("alias");
 9           String type = child.getStringAttribute("type");
10           try {
11             Class<?> clazz = Resources.classForName(type);
12             if (alias == null) {
13               typeAliasRegistry.registerAlias(clazz);
14             } else {
15               typeAliasRegistry.registerAlias(alias, clazz);
16             }
17           } catch (ClassNotFoundException e) {
18             throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e);
19           }
20         }
21       }
22     }
23 }

從源碼實現中我們可以知道兩點,<typeAliases>標簽下可以定義<package>和<typeAlias>兩種標簽,但是看第4行和第7行的判斷,這是一段if...else...,因此可以知道<package>標簽和<typeAlias>標簽只能定義其中的一種。首先看一下解析<package>標簽的代碼,第6行的registerAliases方法:

 1 public void registerAliases(String packageName, Class<?> superType){
 2     ResolverUtil<Class<?>> resolverUtil = new ResolverUtil<Class<?>>();
 3     resolverUtil.find(new ResolverUtil.IsA(superType), packageName);
 4     Set<Class<? extends Class<?>>> typeSet = resolverUtil.getClasses();
 5     for(Class<?> type : typeSet){
 6       // Ignore inner classes and interfaces (including package-info.java)
 7       // Skip also inner classes. See issue #6
 8       if (!type.isAnonymousClass() && !type.isInterface() && !type.isMemberClass()) {
 9         registerAlias(type);
10       }
11     }
12 }

第3行根據路徑packageName尋找它下面的".class"文件拿到所有的".class"文件對應的類的Class,然后遍歷所有的Class,做了三層判斷

  • 必須不是匿名類
  • 必須不是接口
  • 必須不是成員類

此時此Class對應的類符合條件,會進行注冊,通過registerAlias方法進行注冊,看一下方法實現:

1 public void registerAlias(Class<?> type) {
2     String alias = type.getSimpleName();
3     Alias aliasAnnotation = type.getAnnotation(Alias.class);
4     if (aliasAnnotation != null) {
5       alias = aliasAnnotation.value();
6     } 
7     registerAlias(alias, type);
8 }

第2行獲取Class的simpleName,simpleName指的是移除了包名的名稱,比如aa.bb.cc.Mail,getSimpleName()獲取的就是Mail。

第3行獲取類上面的注解Alias,如果Alias注解中有定義value屬性且指定了值,那么第4行~第6行的判斷優先取這個值作為Class的別名。

第7行注冊別名:

 1 public void registerAlias(String alias, Class<?> value) {
 2     if (alias == null) {
 3       throw new TypeException("The parameter alias cannot be null");
 4     }
 5     // issue #748
 6     String key = alias.toLowerCase(Locale.ENGLISH);
 7     if (TYPE_ALIASES.containsKey(key) && TYPE_ALIASES.get(key) != null && !TYPE_ALIASES.get(key).equals(value)) {
 8       throw new TypeException("The alias '" + alias + "' is already mapped to the value '" + TYPE_ALIASES.get(key).getName() + "'.");
 9     }
10     TYPE_ALIASES.put(key, value);
11 }

其實就做了兩步操作:

  1. 將alias全部小寫
  2. 將alias以及Class對象放到TYPE_ALIASES中,TYPE_ALIASES是一個HashMap

這樣一個流程,就將<package>標簽name屬性路徑下的Class(如果符合要求),全部放到了HashMap中以供使用。

接着看一下<typeAlias>標簽的解析,也就是前面說的else部分:

 1 String alias = child.getStringAttribute("alias");
 2 String type = child.getStringAttribute("type");
 3 try {
 4   Class<?> clazz = Resources.classForName(type);
 5   if (alias == null) {
 6     typeAliasRegistry.registerAlias(clazz);
 7   } else {
 8     typeAliasRegistry.registerAlias(alias, clazz);
 9   }
10 } catch (ClassNotFoundException e) {
11   throw new BuilderException("Error registering typeAlias for '" + alias + "'. Cause: " + e, e);
12 }

這里先解析<typeAlias>中的alias屬性,再解析<typeAlias>中的type屬性,當然alias也可以不定義,不定義走的就是第6行的registerAlias方法,定義走的就是第8行的registerAlias方法,這兩個重載的registerAlias方法前面也都說過了,就不說了。

 

默認typeAlias

上面說的是自定義typeAlias,MyBatis本身也默認提供給開發者了一些typeAlias定義,在兩處地方。第一處地方在Configuration的構造方法中:

public Configuration() {
    typeAliasRegistry.registerAlias("JDBC", JdbcTransactionFactory.class);
    typeAliasRegistry.registerAlias("MANAGED", ManagedTransactionFactory.class);

    typeAliasRegistry.registerAlias("JNDI", JndiDataSourceFactory.class);
    typeAliasRegistry.registerAlias("POOLED", PooledDataSourceFactory.class);
    typeAliasRegistry.registerAlias("UNPOOLED", UnpooledDataSourceFactory.class);

    typeAliasRegistry.registerAlias("PERPETUAL", PerpetualCache.class);
    typeAliasRegistry.registerAlias("FIFO", FifoCache.class);
    typeAliasRegistry.registerAlias("LRU", LruCache.class);
    typeAliasRegistry.registerAlias("SOFT", SoftCache.class);
    typeAliasRegistry.registerAlias("WEAK", WeakCache.class);

    typeAliasRegistry.registerAlias("DB_VENDOR", VendorDatabaseIdProvider.class);

    typeAliasRegistry.registerAlias("XML", XMLLanguageDriver.class);
    typeAliasRegistry.registerAlias("RAW", RawLanguageDriver.class);

    typeAliasRegistry.registerAlias("SLF4J", Slf4jImpl.class);
    typeAliasRegistry.registerAlias("COMMONS_LOGGING", JakartaCommonsLoggingImpl.class);
    typeAliasRegistry.registerAlias("LOG4J", Log4jImpl.class);
    typeAliasRegistry.registerAlias("LOG4J2", Log4j2Impl.class);
    typeAliasRegistry.registerAlias("JDK_LOGGING", Jdk14LoggingImpl.class);
    typeAliasRegistry.registerAlias("STDOUT_LOGGING", StdOutImpl.class);
    typeAliasRegistry.registerAlias("NO_LOGGING", NoLoggingImpl.class);

    typeAliasRegistry.registerAlias("CGLIB", CglibProxyFactory.class);
    typeAliasRegistry.registerAlias("JAVASSIST", JavassistProxyFactory.class);

    ...
  }

第二處地方是在TypeAliasRegistry的構造方法中:

 1 public TypeAliasRegistry() {
 2     registerAlias("string", String.class);
 3 
 4     registerAlias("byte", Byte.class);
 5     registerAlias("long", Long.class);
 6     registerAlias("short", Short.class);
 7     registerAlias("int", Integer.class);
 8     registerAlias("integer", Integer.class);
 9     registerAlias("double", Double.class);
10     registerAlias("float", Float.class);
11     registerAlias("boolean", Boolean.class);
12 
13     registerAlias("byte[]", Byte[].class);
14     registerAlias("long[]", Long[].class);
15     registerAlias("short[]", Short[].class);
16     registerAlias("int[]", Integer[].class);
17     registerAlias("integer[]", Integer[].class);
18     registerAlias("double[]", Double[].class);
19     registerAlias("float[]", Float[].class);
20     registerAlias("boolean[]", Boolean[].class);
21 
22     registerAlias("_byte", byte.class);
23     registerAlias("_long", long.class);
24     registerAlias("_short", short.class);
25     registerAlias("_int", int.class);
26     registerAlias("_integer", int.class);
27     registerAlias("_double", double.class);
28     registerAlias("_float", float.class);
29     registerAlias("_boolean", boolean.class);
30 
31     registerAlias("_byte[]", byte[].class);
32     registerAlias("_long[]", long[].class);
33     registerAlias("_short[]", short[].class);
34     registerAlias("_int[]", int[].class);
35     registerAlias("_integer[]", int[].class);
36     registerAlias("_double[]", double[].class);
37     registerAlias("_float[]", float[].class);
38     registerAlias("_boolean[]", boolean[].class);
39 
40     registerAlias("date", Date.class);
41     registerAlias("decimal", BigDecimal.class);
42     registerAlias("bigdecimal", BigDecimal.class);
43     registerAlias("biginteger", BigInteger.class);
44     registerAlias("object", Object.class);
45 
46     registerAlias("date[]", Date[].class);
47     registerAlias("decimal[]", BigDecimal[].class);
48     registerAlias("bigdecimal[]", BigDecimal[].class);
49     registerAlias("biginteger[]", BigInteger[].class);
50     registerAlias("object[]", Object[].class);
51 
52     registerAlias("map", Map.class);
53     registerAlias("hashmap", HashMap.class);
54     registerAlias("list", List.class);
55     registerAlias("arraylist", ArrayList.class);
56     registerAlias("collection", Collection.class);
57     registerAlias("iterator", Iterator.class);
58 
59     registerAlias("ResultSet", ResultSet.class);
60 }

對於這些數據,我們可以直接使用registerAlias方法的第一個參數對應的字符串而不需要定義這些typeAlias。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM