当前位置:主页 > java教程 > MyBatis handleResultSet

MyBatis handleResultSet结果集解析过程示例

发布:2023-03-31 20:40:01 59


给大家整理了相关的编程文章,网友弘莹华根据主题投稿了本篇教程内容,涉及到MyBatis、handleResultSet、MyBatis、handleResultSet、结果集、MyBatis handleResultSet相关内容,已被478网友关注,相关难点技巧可以阅读下方的电子资料。

MyBatis handleResultSet

前置知识

mybatis版本: 3.5.12

之前说到mybatis执行完SQL后的结果集是由DefaultResultHandler组件的handleResultSets处理的。今天就来探讨下这个重要的方法,该方法非常核心,而且内容比较多,所以单拎出来一章。而上文DefaultResultHandler处理结果集的大致流程请参考:MyBatis ResultSetHandler 结果集的解析过程

在理解handleResultSets是怎么对结果集进行处理时,需要明白MyBatis中这么几个组件

  • ResultMap和ResultMapping
  • ResultHandler和ResultContext
  • ResultLoaderMap和ResultLoader(这些是延时加载相关)
  • ProxyFactory(延时加载相关,用来为延时加载对象创建代理对象使用的)

ResultMap和ResultMapping

我们经常使用MyBatis的xml文件配置SQL信息,在select标签上有一个属性是resultMap,它会指向一个resultMap标签,resultMap标签示例如下

<resultMap id="usermap" type="user" autoMapping="true">
    <!--关闭自动映射,那么没有指定的列名不会出现在结果集中-->
    <id property="id" column="id"/>
    <result property="username" column="username"/>
    <result property="birthday" column="birthday"/>
    <result property="password" column="password"/>
</resultMap>

mybatis中每个resultMap标签会被解析成ResultMap对象。而resultMap标签中的idresultconstructor这3种子标签会被解析成ResultMapping对象(id其实就是特殊的result标签)。

接下来我们来看下ResultMap的重要组成部分

public class ResultMap {
  private Configuration configuration;
  // 唯一ID,一般是namespace+resultMap标签的ID
  private String id;
  // 对应的Java类型
  private Class<?> type;
  // resultMap标签中的result标签的集合
  private List<ResultMapping> resultMappings;
  // resultMap标签中的id标签的集合
  private List<ResultMapping> idResultMappings;
  // resultMap标签中的constructor标签的集合
  private List<ResultMapping> constructorResultMappings;
  // resultMap标签中所有子标签的集合
  private List<ResultMapping> propertyResultMappings;
  // 结果集中列名
  private Set<String> mappedColumns;
  // 对应的Java对象的属性名
  private Set<String> mappedProperties;
  // discriminator鉴别器会单独被解析成该对象
  private Discriminator discriminator;
  // 是否是嵌套映射
  private boolean hasNestedResultMaps;
  // 是否嵌套查询
  private boolean hasNestedQueries;
  // 是否自动映射
  private Boolean autoMapping;
}

其中比较重要的几个属性就是

  • resultMappings:它表示resultMap标签中的result标签的集合(包含id标签)后续结果集的解析过程避免不了会遍历它
  • hasNestedResultMaps:是否由嵌套映射,一般都是resumtMap标签中含有collection标签association标签才会为true,后面介绍到嵌套映射会分析。

ResultHandler和ResultContext

在mybatis的解析过程中,我们很容易想象到它是遍历结果集然后和resultMap标签的映射关系进行比较,最终生成结果集对象。那么遍历结果集其实就是对一条一条数据进行单独处理。处理完一条记录,就把这条记录对应的对象添加到一个集合中,最终获取这个集合就是用户想要得到的对象。

ResultContext用来在上下文中传递解析过后的单个对象,ResultHandler内部有一个List集合用来存储单条数据解析后的对象的。他们的源码非常简单易懂,首先我们来看DefaultResultHandler(ResultHandler的实现类)的源码

public class DefaultResultHandler implements ResultHandler<Object> {
  private final List<Object> list;
  @Override
  public void handleResult(ResultContext<?> context) {
    list.add(context.getResultObject());
  }
  public List<Object> getResultList() {
    return list;
  }
}

它只有一个属性listlist就是用来存储解析单条数据后的对象。它还提供了两个方法

  • handleResult:就是把上下文中单条数据处理后的对象存入到list集合中。
  • getResultList:获取结果集解析过后的list,也就是用户最终需要的list对象。

接下来来看下ResultContext这个上下文对象

public class DefaultResultContext<T> implements ResultContext<T> {
  private T resultObject;
  private int resultCount;
  private boolean stopped;
  public T getResultObject() {
    return resultObject;
  }
  public void nextResultObject(T resultObject) {
    resultCount++;
    this.resultObject = resultObject;
  }
}

它由三个属性:

  • resultObject:用来保存当前解析的这条记录的结果
  • resultCount:结果集的数量,每解析完一条记录,该值加一
  • stopped:是否停止解析,当到最后一条记录时,该值为false

它的两个方法比较简单,就不再说了。

接下来我们正式进入mybatis解析结果集的核心代码分析。mybatis解析结果集可以分为两大类:一是简单映射、而是嵌套映射。接下来我们即从这两个方面研究

简单映射

接下来就来看下本文主题handleResultSet方法的核心逻辑

private void handleResultSet(ResultSetWrapper rsw, ResultMap resultMap, List<Object> multipleResults, ResultMapping parentMapping) throws SQLException {
    if (parentMapping != null) {
      // 1. 处理resultSets标签的逻辑
      handleRowValues(rsw, resultMap, null, RowBounds.DEFAULT, parentMapping);
    } else {
      // 2. 正常处理resultMap标签。将结果集中的记录映射为指定对象。可能涉及到嵌套映射。
      if (resultHandler == null) {
        // 默认都会走进该分支。
        // ResultSet是个集合嘛,那Result自然就是集合中的一条记录啦。DefaultResultHandler是用来处理单条Result记录的。
        DefaultResultHandler defaultResultHandler = new DefaultResultHandler(objectFactory);
        // 处理多行记录的方法(重要!!!!!!!)
        handleRowValues(rsw, resultMap, defaultResultHandler, rowBounds, null);
        multipleResults.add(defaultResultHandler.getResultList());
      } else {
        // 如果用户自定义了 resultHandler 使用用户自定义的
        handleRowValues(rsw, resultMap, resultHandler, rowBounds, null);
      }
    }
}

这个方法简介明了,它的执行流程是这样的:

第一步:判断parentMapping是否存在值。而这个分支是专门为解析resultSets标签使用的分支,其实和普通的解析结果集没什么不同。等看完后面的解析过程再回过头看就理解了。这个分支不重要,我们继续往下走

第二步:判断用户是否指定resultHandler类型,一般都是不会指定的,所以他会走第一个分支。创建一个默认的ResultHandler,然后执行handleRowValues方法

第三步:如果用户指定resultHandler类型,就执行handleRowValues方法

可以发现无论是哪个分支,最终都会走到handleRowValues方法,从名字也能看出,该方法的逻辑是处理多行数据。接下来我们就来看下DefaultResultSetHandler#handleRowValues方法

handleRowValues

public void handleRowValues(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler&lt;?&gt; resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
  if (resultMap.hasNestedResultMaps()) {
    ensureNoRowBounds();
    checkResultHandler();
    // 处理嵌套映射
    handleRowValuesForNestedResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
  } else {
    // 处理简单映射
    handleRowValuesForSimpleResultMap(rsw, resultMap, resultHandler, rowBounds, parentMapping);
  }
}

该方法只做了一件事情,那就是判断结果集是否是嵌套映射的。这取决于xml文件中resultMap标签是否定义了collectionassociation标签。这里我们先讨论简单映射的场景。所以接下来我们来看DefaultResultSetHandler#handleRowValuesForSimpleResultMap方法。从名字也可以看出,该方法的逻辑是处理简单映射。简单映射不会涉及延迟加载等复杂的逻辑,所以源码很好理解

handleRowValuesForSimpleResultMap

private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping)
    throws SQLException {
  // 第一步:经过resultHandler处理过后,每行记录会被映射成一个对象。该对象暂存在 这个Result上下文中。(暂存DefaultResultContext)
  DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
  ResultSet resultSet = rsw.getResultSet();
  // 跳过指定的偏移量,相当于limit只不过他是内存分页,一般用不到(RowBounds中指定的偏移量)
  skipRows(resultSet, rowBounds);
  // 第二步:结果集中会有很多记录,通过该循环处理结果集中的每条记录。每个记录最后都会存储到ResultHandler的List集合中。
  while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) {
    // 处理discrimination标签。类似于Java中的switch语法。如果resultMap标签有鉴别器。则根据case的情况动态的获取resultMap映射结果集
    ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null);
    // 第三步:获取记录映射的对象(重要!!!!!!)
    Object rowValue = getRowValue(rsw, discriminatedResultMap, null);
    // 第四步:把对象存储到上下文中。
    storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
  }
}

下面是该方法的执行逻辑

第一步:定义ResultContext对象(前文说过),它用来暂存每一行记录的处理结果的对象。从ResultSetWrapper中获取JDBC的结果集对象ResultSet。skipRows方法跳过指定的内存分页(一般没什么用,忽略该方法即可,研究价值不大)

第二步:遍历结果集,通过resolveDiscriminatedResultMap方法得到鉴别器鉴别的ResultMap对象,其实本质上还是一个ResultMap(有关鉴别器的内容请参考另一篇文章:MyBatis discriminator标签 实战和原理解析

第三步:调用getRowValue方法处理每一行数据并得到结果对象

第四步:把上一步生成的对象通过上下文ResultContext添加到ResultHandler中。(再次提示:ResultHandler对象里面存放结果集解析后的对象集合哦)

其中,重点操作在第三步和第四步。接下来我们就先来聊聊getRowValue方法是如何处理一行记录的。

getRowValue

private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix) throws SQLException {
  final ResultLoaderMap lazyLoader = new ResultLoaderMap();
  // (重要方法!!!!!!!!!!!!)比如返回结果ResultType指定的是map,那么这里就会创建一个空Map对象,下面的if才是给map里添加元素。
  Object rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix);
  if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) {
    final MetaObject metaObject = configuration.newMetaObject(rowValue);
    boolean foundValues = this.useConstructorMappings;
    if (shouldApplyAutomaticMappings(resultMap, false)) {
      // (重要方法!!!!!!!!!!!!)是否应该自动映射。比如说SQL返回结果集有10列,但是resultMap只配置了9个字段,剩下的那个字段,如果满足返回列与映射列名称相同,就会自动进行映射
      foundValues = applyAutomaticMappings(rsw, resultMap, metaObject, columnPrefix) || foundValues;
    }
    // (重要方法!!!!!!!!!!!!)处理resultMap标签中的映射。把结果集根据resultMap中的映射关系生成最终的结果对象。属性会被设置到metaObject中
    foundValues = applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix) || foundValues;
    foundValues = lazyLoader.size() > 0 || foundValues;
    // isReturnInstanceForEmptyRow是否启用。如果开启则返回空对象,否则返回null。
    // 注:这个空对象依赖于createResultObject方法创建出的对象是空还是null。
    rowValue = foundValues || configuration.isReturnInstanceForEmptyRow() ? rowValue : null;
  }
  return rowValue;
}

该方法处理逻辑如下(延时加载的代码请忽略,简单映射不会涉及到延时加载,这里之所以会出现ResultLoaderMap这样延时加载相关的对象,是因为嵌套映射方法也会调用这个方法解析结果集。)

第一步:首先通过createResultObject对象创建一个空壳对象,啥是空壳对象呢?比如说xml文件中的resultMap标签定义了返回值类型是User类型,那么这里就会创建一个User对象,但是属性值全为空。

当然createResultObject背后的逻辑其实很复杂,它会首先判断用户是否在resultMap标签中定义了构造器标签,如果没定义才会按上述流程描述所说的创建一个空壳对象。但如果用户在resultMap标签中定义了构造器标签,那么就会直接将结果集中的数据通过构造器构造出完整的对象。这样也很能理解吗,毕竟调用构造器是要传值的。

第二步:通过applyAutomaticMappings方法完成自动映射,啥是自动映射嘞。比如一条sql返回的列由name age两列,但是resultMap标签中只定义了一个映射关系name,那么age列就会自动寻找映射关系,如果发现列名age与resultMap返回类型的属性同名,就会自动映射到该类型中。该步骤中只会映射resultMap标签中未定义的映射关系对应的字段

第三步:通过applyPropertyMappings方法完成属性映射,它是第二步的补充,完成resultMap标签中result标签的映射关系

最后:返回解析完成的值

到此,结果集中的一条记录已经被解析用户指定的类型对象了,接下来就是要把该对象返回给用户,这就要回到我们上一步的storeObject方法了。接下来来看下DefaultResultSetHandler#storeObject方法

storeObject

private void storeObject(ResultHandler<?> resultHandler, DefaultResultContext<Object> resultContext, Object rowValue, ResultMapping parentMapping, ResultSet rs) throws SQLException {
  if (parentMapping != null) {
    // 处理ResultSets标签时走这个分支
    linkToParents(rs, parentMapping, rowValue);
  } else {
    callResultHandler(resultHandler, resultContext, rowValue);
  }
}
private void callResultHandler(ResultHandler<?> resultHandler, DefaultResultContext<Object> resultContext, Object rowValue) {
  resultContext.nextResultObject(rowValue);
  ((ResultHandler<Object>) resultHandler).handleResult(resultContext);
}

storeObject方法的逻辑也很简单(一般都会走到callResultHandler方法)

第一步:通过nextResultObject方法存储getRowValue方法解析的结果对象。

第二步:把单行记录的解析对象存入到ResultHandler的集合当中

嵌套映射

简单介绍完了简单映射之后,接下来我们来看下比较复杂的嵌套映射,在嵌套映射中也会有很多问题,比如:循环依赖如何解决、延迟加载如何解决。 本节只针对于一般情况下的嵌套映射做出分析,循环依赖和延时加载后续我会继续更新文章。

回想一下上一小节中提到的handleRowValues方法,它会根据ResultMap对象判断是否是嵌套映射,如果是嵌套映射,就会走入handleRowValuesForNestedResultMap方法,接下来我们就来看下handleRowValuesForNestedResultMap这个方法。

handleRowValuesForNestedResultMap

private void handleRowValuesForNestedResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
  // 第一步 获取上下文对象,用于存储单行处理后的记录
  final DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
  ResultSet resultSet = rsw.getResultSet();
  skipRows(resultSet, rowBounds);
  Object rowValue = previousRowValue;
  // 第二步: 遍历结果集
  while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) {
    final ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null);
    // 嵌套映射中的结果都会被存入到nestedResultObjects中。这里的CacheKey就是缓存对象唯一标识
    final CacheKey rowKey = createRowKey(discriminatedResultMap, rsw, null);
    // 映射过程中的对象也会被存入在此。
    Object partialObject = nestedResultObjects.get(rowKey);
    // 通常会走到该分支。获取一行记录处理后的对象(有可能是半成品)
    rowValue = getRowValue(rsw, discriminatedResultMap, rowKey, null, partialObject);
    if (partialObject == null) {
      storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
    }
  }
  if (rowValue != null && mappedStatement.isResultOrdered() && shouldProcessMoreRows(resultContext, rowBounds)) {
    storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
    previousRowValue = null;
  } else if (rowValue != null) {
    previousRowValue = rowValue;
  }
}

其实仔细阅读这段可以发现它和简单映射的代码是有一部分雷同的,这也很好理解,嵌套映射还是基于简单映射的思想进行递归处理结果集映射关系的嘛。接下来来介绍一下代码的流程

第一步:还是获取上下文对象,获取JDBC的结果集对象。就不罗嗦了

第二步:遍历结果集,处理每一行数据

第三步:创建CacheKey对象,使用嵌套映射中的外层对象创建缓存key,为了解决一对多映射

第四步:从缓存中通过CacheKey对象获取外层对象。这里的nestedResultObjects是DefaultResultSetHandler的一个属性,他就是用来缓存嵌套映射中的外层对象的。如果是第一次循环,从缓存中获取肯定是为空的嘛。但是你想一下这种场景——一个用户(user)拥有多个订单(order),这种一堆多的关系,在同一个用户进行到循环的第二轮时,肯定不希望再创建一个外层对象,而是想让order对象都公用一个外城对象。这种情况只能添加缓存来做了。这就是nestedResultObjects的意义。

这里的partialObject就是外层对象,也可以称作是部分对象,因为它不完整嘛。

第五步:调用getRowValue方法获取行记录解析的结果

第六步:调用storeObject方法存储对象到ResultHancler中

其中最重要的是要理解第四步中的嵌套思想,以及第五步解析一行记录的详细过程。这里一定会涉及到循环调用,因为解析外层对象必然要解析内层对象嘛

getRowValue

private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, CacheKey combinedKey, String columnPrefix, Object partialObject) throws SQLException {
  final String resultMapId = resultMap.getId();
  Object rowValue = partialObject;
  // 映射第一次肯定一定为null
  if (rowValue != null) {
    final MetaObject metaObject = configuration.newMetaObject(rowValue);
    putAncestor(rowValue, resultMapId);
    applyNestedResultMappings(rsw, resultMap, metaObject, columnPrefix, combinedKey, false);
    ancestorObjects.remove(resultMapId);
  } else {
    final ResultLoaderMap lazyLoader = new ResultLoaderMap();
    rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix);
    if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) {
      final MetaObject metaObject = configuration.newMetaObject(rowValue);
      boolean foundValues = this.useConstructorMappings;
      if (shouldApplyAutomaticMappings(resultMap, true)) {
        foundValues = applyAutomaticMappings(rsw, resultMap, metaObject, columnPrefix) || foundValues;
      }
      foundValues = applyPropertyMappings(rsw, resultMap, metaObject, lazyLoader, columnPrefix) || foundValues;
      putAncestor(rowValue, resultMapId);
      foundValues = applyNestedResultMappings(rsw, resultMap, metaObject, columnPrefix, combinedKey, true) || foundValues;
      ancestorObjects.remove(resultMapId);
      foundValues = lazyLoader.size() > 0 || foundValues;
      rowValue = foundValues || configuration.isReturnInstanceForEmptyRow() ? rowValue : null;
    }
    if (combinedKey != CacheKey.NULL_CACHE_KEY) {
      nestedResultObjects.put(combinedKey, rowValue);
    }
  }
  return rowValue;
}

映射时,第一次外层对象一定为null,所以会走else分支,只有在一堆多种的第二次映射才会走到if分支。我们来看下else分支的处理逻辑

第一步:首先通过createResultObject对象创建一个空壳对象,简单映射中提过

第二步:通过applyAutomaticMappings方法完成自动映射

第三步:通过applyPropertyMappings方法完成属性映射,它是第二步的补充,完成resultMap标签中result标签的映射关系

第四步:通过applyNestedResultMappings方法完成嵌套映射

到此,结果集中的一条记录已经被嵌套解析完成了。其中和简单映射不一样的地方只有多调用了一个applyNestedResultMappings方法,完成嵌套映射。我们来看下这个方法

applyNestedResultMappings

private boolean applyNestedResultMappings(ResultSetWrapper rsw, ResultMap resultMap, MetaObject metaObject, String parentPrefix, CacheKey parentRowKey, boolean newObject) {
  boolean foundValues = false;
  for (ResultMapping resultMapping : resultMap.getPropertyResultMappings()) {
    final String nestedResultMapId = resultMapping.getNestedResultMapId();
    if (nestedResultMapId != null && resultMapping.getResultSet() == null) {
      // 获取内层对象
      rowValue = getRowValue(rsw, nestedResultMap, combinedKey, columnPrefix, rowValue);
      if (rowValue != null && !knownValue) {
        // 把内层对象关联到外层对象
        linkObjects(metaObject, resultMapping, rowValue);
        foundValues = true;
      }
    }
  }
  return foundValues;
}

它的执行逻辑是遍历resultMapping,对resultMapping进行getRowValue方法的调用,此时就应该属于简单影射了(除非嵌套了很多层)

它通过for循环每个resultMapping,直到找到嵌套映射的哪一个字段,然后再进行简单映射封装结果返回作为外层对象的属性值。

它的调用逻辑是getRowValue——applyNestedResultMappings——getRowValue,直到嵌套映射完成位置,嵌套多少层,这个链路就会深多少层。

小结

mybatis解析结果集是通过遍历结果集数据,并把结果集数据对照resultMap标签中的映射关系一一映射,如果存在嵌套映射则嵌套执行getRowValue获取一行记录的结果并关联到外层对象。

以上就是MyBatis handleResultSet结果集解析过程示例的详细内容,更多关于MyBatis handleResultSet 的资料请关注码农之家其它相关文章!


参考资料

相关文章

  • MyBatis中映射文件的使用案例代码

    发布:2023-04-15

    这篇文章主要介绍了MyBatis中映射文件的使用,本文结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下


  • MyBatis逆向⼯程的生成过程

    发布:2023-04-03

    MyBatis官方为我们提供了一个逆向工程,通过这个逆向工程,只要建立好数据表,那么MyBatis就会根据这个表自动生成pojo类、dao接口、sql映射文件


  • JPA与mybatis-plus不兼容问题的解决

    发布:2023-03-25

    本文主要介绍了JPA与mybatis-plus不兼容问题的解决,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧


  • Mybatis-plus操作json字段实战教程

    发布:2023-04-14

    这篇文章主要介绍了Mybatis-plus操作json字段实战教程,本文结合实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下


  • MyBatis高级映射ResultMap解决属性问题

    发布:2023-03-29

    对于数据库中对表的增删改查操作,我们知道增删改都涉及的是单表,而只有查询操作既可以设计到单表操作又可以涉及到多表操作,所以对于输入映射parameterType而言是没有所谓的高级映射的,也就是说高级映射只针对于输出映射


  • Mybatis-Plus saveBatch()批量保存失效的解决

    发布:2023-03-08

    本文主要介绍了Mybatis-Plus saveBatch()批量保存失效的解决,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧


  • MyBatis一级缓存需要注意的地方

    发布:2020-02-01

    这篇文章主要给大家介绍了关于MyBatis一级缓存避坑的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学


  • 如何批量测试Mybatis项目中的Sql是否正确详解

    如何批量测试Mybatis项目中的Sql是否正确详解

    发布:2022-06-17

    给大家整理了关于Mybatis的教程,这篇文章主要给大家介绍了关于如何批量测试Mybatis项目中Sql是否正确的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着


网友讨论