Java企业级分布式架构师010期课程第一章-第一讲-学习笔记

前言

本文和本文内一切内容,均来自我对于开课吧Java企业级分布式架构师010期课程的学习笔记,并在我自身的理解上整理而成.
此外我的学习经验为,视频课程提供主线思维,详细操作自己查博客,只看视频不行。自己根据自己的节奏学习完毕后再看视频,看看自身的知识点是否全部掌握,如果可以就下一课,如果不够就继续自己查自己学。
并且不要急,课上讲的有些东西,可能要花10倍以上时间去练习,这是值得的。

第一章mybatis第一讲-讲解mybatis的基础和高级应用

Mybatis简介

Mybatis是一个持久层1框架,封装了JDBC代码,在开发过程重用于简化那些重复的动作,并且起到规范的处理一些系统重要事物的作用,另外由于多人使用,在传递和整理上更为简便易于维护。

持久层、业务层、表现层是常用的软件开发3层结构。
表现层:springMVC
业务层:spring
持久层:mybaits

Mybatis初步使用

(我现有案例是springmcv架构,mybaits得重新构建一个。也可以考虑整合两个架构,但是初步分析得出需要有较高的熟练度,因此我进行一次本地搭建项目)

编写流程
1, 全局配置文件SqlMapconfig.xml
2, 映射文件 xxxMapper.xml
3, dao代码/mapper代码(接口API)
4, POJO类(传递参数和结果对象)
5, 测试类

初步准备
首先创建一个工程(如果有就不必创建)
并且还需要有一个可以登录的数据库(没有数据库要这个框架干什么)

maven工程建立(eclipse):

选择新建maven project

选择新建maven project

选择适合自身的模板

选择适合自身的模板

创建包名

创建包名

添加maven依赖

在pom.xml文件中添加servlet-api依赖

<dependency>
	<groupId>javax.servlet</groupId>
	<artifactId>javax.servlet-api</artifactId>
	<version>3.1.0</version>
	<scope>provided</scope>
</dependency>

右击项目名–>maven -update project —>勾选force update,然后-确定

当前目录状态的结果如下:

当前目录状态

==发现问题 ==
从结构上发现没有java文件(src/main/java 文件夹)
解决方案
右击项目—>properties–>java build path

java构建路径图

选中然后先点击右侧的-移除,再点击-add Folder,
在main路径下创建新文件夹:
新建文件夹

填写文件夹名为java,并点击确定。
成果如下:
完整路径

可以开始编程了

添加mybatis相关依赖

但是开发mybatis还需要导入一些包:

	<dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis</artifactId>
        <version>3.4.5</version>
</dependency>

     <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.6</version>
</dependency>

编写全局配置文件SqlMapconfig.xml

src/main/resources文件夹中,创建Mybatis的主配置文件SqlMapConfig.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="mysql">
 	    <!--配置mysql的环境-->
        <environment id="mysql">
  		   <!--配置事务的类型-->
            <transactionManager type="JDBC"></transactionManager>
		   <!--配置连接池-->
            <dataSource type="POOLED">
				<!--配置连接数据库的4个基本信息-->
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://127.0.0.1:3307/cloudserver"/>
                <property name="username" value="root"/>
                <property name="password" value=""/>
            </dataSource>
        </environment>
    </environments>

<!--指定映射配置文件的位置,映射配置文件指的是每个dao独立的配置文件-->
    <mappers>
        <mapper resource="com/bao/mybatisTest/mapper/UserMapper.xml"/>
    </mappers>
</configuration>

目的是为了连接这个数据库,本次以操作user表为例子:
目标数据库表

映射文件 xxxMapper.xml

UserMapper.xml
根据SqlMapconfig.xml里的设置放置在路径(或者是先创建映射文件然后再同步路径):
resource->com->bao ->UserMapper.xml

<?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">
<!--namespace:用来区别不同的类的名字 -->
<mapper namespace="baotest">

    <!-- 通过Id查询一个用户   -->
    <select id="findUserById" parameterType="Integer" resultType="com.ben.domain.User">
        select * from user where id = #{v}
    </select>
</mapper>
核心条目:

    <!-- 通过Id查询一个用户   -->
    <select id="findUserById" parameterType="Integer" resultType="com.bao.domain.User">
        select * from user where id = #{v}
    </select>

注意此处的关键细节:

1,	id="findUserById"				(唯一标识,类似方法名)
2,	namespace="baotest"          (类似包名)
3,	select                         (表示查询)
4,	parameterType="Integer"       (输入参数类型)
5,	resultType="com.bao.domain.User" (类型结构来自这个实体类)
6,	*		(返回全部信息)
7,	#{v}  (绑定输入,当基本类型或string时,填什么无所谓,当为pojo类型时,必须和类型一致)
8,	from user where (从user表中查询)

后续在dao实现类中进行对接。

dao代码/mapper代码(接口API)

我对于dao和mapper不太了解,但是以我的认识,我应该是采用了mapper的方式,但是课件里都称呼为dao。(解决疑惑后我明确我使用的方法为dao方法)
存疑:dao和mapper的区别?

代码:
接口文件

package com.bao.mybatisTest.mapper;

import com.bao.mybatisTest.domain.User;

public interface UserMapper {
	/**
	 * find user by user id 
	 * @param id
	 * @return
	 */
	User findUserById (int id);
}

代理文件

package com.bao.mybatisTest.mapper;

import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSession;

import com.bao.mybatisTest.domain.User;

public class UserDaoImpl implements UserMapper{

	@SuppressWarnings("unused")
	private SqlSessionFactory sqlsessionFactory;

	//注入sqlsessionFactory
	public UserDaoImpl(SqlSessionFactory sqlsessionFactory) {
		this.setSqlsessionFactory(sqlsessionFactory);
	}
	public void setSqlsessionFactory(SqlSessionFactory sqlsessionFactory) {
		this.sqlsessionFactory = sqlsessionFactory;
	}
	
	public User findUserById(int id) {
		//sqlsessionFactory工具类创建sqlsession会话
		SqlSession sqlSession= sqlsessionFactory.openSession();
		//sqlsession 接口,借此进行数据库操作
		User user = sqlSession.selectOne("baotest.findUserById",id);
		//释放资源
		sqlSession.close();
		return user;
	}
}
dao和mapper的区别

mapper方法是dao方法的升级版,编写步骤类似,但是省去了Dao实现类。
在dao方法中, 开发者需要编写DAO接口类和DAO实现类
需要向DAO实现类中注入 SqlSessionFactory,在方法体内通过 SQLSessionFactory创建 SQLSession
而在mapper方法中,开发者需要编写mapper.xml映射文件类
开发者需要mapper接口 需要遵循一些 开发的规范,mybatis可以自动生成mapper实现类的代理对象
因此,我以上代码开发方式为dao方式,后续可以优化为mapper方式
修改为:

<mapper namespace="com.bao.mybatisTest.UserMapper">

    <!-- 通过Id查询一个用户   -->
    <select id="findUserById" 
		    parameterType="Integer" 
		    resultType="com.bao.mybatisTest.domain.User">
        select * from user where UserID = #{v}
    </select>
</mapper>

POJO类(传递参数和结果对象)

课件里貌似没说,先跳过

什么是pojo类?
什么是SqlsessionFactory?
存疑。

pojo类

实体类,比如User。后续有讲

SqlsessionFactory

3个要素:SqlSessionFactoryBuilder、SqlsessionFactory、SqlSession
通过 SqlSessionFactoryBuilder创建会话工厂 SqlSessionFactory
通过 SQLSessionFactory创建 SqlSession

  • SqlSession是mybatis库里提供的一个接口,提供了很多操作数据库的方法,用于调用dao接口;但是SqlSession线程不安全,最好的应用场景为方法内作为局部变量被调用。
  • SqlSessionFactory可以单例管理(只创建一次,之后持续使用)
  • SqlSessionFactoryBuilder不需要单例管理,需要使用时直接new

测试代码

简单测试一下:

package com.bao;

import java.io.IOException;
import java.io.InputStream;

import com.bao.mybatisTest.domain.User;
import com.bao.mybatisTest.mapper.UserDaoImpl;

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

public class Test {
    public static void main(String[] args) {

    	
    	
    	try {
			testSearchById();
		} catch (IOException e) {
			e.printStackTrace();
		}
    }
    
    //通过Id查询一个用户
    public static void testSearchById() throws IOException {
        //1.读取配置文件
        InputStream in = Resources.getResourceAsStream("SqlMapConfig.xml");
        //2.创建SqlSessionFactory工厂
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(in);
        //3.使用工厂创建接口类对象
        UserDaoImpl userDaoImpl = new  UserDaoImpl(sqlSessionFactory); 
        //4.调用接口方法
        User user = userDaoImpl.findUserById(1147);
        //5. 打印结果
        System.out.println(user.getName());

        in.close();
    }
}

打印如下:

111

对应的数据库条目:
数据库条目

运行成功并且数值正确,至此完成本期任务以Mybatis架构操作数据库

mapper代理开发方式

dao方法目前已经相对落后了,因此采用更为简便的mapper方式进行开发。

代理理解

代理分为静态代理动态代理,mybaits中使用的代理方式为动态代理
而动态代理又分两种方式 :
基于JDK基于CGLTB.
区别在于针对有接口的类采用JDK,而针对子类继承的方式采用CGLTB
mybaits支持这2种方式,但是mapper方式使用jdk方式进行代理

XML方式

很方便,
开发时只需要编写mapper接口(java)和mapper映射文件(xml),不需要实现类
开发时需要遵循开发规范,总共有4条(后3条跟dao一样):

  1. mapper接口路径和映射文件中的namespace相同(关键)
  2. mapper接口方法名和映射文件定义的statement的id相同(跟dao一样)
  3. 输入参数类型一致(跟dao一样)
  4. 返回参数类型一致(跟dao一样)

后3条简单记:(输入输出和方法名一致)

测试代码

修改映射文件为:

<mapper namespace="com.bao.mybatisTest.mapper.UserMapper">

    <!-- 通过Id查询一个用户   -->
    <select id="findUserById" 
		    parameterType="Integer" 
		    resultType="com.bao.mybatisTest.domain.User">
        select * from user where UserID = #{v}
    </select>
</mapper>

修改test类新增方法

    //通过Id查询一个用户,mapper方式
    public static void testSearchById2() throws Exception {
        //1.读取配置文件
        InputStream in = Resources.getResourceAsStream("SqlMapConfig.xml");
        //2.创建SqlSessionFactory工厂
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(in);
        //3.创建UserMapper对象
        SqlSession sqlSession= sqlSessionFactory.openSession();
        UserMapper userMapper =  sqlSession.getMapper(UserMapper.class);
        //4.调用UserMapper对象的API
        User user = userMapper.findUserById(1147);
        //5. 打印结果
        System.out.println(user.getName());
        //6. 释放资源
        sqlSession.close();

        in.close();
    }

测试完毕结果一致

注解方式

更简单
只需要编写mapper接口文件
但是课上没详讲,根据课件摸索一下
讲师表示,不建议采用这种方式,因为开发原则不建议频繁修改持久层代码,因此这部分内容应写入配置文件用xml方式代理。
我觉得合理。

测试中:
发现故障:
is not known to the MapperRegistry
研究后得出
课件里没讲一个操作(可能我漏看了)需要在SqlMapconfig.xml里指定映射配置文件(类)。
如下:

<mappers>        <mapper class="com.bao.mybatisTest.mapper.AnnotationUserMapper"></mapper>
    </mappers>

测试代码

    //通过Id查询一个用户注解方式
    public static void testSearchById3() throws Exception {
        //1.读取配置文件
        InputStream in = Resources.getResourceAsStream("SqlMapConfig.xml");
        //2.创建SqlSessionFactory工厂
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(in);
        //3.创建UserMapper对象
        SqlSession sqlSession= sqlSessionFactory.openSession();
        AnnotationUserMapper userMapper =  sqlSession.getMapper(AnnotationUserMapper.class);
        //4.调用UserMapper对象的API
        User user = userMapper.findUserById(1147);
        //5. 打印结果
        System.out.println(user.getName());
        //6. 释放资源
        sqlSession.close();

        in.close();
    }

成功。

全局配置文件

什么是全局配置文件,不知道。课上没详细讲,初步认识是,名称为SqlMapConfig.xml的文件。
找了一下,发现自己有写:
全局配置文件

在这个文件里的标签有且只能按如下顺序排列

  • properties(属性)
  • settings(全局配置参数)
  • typeAliases(类型别名)
  • typeHandlers(类型处理器)–Java类型–JDBC类型—> 数据库类型转换
  • objectFactory(对象工厂)
  • plugins(插件)–在Mybaits执行SQL语句的流程中,插入实现一些功能增强的效果,比如PageHelper分页插件(这是什么,没用过,以后研究)
  • environments(环境集合属性对象)
    • environment(环境子属性对象)
      • transactionManager(事务管理)
      • dateSource(数据源)
  • mappers(映射器)

在我目前的测试工程里,只使用了environmentsmappers里的功能和子标签
但是这些不重要,重点关注这3个:
propertiestypeAliasesmappers
剩余的
settings,在后面课程里的缓存和延时加载时会有使用

properties标签

用于加载数据库的相关信息,我之前是采用直接赋值的方法配置,但是这种方式在后期维护上就显得不太方便,因此可以采用property标签来进行优化。
总体分两步,
第一步在classpath下定义db.properties文件
db

放在这里。
然后输入相关参数:

#sql
jdbc.driver    = com.mysql.jdbc.Driver
jdbc.url       = jdbc:mysql://127.0.0.1:3307/cloudserver
jdbc.username  = root
jdbc.password  = 

再修改文件
SqlMapConfig.xml里对应的标签为:


            <dataSource type="POOLED">
				<!--配置连接数据库的4个基本信息-->
                <property name="driver" value="${jdbc.driver}"/>
                <property name="url" value="${jdbc.url}"/>
                <property name="username" value="${jdbc.username}"/>
                <property name="password" value="${jdbc.password}"/>
            </dataSource>

测试一下,bug了

org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: java.sql.SQLException: Error setting driver on UnpooledDataSource. Cause: java.lang.ClassNotFoundException: Cannot find class: ${jdbc.driver}
### The error may exist in com/bao/mybatisTest/mapper/UserMapper.xml
### The error may involve com.bao.mybatisTest.mapper.UserMapper.findUserById
### The error occurred while executing a query

咋回事呢,发现漏了一句:<properties resource = "db.properties"/>
还是有错:Could not find resource db.properties
研究后发现,对于maven方式创建的工程,默认的resource路径放在:
main中的resources目录里,而不是总目录
,因此修改如下:
newdb

测试运行,运行成功。
以上就是properties标签功能的简单运用。通过加载对应文件的内容来设置参数。此外还可以直接设置子标签内的参数(原本就是这么写的,不做演示了)
总结,properties标签的功能在于以key-value的方式去加载数据
此外,细节
properties标签配置数据时,优先读取直接设置的参数,再读取配置文件里(以url或resource方式)的参数,而在遇到参数名相同的情况时,后读取的会覆盖先读取的值。(简单的说就是配置文件里的可以覆盖直接定义的值
另外还会受到到系统环境同名变量的干扰(具体怎么冲突没说)因此在.properties文件里定义变量时,需要加入前缀,防止同名(例如:jdbc.username)。

typeAliases标签

作用:用于简化映射文件(mapper.xml)中parameterType和resultType
默认支持表。(略)
使用这个标签进行进化(举例)
(出了bug:元素类型为 "configuration" 的内容必须匹配 "(properties?,settings?,typeAliases?,typeHandlers?,objectFactory?,objectWrapperFactory?,reflectorFactory?,plugins?,environments?,这是我放错位置导致的,在全局配置文件里,标签的顺序是固定的)

	<typeAliases>
    	<!-- 单个定义 -->
    	<typeAlias alias= "m_User" type="com.bao.mybatisTest.domain.User"/>
    	<!-- 群体定义 -->
    	<package name="com.bao"/> 
</typeAliases>

此时映射文件里的语句可以改为:

    <!-- 通过Id查询一个用户   -->
    <select id="findUserById" 
		    parameterType="Integer" 
		    resultType="m_User">
        select * from user where UserID = #{v}
    </select>

在上述修改中,演示了单体定义的效果,
而群体定义的规则为:将包名下的所有类生成,类名首字母小写的别名,例如将com.bao.User,自动生成user。当我定义<package name="com.bao"/>时(效果不演示了)

mappers标签

总共有4种用法

  • resource (一般用法)
  • class (注解用法,额外需要:映射文件和接口文件名称相同且在同一目录下)
  • url (绝对路径用法,通常弃用)
  • package (一般用法的批量版本,常用,额外需要:映射文件和接口文件名称相同且在同一目录下)

代码示例:
以下定义的是同一个文件(但是不要同时用多种方式定义同一文件,以下仅做演示)

<mappers>
      <mapper resource="com/bao/mybatisTest/mapper/UserMapper.xml"/>
</mappers>
<mappers>
   <mapper class="com.bao.mybatisTest.mapper.AnnotationUserMapper"/>		   
</mappers>
<mappers>
    <package name="com/bao/mybatisTest/mapper"/> 
</mappers>

测试时发现:由于package方法可以包含同类型下的resource方法和class方法,所以不能共存,但是resource和class独立,可以共存。由于package方式可以批量的添加映射文件,所以常用这种方式

映射文件

输入映射parameterType

parameterType属性可以映射的输入参数java类型有:
简单类型、POJO类型(啥意思?自己定义的类型吗,有空解惑)、Map类型,List类型(数组)
Map类型和POJO类型用法类似,因此主讲POJO
List类型在后续动态SQL部分进行讲解

简单类型

比如,int型,简单,略
简单类型的进阶使用

    <!-- 通过名称模糊查询用户   -->
    <select id="findUserByName" 
		    parameterType="String" 
		    resultType="m_User">
        select * from user where UserName like '%${value}%'
    </select>

当查询结果返回值为一个list时,只需要关注list内的对象类
当使用String格式模糊查询时候,会需要使用${},因为如果使用#{}则系统会在转换参数时,再额外加入一对单引号
比如输入为李四时,合成的查询语句就会变成:
select * from user where UserName like '%'李四'%'
这显然不是所需要的
${}则不会额外加入内容。
使用${}这种方式,需要满足2个条件,

  • 参数名为value
  • 并且输入参数为简单类型

POJO类型

需要将pojo类型设置为输入参数的情况,通常是向sql内添加数据,在这种情况下,需要使用标签:insert
这部关键的点在于从自定义的POJO类中,取出需要的参数进行SQL语句拼合

    <insert id="insertUser"
    		parameterType="m_User">
    	<selectKey keyProperty = "Userid" order ="AFTER" resultType="Integer" >
    		select LAST_INSERT_ID()
    	</selectKey>
    	insert into user(Userid,RegNo,Name,State)	
    	values(null,#{RegNo},#{Name},#{State})
    </insert>
    

在查询语句中添加了一份主键返回的功能,其中的selectKey和后面的insert语句的先后顺序参数 order来决定,此时就是先insert再selectKey

selectKey的返回值需要来自于pojo类里定义的属性
在insert语句中,必须使用#{}而且参数名也必须和POJO里的类名一致,也就是3处一致
LAST_INSERT_ID() 是mysql里的一个库函数

OGNL

  • #{}:是通过反射数据的—StaticSQLSource
  • ${}:是通过OGNL表达式会随着对象的嵌套而相应的发生层级变化(啥意思,以后再研究)–DynamicSQLSource

对象导航语言(用到了再学,先做笔记)

|---User(参数值对象)
	|--userName--张三
	|--birthday
	|--dept – Department
	   |--name

通过OGNL表达式获取Department属性:dept.name
理解:获取对象下属一级属性,直接输入属性名,获取二级属性时,则是一级属性名点二级属性名:A.B;

POJO嵌套类型

课程不详细讲,使用方法很简单当需要用子类的数据而输入参数是父类时,用OGNL表达式从父类对象中取参数。
一般不常使用,用到时要能够理解

输出映射resultType和resultMap

resultType

resultType可以映射的类型有:简单类型POJO类型Map类型
其中Map类型和POJO类型类似,因此省略
使用resultType进行输出映射时,要求sql语句中查询的列名要和映射的pojo的属性名一致
简单举例:

    <!-- 通过Id查询一个用户名   -->
    <select id="findUserNameById" 
		    parameterType="Integer" 
		    resultType="String">
        select name from user where UserID = #{v}
    </select>
	public  String findUserNameById (int id) throws Exception;

注意输出简单类型时,必须查询出的结果集只有一列

resultMap

resultMap存在的目的在于满足查询Sql的查询类名和pojo的属性名不一致的情况。其通过将列名和属性名作一个对应关系,然后将查询结果映射到指定的pojo对象中,而且可以支持输出多列结果集
底层下resultTpye是用resultMap实现的,所以是更为基础的应用方式
同样的也更为繁琐
总体分为两步:
首先建立对应表

    <resultMap type="m_User" id="userListResultMap">
	<!--     
		id标签:查询结果的主键列
    	result标签:查询结果的普通列
	    	column:查询sql的列名
	    	property: POJO类的属性名
	 -->
    	
    	<id column = "id_" property ="userid"/>
    	<result column = "name_" property ="name"/>
    </resultMap>

其次建立语句

    <select id="findUserListResultMap"
    	resultMap="userListResultMap">
    	SELECT userid id_,name name_ FROM user
    </select>

注意,使用这种方法需要单独建立resultMap标签,定义对应关系
后续学延迟加载的时候,也是用这种方法实现

#{}和${}的区别

  • 区别1
    #{} 相当于JDBC SQL 语句中的占位符:?(PrepareStatement)
    ${} 相当于JDBC SQL 语句中的连接符:合 +(Statement)
  • 区别2
    #{} 在输入映射时会对参数进行解析(主要是String类型会自动加单引号)
    ${} 直接输入参数,不做处理
  • 区别3
    #{} 在进行对简单类型的输入映射时,大括号中的参数名可以任意
    ${} 在进行对简单类型的输入映射时,大括号中的参数名只能是value
  • 区别4
    ${} SQL注入会出现问题,使用 OR 1=1 关键字将查询条件忽略(啥意思??)

解惑POJO和${} SQL注入问题

POJO

POJO(Plain Old Java Objects)是简单的Java对象,实际就是bai普通JavaBeans,是为了避免和EJB混淆所创造的简称。
理解:自定义的实体类,比如我的:User

${} SQL注入问题

简单解释:由于是直接输入参数,因此当输入的数据内容可以被组合成SQL语句时,将破坏原本的语句结构,造成不可预计的后果,因此${}建议只采用在String类型输入时使用
如果被迫进行风险编程,则需要在java层进行调用时,更具具体情况做出防御。

关联查询(从这里往下知识变难)

存在表A,和表B存在关联关系,比如两个表里都有用户ID并且指定的用户相同

视屏中举例是在电商等购物网站上下的订单号对应一个客户,而一个客户可以下多个订单

此时存在表A,存放所有用户创建的订单,包含订单号(id)和创建订单的用户(user_id)(外键)等,
存在表B,存放所有下订单的客户信息,包含用户id号(user_id)和其他信息等
所谓外键就是其他表的主键,可以以此建立关联关系,是吗?

  • 那么从表A以单号或者其他条件进行查询时就能获取user_id条目,以这个条目(外键)进行另一个表的信息获取,就是一对一的关联查询。
  • 反过来的话就是可能一对多的关联查询,因为存在一个客户下了多个单的可能性

此处我认为,一对多方法是可以包含一对一查询的,也就是一对一查询法可以理解为关联条件为外键时才能使用,但是有一点我存疑,能不能只是普通条目就进行一对多的关联查询,如果有空验证一下。
为了方便演示,我搭建了2个表,作为实验对象

表A:test_order
表A:test_order

表B:test_user
表B:test_user

一对一关联查询

需求查询所有订单信息,并且关联下单的用户信息。
首先准备SQL语句:

SELECT
	test_order.order_id,
	test_order.user_id,
test_order.buy_info,
	test_user.name
FROM
	test_order LEFT JOIN test_user
	ON test_order.user_id = test_user.user_id

主信息:订单信息
从信息:用户信息

方法一:resultType
学生自己实现(课后我研究一下)

方法二:resultMap

使用resultMap进行结果映射,定义专门的resultMap 用于映射一对一查询结果
首先准备扩展po类:

public class OrderEx extends TestOrder{
	private TestUser testUser;

	public TestUser getTestUser() {
		return testUser;
	}

	public void setTestUser(TestUser testUser) {
		this.testUser = testUser;
	}
}

用于封装结果对象,由于是1对1因此只使用单个TestUser对象存储关联查询的用户信息
然后准备Mapper映射文件

  <resultMap type="OrderEx" id="ordersAndUserRstMap">
  		<!--   方便起见,我采用了相同的名建立对应关系 -->
    	<id column = "order_id" property ="order_id"/>
    	<result column = "user_id" property ="user_id"/>
    	<result column = "buy_info" property ="buy_info"/>
    	
    	<!-- TestUser用了全局配置文件群体定义的别名 
    		testUser 则是封装类里定义的变量-->
    	<association property="testUser"
    	javaType = "TestUser">
    		<id column = "user_id" property ="user_id"/>
    		<result column = "name" property ="name"/>
    	</association>
    	
    </resultMap>
    
    <select id="findOrdersAndUserRstMap"
    	resultMap="ordersAndUserRstMap">
    	SELECT 
			o.order_id,
			o.user_id,
			o.buy_info,
			tu.name
		FROM
			test_order o
		JOIN `test_user` tu ON tu.user_id = o.user_id
    </select>

准备接口函数

public List< OrderEx > findOrdersAndUserRstMap() throws Exception;

完毕测试结果:

    //4.调用Mapper对象的API
    List<OrderEx> orders= mapper.findOrdersAndUserRstMap();
    //5. 打印结果
    System.out.println(orders.toString());

打印信息如下

[OrderExTestOrder [order_id=1, user_id=1, buy_info=apple]
 [testUser=TestUser [user_id=1, name=bob]
]
, OrderExTestOrder [order_id=2, user_id=1, buy_info=iphone]
 [testUser=TestUser [user_id=1, name=bob]
]
, OrderExTestOrder [order_id=3, user_id=2, buy_info=banana]
 [testUser=TestUser [user_id=2, name=edison]
]
, OrderExTestOrder [order_id=4, user_id=2, buy_info=light]
 [testUser=TestUser [user_id=2, name=edison]
]
]

和自身的表对上,合理

研究一下resultType的方法

研究了一段时间一度遇到困境,虽然能够获取order表里的信息,但是无法把信息传入OrderEx类里的TestUser
比如:OrderExTestOrder [order_id=4, user_id=2, buy_info=light] [test_user=null]
深入摸索后,得出结论,
在sql查询语句的运行情况中,是不返回嵌套值的
因此修改如下,
修改封装类:

public class OrderEx extends TestOrder{
	
	private int user_id; 
	private String name;

	public int getUser_id() {
		return user_id;
	}
	public void setUser_id(int user_id) {
		this.user_id = user_id;
	}
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	
	@Override
	public String toString() {
		return "OrderEx"+super.toString()
				+ "test_user [user_id=" + user_id + ", name=" + name + "]\n";
	}
	
}

使得满足resultType传参数的条件:返回列名必须和POJO类的变量名一致
其次创建映射语句:

    <select id="findOrdersAndUserRstType"
    	resultType="OrderEx"
    	>
    	SELECT 
			*
		FROM
			test_order o,test_user tu
		WHERE
		 	tu.user_id = o.user_id
    </select>

这种查询摸索和关联查询效果相同,不清楚实际使用时采用什么方案。
运行结果:

[OrderExTestOrder [order_id=1, user_id=0, buy_info=apple]
test_user [user_id=1, name=bob]
, OrderExTestOrder [order_id=2, user_id=0, buy_info=iphone]
test_user [user_id=1, name=bob]
, OrderExTestOrder [order_id=3, user_id=0, buy_info=banana]
test_user [user_id=2, name=edison]
, OrderExTestOrder [order_id=4, user_id=0, buy_info=light]
test_user [user_id=2, name=edison]
]

与预期相符。
并且此处也发现了sql查询是不返回嵌套值的。
结论
对于复杂情况老实用resultMap,一方面结构更强清晰,另一方面传参更为简单

一对多关联查询

当情况变的更加复杂的时候,就只能使用resultMap(所以老实研究这个,别想做简化,简单模式下就是resultType,其余情况全都用resultMap

一对多关联查询的情况,通常需要将对象封装,因此resultMap的结构更为合理合适,首先准备SQL语句

    	SELECT
			u.*,
			o.order_id,
			o.buy_info
		FROM
			`test_user` u
		LEFT JOIN test_order o ON o.user_id = u.user_id

(存疑这种方式和这样的语句的区别是什么,有空试试)

SELECT
	u.*,
	o.order_id,
	o.user_id,
	o.buy_info
FROM
	`test_user` u , `test_order` o
WHERE 
o.user_id = u.user_id

然后扩充po类:

public class UserEx extends TestUser{
	private List<TestOrder> orders;

	public List<TestOrder> getOrders() {
		return orders;
	}

	public void setOrders(List<TestOrder> orders) {
		this.orders = orders;
	}

	@Override
	public String toString() {
		return "UserEx "
				+ super.toString()
				+ "[orders=" + orders + "]";
	}

}

最后准备映射文件和接口文件类

   <resultMap type="UserEx" id="UsersAndordersRstMap">
  		<!--   方便起见,我采用了相同的名建立对应关系 -->
  		<!-- 用户信息映射 -->
    	<id column = "user_id" property ="user_id"/>
    	<result column = "name" property ="name"/>
	
    	<!-- 一对多关联映射 -->
    	<collection property="orders"
    	ofType = "TestOrder">
    		<id column = "order_id" property ="order_id"/>
    		<result column = "user_id" property ="user_id"/>
    		<result column = "buy_info" property ="buy_info"/>
    	</collection>
    </resultMap>
    
    <select id="findUsersAndOrderRstMap"
    	resultMap="UsersAndordersRstMap">
    	SELECT
			u.*,
			o.order_id,
			o.buy_info
		FROM
			`test_user` u
		LEFT JOIN test_order o ON o.user_id = u.user_id
    </select>  

这里关注一个知识点,一对多关联映射使用标签为collection
并且 用ofType标注POJO类
(一对一则是javaType,)
其余基本不变。
我猜想,一对多模式下,应该也能包含一对一的情况,那么能不能只记忆一对多的写法呢。于是我认为在底层源码上,一对一可能效率更高。

运行语句(前后略)

		List<UserEx> userEx= mapper.findUsersAndOrderRstMap();
        //5. 打印结果
        System.out.println(userEx.toString());

结果:

[UserEx TestUser [user_id=1, name=bob]
[orders=[TestOrder [order_id=1, user_id=1, buy_info=apple]
, TestOrder [order_id=2, user_id=1, buy_info=iphone]
]], UserEx TestUser [user_id=2, name=edison]
[orders=[TestOrder [order_id=3, user_id=2, buy_info=banana]
, TestOrder [order_id=4, user_id=2, buy_info=light]
]]]

另一种写法的结果:

[UserEx TestUser [user_id=1, name=bob]
[orders=[TestOrder [order_id=1, user_id=1, buy_info=apple]
, TestOrder [order_id=2, user_id=1, buy_info=iphone]
]], UserEx TestUser [user_id=2, name=edison]
[orders=[TestOrder [order_id=3, user_id=2, buy_info=banana]
, TestOrder [order_id=4, user_id=2, buy_info=light]
]]]

一致(所以我不懂二者的区别,等我境界高了再研究
后续研究,根据后续课程延迟加载的定义,我认为第二种查询方式可能是直接查询无法实现延迟查询。真的吗?存疑有空研究。
结论
一对多模式只能使用resultMap做返回属性定义,并且内部封装类要用
collection标签

(不难,我学会了)

延迟加载

定义

(抄课件)
Mybatis中的延迟加载,也称为懒加载,是指在进行关联查询时,按照设置延迟规则推迟对关联对象的select查询,延迟加载可以有效的减少数据库压力。(说明优势)
Mybatis的延迟加载,需要通过resultMap标签中的associationcollection子标签才能延时成功(为什么?这个两个标签不是关联查询时用的吗
Mybatis的延迟加载,也被称为是嵌套查询,对应的还有嵌套结果的概念,可以参考一对多关联的案例(这么看来对于下层来说可能是resultMap标签一种功能的不同执行方式
注意:MyBaits的延迟加载只是对关联对象的查询有延迟设置(所以用resultMap?),对于主加载对象都是直接执行查询语句的

分类

Mybatis根据对关联对象查询的select语句的执行时机,分为3种类型:直接加载、侵入式延迟加载与深度延迟加载

  • 直接加载:执行完主对象的查询,马上进行关联对象的查询(也就是不延迟)
  • 侵入式延迟加载:执行对主加载对象的查询时,不会执行对关联对象的查询,但是要访问主加载对象的某个属性时(该属性不是关联对象的属性,此处我猜想是应该是任何属性的意思,有空研究),就会马上执行关联对象的查询
  • 深度延迟加载:执行对主加载对象的查询时,不会执行对关联对象的查询。只在访问真正的关联对象的详情时,才会进行对关联对象的查询

这3个层次就是3种关联查询的启动标准,直接,访问时,访问关联对象时。我是这么认为的

延迟加载策略需要在Mybaits的全局配置文件(SqlMapConfig.xml)中,通过<settings>标签进行设置。

直接加载

配置参数:lazyLoadingEnabled = false;
相当于没有配置。不配置就是直接加载

	<settings>
		<setting name="lazyLoadingEnabled" value="false"/>
	</settings>

侵入式延迟加载

配置参数:lazyLoadingEnabled = true;
配置参数:aggressiveLazyLoading = true;

	<settings>
		<setting name="lazyLoadingEnabled"    value="true"/>
		<setting name="aggressiveLazyLoading" value="true"/>
	</settings>

深度延迟加载

配置参数:lazyLoadingEnabled = true;
配置参数:aggressiveLazyLoading = false;

	<settings>
		<setting name="lazyLoadingEnabled"    value="true"/>
		<setting name="aggressiveLazyLoading" value="false"/>
	</settings>

案例准备
查询订单信息以及他的下单用户信息。
测试表A和B

[UserEx TestUser [user_id=1, name=bob]
[orders=[TestOrder [order_id=1, user_id=1, buy_info=apple]
, TestOrder [order_id=2, user_id=1, buy_info=iphone]
]], UserEx TestUser [user_id=2, name=edison]
[orders=[TestOrder [order_id=3, user_id=2, buy_info=banana]
, TestOrder [order_id=4, user_id=2, buy_info=light]
]]]

运行了一下,没发现什么变化,这种优化的手段需要特殊的情况下才能发现,在这种简单的测试环境里,看不出差别,
学完了

N+1问题(拓展研究)

延迟加载的目的是为了减轻数据库压力,但是在部分情况下可能会增大对数据库的压力
当延迟加载的表数据太多时,由于采用延迟加载测量,因此从表的信息查询会每一条被访问的数据,进行多次查询
这种情况下应该怎么办?如果出现这种设计场景,应该尽力采用单次查询的方法。

Mybatis缓存

介绍

Mybatis提供查询缓存,如果缓存中有数据就不用从数据库中获取,用于减轻数据压力,提高系统性能
Mybatis的查询缓存有2级,称为一级缓存和二级缓存

  • 一级缓存是SQLSession级别的缓存,操作数据时生成SQLSession对象时会生成一个数据结构(HashMap)用于存储缓存数据,不同对象间数据区域不共享(因此SqlSession对象不使用时需要释放空间)
  • 二级缓存是Mapper(namespace)级别的缓存,多个sqlSession对象去操作同一个Mapper的sql语句时(同一个),可以共用二级缓存。
    二级的范围比一级大。但是二级不太好用,当sqlSession进行数据库变化的操作时,缓存区域也要进行清除,以确保最新的数据能够被获取,因此由于二级缓存范围过大,因此在场景为有变化操作的环境里,会频繁的清除缓存。一级也有同样的问题,但是范围小所以,影响不大

一级缓存

一级缓存是默认开启的,怎么关没有讲,可以认为必然使用
运行逻辑为
查询时,先从缓存里查询,如果没有就从数据库查询,把结果存入一级缓存
当sqlsession执行变化操作时(增删改),清除缓存
演示:
略,自己想想。反正就是进行查找时如果缓存里有就不去数据库里找,如果没有就去。另外根据需求进行缓存更新。

二级缓存

二级缓存是默认不开启的,想要开启需要操作核心配置文件
添加setting条目:
<setting name="cacheEnabled" value="true"/>
然后在想要使用二级缓存的mapper文件添加:
<cache></cache>
并且还要为二级缓存的目标实现序列化(准备一个实例)
为需要缓存的实体类,实现序列化(如果该类有父类,则父类也需要序列化)
implements Serializable
(我之前创建的都有序列化,原来是这个意思)
演示:
略。
禁用二级缓存和刷新二级缓存
略。

应用场景

要求较高的响应速度,但是实时性要求不是很高时。
注意,使用二级缓存时还需根据需求设置刷新间隔,详细操作略。

局限性

二级缓存的缺点之前也说了,如果目标众多,而单一目标的修改需要清除全部缓存,比较浪费。
对此可以有解决方案,就是确保缓存对象不会被变化,将需要变化或者可能的变化的对象,放入另一个映射文件去处理

动态SQL(重点知识)

目的:完成字符串的拼接处理、循环判断。
(sql的逻辑处理)
解决的问题:

  1. 在映射文件中,会编写很多有重叠部分的SQL语句
  2. SQL中where条件有多个,但是页面参数不确定

总结:用逻辑来处理和简化问题
这里我提出一个问题:为什么不用java逻辑来实现简化问题,我认为逻辑方面还是交给逻辑代码更合适,求解。

if标签

例如处理用户名问题

如果是数值可以判断是什么数
如果是字符传可以判断是否为空,通常包括null和0长度字符串情况
演示代码

	<select id="findUserByName"
    	resultType="TestUser"
    	parameterType = "ALL_User"
    	>
    	SELECT 
			*
		FROM
			test_user
		WHERE
		 	1=1
		<if test="m_User != null">
			<if test="m_User.name != null and m_User.name != ''">
				AND name like '%${test_user.name}%'
			</if>
		</if>
    </select>

这段演示代码的入参的结构为ALL_User.m_User.name(OGNL)依次判断里面的m_User对象然后继续拼接

当这些if标签内的test的属性语句运算为时才会允许拼接下面的
而此处where后的1=1目的是为了保证语法的正确性,因为sql语句可能拼接,也可能不拼接,设计时需要注意。
但是这种写法会让sql执行是多一个步骤,而用上where标签则可以优化这个问题

where标签

使用where标签后相同功能的代码可以改写为这样:

   <select id = "findUserByName2"
    	resultType="TestUser"
    	parameterType = "ALL_User"
    	>
    	SELECT 
			*
		FROM
			test_user
		<!-- where标签相当于where并且会自动处理后面连接的AND -->
		<where>
			<if test="m_User != null">
				<if test="m_User.name != null and m_User.name != ''">
					AND name like '%${test_user.name}%'
				</if>
			</if>
		</where>
    </select>

存疑,上述的写法我能不能手动把AND去掉呢
验证后得出可以去掉,但是有不影响运行,这里有AND的目的为了方便可能的连续的if标签组使用
结论:if标签建议配上where标签使用

SQL片段

在某些情况下,多个sql语句的部分内容是完全相同的,例如在多种情况下查询同一个表,查的表和一些条件是相同的,如果重复的写,会使得映射文件篇幅较大。

因此可以使用SQL片段的方式来进行省略。
(这个功能和C语言中的宏定义函数块类似,都是以某种方式,让简单化的信息代替重复的复杂信息。)

使用方法如下
举例:

    <sql id="equal_name">
		<if test="m_User != null">
			<if test="m_User.name != null and m_User.name != ''">
				AND name like '%${test_user.name}%'
			</if>
		</if>
    </sql>
    
    <select id="findUserByName3"
    	resultType="TestUser"
    	parameterType = "ALL_User"
    	>
    	SELECT 
			*
		FROM
			test_user
		<where>
			<include refid="equal_name"></include>
		</where>
    </select>

用法:
在定义时用sql标签,在使用是用include标签
当引用其他映射文件里的片段时需要在前面加上文件名,例如
当其他文件引用上文定义的片段时,include标签要这么写:

<include refid="com.bao.mybatisTest.mapper.TestMapper.equal_name"></include>

foreach标签

表示遍历,当使用的输入映射里有个集合时,但是需要对集合内的每一个对象进行参数判别来形成查询条件时(比如我抽奖时,需要从库中取得座位号是若干号码的情况,此时我传入一个类中有个id集合表号码)

这种情况下可以又SQL语句来实现:
假定集合的内容是,{5,9,11}
那么最终需要拼合语句就是
where id in (5,9,11)
那么SQL的判别语句用foreach来实现就是如下:

<if test="ids != null and ids.size() > 0">
			<foreach collection="ids"
					 item = "id"
					 open = " id IN ( "
					 close = " ) "
					 separator = ","
			>
				#{id}
			</foreach>
		</if>
  • collection 表示输入参数的集合名称(输入POJO类中定义的集合元素的名字)
  • item 表示声明集合内参数的变量名(这个名称是自定义的,但是需要和下方的入参关联)
  • open 表示循环开始时拼接的字符串
  • close 表示循环结束时拼接的字符串
  • separate 表示循环每执行一步后拼接的字符串

结论:foreach标签可以将入参中的集合拆解,顺次提取出内部的元素,然后根据需求进行字符串拼接

注意:如果,入参不是POJO类而是一个List或者Array时,foreach标签内的collection的属性为对应的List或者Array,包括if语句里的数组名(也就是所有出现的数组名改为List或者Array,因为没有这个概念了)

Mybatis逆向工程工具(略过)

介绍

逆向工程是使用官方网站的Mapper自动生成工具mybatis-generator-core-1.3.2来针对单表生成PO类(Example)和Mapper接口和Mapper映射文件
我认为我中短期用不到,因此本课简单略过。后续用到了再做专门的说明

使用方法

  • 第一步准备一个工程mybatis-generator去官网下载
  • 第二步修改工程的配置文件generatiorConfig.xml
  • 第三步运行工程,以java方式

修改配置文件

下载完成模板后,主要需要做的就是修改配置文件
generatiorConfig.xml中配置Mapper生成的详细信息,配置时主要以下细节:

  1. 生成的数据库表
  2. POJO文件所在的包路径
  3. Mapper所在的包路径

配置文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<!-- 配置生成器 -->
<generatorConfiguration>
	<context id="test_order2" targetRuntime = "MyBatis3">
		<commentGenerator>
			<!-- 是否去除自动生成的注释 true :是 :false :否 -->
			<property name="suppressAllComments" value="false"/>
		</commentGenerator>
		<!-- 数据库连接信息 -->
		<jdbcConnection driverClass="com.mysql.jdbc.Driver" 
			connectionURL="jdbc:mysql://127.0.0.1:3307/cloudserver"
			userId = "root"
			password = ""/>
		<!-- 默认为false,将JDBC EDCIMAL和NUMERIC 解析为int,为true时,二者解析为java.math.BigDeciaml -->		
		<javaTypeResolver>
			<property name="forceBigDecimals" value="false"/>
		</javaTypeResolver>
		
		<!-- 重点关注以下 -->
		<!--javaModelGenerator的 targetProject:生成PO类的位置 -->
		<javaModelGenerator targetPackage="com.bao.mybatisTest.domain" 
			targetProject=".\src">
			<!-- enableSubPackages:是否让schema作为包的后缀 -->
			<property name="enableSubPackages" value="false"/>
			<!-- 从数据库返回值被清除前后的空格 -->
			<property name="trimStrings" value="true"/>
		</javaModelGenerator>
		
		<!--sqlMapGenerator的 targetProject:生成映射文件的位置 -->
		<sqlMapGenerator targetPackage="com.bao.mybatisTest.mapper" 
			targetProject=".\src">
			<!-- enableSubPackages:是否让schema作为包的后缀 -->
			<property name="enableSubPackages" value="false"/>
		</sqlMapGenerator>
		
		<!--javaClientGenerator的 targetProject:生成接口的位置 -->
		<javaClientGenerator targetPackage="com.bao.mybatisTest.mapper" 
			targetProject=".\src"
			type = "XMLMAPPER">
			<!-- enableSubPackages:是否让schema作为包的后缀 -->
			<property name="enableSubPackages" value="false"/>
		</javaClientGenerator>
		
		<!-- 指定数据库内表 -->
		<table tableName = "test_order"/>
		
	</context>
</generatorConfiguration>

总结:
下载工程或者准备相关环境,修改配置文件,关键修改数据库连接信息,修改生成的POJO类映射文件接口文件的路径,以及需要转换的表
十分简单。
但是我不想进行相关练习,因为目前我不太有必要这么做。如果后续我有必要进行类似的操作时,我会记录当时的结论。

PageHelper分页插件

开学。

简介:

  • 5.1.6版本好用,建议用,几乎支持目前所有的关系型数据库
  • PageHelper是一个第三方插件,通过mybatis对外开发的接口进行实现

使用方法

首先添加依赖

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

其次配置全局配置文件

    <plugins>
		<plugin interceptor="com.github.pagehelper.PageInterceptor">
			<!-- 指定为mysql生成代码 -->
			<property name="helperDialect" value="mysql"/>
		</plugin>
	</plugins>

当和spring架构整合后,改为配置spring配置文件,本次不提

最后使用

在查询语句前
加入: PageHelper.startPage(pageNum, pageSize);

演示结构如下:

		//4.1添加分页代码
        int pageNum  = 1; //取第一页
        int pageSize = 2; //分页规则,每2个元素就分一页
        PageHelper.startPage(pageNum, pageSize);
        
        List<TestOrder> orders= mapper.findAllOrders();
        //5. 打印结果
        System.out.println(orders.toString());

最终获得结果为:

Page{count=true, pageNum=1, pageSize=2, startRow=0, endRow=2, total=4, pages=2, reasonable=false, pageSizeZero=false}
[TestOrder [order_id=1, user_id=1, buy_info=apple]
, TestOrder [order_id=2, user_id=1, buy_info=iphone]
]

修改页码为2得出

Page{count=true, pageNum=2, pageSize=2, startRow=2, endRow=4, total=4, pages=2, reasonable=false, pageSizeZero=false}
[TestOrder [order_id=3, user_id=2, buy_info=banana]
, TestOrder [order_id=4, user_id=2, buy_info=light]
]

修改页码为3得出

Page{count=true, pageNum=3, pageSize=2, startRow=4, endRow=6, total=4, pages=2, reasonable=false, pageSizeZero=false}[]

以此可以明确分页的规则,将查询的结果根据每页的数量进行分页,并且根据页码取出该页的内容

并且根据打印可以看出,返回的list不再是ArrayList,
此时返回的是PageHelpert提供的Page对象,这个对象有list接口。
关于这个page对象想要获得内部的页码信息,需要进行一次数据封装

        PageInfo <TestOrder> ordersPageInfo= new PageInfo <TestOrder> (orders);

之后就可以根据PageInfo类内部的方法获取页码信息
举例:

        PageInfo <TestOrder> ordersPageInfo= new PageInfo <TestOrder> (orders);
        
        System.out.println(ordersPageInfo.getPageNum());

打印结果:
1.

结语

以上内容占总课的2%,我从1号学习到了11号,用的是空闲的时间,但是平均每一段都近似花了视频5到10倍的时间,
通常是第一遍,听,第二遍记,第三遍写,然后测试验证,再几遍后,整体过一遍看看有没有记错的地方。
目前基本是掌握了以上的知识点了,继续努力吧。

特殊说明

本文仅做学习用,详细内容请自行购课观看。


  1. ↩︎
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值