SpringCloud学习笔记

说明

这里记录不需要在代码中记录&已经存在的笔记的知识点.

代码的知识点已经打包:

同时还有一份在线文档:

需要复习的时候三个部分同时查阅.

MybatisPlus

实现基本的CRUD

为了简化单表CRUD,MybatisPlus提供了一个基础的BaseMapper接口,其中已经实现了单表的CRUD. 就是mapper中什么都不需要写, 就已经可以进行基本的CRUD了.

1public interface UserMapper extends BaseMapper<User> {
2}
 1	@Test
 2    void testInsert() {
 3        User user = new User();
 4        user.setId(5L);
 5        user.setUsername("Lucy");
 6        user.setPassword("123");
 7        user.setPhone("18688990011");
 8        user.setBalance(200);
 9        user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
10        user.setCreateTime(LocalDateTime.now());
11        user.setUpdateTime(LocalDateTime.now());
12        userMapper.insert(user);
13    }

只需要继承BaseMapper就能省去所有的单表CRUD

UserMapper在继承BaseMapper的时候指定了一个泛型, 泛型中的User就是与数据库对应的PO.

MybatisPlus就是根据PO实体的信息来推断出表的信息,从而生成SQL的。默认情况下:

  • MybatisPlus会把PO实体的类名驼峰转下划线作为表名
  • MybatisPlus会把PO实体的所有变量名驼峰转下划线作为表的字段名,并根据变量类型推断字段类型
  • MybatisPlus会把名为id的字段作为主键

常见注解

如果默认的实现与实际场景不符,MybatisPlus提供了一些注解便于我们声明表信息。

@TableName

  • 描述:表名注解,标识实体类对应的表
  • 使用位置:实体类

还可以指定一些属性, 其中autoResultMap适用于类与类嵌套时, 自动结果集映射.

@TableId

  • 描述:主键注解,标识实体类中的主键字段
  • 使用位置:实体类的主键字段

支持两个属性:

属性 类型 必须指定 默认值 描述
value String "" 表名
type Enum IdType.NONE 指定主键类型

IdType比较常见的有三种:

  • AUTO:利用数据库的id自增长
  • INPUT:手动生成id
  • ASSIGN_ID:雪花算法生成Long类型的全局唯一id,这是默认的ID策略

@TableField

  • 描述:普通字段注解

一般情况下我们并不需要给字段添加@TableField注解,一些特殊情况除外:

  • 成员变量名与数据库字段名不一致
  • 成员变量是以isXXX命名,按照JavaBean的规范,MybatisPlus识别字段时会把is去除,这就导致与数据库不符。
  • 成员变量名与数据库一致,但是与数据库的关键字冲突。使用@TableField注解给字段名添加转义字符:``

例:

 1mybatisplus通过扫描实体类, 并基于反射获取实体类信息作为数据库表信息
 2实现CRUD数据库表信息的约定:
 31. 类名驼峰转下划线作为数据库表名, 如userName的类对应的数据库就是user_name
 42. 名为id的字段作为主键
 53. 变量名驼峰转下划线违表的字段名, 如createTime字段在数据库表中的字段就是create_time
 6
 7符合约定的不用配置直接用, 不符合约定需自定义表名和字段名, 通过注解:
 8@TableName指定表名 @TableId指定主键 @TableField指定字段
 9
10这个在pojo中定义, 
11@TableName("tb_user")
12public class User {
13//    idtype枚举中代表了主键的三种类型:
14//    1. AUTO: 自增长
15//    2. INPUT: 通过set方法自动输入
16//    3. ASSIGN_ID: 分配ID,mp提供的IdentifierGenerator的nextID生成id, 不需要手动提供. 这是默认策略
17    @TableId(value = "index", type = IdType.AUTO)
18    private Long id;
19//    使用@TableField的场景
20//    1. 成员变量名与数据库的字段名不一致
21//    2. 成员变量以is开头且是布尔值, 如is_married会被解析为married
22//    3. 成员变量名与数据库关键字冲突, order就是关键字
23//    4. 成员变量不是数据库字段, 下面的address字段数据库中没有.
24    @TableField("username")
25    private String name;
26    @TableField("is_married")
27    private Boolean isMarried;
28    @TableField("`order`")
29    private Integer order;
30    @TableField(exist = false)
31    private String address;
32}

核心功能

为了实现复杂的SQL需要用到核心功能.

条件构造器

是为了代替where语句. BaseMapper中提供的相关方法除了以id作为where条件以外,还支持更加复杂的where条件。这些方法中的参数中的Wrapper就是条件构造的抽象类. Wrapper的子类AbstractWrapper提供了where中包含的所有条件构造方法, 而QueryWrapper在AbstractWrapper的基础上拓展了一个select方法,允许指定查询字段, UpdateWrapper在AbstractWrapper的基础上拓展了一个set方法,允许指定SQL中的SET部分. 所以我们根据需要选择不同的wrapper实现类.

条件构造器wrapper: 主键的查询无法满足要求, 需要用wrapper满足更复杂的sql需求, 定义where条件等.

  • queryWrapper来代替select, delete, update中的where部分
  • updateWrapper通常在set语句比较特殊才使用
  • lambda的wrapper支持lambda
queryWrapper(应对复杂的where)

查询名字带o的, 存款大于等于1000的人的id,名字, 信息, 收入

select id,username,info,balance from user where username like ? and balance >= ?

 1    @Test
 2    void testQueryWrapper(){
 3//        1. 构建查询条件
 4        QueryWrapper<User> wrapper = new QueryWrapper<User>()
 5                .select("id", "username", "info", "balance")
 6                .like("username", "o")
 7                .ge("balance", 1000);
 8//        2. 查询
 9        List<User> users = userMapper.selectList(wrapper);
10        users.forEach(System.out::println);
11    }
12//    eq是等于, ne不等于, gt大于, ge大于等于, lt小于, le小于等于

更新时使用queryWrapper

将名字为jack的人的存款设置为2000

update user set balance=2000 where username=“jack”

 1    @Test
 2    void testUpdateByQueryWrapper(){
 3//        1. 要更新的数据, user中非null字段都会作为set语句
 4        User user = new User();
 5        user.setBalance(2000);
 6//        2.更新的条件
 7        QueryWrapper<User> wrapper = new QueryWrapper<User>().eq("username", "jack");
 8//        3.执行更新
 9        userMapper.update(user, wrapper);
10    }
UpdateWrapper(应对复杂的set)

基于BaseMapper中的update方法更新时只能直接赋值,对于一些复杂的需求就难以实现。

将id为1,2,4的用户余额减200

update user set balance = balance - 200 where id in (1,2,4)

SET的赋值结果是基于字段现有值的,这个时候就要利用UpdateWrapper中的setSql功能了

1    void testUpdateWrapper(){
2        List<Long> ids = List.of(1L,2L,4L);
3        UpdateWrapper<User> wrapper = new UpdateWrapper<User>()
4                .setSql("balance = balance - 200")
5                .in("id", ids);
6        userMapper.update(null, wrapper);
7    }
LambdaWrapper(为了解字符串魔法值)

无论是QueryWrapper还是UpdateWrapper在构造条件的时候都需要写死字段名称,会出现字符串魔法值(“字符串魔法值”通常指的是硬编码在代码中的字符串常量,这些常量没有明确的意义或者上下文,可能难以理解和维护)。这在编程规范中显然是不推荐的。

其中一种办法是基于变量的gettter方法结合反射技术。因此我们只要将条件对应的字段的getter方法传递给MybatisPlus,它就能计算出对应的变量名了。而传递方法可以使用JDK8中的方法引用和Lambda表达式。

因此MybatisPlus又提供了一套基于Lambda的Wrapper,包含两个:

  • LambdaQueryWrapper

  • LambdaUpdateWrapper

分别对应QueryWrapper和UpdateWrapper

 1//    使用Lambda的wrapper来接触硬编码模式, 这样里面没有字符串了
 2
 3    void testLambdaQueryWrapper(){
 4//        1. 构建查询条件
 5        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
 6                .select(User::getId, User::getUsername, User::getInfo, User::getBalance)
 7                .like(User::getUsername, "o")
 8                .ge(User::getBalance, 1000);
 9//        2. 查询
10        List<User> users = userMapper.selectList(wrapper);
11        users.forEach(System.out::println);
12    }

自定义SQL

语句

1UpdateWrapper<User> wrapper = new UpdateWrapper<User>()
2                .setSql("balance = balance - 200")
3                .in("id", ids);

中, 这种写法在某些企业也是不允许的,因为SQL语句最好都维护在持久层,而不是业务层。就当前案例来说,由于条件是in语句,只能将SQL写在Mapper.xml文件,利用foreach来生成动态SQL。 这实在是太麻烦了。假如查询条件更复杂,动态SQL的编写也会更加复杂。

所以,MybatisPlus提供了自定义SQL功能,可以让我们利用Wrapper生成查询条件,再结合Mapper.xml编写SQL

基本用法

利用mp的wrapper构建复杂的where条件, 然后自己定义sql语句中剩下的部分

  1. 基于wrapper构建where条件

  2. 在mapper接口方法参数中用param注解声明变量名称, 必须是ew

  3. 自定义sql, 并使用wrapper条件(这个可以在xml中, 也可以在接口中用注解写) 使用${ew.customSqlSegment}调用mp的where条件

这样做是为了满足不在业务层编写sql, 同时享受mp生成sql条件这种便利, 上面的sql是在业务层写sql了.

 1@Test
 2    void testCustomSqlUpdate(){
 3//        1.更新条件
 4        List<Long> ids = List.of(1L, 2L, 4L);
 5        int amount = 200;
 6//        2.定义条件
 7        QueryWrapper<User> wrapper = new QueryWrapper<User>().in("id", ids);
 8//        3. 调用自定义sql方法
 9        userMapper.updateBalanceByIds(wrapper, amount);
10    }
1public interface UserMapper extends BaseMapper<User> {
2    @Select("UPDATE user SET balance = balance - #{money} ${ew.customSqlSegment}")
3    void deductBalanceByIds(@Param("money") int money, @Param("ew") QueryWrapper<User> wrapper);
4}

或者

1<update id="updateBalanceByIds">
2        UPDATE user SET balance = balance - #{amount} ${ew.customSqlSegment}
3</update>
多表查询

理论上来讲MyBatisPlus是不支持多表查询的,不过我们可以利用Wrapper中自定义条件结合自定义SQL来实现多表查询的效果。 例如,我们要查询出所有收货地址在北京的并且用户id在1、2、4之中的用户 要是自己基于mybatis实现SQL,大概是这样的:

 1<select id="queryUserByIdAndAddr" resultType="com.itheima.mp.domain.po.User">
 2      SELECT *
 3      FROM user u
 4      INNER JOIN address a ON u.id = a.user_id
 5      WHERE u.id
 6      <foreach collection="ids" separator="," item="id" open="IN (" close=")">
 7          #{id}
 8      </foreach>
 9      AND a.city = #{city}
10  </select>

可以看出其中最复杂的就是WHERE条件的编写,如果业务复杂一些,这里的SQL会更变态。

但是基于自定义SQL结合Wrapper的玩法,我们就可以利用Wrapper来构建查询条件,然后手写SELECT及FROM部分,实现多表查询。

查询条件这样来构建:

 1@Test
 2void testCustomJoinWrapper() {
 3    // 1.准备自定义查询条件
 4    QueryWrapper<User> wrapper = new QueryWrapper<User>()
 5            .in("u.id", List.of(1L, 2L, 4L))
 6            .eq("a.city", "北京");
 7
 8    // 2.调用mapper的自定义方法
 9    List<User> users = userMapper.queryUserByWrapper(wrapper);
10
11    users.forEach(System.out::println);
12}

然后在UserMapper中自定义方法:

1@Select("SELECT u.* FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment}")
2List<User> queryUserByWrapper(@Param("ew")QueryWrapper<User> wrapper);

当然,也可以在UserMapper.xml中写SQL:

1<select id="queryUserByIdAndAddr" resultType="com.itheima.mp.domain.po.User">
2    SELECT * FROM user u INNER JOIN address a ON u.id = a.user_id ${ew.customSqlSegment}
3</select>

Service接口

MybatisPlus不仅提供了BaseMapper,还提供了通用的Service接口及默认实现,封装了一些常用的service模板方法。 通用接口为IService,默认实现为ServiceImpl

CRUD

新增:

  • save是新增单个元素
  • saveBatch是批量新增
  • saveOrUpdate是根据id判断,如果数据存在就更新,不存在则新增
  • saveOrUpdateBatch是批量的新增或修改

删除:

  • removeById:根据id删除
  • removeByIds:根据id批量删除
  • removeByMap:根据Map中的键值对为条件删除
  • remove(Wrapper<T>):根据Wrapper条件删除
  • ~~removeBatchByIds~~:暂不支持

修改:

  • updateById:根据id修改
  • update(Wrapper<T>):根据UpdateWrapper修改,Wrapper中包含setwhere部分
  • update(T,Wrapper<T>):按照T内的数据修改与Wrapper匹配到的数据
  • updateBatchById:根据id批量修改

查询一条Get:

  • getById:根据id查询1条数据
  • getOne(Wrapper<T>):根据Wrapper查询1条数据
  • getBaseMapper:获取Service内的BaseMapper实现,某些时候需要直接调用Mapper内的自定义SQL时可以用这个方法获取到Mapper

查询多条List:

  • listByIds:根据id批量查询
  • list(Wrapper<T>):根据Wrapper条件查询多条数据
  • list():查询所有

计数Count:

  • count():统计所有数量
  • count(Wrapper<T>):统计符合Wrapper条件的数据数量

getBaseMapper:

当我们在service中要调用Mapper中自定义SQL时,就必须获取service对应的Mapper,就可以通过这个方法

基本用法

①由于Service中经常需要定义与业务有关的自定义方法,因此我们不能直接使用IService,而是自定义Service接口,然后继承IService以拓展方法。同时,让自定义的Service实现类继承ServiceImpl,这样就不用自己实现IService中的接口了。

1.首先,定义IUserService,继承IService

1package com.itheima.mp.service;
2
3public interface IUserService extends IService<User> {
4    // 拓展自定义方法
5}

2.然后,编写UserServiceImpl类,继承ServiceImpl,实现UserService

1package com.itheima.mp.service.impl;
2
3@Service
4public class UserServiceImpl extends ServiceImpl<UserMapper, User>
5                                                                                                        implements IUserService {
6}

②快速实现下面4个接口:

编号 接口 请求方式 请求路径 请求参数 返回值
1 新增用户 POST /users 用户表单实体
2 删除用户 DELETE /users/{id} 用户id
3 根据id查询用户 GET /users/{id} 用户id 用户VO
4 根据id批量查询 GET /users 用户id集合 用户VO集合
 1@Api(tags = "用户管理接口")
 2@RequestMapping("/users")
 3@RestController
 4@RequiredArgsConstructor
 5public class UserController {
 6
 7//    这里spring不推荐自动注入, 我们用构造函数代替, 将字段设置为final然后再类上加注解@RequiredArgsConstructor就可以自动生成了
 8//    @Autowired
 9//    private IUserService userService;
10    private final IUserService userService;
11
12//    对于简单的逻辑, 直接调用原生的service方法就行了, 对于复杂的业务逻辑需要自定义service, 对于复杂的sql需要自定义mapper并调用
13    @ApiOperation("新增用户接口")
14    @PostMapping
15    public void saveUser(@RequestBody UserFormDTO userDTO){
16//        1. 利用hutu工具中的BeanUtil将DTO拷贝到PO
17        User user = BeanUtil.copyProperties(userDTO, User.class);
18//        2.新增
19        userService.save(user);
20    }
21
22    @ApiOperation("删除用户接口")
23    @DeleteMapping("{id}")
24    public void deleteUserById(@ApiParam("用户id") @PathVariable("id") Long id){
25        userService.removeById(id);
26    }
27
28    @ApiOperation("根据id查询用户接口")
29    @GetMapping("{id}")
30    public UserVO queryUserById(@ApiParam("用户id") @PathVariable("id") Long id){
31//        User user = userService.getById(id);
32////        将po拷贝到vo
33//        return BeanUtil.copyProperties(user, UserVO.class);
34        return userService.queryUserAndAddressById(id);
35    }
36
37    @ApiOperation("根据ids批量查询用户接口")
38    @GetMapping
39    public List<UserVO> queryUserByIds(@ApiParam("用户id集合") @RequestParam("ids") List<Long> ids){
40//        List<User> user = userService.listByIds(ids);
41////        将po拷贝到vo
42//        return BeanUtil.copyToList(user, UserVO.class);
43        return userService.queryUserAndAddressByIds(ids);
44    }
45}

③不过,一些带有业务逻辑的接口则需要在service中自定义实现了。例如下面的需求:

  • 根据id扣减用户余额

这看起来是个简单修改功能,只要修改用户余额即可。但这个业务包含一些业务逻辑处理:

  • 判断用户状态是否正常
  • 判断用户余额是否充足

这些业务逻辑都要在service层来做,另外更新余额需要自定义SQL,要在mapper中来实现。因此,我们除了要编写controller以外,具体的业务还要在service和mapper中编写。

首先在UserController中定义一个方法:

1@ApiOperation("根据id扣减用户余额")
2@PutMapping("/{id}/document/{money}")
3public void deductMoneyById(
4        @ApiParam("用户id") @PathVariable("id") Long id,
5        @ApiParam("扣减的金额") @PathVariable("money") Integer money){
6    userService.deductBalance(id, money);
7}

然后是UserService接口:

1public interface IUserService extends IService<User> {
2    void deductBalance(Long id, Integer money);
3}

最后是UserServiceImpl实现类:

 1//我们的service接口继承IService, 然后我们service接口实现类继承IService接口的实现类ServiceImpl, 这样就不需要一一实现就可以用了
 2@Service
 3public class IUserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
 4
 5    @Override
 6    public void deductBalance(Long id, Integer money) {
 7        //1.查询用户
 8        //调用的方法, 自己就是service不需要注入额外的service.
 9//        User user = this.getById(id);
10        User user = getById(id);
11        //2.校验用户状态
12        //反向校验保证不会出现if嵌套
13        if(user == null || user.getStatus() == UserStatus.FROZEN){
14            throw new RuntimeException("用户状态异常");
15        }
16        //3.校验余额是否充足
17        if(user.getBalance() < money){
18            throw new RuntimeException("用户余额不足");
19        }
20        //4.扣减余额 update user set balance = balance - money where id = id;
21        //service不写sql, 去mapper中写.
22        //如果要使用mapper在父类serviceimpl中已经注入base mapper了可以直接用
23        baseMapper.deductBalance(id, money);
24    }
25}

最后是mapper:

1public interface UserMapper extends BaseMapper<User> {
2    @Update("update user set balance = balance - #{money} where id = #{id}")
3    void deductBalance(@Param("id") Long id, @Param("money") Integer money);
4}
Lambda(结合服务层与wrapper)

IService中还提供了Lambda功能来简化我们的复杂查询及更新功能。

案例一:实现一个根据复杂条件查询用户的接口,查询条件如下:

  • name:用户名关键字,可以为空
  • status:用户状态,可以为空
  • minBalance:最小余额,可以为空
  • maxBalance:最大余额,可以为空

在UserController中定义一个controller方法:

 1@GetMapping("/list")
 2@ApiOperation("根据id集合查询用户")
 3public List<UserVO> queryUsers(UserQuery query){
 4    // 1.组织条件
 5    String username = query.getName();
 6    Integer status = query.getStatus();
 7    Integer minBalance = query.getMinBalance();
 8    Integer maxBalance = query.getMaxBalance();
 9    LambdaQueryWrapper<User> wrapper = new QueryWrapper<User>().lambda()
10            .like(username != null, User::getUsername, username)
11            .eq(status != null, User::getStatus, status)
12            .ge(minBalance != null, User::getBalance, minBalance)
13            .le(maxBalance != null, User::getBalance, maxBalance);
14    // 2.查询用户
15    List<User> users = userService.list(wrapper);
16    // 3.处理vo
17    return BeanUtil.copyToList(users, UserVO.class);
18}

在组织查询条件的时候,我们加入了 username != null 这样的参数,意思就是当条件成立时才会添加这个查询条件,类似Mybatis的mapper.xml文件中的<if>标签。这样就实现了动态查询条件效果了。

不过,上述条件构建的代码太麻烦了。 因此Service中对LambdaQueryWrapperLambdaUpdateWrapper的用法进一步做了简化。我们无需自己通过new的方式来创建Wrapper,而是直接调用lambdaQuerylambdaUpdate方法:

基于Lambda查询:

1@ApiOperation("根据复杂条件批量查询用户接口")
2    @GetMapping("/list")
3    public List<UserVO> queryUsers(UserQuery query){
4        List<User> user = userService.queryUsers(
5                query.getName(), query.getStatus(), query.getMinBalance(), query.getMaxBalance());
6//        将po拷贝到vo
7        return BeanUtil.copyToList(user, UserVO.class);
8    }
 1//查询用户姓名, 状态, 存款余额介于最大和最小之间的用户
 2    @Override
 3    public List<User> queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance) {
 4        //IService的lambda查询, 这个查询条件替代mapper中的复杂的语句, 当条件满足时才去添加对应的sql语句
 5        //lambdaQuery,直接将wrapper和查询一步到位.不需要单独new wrapper.
 6        return lambdaQuery()
 7                .like(name != null, User::getUsername, name)
 8                .eq(status != null, User::getStatus, status)
 9                .ge(minBalance != null, User::getBalance, minBalance)
10                .le(maxBalance != null, User::getBalance, maxBalance)
11                .list(); // 相应返回一个就写one(),分页写page(),数目写count(),列表写list()
12    }

IService中的lambdaUpdate方法可以非常方便的实现复杂更新业务。

案例二:

改造根据id修改用户余额的接口,要求如下

  • 如果扣减后余额为0,则将用户status修改为冻结状态(2)

也就是说我们在扣减用户余额时,需要对用户剩余余额做出判断,如果发现剩余余额为0,则应该将status修改为2,这就是说update语句的set部分是动态的。

 1//修改用户余额接口, 要求对用户状态校验, 用户余额校验, 如果扣减口为0了则修改状态为冻结状态(2)
 2    @Override
 3    @Transactional
 4    public void deductBalance(Long id, Integer money) {
 5        //1.查询用户
 6        //调用的方法, 自己就是service不需要注入额外的service.
 7//        User user = this.getById(id);
 8        User user = getById(id);
 9        //2.校验用户状态
10        //反向校验保证不会出现if嵌套
11        if(user == null || user.getStatus() == UserStatus.FROZEN){
12            throw new RuntimeException("用户状态异常");
13        }
14        //3.校验余额是否充足
15        if(user.getBalance() < money){
16            throw new RuntimeException("用户余额不足");
17        }
18        //4.扣减余额 update user set balance = balance - money where id = id;
19        //service不写sql, 去mapper中写.
20        //如果要使用mapper在父类serviceimpl中已经注入base mapper了可以直接用
21        //baseMapper.deductBalance(id, money);
22        int remainBalance = user.getBalance() - money;
23        lambdaUpdate()
24                .set(User::getBalance, remainBalance)
25                .set(remainBalance == 0, User::getStatus, UserStatus.FROZEN)
26                .eq(User::getId, id)
27                // 构建条件
28                .eq(User::getBalance, user.getBalance()) //乐观锁. 有并发安全风险, 当最后查到的余额等于用户的余额才update
29                .update(); // 最后执行update
30    }
优化批量新增(批处理功能)

测试逐条插入数据和MybatisPlus的批处理(saveBatch):

 1//普通的for循环添加10万条数据
 2//相对于10万次网络请求, 很慢
 3@Test
 4void testSaveOneByOne(){
 5    long b = System.currentTimeMillis();
 6    for (int i = 0; i < 100000; i++) {
 7        userService.save(buildUser(i));
 8    }
 9    long e = System.currentTimeMillis();
10    System.out.println("cost time: " + (e-b));
11}
12//使用IService添加10万数据
13//预编译成1000条sql语句, 一次性提交1000条, 相对于100次网络请求
14//但是1000条sql还是慢了,想办法变成一条sql, 只需要添加一条mysql配置,让引擎来完成:rewriteBatchedStatements=true
15//拼接到yaml配置jdbcurl后面.
16@Test
17void testSaveBatch(){
18    //每次批量插入1000条
19    ArrayList<User> users = new ArrayList<>(1000);
20    long b = System.currentTimeMillis();
21    for (int i = 0; i < 100000; i++) {
22        users.add(buildUser(i));
23        if(i%1000 == 0){
24            userService.saveBatch(users);
25            users.clear();
26        }
27    }
28}

可以看到使用了批处理以后,比逐条新增效率提高了10倍左右,性能还是不错的。

MybatisPlus的批处理是基于PrepareStatement的预编译模式,然后批量提交,最终在数据库执行时还是会有多条insert语句,逐条插入数据。如果想要得到最佳性能,最好是将多条SQL合并为一条:

 1Preparing: INSERT INTO user ( username, password, phone, info, balance, create_time, update_time ) VALUES ( ?, ?, ?, ?, ?, ?, ? )
 2Parameters: user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01
 3Parameters: user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01
 4Parameters: user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01
 5
 6
 7INSERT INTO user ( username, password, phone, info, balance, create_time, update_time )
 8VALUES 
 9(user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01),
10(user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01),
11(user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01),
12(user_4, 123, 18688190004, "", 2000, 2023-07-01, 2023-07-01);

MySQL的客户端连接参数中有这样的一个参数:rewriteBatchedStatements。顾名思义,就是重写批处理的statement语句。

这个参数的默认值是false,我们需要修改连接参数,将其配置为true

修改项目中的application.yml文件,在jdbc的url后面添加参数&rewriteBatchedStatements=true:

1spring:
2  datasource:
3    url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
4    driver-class-name: com.mysql.cj.jdbc.Driver
5    username: root
6    password: MySQL123

再次测试插入10万条数据,可以发现速度有非常明显的提升.

ClientPreparedStatementexecuteBatchInternal中,有判断rewriteBatchedStatements值是否为true并重写SQL的功能

扩展功能

代码生成(解决重复性工作)

在使用MybatisPlus以后,基础的MapperServicePO代码相对固定,重复编写也比较麻烦。因此MybatisPlus官方提供了代码生成器根据数据库表结构生成POMapperService等相关代码。只不过代码生成器同样要编码使用,也很麻烦。

Idea的plugins市场中搜索并安装MyBatisPlus插件, 然后重启你的Idea即可使用。

刚好数据库中还有一张address表尚未生成对应的实体和mapper等基础代码。我们利用插件生成一下。 首先需要配置数据库地址,在Idea顶部菜单中,找到other,选择Config Database, 在弹出的窗口中填写数据库连接的基本信息

点击OK保存。然后再次点击Idea顶部菜单中的other,然后选择Code Generator

在弹出的表单中填写信息:

最终,代码自动生成到指定的位置了

静态工具(预防循环依赖)

有的时候Service之间也会相互调用,为了避免出现循环依赖问题,MybatisPlus提供一个静态工具类:Db,其中的一些静态方法与IService中方法签名基本一致,也可以帮助我们实现CRUD功能.

需求:改造根据id用户查询的接口,查询用户的同时返回用户收货地址列表

 1//根据用户id查询用户,还要查询address表上对应用户的地址列表
 2@Override
 3public UserVO queryUserAndAddressById(Long id) {
 4    //1.查询用户
 5    User user = getById(id);
 6    if(user == null || user.getStatus() == UserStatus.FROZEN){
 7        throw new RuntimeException("用户状态异常");
 8    }
 9    //2.查询地址
10    //使用静态方法查询. 因为如果userService注入addressService,之后addressService也会因为需求注入userService,会出现循环依赖
11    //为了解决循环依赖, 使用静态方法Db完成需求
12    List<Address> addresses = Db.lambdaQuery(Address.class) //添加需要查询类的字节码
13            .eq(Address::getUserId, id).list();//多个地址用list()返回多个.
14    //3.封装po成vo
15    //3.1先将user po装维vo
16    UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
17    //3.2转地址vo
18    if(CollUtil.isNotEmpty(addresses)){
19        userVO.setAddresses(BeanUtil.copyToList(addresses, AddressVO.class));
20    }
21    return userVO;
22}

在查询地址时,我们采用了Db的静态方法,因此避免了注入AddressService,减少了循环依赖的风险。

逻辑删除

对于一些比较重要的数据,我们往往会采用逻辑删除的方案,即:

  • 在表中添加一个字段标记数据是否被删除
  • 当删除数据时把标记置为true
  • 查询时过滤掉标记为true的数据

一旦采用了逻辑删除,所有的查询和删除逻辑都要跟着变化,非常麻烦。

为了解决这个问题,MybatisPlus就添加了对逻辑删除的支持。

注意,只有MybatisPlus生成的SQL语句才支持自动的逻辑删除,自定义SQL需要自己手动处理逻辑删除。

配置逻辑删除字段:

1mybatis-plus:
2  global-config:
3    db-config:
4      logic-delete-field: deleted #全局逻辑删除的实体的字段名,字段类型可以是boolean,integer.指定数据表里有的字段
5      logic-delete-value: 1 #逻辑已删除值(默认1)
6      logic-not-delete-value: 0 #逻辑未删除值(默认0)

测试: 我们执行一个删除操作:

1@Test
2void testDeleteByLogic() {
3    // 删除方法与以前没有区别
4    addressService.removeById(59L);
5}

方法与普通删除一模一样,但是底层的SQL逻辑变为update. 查询也会附加where条件.

通用枚举

User类中有一个用户状态字段, 像这种字段我们一般会定义一个枚举,做业务判断的时候就可以直接基于枚举做比较。但是我们数据库采用的是int类型,对应的PO也是Integer。因此业务操作时必须手动把枚举Integer转换,非常麻烦。

因此,MybatisPlus提供了一个处理枚举的类型转换器,可以帮我们把枚举类型与数据库类型自动转换

定义枚举
 1package com.itheima.mp.enums;
 2
 3import com.baomidou.mybatisplus.annotation.EnumValue;
 4import lombok.Getter;
 5
 6@Getter
 7public enum UserStatus {
 8    NORMAL(1, "正常"),
 9    FREEZE(2, "冻结")
10    ;
11    private final int value;
12    private final String desc;
13
14    UserStatus(int value, String desc) {
15        this.value = value;
16        this.desc = desc;
17    }
18}

然后把User类中的status字段从Integer改为UserStatus 类型.

要让MybatisPlus处理枚举与数据库类型自动转换,我们必须告诉MybatisPlus,枚举中的哪个字段的值作为数据库值。 MybatisPlus提供了@EnumValue注解来标记枚举属性

 1@Getter //getter注解加get方法
 2public enum UserStatus {
 3    NORMAL(1,"正常"),
 4    FROZEN(2,"冻结"),
 5    ;
 6    @EnumValue //enumValue注解,标明枚举类型中与数据表对应的字段的属性. 这样对于Po的枚举类型变量和数据表字段就可以转化了.
 7    private final int value;
 8    //前端返回是SpringMVC中Jackson处理的
 9    @JsonValue //这个注解是用作前端返回的,. 于是返回的是正常或冻结.这样前端返回这个字段的时候就会返回对应的desc信息
10    private final String desc;
11
12    UserStatus(int value, String desc) {
13        this.value = value;
14        this.desc = desc;
15    }
16}
配置枚举处理器

在application.yaml文件中添加配置:

1mybatis-plus:
2  configuration:
3    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
定义枚举的展示字段

为了使页面查询结果也是枚举格式,我们需要修改UserVO中的status属性为UserStatus

并且,在UserStatus枚举中通过@JsonValue注解标记JSON序列化时展示的字段.

JSON类型处理器(实体类与数据库字符串的转化)

MybatisPlus提供了很多特殊类型字段的类型处理器,解决特殊字段类型与数据库类型转换的问题。例如处理JSON就可以使用JacksonTypeHandler处理器。

创建实体
1@Data
2@NoArgsConstructor //无参构造
3@AllArgsConstructor(staticName = "of") //有参构造, 提供静态方法of,这样可以类名+静态方法名构造类.
4public class UserInfo {
5    private Integer age;
6    private String intro;
7    private String gender;
8}
使用类型处理器

就是在需要的字段上面添加注解@TableField并制定类型处理器.

1@TableField(typeHandler = JacksonTypeHandler.class) //自定义类型处理器, 在需要的字段添加. 这样就可以实现json数据和类的转化
2private UserInfo info;

这样, 传过来的字符串会被正确处理为对象.

插件功能

MybatisPlus提供了很多的插件功能,进一步拓展其功能。目前已有的插件有:

  • PaginationInnerInterceptor:自动分页
  • TenantLineInnerInterceptor:多租户
  • DynamicTableNameInnerInterceptor:动态表名
  • OptimisticLockerInnerInterceptor:乐观锁
  • IllegalSQLInnerInterceptor:sql 性能规范
  • BlockAttackInnerInterceptor:防止全表更新与删除

注意: 使用多个分页插件的时候需要注意插件定义顺序,建议使用顺序如下:

  • 多租户,动态表名
  • 分页,乐观锁
  • sql 性能规范,防止全表更新与删除

分页插件

在未引入分页插件的情况下,MybatisPlus是不支持分页功能的,IServiceBaseMapper中的分页方法都无法正常起效。 所以,我们必须配置分页插件。

配置分页插件

新建一个配置类

 1@Configuration
 2public class MyBatisConfig {
 3
 4    @Bean
 5    public MybatisPlusInterceptor mybatisPlusInterceptor(){
 6        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
 7        //1.创建分页插件
 8        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
 9        //1.1配置最大查询数目
10        paginationInnerInterceptor.setMaxLimit(1000L);
11        //2.添加插件
12        interceptor.addInnerInterceptor(paginationInnerInterceptor);
13        return interceptor;
14    }
15}
使用分页API
 1//分页查询测试
 2    @Test
 3    void testPageQuery(){
 4        int pageNo = 1, pageSize=2; //从1开始, 每次查2条
 5        //1.准备分页条件
 6        //1.1分页条件
 7        Page<User> page = Page.of(pageNo, pageSize);
 8        //1.2排序条件
 9        page.addOrder(new OrderItem("balance", true));
10        page.addOrder(new OrderItem("id", true));
11        //2.查询
12        Page<User> p = userService.page(page);
13        //3.解析结果
14        long total = p.getTotal();//总条数
15        System.out.println("total: " + total);
16        long pages = p.getPages();//总页数
17        System.out.println("pages: " + pages);
18        List<User> records = p.getRecords(); //记录
19        records.forEach(System.out::println);
20
21
22    }

通用分页实体

实体

分页条件不仅仅用户分页查询需要,以后其它业务也都有分页查询的需求。因此建议将分页查询条件单独定义为一个PageQuery实体, 让我们的UserQuery继承这个实体, 这样其他业务需要分页查询只需要继承PageQuery实体, 然后新增自己需要的字段.

PageQuery:

 1@Data
 2@ApiModel(description = "分页查询实体")
 3public class PageQuery {
 4    @ApiModelProperty("页码")
 5    private Integer pageNo = 1;
 6
 7    @ApiModelProperty("页码")
 8    private Integer pageSize = 5;
 9
10    @ApiModelProperty("排序字段")
11    private String sortBy ;
12
13    @ApiModelProperty("是否升序")
14    private Boolean isAsc = true;
15
16    //封装service层的构建分页查询条件的代码.前面这个<T>是定义的,因为用不了对象的泛型
17    public <T> Page<T> toMpPage(OrderItem ... items){
18        //1.分页条件
19        Page<T> page = Page.of(pageNo, pageSize) ;
20        //1.排序条件
21        if(StrUtil.isNotBlank(sortBy)){
22            //用户给出了需要排序的字段
23            page.addOrder(new OrderItem(sortBy, isAsc));
24        }else if(items != null){
25            //没有给出, 则默认按更新时间排序
26            page.addOrder(items) ;
27        }
28        return page;
29    }
30
31    //再补充一个默认的方法,直接就用create_time默认排序
32    public <T> Page<T> toMpPageDefaultSortByCreate(){
33        return toMpPage(new OrderItem("create_time", isAsc));
34    }
35
36    //再补充一个默认的方法,直接就用update_time默认排序
37    public <T> Page<T> toMpPageDefaultSortByUpdate(){
38        return toMpPage(new OrderItem("update_time", isAsc));
39    }
40
41    //直接只需要传递排序字段和升序降序.
42    public <T> Page<T> toMpPage(String defaultSortBy, Boolean defaultAsc){
43        return toMpPage(new OrderItem(defaultSortBy, defaultAsc));
44    }
45}

UserQuery:

 1//@EqualsAndHashCode 注解用于自动生成 equals 和 hashCode 方法。
 2@EqualsAndHashCode(callSuper = true)
 3@Data
 4@ApiModel(description = "用户查询条件实体")
 5public class UserQuery extends PageQuery{
 6    @ApiModelProperty("用户名关键字")
 7    private String name;
 8    @ApiModelProperty("用户状态:1-正常,2-冻结")
 9    private Integer status;
10    @ApiModelProperty("余额最小值")
11    private Integer minBalance;
12    @ApiModelProperty("余额最大值")
13    private Integer maxBalance;
14}

分页实体PageDTO, 用来装返回的结果:

 1@Data
 2@ApiModel(description = "分页结果")
 3public class PageDTO<T> {
 4    @ApiModelProperty("总条数")
 5    private Long total;
 6    @ApiModelProperty("总页数")
 7    private Long pages;
 8    @ApiModelProperty("集合")
 9    private List<T> list;
10
11    //构建自己,使用static直接调用
12    //类的泛型只能对象用, 需要定义泛型
13    //这个方法使用的前提是转化的对象字段名一样, 可以少但是要一样,因为它使用了BeanUtil.copyToList.
14    //不一样只能自己完成PO到VO的转化, 于是提供一个函数式接口Function<PO,VO> convertor, 前一个是接受参数,后一个是返回参数
15    public static <PO,VO> PageDTO<VO> of(Page<PO> p,  Function<PO,VO> convertor){
16        PageDTO<VO> dto = new PageDTO<>();
17        //3.1总条数
18        dto.setTotal(p.getTotal());
19        //3.2总页数
20        dto.setPages(p.getPages());
21        //3.3当前数据
22        List<PO> records = p.getRecords();
23        if(CollUtil.isEmpty(records)){
24            //空的就放空列表
25            dto.setList(Collections.emptyList());
26            return dto;
27        }
28        //3.4拷贝User的VO
29//        dto.setList(BeanUtil.copyToList(records, clazz)); //参数列表加Class<VO> clazz
30        dto.setList(records.stream().map(convertor).collect(Collectors.toList()));
31        //4.返回
32        return dto;
33    }
34}
开发接口

Controller:

1@ApiOperation("根据条件分页批量查询用户接口")
2    @GetMapping("/page")
3    public PageDTO<UserVO> queryUsersPages(UserQuery query){
4        return userService.queryUsersPage(query);
5    }

service:

 1//分页查询复杂条件的用户.定义了多个实体: PageDTO返回的, PageQuery查询实体,UserQuery继承了PageQuery.这样不管是什么分页查询很多代码可以复用
 2    @Override
 3    public PageDTO<UserVO> queryUsersPage(UserQuery query) {
 4        String name = query.getName();
 5        Integer status = query.getStatus();
 6        //1.构建查询条件
 7        Page<User> page = query.toMpPageDefaultSortByUpdate();
 8//        //下面的代码封装到PageQuery里(将和业务关系不大的代码封装)
 9//        //1.1分页条件
10//        Page<User> page = Page.of(query.getPageNo(), query.getPageSize()) ;
11//        //1.2排序条件
12//        if(StrUtil.isNotBlank(query.getSortBy())){
13//            //用户给出了需要排序的字段
14//            page.addOrder(new OrderItem(query.getSortBy(), query.getIsAsc()));
15//        }else {
16//            //没有给出, 则默认按更新时间排序
17//            page.addOrder(new OrderItem("update_time", query.getIsAsc()));
18//        }
19
20        //2.分页查询
21        Page<User> p = lambdaQuery()
22                .like(name != null, User::getUsername, name)
23                .eq(status != null, User::getStatus, status)
24                .page(page);
25        
26        //3.封装VO结果
27//        return PageDTO.of(p, user -> BeanUtil.copyProperties(user, UserVO.class));
28        return PageDTO.of(p, user -> {
29            //对于PO和VO字段不一致的情况作处理
30            //1.拷贝基础属性
31            UserVO vo = BeanUtil.copyProperties(user, UserVO.class);
32            //2.处理特殊逻辑
33            vo.setUsername(vo.getUsername().substring(0,vo.getUsername().length()-2) + "**"); //隐藏用户名后两位
34            return vo;
35        });
36//        封装将po转vo的代码到PageDTO并抽象能够通用.
37//        PageDTO<UserVO> dto = new PageDTO<>();
38//        //3.1总条数
39//        dto.setTotal(p.getTotal());
40//        //3.2总页数
41//        dto.setPages(p.getPages());
42//        //3.3当前数据
43//        List<User> records = p.getRecords();
44//        if(CollUtil.isEmpty(records)){
45//            //空的就放空列表
46//            dto.setList(Collections.emptyList());
47//            return dto;
48//        }
49//        //3.4拷贝User的VO
50//        dto.setList(BeanUtil.copyToList(records, UserVO.class));
51//        //4.返回
52//        return dto;
53    }

Docker

常见命令

文档: docs.docker.com

 1docker pull #从镜像仓库将镜像拉取到本地仓库,名字后面跟版本号, 不跟就是latest.
 2docker puth #将本地仓库的镜像上传到镜像仓库/私服
 3docker images #查看镜像
 4docker rmi #删除镜像(remove image)
 5docker build #构建镜像
 6docker save #保存镜像到文件
 7docker load #加载文件中的镜像
 8docker run #根据镜像开启容器
 9docker inspect #查看容器详情
10docker stop #停止容器
11docker start #启动容器
12docker ps #查看现存容器(process status)
13docker rm #删除容器
14docker logs #查看日志
15docker exec #进入容器环境执行命令

案例:

 1docker pull nginx #拉取nginx镜像
 2docker images #查看镜像
 3docker save -o nginx.tar nginx:latest
 4docker rmi nginx:latest
 5docker load -i nginx.tar 
 6docker run -d --name nginx -p 80:80 nginx #-d后台,-p端口映射前面是本机后面是容器,最后跟上镜像名
 7docker stop nginx
 8docker start nginx
 9docker logs -f nginx #-f持续输出日志
10docker exec -it nginx bash #-it可交互终端 
11exit
12docker exec -it mysql mysql -uroot -p
13docker rm nginx -f #-f强制删除,不然需要先stop再删除.
14

数据卷挂载

直接进入容器内修改是不可能的, 没有很多命令.于是产生了需求.

数据卷(volume)是虚拟目录, 映射容器目录和主机目录. 创建数据卷, 那么docker会在/var/lib/docker/volumes下创建文件.这样容器内的文件通过数据卷和主机的文件绑定起来了, docker进行双向绑定.

1docker volume create #创建数据卷
2docker volume ls #查看所有数据卷
3docker volume rm #删除指定数据卷
4docker volume inspect #查看某个数据卷详情
5docker volume prune #清除所有未使用的数据卷

案例1: 修改nginx的文件, 将静态资源部署到nginx的html目录

执行docker run命令时, 使用-v 数据卷:容器内目录 可以完成数据卷挂载

1docker run -d --name nginx -p 80:80 -v html:/usr/share/nginx/html nginx
2docker volume ls
3docker volume inspect html
4cd /var/lib/docker/volumes/html/_data
5ll
6#修改html文件,可以成功修改容器内文件.
7#上传静态文件, 容器内也有了这个文件

本地目录挂载

案例2: 查看mysql容器, 看看是否有数据卷挂载(是,匿名卷); 基于宿主机目录实现mysql数据目录,配置文件,初始化脚本的挂载

  • 挂载/root/mysql/data 到容器内的/var/lib/mysql
  • 挂载/root/mysql/init到容器内的/docker-entrypoint-initdb.d目录.携带课前准备的sql脚本
  • 挂载/root/mysql/conf到容器内的/etc/mysql/conf.d目录,携带课前准备的配置文件.

执行docker run命令时, 使用 -v 本地目录:容器内目录 可以完成本地目录挂载

本地目录必须以/ or ./开头. 这是为了区分数据卷

1docker run -d\
2 --name mysql \
3 -p 3306:3306 \
4 -e TZ=Asia/Shanghai \
5 -v /root/mysql/data:/var/lib/mysql
6 -v /root/mysql/init:/docker-entrypoint-initdb.d
7 -v /root/mysql/conf:/etc/mysql/conf.d
8 mysql

即便删掉了容器, 数据依然存在, 实现了数据的持久保存, 下次创建容器时只需要直接指定挂载便实现了数据迁移. 而默认的匿名卷模式目录太深, 容器删除后数据卷在那里, 但是重新创建后又会生成新的匿名卷, 迁移还需要手动转移.

自定义镜像Dockerfile

参考资料

网络

默认情况下, 所有容器都是以bridge方式连接到Docker的一个虚拟网桥上. 但是这样Ip地址可能会变动

加入自定义网络才可以通过容器名互相访问, Docker的网络操作命令如下:

命令 说明
docker network create 创建一个网络
docker network ls 查看所有网络
docker network rm 删除指定网络
docker network prune 清除未使用的网络
docker network connect 使指定容器连接加入某网络
docker network disconnect 使指定容器连接离开某网络
docker network inspect 查看网络详细信息
1docker network create heima
2docker network connect heima mysql
3docker network connect heima dd
4docker run -d --name mysql -p 3306:3306 --network heima mysql #创建时加入网络
5docker exec -it dd bash
6ping mysql #可以直接用容器名ping

部署项目

部署Java应用

1docker build -t hmall .
2docker images
3docker run -d --name hm -p 80:80 --network heima hmall
4docker ps
5docker logs -f hm

部署前端

创建额nginx容器, 将提供的nginx.conf, html目录和容器挂载

1docker run -d \
2	--name nginx \
3	-p 18080:18080 \
4	-p 18081:18081 \
5	-v /root/nginx/html:/usr/share/nginx/html \
6	-v /root/nginx/nginx.conf:/etc/ngnix.conf \
7	--network heima
8	nginx

DockerCompose

Docker Compose通过一个单独的docker-compose.yml 模板文件(YAML 格式)来定义一组相关联的应用容器,帮助我们实现多个相互关联的Docker容器的快速部署。

 1version: "3.8"
 2
 3services:
 4  mysql:
 5    image: mysql
 6    container_name: mysql
 7    ports:
 8      - "3306:3306"
 9    environment:
10      TZ: Asia/Shanghai
11      MYSQL_ROOT_PASSWORD: 123
12    volumes:
13      - "./mysql/conf:/etc/mysql/conf.d"
14      - "./mysql/data:/var/lib/mysql"
15      - "./mysql/init:/docker-entrypoint-initdb.d"
16    networks:
17      - hm-net
18  hmall:
19    build:
20       context: .
21       dockerfile: Dockerfile
22    container_name: hmall
23    ports:
24      - "8080:8080"
25    networks:
26      - hm-net
27    depends_on:
28      - mysql
29  nginx:
30    image: nginx
31    container_name: nginx
32    ports:
33      - "18080:18080"
34      - "18081:18081"
35    volumes:
36      - "./nginx/nginx.conf:/etc/nginx/nginx.conf"
37      - "./nginx/html:/usr/share/nginx/html"
38    depends_on:
39      - hmall
40    networks:
41      - hm-net
42networks:
43  hm-net:
44    name: hmall
参数 说明
-f 指定compose文件的路径和名称
-p 指定project名称
up 创建并启动所有service容器
down 停止并移除所有容器、网络
ps 列出所有启动的容器
logs 查看指定容器的日志
stop 停止容器
start 启动容器
restart 重启容器
top 查看运行的进程
exec 在指定的运行中容器中执行命令
1docker compose up -d  #就在本目录, 则不需要-f指定文件
2docker compose down  #全部都给移除了, 干干净净.

微服务

普通地服务调用

购物车业务中需要查询商品信息,但商品信息查询的逻辑全部迁移到了item-service服务,导致我们无法查询。想解决这个问题,我们就必须把原本本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。

RestTemplate

Spring给我们提供了一个RestTemplate的API,可以方便的实现Http请求的发送。RestTemplate提供了常见的Get、Post、Put、Delete请求都支持,如果请求参数比较复杂,还可以使用exchange方法来构造请求。

编写配置类, 将RestTemplate注册为一个Bean:

 1package com.hmall.cart.config;
 2
 3import org.springframework.context.annotation.Bean;
 4import org.springframework.context.annotation.Configuration;
 5import org.springframework.web.client.RestTemplate;
 6
 7@Configuration
 8public class RemoteCallConfig {
 9
10    @Bean
11    public RestTemplate restTemplate() {
12        return new RestTemplate();
13    }
14}

远程调用

 1private void handleCartItems(List<CartVO> vos) {
 2    // TODO 1.获取商品id
 3    Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
 4    // 2.查询商品
 5    // List<ItemDTO> items = itemService.queryItemByIds(itemIds);
 6    // 2.1.利用RestTemplate发起http请求,得到http的响应
 7    ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
 8            "http://localhost:8081/items?ids={ids}",
 9            HttpMethod.GET,
10            null,
11            new ParameterizedTypeReference<List<ItemDTO>>() {
12            }, //利用反射获取字节码, List<ItemDTO>.class是不行的, 泛型会被擦除
13            Map.of("ids", CollUtil.join(itemIds, ",")) //将集合转字符串, 通过逗号拼接, 满足输入参数要求
14    );
15    // 2.2.解析响应
16    if(!response.getStatusCode().is2xxSuccessful()){
17        // 查询失败,直接结束
18        return;
19    }
20    List<ItemDTO> items = response.getBody();
21    if (CollUtils.isEmpty(items)) {
22        return;
23    }
24    // 3.转为 id 到 item的map
25    Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
26    // 4.写入vo
27    for (CartVO v : vos) {
28        ItemDTO item = itemMap.get(v.getItemId());
29        if (item == null) {
30            continue;
31        }
32        v.setNewPrice(item.getPrice());
33        v.setStatus(item.getStatus());
34        v.setStock(item.getStock());
35    }
36}

服务注册和发现(解决多个实例地址不一样如何调用的问题)

注册中心对服务提供者提供心跳检测, 注册服务. 向服务消费者推送变更, 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表.

Nacos注册中心

服务注册

微服务提供者需要将服务注册到注册中心, 步骤如下:

  • 微服务中引入依赖

    1<!--nacos 服务注册发现-->
    2<dependency>
    3    <groupId>com.alibaba.cloud</groupId>
    4    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    5</dependency>
    
  • 配置Nacos地址

    1spring:
    2  application:
    3    name: item-service # 服务名称
    4  cloud:
    5    nacos:
    6      server-addr: 192.168.150.101:8848 # nacos地址
    
  • 重启程序

访问nacos控制台,可以发现服务注册成功.

服务发现

服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:

  • 引入依赖

    服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。

    1<!--nacos 服务注册发现-->
    2<dependency>
    3    <groupId>com.alibaba.cloud</groupId>
    4    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    5</dependency>
    

    这里Nacos的依赖于服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务可以是调用者,也可以是提供者。

  • 配置Nacos地址

    1spring:
    2  cloud:
    3    nacos:
    4      server-addr: 192.168.150.101:8848
    
  • 发现并调用服务

    微服务有多个实例, 服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:

    • 随机
    • 轮询
    • IP的hash
    • 最近最少访问

    这里我们可以选择最简单的随机负载均衡。

    服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用.

     1    private void handleCartItems(List<CartVO> vos) {
     2        //这边依赖ItemService, 通过网络远程请求来完成, 使用spring的restTemplate
     3        // 1.获取商品id
     4        Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
     5        //2.查询商品
     6        List<ItemDTO> items = itemService.queryItemByIds(itemIds);
     7        //2.1根据服务名称获取服务的实例列表
     8        List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
     9        if(CollUtil.isEmpty(instances)){
    10            return;
    11        }
    12        //2.2手写负载均衡, 从实例列表中挑选实例
    13        ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
    14        //2.3利用RestTemplate发起http请求,得到http响应
    15        ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
    16                instance.getUri()+"/items?ids={ids}",
    17//                "http://localhost:8081/items?ids={ids}",
    18                HttpMethod.GET,
    19                null,
    20                new ParameterizedTypeReference<List<ItemDTO>>() {
    21                }, //利用反射获取字节码, List<ItemDTO>.class是不行的, 泛型会被擦除
    22                Map.of("ids", CollUtil.join(itemIds, ",")) //将集合转字符串, 通过逗号拼接, 满足输入参数要求
    23        );
    24        //2.4解析结果
    25        if(!response.getStatusCode().is2xxSuccessful()){
    26            //查询失败
    27            return;
    28        }
    29        List<ItemDTO> items = response.getBody();
    30        if (CollUtils.isEmpty(items)) {
    31            return;
    32        }
    33        // 3.转为 id 到 item的map
    34        Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
    35        // 4.写入vo
    36        for (CartVO v : vos) {
    37            ItemDTO item = itemMap.get(v.getItemId());
    38            if (item == null) {
    39                continue;
    40            }
    41            v.setNewPrice(item.getPrice());
    42            v.setStatus(item.getStatus());
    43            v.setStock(item.getStock());
    44        }
    45    }
    

OpenFeign(实现更简单)

利用RestTemplate实现的远程调用的代码太复杂了, 想让远程调用像本地方法调用一样简单这就要用到OpenFeign组件了.

快速入门(通过client构造请求, feign完成负载均衡和请求)

1.服务调用者引入依赖:

 1  <!--openFeign-->
 2  <dependency>
 3      <groupId>org.springframework.cloud</groupId>
 4      <artifactId>spring-cloud-starter-openfeign</artifactId>
 5  </dependency>
 6  <!--负载均衡器-->
 7  <dependency>
 8      <groupId>org.springframework.cloud</groupId>
 9      <artifactId>spring-cloud-starter-loadbalancer</artifactId>
10  </dependency>

2.启用OpenFeign. 在微服务调用者cart-service的启动类CartApplication上添加注解@EnableFeignClients,启动OpenFeign功能:

1@EnableFeignClients
2@MapperScan("com.hmall.cart.mapper")
3@SpringBootApplication
4public class CartApplication {
5    public static void main(String[] args) {
6        SpringApplication.run(CartApplication.class, args);
7    }
8}

3.编写OpenFeign客户端. 在微服务调用者中, 新定义接口, 编写Feign客户端:

 1package com.hmall.cart.client;
 2
 3import com.hmall.cart.domain.dto.ItemDTO;
 4import org.springframework.cloud.openfeign.FeignClient;
 5import org.springframework.web.bind.annotation.GetMapping;
 6import org.springframework.web.bind.annotation.RequestParam;
 7
 8import java.util.List;
 9
10@FeignClient("item-service")
11public interface ItemClient {
12
13    @GetMapping("/items")
14    List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
15}

这里只需要声明接口,无需实现方法。接口中的几个关键信息:

  • @FeignClient("item-service") :声明服务名称
  • @GetMapping :声明请求方式
  • @GetMapping("/items") :声明请求路径
  • @RequestParam("ids") Collection<Long> ids :声明请求参数
  • List<ItemDTO> :返回值类型

有了上述信息,OpenFeign就可以利用动态代理帮我们实现这个方法,并且向http://item-service/items发送一个GET请求,携带ids为请求参数,并自动将返回值处理为List<ItemDTO>

我们只需要直接调用这个方法,即可实现远程调用了。

4.使用FeignClient

feign替我们完成了服务拉取、负载均衡、发送http请求的所有工作, 我们不再需要RestTemplate了,还省去了RestTemplate的注册.

 1private void handleCartItems(List<CartVO> vos) {
 2        //这边依赖ItemService, 通过网络远程请求来完成, 使用spring的restTemplate
 3        // 1.获取商品id
 4        Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
 5        // 2.查询商品
 6//        List<ItemDTO> items = itemService.queryItemByIds(itemIds);
 7//        //2.1根据服务名称获取服务的实例列表
 8//        List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
 9//        if(CollUtil.isEmpty(instances)){
10//            return;
11//        }
12//        //2.2手写负载均衡, 从实例列表中挑选实例
13//        ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
14//        //2.3利用RestTemplate发起http请求,得到http响应
15//        ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
16//                instance.getUri()+"/items?ids={ids}",
17////                "http://localhost:8081/items?ids={ids}",
18//                HttpMethod.GET,
19//                null,
20//                new ParameterizedTypeReference<List<ItemDTO>>() {
21//                }, //利用反射获取字节码, List<ItemDTO>.class是不行的, 泛型会被擦除
22//                Map.of("ids", CollUtil.join(itemIds, ",")) //将集合转字符串, 通过逗号拼接, 满足输入参数要求
23//        );
24        //2.4解析结果
25//        if(!response.getStatusCode().is2xxSuccessful()){
26//            //查询失败
27//            return;
28//        }
29//        List<ItemDTO> items = response.getBody();
30//        if (CollUtils.isEmpty(items)) {
31//            return;
32//        }
33        //使用了Open Feign上面的就不需要了, 别人帮做了
34    	//itemClient注入得到
35        List<ItemDTO> items = itemClient.queryItemByIds(itemIds);
36        // 3.转为 id 到 item的map
37        Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
38        // 4.写入vo
39        for (CartVO v : vos) {
40            ItemDTO item = itemMap.get(v.getItemId());
41            if (item == null) {
42                continue;
43            }
44            v.setNewPrice(item.getPrice());
45            v.setStatus(item.getStatus());
46            v.setStock(item.getStock());
47        }
48    }

连接池

Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:

  • HttpURLConnection:默认实现,不支持连接池
  • Apache HttpClient :支持连接池
  • OKHttp:支持连接池

因此我们通常会使用带有连接池的客户端来代替默认的HttpURLConnection。比如,我们使用OK Http.

1.微服务中引入依赖

1<!--OK http 的依赖 -->
2<dependency>
3  <groupId>io.github.openfeign</groupId>
4  <artifactId>feign-okhttp</artifactId>
5</dependency>

2.微服务中开启连接池

1feign:
2  okhttp:
3    enabled: true # 开启OKHttp功能

重启服务,连接池就生效了。

最佳实践

需要在每个微服务调用者中再次定义微服务提供者的Client接口,这不是重复编码吗? 有什么办法能加避免重复编码呢?避免重复编码的办法就是抽取. 这里有两种抽取思路:

  • 思路1:抽取到微服务之外的公共module
  • 思路2:每个微服务自己抽取一个module(每个微服务都有个api模块)

方案1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。

方案2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。

由于item-service已经创建好,无法继续拆分,因此这里我们采用方案1.

抽取Feign客户端

hmall下定义一个新的module,命名为hm-api, 将client和需要的类复制过来. 现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了。

引入

在微服务调用者的pom.xml中引入hm-api模块:

1  <!--feign模块-->
2  <dependency>
3      <groupId>com.heima</groupId>
4      <artifactId>hm-api</artifactId>
5      <version>1.0.0</version>
6  </dependency>

删除调用者与api模块已经重复的类之后报错了, 因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient(pom引入的api包),所以报错了。

解决办法很简单,在cart-service的启动类上添加声明即可,两种方式:

  • 方式1:声明扫描包:

    1//basePackages指定远程包的位置, 这样才能被SpringBootApplication扫描到, 才能用这些FeignClient.
    2@EnableFeignClients(basePackages = "com.hmall.api.client")
    3@MapperScan("com.hmall.cart.mapper")
    4@SpringBootApplication
    5public class CartApplication {
    6    public static void main(String[] args) {
    7        SpringApplication.run(CartApplication.class, args);
    8    }
    9}
    
  • 方式2:声明要用的FeignClient

    1@EnableFeignClients(client = {ItemClient.class})
    2@MapperScan("com.hmall.cart.mapper")
    3@SpringBootApplication
    4public class CartApplication {
    5    public static void main(String[] args) {
    6        SpringApplication.run(CartApplication.class, args);
    7    }
    8}
    

日志配置

OpenFeign只会在FeignClient所在包的日志级别为DEBUG时,才会输出日志。而且其日志级别有4级:

  • NONE:不记录任何日志信息,这是默认值。
  • BASIC:仅记录请求的方法,URL以及响应状态码和执行时间
  • HEADERS:在BASIC的基础上,额外记录了请求和响应的头信息
  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据。

1.定义日志级别

在hm-api模块下新建一个配置类,定义Feign的日志级别:

 1package com.hmall.api.config;
 2
 3import feign.Logger;
 4import org.springframework.context.annotation.Bean;
 5
 6public class DefaultFeignConfig {
 7    @Bean
 8    public Logger.Level feignLogLevel(){
 9        return Logger.Level.FULL;
10    }
11}

2.配置

接下来,要让日志级别生效,还需要配置这个类。有两种方式:

  • 局部生效:在某个FeignClient中配置,只对当前FeignClient生效

    1@FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)
    
  • 全局生效:在@EnableFeignClients中配置,针对所有FeignClient生效。

    1@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)
    

网关路由

网关就是络的口。数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验

前端请求不能直接访问微服务,而是要请求网关:

  • 网关可以做安全控制,也就是登录身份校验,校验通过才放行
  • 通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去

快速入门

创建项目hm-gateway并引入依赖:

 1<?xml version="1.0" encoding="UTF-8"?>
 2<project xmlns="http://maven.apache.org/POM/4.0.0"
 3         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 4         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
 5    <parent>
 6        <artifactId>hmall</artifactId>
 7        <groupId>com.heima</groupId>
 8        <version>1.0.0</version>
 9    </parent>
10    <modelVersion>4.0.0</modelVersion>
11
12    <artifactId>hm-gateway</artifactId>
13
14    <properties>
15        <maven.compiler.source>11</maven.compiler.source>
16        <maven.compiler.target>11</maven.compiler.target>
17    </properties>
18    <dependencies>
19        <!--common-->
20        <dependency>
21            <groupId>com.heima</groupId>
22            <artifactId>hm-common</artifactId>
23            <version>1.0.0</version>
24        </dependency>
25        <!--网关-->
26        <dependency>
27            <groupId>org.springframework.cloud</groupId>
28            <artifactId>spring-cloud-starter-gateway</artifactId>
29        </dependency>
30        <!--nacos discovery-->
31        <dependency>
32            <groupId>com.alibaba.cloud</groupId>
33            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
34        </dependency>
35        <!--负载均衡-->
36        <dependency>
37            <groupId>org.springframework.cloud</groupId>
38            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
39        </dependency>
40    </dependencies>
41    <build>
42        <finalName>${project.artifactId}</finalName>
43        <plugins>
44            <plugin>
45                <groupId>org.springframework.boot</groupId>
46                <artifactId>spring-boot-maven-plugin</artifactId>
47            </plugin>
48        </plugins>
49    </build>
50</project>

创建启动类:

 1package com.hmall.gateway;
 2
 3import org.springframework.boot.SpringApplication;
 4import org.springframework.boot.autoconfigure.SpringBootApplication;
 5
 6@SpringBootApplication
 7public class GatewayApplication {
 8    public static void main(String[] args) {
 9        SpringApplication.run(GatewayApplication.class, args);
10    }
11}
配置路由

hm-gateway模块的resources目录新建一个application.yaml文件,内容如下:

 1server:
 2  port: 8080
 3spring:
 4  application:
 5    name: gateway
 6  cloud:
 7    nacos:
 8      server-addr: 192.168.170.128:8848
 9    gateway:
10      routes:
11        - id: item-service # 路由规则id,自定义,唯一
12          uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
13          predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
14            - Path=/items/**  # 这里是以请求路径作为判断规则
15            - Path=/search/**
16          filters:
17            - AddRequestHeader=Truth, anyone workhard will be rich #添加请求头的过滤器
18        - id: user-service
19            uri: lb://user-service
20            predicates:
21              - Path=/users/**,/addresses/**
22        - id: pay-service
23            uri: lb://pay-service
24            predicates:
25              - Path=/pay-orders/**
26        - id: trade-service
27            uri: lb://trade-service
28            predicates:
29              - Path=/orders/**
30        - id: cart-service
31            uri: lb://cart-service
32            predicates:
33              - Path=/carts/**

uri中需要是微服务名称, predicates中填入路径, 这样对应路径的请求就会被转发至响应的微服务.

访问http://localhost:8080/items/page?pageNo=1&pageSize=1, 实际上被转发到item-service微服务中.

路由过滤

路由规则的定义语法如下:

1spring:
2  cloud:
3    gateway:
4      routes:
5        - id: item
6          uri: lb://item-service
7          predicates:
8            - Path=/items/**,/search/**

其中routes对应的类型是GatewayProperties类,

1@ConfigurationProperties("spring.cloud.gateway")
2@Validated
3public class GatewayProperties {
4    public static final String PREFIX = "spring.cloud.gateway";
5    private final Log logger = LogFactory.getLog(this.getClass());
6    @NotNull
7    @Valid
8    private List<RouteDefinition> routes = new ArrayList();

是一个集合,也就是说可以定义很多路由规则。集合中的RouteDefinition就是具体的路由规则定义,其中常见的属性如下:

 1@Validated
 2public class RouteDefinition {
 3    private String id;
 4    @NotEmpty
 5    @Valid
 6    private List<PredicateDefinition> predicates = new ArrayList();
 7    @Valid
 8    private List<FilterDefinition> filters = new ArrayList();
 9    @NotNull
10    private URI uri;

四个属性含义如下:

  • id:路由的唯一标示
  • predicates:路由断言,其实就是匹配条件
  • filters:路由过滤条件,后面讲
  • uri:路由目标地址,lb://代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。

predicates,也就是路由断言。SpringCloudGateway中支持的断言类型有很多:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些header - Header=X-Request-Id, \d+
Host 请求必须是访问某个host(域名) - Host=.somehost.org,.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack或者- Query=name
RemoteAddr 请求者的ip必须是指定范围 - RemoteAddr=192.168.1.1/24
weight 权重处理

网关登录校验

我们的登录是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:

  • 每个微服务都需要知道JWT的秘钥,不安全
  • 每个微服务重复编写登录校验代码、权限校验代码,麻烦

既然网关是所有微服务的入口,一切请求都需要先经过网关。我们:

  • 只需要在网关和用户服务保存秘钥
  • 只需要在网关开发登录校验功能

网关过滤器

想在请求转发之前做登录校验,就必须了解Gateway内部工作的基本原理。

  1. 客户端请求进入网关后由HandlerMapping对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给WebHandler去处理。
  2. WebHandler则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为**Filter**)。
  3. Filter分为两部分,是因为Filter内部的逻辑分为prepost两部分,分别会在请求路由到微服务之前之后被执行。
  4. 只有所有Filterpre逻辑都依次顺序执行通过后,请求才会被路由到微服务。
  5. 微服务返回结果后,再倒序执行Filterpost逻辑。
  6. 最终把响应结果返回。

最终请求转发是有一个名为NettyRoutingFilter的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到NettyRoutingFilter之前,这就符合我们的需求了

那么,该如何实现一个网关过滤器呢?

网关过滤器链中的过滤器有两种:

  • GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route.
  • GlobalFilter:全局过滤器,作用范围是所有路由,不可配置。

GatewayFilterGlobalFilter这两种过滤器的方法签名完全一致:

1/**
2 * 处理请求并将其传递给下一个过滤器
3 * @param exchange 当前请求的上下文,其中包含request、response等各种数据
4 * @param chain 过滤器链,基于它向下传递请求
5 * @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
6 */
7Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

Gateway内置的GatewayFilter过滤器使用起来非常简单,无需编码,只要在yaml文件中简单配置即可。而且其作用范围也很灵活,配置在哪个Route下,就作用于哪个Route.

例如,有一个过滤器叫做AddRequestHeaderGatewayFilterFacotry,顾明思议,就是添加请求头的过滤器,可以给请求添加一个请求头并传递到下游微服务。

使用的使用只需要在application.yaml中这样配置:

 1spring:
 2  cloud:
 3    gateway:
 4      routes:
 5      - id: test_route
 6        uri: lb://test-service
 7        predicates:
 8          -Path=/test/**
 9        filters:
10          - AddRequestHeader=key, value # 逗号之前是请求头的key,逗号之后是value

如果想要让过滤器作用于所有的路由,则可以这样配置:

 1spring:
 2  cloud:
 3    gateway:
 4      default-filters: # default-filters下的过滤器可以作用于所有路由
 5        - AddRequestHeader=key, value
 6      routes:
 7      - id: test_route
 8        uri: lb://test-service
 9        predicates:
10          -Path=/test/**

自定义过滤器

无论是GatewayFilter还是GlobalFilter都支持自定义,只不过编码方式、使用方式略有差别。

自定义过滤器过滤器还可以支持动态配置参数,不过实现起来比较复杂,示例:

 1
 2@Component
 3public class PrintAnyGatewayFilterFactory // 父类泛型是内部类的Config类型
 4                extends AbstractGatewayFilterFactory<PrintAnyGatewayFilterFactory.Config> {
 5
 6    @Override
 7    public GatewayFilter apply(Config config) {
 8        // OrderedGatewayFilter是GatewayFilter的子类,包含两个参数:
 9        // - GatewayFilter:过滤器
10        // - int order值:值越小,过滤器执行优先级越高
11        return new OrderedGatewayFilter(new GatewayFilter() {
12            @Override
13            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
14                // 获取config值
15                String a = config.getA();
16                String b = config.getB();
17                String c = config.getC();
18                // 编写过滤器逻辑
19                System.out.println("a = " + a);
20                System.out.println("b = " + b);
21                System.out.println("c = " + c);
22                // 放行
23                return chain.filter(exchange);
24            }
25        }, 100);
26    }
27
28    // 自定义配置属性,成员变量名称很重要,下面会用到
29    @Data
30    static class Config{
31        private String a;
32        private String b;
33        private String c;
34    }
35    // 将变量名称依次返回,顺序很重要,将来读取参数时需要按顺序获取
36    @Override
37    public List<String> shortcutFieldOrder() {
38        return List.of("a", "b", "c");
39    }
40        // 返回当前配置类的类型,也就是内部的Config
41    @Override
42    public Class<Config> getConfigClass() {
43        return Config.class;
44    }
45
46}

然后在yaml文件中使用:

1spring:
2  cloud:
3    gateway:
4      default-filters:
5            - PrintAny=1,2,3 # 注意,这里多个参数以","隔开,将来会按照shortcutFieldOrder()方法返回的参数顺序依次复制

上面这种配置方式参数必须严格按照shortcutFieldOrder()方法的返回参数名顺序来赋值。

还有一种用法,无需按照这个顺序,就是手动指定参数名:

1spring:
2  cloud:
3    gateway:
4      default-filters:
5            - name: PrintAny
6              args: # 手动指定参数名,无需按照参数顺序
7                a: 1
8                b: 2
9                c: 3

自定义GlobalFilter

自定义GlobalFilter则简单很多,直接实现GlobalFilter即可,而且也无法设置动态参数:

 1@Component
 2public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
 3    @Override
 4    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
 5        // 编写过滤器逻辑
 6        System.out.println("未登录,无法访问");
 7        // 放行
 8        // return chain.filter(exchange);
 9
10        // 拦截
11        ServerHttpResponse response = exchange.getResponse();
12        response.setRawStatusCode(401);
13        return response.setComplete();
14    }
15
16    @Override
17    public int getOrder() {
18        // 过滤器执行顺序,值越小,优先级越高
19        return 0;
20    }
21}

登录校验

服务保护和分布式事务

微服务保护

微服务保护的方案有很多,比如:

  • 请求限流: 限制或控制接口访问的并发流量,避免服务因流量激增而出现故障。
  • 线程隔离: 当一个业务接口响应时间长,而且并发高时,就可能耗尽服务器的线程资源,导致服务内的其它接口受到影响。所以我们必须把这种影响降低,或者缩减影响的范围
  • 服务熔断: 线程隔离虽然避免了雪崩问题,但故障服务依然会拖慢购物车服务的接口响应速度。我们需编写服务降级逻辑, 统计服务提供方的异常比例,当比例过高表明该接口会影响到其它服务,应该拒绝调用该接口,而是直接走降级逻辑。

微服务保护的技术有很多,但在目前国内使用较多的还是Sentinel

分布式事务

出现以下情况之一就可能产生分布式事务问题:

  • 业务跨多个服务实现
  • 业务跨多个数据源实现

Seata

解决分布式事务的思想非常简单:

就是找一个统一的事务协调者,与多个分支事务通信,检测每个分支事务的执行状态,保证全局事务下的每一个分支事务同时成功或失败即可。大多数的分布式事务框架都是基于这个理论来实现的。

Seata也不例外,在Seata的事务管理中有三个重要的角色:

  • TC **(Transaction Coordinator) - 事务协调者:**维护全局和分支事务的状态,协调全局事务提交或回滚。
  • TM (Transaction Manager) - **事务管理器:**定义全局事务的范围、开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager) - **资源管理器:**管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

XA模式

原始模式

一阶段:

  • 事务协调者通知每个事务参与者执行本地事务
  • 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁

二阶段:

  • 事务协调者基于一阶段的报告来判断下一步操作
  • 如果一阶段都成功,则通知所有事务参与者,提交事务
  • 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
Seata的XA模型

RM一阶段的工作:

  1. 注册分支事务到TC
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC

TC二阶段的工作:

  1. TC检测各分支事务执行状态
  2. 如果都成功,通知所有RM提交事务
  3. 如果有失败,通知所有RM回滚事务

RM二阶段的工作:

  • 接收TC指令,提交或回滚事务
优缺点

XA模式的优点是什么?

  • 事务的强一致性,满足ACID原则
  • 常用数据库都支持,实现简单,并且没有代码侵入

XA模式的缺点是什么?

  • 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
  • 依赖关系型数据库实现事务

AT模式

AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。

Seata的AT模型

阶段一RM的工作:

  • 注册分支事务
  • 记录undo-log(数据快照)
  • 执行业务sql并提交
  • 报告事务状态

阶段二提交时RM的工作:

  • 删除undo-log即可

阶段二回滚时RM的工作:

  • 根据undo-log恢复数据到更新前

AT模式下,当前分支事务执行流程如下:

一阶段

  1. TM发起并注册全局事务到TC
  2. TM调用分支事务
  3. 分支事务准备执行业务SQL
  4. RM拦截业务SQL,根据where条件查询原始数据,形成快照。
  5. RM执行业务SQL,提交本地事务,释放数据库锁。此时 money = 90
  6. RM报告本地事务状态给TC

二阶段

  1. TM通知TC事务结束
  2. TC检查分支事务状态
    1. 如果都成功,则立即删除快照
    2. 如果有分支事务失败,需要回滚。读取快照数据({“id”: 1, “money”: 100}),将快照恢复到数据库。此时数据库再次恢复为100
AT与XA的区别

简述AT模式与XA模式最大的区别是什么?

  • XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
  • XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
  • XA模式强一致;AT模式最终一致

可见,AT模式使用起来更加简单,无业务侵入,性能更好。因此企业90%的分布式事务都可以用AT模式来解决。

MQ

微服务一旦拆分,必然涉及到服务之间的相互调用,目前我们服务之间调用采用的都是基于OpenFeign的调用。这种调用中,调用者发起请求后需要等待服务提供者执行业务返回结果后,才能继续执行后面的业务。也就是说调用者在调用过程中处于阻塞状态,因此我们称这种调用方式为同步调用,也可以叫同步通讯。如果我们的业务需要实时得到服务提供方的响应,则应该选择同步通讯(同步调用)。而如果我们追求更高的效率,并且不需要实时响应,则应该选择异步通讯(异步调用)。

异步调用的优势包括:

  • 耦合度更低
  • 性能更好
  • 业务拓展性强
  • 故障隔离,避免级联失败

几种常见MQ的对比:

追求可用性:Kafka、 RocketMQ 、RabbitMQ

追求可靠性:RabbitMQ、RocketMQ

追求吞吐能力:RocketMQ、Kafka

追求消息低延迟:RabbitMQ、Kafka

RabbitMQ

  • publisher:生产者,也就是发送消息的一方
  • consumer:消费者,也就是消费消息的一方
  • queue:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理
  • exchange:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。
  • virtual host:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue

SpringAMQP

由于RabbitMQ采用了AMQP协议,因此它具备跨语言的特性。任何语言只要遵循AMQP协议收发消息,都可以与RabbitMQ交互。Spring的官方刚好基于RabbitMQ提供了这样一套消息收发的模板工具:SpringAMQP。并且还基于SpringBoot对其实现了自动装配,使用起来非常方便。

SpringAMQP提供了三个功能:

  • 自动声明队列、交换机及其绑定关系
  • 基于注解的监听器模式,异步接收消息
  • 封装了RabbitTemplate工具,用于发送消息

消息发送

rabbitTemplate.convertAndSend(queueName, message);

 1package com.itheima.publisher.amqp;
 2
 3import org.junit.jupiter.api.Test;
 4import org.springframework.amqp.rabbit.core.RabbitTemplate;
 5import org.springframework.beans.factory.annotation.Autowired;
 6import org.springframework.boot.test.context.SpringBootTest;
 7
 8@SpringBootTest
 9public class SpringAmqpTest {
10
11    @Autowired
12    private RabbitTemplate rabbitTemplate;
13
14    @Test
15    public void testSimpleQueue() {
16        // 队列名称
17        String queueName = "simple.queue";
18        // 消息
19        String message = "hello, spring amqp!";
20        // 发送消息
21        rabbitTemplate.convertAndSend(queueName, message);
22    }
23}

消息接收

@RabbitListener

 1package com.itheima.consumer.listener;
 2
 3import org.springframework.amqp.rabbit.annotation.RabbitListener;
 4import org.springframework.stereotype.Component;
 5
 6@Component
 7public class SpringRabbitListener {
 8        // 利用RabbitListener来声明要监听的队列信息
 9    // 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
10    // 可以看到方法体中接收的就是消息体的内容
11    @RabbitListener(queues = "simple.queue")
12    public void listenSimpleQueueMessage(String msg) throws InterruptedException {
13        System.out.println("spring 消费者接收到消息:【" + msg + "】");
14    }
15}

WorkQueues模型

简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息

当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。

此时就可以使用work 模型,多个消费者共同处理消息处理,消息处理的速度就能大大提高了。

 1@RabbitListener(queues = "work.queue")
 2public void listenWorkQueue1(String msg) throws InterruptedException {
 3    System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
 4    Thread.sleep(20);
 5}
 6
 7@RabbitListener(queues = "work.queue")
 8public void listenWorkQueue2(String msg) throws InterruptedException {
 9    System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
10    Thread.sleep(200);
11}

消息是平均分配给每个消费者,并没有考虑到消费者的处理能力。导致1个消费者空闲,另一个消费者忙的不可开交。

能者多劳

在spring中有一个简单的配置,可以解决这个问题。我们修改consumer服务的application.yml文件,添加配置:

1spring:
2  rabbitmq:
3    listener:
4      simple:
5        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

可以发现,由于消费者1处理速度较快,所以处理了更多的消息;消费者2处理速度较慢,只处理了6条消息。而最终总的执行耗时也在1秒左右,大大提升。

交换机类型

在订阅模型中,多了一个exchange角色,而且过程略有变化:

  • Publisher:生产者,不再发送消息到队列中,而是发给交换机
  • Exchange:交换机,一方面,接收生产者发送的消息。另一方面,知道如何处理消息,例如递交给某个特别队列、递交给所有队列、或是将消息丢弃。到底如何操作,取决于Exchange的类型。
  • Queue:消息队列也与以前一样,接收消息、缓存消息。不过队列一定要与交换机绑定。
  • Consumer:消费者,与以前一样,订阅队列,没有变化

Exchange(交换机)只负责转发消息,不具备存储消息的能力,因此如果没有任何队列与Exchange绑定,或者没有符合路由规则的队列,那么消息会丢失!

交换机的类型有四种:

  • Fanout:广播,将消息交给所有绑定到交换机的队列。我们最早在控制台使用的正是Fanout交换机
  • Direct:订阅,基于RoutingKey(路由key)发送给订阅了消息的队列
  • Topic:通配符订阅,与Direct类似,只不过RoutingKey可以使用通配符
  • Headers:头匹配,基于MQ的消息头匹配,用的较少。

Fanout交换机

Fanout,英文翻译是扇出,我觉得在MQ中叫广播更合适。

  • 1) 可以有多个队列
  • 2) 每个队列都要绑定到Exchange(交换机)
  • 3) 生产者发送的消息,只能发送到交换机
  • 4) 交换机把消息发送给绑定过的所有队列
  • 5) 订阅队列的消费者都能拿到消息

Direct交换机

在Fanout模式中,一条消息,会被所有订阅的队列都消费。但是,在某些场景下,我们希望不同的消息被不同的队列消费。这时就要用到Direct类型的Exchange。

在Direct模型下:

  • 队列与交换机的绑定,不能是任意绑定了,而是要指定一个RoutingKey(路由key)
  • 消息的发送方在 向 Exchange发送消息时,也必须指定消息的 RoutingKey
  • Exchange不再把消息交给每一个绑定的队列,而是根据消息的Routing Key进行判断,只有队列的Routingkey与消息的 Routing key完全一致,才会接收到消息

Topic交换机

Topic类型的ExchangeDirect相比,都是可以根据RoutingKey把消息路由到不同的队列。

只不过Topic类型Exchange可以让队列在绑定BindingKey 的时候使用通配符!

BindingKey一般都是有一个或多个单词组成,多个单词之间以.分割,例如: item.insert

通配符规则:

  • #:匹配一个或多个词
  • *:匹配不多不少恰好1个词

举例:

  • item.#:能够匹配item.spu.insert 或者 item.spu
  • item.*:只能匹配item.spu

声明队列和交换机

发送者的可靠性

生产者重试机制

生产者发送消息时,出现了网络故障,导致与MQ的连接中断。为了解决这个问题,SpringAMQP提供的消息发送时的重试机制。即:当RabbitTemplate与MQ连接超时后,多次重试。

修改publisher模块的application.yaml文件,添加下面的内容:

 1spring:
 2  rabbitmq:
 3    connection-timeout: 1s # 设置MQ的连接超时时间
 4    template:
 5      retry:
 6        enabled: true # 开启超时重试机制
 7        initial-interval: 1000ms # 失败后的初始等待时间
 8        multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
 9        max-attempts: 3 # 最大重试次数
10        

SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的。

如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。

生产者确认机制

一般情况下,只要生产者与MQ之间的网路连接顺畅,基本不会出现发送消息丢失的情况,因此大多数情况下我们无需考虑这种问题。

不过,在少数情况下,也会出现消息发送到MQ之后丢失的现象,比如:

  • MQ内部处理消息的进程发生了异常
  • 生产者发送消息到达MQ后未找到Exchange
  • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,因此无法路由

针对上述情况,RabbitMQ提供了生产者消息确认机制,包括Publisher ConfirmPublisher Return两种。在开启确认机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回不同的回执

  • 当消息投递到MQ,但是路由失败时,通过Publisher Return返回异常信息,同时返回ack的确认信息,代表投递成功
  • 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功
  • 其它情况都会返回NACK,告知投递失败

MQ的可靠性

数据持久化

为了提升性能,默认情况下MQ的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:

  • 交换机持久化
  • 队列持久化
  • 消息持久化

LazyQueue

在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为成为PageOut. PageOut会耗费一段时间,并且会阻塞队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有请求都会被阻塞。

为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)
  • 支持数百万条的消息存储

消费者的可靠性

当RabbitMQ向消费者投递消息以后,需要知道消费者的处理状态如何。因为消息投递给消费者并不代表就一定被正确消费了,可能出现的故障有很多,比如:

  • 消息投递的过程中出现了网络故障
  • 消费者接收到消息后突然宕机
  • 消费者接收到消息后,因处理不当导致异常

一旦发生上述情况,消息也会丢失。因此,RabbitMQ必须知道消费者的处理状态,一旦消息处理失败才能重新投递消息。

消费者确认机制

为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement)。即:当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:

  • ack:成功处理消息,RabbitMQ从队列中删除该消息
  • nack:消息处理失败,RabbitMQ需要再次投递消息
  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息

一般reject方式用的较少。因此大多数情况下我们需要将消息处理的代码通过try catch机制捕获,消息处理成功时返回ack,处理失败时返回nack.

由于消息回执的处理代码比较统一,因此SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
  • manual:手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活
  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:
    • 如果是业务异常,会自动返回nack
    • 如果是消息处理或校验异常,自动返回reject;
1spring:
2  rabbitmq:
3    listener:
4      simple:
5        acknowledge-mode: none # 不做处理
失败重试机制

当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。

极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力

为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。

修改consumer服务的application.yml文件,添加内容:

 1spring:
 2  rabbitmq:
 3    listener:
 4      simple:
 5        retry:
 6          enabled: true # 开启消费者失败重试
 7          initial-interval: 1000ms # 初识的失败等待时长为1秒
 8          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
 9          max-attempts: 3 # 最大重试次数
10          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
  • 开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试
  • 重试达到最大次数后,抛出了AmqpRejectAndDontRequeueException异常, Spring会返回reject,消息会被丢弃
失败处理策略

在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃。这在某些对于消息可靠性要求较高的业务场景下,显然不太合适了。

因此Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,它有3个不同实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。

1@Bean
2public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
3    return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
4}
业务幂等性

在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。例如:

  • 根据id删除数据
  • 查询数据
  • 新增数据

数据的更新往往不是幂等的,如果重复执行可能造成不一样的后果。比如:

  • 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况
  • 退款业务。重复退款对商家而言会有经济损失。

所以,我们要尽可能避免业务被重复执行。

因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:

  • 唯一消息ID
  • 业务状态判断

唯一消息ID:

这个思路非常简单:

  1. 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
  2. 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
  3. 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。

我们该如何给消息添加唯一ID呢?

其实很简单,SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。

以Jackson的消息转换器为例:

1@Bean
2public MessageConverter messageConverter(){
3    // 1.定义消息转换器
4    Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
5    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
6    jjmc.setCreateMessageIds(true);
7    return jjmc;
8}

业务判断:

业务判断就是基于业务本身的逻辑或状态来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。

在极小概率下可能存在线程安全问题。

兜底方案