Mybatis面试题(底层原理)

Mybatis面试题(底层原理),第1张

目录

一.JDBC流程回顾(以mysql为例)

二.mybatis底层执行流程

 1.前期

1.1画图说明如何解析文件(前期)

一、解析PACKAGE

 二、解析mappers


一.JDBC流程回顾(以mysql为例)

(1).加载驱动

Class.forName(com.mysql.jdbc.Driver);

(2).创建链接

String url = "jdbc:mysql://localhost:3306/0120mysql?characterEncoding=utf8";
String username = "root";
String password = "123456";
Connection conn = DriverManager.getConnection(url,username,password);

(3).创建状态参数

Statement stat = conn.createStatement();

(4).执行 *** 作

//改
String sql = "insert into dept values (50,'测试1','哈尔滨')";
stat.executeUpdate(sql);
stat.execute()
//查
ResultSet rs = stat.executeQuery(sql);
//迭代器 - Iterator hasNext(判断是否有元素) next(移动指针位置指向元素)
while(rs.next()){
    //显示数据
    System.out.println(rs.getInt("empno")+"-"+rs.getString("ename")+"-"+rs.getString("comm")+"-"+rs.getString("hiredate")+"-"+rs.getString("empno"));
    System.out.println(rs.getInt(1)+"-"+rs.getString(2));
}

(5).关闭

stat.close();
conn.close();

二.mybatis底层执行流程

大概可以分为前、中、后三期,同时也是jdbc细化执行流程:   

前期处理:加载驱动,创建连接-mybatis如何给必要的属性进行赋值,如何解析主配置文件

中期执行:创建状态参数 动态参数赋值 执行程序-mybatis具体执行流程

后期结果:处理结果集 关闭和事务处理-mybatis如何处理结果

 1.前期

如何解析主配置文件

public class MybatisUtils {
    private static SqlSessionFactory sqlSessionFactory;
    static {
        try {
            //使用mybatis第一步获取sqlSessionFactory对象
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public static SqlSession getSqlSession(){
        return sqlSessionFactory.openSession(true);
    }
}

public class SqlSessionFactoryBuilder

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);
        //一定是parser.parse()这个方法解析的xml文件
     return build(parser.parse());
} catch (Exception e) {
      throw ExceptionFactory.wrapException("Error building SqlSession.", e);


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

换类了 public class XMLConfigBuilder extends BaseBuilder

1.1画图说明如何解析文件(前期)

XNode root = parser.evalNode("/configuration");

所以说具体解析xml文件的就是上面画红的代码,已经完成了对xml解析的过程,底层一定用的dom4j

解析完成的元素形成了Xnode形成的对象root

所以说parseConfiguration负责具体把有价值的内容还原到属性上面

public Configuration parse() {
    if (parsed) {
      throw new BuilderException("Each XMLConfigBuilder can only be used once.");
    }
    parsed = true;
    parseConfiguration(parser.evalNode("/configuration"));
    return configuration;
  }
private void parseConfiguration(XNode root) {
    try {
      //issue #117 read properties first
      propertiesElement(root.evalNode("properties"));
      Properties settings = settingsAsProperties(root.evalNode("settings"));
      loadCustomVfs(settings);
      loadCustomLogImpl(settings);
      typeAliasesElement(root.evalNode("typeAliases"));
      pluginElement(root.evalNode("plugins"));
      objectFactoryElement(root.evalNode("objectFactory"));
      objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
      reflectorFactoryElement(root.evalNode("reflectorFactory"));
      settingsElement(settings);
      // read it after objectFactory and objectWrapperFactory issue #631
      environmentsElement(root.evalNode("environments"));
      databaseIdProviderElement(root.evalNode("databaseIdProvider"));
      typeHandlerElement(root.evalNode("typeHandlers"));
      mapperElement(root.evalNode("mappers"));
    } catch (Exception e) {
      throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
    }
  }

//解析主配置文件中的链接数据的

environmentsElement(root.evalNode("environments"));

private void environmentsElement(XNode context) throws Exception {
    if (context != null) {
      if (environment == null) {
        environment = context.getStringAttribute("default");
      }
      for (XNode child : context.getChildren()) {
        String id = child.getStringAttribute("id");
        if (isSpecifiedEnvironment(id)) {
          TransactionFactory txFactory = transactionManagerElement(child.evalNode("transactionManager"));
          DataSourceFactory dsFactory = dataSourceElement(child.evalNode("dataSource"));
          DataSource dataSource = dsFactory.getDataSource();
          Environment.Builder environmentBuilder = new Environment.Builder(id)
              .transactionFactory(txFactory)
              .dataSource(dataSource);
          configuration.setEnvironment(environmentBuilder.build());
        }
      }
    }
  }

//解析映射文件的
mapperElement(root.evalNode("mappers"));

//首先我们应该明白的是 mappers标签的作用是用来指定映射文件路径,用于解析这个文件

private void mapperElement(XNode parent) throws Exception {
    if (parent != null) {
      for (XNode child : parent.getChildren()) {
        if ("package".equals(child.getName())) {
          String mapperPackage = child.getStringAttribute("name");
          configuration.addMappers(mapperPackage);
        } else {
          String resource = child.getStringAttribute("resource");
          String url = child.getStringAttribute("url");
          String mapperClass = child.getStringAttribute("class");
          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();
          } 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();
          } 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.");
          }
        }
      }
    }
  }
一、解析PACKAGE

1.先解析扫描package(以下蓝体表示方法在对应类里)

  XMLConfigBuilder.mapperElement(XNode parent)-->解析主配置文件中的Mappers标签

  2.//如果mappers标签中写的是package分支(Configuration)

    Configuration.addMappers(mapperPackage);

     3. //继续调(MapperRegistry)

       mapperRegistry.addMappers(packageName);

        4. //继续(MapperRegistry)

           mapperRegistry.addMappers(packageName, Object.class);

           5. //继续(MapperRegistry)

public void addMappers(String packageName, Class superType) {
  ResolverUtil> resolverUtil = new ResolverUtil<>();
  resolverUtil.find(new ResolverUtil.IsA(superType), packageName);
  Set>> mapperSet = resolverUtil.getClasses();
  for (Class mapperClass : mapperSet) {
    addMapper(mapperClass);
  }
}

              6.  //继续         type.isInterface()只解析接口(所以包里必须要有接口)

                (MapperRegistry)

public  void addMapper(Class 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);
      }
    }
  }
}

       7. //继续(MapperAnnotationBuilder)

public void parse() {
  String resource = type.toString();
  if (!configuration.isResourceLoaded(resource)) {
    loadXmlResource();//解析xml文件的
    configuration.addLoadedResource(resource);
    assistant.setCurrentNamespace(type.getName());
    parseCache();
    parseCacheRef();
    Method[] methods = type.getMethods();
    for (Method method : methods) {
      try {
        // issue #237
        if (!method.isBridge()) {
          parseStatement(method);
        }
      } catch (IncompleteElementException e) {
        configuration.addIncompleteMethod(new MethodResolver(this, method));
      }
    }
  }
  parsePendingMethods();
}

 7. //继续(MapperAnnotationBuilder)

private void loadXmlResource() {
  // Spring may not know the real resource name so we check a flag
  // to prevent loading again a resource twice
  // this flag is set at XMLMapperBuilder#bindMapperForNamespace
  if (!configuration.isResourceLoaded("namespace:" + type.getName())) {
    String xmlResource = type.getName().replace('.', '/') + ".xml";
    // #1347
    InputStream inputStream = type.getResourceAsStream("/" + xmlResource);
    if (inputStream == null) {
      // Search XML mapper that is not in the module but in the classpath.
      try {
        inputStream = Resources.getResourceAsStream(type.getClassLoader(), xmlResource);
      } catch (IOException e2) {
        // ignore, resource is not required
      }
    }
    if (inputStream != null) {
      XMLMapperBuilder xmlParser = new XMLMapperBuilder(inputStream, assistant.getConfiguration(), xmlResource, configuration.getSqlFragments(), type.getName());
      xmlParser.parse();
    }
  }
}

package处理注意事项

~

(1).package中的name属性代表要扫表的包,mybatis首先会扫描这个包下全部的类

(2).package的name所表示的包中必须有一个接口,xml文件的名字和这个接口的名字必须和一样且路径必须一致

(3).映射文件的namespace必须是与映射文件对应的接口的全限定名     

 二、解析mappers

1.mappers标签处理注意事项

他的解析直接发生在下面代码里

XMLMapperBuilder.parse()这个方法是最终解析的--真正负责映射文件的方法

(1)*******一个mapper里面只能.resource url和mapperClass必须三选一

String resource = child.getStringAttribute("resource");
          String url = child.getStringAttribute("url");
          String mapperClass = child.getStringAttribute("class");
          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();
          } 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();
          } 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.");
          }

1.1如何解析 映射xml里面的sql语句(继如上mapperParser.parse();之后)

 a.类:XMLMapperBuilder

public void parse() {
    if (!configuration.isResourceLoaded(resource)) {
      configurationElement(parser.evalNode("/mapper"));
      configuration.addLoadedResource(resource);
      bindMapperForNamespace();
    }

    parsePendingResultMaps();
    parsePendingCacheRefs();
    parsePendingStatements();
  }

 b.类:XMLMapperBuilder

private void configurationElement(XNode context) {
    try {
      String namespace = context.getStringAttribute("namespace");
      if (namespace == null || namespace.equals("")) {
        throw new BuilderException("Mapper's namespace cannot be empty");
      }
      builderAssistant.setCurrentNamespace(namespace);
      cacheRefElement(context.evalNode("cache-ref"));
      cacheElement(context.evalNode("cache"));
      parameterMapElement(context.evalNodes("/mapper/parameterMap"));
      resultMapElements(context.evalNodes("/mapper/resultMap"));
      sqlElement(context.evalNodes("/mapper/sql"));
      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);
    }
  }

 c.类:XMLMapperBuilder

private void buildStatementFromContext(List list) {
    if (configuration.getDatabaseId() != null) {
      buildStatementFromContext(list, configuration.getDatabaseId());
    }
    buildStatementFromContext(list, null);
  }

d.类: XMLMapperBuilder

private void buildStatementFromContext(List 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);
      }
    }
  }

e.类:XMLStatementBuilder:parseStatementNode()解析sql的核心程序

public void parseStatementNode() {
    String id = context.getStringAttribute("id");
    String databaseId = context.getStringAttribute("databaseId");

    if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
      return;
    }

    String nodeName = context.getNode().getNodeName();
    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
    XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
    includeParser.applyIncludes(context.getNode());

    String parameterType = context.getStringAttribute("parameterType");
    Class parameterTypeClass = resolveClass(parameterType);

    String lang = context.getStringAttribute("lang");
    LanguageDriver langDriver = getLanguageDriver(lang);

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

    // Parse the SQL (pre:  and  were parsed and removed)
    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;
    }

    SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
    StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
    Integer fetchSize = context.getIntAttribute("fetchSize");
    Integer timeout = context.getIntAttribute("timeout");
    String parameterMap = context.getStringAttribute("parameterMap");
    String resultType = context.getStringAttribute("resultType");
    Class resultTypeClass = resolveClass(resultType);
    String resultMap = context.getStringAttribute("resultMap");
    String resultSetType = context.getStringAttribute("resultSetType");
    ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);
    if (resultSetTypeEnum == null) {
      resultSetTypeEnum = configuration.getDefaultResultSetType();
    }
    String keyProperty = context.getStringAttribute("keyProperty");
    String keyColumn = context.getStringAttribute("keyColumn");
    String resultSets = context.getStringAttribute("resultSets");

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

f类: MapperBuilderAssistant

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);
    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) {
      statementBuilder.parameterMap(statementParameterMap);
    }

    MappedStatement statement = statementBuilder.build();
    configuration.addMappedStatement(statement);
    return statement;
  }

g类:Configuration

public void addMappedStatement(MappedStatement ms) {
    mappedStatements.put(ms.getId(), ms);
  }

(2) url是远程的


所以rescourse是本地源 url是远程源

class需要指定一个接口进行处理:流程上等效于package中的某一个接口的解析过程

 三、前期处理的面试题

 1.package和mapper的区别

package做的是全包的扫描,当扫描到接口,会找到与接口名字相匹配的映射xml文件(当然接口的名字和映射文件的名字必须一样),mapper则是指定特定的xml文件进行单独的加载.

2. mapper的三种属性的区别

rescourse:读本地的xml文件

url:读远程的文件(实际的工作种会防止泄密)

class:必须指定一个接口,来读取xml文件, 相当于package包的一个子集,加载流程和package差不多,也必须接口和映射文件名字一样.

eg:
    

     

     

     

3. environments切换数据源可以使用构建build方法时添加参数来指定使用哪个数据源

//这行代码指定使用叫developmentx的数据源
SqlSessionFactory sqlSessionFactory = builder.build(reader,"developmentx");

4.preparedstatement和statement以及sql注入问题(我们先看两段代码)

 这就是sql注入而且用的是statement,每次执行都要重新编译sql语句(so可以拼串),效率低下.

String ID = "asdasdasdas' or '1'='1";
        Class.forName("com.mysql.jdbc.Driver");
        String url =  "jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf8";
        String username = "root";
        String password = "5541129";
        Connection connection = DriverManager.getConnection(url,username,password);
        Statement stat = connection.createStatement();
        String sql = "select * from mybatis.user where id = '"+ID+"'";
        System.out.println(sql);
        ResultSet rs = stat.executeQuery(sql);
        if (rs.next()){
            System.out.println(rs.getInt(1));
        }

这是preparestatement,因为sql语句是预编译的,而且语句中使用了占位符,规定了sql语句的结构。用户可以设置"?"的值,但是不能改变sql语句的结构,因此想在sql语句后面加上如“or 1=1”实现sql注入是行不通的。实际开发中,一般采用PreparedStatement访问数据库,它不仅能防止sql注入,还是预编译的(不用改变一次参数就要重新编译整个sql语句,效率高),此外,它执行查询语句得到的结果集是离线的,连接关闭后,仍然可以访问结果集。

public static void main(String[] args) throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.jdbc.Driver");
        String url =  "jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf8";
        String username = "root";
        String password = "5541129";
        Connection connection = DriverManager.getConnection(url,username,password);
        String sql = "select * from mybatis.user where id = ?";
        PreparedStatement preparedStatement = connection.prepareStatement(sql);
        preparedStatement.setString(1, "1");
        ResultSet resultSet =  preparedStatement.executeQuery();
        if(resultSet.next()){
            System.out.println(resultSet.getInt(1)+resultSet.getString(2));
        }

区别与联系

联系

1.PreparedStatement和Statement都是用来执行SQL查询语句的API之一

2.PreparedStatement接口继承了Statement接口

区别:

1.PreparedStatement和Statement的sql语句放置的位置不同

2.Statement不对sql语句作处理,直接交给数据库;而PreparedStatement支持预编译,会将编译好的sql语句放在数据库端,相当于缓存。对于多次重复执行的sql语句,使用PreparedStatement可以使得代码的执行效率更高。

3.Statement的sql语句使用字符串拼接的方式,容易导致出错,且存在sql注入的风险;PreparedStatement使用“?”占位符提升代码的可读性和可维护性,并且这种绑定参数的方式,可以有效的防止sql注入

我们把链接Connection比作桥,那么statement和preparestatement便是车, statement需要每次拉一整条sql过去进行编译运行(所以不能防止sql注入,阻止不了拼串),所以sql必须是完整的,在执行的时候传入sql,而preparestatement就比较聪明,sql语句参数当问号,进行预编译把sql语句放在数据库端不动,把sql的完整结构(这样sql结构确定,就可以防止sql注入)传过去一次PreparedStatement preparedStatement = connection.prepareStatement(sql);,这样sql的结构就确定了,以后每次小车只需要拉值过去就可以了,提高了效率.

 5.#{}和${}的区别

静态sql:在SQL执行前,会先将上面的SQL发送给数据库进行编译;执行时,直接使用编译好的SQL,替换占位符“?”就可以了。因为SQL注入只能对编译过程起作用,所以这样的方式就很好地避免了SQL注入的问题。

动态sql:原来什么样就是什么样,执行的时候执行整个sql语句

如上图1.1.e所示 XMLStatementBuilder:parseStatementNode()解析sql的核心程序

select * from mybatis.teacher where id = ${ID}

sqlSource = select * from mybatis.teacher where id = ${ID} 是不变的 称为动态sql

select * from mybatis.teacher where id = #{ID}
sqlSource = select * from mybatis.teacher where id = ?变成了预处理程序 称为静态sql

所以区别是(#相当与预处理,考虑类型 )($表示元素可以拼串 $直接替换成值 不会考虑类型)

1."#"处理的sql叫静态sql,前期mapper文件解析的时候会把#部分变成?,将sql进行预编译到数据库端且sql语句结构不会再变,变成预处理sql,执行期间传入值直接执行,所以不会发生sql注入问题。

"$"处理的叫动态sql,前期处理时候不会被改变,运行sql时候,运行sql的时候才会进行编译和值替换,进行值拼串,容易发生sql注入。(这玩意可以结合orderby来用,用的时候参数类型写string类型,将来就可以根据自己的按什么排序规则动态的进行改变)

2.#预处理发生在文件解析过程钟只发生一次,所以执行效率比较高

$发生在程序执行期间,而且每次都要处理,所以执行效率相对于#会略低

3.如果出现了#和$同时存在的时候,sql处理过程就会变成动态sql,而不是静态sql

 

 6.

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/732788.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-27
下一篇 2022-04-27

发表评论

登录后才能评论

评论列表(0条)

保存