当前位置: 移动技术网 > IT编程>开发语言>Java > Mybaits 源码解析 (三)----- Mapper接口底层原理(为什么Mapper不用写实现类就能访问到数据库?)

Mybaits 源码解析 (三)----- Mapper接口底层原理(为什么Mapper不用写实现类就能访问到数据库?)

2019年10月29日  | 移动技术网IT编程  | 我要评论

当涂房屋出租,太子妃升职记360网盘,纪念918演讲稿

上一篇我们讲解到mapperelement方法用来解析mapper,我们这篇文章具体来看看mapper.xml的解析过程

mappers配置方式

mappers 标签下有许多 mapper 标签,每一个 mapper 标签中配置的都是一个独立的映射配置文件的路径,配置方式有以下几种。

接口信息进行配置

<mappers>
    <mapper class="org.mybatis.mappers.usermapper"/>
    <mapper class="org.mybatis.mappers.productmapper"/>
    <mapper class="org.mybatis.mappers.managermapper"/>
</mappers>

注意:这种方式必须保证接口名(例如usermapper)和xml名(usermapper.xml)相同,还必须在同一个包中。因为是通过获取mapper中的class属性,拼接上.xml来读取usermapper.xml,如果xml文件名不同或者不在同一个包中是无法读取到xml的。

相对路径进行配置

<mappers>
    <mapper resource="org/mybatis/mappers/usermapper.xml"/>
    <mapper resource="org/mybatis/mappers/productmapper.xml"/>
    <mapper resource="org/mybatis/mappers/managermapper.xml"/>
</mappers>

注意:这种方式不用保证同接口同包同名。但是要保证xml中的namespase和对应的接口名相同。

绝对路径进行配置

<mappers>
    <mapper url="file:///var/mappers/usermapper.xml"/>
    <mapper url="file:///var/mappers/productmapper.xml"/>
    <mapper url="file:///var/mappers/managermapper.xml"/>
</mappers>

接口所在包进行配置

<mappers>
    <package name="org.mybatis.mappers"/>
</mappers>

这种方式和第一种方式要求一致,保证接口名(例如usermapper)和xml名(usermapper.xml)相同,还必须在同一个包中。

注意:以上所有的配置都要保证xml中的namespase和对应的接口名相同。

我们以packae属性为例详细分析一下:

mappers解析入口方法

接上一篇文章最后部分,我们来看看mapperelement方法:

private void mapperelement(xnode parent) throws exception {
    if (parent != null) {
        for (xnode child : parent.getchildren()) {
            //包扫描的形式
            if ("package".equals(child.getname())) {
                // 获取 <package> 节点中的 name 属性
                string mapperpackage = child.getstringattribute("name");
                // 从指定包中查找 所有的 mapper 接口,并根据 mapper 接口解析映射配置
                configuration.addmappers(mapperpackage);
            } else {
                // 获取 resource/url/class 等属性
                string resource = child.getstringattribute("resource");
                string url = child.getstringattribute("url");
                string mapperclass = child.getstringattribute("class");

                // resource 不为空,且其他两者为空,则从指定路径中加载配置
                if (resource != null && url == null && mapperclass == null) {
                    errorcontext.instance().resource(resource);
                    inputstream inputstream = resources.getresourceasstream(resource);
                    xmlmapperbuilder mapperparser = new xmlmapperbuilder(inputstream, configuration, resource, configuration.getsqlfragments());
                    // 解析映射文件
                    mapperparser.parse();
                // url 不为空,且其他两者为空,则通过 url 加载配置
                } else if (resource == null && url != null && mapperclass == null) {
                    errorcontext.instance().resource(url);
                    inputstream inputstream = resources.geturlasstream(url);
                    xmlmapperbuilder mapperparser = new xmlmapperbuilder(inputstream, configuration, url, configuration.getsqlfragments());
                    // 解析映射文件
                    mapperparser.parse();
                // mapperclass 不为空,且其他两者为空,则通过 mapperclass 解析映射配置
                } else if (resource == null && url == null && mapperclass != null) {
                    class<?> mapperinterface = resources.classforname(mapperclass);
                    configuration.addmapper(mapperinterface);
                } else {
                    throw new builderexception("a mapper element may only specify a url, resource or class, but not more than one.");
                }
            }
        }
    }
}

在 mybatis 中,共有四种加载映射文件或信息的方式。第一种是从文件系统中加载映射文件;第二种是通过 url 的方式加载和解析映射文件;第三种是通过 mapper 接口加载映射信息,映射信息可以配置在注解中,也可以配置在映射文件中。最后一种是通过包扫描的方式获取到某个包下的所有类,并使用第三种方式为每个类解析映射信息。

我们先看下以packae扫描的形式,看下configuration.addmappers(mapperpackage)方法

public void addmappers(string packagename) {
    mapperregistry.addmappers(packagename);
}

我们看一下mapperregistry的addmappers方法:

 1 public void addmappers(string packagename) {
 2     //传入包名和object.class类型
 3     this.addmappers(packagename, object.class);
 4 }
 5 
 6 public void addmappers(string packagename, class<?> supertype) {
 7     resolverutil<class<?>> resolverutil = new resolverutil();
 8     /*
 9      * 查找包下的父类为 object.class 的类。
10      * 查找完成后,查找结果将会被缓存到resolverutil的内部集合中。上一篇文章我们已经看过这部分的源码,不再累述了
11      */ 
12     resolverutil.find(new isa(supertype), packagename);
13     // 获取查找结果
14     set<class<? extends class<?>>> mapperset = resolverutil.getclasses();
15     iterator i$ = mapperset.iterator();
16 
17     while(i$.hasnext()) {
18         class<?> mapperclass = (class)i$.next();
19         //我们具体看这个方法
20         this.addmapper(mapperclass);
21     }
22 
23 }

其实就是通过 vfs(虚拟文件系统)获取指定包下的所有文件的class,也就是所有的mapper接口,然后遍历每个mapper接口进行解析,接下来就和第一种配置方式(接口信息进行配置)一样的流程了,接下来我们来看看 基于 xml 的映射文件的解析过程,可以看到先创建一个xmlmapperbuilder,再调用其parse()方法:

 1 public void parse() {
 2     // 检测映射文件是否已经被解析过
 3     if (!configuration.isresourceloaded(resource)) {
 4         // 解析 mapper 节点
 5         configurationelement(parser.evalnode("/mapper"));
 6         // 添加资源路径到“已解析资源集合”中
 7         configuration.addloadedresource(resource);
 8         // 通过命名空间绑定 mapper 接口
 9         bindmapperfornamespace();
10     }
11 
12     parsependingresultmaps();
13     parsependingcacherefs();
14     parsependingstatements();
15 }

我们重点关注第5行和第9行的逻辑,也就是configurationelement和bindmapperfornamespace方法

解析映射文件

在 mybatis 映射文件中,可以配置多种节点。比如 <cache>,<resultmap>,<sql> 以及 <select | insert | update | delete> 等。下面我们来看一个映射文件配置示例。

<?xml version="1.0" encoding="utf-8"?>
<!doctype mapper public "-//mybatis.org//dtd mapper 3.0//en" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="mapper.employeemapper">
    <cache/>
    
    <resultmap id="basemap" type="entity.employee">
        <result property="id" column="id" jdbctype="integer"></result>
        <result property="name" column="name" jdbctype="varchar"></result>
    </resultmap>
    
    <sql id="table">
        employee
    </sql>
    
    <select id="getall" resultmap="basemap">
        select * from  <include refid="table"/>  where id = #{id}
    </select>
    
    <!-- <insert|update|delete/> -->
</mapper>

接着来看看configurationelement解析mapper.xml中的内容。

 1 private void configurationelement(xnode context) {
 2     try {
 3         // 获取 mapper 命名空间,如 mapper.employeemapper
 4         string namespace = context.getstringattribute("namespace");
 5         if (namespace == null || namespace.equals("")) {
 6             throw new builderexception("mapper's namespace cannot be empty");
 7         }
 8 
 9         // 设置命名空间到 builderassistant 中
10         builderassistant.setcurrentnamespace(namespace);
11 
12         // 解析 <cache-ref> 节点
13         cacherefelement(context.evalnode("cache-ref"));
14 
15         // 解析 <cache> 节点
16         cacheelement(context.evalnode("cache"));
17 
18         // 已废弃配置,这里不做分析
19         parametermapelement(context.evalnodes("/mapper/parametermap"));
20 
21         // 解析 <resultmap> 节点
22         resultmapelements(context.evalnodes("/mapper/resultmap"));
23 
24         // 解析 <sql> 节点
25         sqlelement(context.evalnodes("/mapper/sql"));
26 
27         // 解析 <select>、<insert>、<update>、<delete> 节点
28         buildstatementfromcontext(context.evalnodes("select|insert|update|delete"));
29     } catch (exception e) {
30         throw new builderexception("error parsing mapper xml. the xml location is '" + resource + "'. cause: " + e, e);
31     }
32 }

接下来我们就对其中关键的方法进行详细分析

解析 cache 节点

mybatis 提供了一、二级缓存,其中一级缓存是 sqlsession 级别的,默认为开启状态。二级缓存配置在映射文件中,使用者需要显示配置才能开启。如下:

<cache/>

也可以使用第三方缓存

<cache type="org.mybatis.caches.redis.rediscache"/>

其中有一些属性可以选择

<cache eviction="lru"  flushinterval="60000"  size="512" readonly="true"/>
  1. 根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”
  2. 缓存的容量为 512 个对象引用
  3. 缓存每隔60秒刷新一次
  4. 缓存返回的对象是写安全的,即在外部修改对象不会影响到缓存内部存储对象

下面我们来分析一下缓存配置的解析逻辑,如下:

private void cacheelement(xnode context) throws exception {
    if (context != null) {
        // 获取type属性,如果type没有指定就用默认的perpetual(早已经注册过的别名的perpetualcache)
        string type = context.getstringattribute("type", "perpetual");
        // 根据type从早已经注册的别名中获取对应的class,perpetual对应的class是perpetualcache.class
        // 如果我们写了type属性,如type="org.mybatis.caches.redis.rediscache",这里将会得到rediscache.class
        class<? extends cache> typeclass = typealiasregistry.resolvealias(type);
        //获取淘汰方式,默认为lru(早已经注册过的别名的lrucache),最近最少使用到的先淘汰
        string eviction = context.getstringattribute("eviction", "lru");
        class<? extends cache> evictionclass = typealiasregistry.resolvealias(eviction);
        long flushinterval = context.getlongattribute("flushinterval");
        integer size = context.getintattribute("size");
        boolean readwrite = !context.getbooleanattribute("readonly", false);
        boolean blocking = context.getbooleanattribute("blocking", false);

        // 获取子节点配置
        properties props = context.getchildrenasproperties();

        // 构建缓存对象
        builderassistant.usenewcache(typeclass, evictionclass, flushinterval, size, readwrite, blocking, props);
    }
}

public <t> class<t> resolvealias(string string) {
    try {
        if (string == null) {
            return null;
        } else {
            // 转换成小写
            string key = string.tolowercase(locale.english);
            class value;
            // 如果没有设置type属性,则这里传过来的是perpetual,能从别名缓存中获取到perpetualcache.class
            if (this.type_aliases.containskey(key)) {
                value = (class)this.type_aliases.get(key);
            } else {
                //如果是设置了自定义的type,则在别名缓存中是获取不到的,直接通过类加载,加载自定义的type,如rediscache.class
                value = resources.classforname(string);
            }

            return value;
        }
    } catch (classnotfoundexception var4) {
        throw new typeexception("could not resolve type alias '" + string + "'.  cause: " + var4, var4);
    }
}

缓存的构建封装在 builderassistant 类的 usenewcache 方法中,我们来看看

public cache usenewcache(class<? extends cache> typeclass,
    class<? extends cache> evictionclass,long flushinterval,
    integer size,boolean readwrite,boolean blocking,properties props) {

    // 使用建造模式构建缓存实例
    cache cache = new cachebuilder(currentnamespace)
        .implementation(valueordefault(typeclass, perpetualcache.class))
        .adddecorator(valueordefault(evictionclass, lrucache.class))
        .clearinterval(flushinterval)
        .size(size)
        .readwrite(readwrite)
        .blocking(blocking)
        .properties(props)
        .build();

    // 添加缓存到 configuration 对象中
    configuration.addcache(cache);

    // 设置 currentcache 属性,即当前使用的缓存
    currentcache = cache;
    return cache;
}

上面使用了建造模式构建 cache 实例,我们跟下去看看。

public cache build() {
    // 设置默认的缓存类型(perpetualcache)和缓存装饰器(lrucache)
    setdefaultimplementations();

    // 通过反射创建缓存
    cache cache = newbasecacheinstance(implementation, id);
    setcacheproperties(cache);
    // 仅对内置缓存 perpetualcache 应用装饰器
    if (perpetualcache.class.equals(cache.getclass())) {
        // 遍历装饰器集合,应用装饰器
        for (class<? extends cache> decorator : decorators) {
            // 通过反射创建装饰器实例
            cache = newcachedecoratorinstance(decorator, cache);
            // 设置属性值到缓存实例中
            setcacheproperties(cache);
        }
        // 应用标准的装饰器,比如 loggingcache、synchronizedcache
        cache = setstandarddecorators(cache);
    } else if (!loggingcache.class.isassignablefrom(cache.getclass())) {
        // 应用具有日志功能的缓存装饰器
        cache = new loggingcache(cache);
    }
    return cache;
}

private void setdefaultimplementations() {
    if (this.implementation == null) {
        //设置默认缓存类型为perpetualcache
        this.implementation = perpetualcache.class;
        if (this.decorators.isempty()) {
            this.decorators.add(lrucache.class);
        }
    }
}

private cache newbasecacheinstance(class<? extends cache> cacheclass, string id) {
    //获取构造器
    constructor cacheconstructor = this.getbasecacheconstructor(cacheclass);

    try {
        //通过构造器实例化cache
        return (cache)cacheconstructor.newinstance(id);
    } catch (exception var5) {
        throw new cacheexception("could not instantiate cache implementation (" + cacheclass + "). cause: " + var5, var5);
    }
}

如上就创建好了一个cache的实例,然后把它添加到configuration中,并且设置到currentcache属性中,这个属性后面还要使用,也就是cache实例后面还要使用,我们后面再看。

解析 resultmap 节点

resultmap 主要用于映射结果。通过 resultmap 和自动映射,可以让 mybatis 帮助我们完成 resultset → object 的映射。下面开始分析 resultmap 配置的解析过程。

private void resultmapelements(list<xnode> list) throws exception {
    // 遍历 <resultmap> 节点列表
    for (xnode resultmapnode : list) {
        try {
            // 解析 resultmap 节点
            resultmapelement(resultmapnode);
        } catch (incompleteelementexception e) {
        }
    }
}

private resultmap resultmapelement(xnode resultmapnode) throws exception {
    return resultmapelement(resultmapnode, collections.<resultmapping>emptylist());
}

private resultmap resultmapelement(xnode resultmapnode, list<resultmapping> additionalresultmappings) throws exception {
    errorcontext.instance().activity("processing " + resultmapnode.getvaluebasedidentifier());

    // 获取 id 和 type 属性
    string id = resultmapnode.getstringattribute("id", resultmapnode.getvaluebasedidentifier());
    string type = resultmapnode.getstringattribute("type",
        resultmapnode.getstringattribute("oftype",
            resultmapnode.getstringattribute("resulttype",
                resultmapnode.getstringattribute("javatype"))));
    // 获取 extends 和 automapping
    string extend = resultmapnode.getstringattribute("extends");
    boolean automapping = resultmapnode.getbooleanattribute("automapping");

    // 获取 type 属性对应的类型
    class<?> typeclass = resolveclass(type);
    discriminator discriminator = null;
    //创建resultmapping集合,对应resultmap子节点的id和result节点
    list<resultmapping> resultmappings = new arraylist<resultmapping>();
    resultmappings.addall(additionalresultmappings);

    // 获取并遍历 <resultmap> 的子节点列表
    list<xnode> resultchildren = resultmapnode.getchildren();
    for (xnode resultchild : resultchildren) {
        if ("constructor".equals(resultchild.getname())) {
            processconstructorelement(resultchild, typeclass, resultmappings);
        } else if ("discriminator".equals(resultchild.getname())) {
            discriminator = processdiscriminatorelement(resultchild, typeclass, resultmappings);
        } else {

            list<resultflag> flags = new arraylist<resultflag>();
            if ("id".equals(resultchild.getname())) {
                // 添加 id 到 flags 集合中
                flags.add(resultflag.id);
            }
            // 解析 id 和 result 节点,将id或result节点生成相应的 resultmapping,将resultmapping添加到resultmappings集合中
            resultmappings.add(buildresultmappingfromcontext(resultchild, typeclass, flags));
        }
    }
    //创建resultmapresolver对象
    resultmapresolver resultmapresolver = new resultmapresolver(builderassistant, id, typeclass, extend,
        discriminator, resultmappings, automapping);
    try {
        // 根据前面获取到的信息构建 resultmap 对象
        return resultmapresolver.resolve();
    } catch (incompleteelementexception e) {
        configuration.addincompleteresultmap(resultmapresolver);
        throw e;
    }
}

解析 id 和 result 节点

在 <resultmap> 节点中,子节点 <id> 和 <result> 都是常规配置,比较常见。我们来看看解析过程

private resultmapping buildresultmappingfromcontext(xnode context, class<?> resulttype, list<resultflag> flags) throws exception {
    string property;
    // 根据节点类型获取 name 或 property 属性
    if (flags.contains(resultflag.constructor)) {
        property = context.getstringattribute("name");
    } else {
        property = context.getstringattribute("property");
    }

    // 获取其他各种属性
    string column = context.getstringattribute("column");
    string javatype = context.getstringattribute("javatype");
    string jdbctype = context.getstringattribute("jdbctype");
    string nestedselect = context.getstringattribute("select");
    
    /*
     * 解析 resultmap 属性,该属性出现在 <association> 和 <collection> 节点中。
     * 若这两个节点不包含 resultmap 属性,则调用 processnestedresultmappings 方法,递归调用resultmapelement解析<association> 和 <collection>的嵌套节点,生成resultmap,并返回resultmap.getid();
     * 如果包含resultmap属性,则直接获取其属性值,这个属性值对应一个resultmap节点
     */
    string nestedresultmap = context.getstringattribute("resultmap", processnestedresultmappings(context, collections.<resultmapping>emptylist()));
    
    string notnullcolumn = context.getstringattribute("notnullcolumn");
    string columnprefix = context.getstringattribute("columnprefix");
    string typehandler = context.getstringattribute("typehandler");
    string resultset = context.getstringattribute("resultset");
    string foreigncolumn = context.getstringattribute("foreigncolumn");
    boolean lazy = "lazy".equals(context.getstringattribute("fetchtype", configuration.islazyloadingenabled() ? "lazy" : "eager"));

    class<?> javatypeclass = resolveclass(javatype);
    class<? extends typehandler<?>> typehandlerclass = (class<? extends typehandler<?>>) resolveclass(typehandler);
    jdbctype jdbctypeenum = resolvejdbctype(jdbctype);

    // 构建 resultmapping 对象
    return builderassistant.buildresultmapping(resulttype, property, column, javatypeclass, jdbctypeenum, nestedselect,
        nestedresultmap, notnullcolumn, columnprefix, typehandlerclass, flags, resultset, foreigncolumn, lazy);
}

看processnestedresultmappings解析<association> 和 <collection> 节点中的子节点,并返回resultmap.id

private string processnestedresultmappings(xnode context, list<resultmapping> resultmappings) throws exception {
    if (("association".equals(context.getname()) || "collection".equals(context.getname()) || "case".equals(context.getname())) && context.getstringattribute("select") == null) {
        resultmap resultmap = this.resultmapelement(context, resultmappings);
        return resultmap.getid();
    } else {
        return null;
    }
}

下面以 <association> 节点为例,演示该节点的两种配置方式,分别如下:

第一种配置方式是通过 resultmap 属性引用其他的 <resultmap> 节点,配置如下:

<resultmap id="articleresult" type="article">
    <id property="id" column="id"/>
    <result property="title" column="article_title"/>
    <!-- 引用 authorresult -->
    <association property="article_author" column="article_author_id" javatype="author" resultmap="authorresult"/>
</resultmap>

<resultmap id="authorresult" type="author">
    <id property="id" column="author_id"/>
    <result property="name" column="author_name"/>
</resultmap>

第二种配置方式是采取 resultmap 嵌套的方式进行配置,如下:

<resultmap id="articleresult" type="article">
    <id property="id" column="id"/>
    <result property="title" column="article_title"/>
    <!-- resultmap 嵌套 -->
    <association property="article_author" javatype="author">
        <id property="id" column="author_id"/>
        <result property="name" column="author_name"/>
    </association>
</resultmap>

第二种配置,<association> 的子节点是一些结果映射配置,这些结果配置最终也会被解析成 resultmap。

下面分析 resultmapping 的构建过程。

public resultmapping buildresultmapping(class<?> resulttype, string property, string column, class<?> javatype,jdbctype jdbctype, 
    string nestedselect, string nestedresultmap, string notnullcolumn, string columnprefix,class<? extends typehandler<?>> typehandler, 
    list<resultflag> flags, string resultset, string foreigncolumn, boolean lazy) {

    // resulttype:即 <resultmap type="xxx"/> 中的 type 属性
    // property:即 <result property="xxx"/> 中的 property 属性
    class<?> javatypeclass = resolveresultjavatype(resulttype, property, javatype);

    typehandler<?> typehandlerinstance = resolvetypehandler(javatypeclass, typehandler);

    list<resultmapping> composites = parsecompositecolumnname(column);

    // 通过建造模式构建 resultmapping
    return new resultmapping.builder(configuration, property, column, javatypeclass)
        .jdbctype(jdbctype)
        .nestedqueryid(applycurrentnamespace(nestedselect, true))
        .nestedresultmapid(applycurrentnamespace(nestedresultmap, true))
        .resultset(resultset)
        .typehandler(typehandlerinstance)
        .flags(flags == null ? new arraylist<resultflag>() : flags)
        .composites(composites)
        .notnullcolumns(parsemultiplecolumnnames(notnullcolumn))
        .columnprefix(columnprefix)
        .foreigncolumn(foreigncolumn)
        .lazy(lazy)
        .build();
}

private class<?> resolveresultjavatype(class<?> resulttype, string property, class<?> javatype) {
    if (javatype == null && property != null) {
        try {
            //获取resultmap中的type属性的元类,如<resultmap id="user" type="java.model.user"/> 中user的元类
            metaclass metaresulttype = metaclass.forclass(resulttype, this.configuration.getreflectorfactory());
            //<result property="name" javatype="string"/>,如果result中没有设置javatype,则获取元类属性对那个的类型
            javatype = metaresulttype.getsettertype(property);
        } catch (exception var5) {
            ;
        }
    }

    if (javatype == null) {
        javatype = object.class;
    }

    return javatype;
}
    
public resultmapping build() {
    resultmapping.flags = collections.unmodifiablelist(resultmapping.flags);
    resultmapping.composites = collections.unmodifiablelist(resultmapping.composites);
    resolvetypehandler();
    validate();
    return resultmapping;
}

我们来看看resultmapping类

public class resultmapping {
    private configuration configuration;
    private string property;
    private string column;
    private class<?> javatype;
    private jdbctype jdbctype;
    private typehandler<?> typehandler;
    private string nestedresultmapid;
    private string nestedqueryid;
    private set<string> notnullcolumns;
    private string columnprefix;
    private list<resultflag> flags;
    private list<resultmapping> composites;
    private string resultset;
    private string foreigncolumn;
    private boolean lazy;

    resultmapping() {
    }
    //略
}

resultmapping就是和resultmap中子节点id和result对应

<id column="wi_id" jdbctype="integer"  property="id" />
<result column="warrant_no" jdbctype="string"  jdbctype="char" property="warrantno" />

resultmap 对象构建

前面的分析我们知道了<id>,<result> 等节点最终都被解析成了 resultmapping。并且封装到了resultmappings集合中,紧接着要做的事情是构建 resultmap,关键代码在resultmapresolver.resolve():

public resultmap resolve() {
    return assistant.addresultmap(this.id, this.type, this.extend, this.discriminator, this.resultmappings, this.automapping);
}

public resultmap addresultmap(
    string id, class<?> type, string extend, discriminator discriminator,
    list<resultmapping> resultmappings, boolean automapping) {
    
    // 为 resultmap 的 id 和 extend 属性值拼接命名空间
    id = applycurrentnamespace(id, false);
    extend = applycurrentnamespace(extend, true);

    if (extend != null) {
        if (!configuration.hasresultmap(extend)) {
            throw new incompleteelementexception("could not find a parent resultmap with id '" + extend + "'");
        }
        resultmap resultmap = configuration.getresultmap(extend);
        list<resultmapping> extendedresultmappings = new arraylist<resultmapping>(resultmap.getresultmappings());
        extendedresultmappings.removeall(resultmappings);
        
        boolean declaresconstructor = false;
        for (resultmapping resultmapping : resultmappings) {
            if (resultmapping.getflags().contains(resultflag.constructor)) {
                declaresconstructor = true;
                break;
            }
        }
        
        if (declaresconstructor) {
            iterator<resultmapping> extendedresultmappingsiter = extendedresultmappings.iterator();
            while (extendedresultmappingsiter.hasnext()) {
                if (extendedresultmappingsiter.next().getflags().contains(resultflag.constructor)) {
                    extendedresultmappingsiter.remove();
                }
            }
        }
        resultmappings.addall(extendedresultmappings);
    }

    // 构建 resultmap
    resultmap resultmap = new resultmap.builder(configuration, id, type, resultmappings, automapping)
        .discriminator(discriminator)
        .build();
    // 将创建好的resultmap加入configuration中
    configuration.addresultmap(resultmap);
    return resultmap;
}

我们先看看resultmap

public class resultmap {
    private string id;
    private class<?> type;
    private list<resultmapping> resultmappings;
    //用于存储 <id> 节点对应的 resultmapping 对象
    private list<resultmapping> idresultmappings;
    private list<resultmapping> constructorresultmappings;
    //用于存储 <id> 和 <result> 节点对应的 resultmapping 对象
    private list<resultmapping> propertyresultmappings;
    //用于存储 所有<id>、<result> 节点 column 属性
    private set<string> mappedcolumns;
    private discriminator discriminator;
    private boolean hasnestedresultmaps;
    private boolean hasnestedqueries;
    private boolean automapping;

    private resultmap() {
    }
    //略
}

再来看看通过建造模式构建 resultmap 实例

public resultmap build() {
    if (resultmap.id == null) {
        throw new illegalargumentexception("resultmaps must have an id");
    }
    resultmap.mappedcolumns = new hashset<string>();
    resultmap.mappedproperties = new hashset<string>();
    resultmap.idresultmappings = new arraylist<resultmapping>();
    resultmap.constructorresultmappings = new arraylist<resultmapping>();
    resultmap.propertyresultmappings = new arraylist<resultmapping>();
    final list<string> constructorargnames = new arraylist<string>();

    for (resultmapping resultmapping : resultmap.resultmappings) {
        /*
         * 检测 <association> 或 <collection> 节点
         * 是否包含 select 和 resultmap 属性
         */
        resultmap.hasnestedqueries = resultmap.hasnestedqueries || resultmapping.getnestedqueryid() != null;
        resultmap.hasnestedresultmaps =
            resultmap.hasnestedresultmaps || (resultmapping.getnestedresultmapid() != null && resultmapping.getresultset() == null);

        final string column = resultmapping.getcolumn();
        if (column != null) {
            // 将 colum 转换成大写,并添加到 mappedcolumns 集合中
            resultmap.mappedcolumns.add(column.touppercase(locale.english));
        } else if (resultmapping.iscompositeresult()) {
            for (resultmapping compositeresultmapping : resultmapping.getcomposites()) {
                final string compositecolumn = compositeresultmapping.getcolumn();
                if (compositecolumn != null) {
                    resultmap.mappedcolumns.add(compositecolumn.touppercase(locale.english));
                }
            }
        }

        // 添加属性 property 到 mappedproperties 集合中
        final string property = resultmapping.getproperty();
        if (property != null) {
            resultmap.mappedproperties.add(property);
        }

        if (resultmapping.getflags().contains(resultflag.constructor)) {
            resultmap.constructorresultmappings.add(resultmapping);
            if (resultmapping.getproperty() != null) {
                constructorargnames.add(resultmapping.getproperty());
            }
        } else {
            // 添加 resultmapping 到 propertyresultmappings 中
            resultmap.propertyresultmappings.add(resultmapping);
        }

        if (resultmapping.getflags().contains(resultflag.id)) {
            // 添加 resultmapping 到 idresultmappings 中
            resultmap.idresultmappings.add(resultmapping);
        }
    }
    if (resultmap.idresultmappings.isempty()) {
        resultmap.idresultmappings.addall(resultmap.resultmappings);
    }
    if (!constructorargnames.isempty()) {
        final list<string> actualargnames = argnamesofmatchingconstructor(constructorargnames);
        if (actualargnames == null) {
            throw new builderexception("error in result map '" + resultmap.id
                + "'. failed to find a constructor in '"
                + resultmap.gettype().getname() + "' by arg names " + constructorargnames
                + ". there might be more info in debug log.");
        }
        collections.sort(resultmap.constructorresultmappings, new comparator<resultmapping>() {
            @override
            public int compare(resultmapping o1, resultmapping o2) {
                int paramidx1 = actualargnames.indexof(o1.getproperty());
                int paramidx2 = actualargnames.indexof(o2.getproperty());
                return paramidx1 - paramidx2;
            }
        });
    }

    // 将以下这些集合变为不可修改集合
    resultmap.resultmappings = collections.unmodifiablelist(resultmap.resultmappings);
    resultmap.idresultmappings = collections.unmodifiablelist(resultmap.idresultmappings);
    resultmap.constructorresultmappings = collections.unmodifiablelist(resultmap.constructorresultmappings);
    resultmap.propertyresultmappings = collections.unmodifiablelist(resultmap.propertyresultmappings);
    resultmap.mappedcolumns = collections.unmodifiableset(resultmap.mappedcolumns);
    return resultmap;
}

主要做的事情就是将 resultmapping 实例及属性分别存储到不同的集合中。

解析 sql 节点

<sql> 节点用来定义一些可重用的 sql 语句片段,比如表名,或表的列名等。在映射文件中,我们可以通过 <include> 节点引用 <sql> 节点定义的内容。

<sql id="table">
    user
</sql>

<select id="findone" resulttype="article">
    select * from <include refid="table"/> where id = #{id}
</select>

下面分析一下 sql 节点的解析过程,如下:

private void sqlelement(list<xnode> list) throws exception {
    if (configuration.getdatabaseid() != null) {
        // 调用 sqlelement 解析 <sql> 节点
        sqlelement(list, configuration.getdatabaseid());
    }

    // 再次调用 sqlelement,不同的是,这次调用,该方法的第二个参数为 null
    sqlelement(list, null);
}

private void sqlelement(list<xnode> list, string requireddatabaseid) throws exception {
    for (xnode context : list) {
        // 获取 id 和 databaseid 属性
        string databaseid = context.getstringattribute("databaseid");
        string id = context.getstringattribute("id");

        // id = currentnamespace + "." + id
        id = builderassistant.applycurrentnamespace(id, false);

        // 检测当前 databaseid 和 requireddatabaseid 是否一致
        if (databaseidmatchescurrent(id, databaseid, requireddatabaseid)) {
            // 将 <id, xnode> 键值对缓存到xmlmapperbuilder对象的 sqlfragments 属性中,以供后面的sql语句使用
            sqlfragments.put(id, context);
        }
    }
}

解析select|insert|update|delete节点

 <select>、<insert>、<update> 以及 <delete> 等节点统称为 sql 语句节点,其解析过程在buildstatementfromcontext方法中:

private void buildstatementfromcontext(list<xnode> list) {
    if (configuration.getdatabaseid() != null) {
        // 调用重载方法构建 statement
        buildstatementfromcontext(list, configuration.getdatabaseid());
    }
    buildstatementfromcontext(list, null);
}

private void buildstatementfromcontext(list<xnode> list, string requireddatabaseid) {
    for (xnode context : list) {
        // 创建 xmlstatementbuilder 建造类
        final xmlstatementbuilder statementparser = new xmlstatementbuilder(configuration, builderassistant, context, requireddatabaseid);
        try {
            /*
             * 解析sql节点,将其封装到 statement 对象中,并将解析结果存储到 configuration 的 mappedstatements 集合中
             */
            statementparser.parsestatementnode();
        } catch (incompleteelementexception e) {
            configuration.addincompletestatement(statementparser);
        }
    }
}

我们继续看 statementparser.parsestatementnode();

public void parsestatementnode() {
    // 获取 id 和 databaseid 属性
    string id = context.getstringattribute("id");
    string databaseid = context.getstringattribute("databaseid");

    if (!databaseidmatchescurrent(id, databaseid, this.requireddatabaseid)) {
        return;
    }

    // 获取各种属性
    integer fetchsize = context.getintattribute("fetchsize");
    integer timeout = context.getintattribute("timeout");
    string parametermap = context.getstringattribute("parametermap");
    string parametertype = context.getstringattribute("parametertype");
    class<?> parametertypeclass = resolveclass(parametertype);
    string resultmap = context.getstringattribute("resultmap");
    string resulttype = context.getstringattribute("resulttype");
    string lang = context.getstringattribute("lang");
    languagedriver langdriver = getlanguagedriver(lang);

    // 通过别名解析 resulttype 对应的类型
    class<?> resulttypeclass = resolveclass(resulttype);
    string resultsettype = context.getstringattribute("resultsettype");
    
    // 解析 statement 类型,默认为 prepared
    statementtype statementtype = statementtype.valueof(context.getstringattribute("statementtype", statementtype.prepared.tostring()));
    
    // 解析 resultsettype
    resultsettype resultsettypeenum = resolveresultsettype(resultsettype);

    // 获取节点的名称,比如 <select> 节点名称为 select
    string nodename = context.getnode().getnodename();
    // 根据节点名称解析 sqlcommandtype
    sqlcommandtype sqlcommandtype = sqlcommandtype.valueof(nodename.touppercase(locale.english));
    boolean isselect = sqlcommandtype == sqlcommandtype.select;
    boolean flushcache = context.getbooleanattribute("flushcache", !isselect);
    boolean usecache = context.getbooleanattribute("usecache", isselect);
    boolean resultordered = context.getbooleanattribute("resultordered", false);

    // 解析 <include> 节点
    xmlincludetransformer includeparser = new xmlincludetransformer(configuration, builderassistant);
    includeparser.applyincludes(context.getnode());

    processselectkeynodes(id, parametertypeclass, langdriver);

    // 解析 sql 语句
    sqlsource sqlsource = langdriver.createsqlsource(configuration, context, parametertypeclass);
    string resultsets = context.getstringattribute("resultsets");
    string keyproperty = context.getstringattribute("keyproperty");
    string keycolumn = context.getstringattribute("keycolumn");

    keygenerator keygenerator;
    string keystatementid = id + selectkeygenerator.select_key_suffix;
    keystatementid = builderassistant.applycurrentnamespace(keystatementid, true);
    if (configuration.haskeygenerator(keystatementid)) {
        keygenerator = configuration.getkeygenerator(keystatementid);
    } else {
        keygenerator = context.getbooleanattribute("usegeneratedkeys",
            configuration.isusegeneratedkeys() && sqlcommandtype.insert.equals(sqlcommandtype)) ? jdbc3keygenerator.instance : nokeygenerator.instance;
    }

    /*
     * 构建 mappedstatement 对象,并将该对象存储到 configuration 的 mappedstatements 集合中
     */
    builderassistant.addmappedstatement(id, sqlsource, statementtype, sqlcommandtype,
        fetchsize, timeout, parametermap, parametertypeclass, resultmap, resulttypeclass,
        resultsettypeenum, flushcache, usecache, resultordered,
        keygenerator, keyproperty, keycolumn, databaseid, langdriver, resultsets);
}

我们主要来分析下面几个重要的方法:

  1. 解析 <include> 节点
  2. 解析 sql,获取 sqlsource
  3. 构建 mappedstatement 实例

解析 <include> 节点

先来看一个include的例子

<mapper namespace="java.mybaits.dao.usermapper">
    <sql id="table">
        user
    </sql>

    <select id="findone" resulttype="user">
        select  * from  <include refid="table"/> where id = #{id}
    </select>
</mapper>

<include> 节点的解析逻辑封装在 applyincludes 中,该方法的代码如下:

public void applyincludes(node source) {
    properties variablescontext = new properties();
    properties configurationvariables = configuration.getvariables();
    if (configurationvariables != null) {
        // 将 configurationvariables 中的数据添加到 variablescontext 中
        variablescontext.putall(configurationvariables);
    }

    // 调用重载方法处理 <include> 节点
    applyincludes(source, variablescontext, false);
}

继续看 applyincludes 方法

private void applyincludes(node source, final properties variablescontext, boolean included) {

    // 第一个条件分支
    if (source.getnodename().equals("include")) {

        //获取 <sql> 节点。
        node toinclude = findsqlfragment(getstringattribute(source, "refid"), variablescontext);

        properties toincludecontext = getvariablescontext(source, variablescontext);

        applyincludes(toinclude, toincludecontext, true);

        if (toinclude.getownerdocument() != source.getownerdocument()) {
            toinclude = source.getownerdocument().importnode(toinclude, true);
        }
        // 将 <select>节点中的 <include> 节点替换为 <sql> 节点
        source.getparentnode().replacechild(toinclude, source);
        while (toinclude.haschildnodes()) {
            // 将 <sql> 中的内容插入到 <sql> 节点之前
            toinclude.getparentnode().insertbefore(toinclude.getfirstchild(), toinclude);
        }

        /*
         * 前面已经将 <sql> 节点的内容插入到 dom 中了,
         * 现在不需要 <sql> 节点了,这里将该节点从 dom 中移除
         */
        toinclude.getparentnode().removechild(toinclude);

    // 第二个条件分支
    } else if (source.getnodetype() == node.element_node) {
        if (included && !variablescontext.isempty()) {
            namednodemap attributes = source.getattributes();
            for (int i = 0; i < attributes.getlength(); i++) {
                node attr = attributes.item(i);
                // 将 source 节点属性中的占位符 ${} 替换成具体的属性值
                attr.setnodevalue(propertyparser.parse(attr.getnodevalue(), variablescontext));
            }
        }
        
        nodelist children = source.getchildnodes();
        for (int i = 0; i < children.getlength(); i++) {
            // 递归调用
            applyincludes(children.item(i), variablescontext, included);
        }
        
    // 第三个条件分支
    } else if (included && source.getnodetype() == node.text_node && !variablescontext.isempty()) {
        // 将文本(text)节点中的属性占位符 ${} 替换成具体的属性值
        source.setnodevalue(propertyparser.parse(source.getnodevalue(), variablescontext));
    }
}

我们先来看一下 applyincludes 方法第一次被调用时的状态,source为<select> 节点,节点类型:element_node,此时会进入第二个分支,获取到获取 <select> 子节点列表,遍历子节点列表,将子节点作为参数,进行递归调用applyincludes ,此时可获取到的子节点如下:

编号子节点类型描述
1 select * from text_node 文本节点
2 <include refid="table"/> element_node 普通节点
3 where id = #{id} text_node 文本节点

接下来要做的事情是遍历列表,然后将子节点作为参数进行递归调用。第一个子节点调用applyincludes方法,source为 select * from 节点,节点类型:text_node,进入分支三,没有${},不会替换,则节点一结束返回,什么都没有做。第二个节点调用applyincludes方法,此时source为 <include refid="table"/>节点,节点类型:element_node,进入分支一,通过refid找到 sql 节点,也就是toinclude节点,然后执行source.getparentnode().replacechild(toinclude, source);,直接将<include refid="table"/>节点的父节点,也就是<select> 节点中的当前<include >节点替换成 <sql> 节点,然后调用toinclude.getparentnode().insertbefore(toinclude.getfirstchild(), toinclude);,将 <sql> 中的内容插入到 <sql> 节点之前,也就是将user插入到 <sql> 节点之前,现在不需要 <sql> 节点了,最后将该节点从 dom 中移除

创建sqlsource

创建sqlsource在createsqlsource方法中

public sqlsource createsqlsource(configuration configuration, xnode script, class<?> parametertype) {
    xmlscriptbuilder builder = new xmlscriptbuilder(configuration, script, parametertype);
    return builder.parsescriptnode();
}

// -☆- xmlscriptbuilder
public sqlsource parsescriptnode() {
    // 解析 sql 语句节点
    mixedsqlnode rootsqlnode = parsedynamictags(context);
    sqlsource sqlsource = null;
    // 根据 isdynamic 状态创建不同的 sqlsource
    if (isdynamic) {
        sqlsource = new dynamicsqlsource(configuration, rootsqlnode);
    } else {
        sqlsource = new rawsqlsource(configuration, rootsqlnode, parametertype);
    }
    return sqlsource;
}

继续跟进parsedynamictags

/** 该方法用于初始化 nodehandlermap 集合,该集合后面会用到 */
private void initnodehandlermap() {
    nodehandlermap.put("trim", new trimhandler());
    nodehandlermap.put("where", new wherehandler());
    nodehandlermap.put("set", new sethandler());
    nodehandlermap.put("foreach", new foreachhandler());
    nodehandlermap.put("if", new ifhandler());
    nodehandlermap.put("choose", new choosehandler());
    nodehandlermap.put("when", new ifhandler());
    nodehandlermap.put("otherwise", new otherwisehandler());
    nodehandlermap.put("bind", new bindhandler());
}
    
protected mixedsqlnode parsedynamictags(xnode node) {
    list<sqlnode> contents = new arraylist<sqlnode>();
    nodelist children = node.getnode().getchildnodes();
    // 遍历子节点
    for (int i = 0; i < children.getlength(); i++) {
        xnode child = node.newxnode(children.item(i));
        //如果节点是text_node类型
        if (child.getnode().getnodetype() == node.cdata_section_node || child.getnode().getnodetype() == node.text_node) {
            // 获取文本内容
            string data = child.getstringbody("");
            textsqlnode textsqlnode = new textsqlnode(data);
            // 若文本中包含 ${} 占位符,会被认为是动态节点
            if (textsqlnode.isdynamic()) {
                contents.add(textsqlnode);
                // 设置 isdynamic 为 true
                isdynamic = true;
            } else {
                // 创建 statictextsqlnode
                contents.add(new statictextsqlnode(data));
            }

        // child 节点是 element_node 类型,比如 <if>、<where> 等
        } else if (child.getnode().getnodetype() == node.element_node) {
            // 获取节点名称,比如 if、where、trim 等
            string nodename = child.getnode().getnodename();
            // 根据节点名称获取 nodehandler,也就是上面注册的nodehandlermap
            nodehandler handler = nodehandlermap.get(nodename);
            if (handler == null) {
                throw new builderexception("unknown element <" + nodename + "> in sql statement.");
            }
            // 处理 child 节点,生成相应的 sqlnode
            handler.handlenode(child, contents);

            // 设置 isdynamic 为 true
            isdynamic = true;
        }
    }
    return new mixedsqlnode(contents);
}

对于if、trim、where等这些动态节点,是通过对应的handler来解析的,如下

handler.handlenode(child, contents);

该代码用于处理动态 sql 节点,并生成相应的 sqlnode。下面来简单分析一下 wherehandler 的代码。

/** 定义在 xmlscriptbuilder 中 */
private class wherehandler implements nodehandler {

    public wherehandler() {
    }

    @override
    public void handlenode(xnode nodetohandle, list<sqlnode> targetcontents) {
        // 调用 parsedynamictags 解析 <where> 节点
        mixedsqlnode mixedsqlnode = parsedynamictags(nodetohandle);
        // 创建 wheresqlnode
        wheresqlnode where = new wheresqlnode(configuration, mixedsqlnode);
        // 添加到 targetcontents
        targetcontents.add(where);
    }
}

我们已经将 xml 配置解析了 sqlsource,下面我们看看mappedstatement的构建。

构建mappedstatement

sql 语句节点可以定义很多属性,这些属性和属性值最终存储在 mappedstatement 中。

public mappedstatement addmappedstatement(
    string id, sqlsource sqlsource, statementtype statementtype, 
    sqlcommandtype sqlcommandtype,integer fetchsize, integer timeout, 
    string parametermap, class<?> parametertype,string resultmap, 
    class<?> resulttype, resultsettype resultsettype, boolean flushcache,
    boolean usecache, boolean resultordered, keygenerator keygenerator, 
    string keyproperty,string keycolumn, string databaseid, 
    languagedriver lang, string resultsets) {

    if (unresolvedcacheref) {
        throw new incompleteelementexception("cache-ref not yet resolved");
    }
  // 拼接上命名空间,如 <select id="findone" resulttype="user">,则id=java.mybaits.dao.usermapper.findone
    id = applycurrentnamespace(id, false);
    boolean isselect = sqlcommandtype == sqlcommandtype.select;

    // 创建建造器,设置各种属性
    mappedstatement.builder statementbuilder = new mappedstatement.builder(configuration, id, sqlsource, sqlcommandtype)
        .resource(resource).fetchsize(fetchsize).timeout(timeout)
        .statementtype(statementtype).keygenerator(keygenerator)
        .keyproperty(keyproperty).keycolumn(keycolumn).databaseid(databaseid)
        .lang(lang).resultordered(resultordered).resultsets(resultsets)
        .resultmaps(getstatementresultmaps(resultmap, resulttype, id))
        .flushcacherequired(valueordefault(flushcache, !isselect))
        .resultsettype(resultsettype).usecache(valueordefault(usecache, isselect))
        .cache(currentcache);//这里用到了前面解析<cache>节点时创建的cache对象,设置到mappedstatement对象里面的cache属性中

    // 获取或创建 parametermap
    parametermap statementparametermap = getstatementparametermap(parametermap, parametertype, id);
    if (statementparametermap != null) {
        statementbuilder.parametermap(statementparametermap);
    }

    // 构建 mappedstatement
    mappedstatement statement = statementbuilder.build();
    // 添加 mappedstatement 到 configuration 的 mappedstatements 集合中
    // 通过usermapper代理对象调用findone方法时,就可以拼接usermapper接口名java.mybaits.dao.usermapper和findone方法找到id=java.mybaits.dao.usermapper的mappedstatement,然后执行对应的sql语句
    configuration.addmappedstatement(statement);
    return statement;
}

这里我们要注意,mappedstatement对象中有一个cache属性,将前面解析<cache>节点时创建的cache对象,设置到mappedstatement对象里面的cache属性中,以备后面二级缓存使用,我们后面专门来讲这一块。

mapper 接口绑定

映射文件解析完成后,我们需要通过命名空间将绑定 mapper 接口,看看具体绑定的啥

private void bindmapperfornamespace() {
    // 获取映射文件的命名空间
    string namespace = builderassistant.getcurrentnamespace();
    if (namespace != null) {
        class<?> boundtype = null;
        try {
            // 根据命名空间解析 mapper 类型
            boundtype = resources.classforname(namespace);
        } catch (classnotfoundexception e) {
        }
        if (boundtype != null) {
            // 检测当前 mapper 类是否被绑定过
            if (!configuration.hasmapper(boundtype)) {
                configuration.addloadedresource("namespace:" + namespace);
                // 绑定 mapper 类
                configuration.addmapper(boundtype);
            }
        }
    }
}

// configuration
public <t> void addmapper(class<t> type) {
    // 通过 mapperregistry 绑定 mapper 类
    mapperregistry.addmapper(type);
}

// mapperregistry
public <t> void addmapper(class<t> type) {
    if (type.isinterface()) {
        if (hasmapper(type)) {
            throw new bindingexception("type " + type + " is already known to the mapperregistry.");
        }
        boolean loadcompleted = false;
        try {
            /*
             * 将 type 和 mapperproxyfactory 进行绑定,mapperproxyfactory 可为 mapper 接口生成代理类
             */
            knownmappers.put(type, new mapperproxyfactory<t>(type));
            
            mapperannotationbuilder parser = new mapperannotationbuilder(config, type);
            // 解析注解中的信息
            parser.parse();
            loadcompleted = true;
        } finally {
            if (!loadcompleted) {
                knownmappers.remove(type);
            }
        }
    }
}

其实就是获取当前映射文件的命名空间,并获取其class,也就是获取每个mapper接口,然后为每个mapper接口创建一个代理类工厂,new mapperproxyfactory<t>(type),并放进 knownmappers 这个hashmap中,我们来看看这个mapperproxyfactory

public class mapperproxyfactory<t> {
    //存放mapper接口class
    private final class<t> mapperinterface;
    private final map<method, mappermethod> methodcache = new concurrenthashmap();

    public mapperproxyfactory(class<t> mapperinterface) {
        this.mapperinterface = mapperinterface;
    }

    public class<t> getmapperinterface() {
        return this.mapperinterface;
    }

    public map<method, mappermethod> getmethodcache() {
        return this.methodcache;
    }

    protected t newinstance(mapperproxy<t> mapperproxy) {
        //生成mapperinterface的代理类
        return proxy.newproxyinstance(this.mapperinterface.getclassloader(), new class[]{this.mapperinterface}, mapperproxy);
    }

    public t newinstance(sqlsession sqlsession) {
        mapperproxy<t> mapperproxy = new mapperproxy(sqlsession, this.mapperinterface, this.methodcache);
        return this.newinstance(mapperproxy);
    }
}

这一块我们后面文章再来看是如何调用的。

 

如对本文有疑问,请在下面进行留言讨论,广大热心网友会与你互动!! 点击进行留言回复

相关文章:

验证码:
移动技术网