MyBatis专题

一、前言

平时学习比较松散,凌乱,现在趁着9102最后的几天,整理一下这些基础知识。

1.Mybatis 是什么?

MyBatis 是一个可以自定义SQL、存储过程和高级映射的持久层框架。
MyBatis 摒除了大部分的JDBC代码、手工设置参数和结果集重获。

2.使用Mybatis 的原因

MyBatis可以使用简单的XML或注解用于配置和原始映射,将接口和Java的POJO(Plain Old Java Objects,普通的Java对象)映射成数据库中的记录。
相对Hibernate和Apache OJB等“一站式”ORM解决方案而言,Mybatis 是一种“半自动化”的ORM实现。

3.为什么要学

即使使用MP,那也是要基于mybatis的,因为毕竟是叠加版。如果你用JPA,Hibernate,也行。只要精通一样,其他也是触类旁通。 毕竟,mybatis能使用xml的方式混入整个sql,就让人很爽,因为可以给DBA检查sql~

二、正文

码云地址:

https://gitee.com/chenscript/mybatis_learning.git

1.原理篇

有几个关键的主键:Executor、StatementHandler、ParameterHandler、ResultSetHandler、TypeHandler。

根据原生访问数据库的语句,也是存在statement、parameter、resultset这几个关键字,所以可以大胆暂时把它们联系在一起。带着疑问,走进源码。(虽然我不解析源码,但可以大概聊一下)
在这里插入图片描述
(参考2.1 helloworld )根据初始化读取xml文件获取到的SessionFactoryBuilder.build()生成SessionFactory,利用sessionFactory.openSession()建立起 一个会话实例,也就是创建了一个DefaultSqlSession对象。
在这里插入图片描述
上图展示了查询的过程,提到的几大主件的作用。
在这里插入图片描述

根据mapper中的方法,生成一个代理类对象MapperProxy,这个代理了EmployeeMapper.class。
在这里插入图片描述
在这里插入图片描述
获取了代理对象之后,该代理对象就会代理对应接口的所有方法。
当你调用里面的某一个方法的时候,调用栈是这样的。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20191225214723733.png
也就是说,你访问的方法已经被代理对象拦截,并加上了一些操作。本次查询进入了MapperMethod.execute()中,进入DefaultSqlSession的查询方法中。
在这里插入图片描述
往下调试,就会遇上了第一个组件,Executor。
在这里插入图片描述
这里会先进入CacheExecutor查询缓存(所谓的一级二级缓存),如果缓存没有,就会走查询数据库DB的道路。
在这里插入图片描述
当前方法调用链:
在这里插入图片描述
再往后边,第二个组件要出现了:StatementHandler
在这里插入图片描述
很熟悉吧,就是用来整理出prepareStatement或者Statement的。
继续进入prepareStatement()中,根据方法名字可以知道是预处理sql的。
获取连接,处理初始化Statement实例(混入boundsql,transaction等等),参数化处理。
在这里插入图片描述
进入parameterize(),第三个组件:parameterHandler出现。
在这里插入图片描述
想到设置参数,那必须要和数据格式打交道,所以,接下来应该是要到TypeHandler出场了。我们需要把#{id}的值映射,对吧?也就是要把Java中的值的类型,映射到jdbc的值的类型。
尝试寻找Java类型的值与jdbc对应的值的类型,但好像没找到。于是进入了try阶段。
在这里插入图片描述

一路跳进方法中,找到了Integer的TypeHandler。然后把 Integer值映射到int i中 加入到sql语句中。
在这里插入图片描述
从这里可以看到一个TypeHandler的实现形式。
参数映射完之后,prepareStatement()就运行结束了。于是返回了stmt,开始jdbc查询了。
在这里插入图片描述
查询完之后,就要到我们的ResultSetHandler出场了!(友情提示:关于ResultSetHandler和parameterHandler是在StatementHandler的构造器中实现初始化的。。)
在这里插入图片描述
处理结果时,获取到的结果会被包装成ResultSetWrapper的对象,对象包含着准备要解析的数据以及typeHandlers
在这里插入图片描述
然后会根据mapper.xml中的接口对应的resultMap进行解析,返回需要的字段在这里插入图片描述
找到applyAutomaticMappings方法,这里就是映射值的地方。
在这里插入图片描述
解析上图,

  • 1.分析createAutomaticMappings()方法。
    在这里插入图片描述
    这行代码就是进行column类型匹配,与对应的TypeHandler,构成Map。
 autoMapping.add(new UnMappedColumnAutoMapping(columnName, property, typeHandler, propertyType.isPrimitive()));

在这里插入图片描述
根据对应的handler获取Java的类型值。
然后装入到metaObject中。
在这里插入图片描述
最后,返回DefaultResultSetHandler中,判断是否还需要再次补充结果集(可能存在分步查询的方式),如果没有就直接返回查询结果了。当然,还会存入缓存中,这就让读者自己去看吧。在这里实现的queryFromDatabase();
在这里插入图片描述

2.实用篇(JDK1.8,mybatis3.4.1,mysql 5.7)

本文主要是实现mybatis相关的内容,与spring无关,只有在最后搭建SSM的时候需要spring。

1)hello world

只需要依赖日志,方便打印mysql语句。另外还要mysql驱动包以及mybatis依赖

        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.4.1</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/log4j/log4j -->
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.17</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.38</version>
        </dependency>

2.引入mybatis-config.xml

mybatis-config的文件内容可以有很多,但最基本的就可以只放mappers配置、environment中的transactionManager、dataSource这些元素就行了,还可以不需要transactionManager,但保持一致性还是要的。有了这个配置文件,还需要找到mapper映射的xml文件,所以接下来是EmployeeMapper.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>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/mybatis"/>
                <property name="username" value="root"/>
                <property name="password" value="root"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <mapper resource="EmployeeMapper.xml"/>
    </mappers>
</configuration>

3.EmployeeMapper.xml
mapper文件中以<mapper>包含住所有内容,作为mapper属性的namespace需要映射到对应的接口中,由此代理对象根据子标签的id与接口中的方法映射,定位到标签中的sql语句,进而执行数据库的操作。

<?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.mybatis.EmployeeMapper">
    <select id="selectEmployee" resultType="com.mybatis.Employee">
        select id,last_name as lastName,email,gender from tbl_employee where id = #{id}
    </select>
</mapper>

4.EmployeeMapper

public interface EmployeeMapper {
	Employee selectEmployee(int i);
}

5.实体操作

@Data
public class Employee {
	private Integer id;
	private String lastName;
	private String email;
	private String gender;
}

6.最后就是启动mybatis配置文件,调用接口了。
主入口主要有三步:

  • 1、根据xml配置文件(全局配置文件)创建一个SqlSessionFactory对象,有数据源一些运行环境信息
  • 2、sql映射文件;配置了每一个sql,以及sql的封装规则等
  • 3、将sql映射文件注册在全局配置中
 public static void main(String... args) throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        //2.获取sqlSession实例,能直接执行已经映射的sql语句
        SqlSession sqlSession = sqlSessionFactory.openSession();
        try{
            EmployeeMapper mapper = sqlSession.getMapper(EmployeeMapper.class);
            Employee employee = mapper.selectEmployee(1);
            System.out.println(employee.toString());
        }finally {
            sqlSession.close();
        }
    }

7.关于log4j的配置,就不列出来了。放在码云上。

https://gitee.com/chenscript/mybatis_learning/tree/master/helloworld

把这个值改成DEBUG,就能调试打印sql了
在这里插入图片描述

2)CRUD 简单版

这里只写select,并且基于前面例子配置,不累赘。

3)CRUD 动态sql实现

mybatis的动态sql是基于OGNL实现的。再具体一点就是被这个类的这个方法解析的。
(org.apache.ibatis.ognl;)
在这里插入图片描述
在这个类所在的包下,被转化后的动态语句的运算符等等。

4)动态sql之if、choose|when|otherwise、trim、foreach、<sql>|<include>、<where>、<set>、bind

例子主要以select标签引入其他子标签。

1.<where> + <if> + <bind>
 <!--bind:可以将OGNL表达式的值绑定到一个变量中,方便后来引用这个变量的值-->
    <select id="getEmpsByConditionIf" resultType="com.mybatis.Employee">
        select  * from tbl_employee
        <where>
        <if test="id != null">
           and  id=#{id}
        </if>
        <if test="lastName != null and lastName.trim() != ''">
            <bind value="'%' + lastName+'%'" name="_lastName"/>
            and last_name like #{_lastName}
        </if>
        <if test="email != null and email.trim() != ''">
            and email =#{email}
        </if>
        <if test="gender == 0 or gender == 1">
            and gender =#{gender}
        </if>
        </where>
    </select>
2. <where> + <choose> +<when>+<otherwise>
<!--如果带了id就用id查,如果带了其他,就带其他的查,只会选择一个-->
    <select id="getEmpsByConditionChoose" resultType="com.mybatis.Employee">
        select *  from tbl_employee
        <where>
            <choose>
                <when test="id != null">
                    id = #{id}
                </when>
                <when test="lastName != null">
                    last_name like #{lastName}
                </when>
                <when test="email != null">
                    email = #{email}
                </when>
                <otherwise>
                     gender = 0
                </otherwise>
            </choose>
        </where>
    </select>
3.<foreach> 批量插入数据
<insert id="addEmpsByForeach" >
            insert into tbl_employee (last_name,gender,email,d_id)
            values
            <foreach collection="emps" separator=","  item="emp">
                (#{emp.lastName},#{emp.gender},#{emp.email},#{emp.dept.id})
            </foreach>
    </insert>
4.<set>
<update id="updateEmps" >
        update tbl_employee
        <set>
            <if test="lastName != null">
                last_name = #{lastName} ,
            </if>
            <if test="gender != null">
                gender = #{gender} ,
            </if>
            <if test="email != null">
                email = #{email}
            </if>
        </set>        
        where id = #{id}
    </update>
5.<trim> 与4的操作相同的不同书写形式
<update id="updateEmps" >
        update tbl_employee
        <trim prefix="set" suffixOverrides=",">
            <if test="lastName != null">
            last_name = #{lastName} ,
            </if>
            <if test="gender != null">
            gender = #{gender} ,
            </if>
            <if test="email != null">
            email = #{email}
            </if>
        </trim>
        where id = #{id}
    </update>
6.<sql>
    <sql id="selectColumn">
        <if test="_databaseId == 'mysql'">
          id,last_name lastName
        </if>
    </sql>
    <select id="getEmpsBySection" resultType="com.mybatis.Employee">
        select
        <include refid="selectColumn"></include>
        from tbl_employee where id = #{id}
    </select>
5)动态sql之关联查询、分步查询、延迟加载、鉴别器
5.1关联查询(全sql大法)

关联查询主要的操作是在****标签中处理。sql语句和平时写的关联查询差不多,也可以差很多。
用一个例子讲解这一小节的所有内容:

场景:
Employee ===查出员工信息、所属部门
一个员工有对应的部门

 <select id="getEmpAndDept" resultMap="MydifEmp">
          SELECT
                a.id id,
                a.last_name last_name,
                a.gender gender,
                a.email email,
                a.d_id d_id,
                b.id did,
                b.dept_name dept_name
            FROM
                tbl_employee a,
                tbl_dept b
            WHERE
                a.d_id = b.id
            AND a.id = #{id};
    </select>

查询是这样select,倒是没有争议的。问题就在于怎么把结果映射出来,特别是Department实体属性? 结果是要放在一个实体Employee中的。

public class Employee {
	private Integer id;
	private String lastName;
	private String email;
	private String gender;
	private Department dept;

这里介绍两种方法:
一种是在resultMap中使用级联的方式

  <!--方法一.一:级联属性的方式关联-->
    <resultMap id="MydifEmp" type="com.mybatis.Employee">
        <id column="id" property="id"/>
        <result column="last_name" property="lastName"/>
        <result column="gender" property="gender"/>
        <result column="email" property="email"/>
        <result column="did" property="dept.id"/>
        <result column="dept_name" property="dept.departmentName"/>
    </resultMap>

另一种方法则是使用<association>标签

  <resultMap id="MydifEmp1" type="Employee">
        <id column="id" property="id"/>
        <result column="last_name" property="lastName"/>
        <result column="gender" property="gender"/>
        <result column="email" property="email"/>
        <!--association 指定哪个属性是联合对象的属性-->
        <association property="dept" javaType="com.mybatis.Department">
            <id column="did" property="id" />
            <result column="dept_name" property="departmentName" />
        </association>
    </resultMap>

第三种方法就有点复杂了。连sql语句也不一样了。 是通过两个查询,使用<association>实现分步查询的方式,并且可以按照需求使用延迟加载的手段,控制查询速度。延迟加载,也就是,当你访问这个方法的时候,如果没使用到另一个查询,也就是<association>里面的查询的字段,那么这个查询就是没有被加载进去。可以通过DEBUG模式,打印日志看到打印出来的sql的差异。从源码上看,也是存在检查是否需要二次查询的操作。

使用association分步查询
1.按照员工id查询员工信息
2.根据查询员工信息中的d_id值去部门表查询部门信息
3.部门设置到员工中

    <resultMap id="MyEmpByStep" type="com.mybatis.Employee">
        <id column="id" property="id" />
        <result column="last_name" property="lastName"/>
        <result column="email" property="email"/>
        <result column="gender" property="gender"/>
        <!--association 定义关联对象的封装规则
                select:表明当前属性是调用select指定的方法查出的结果 (dao层接口方法)
                column:指定将哪一列的值传给这个方法
        -->
        <association 
        	property="dept" select="com.mybatis.DepartmentMapper.getDeptById" column="d_id">
        </association>
    </resultMap>
    <select id="getEmpByIdStep" resultMap="MyEmpDiscr">
        select * from tbl_employee where id=#{id}
    </select>

如果要使用延迟加载的话,还需要在mybatis-config.xml文件中的<setting>标签下配置两个属性:

        <!--延迟加载的全局开关。-->
        <setting name="lazyLoadingEnabled" value="true"/>
        <!--当开启时,任何方法的调用都会加载该对象的所有属性。 否则,每个属性会按需加载-->
        <setting name="aggressiveLazyLoading" value="false"/>

最后介绍一下,鉴别器。鉴别器的作用,个人理解就是个"when"。。。
以下鉴别器的作用:

鉴别器:mybatis可以使用discriminator 判断某列的值,然后根据某列的值改变封装行为
封装Employee:
如果查出的是女生(gender==0):就把部门信息查出来,否则不查询
如果是男生(gender ==1):把last_name这列的值赋值给email

   <select id="getEmpByIdStep" resultMap="MyEmpDiscr">
        select * from tbl_employee where id=#{id}
    </select>
    <resultMap id="MyEmpDiscr" type="com.mybatis.Employee">
        <id column="id" property="id" />
        <result column="last_name" property="lastName"/>
        <result column="email" property="email"/>
        <result column="gender" property="gender"/>
        <discriminator javaType="string" column="gender">
            <!--女生 resultType:指定封装的结果类型-->
            <case value="0" resultType="com.mybatis.Employee">
                <association 
	                property="dept" select="com.mybatis.DepartmentMapper.getDeptById" 
	                column="d_id">
                </association>
            </case>
            <!--男生-->
            <case value="1" resultType="com.mybatis.Employee">
                <id column="id" property="id" />
                <result column="last_name" property="lastName"/>
                <result column="last_name" property="email"/>
                <result column="gender" property="gender"/>
            </case>
        </discriminator>
    </resultMap>
6)一级缓存、二级缓存(使用第三方缓存)
一级缓存:(本地缓存): 与数据库同一次会话期间查询到的数据会放在本地缓存中。以后如果需要获取相同的数据,直接从缓存中拿,没必要再去查数据库
二级缓存:(全局缓存):基于namespace级别的缓存,一个namespace对应一个二级缓存

1、使用一级缓存:
在mapper.xml中配置:

<cache eviction="FIFO" flushInterval="60000" readOnly="false" size="1024"/>

然后使用相同的会话并执行相同的sql:

private static void queryCache(EmployeeMapper mapper, EmployeeMapper mapper2,Integer id1,Integer id2) {
		Map<String, Object> maps = mapper.selectMap(id1);
		System.out.println(maps);
		Map<String, Object> maps2 = mapper2.selectMap(id2);
		System.out.println(maps2);
		System.out.println(maps==maps2);
	}

结果:在第二次查询时就会在缓存中获取

2019-12-26 23:05:24,976 DEBUG com.mybatis.EmployeeMapper.getObject:62 - Cache Hit Ratio [com.mybatis.EmployeeMapper]: 0.0
2019-12-26 23:05:24,987 DEBUG com.mybatis.EmployeeMapper.selectMap.debug:145 - ==> Preparing: select * from tbl_employee where id = ?
2019-12-26 23:05:25,017 DEBUG com.mybatis.EmployeeMapper.selectMap.debug:145 - > Parameters: 1(Integer)
2019-12-26 23:05:25,055 DEBUG com.mybatis.EmployeeMapper.selectMap.debug:145 - <
Total: 1
{gender=0, d_id=1, last_name=tom, id=1, email=kkk@qq.com}
2019-12-26 23:05:25,055 DEBUG com.mybatis.EmployeeMapper.getObject:62 - Cache Hit Ratio [com.mybatis.EmployeeMapper]: 0.0
{gender=0, d_id=1, last_name=tom, id=1, email=kkk@qq.com}
true

关于一级缓存失效的情况,有如下几种情形

1.sqlsession不同
2.sqlsession相同,查询条件不同
3.sqlsession相同,两次查询之间执行了增删改操作
4.sqlsession相同,手动清除了一级缓存(缓存清空)

2、二级缓存

 工作机制:
       1、一个会话,查询一个数据,这个数据就会被放在当前会话的一级缓存中
       2、如果会话关闭;一级缓存中的数据会被保存到二级缓存中;新的会话查询信息,就可以参照二级缓存中
       3、sqlSession === EmployeeMapper ==>Employee
                          DepartmentMapper ===>Department
             不同namespace查出的数据会放在自己对应的缓存中(map)
             效果:数据会从二级缓存中获取
             查出的数据都会默认放在一级缓存中。
             只有会话提交或者关闭后,一级缓存中的数据才会转移到二级缓存中

使用二级缓存:
1.开启全局二级缓存配置
在这里插入图片描述
2.在mapper.xml中配置使用二级缓存
在这里插入图片描述
这里使用了ehcache缓存,所以需要依赖包和相关的xml配置。这里就不展开了。在文章头部介绍的码云地址中:cachedemo模块中的ehcache.xml和关于ehcache的maven依赖。

3.POJO需要实现序列化接口

public class Employee implements Serializable
7)代码生成器

generator模块中

8)插件开发

myfirstplugins中

 * 插件编写:
 *  1.编写Interceptor的实现类
 *  2.使用@Intercepts()注解完成插件签名
 *  3.将写好的插件注册到全局配置文件中
@Intercepts({
		@Signature(type= StatementHandler.class,
		method="parameterize",args = Statement.class)
})

关于插件开发,要知道插件是在哪里被调用的。可以看下源码:
在这里插入图片描述
前面提到的主件中,parameterHandler、resultSetHandler、statementHandler主件都会在创建之后使用上插件。所以,你注册上的插件也会在这上面调用。而@Signature 是具体指定到哪个类型的哪个方法需要添加插件的附加功能的。

9)分页插件

分页插件是基于8)的原理进行的。这里就只是展示使用方法。

        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper</artifactId>
            <version>5.0.0</version>
        </dependency>

在这里插入图片描述

10)自定义类型转换器

在这里插入图片描述

public class MyEnumEmpStatusTypeHandler implements TypeHandler<EmpStatus>{

	/**
	 * 定义当前数据如何保存到数据库中
	 * @param ps
	 * @param i
	 * @param parameter
	 * @param jdbcType
	 * @throws SQLException
	 */
	@Override
	public void setParameter(PreparedStatement ps, int i, EmpStatus parameter, JdbcType jdbcType) throws SQLException {
		ps.setString(i,parameter.getCode().toString());
	}

	@Override
	public EmpStatus getResult(ResultSet rs, String columnName) throws SQLException {
		//需要根据从数据库中拿到的状态码返回对应的枚举类型
		int anInt = rs.getInt(columnName);
		return EmpStatus.getEmpStatusByCode(anInt);
	}

	@Override
	public EmpStatus getResult(ResultSet rs, int columnIndex) throws SQLException {
		//需要根据从数据库中拿到的状态码返回对应的枚举类型
		int anInt = rs.getInt(columnIndex);
		return EmpStatus.getEmpStatusByCode(anInt);
	}

	@Override
	public EmpStatus getResult(CallableStatement cs, int columnIndex) throws SQLException {
		int anInt = cs.getInt(columnIndex);
		return EmpStatus.getEmpStatusByCode(anInt);
	}
}
/**
 * 希望数据库保存的是100,200 等状态码
 */
public enum EmpStatus {
	LOGIN(100,"用户登录"),LOGOUT(200,"用户登出"),REMOVE(300,"用户不存在");

	private Integer code;
	private String msg;

	private EmpStatus(Integer code,String msg){
		this.code = code;
		this.msg = msg;
	}

	public Integer getCode() {
		return code;
	}

	public String getMsg() {
		return msg;
	}

	//按照状态码返回枚举对象
	public static EmpStatus getEmpStatusByCode(Integer code){
		switch(code){
			case 100:
				return LOGIN;
			case 200:
				return LOGOUT;
			case 300:
				return REMOVE;
			default:
				return LOGOUT;
		}
	}
	}

3.关键组件篇

1)Excutors
2)StatementHandler
3)ParameterHandler
4)ResultsetHandler
5)TypeHandler
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 内容概要 《计算机试卷1》是一份综合性的计算机基础和应用测试卷,涵盖了计算机硬件、软件、操作系统、网络、多媒体技术等多个领域的知识点。试卷包括单选题和操作应用两大类,单选题部分测试学生对计算机基础知识的掌握,操作应用部分则评估学生对计算机应用软件的实际操作能力。 ### 适用人群 本试卷适用于: - 计算机专业或信息技术相关专业的学生,用于课程学习或考试复习。 - 准备计算机等级考试或职业资格认证的人士,作为实战演练材料。 - 对计算机操作有兴趣的自学者,用于提升个人计算机应用技能。 - 计算机基础教育工作者,作为教学资源或出题参考。 ### 使用场景及目标 1. **学习评估**:作为学校或教育机构对学生计算机基础知识和应用技能的评估工具。 2. **自学测试**:供个人自学者检验自己对计算机知识的掌握程度和操作熟练度。 3. **职业发展**:帮助职场人士通过实际操作练习,提升计算机应用能力,增强工作竞争力。 4. **教学资源**:教师可以用于课堂教学,作为教学内容的补充或学生的课后练习。 5. **竞赛准备**:适合准备计算机相关竞赛的学生,作为强化训练和技能检测的材料。 试卷的目标是通过系统性的题目设计,帮助学生全面复习和巩固计算机基础知识,同时通过实际操作题目,提高学生解决实际问题的能力。通过本试卷的学习与练习,学生将能够更加深入地理解计算机的工作原理,掌握常用软件的使用方法,为未来的学术或职业生涯打下坚实的基础。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值