Mybatis(二):执行流程

303 阅读9分钟

[toc]

在上篇文章中,我们介绍了Mybatis的基本使用,如果大家想要学习Mybatis的配置及高级使用可以去官方文档里进行查看。

今天的文章中我们学习下Mybatis的执行流程,相信大家还记得下面的代码,在本篇文章中我们就以其作为咱们梳理执行流程的入口。

public static void main(String[] args) throws IOException {
    // 配置文件路径
    String resource = "mybatis.xml";
    // 加载配置文件
    InputStream inputStream = Resources.getResourceAsStream(resource);
    // 创建SqlSessionFactory对象
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    // 获取SqlSession对象
    SqlSession sqlSession = sqlSessionFactory.openSession();
    // 获取Mapper
    UserMapper mapper = sqlSession.getMapper(UserMapper.class);
    User user = mapper.getById(1);
    System.out.println(JSON.toJSONString(user));
    sqlSession.close();
}

通过上面的代码,我们可以很清晰的看到Mybatis的执行包含如下几步

  1. 加载配置文件
  2. 创建SqlSessionFactory对象
  3. 获取SqlSession对象
  4. 获取Mapper对象
  5. 执行对应的方法

1 Resources加载配置文件

Resourcesorg.apache.ibatis.io包下的一个IO操作工具类,该类可以从类路径、文件系统或者web URL中加载资源文件。

2 SqlSessionFactory的创建流程

通过示例代码我们可以看到SqlSessionFactory对象是通过SqlSessionFactoryBuilder.build方法进行的创建。其源码如下:

public SqlSessionFactory build(InputStream inputStream) {
    return build(inputStream, null, null);
}

public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
    try {
        XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
        return build(parser.parse());
    } catch (Exception e) {
        throw ExceptionFactory.wrapException("Error building SqlSession.", e);
    } finally {
        ErrorContext.instance().reset();
        try {
            inputStream.close();
        } catch (IOException e) {
            // Intentionally ignore. Prefer previous error.
        }
    }
}

public SqlSessionFactory build(Configuration config) {
    return new DefaultSqlSessionFactory(config);
}

在上面的代码逻辑主要处理如下的逻辑

  1. 创建XMLConfigBuilder对象
  2. 调用XMLConfigBuilder的parse方法获取Configuration对象
  3. 使用Configuration构建DefaultSqlSessionFactory对象

2.1 XMLConfigBuilder创建过程

XMLConfigBuilder的类图如下图所示

image-20210814002121761

XMLConfigBuilder的类图是比较简单的,成员属性有如下几个

  • Configuration configuration 这个属性是比较重要的一个对象,Mybatis解析出来的内容都会记录在这个对象里。
  • TypeAliasRegistry typeAliasRegistry 类型别名注册器
  • TypeHandlerRegistry 类型处理器注册器
  • boolean parsed 用来记录是否被解析过
  • XPathParser parser 解析器
  • String environment 环境编码

XMLConfigBuilder的构造函数逻辑如下

public XMLConfigBuilder(InputStream inputStream, String environment, Properties props) {
    this(new XPathParser(inputStream, true, props, new XMLMapperEntityResolver()), environment, props);
}

private XMLConfigBuilder(XPathParser parser, String environment, Properties props) {
    // 调用父类构造函数   设置configuration   typeAliasRegistry  typeHandlerRegistry
    super(new Configuration());
    ErrorContext.instance().resource("SQL Mapper Configuration");
    this.configuration.setVariables(props);
    // 标记为未解析
    this.parsed = false;
    this.environment = environment;
    this.parser = parser;
}
// BaseBuilder的构造函数
public BaseBuilder(Configuration configuration) {
    this.configuration = configuration;
    this.typeAliasRegistry = this.configuration.getTypeAliasRegistry();
    this.typeHandlerRegistry = this.configuration.getTypeHandlerRegistry();
}

2.1.1 Configuration的创建

接着上面的源码,我们点进Configuration的构造方法中,其内容如下:

public Configuration() {
    // 事务管理器的别名
    typeAliasRegistry.registerAlias("JDBC", JdbcTransactionFactory.class);
    typeAliasRegistry.registerAlias("MANAGED", ManagedTransactionFactory.class);
    // 数据源
    // JNDI数据源实现是为了能在如 EJB 或应用服务器这类容器中使用,容器可以集中或在外部配置数据源,然后放置一个 JNDI 上下文的数据源引用
    // POOLED 数据源的实现利用“池”的概念将 JDBC 连接对象组织起来,避免了创建新的连接实例时所必需的初始化和认证时间。 这种处理方式很流行,能使并发 Web 应用快速响应请求。
    // UNPOOLED 数据源的实现会每次请求时打开和关闭连接。
    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);
    // AOP
    typeAliasRegistry.registerAlias("CGLIB", CglibProxyFactory.class);
    typeAliasRegistry.registerAlias("JAVASSIST", JavassistProxyFactory.class);

    languageRegistry.setDefaultDriverClass(XMLLanguageDriver.class);
    languageRegistry.register(RawLanguageDriver.class);
}

看到这里是不是发现一些比较熟悉的东西,例如:JDBCPOOLED....,这个XML中的type属性也不是随便写的,而是Mybatis为我们定义好了的,Mybatis可以通过我们设置的别名知道我们需要使用的是哪个类。 image-20210816194528580

2.2 配置文件的解析

在上面的逻辑是创建好了XMLConfigBuilder对象,再后面的逻辑是调用XMLConfigBuilder.parse方法解析配置文件并获取到Configuration对象,其源码如下:

public Configuration parse() {
    // 判断是否已经解析过 解析过就抛出异常
    if (parsed) {
        throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    // 将解析标记设置为true
    parsed = true;
    // 解析configuration
    parseConfiguration(parser.evalNode("/configuration"));
    // 返回Configuration对象
    return configuration;
}

private void parseConfiguration(XNode root) {
    try {
        // 解析properties,将解析出来的值存储到parser.variables和configuration.variables中
        propertiesElement(root.evalNode("properties"));
        // 解析setting标签   每个子标签都有默认值
        Properties settings = settingsAsProperties(root.evalNode("settings"));
        loadCustomVfs(settings);
        loadCustomLogImpl(settings);
        // 解析typeAliases标签
        typeAliasesElement(root.evalNode("typeAliases"));
        // 解析插件标签 存储到interceptorChain
        pluginElement(root.evalNode("plugins"));
        // 解析objectFactory标签
        objectFactoryElement(root.evalNode("objectFactory"));
        // 解析 objectWrapperFactory标签
        objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
        reflectorFactoryElement(root.evalNode("reflectorFactory"));
        settingsElement(settings);
        // 解析environments标签,如果没指定的话会取我们配置的默认值  设置到environment中
        environmentsElement(root.evalNode("environments"));
        databaseIdProviderElement(root.evalNode("databaseIdProvider"));
        // 解析typeHandler
        typeHandlerElement(root.evalNode("typeHandlers"));
        // 解析mapper
        mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
        throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
}

2.2.1 标签的解析

在上篇中,我们示例代码中properties标签的配置如下:

<properties resource="datasource.properties"/>

接下来我们看看Mybatis是如何解析上面的配置的,其源码如下:

private void propertiesElement(XNode context) throws Exception {
    if (context != null) {
        // 解析子标签   获取子标签中的name和value的值,存储到Properties中,使用的hasTable进行的存储
        Properties defaults = context.getChildrenAsProperties();
        // 获取resource值
        String resource = context.getStringAttribute("resource");
        // 获取url的值
        String url = context.getStringAttribute("url");
        // resource和url不可同时存在,否在会抛出异常
        if (resource != null && url != null) {
            throw new BuilderException("The properties element cannot specify both a URL and a resource based property file reference.  Please specify one or the other.");
        }
        // 解析出properties
        if (resource != null) {
            defaults.putAll(Resources.getResourceAsProperties(resource));
        } else if (url != null) {
            defaults.putAll(Resources.getUrlAsProperties(url));
        }
        Properties vars = configuration.getVariables();
        if (vars != null) {
            defaults.putAll(vars);
        }
        // 设置variables
        parser.setVariables(defaults);
        // 设置variables
        configuration.setVariables(defaults);
    }
}

标签解析的逻辑都大同小异,解析出来数据会存储到Configuration对象的不同的属性中,settings、typeAliases、plugins、objectFactory、objectWrapperFactory、reflectorFactory、environments、databaseIdProvider、typeHandlers的解析都大同小异,我们这里就不贴源码进行解析了,大家自行跟踪代码进行查看即可,插件类型解析器Mybatis比较重要的组件,我们会在后面的文章中进行详细说明。

接下来我们看看Mybatis对mapper文件的解析流程,还是先看看我们在示例代码中mappers标签的配置内容

<mappers>
    <mapper resource="mapper/UserMapper.xml"/>
</mappers>

其解析入口是mapperElement方法,源码如下:

private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            // 配置的是package的解析
            if ("package".equals(child.getName())) {
                String mapperPackage = child.getStringAttribute("name");
                configuration.addMappers(mapperPackage);
            } else {
                // 获取resource值
                String resource = child.getStringAttribute("resource");
                // 获取url值
                String url = child.getStringAttribute("url");
                // 获取class值
                String mapperClass = child.getStringAttribute("class");
                if (resource != null && url == null && mapperClass == null) {
                    ErrorContext.instance().resource(resource);
                    // 使用Resources类加载mapper.xml文件
                    try(InputStream inputStream = Resources.getResourceAsStream(resource)) {
                        // 创建XMLMapperBuilder对象
                        XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
                        // 解析mapper配置文件
                        mapperParser.parse();
                    }
                } else if (resource == null && url != null && mapperClass == null) {
                    ErrorContext.instance().resource(url);
                    try(InputStream inputStream = Resources.getUrlAsStream(url)){
                        XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
                        mapperParser.parse();
                    }
                } else if (resource == null && url == null && mapperClass != null) {
                    Class<?> mapperInterface = Resources.classForName(mapperClass);
                    configuration.addMapper(mapperInterface);
                } else {
                    // 如果resource url  class有多个存在则抛出异常
                    throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
                }
            }
        }
    }
}

通过上面的代码发现,mapper.xml文件的解析使用的是XMLMapperBuilder类,该类也是BaseBuilder的一个实现类。

XMLMapperBuilder.parse方法内容如下

public void parse() {
    // 判断是否已经解析过
    if (!configuration.isResourceLoaded(resource)) {
        // 解析
        configurationElement(parser.evalNode("/mapper"));
        // 将resource值进行记录   使用的是一个Set   Configuration.loadedResources属性
        configuration.addLoadedResource(resource);
        bindMapperForNamespace();
    }
    // 解析resultMap
    parsePendingResultMaps();
    // 解析cacheRef
    parsePendingCacheRefs();
    // 解析statement
    parsePendingStatements();
}
​
private void configurationElement(XNode context) {
    try {
        // 获取namespace
        String namespace = context.getStringAttribute("namespace");
        if (namespace == null || namespace.isEmpty()) {
            throw new BuilderException("Mapper's namespace cannot be empty");
        }
        // 将namespace记录到builderAssistant中
        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"));
        // 解析sql标签
        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);
    }
}

在mapper的文件解析中我们重点看下我们写的sql在Mybatis中是如何处理的,其入口时buildStatementFromContext,源码如下:

private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
    for (XNode context : list) {
        final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
        try {
            statementParser.parseStatementNode();
        } catch (IncompleteElementException e) {
            configuration.addIncompleteStatement(statementParser);
        }
    }
}

后面的解析代码比较长,这里就不进行粘贴了,可以自行点进去查看,最后会发现我们写的sql会被定义成一个个的Statement对象,存储到configuration.mappedStatements中。

至此mapper.xml的解析逻辑已经结束,接下来我们看看这个mapper对象的存储逻辑,我们点进bindMapperForNamespace方法,其源码如下

private void bindMapperForNamespace() {
    String namespace = builderAssistant.getCurrentNamespace();
    if (namespace != null) {
        Class<?> boundType = null;
        try {
            boundType = Resources.classForName(namespace);
        } catch (ClassNotFoundException e) {
            // ignore, bound type is not required
        }
        // 判断是否存在
        if (boundType != null && !configuration.hasMapper(boundType)) {
            // Spring may not know the real resource name so we set a flag
            // to prevent loading again this resource from the mapper interface
            // look at MapperAnnotationBuilder#loadXmlResource
            configuration.addLoadedResource("namespace:" + namespace);
            // 进行存储
            configuration.addMapper(boundType);
        }
    }
}
​
public <T> void addMapper(Class<T> type) {
    mapperRegistry.addMapper(type);
}
​
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 {
            knownMappers.put(type, new MapperProxyFactory<>(type));
            // It's important that the type is added before the parser is run
            // otherwise the binding may automatically be attempted by the
            // mapper parser. If the type is already known, it won't try.
            MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
            parser.parse();
            loadCompleted = true;
        } finally {
            if (!loadCompleted) {
                knownMappers.remove(type);
            }
        }
    }
}

通过上面的代码我们可以看到在Mybatis中是通过将mapper类型记录到一个MapperProxyFactory对象中并存储到Configuration.knowMappers中的。

2.3 创建SqlSessionFactory对象

通过上面的逻辑Mybatis就完成了配置文件的解析工作,并将所有的信息记录到了Configuration对象中。

创建SqlSessionFactory的逻辑比较简单,通过我们上面拿到的Configuration对象,直接new一个DefaultSqlSessionFactory对象,其源码如下:

public SqlSessionFactory build(Configuration config) {
    return new DefaultSqlSessionFactory(config);
}

至此SqlSessionFactory的创建过程就结束了,其创建时序图如下所示:

image-20210817145621030

3 SqlSession的创建流程

SqlSession是通过SqlSessionFactory来获取的,在SqlSessionFactory提供了多个创建SqlSession对象的方法,如下:

public interface SqlSessionFactory {

    SqlSession openSession();

    SqlSession openSession(boolean autoCommit);

    SqlSession openSession(Connection connection);

    SqlSession openSession(TransactionIsolationLevel level);

    SqlSession openSession(ExecutorType execType);
    
    SqlSession openSession(ExecutorType execType, boolean autoCommit);

    SqlSession openSession(ExecutorType execType, TransactionIsolationLevel level);

    SqlSession openSession(ExecutorType execType, Connection connection);

    Configuration getConfiguration();

}

上面方法的参数如下

  • autoCommit 是否自动提交

  • connection 自定义连接

  • level 事务隔离级别 取值如下

    • NONE
    • READ_COMMITTED
    • READ_UNCOMMITTED
    • REPEATABLE_READ
    • SERIALIZABLE
  • execType 执行器类型 取值如下

    • SIMPLE 为每个语句的执行创建一个新的预处理语句
    • REUSE 会复用预处理语句
    • BATCH 会批量执行所有更新语句,如果 SELECT 在多个更新中间执行,将在必要时将多条更新语句分隔开来,以方便理解。

在我们的示例中使用的是SqlSessionFactory的无参方法,其在DefaultSqlSessionFactory中实现的源码如下:

@Override
public SqlSession openSession() {
    return openSessionFromDataSource(configuration.getDefaultExecutorType(), null, false);
}

private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level, boolean autoCommit) {
    Transaction tx = null;
    try {
        // 获取environment
        final Environment environment = configuration.getEnvironment();
        // 获取事务工厂
        final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
        // 创建事务对象
        tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
        // 创建执行器对象
        final Executor executor = configuration.newExecutor(tx, execType);
        // 返回一个DefaultSqlSession对象
        return new DefaultSqlSession(configuration, executor, autoCommit);
    } catch (Exception e) {
        closeTransaction(tx); // may have fetched a connection so lets call close()
        throw ExceptionFactory.wrapException("Error opening session.  Cause: " + e, e);
    } finally {
        ErrorContext.instance().reset();
    }
}

4 Mapper的创建流程

在上面的内容中我们已经获取到了SqlSession对象了,查看其源码里面提供了一些操作方法,我们可以直接使用这些方法进行相应的数据库操作,我们可以将示例中的代码查询方式修改为如下:

User user= sqlSession.selectOne("com.xh.sample.mybatis.mapper.UserMapper.getById", 1);

这种方式会导致我们的业务代码中存在一些硬编码。

接下来我们看看Mapper的创建逻辑,我们点到DefaultSqlSession.getMapper方法中,其源码如下:

public <T> T getMapper(Class<T> type) {
    return configuration.getMapper(type, this);
}

Configuation.getMapper方法源码如下:

public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
    return mapperRegistry.getMapper(type, sqlSession);
}

MapperRegistry.getMapper的逻辑如下:

public <T> T getMapper(Class<T> type, SqlSession sqlSession) {
    // 通过类型获取MapperProxyFactory对象
    final MapperProxyFactory<T> mapperProxyFactory = (MapperProxyFactory<T>) knownMappers.get(type);
    if (mapperProxyFactory == null) {
        throw new BindingException("Type " + type + " is not known to the MapperRegistry.");
    }
    try {
        // 创建Mapper类
        return mapperProxyFactory.newInstance(sqlSession);
    } catch (Exception e) {
        throw new BindingException("Error getting mapper instance. Cause: " + e, e);
    }
}

MapperProxyFactory的创建逻辑如下:

public T newInstance(SqlSession sqlSession) {
    final MapperProxy<T> mapperProxy = new MapperProxy<>(sqlSession, mapperInterface, methodCache);
    return newInstance(mapperProxy);
}

protected T newInstance(MapperProxy<T> mapperProxy) {
    // 使用java的动态代理创建一个Mapper的实现类
    return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy);
}

至此,Mapper的常见逻辑已经结束了,通过源码我们发现,在Mybatis是通过动态代理的方式创建了一个Mapper的接口代理类MapperProxy

5 方法执行

通过上面的逻辑我们知道,创建的Mapper对象是由MapperProxy代理的一个类,所以我们方法执行入口在MapperProxy.invoker方法中,其源码如下:

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        if (Object.class.equals(method.getDeclaringClass())) {
            return method.invoke(this, args);
        } else {
            return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
        }
    } catch (Throwable t) {
        throw ExceptionUtil.unwrapThrowable(t);
    }
}

跟踪这个方法的逻辑会发现,最终会走到,MapperMethod.execute方法中,其源码如下:

public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    switch (command.getType()) {
        case INSERT: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.insert(command.getName(), param));
            break;
        }
        case UPDATE: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.update(command.getName(), param));
            break;
        }
        case DELETE: {
            Object param = method.convertArgsToSqlCommandParam(args);
            result = rowCountResult(sqlSession.delete(command.getName(), param));
            break;
        }
        case SELECT:
            if (method.returnsVoid() && method.hasResultHandler()) {
                executeWithResultHandler(sqlSession, args);
                result = null;
            } else if (method.returnsMany()) {
                result = executeForMany(sqlSession, args);
            } else if (method.returnsMap()) {
                result = executeForMap(sqlSession, args);
            } else if (method.returnsCursor()) {
                result = executeForCursor(sqlSession, args);
            } else {
                Object param = method.convertArgsToSqlCommandParam(args);
                result = sqlSession.selectOne(command.getName(), param);
                if (method.returnsOptional()
                    && (result == null || !method.getReturnType().equals(result.getClass()))) {
                    result = Optional.ofNullable(result);
                }
            }
            break;
        case FLUSH:
            result = sqlSession.flushStatements();
            break;
        default:
            throw new BindingException("Unknown execution method for: " + command.getName());
    }
    if (result == null && method.getReturnType().isPrimitive() && !method.returnsVoid()) {
        throw new BindingException("Mapper method '" + command.getName()
                                   + " attempted to return null from a method with a primitive return type (" + method.getReturnType() + ").");
    }
    return result;
}

到这里你应该能发现一些熟悉的代码,最终的执行都会落到SqlSession中对应的操作数据库方法上。

6 总结

在本篇文章中我们了解了Mybatis的执行流程,在本篇文章中没有深入的去介绍某个模块,缓存、插件、类型转换器......,这些重要的部分我们会在后面的文章中进行介绍。

希望大家能通过本文对Mybatis的执行流程有一个简单的了解,同时也对一些类有些印象,例如:

  • Configuration
  • SqlSessionFactory
  • SqlSession
  • XMLConfigBuilder
  • XMLMapperBuilder
  • XMLStatementBuilder
  • MapperProxy
  • MapperMethod
  • Executor

只看文章还是比较难理解整个执行流程了,大家最好自己Debug以下去跟踪以下处理流程。

欢迎关注本人公众号,会持续更新Java相关的技术文章

qrcode_for_gh_8febd60b14c9_258.jpg