一. Spring 内置的 JUnit 框架
在讲解 Mybatis 的标签之前, 要先介绍一下另一个 Java 的好帮手 Spring 框架内置的 JUnit 测试框架. 为什么要在 Mybatis 学习之前了解 JUnit 呢 ? 很大一部分原因不仅仅是因为单元测试是写完项目后开发人员自己需要做的, 更重要的是当前阶段学习中, 利用 JUnit 可以更简单的构造数据来帮我们学习 Mybatis 的用法.
可以想象一下, 如果不用 JUnit 我们要怎么去测这个 Mybatis 的标签呢 ? 当我们写好了 SQL 语句过后, 让 Interface 接口暴露出去, 让 service 去调用 Interface 然后再用 controller 去调用 service 一样可以完成, 然后通过访问路由方法, 一样是可以测的. 如果你得功能是在登陆页面之后的某个功能, 你是需要每次去不断授权登陆的才能看, 这将是非常麻烦的.
但是, 当你使用 Spring 内置的 JUnit 框架进行单元测试, 它将替你解决这些问题, 可以跳过授权, 帮你模拟数据等等, 更简单的让你测试你得代码, 堪称一大神器 ! ! !
二. Mybatis 基本标签使用
Mybatis 既然是操作数据库的, 哪最基本的功能肯定是 CURD 即我们所谓的增删改查操作( 和前面的 CURD 不是一一对应, 感兴趣可以自己去看看英文单词 ). 那么同样是增删改查, Mybatis 里和我们直接操作 MySQL 这样的数据库有什么不一样呢 ? 下面就一起来看看
1. <select> 查询标签
查询标签查询操作是非常常见而又基础的操作, 下面来看看这个业务, 根据 id 查询用户 :
如果是在 MySQL 中, 我们写的肯定是 : select * from userInfo where id = ?
那在 Mybatis 中又该如何去写呢 ? 在来复习一次上一篇文章中如何用 mybatis 写一个基本业务
1.1 mybatis 的两个组件之一 ( Interface 接口的方法定义 )
@Mapper public interface UserMapper { // 根据 id 查询用户 UserEntity getUserById(@Param("id") Integer id); }
@Param 注解 来自 org.apache.ibatis.annotations.Param 包底下, 而这里的 ibatis 其实就是现在的 mybatis 的前身, 改名了而已. 这个注解就是提供外部参数给 XML 中的 SQL 语句使用的
比如在 MySQL 中针对刚刚的业务根据 id 查询用户写的 SQL 语句为 :
select * from userinfo where id = ?
这里的 ? 就是占位符, 我们要输入这里的 id 为多少, 在 mybatis 里我们通过传入参数来达到替换这个类似占位符的功能. 只不过写法上不太一样, 下面会看到如何写这个语句
这里最主要就是通过传入 id 这个参数 赋值给 @Parma(“id”) 注解里面这个 id, 注解里的 id 在提供给 XML 中使用
1.2 mybatis 的两个组件之二 ( XMl 文件中对 Interface 接口方法的具体实现 )
同样的, 上一篇文章中说道, <select> 标签最少是需要两个属性的, 一是 id - 接口方法名, 二是返回类型 ( XML 文件中的配置可以查看我上一篇文章复制即可, 是固定的 )
<select id="getUserById" resultType="com.example.demo.entity.UserEntity" > // 此处方法名就是我在 Interface 中定义的查询方法 // 返回类型因为查询的是一个用户, 因此返回的就是该用户 </select>
接着就是构造 SQL 语句了
<select id="getUserById" resultType="com.example.demo.entity.UserEntity" > select * from userinfo where id=${id} </select>
1.3.1. 遵循标准分层调用
- 建立 service 层调用 mybatis 的 Interface 接口,并提供方法供外部调用
@Service // 托管给 Spring 框架 public class UserService { // 如果不托管给 Spring, UserMapper 是无法注入进来的 @Autowired private UserMapper userMapper; public UserEntity getUserById(Integer id) { return userMapper.getUserById(id); } }
- 建立 controller 层调用 service 层的外部调用方法
@RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @RequestMapping("/getId") public UserEntity getUserById() { // 此处需要自己手动传入 id return userService.getUserById(1); } }
- 访问 controller 里的路由方法检验是否正确
先来看看我之前的数据库中的 userinf 表有什么数据,
在看看拿取 id = 2 这个用户, 一样是可以正确拿取的
除了我们去构建 mybatis 的两个组件外, 剩下的都是在去为测试这个 SQL 语句是否正确做铺垫, 过程很麻烦, 既然刚刚说到 JUnit 可以很好地解决这个问题, 那我们就来用用看有多神, 被大家说是神器 !
1.4 JUnit 单元测试
Spring 中是内置了 JUnit 的, 只要你创建的是 Spring 项目, 我们是可以在依赖库中看到的, 现在直接去使用它就可以了.
1.4.1. 生成测试方法
在你需要测试的类下面右键选择 Generate
接着选择跳出来的 Test
Test 里面的这些设置, 只需要选择我们要测试的方法就行, 其他的最好是默认的, 这样可以一目了然的找到我们的测试方法在哪里, 名称是什么等待
选择好后, 就会来到当前界面
最重要的是添加@SpringBootTest 注解, 这个注解时非常重要的, 因为咱们的 UserMapper 是托管给 Spring Boot 框架的, 如果不加这个注解, 该测试方法就不是运行在 Spring Boot 环境下, 无法使用属性注入等方法进行注入属性
添加测试方法的具体实现, 我们测试的是 UserMapper 的接口方法, 因此此处注入 UserMapper
@SpringBootTest // 表明接下来我当前类所有的测试方法将运行 Spring Boot 环境上的 // 如果没有这个注解, 是没有 IoC 的, 是没办法注入的, 无法使用注入对象 class UserMapperTest { @Autowired private UserMapper userMapper; @Test void getUserById() { UserEntity user = userMapper.getUserById(2); System.out.println(user); } }
运行测试方法, 如果点的是方法坐标的一个箭头, 就是测试运行当前方法, 如果点的是类前面的两个箭头, 运行的就是当前这个类里的所有测试方法
绿色打钩显示正确运行, 并且我们也在控制台看到了结果获取到了数据库中用户表里 id 为 1 的那个用户. 相比之下, 比起我们遵循标准分层去调用测试是不是简单的太多了, 只需要生成测试方法就可以了. 不愧为神器 ! ! ! 太香啦~~
1.5 resultMap 字典映射
上面的 标签中, 我们提及了两个属性是必须要写的, 一个是 id 属性, 另一个就是返回类型 resultType.对于 resultType 时, 我们强调的是一定要和数据库中的字段一致. 那么当数据库中的字段和我们定义的实体类属性不一致时, 我们该怎么让它兼容呢 ? 而其中一种方式就是使用 resultMap 字典映射
看下面这个业务场景 : 根据用户名和密码正确登陆后查询当前用户
// 根据用户名和密码正确登陆后查询当前用户 UserEntity login(UserEntity user);
但是此时我的密码 UserEntity 实体类的密码属性已经更改为了pwd 而不是和数据库中对应的 password 了. 在来执行之前的测试方法还能行吗 ?
@Test void login() { String username = "admin"; String password = "admin"; // 构建对象传入 UserEntity inputUser = new UserEntity(); inputUser.setUsername(username); // 设置 UserEntity 用户实体类的账号和密码 inputUser.setPwd(password); UserEntity user = userMapper.login(inputUser); // 接受查询到的结果 System.out.println(user); }
运行测试方法后发现可以正确拿到该用户, 但是密码却丢失了 ! 账号密码是正确的但是返回对象中无法获取到数据库里的对应的密码字段, 这边是因为我们使用 resultType 时返回的实体类的字段必须要和数据库中字段一致的原因. 否则就会拿不到对应的内容.
但是在开发中, 往往需求是很多的, 如果要求我们的 UserEntity 实体类的字段中密码就用 pwd 表示, 而数据库中又只能是 password 该怎么办呢 ? 这就需要前面说到的 resultMap 字典映射了.
字典映射标签中, 有两个必不可少的属性要设置, **同样是 id 但这里表示的是这个字典映射的名称, 而 type 属性表示的是我们需要将数据库映射到那个实体类.
字典映射标里面的 标签中需要设置两个属性, 一个是 property 也就是设置自增主键在实体类中的名称的. 而 column 表示对应到数据库中的那个字段
字典序标签里面的 标签表示设置字段映射. 什么意思呢 ?
也就是说, 想要把数据库中的某张表字段和我们的实体类关联起来就是通过这个标签来设置的
例如 : 设置
<resultMap id="MapDemo" type="com.example.demo.entity.UserEntity"> <id property="id" column="id"></id> // 需要哪些字段就映射那些就好 <result property="username" column="username"></result> <result property="pwd" column="password"></result> </resultMap>
这时候我们在去刚刚的 XML 中修改 resultType 为 resultMap 看看能否成功映射
<select id="login" resultType="com.example.demo.entity.UserEntity"> select id, username, password as pwd, state from userinfo where username=#{username} and password=#{pwd} </select>
再次执行单元测试方法, 这时候 pwd 就成功被映射了, 能够对应到数据库里 userInfo 表中的 password 了.
但是通常我们还是使用 resultType 更多一些, 但 resultMap 也需要掌握, 避免出现类似情况时而没有办法操作.
1.6 MySQL 中字段起别名
除了 resultMap 能解决这个问题以外, 我们又不想麻烦的设置 resultMap 字典映射, 那么我们就可以在 SQL 语句中起别名来达到同样的目的
<select id="login" resultType="com.example.demo.entity.UserEntity"> select id, username, password as pwd, state from userinfo where username=#{username} and password=#{pwd} </select>
执行单元测试方法可以看到, 起别名的方式也是可以解决实体类字段名和数据库字段名不相同的问题了.
2. 参数替换的两种方式
2.1 ${ } 直接替换
刚刚说到, @Parma 注解后, 为 XML 里的 SQL 语句提供了参数, 而这里我们用的是 ${ } 的形式来提供参数, 这种方式叫做直接赋值的形式
<select id="getUserById" resultType="com.example.demo.entity.UserEntity" > select * from userinfo where id=${id} </select>
哪什么是直接替换 ? 为了更好地看清 SQL 的语句执行, 需要配置一下 Mybatis 的执行 SQL 和日志文件
mybatis: configuration: # 配置打印 MyBatis 执行的 SQL log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 配置打印 MyBatis 执行的 SQL 日志 logging: level: # 只设置我们写的代码下的日志 com: example: demo: debug # 默认是 INFO 等级, 此处设置为 debug
配置好后, 再去执行刚刚的单元测试方法, 可以直观的看到, 我们传入的参数 1 被 ${ id } 给直接替换成了 1
2.2 #{ } 预执行替换
除了 ${ } 的方式, 还有一种 #{ } 预执行方式, 哪什么又是预执行方式, 同样我们修改 SQL 语句后再来执行单元测试看看
<select id="getUserById" resultType="com.example.demo.entity.UserEntity" > select * from userinfo where id=#{id} # 注意这里是 #{ } </select>
执行测试后可以看到, 这里的 id = ? 这个问号就是 JDBC 里写 SQL 语句的占位符, 预执行就是下面的 1 ( Integer ), 它会先去获取参数, 然后再进行替换
可以看到, #{ } 和 ${ } 好像是一样的, 他们都是传入了一个整数 1哪他们有什么区别呢 ? 接着往下看
2.3 #{ } 和 ${ } 的区别
2.3.1. 非整形类型无法直接使用 ${ } 而可以使用 #{ }
刚刚我们看到, 传入一个整数的时候, 无论是#{ } 还是 ${ } 的方式都是可以正确获取的, 那么这不是一样嘛 ? 非也, 下面细看这个业务场景 : 还是以用户表里查询一个用户, 只不过现在是根据名称来查询了
还是一样, 要先写 接口方法和 XML 实现
// 关于用户的操作都写在 UserMapper 里面 UserEntity getUserByName(@Param("username") String username);
先用 #{ } 方式
# 关于用户操作的接口方法实现都是在 UserMapper.xml 中 <select id="getUserByName" resultType="com.example.demo.entity.UserEntity"> select * from userinfo where username=#{username} </select>
创建单元测试方法, 在这需要提醒一下大家, 由于我们刚刚的接口方法是写在 UserMapper 里面的, 之前已经生成过这个测试类了, 此时它会报错提醒是否在该类中更新, 确认更新就行
@Test void getUserByName() { UserEntity user = userMapper.getUserByName("admin"); System.out.println(user); }
执行测试方法, 还是和之前一样的预处理, 并且正确返回了查询到的数据
那我们再来看看使用 ${ } 能达到预期效果嘛 ?
<select id="getUserByName" resultType="com.example.demo.entity.UserEntity"> select * from userinfo where username=${username} </select>
再次执行单元测试方法, 发现它报错提示 where 字句错误, 我们再去看看 SQL 语句执行的是什么 ?
一看 SQL 语句想必大家就明白了, 但我们差的是字符串, 应该加上单引号 ’ admin ', 才是正确的语句, 这也就说明了, 当我们是引用数据类型时, 如果用 ${ } 方式就是所见即所得的方法
我们可以主动给它加上单引号看看是不是会正确的, 倒地是不是所见即所得
<select id="getUserByName" resultType="com.example.demo.entity.UserEntity"> select * from userinfo where username='${username}' # 我主动加了 单引号 </select>
可以看到, ${ } 这种直接替换的模式就是所见即所得, 虽然同样可以主动加上单引号解决这个问题, 但是它有另一个严重问题 - SQL 注入问题
2.3.2. SQL 注入问题
什么是 SQL 注入问题 ? 简单来说它就是一种常见的漏洞, 攻击者通过嵌入恶意的 SQL 语句从而执行非授权的数据库操作, 并且 SQL 注入通常发生在动态 SQL 语句拼接中( 动态语句下面会说 ).
来看下面这个业务场景 : 根据账号密码登录后并查询用户该用户
// 由于我们传的是一个用户实体类, 不再需要参数注解 @Parma UserEntity login(UserEntity user); // 传对象更方便与后续的修改
<select id="login" resultType="com.example.demo.entity.UserEntity"> # 因为是字符串类型, ${ } 需要手动拼接单引号 select * from userinfo where username='${username}' and password='${password}' </select>
创建单引测试方法
@Test void login() { String username = "admin"; String password = "123"; // 构建对象传入 UserEntity inputUser = new UserEntity(); inputUser.setUsername(username); // 设置 UserEntity 用户实体类的账号和密码 inputUser.setPassword(password); UserEntity user = userMapper.login(inputUser); // 接受查询到的结果 System.out.println(user); }
运行测试方法后, 是可以正确查询到的.
但是它存在 SQL 注入问题, 比如现在有人恶意注入了 SQL 语句, 传了一个特殊密码给你, 但是一定不是 admin 对应的 123 这个密码, 按理他是不能查询出来的才是正确的
@Test void login() { String username = "admin"; String password = "' or 1='1 "; // 注意我此时写的密码不再是 "123" // 构建对象传入 UserEntity inputUser = new UserEntity(); inputUser.setUsername(username); // 设置 UserEntity 用户实体类的账号和密码 inputUser.setPassword(password); UserEntity user = userMapper.login(inputUser); // 接受查询到的结果 System.out.println(user); }
运行测试方法一看, 出大问题了, 密码不对居然还查询到了我的 admin ? 你想想这有多危险, 通过恶意的 SQL 语句查询到了本不该让你知道的敏感信息, 现在这就是密码泄漏了, 是非常危险的行为.
那么, 他具体是如何出问题的呢 ? 为什么用 ${ } 会出这个问题, 通过 SQL 的执行就可以看出来
最终执行的语句变成了查询全表, 我此时数据库里是一条数据, 查询到的就是一条, 但当你数据库中有很多用户数据, 被人恶意注入查询到了所有用户的账号密码, 这将是毁灭性的打击. 因此避免 SQL 注入是我们操作数据库需要解决的重要问题
那么, ${ } 的方式用不了, #{ } 的方式能行吗 ? 来试试
测试发现, 当使用 #{ } 的方式时, 由于他是通过占位符的预处理的方式, 无论你传入的是什么, mybatis 会自动帮你去进行适配处理, 这个使用这个奇怪的密码 ’ or 1='1 不在会当做 SQL 关键字去执行, 而是直接当做了字符串放到了占位符中进行处理. 因此他们直接第二个重要的区别就是 #{ } 是安全的, 而#{ } 是非安全你得
2.3.3. #{ } 和 ${ }总结
这时候不免有人会问, #{ } 同样可以替换参数, 并且还可以避免 SQL 注入问题, 全部用 #{ } 就行了, 哪里还有 ${ ] 什么事呢 ?
但是, 我们刚刚提及到的是 #{ } 之所以安全是因为他把刚刚的那个奇怪密码当成了字符串处理了, 但是如果我们本身要执行的就是 SQL 关键字( 比如按照价格排序使用 order by XXX desc 关键字), 这时候在用 #{ } 去处理, 这个 SQL 指令就被当成了字符串从而不会被执行.
既然 ${ } 这么危险, 不得已要用又该如何去防范呢 ? 当传来的 SQL 语句的值是可以被枚举的时候, 这时候使用 ${ } 是相对安全的, 如果不能被枚举, 此时不知道传来的是什么语句, 这将是非常危险的. 因此无论是 #{ } 还是 ${ } 都是有其独特之处的, 还是需要根据自己的业务需求进行选择的.
3. <update> 修改标签
<update> 修改标签和前面学的 <select> 不同, 它只需要一必传参数 id, 也就是你得接口方法名称, 至于返回的数据类型, 默认就是 int 即受影响的行数.
还是一样, 结合我们的场景来看, 根据用户的 id 来实现当前用户密码的修改
// 根据用户 id 修改密码 int updatePassword(@Param("id") String id, @Param("newPassword") String password);
<update id="updatePassword"> # 只需要写 id 属性就行 update userinfo set password=#{newPassword} </update>
执行单元测试方法
@Test void updatePassword() { int row = userMapper.updatePassword("1", "admin"); System.out.println(row); }
上数据库中查看是否修改成功
3.1 JUnit 防数据污染
之前提及过, JUnit 有一个最大的优点之一就是不会污染数据, 但是我们上面用单元测试执行的时候, 密码却被修改了. 这又是怎么回事不是说好了不会污染数据库嘛 ?
别急, 下面就来让 JUnit 不去污染数据, 那就是添加 @Transactional 事务注解. 事务大家都不陌生, 这里添加这个代码有什么用呢 ? 接线来就看神奇之处
在测试方法里, 我们传入密码为 “123”, 看看执行过后能不能给原本 “admin” 的密码修改
@Test @Transactional // 开启事务 void updatePassword() { int row = userMapper.updatePassword("1", "123"); System.out.println(row); }
执行单元测试方法后可以看到执行时成功了的, 并且已经显示修改了一条数据
上数据库查看倒地修改成功了嘛 ? 我这三次数据库查询可以看到, 当我们添加 @Transactional 注解过后, 虽然执行成功了修改操作, 但是并没有在数据库中进行修改, 从而保证了不污染数据库
哪这是为什么呢 ? @Transactional 它是事务注解, 当开启事务后, 即使执行了事务, 进行回滚之后就会对数据进行复原. 在 SQL 执行里面也可以看到, 当测试结束过后它会自动进行事务回滚
4. <insert> 新增标签
<insert> 新增标签和 <update> 修改标签类似, 返回的都是受影响的行数, 因此此处同样不需要给定返回的数据类型, 只需要指定 id 属性即 接口方法名称即可
结合下面这个业务来看 : 在用户表中新增一个用户 ( 添加指定的用户名和密码字段 )
// 新增用户 - 只添加用户名和密码 其余字段不添加 int insertUser(UserEntity user);
<insert id="insertUser"> insert into userinfo(username, password) values( #{username}, #{password} ) </insert>
执行单元测试方法, 此处没有加事务注解, 如果执行成功会正确添加到数据库中
@Test void insertUser() { // 构建对象传入 UserEntity inputUser = new UserEntity(); inputUser.setUsername("zhangsan"); inputUser.setPassword("123"); int row = userMapper.insertUser(inputUser); System.out.println("新增行数 : " + row); }
上数据库中查看, 可以看到是正确添加了的
4.1 返回自增 id
在<insert> 添加标签里面有一类特殊的添加, 返回自增 id, 什么意思呢 ?
也就是说, 当我们通过添加标签添加一条数据后, 如果数据库里这个字段舍友自增 id, 那么我们是可以把这个新加这条数据的自增 id 获取到的.
还是刚刚的添加业务 : 在用户表中新增一个用户 ( 添加指定的用户名和密码字段 )
// 新增用户 - 只添加用户名和密码, 并且返回自增主键 int insertUserAndId(UserEntity user);
在 <insert> 标签中, 需要添加 useGeneratedKeys 属性和 keyProperty 属性
useGeneratedKeys 属性表示是否需要获取自增主键, 默认是 false 不获取的, 想要获取就需要设置为 true
keyProperty 属性表示获取到的自增主键放到个字段里
<insert id="insertUserAndId" useGeneratedKeys="true" keyProperty="id"> insert into userinfo(username, password) values( #{username}, #{password} ) </insert>
创建单元测试方法
@Test void insertUserAndId() { // 构建对象传入 UserEntity inputUser = new UserEntity(); inputUser.setUsername("lisi"); inputUser.setPassword("123"); int row = userMapper.insertUserAndId(inputUser); System.out.println("新增行数 : " + row); // 由于开启了获取自增主键并赋值给 id 这一列, 因此没有设置 id 的值便可以直接获取 System.out.println("Id : " + inputUser.getId()); }
执行单元测试, 由于已经开启了获取自增主键, 并且把获取到的自增主键的值放到了 id 这一列, 因此此时该对象中就可以直接获取了. 可以看到此时获取到的这条新增用户的自增 id 为 9
上数据库中查验, 可以看到是正确的获取了的.
PS : 表中字段必须要有自增主键, 否则是无法正确获取并且执行报错的
5. <delete> 删除标签
<delete> 删除标签和 <delete> 以及 <update> 标签一样, 只需要指定 id 属性即为接口方法即可, 同样是默认返回受影响的行数, 因此也不需要指定返回类型
同样还是结合业务来看 : 删除用户表中 id 为 2 的用户
// 删除指定 id 用户 int deleteUser(@Param("id") Integer id);
<delete id="deleteUser"> delete from userinfo where id=#{id} </delete>
创建单元测试方法
@Test @Transactional // 开启事务, 防止污染数据 void deleteUser() { int row = userMapper.deleteUser(1); System.out.println("删除行数 : " + row); }
执行测试方法可以看到, 是成功正确执行了的
6. 模糊匹配 like
模糊匹配有所不同, 和之前咱们用直接用 like 有一些不一样的. 在这儿因为无法枚举用户的输入, 因此我们此处为了防止 SQL 注入问题, 只能去使用 #{ } 的占位符方式.
下面根据用户名来实现一个模糊查询
// 根据用户名模糊匹配用户 List<UserEntity> getUserOfLikeName(@Param("username") String username);
<select id="getUserOfLikeName" resultType="com.example.demo.entity.UserEntity"> select * from userinfo where username like %#{username}% </select>
建立测试方法并执行
@Test void getUserOfLikeName() { List<UserEntity> list = userMapper.getUserOfLikeName("王"); list.stream().forEach(System.out::println); // lambda 表达式 }
提示 SQL 语句执行错误了, 但是#{ } 确实是可以用于字符串类型并且防止 SQL 注入, 但是仔细看 SQL 的执行’%‘王’%', 而我们期望的是 select * from userinfo where username like ‘%王’% , 王字是不包括在单引号之中的.
但是现在 ${ } 存在 SQL 注入问题无法使用, 而#{ } 又无法正确执行 SQL 语句, 哪这个模糊匹配该使用什么呢 ?
在 MySQL 中提供了一个拼接函数 contact( String1, String2, …)
同样的在 mybatis 中有这个拼接函数, 下面来重写 SQL 语句
<select id="getUserOfLikeName" resultType="com.example.demo.entity.UserEntity"> select * from userinfo where username like concat('%',#{username},'%') </select>
执行单元测试方法, 现在就可以正确的获取到了.
Mybatis 中内置的方法有很多, 大家可以上 MySQL 的官方文档中去查看 ( 菜鸟教程 )