Mybatis初始化加载流程————配置文件解析

本次测试的实例中的相关配置文件如下:
application.xml文件:

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

    <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource">
        <property name="name" value="mysql"/>
        <property name="url" value="xxxxxxx"/>
        <property name="username" value="xxxxxxx"/>
        <property name="password" value="zh4y4q5ang"/>
        <property name="driverClassName" value="com.mysql.jdbc.Driver"/>
    </bean>
    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource"/>
        <property name="mapperLocations" value="classpath:mybatis/*.xml"/>
        <property name="typeAliasesPackage" value="com.entities"/>
        <property name="configLocation" value="classpath:config/configuration.xml"/>
    </bean>
    <bean id="mapperScannerConfigurer" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="basePackage" value="com.interfaces"/>
    </bean>
    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource"/>
    </bean>
</beans>

configuration.xml文件:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <plugins>
        <plugin interceptor="com.interceptors.LogInterceptor" ></plugin>
    </plugins>
</configuration>

CityMapper.xml文件:

<?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="com.interfaces.CityMapper">
<resultMap id="BaseResultMap" type="com.entities.City">
    <id column="ID" jdbcType="INTEGER" property="id"/>
    <result column="Name" jdbcType="CHAR" property="name"/>
    <result column="CountryCode" jdbcType="CHAR" property="countrycode"/>
    <result column="District" jdbcType="CHAR" property="district"/>
    <result column="Population" jdbcType="INTEGER" property="population"/>
</resultMap>
<select id="selectCityById" parameterType="int" resultType="com.entities.City">
    SELECT * FROM city WHERE ID=#{id,jdbcType=INTEGER}
</select>
<insert id="insertCity" parameterType="com.entities.City">
    insert into city
    <trim prefix="(" suffix=")" suffixOverrides=",">
        <if test="id != null">
            ID,
        </if>
        <if test="name != null">
            Name,
        </if>
        <if test="countrycode != null">
            CountryCode,
        </if>
        <if test="district != null">
            District,
        </if>
        <if test="population != null">
            Population,
        </if>
    </trim>
    <trim prefix="values (" suffix=")" suffixOverrides=",">
        <if test="id != null">
            #{id,jdbcType=INTEGER},
        </if>
        <if test="name != null">
            #{name,jdbcType=CHAR},
        </if>
        <if test="countrycode != null">
            #{countrycode,jdbcType=CHAR},
        </if>
        <if test="district != null">
            #{district,jdbcType=CHAR},
        </if>
        <if test="population != null">
            #{population,jdbcType=INTEGER},
        </if>
    </trim>
</insert>
<delete id="deleteCityById" parameterType="java.lang.Integer">
    delete from city
    where ID = #{id,jdbcType=INTEGER}
</delete>

<update id="updateCityById" parameterType="com.entities.City">
    UPDATE city set Name=#{name,jdbcType=CHAR},
    CountryCode=#{countrycode,jdbcType=CHAR},
    District=#{district,jdbcType=CHAR},
    Population=#{population,jdbcType=INTEGER}
    WHERE ID=#{id,jdbcType=INTEGER}
</update>
    <select id="selectWithIf" parameterType="map" resultMap="BaseResultMap">
        SELECT * FROM city <if test="id!=0">WHERE city.id=#{id}</if> <if test="id==0" >limit 2</if>
    </select>

    <select id="selectWithChoose" parameterType="map" resultMap="BaseResultMap">
        SELECT * FROM city
        <choose >
            <when test="id==1">
                limit 1
            </when>
            <when test="id==2">
                limit 2
            </when>
            <otherwise>
                WHERE city.id=#{id}
            </otherwise>
        </choose>
    </select>
    <select id="selectWithForeach" parameterType="map" resultMap="BaseResultMap">
        SELECT * FROM city
            WHERE city.id in
        <foreach collection="ids" item="tmp" open="(" close=")" separator="," index="">
            #{tmp.id}
        </foreach>
    </select>
</mapper>

在初始化spring框架的时候,会将在application.xml声明的bean注入到spring的上下文中。包括:
初始化dataSource,并将DataSource注入到spring框架中。
初始化SqlSessionFactoryBean对象,并将DataSource的实体对象注入到SqlSessionFactory中。
SqlSessionFactoryBean实现InitializingBean方法,在其实现的方法中,创建一个SqlSessionFactory对象,具体实现为DefaultSqlSessionFactory类,内部传入configuration对象。关于配置文件的主要解析过程就是在这里实现的。
下面来分析一下源码:

    @Override
    public void afterPropertiesSet() throws Exception {
        notNull(dataSource, "Property 'dataSource' is required");
        notNull(sqlSessionFactoryBuilder, "Property 'sqlSessionFactoryBuilder' is required");
        state((configuration == null && configLocation == null) || !(configuration != null && configLocation != null),
                "Property 'configuration' and 'configLocation' can not specified with together");
        //创建一个SqlSessionFactory对象,具体实现为DefaultSqlSessionFactor类
        this.sqlSessionFactory = buildSqlSessionFactory();
    }

    protected SqlSessionFactory buildSqlSessionFactory() throws IOException {

        Configuration configuration;
        //刚开始时,this.configuration为空,在测试中设置了mybatis的configuration的xml配置文件
        XMLConfigBuilder xmlConfigBuilder = null;
        if (this.configuration != null) {
            configuration = this.configuration;
            if (configuration.getVariables() == null) {
                configuration.setVariables(this.configurationProperties);
            } else if (this.configurationProperties != null) {
                configuration.getVariables().putAll(this.configurationProperties);
            }
        } else if (this.configLocation != null) {
            xmlConfigBuilder = new XMLConfigBuilder(this.configLocation.getInputStream(), null, this.configurationProperties);
            //从XmlConfigBuilder中构建一个Configuration
            configuration = xmlConfigBuilder.getConfiguration();
        } else {
            LOGGER.debug(() -> "Property 'configuration' or 'configLocation' not specified, using default MyBatis Configuration");
            //直接新建一个Configuration对象
            configuration = new Configuration();
            if (this.configurationProperties != null) {
                configuration.setVariables(this.configurationProperties);
            }
        }

        if (this.objectFactory != null) {
            configuration.setObjectFactory(this.objectFactory);
        }

        if (this.objectWrapperFactory != null) {
            configuration.setObjectWrapperFactory(this.objectWrapperFactory);
        }

        if (this.vfs != null) {
            configuration.setVfsImpl(this.vfs);
        }

        //测试中设置了typeAliasesPackage
        if (hasLength(this.typeAliasesPackage)) {
            //根据,或者;对一条字符串进行分割,得到用来存放实体类的包名
            String[] typeAliasPackageArray = tokenizeToStringArray(this.typeAliasesPackage,
                    ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
            for (String packageToScan : typeAliasPackageArray) {
                //将对数据库记录封装的包名保存在Configuration对象中
                configuration.getTypeAliasRegistry().registerAliases(packageToScan,
                        typeAliasesSuperType == null ? Object.class : typeAliasesSuperType);
                LOGGER.debug(() -> "Scanned package: '" + packageToScan + "' for aliases");
            }
        }

        if (!isEmpty(this.typeAliases)) {
            for (Class<?> typeAlias : this.typeAliases) {
                configuration.getTypeAliasRegistry().registerAlias(typeAlias);
                LOGGER.debug(() -> "Registered type alias: '" + typeAlias + "'");
            }
        }

        if (!isEmpty(this.plugins)) {
            for (Interceptor plugin : this.plugins) {
                //设置类一个log拦截器
                configuration.addInterceptor(plugin);
                LOGGER.debug(() -> "Registered plugin: '" + plugin + "'");
            }
        }

        if (hasLength(this.typeHandlersPackage)) {
            String[] typeHandlersPackageArray = tokenizeToStringArray(this.typeHandlersPackage,
                    ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS);
            for (String packageToScan : typeHandlersPackageArray) {
                configuration.getTypeHandlerRegistry().register(packageToScan);
                LOGGER.debug(() -> "Scanned package: '" + packageToScan + "' for type handlers");
            }
        }

        if (!isEmpty(this.typeHandlers)) {
            for (TypeHandler<?> typeHandler : this.typeHandlers) {
                configuration.getTypeHandlerRegistry().register(typeHandler);
                LOGGER.debug(() -> "Registered type handler: '" + typeHandler + "'");
            }
        }

        //没有设置databaseId
        if (this.databaseIdProvider != null) {//fix #64 set databaseId before parse mapper xmls
            try {
                configuration.setDatabaseId(this.databaseIdProvider.getDatabaseId(this.dataSource));
            } catch (SQLException e) {
                throw new NestedIOException("Failed getting a databaseId", e);
            }
        }

        //测试中,没有使用缓存
        if (this.cache != null) {
            configuration.addCache(this.cache);
        }

        if (xmlConfigBuilder != null) {
            try {
                //开始解析configurationxml配置文件
                //由于在测试的configuration配置文件中,只是定义了一个plugin,在解析的时候,直接解析出plugin的实现类,并将plugin的实体对象保存在configuration的实体对象中。
                xmlConfigBuilder.parse();
                LOGGER.debug(() -> "Parsed configuration file: '" + this.configLocation + "'");
            } catch (Exception ex) {
                throw new NestedIOException("Failed to parse config resource: " + this.configLocation, ex);
            } finally {
                ErrorContext.instance().reset();
            }
        }

        if (this.transactionFactory == null) {
            this.transactionFactory = new SpringManagedTransactionFactory();
        }

        configuration.setEnvironment(new Environment(this.environment, this.transactionFactory, this.dataSource));

        //从这里开始,处理mapper.xml文件
        if (!isEmpty(this.mapperLocations)) {
            for (Resource mapperLocation : this.mapperLocations) {
                if (mapperLocation == null) {
                    continue;
                }

                try {
                    //创建一个XMLMapperBuilder用来解析定义sql语句的xml文件
                    XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(mapperLocation.getInputStream(),
                            configuration, mapperLocation.toString(), configuration.getSqlFragments());
                    //开始解析mapper.xml文件
                    xmlMapperBuilder.parse();
                } catch (Exception e) {
                    throw new NestedIOException("Failed to parse mapping resource: '" + mapperLocation + "'", e);
                } finally {
                    ErrorContext.instance().reset();
                }
                LOGGER.debug(() -> "Parsed mapper file: '" + mapperLocation + "'");
            }
        } else {
            LOGGER.debug(() -> "Property 'mapperLocations' was not specified or no matching resources found");
        }
        //返回一个SqlSessionFactory实体对象
        return this.sqlSessionFactoryBuilder.build(configuration);
    }

下面看看如何解析mapper.xml文件的:

    public void parse() {
        //判断当前mapper.xml文件是否已经被解析过
        if (!configuration.isResourceLoaded(resource)) {
            //在configurationElement完成mapper.xml中所有元素的解析,并将解析出来的相关属性全部保存在configuration中
            configurationElement(parser.evalNode("/mapper"));
            //添加已经加载过的mapper文件
            configuration.addLoadedResource(resource);
            //绑定sql语句与Java中定义的接口
            bindMapperForNamespace();
        }
        //这个几个方法都是将前面判断是不完整的语句再次解析一下,就不多说了
        parsePendingResultMaps();
        parsePendingCacheRefs();
        parsePendingStatements();
    }

    private void configurationElement(XNode context) {
        try {
            //namespace就是定义的接口:com.interfaces.CityMapper
            String namespace = context.getStringAttribute("namespace");
            if (namespace == null || namespace.equals("")) {
                throw new BuilderException("Mapper's namespace cannot be empty");
            }
            //想builderAssistant设置解析的文件对应的java接口
            builderAssistant.setCurrentNamespace(namespace);
            //解析出<cache-ref>标签,测试中没有使用此标签
            cacheRefElement(context.evalNode("cache-ref"));
            //解析出<cache>标签,测试中没有使用此标签
            cacheElement(context.evalNode("cache"));
            //解析<parameterMap>标签,这个标签官方已经不推荐使用了
            parameterMapElement(context.evalNodes("/mapper/parameterMap"));
            //解析<resultMap>标签
            resultMapElements(context.evalNodes("/mapper/resultMap"));
            //测试中没有使用此标签
            sqlElement(context.evalNodes("/mapper/sql"));
            //解析<select>、<insert>、<update>、<delete>标签
            buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
        } catch (Exception e) {
            throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
        }
    }

接来下分析如何解析resultMap标签:

    //additionalResultMappings为一个空的list
    private ResultMap resultMapElement(XNode resultMapNode, List<ResultMapping> additionalResultMappings) throws Exception {
        ErrorContext.instance().activity("processing " + resultMapNode.getValueBasedIdentifier());
        //获取resultMap的id,也是这个resultMap的唯一名称
        String id = resultMapNode.getStringAttribute("id",
                resultMapNode.getValueBasedIdentifier());
        //指明这个resultMap用来与数据库中字段相映射的java类
        String type = resultMapNode.getStringAttribute("type",
                resultMapNode.getStringAttribute("ofType",
                        resultMapNode.getStringAttribute("resultType",
                                resultMapNode.getStringAttribute("javaType"))));
        //测试中没有使用这个属性,暂不分析
        String extend = resultMapNode.getStringAttribute("extends");
        //是否自动映射
        Boolean autoMapping = resultMapNode.getBooleanAttribute("autoMapping");
        //获取与数据库字段相映射的java类型
        Class<?> typeClass = resolveClass(type);
        //没有使用<Discriminator>标签
        Discriminator discriminator = null;
        List<ResultMapping> resultMappings = new ArrayList<ResultMapping>();
        resultMappings.addAll(additionalResultMappings);
        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())) {
                    flags.add(ResultFlag.ID);
                }
                //解析<result>标签
                resultMappings.add(buildResultMappingFromContext(resultChild, typeClass, flags));
            }
        }
        //构建一个resultMapResolver对象,这个里面什么实质性的操作,具体的操作还是有MapperBuilderAssistant完成
        ResultMapResolver resultMapResolver = new ResultMapResolver(builderAssistant, id, typeClass, extend, discriminator, resultMappings, autoMapping);
        try {
            //创建一个ResultMap,返回ResultMap对象并将ResultMap保存在Configuration对象中
            //在Configuration对象中存在一个ResultMaps,以resultMap中的id作为key用来存储resultMap实体对象
            return resultMapResolver.resolve();
        } catch (IncompleteElementException e) {
            configuration.addIncompleteResultMap(resultMapResolver);
            throw e;
        }
    }

    private ResultMapping buildResultMappingFromContext(XNode context, Class<?> resultType, List<ResultFlag> flags) throws Exception {
        //解析出result标签中所有的属性,并将属性的值传入builderAssistant中,又builderAssistant创建一个ResultMapping对象。
        String 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");
        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);
        @SuppressWarnings("unchecked")
        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);
    }

接下来看看如何解析具体的select语句:

    //在测试中传入的requiredDatabaseId为null
    private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
        for (XNode context : list) {
            final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
            try {
                //开始解析sql语句的Satatement
                statementParser.parseStatementNode();
            } catch (IncompleteElementException e) {
                configuration.addIncompleteStatement(statementParser);
            }
        }
    }

    public void parseStatementNode() {
        //获取id,也是在java接口中的方法名
        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);

        Class<?> resultTypeClass = resolveClass(resultType);
        String resultSetType = context.getStringAttribute("resultSetType");
        //没有指定的情况下,statementType默认为StatementType.PREPARED
        StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
        ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
        //获取标签名称
        String nodeName = context.getNode().getNodeName();
        //分析出sql的命令
        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 Fragments before parsing
        //解析sql语句中的include标签
        XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
        includeParser.applyIncludes(context.getNode());

        // Parse selectKey after includes and remove them.
        processSelectKeyNodes(id, parameterTypeClass, langDriver);

        // Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
        SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
        String resultSets = context.getStringAttribute("resultSets");
        String keyProperty = context.getStringAttribute("keyProperty");
        String keyColumn = context.getStringAttribute("keyColumn");
        KeyGenerator keyGenerator;
        //生成statementId
        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;
        }

        builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
            fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
            resultSetTypeEnum, flushCache, useCache, resultOrdered, 
            keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
    }

开始构建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");
    }

    id = applyCurrentNamespace(id, false);
    //判断是否是select命令,只有这个命令才会用到resultMap或者resultType这个属性
    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))
        .resultSetType(resultSetType)
        .flushCacheRequired(valueOrDefault(flushCache, !isSelect))
        .useCache(valueOrDefault(useCache, isSelect))
        .cache(currentCache);

    ParameterMap statementParameterMap = getStatementParameterMap(parameterMap, parameterType, id);
    if (statementParameterMap != null) {
      //将ParameterMap设置到StatementBuilder中,这样在创建Mapped的时候就可以包含ParameterMap的值了
      statementBuilder.parameterMap(statementParameterMap);
    }

    MappedStatement statement = statementBuilder.build();
    //将创建好的Statement保存在Configuration对象中mappedStatements集合中,以statement的id为key进行存储
    configuration.addMappedStatement(statement);
    return statement;
  }

代码中都添加了注释,应该还算是比较容易看的,就不多说了!!!!
到这里基本上就完成了初始的工作了,剩下的就是根据在java中声明的接口生成动态代理了。在实际使用接口的时候,实际是调用的是根据接口动态生成的代理对象。相关的内容在以后的博客中说吧!

猜你喜欢

转载自blog.csdn.net/u011043551/article/details/80607050