mybatis详解(原理、常用配置、源码、案例、相关工具)

mybatis

v20190523

目录

一、框架概述

1 什么是框架?

  • 一个框架是一个可复用的设计构件
  • 整体设计、依赖关系、责任分配、流程控制
  • 上下文 Context

2 为什么要使用框架?

  • 软件系统十分复杂
    • 现代系统,不仅是模块很多,系统也很多
    • 例如互联网、电商、分布式等项目
  • 帮助完成基础工作
    • 开发人员可以集中精力在业务逻辑设计
    • 消除业务无关的重复代码
  • 解决细节问题
    • 成熟稳健
    • 事务处理、安全性、数据流控制等
  • 持续升级
    • 很多人使用和维护
    • 不需要自己升级代码

3 软件分层分层

  • 表现层
    • servlet
    • spring mvc
    • struts2
  • 业务层
    • java bean
    • spring
  • 持久层
    • jdbc
    • mybatis
    • hibernate
    • spring data jpa

二、Mybatis概述

1 官网

http://www.mybatis.org/mybatis-3/zh/index.html

2 mybatis是什么?

  • 持久层框架
    • java本身使用jdbc驱动做持久层开发
    • JDBC Java数据库连接,Java Database Connectivity,简称JDBC
  • 半自动化ORM框架
    • ORM 对象关系映射,Object Relational Mapping,简称ORM,或O/RM,或O/R mapping
  • 封装通用方法
    • 开发人员只需要关注 SQL 本身
    • 封装了 注册驱动、获取连接、创建statement、设置参数、对结果处理等
    • xml/注解方式处理SQL

3 mybatis由来

  • iBatis
    • iBATIS一词来源于“internet”和“abatis”的组合
    • mybatis 前身
  • mybaits
    • 官方简介 https://blog.mybatis.org/p/about.html
    • MyBatis项目继承自iBATIS 3.0,其维护团队也包含iBATIS的初创成员。
    • 2010年5月19日项目创建。当时Apache iBATIS 3.0发布,其开发团队宣布会在新的名字、新的站点中继续开发。
    • 2013年11月10日,项目迁移到了GitHub。

4 JDBC使用步骤

  • JDBC代码示例
  • 1 加载驱动
  • 2 获取数据库连接
  • 3 sql预处理
  • 4 执行sql
  • 5 获取执行结果

5 简单使用JDBC存在的问题

  • 数据库连接操作存在硬编码
    • Mybatis全局配置文件,支持配置连接信息
  • statement操作存在硬编码
    • Mybatis通过Mapper文件,提供sql、参数映射
  • 频繁开启数据库连接会降低数据库性能
    • Mybatis全局配置文件,支持配置连接池

6 mybatis架构原理

  • XML配置文件解析
  • SqlSessionFactory
  • SqlSession
  • Executor
  • MappedStatement

架构图

三、Mybatis开发

1 开发示例

2 需要关注的文件

  • POJO类(DTO、VO、PO等)
  • Mapper(dao)
  • Mapper 映射文件
  • 全局配置文件

3 mybatis使用常见问题

  • #{} 相当于jdbc里的占位符 ?
    • PrepareStatement
    • 可以做SQL预编译
    • 进行输入映射时,会对参数进行类型解析(例如 string 类型,解析后SQL语句会加上 '')
    • 如果进行简单类型(String、Date、8种基本类型的包装类)的输入映射时,#{}中参数名称可以任意
  • ${} 相当于jdbc里的连接符 +
    • Statement
    • 每一次都是一个新的SQL语句,就是字符串拼SQL
    • 存在SQL注入问题,使用 OR 关键字(OR 1=1),将查询条件忽略
    • 如果进行简单类型(String、Date、8种基本类型的包装类)的输入映射时,#{}中参数名称必须用value
  • OGNL
    • 对象图导航语言 Object-Graph Navigation Language
    • 多个入参时,使用对象
  • 返回值是单一/多个对象,resultType都只描述单一对象的类型
  • mapper xml 加载注意事项
    • 当遇到 resource not found 或者 parsing error 时,注意检查mapper是否有内容写错误(参数、返回类型等)

4 mybatis开发dao层的三种方式

  • 原始开发实现 dao接口及实现
    • 代码示例
    • 该种方式常见于ibatis迁移过来的项目
    • 实现层注入 SqlSessionFactory 创建SqlSession
    • 作用域
      • SqlSessionFactoryBuilder
        • 方法级别
      • SqlSessionFactory
        • 全局范围(应用级别)
      • SqlSession
        • 方法级别
  • Mapper 代理开发实现(基于JDK的动态代理)
    • 代码示例
    • 动态代理(代理分为静态代理、动态代理)
      • 基于JDK
        • 针对有接口的类,进行动态代理
      • 基于CGLIB
        • 针对子类继承父类的方式,进行动态代理
        • 目标类是父类,代理类是子类
    • XML方式
      • 只需要 Mapper 接口(dao 接口)和 Mapper 约束文件,不需要实现类
      • 规范
        • 接口类的路径与 XML 文件中的 namespace 相同
        • 接口方法名与 XML 文件中每个 statement 的 id 相同
        • 接口方法入参类型与 XML 文件中每个 statement 的 parameterType 相同
        • 接口方法返回值类型与 XML 文件中每个 statement 的 resultType 相同
      • 与 spring 整合后,需要在 ioc 容器中管理一个 SqlSessionFactory 对象
    • 注解方式
      • 后续讲解

5 POJO输入映射和输出映射

5.1 parameterType 输入映射

  • 输入参数类型
    • 简单类型
    • POJO类型
      • #{} 通过反射获取数据
        • StaticSqlSource (RawSqlSource)
      • ${} 通过OGNL表达式获取数据
        • DynamicSqlSource
    • Map类型
    • List类型
      • 动态SQL会讲解

5.2 resultType/resultMap 结果映射

  • resultType
    • 查询结果列名和属性名一致
    • 不一致的会得不到结果
  • resultMap
    • 关联查询 一对多查询可以进行对象嵌套
    • 延时加载(懒加载)

四、mybatis-config.xml

  • properties(属性)
    • 引入外部的 Java 配置文件(properties)
    • 直接设置子标签
    • 优先级 环境变量 > 配置文件 > 子标签
    • 建议加前缀,避免受到环境影响
  • settings(全局配置参数)
  • typeAliases(类型别名)
    • 用于POJO类
    • 简化映射文件中的 parameterType resultType
  • typeHandlers(类型处理器)
    • 转换:Java 类型 -> JDBC 类型(mybatis) -> 数据库类型(数据库驱动)
  • objectFactory(对象工厂)
  • plugins(插件)
    • 增强 Mybatis 执行 SQL 语句过程中的功能
    • 例如:PageHelper分页插件
  • environments(环境集合属性对象)
    • 一般不使用这个,例如连接池
    • environment(环境子属性对象)
      • transactionManager(事务管理)
      • dataSource(数据源)
  • mappers(映射器)
    • 批量加载 package,批量加载同一包下的 Mapper XML 文件,该包下接口和 XML 要配对才起效

五、Mybatis源码专题

1 设计模式

Java 常见三类23种设计模式

  • 创建型 5
    • 工厂模式 同属性的同一对象
    • 构建者模式 不同属性的同一对象
  • 结构型 7
  • 行为性 11

2 初始化过程解析

2.1 核心流程

全局 XML 配置文件 -> Configuration 配置对象 -> SqlSessionFactory对象

// 1. 顶层 创建SqlSessionFactory
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

// 1.1 new SqlSessionFactoryBuilder().build()底层 
// 1.1.1 创建XMLConfigBuilder
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);

// 1.1.2 XMLConfigBuilder进行解析,返回Configuration
Configuration config = parser.parse();

// 1.1.3 根据配置构造,返回DefaultSqlSessionFactory
build(config);
public SqlSessionFactory build(Configuration config) {
  return new DefaultSqlSessionFactory(config);
}

2.2 XMLxxxBuilder XML文件解析构造器

父类 BaseBuilder(Configuration、TypeAliasRegistry、TypeHandlerRegistry)

  1. XMLConfigBuilder 用来解析 MyBatis 的全局配置文件 mybatis-config.xml
  2. XMLMapperBuilder 用来解析 MyBatis 中的映射文件 xxxMapper.xml
  3. XMLStatementBuilder 用来解析映射文件中的 statement 语句 <select>xxx</select>
  4. MapperBuilderAssistant 用来辅助解析映射文件并生成 MappedStatement 对象

XML 解析使用了 XPath(javax.xml.xpath.XPath),将 XML 解析为七种类型节点(XNode)

private XMLConfigBuilder(XPathParser parser, String environment, Properties props) {
  // 1 初始化Configuration
  super(new Configuration());
  ...
  this.configuration.setVariables(props);
  this.parsed = false;
  this.environment = environment;
  this.parser = parser;
}

// 1.1 new Configuration()
// 1.1.1 注册常用别名
typeAliasRegistry.registerAlias("JDBC", JdbcTransactionFactory.class);
...

2.3 parser.parse() 解析成 Configuration 流程

// 1 只解析一次,从configuration根节点开始
if (parsed) {
  ...
}
parsed = true;
parseConfiguration(parser.evalNode("/configuration"));
return configuration;

// 1.1 parseConfiguration() 流程 XML -> Configuration
// 按顺序解析XML标签
// 解析properties
propertiesElement(root.evalNode("properties"));
...
// 解析Mappers
mapperElement(root.evalNode("mappers"));

2.4 mapperElement() Mapper 解析流程

XMLMapperBuilder

// 1 全局配置文件获取到resource资源路径,加载xxxMapper.xml映射文件
String resource = child.getStringAttribute("resource");
...
InputStream inputStream = Resources.getResourceAsStream(resource);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(...);
// 2 执行Mapper解析
mapperParser.parse();

// 2.1 执行Mapper解析流程
...
configurationElement(parser.evalNode("/mapper"));
...
parsePendingResultMaps();
...
parsePendingStatements();

// 2.1.1 configurationElement()
...
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
resultMapElements(context.evalNodes("/mapper/resultMap"));
sqlElement(context.evalNodes("/mapper/sql"));
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));

// 2.1.1.1 buildStatementFromContext() 
// 使用 XMLStatementBuilder 解析 select|insert|update|delete 标签为 statement
statementParser.parseStatementNode();

// 2.1.1.1.1 statementParser.parseStatementNode() 解析核心部分
...
// 2.1.1.1.2 转换为真正的SQL语言
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
...
// 2.1.1.1.3 使用 MapperBuilderAssistant 转换为 statement
builderAssistant.addMappedStatement(...);

3 SQL执行流程

3.1 核心流程

openSession ->

SqlSession sqlSession = sqlSessionFactory.openSession();
User user = sqlSession.selectOne("findUserById", 1);

3.2 openSessionFromDataSource() 从数据源打开session

  • 执行器类型 ExecutorType
    • SIMPLE
    • REUSE
    • BATCH
  • 设置事务级别 TransactionIsolationLevel
    • NONE
    • READ_COMMITTED
    • READ_UNCOMMITTED
    • REPEATABLE_READ
    • SERIALIZABLE
  • 是否自动提交 autoCommit
// 创建 DefaultSqlSession 流程
...
final Environment environment = configuration.getEnvironment();
...
tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
...

3.3 查询执行流程解析 sqlSession.selectList()

  • 参数1 statement
    • "com.aizain.jhome.user.findUserById"
  • 参数2 唯一入参
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {

  // 根据 statement(id)获取 MappedStatement
  MappedStatement ms = configuration.getMappedStatement(statement);
 
  // 委托者模式,委托给 executor 去执行
  // 默认使用 CachingExecutor
  // CachingExecutor 主要用于处理二级缓存
  // 如果无二级缓存 CachingExecutor 默认委托给 SimpleExecutor/BaseExecutor 真正执行
  return executor.query(
    ms,
    // 特殊处理入参为集合类型的 涉及到动态SQL
    wrapCollection(parameter), 
    rowBounds, 
    Executor.NO_RESULT_HANDLER
  );
...
}

3.4 executor.query() 执行细节

CachingExecutor sqlSource绑定parameter -> CachingExecutor 查二级缓存 -> BaseExecutor 查一级缓存 -> BaseExecutor 查数据库 -> SimpleExecutor 真正执行查询

// 1 executor.query()
// 绑定入参 通过sqlSource,之前初始化流程时,已装载过sqlSource
BoundSql boundSql = ms.getBoundSql(parameterObject);
CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
// query 会先走二级缓存,之后走一级缓存,最后走数据库查询 BaseExecutor.queryFromDatabase()
query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);

// 1.1 BaseExecutor.queryFromDatabase() 部分
// queryFromDatabase会调用SimpleExecutor.doQuery()真正执行查询,然后保存一级缓存
...
// 为了防止缓存穿透 后续讲解
localCache.putObject(key, EXECUTION_PLACEHOLDER);
...
// SimpleExecutor.doQuery()
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
...
localCache.removeObject(key);
...
localCache.putObject(key, list);

3.5 SimpleExecutor.doQuery() 真正执行查询

  • 真正开始与 jdbc 打交道
  • 根据 Statement 类型路由到不同 statement 处理
    • 本质上是 jdbc statement 类型
    • STATEMENT
      • 用于对数据库进行通用访问
      • 在运行时使用静态SQL语句时很有用
      • Statement 接口不能接受参数
    • PREPARED
      • 当计划要多次使用SQL语句时使用
      • PreparedStatement 接口在运行时接受输入参数
    • CALLABLE
      • 当想要访问数据库存储过程时使用
      • CallableStatement 接口也可以接受运行时输入参数
// 1 SimpleExecutor.doQuery()
Configuration configuration = ms.getConfiguration();
// 获取不同类型的Statement处理器
StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
stmt = prepareStatement(handler, ms.getStatementLog());
handler.query(stmt, resultHandler);
...
closeStatement(stmt);


// 1.1 prepareStatement()
Connection connection = getConnection(statementLog);
stmt = handler.prepare(connection, transaction.getTimeout());
// 内部执行了 DefaultParameterHandler.setParameters()
// 底层根据参数类型,调用不同的 prepareStatement.setXXX()
handler.parameterize(stmt);

// 1.1.1 handler.prepare()
...
// 内部执行了 connection.prepareStatement() 生成 prepareStatement
statement = instantiateStatement(connection);
setStatementTimeout(statement, transactionTimeout);
setFetchSize(statement);
...

4 Mapper代理解析

// 解析xml时,注册mapper接口 XMLConfigBuilder.mapperElement()
// 注册到 MapperRegistry对象中
configuration.addMappers(mapperPackage);
...
configuration.addMapper(mapperInterface);

// MapperRegistry对象
// 用于存储注册的 接口类:代理工厂
Map<Class<?>, MapperProxyFactory<?>> knownMappers

// MapperProxyFactory 中使用 jdk 生成动态代理
protected T newInstance(MapperProxy<T> mapperProxy) {
  // jdk 方法 java.lang.reflect.Proxy
  return (T) Proxy.newProxyInstance(
    mapperInterface.getClassLoader(), 
    new Class[] { mapperInterface }, 
    mapperProxy
  );
}

// addMapper() 解析
if (hasMapper(type)) {
  throw new BindingException("Type " + type + " is already known to the MapperRegistry.");
}
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 用于处理Mapper的注解
MapperAnnotationBuilder parser = new MapperAnnotationBuilder(config, type);
parser.parse();

六、关联查询/延迟加载

1 关联查询

  • 关联查询 一对多查询可以进行对象嵌套 嵌套结果
    • 对象嵌套需要使用association/collection子标签
    • 对象嵌套示例
    • 对象嵌套可以将一对多的单维数据转换为二维数据,见下方例子

1.1 对象嵌套前

列2
a1
a2
a3
b1
b2

1.2 对象嵌套后

列2
a[ 1 2 3 ]
b[ 1 2 ]

2 延迟加载

  • 懒加载,推迟关联对象的select查询,可以减小数据库压力
  • 在resultMap中依赖association/collection子标签实现
  • 延迟加载也被称为嵌套查询
  • 分类 三类
    • 直接加载
      • 主/关联对象select同时执行
    • 侵入式延迟
      • 用到了对象的任何属性,就执行select
    • 深度延迟
      • 只有用到了关联对象,才执行select
  • 为了防止逆向工程覆盖特殊业务字段或处理,可以使用继承关系避免该问题(父类/子类)
    • 对修改关闭,对扩展开放

2.1 延迟加载 映射文件配置

  • select 指定延迟执行的语句
  • column 以哪一列为基准进行延迟查询
  • property 放入当前java对象的那个属性
  • javaType 延迟查询结果的java类型
<association column="user_id" property="user" javaType="User" select="xxx.findUserById"/>

2.2 延迟加载 全局文件配置

  • lazyLoadingEnabled
    • 是否启用延迟加载
    • 默认 false
    • false 所有延迟加载配置都不会生效
  • aggressiveLazyLoading
    • 任何方法的调用都会触发延迟加载
    • true 侵入式延迟模式 false 深度延迟
    • 默认 true
  • lazyLoadTriggerMethods
    • 指定哪些方法触发延迟加载
    • 默认 equals,clone,hashCode,toString
<settings>
    <setting name="lazyLoadingEnabled" value="false"/>
    <setting name="aggressiveLazyLoading" value="true"/>
</settings>
for (OrderWithUser orderWithUser : orderWithUsers) {
    // debug可能看不出来区别,需要日志观察
    // 开启侵入式延迟加载时 aggressiveLazyLoading true, 这步之前就会查询sql
    log.debug("Order get id {}", orderWithUser.getId());
    // 开启深度延迟加载时 aggressiveLazyLoading false, 这步之前才会查询sql
    log.debug("Order get user {}", orderWithUser.getUser());
}

2.3 延迟加载 N+1问题

  • 主表查询一次,关联表查询 N 次

七、动态SQL

  • 目的:处理SQL字符串动态拼接
  • 源码 BoundSql 处理该动态SQL参数绑定
  • if 根据入参动态拼接SQL
  • sql 去除重复部分的语句
  • foreach 遍历拼接集合SQL

1 foreach collection属性 处理源码

  • DefaultSqlSessionFactory.wrapCollection()
if (object instanceof Collection) {
  StrictMap<Object> map = new StrictMap<>();
  map.put("collection", object);
  if (object instanceof List) {
    map.put("list", object);
  }
  return map;
} else if (object != null && object.getClass().isArray()) {
  StrictMap<Object> map = new StrictMap<>();
  map.put("array", object);
  return map;
}
return object;

八、缓存

  • 一级缓存
    • 默认开启
    • 执行增删改会使缓存失效
    • SqlSession 内共享
    • SqlSession 使用 HashMap 存储一级缓存
    • 取缓存源码位置 BaseExecutor.query()
    • 存缓存源码位置 BaseExecutor.queryFromDatabase()
  • 二级缓存
    • 默认不开启
    • 缓存对象需要实现序列化
    • 同一 Mapper(namespace) 内共享
    • 源码位置 CacheExecutor.query()

1 二级缓存

  • 需要开启两处
  • 缓存对象需要实现 Serializable

1.1 全局配置

<setting name="cacheEnabled" value="true"/>

1.2 Mapper配置

  • 指定 mapper 文件命名空间下的二级缓存
<cache/>
<select useCache="true" >xxx</select>

1.3 整合ehcache

1.3.1 简介

  • 开源的 Java 分布式缓存
  • 主要面向通用缓存
  • 具有内存和磁盘存储、缓存加载器、缓存扩展、缓存异常处理程序
  • 支持 REST、SOAP 等
  • 分布式缓存常用 Redis
  • Mybatis 的定位是做持久层框架,缓存数据管理不是 Mybatis 特长

1.3.2 特点

  • 快速、简单
  • 多种缓存策略
  • 支持磁盘和内存存储
  • 缓存数据会在虚拟机重启的过程中写入磁盘
  • 可通过 RMI 可插入 API 等方式进行分布式缓存
  • 具有缓存和缓存管理器的侦听接口
  • 支持多缓存管理实例,以及一个实例的多个缓存区域
  • 提供 Hibernate 的缓存实现

1.3.3 分布式缓存

  • Mybatis 自身无法支持分布式缓存,需要整合其他框架
  • 分布式缓存场景:集群部署方式
  • 可以解决缓存不一致问题

1.3.4 整合思路

  • Mybatis Cache 接口
    • 默认实现 PerpetualCache
    • 实现该接口,就可以实现二级缓存
  • 引入 jar 包
    • ehcache
    • mybatis-ehcache
  • 指定 type 类型
<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>

九、Mybatis相关工具和插件

1 逆向工程

  • 自动生成文件
    • po 类
    • mapper 接口
    • mapper 映射文件
  • 相关扩展框架
    • mybatis-plus
  • 注意事项
    • mapper.xml文件的内容不是被覆盖而是进行内容追加
    • po类及mapper.java文件的内容是直接覆盖

2 PafeHelper分页插件

3 注解开发

  • 代码示例
  • 不需要使用 xml 映射文件
  • 增删改查 静态SQL
    • @Select
    • @Insert
    • @Delete
    • @Update
    • @SelectKey
  • 增删改查 动态SQL
    • @SelectProvider
    • @InsertProvider
    • @DeleteProvider
    • @UpdateProvider
  • @Results 注解
    • 代替的是标签<resultMap>
    • 该注解中可以使用单个@Result 注解,也可以使用@Result 集合
    • @Results({@Result(),@Result()})或@Results(@Result())
  • @Resutl 注解
    • 代替了<id>标签和<result>标签
    • @Result 中 属性介绍:
    • column 数据库的列名
    • Property 需要装配的属性名
    • one 需要使用的@One 注解(@Result(one=@One)()))
    • many 需要使用的@Many 注解(@Result(many=@many)()))
  • @One 注解(一对一)
    • 代替了<assocation>标签,是多表查询的关键,在注解中用来指定子查询返回单一对象。
    • @One 注解属性介绍:
    • select 指定用来多表查询的 sqlmapper
    • fetchType 会覆盖全局的配置参数 lazyLoadingEnabled。。
    • 使用格式:
      • @Result(column=" ",property="",one=@One(select=""))
  • @Many 注解(多对一)
    • 代替了标签,是是多表查询的关键,在注解中用来指定子查询返回对象集合。
    • 注意:聚集元素用来处理“一对多”的关系。需要指定映射的 Java 实体类的属性,属性的 javaType(一般为 ArrayList)但是注解中可以不定义; 使用格式:
    • @Result(property="",column="",many=@Many(select=""))

十、扩展知识

JDBC

  • 参考 https://zh.wikipedia.org/wiki/Java%E6%95%B0%E6%8D%AE%E5%BA%93%E8%BF%9E%E6%8E%A5
  • JDBC Java数据库连接,Java Database Connectivity,简称JDBC
  • JDBC API主要位于JDK中的java.sql包中(之后扩展的内容位于javax.sql包中)
    • DriverManager:负责加载各种不同驱动程序(Driver),并根据不同的请求,向调用者返回相应的数据库连接(Connection)。
    • Driver:驱动程序,会将自身加载到DriverManager中去,并处理相应的请求并返回相应的数据库连接(Connection)。
    • Connection:数据库连接,负责进行与数据库间的通讯,SQL执行以及事务处理都是在某个特定Connection环境中进行的。可以产生用以执行SQL的Statement。
    • Statement:用以执行SQL查询和更新(针对静态SQL语句和单次执行)。
    • PreparedStatement:用以执行包含动态参数的SQL查询和更新(在服务器端编译,允许重复执行以提高效率)。
    • CallableStatement:用以调用数据库中的存储过程。
    • SQLException:代表在数据库连接的创建和关闭和SQL语句的执行过程中发生了例外情况(即错误)。

十一、参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值