黑马商城的总结和不少的经验教训,另一个方面写这篇博客也是为了复盘在解决单体项目拆分成微服务项目过程中,特别需要理解并且掌握的思想,还有踩过的坑,自认为比较全面,需要注意,黑马商城的主要目的在于学习微服务相关组件和一些拆分的思想,关于微服务项目的学习,需要在真实的项目实践中不断打磨,黑马商城仅仅是开始😀但对于理解微服务,还有实际开发中处理高并发的技术的学习,包括网关,注册中心,seata,分布式,数据的强一致性和最终一致性,搜索引擎,都很有帮助,认真学完会受益匪浅.我会尽量对这个项目进行一个比较细致,全面的总结.

一.mybatis-plus

第一天的mybatis-plus推荐好好学习,因为如果是mybatis让你可以在mapper的xml中写sql语句,plus则是连手写这一步都省去,直接调用现成的方法即可(不再写简单的crud单表查询,极大提高开发效率,非常值得学习).

1.引入依赖,不需要再引入mybatis的依赖


MybatisPlus提供了starter,实现了自动Mybatis以及MybatisPlus的自动装配功能,坐标如下:
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>

2.我们自定义的Mapper只要实现了这个BaseMapper,就无需自己实现单表CRUD了。

public interface UserMapper extends BaseMapper<USer>{}

只要继承了BaseMapper,注入数据库对应的实体类,那么简单的CRUD,如insert()方法,getById()方法等,都是现成的,连xml或者注解里的数据库查询语句都可以直接不写了!

进行测试

package com.itheima.mp.mapper;

import com.itheima.mp.domain.po.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.util.List;

@SpringBootTest
class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    void testInsert() {
        User user = new User();
        user.setId(5L);
        user.setUsername("Lucy");
        user.setPassword("123");
        user.setPhone("18688990011");
        user.setBalance(200);
        user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
        user.setCreateTime(LocalDateTime.now());
        user.setUpdateTime(LocalDateTime.now());
        userMapper.insert(user);
    }

    @Test
    void testSelectById() {
        User user = userMapper.selectById(5L);
        System.out.println("user = " + user);
    }

    @Test
    void testSelectByIds() {
        List<User> users = userMapper.selectBatchIds(List.of(1L, 2L, 3L, 4L, 5L));
        users.forEach(System.out::println);
    }

    @Test
    void testUpdateById() {
        User user = new User();
        user.setId(5L);
        user.setBalance(20000);
        userMapper.updateById(user);
    }

    @Test
    void testDelete() {
        userMapper.deleteById(5L);
    }
}

可以看到,userMapper里面我们只是继承了BaseMapper,并没有写任何的查询语句,也没有任何的xml文件,但是却有userMapper.insert(),userMapper.selectById().userMapper.selectBatchIds()(批量查询),userMapper.deleteById(),userMapper.updataById(),等众多常用的方法!

3.常用注解

userMapper extends BaseMapper<User>时,指定了泛型User,通过User这个实体类mp去推断表的信息,一般情况下:

  • MybatisPlus会把PO实体的类名驼峰转下划线作为表名

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

  • MybatisPlus会把名为id的字段作为主键

但很多情况下,默认的实现与实际场景不符,因此MybatisPlus提供了一些注解便于我们声明表信息。

@TableName("user") 用在类上,表示该实体类对应的数据库的表名是啥

@TableId(value="",type="") 要在数据库的主键id上.type极为Id自增策略

type = IdType.

AUTO:自增

INPUT:手动生成id

ASSING_ID:雪花算法生成LONG类型全局唯一id,这是默认的id策略

@TableField().普通字段映射的数据库的表名

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

  • 成员变量名与数据库字段名不一致

  • 成员变量是以isXXX命名,按照JavaBean的规范,MybatisPlus识别字段时会把is去除,这就导致与数据库不符。

  • 成员变量名与数据库一致,但是与数据库的关键字冲突。使用@TableField注解给字段名添加转义字符:``

@TableName("user")
public class User {
    @TableId
    private Long id;
    private String name;
    private Integer age;
    @TableField(is_married")
    private Boolean isMarried;
    @TableField("`concat`")
    private String concat;
}

大多数的配置都有默认值,因此我们都无需配置。但还有一些是没有默认值的,例如:

  • 实体类的别名扫描包

  • 全局id类型

mybatis-plus:
  type-aliases-package: com.itheima.mp.domain.po
  global-config:
    db-config:
      id-type: auto # 全局id类型为自增长

需要注意的是,MyBatisPlus也支持手写SQL的,而mapper文件的读取地址可以自己配置:

mybatis-plus:
  mapper-locations: "classpath*:/mapper/**/*.xml" # Mapper.xml文件地址,当前这个是默认值。

可以看到默认值是classpath*:/mapper/**/*.xml,也就是说我们只要把mapper.xml文件放置这个目录下就一定会被加载。

4.QueryWrapper

无论是修改、删除、查询,都可以使用QueryWrapper来构建查询条件(区别LambdaQueryMapper)。例如

@Test
void testQueryWrapper() {
    // 1.构建查询条件 where name like "%o%" AND balance >= 1000
    QueryWrapper<User> wrapper = new QueryWrapper<User>()
            .select("id", "username", "info", "balance")
            .like("username", "o")
            .ge("balance", 1000);
    // 2.查询数据
    List<User> users = userMapper.selectList(wrapper);
    users.forEach(System.out::println);
}

无论是QueryWrapper还是UpdateWrapper在构造条件的时候都需要写死字段名称,会出现字符串魔法值。这在编程规范中显然是不推荐的。 那怎么样才能不写字段名,又能知道字段名呢?

(一般不用QueryWrapper,因为他难以实现复杂条件的查询.)

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

  • LambdaQueryMapper

  • LambdaUpdateMapper

分别对应QueryWrapper和UpdateWrapper.

QueryMapper<USer> wrapper = new QueryMapper<>();
wrapper.lambda()
.select(USer::GetId,User::getUSername,User::getInfo,User.getBalance)
.like(User::getUsername,"o")//模糊查询
.ge(User::getBalance,1000);//大于1000块

List<User> users = userMapper.selectList(wrapper);//批量查询
users.forEach(System.out::println);

但对于复杂的多表查询,不要lambdaQueryWrapper和xml文件混用,因为这会让sql语句变得晦涩难懂,复杂查询往往意味着复杂的and/or嵌套、动态where条件、多表join后的字段映射。用Java代码的LambdaQueryWrapper去拼接这些逻辑,代码会迅速变得冗长、晦涩,难以调试。你很难一眼看出最终生成的SQL是什么。

场景推荐方案理由
单表CRUD只用 MyBatis-Plus极致简洁,无需写任何SQL或XML。利用 BaseMapper 和 LambdaQueryWrapper。
单表但动态条件非常复杂MyBatis-Plus + LambdaQueryWrapper比如有几十个可选筛选项,用Java的 if 语句和 wrapper.eq(..., value) 动态拼接,比在XML里写 <if> 标签更自然、更安全(编译时检查)。
任何涉及多表关联(join)全部写在XML文件即使是两三个表的简单join,为了未来扩展和维护,也应该从一开始就用XML。
单表但需复杂子查询/union/case when全部写在XML文件因为Wrapper对这些特性的支持要么不够好,要么写起来比SQL还难看。
结果集不是或包含多个表的字段全部写在XML文件需要自定义 resultMap 来映射到 VO 或 DTO 对象。

5.Service接口

MybatisPlus不仅提供了BaseMapper,还提供了通用的Service接口及默认实现,封装了一些常用的service模板方法。 通用接口为IService,默认实现为ServiceImpl,其中封装的方法可以分为以下几类:

  • save:新增

  • remove:删除

  • update:更新

  • get:查询单个结果

  • list:查询集合结果

  • count:计数

  • page:分页查询

CRUD

我们先俩看下基本的CRUD接口。 新增:

  • save是新增单个元素

  • saveBatch是批量新增

  • saveOrUpdate是根据id判断,如果数据存在就更新,不存在则新增

  • saveOrUpdateBatch是批量的新增或修改

删除:

  • removeById:根据id删除

  • removeByIds:根据id批量删除

  • removeByMap:根据Map中的键值对为条件删除

  • remove(Wrapper<T>):根据Wrapper条件删除

  • ~~removeBatchByIds~~:暂不支持

修改:

  • updateById:根据id修改

  • update(Wrapper<T>):根据UpdateWrapper修改,Wrapper中包含set和where部分

  • 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中的接口了。

首先:

publice interface IUserService extends IService<User>{}

接着

publice class UserServiceImpl extends ServiceImpl<UserMapper,USer>{}

举例

package com.itheima.mp.controller;

import cn.hutool.core.bean.BeanUtil;
import com.itheima.mp.domain.dto.UserFormDTO;
import com.itheima.mp.domain.po.User;
import com.itheima.mp.domain.vo.UserVO;
import com.itheima.mp.service.IUserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Api(tags = "用户管理接口")
@RequiredArgsConstructor
@RestController
@RequestMapping("users")
public class UserController {

    private final IUserService userService;

    @PostMapping
    @ApiOperation("新增用户")
    public void saveUser(@RequestBody UserFormDTO userFormDTO){
        // 1.转换DTO为PO
        User user = BeanUtil.copyProperties(userFormDTO, User.class);
        // 2.新增
        userService.save(user);
    }

    @DeleteMapping("/{id}")
    @ApiOperation("删除用户")
    public void removeUserById(@PathVariable("id") Long userId){
        userService.removeById(userId);
    }

    @GetMapping("/{id}")
    @ApiOperation("根据id查询用户")
    public UserVO queryUserById(@PathVariable("id") Long userId){
        // 1.查询用户
        User user = userService.getById(userId);
        // 2.处理vo
        return BeanUtil.copyProperties(user, UserVO.class);
    }

    @GetMapping
    @ApiOperation("根据id集合查询用户")
    public List<UserVO> queryUserByIds(@RequestParam("ids") List<Long> ids){
        // 1.查询用户
        List<User> users = userService.listByIds(ids);
        // 2.处理vo
        return BeanUtil.copyToList(users, UserVO.class);
    }
}

6.LambdaQueryWrapper

这才是用的最多的mp查询方式

publice List<UserVO> queryUsers(UserQuery query){

String username = query.getName();
Integer status = query.geyStatus();
Integer minBalance  =query.getBalance();
Integer maxBalance  = query.getBalance();

List<User> users = userService.lambdaQuery()
.like(username!=null,User::getUsername,username)
.eq(status!=null,User::getStatus,status)
.ge(minBalance!=null,User::getBalance,minBalance)
le(maxBalance!=null,User::getBalance,maxbalance)
.list();

return BeanUtil.copyTolist(users,UserVO.class);


}

可以发现lambdaQuery方法中除了可以构建条件,还需要在链式编程的最后添加一个list(),这是在告诉MP我们的调用结果需要是一个list集合。这里不仅可以用list(),可选的方法有:

  • .one():最多1个结果

  • .list():返回集合结果

  • .count():返回计数结果

MybatisPlus会根据链式编程的最后一个方法来判断最终的返回结果。

以下是更新操作

@Override
@Transactional
public void deductBalance(Long id, Integer money) {
    // 1.查询用户
    User user = getById(id);
    // 2.校验用户状态
    if (user == null || user.getStatus() == 2) {
        throw new RuntimeException("用户状态异常!");
    }
    // 3.校验余额是否充足
    if (user.getBalance() < money) {
        throw new RuntimeException("用户余额不足!");
    }
    // 4.扣减余额 update tb_user set balance = balance - ?
    int remainBalance = user.getBalance() - money;
    lambdaUpdate()
            .set(User::getBalance, remainBalance) // 更新余额
            .set(remainBalance == 0, User::getStatus, 2) // 动态判断,是否更新status
            .eq(User::getId, id)
            .eq(User::getBalance, user.getBalance()) // 乐观锁
            .update();
}

总结如下:

1.常见用法
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper
.eq(User::getName,"张三")
.ne(User::getStatus,0)
.gt(User.getAge,18)
.lt(User.getAge,25)
.ge(User::getAge,18)
.lt(User::getAge,25)
.like(User::getEmail,"test")
.likeLeft(User::getEmail,"@qq")
.likeRight(User::getEmail,"user")
.between(User::getCreateTime,start,end)
.in(User::getDepId,1,2,3)
.isNull(User::getRemark)
.orderByDesc(User::getCreateTime)

List<User> users = userMapper.selectList(wrapper);    
wrapper.eq(User::getName, "张三")          // name = '张三'
       .ne(User::getStatus, 0)            // status != 0
       .gt(User::getAge, 18)              // age > 18
       .lt(User::getAge, 60)              // age < 60
       .ge(User::getScore, 80)            // score >= 80
       .le(User::getScore, 100)           // score <= 100
       .like(User::getEmail, "test")      // email LIKE '%test%'
       .likeLeft(User::getEmail, "@qq")   // email LIKE '%@qq'
       .likeRight(User::getEmail, "user") // email LIKE 'user%'
       .between(User::getCreateTime, start, end)
       .in(User::getDeptId, 1, 2, 3)
       .isNull(User::getRemark)
       .orderByDesc(User::getCreateTime);
2.组合和嵌套
wrapper
.eq(User::getStatus,1)
.and(w->w.gt(User::getAge,25).or().lt(User::getAge,18))
.or(w->w.eq(User::getName,"李四").eq(User::getDepLd,5))

wrapper.eq(User::getStatus, 1)
       .and(w -> w.gt(User::getAge, 25).or().lt(User::getAge, 18)) // AND (age>25 OR age<18)
       .or(w -> w.eq(User::getName, "李四").eq(User::getDeptId, 5)); // OR (name='李四' AND dept_id=5)

更新操作

LambdaUpdateWrapper<User> updateWrapper  = new LambdaUpdateWrapper<>();
updateWrapper
.eq(User::getId,1)
.set(User::getStatus,0)
.set(User::getName,"新名字")
userMapper.update(updateWrapper);
为什么推荐 Lambda?
  • 类型安全:字段名通过 getter 方法引用,编译期就能发现错误,不会因字符串拼写错误而埋坑。

  • 重构友好:实体类字段改名时,IDE 会自动重命名方法引用,不用全局搜索字符串。

  • 代码简洁:User::getName 比 "name" 更直观,配合 IDE 自动补全效率更高。

如果项目里还混用 QueryWrapper,可以逐步替换为 LambdaQueryWrapper,除了创建方式不同,其他方法名完全一致,迁移成本极低。

7.逻辑删除

对于某些数据,比如购物车清单(比如),这些数据可能对数据调研人员来说是有用的,用户想删除购物车清单,但我们并不能真的在数据库里将这条数据删除(因为是有用的😀),所以要做一个逻辑删除,就是伪删除,在表中新增一个字段deleted,当用户删除时记为1,否则则记为0,查询时配置,将自动在查询语句后面增添 where deleted=0来查询未被删除的订单,这样就实现了伪删除,实际这条数据还是存在的!

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

逻辑删除本身也有自己的问题,比如:

  • 会导致数据库表垃圾数据越来越多,从而影响查询效率

  • SQL中全都需要对逻辑删除字段做判断,影响查询效率

因此,不太推荐采用逻辑删除功能,如果数据不能删除,可以采用把数据迁移到其它表的办法。

8.分页操作,需要下载分页插件,因为mp本身不支持分页查询

package com.itheima.mp.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        // 初始化核心插件
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

这里有个通用分页查询,主要是为了管理很多个分页查询统一格式,但是较多时候没必要

  • 直接用插件分页就很好,一点也不复杂,没必要硬上通用封装。

  • 封装是为了减少大量重复的“体力活”,如果你的项目还没到那个规模,强行封装反而增加复杂度,不重要,可以完全不弄。

  • 等哪天你发现自己在不同 Service 里写了十几次几乎一样的 new Page() 和 setTotal 时,再考虑抽一个简单方法也不迟

基本代码如下

Page<User> p = userService.page(new Page<>(2,2))//页码,每页大小 pageNo,PageSiza
page.addOrder(new OrderItem("balance",false));//排序参数
System.out(p.getTotal());//总条数
System.out(p.getPages())//总页数
List<User> records = p.getRecords();//数据
records.ForEach(System.out::pringtln);

二.Docker

这个知识也很重要

  • MobaXterm = 操作 Linux 的“专用 IDE”
    就像你用 IDEA 写 Java 代码,MobaXterm 就是用来写命令、管理文件和查看服务的“Linux 专用图形化终端”。它不仅提供命令行,还内置了文件传输(可视化的 SFTP)、X11 图形转发(能让虚拟机里的程序在 Windows 上显示窗口)等强大功能,专门为操控远程/虚拟机设计。

  • Docker = 让部署“无视环境”的快捷方式
    它确实是为了解决“我这运行得好好的,怎么你那儿就报错”这个环境不一致的痛点。你把应用及其所有依赖(库、配置、操作系统基础层)打包成一个镜像,这个镜像就像个“自带环境的标准集装箱”,不论放在哪台支持 Docker 的机器(你的虚拟机、云服务器、同事的电脑)上,都能一把跑起来,实现“一次构建,到处运行”,极大地屏蔽了环境差异。("一行命令,到处运行")

比如,使用docker在虚拟机中安装mysql,只需一行命令

docker run -d \
  --name mysql \
  -p 3306:3306 \
  -e TZ=Asia/Shanghai \
  -e MYSQL_ROOT_PASSWORD=123 \
  mysql

不同操作系统下其安装包、运行环境是都不相同的!如果是手动安装,必须手动解决安装包不同、环境不同的、配置不同的问题!

而使用Docker,这些完全不用考虑。就是因为Docker会自动搜索并下载MySQL。注意:这里下载的不是安装包,而是镜像。镜像中不仅包含了MySQL本身,还包含了其运行所需要的环境、配置、系统级函数库。因此它在运行时就有自己独立的环境,就可以跨系统运行,也不需要手动再次配置环境了。这套独立运行的隔离环境我们称为容器。(docker拉取镜像并下载,还包括运行的配置,环境,函数库等等,我们下载的是mysql镜像.让mysql镜像能够跑起来的这套独立隔离的环境称为容器)

说明:

  • 镜像:英文是image

  • 容器:英文是container

因此,Docker安装软件的过程,就是自动搜索下载镜像,然后创建并运行容器的过程。

Docker官方提供了一个专门管理、存储镜像的网站,并对外开放了镜像上传、下载的权利。Docker官方提供了一些基础镜像,然后各大软件公司又在基础镜像基础上,制作了自家软件的镜像,全部都存放在这个网站。这个网站就成了Docker镜像交流的社区:

https://hub.docker.com/

基本上我们常用的各种软件都能在这个网站上找到,我们甚至可以自己制作镜像上传上去。

像这种提供存储、管理Docker镜像的服务器,被称为DockerRegistry,可以翻译为镜像仓库。DockerHub网站是官方仓库,阿里云、华为云会提供一些第三方仓库,我们也可以自己搭建私有的镜像仓库。

利用Docker快速的安装了MySQL,非常的方便,不过我们执行的命令到底是什么意思呢?

 

docker run -d \ 

--name mysql \

-p 3306:3306 \(宿主机端口:容器端口)

-e TZ=Asia/Shanghai \

-e MYSQL_ROOT_PASSWORD=123 \

mysql(去拉取mysql这个镜像,不能乱取,在镜像仓库上去找这个名字)

解读:

  • docker run -d :创建并运行一个容器,-d则是让容器以后台进程运行

  • --name mysql : 给容器起个名字叫mysql,你可以叫别的

  • -p 3306:3306 : 设置端口映射。

    • 容器是隔离环境,外界不可访问。但是可以将宿主机端口映射容器内到端口,当访问宿主机指定端口时,就是在访问容器内的端口了。

    • 容器内端口往往是由容器内的进程决定,例如MySQL进程默认端口是3306,因此容器内端口一定是3306;而宿主机端口则可以任意指定,一般与容器内保持一致。

    • 格式: -p 宿主机端口:容器内端口,示例中就是将宿主机的3306映射到容器内的3306端口

  • -e TZ=Asia/Shanghai : 配置容器内进程运行时的一些参数

    • 格式:-e KEY=VALUE,KEY和VALUE都由容器内进程决定

    • 案例中,TZ=Asia/Shanghai是设置时区;MYSQL_ROOT_PASSWORD=123是设置MySQL默认密码

  • mysql : 设置镜像名称,Docker会根据这个名字搜索并下载镜像

    • 格式:REPOSITORY:TAG,例如mysql:8.0,其中REPOSITORY可以理解为镜像名,TAG是版本号

    • 在未指定TAG的情况下,默认是最新版本,也就是mysql:latest

镜像的名称不是随意的,而是要到DockerRegistry中寻找,镜像运行时的配置也不是随意的,要参考镜像的帮助文档,这些在DockerHub网站或者软件的官方网站中都能找到。

1.基础命令

命令

说明

文档地址

docker pull

拉取镜像

docker pull

docker push

推送镜像到DockerRegistry

docker push

docker images

查看本地镜像

docker images

docker rmi

删除本地镜像

docker rmi

docker run

创建并运行容器(不能重复创建)

docker run

docker stop

停止指定容器

docker stop

docker start

启动指定容器

docker start

docker restart

重新启动容器

docker restart

docker rm

删除指定容器

docs.docker.com

docker ps

查看容器

docker ps

docker logs

查看容器运行日志

docker logs

docker exec

进入容器

docker exec

docker save

保存镜像到本地压缩文件

docker save

docker load

加载本地压缩文件到镜像

docker load

docker inspect

查看容器详细信息

docker inspect

docker pull

docker images

docker run -d --name nginx -p 80:80 nginx

docker ps

docker stop

docker start

docker restart

docker logs

docker exec -it nginx bash//进入容器

docker rm -f nginx//运作中强行删除

(要把这些docker命令记住)

还可以起别名

# 修改/root/.bashrc文件
vi /root/.bashrc
内容如下:
# .bashrc

# User specific aliases and functions

alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'
alias dps='docker ps --format "table {{.ID}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}\t{{.Names}}"'
alias dis='docker images'

# Source global definitions
if [ -f /etc/bashrc ]; then
        . /etc/bashrc
fi

2.数据卷

不想看下面的直接总结:

无论你怎么删除、重建 MySQL 容器,只要数据卷还在,数据库里的表和数据就不会丢。数据的持久化就是数据卷.

一般直接直接映射目录(Bind Mount)

你有时也会看到写成这样:

yaml

volumes:
  - ./mysql-data:/var/lib/mysql   # 宿主机相对路径:容器内路径

这是把宿主机的某个目录直接映射进去,比数据卷更直观,你可以在宿主机直接看到文件。两者都能实现持久化,开发环境常用直接映射,生产环境更推荐用命名数据卷(因为由 Docker 管理,迁移和备份更方便.

---------------------------------------------------------------------------------------------------------------------------------

容器是隔离环境,容器内程序的文件、配置、运行时产生的容器都在容器内部,我们要读写容器内的文件非常不方便。大家思考几个问题:

  • 如果要升级MySQL版本,需要销毁旧容器,那么数据岂不是跟着被销毁了?

  • MySQL、Nginx容器运行后,如果我要修改其中的某些配置该怎么办?

  • 我想要让Nginx代理我的静态资源怎么办?

因此,容器提供程序的运行环境,但是程序运行产生的数据、程序运行依赖的配置都应该与容器解耦。

数据卷(volume)是一个虚拟目录,是容器内目录与宿主机目录之间映射的桥梁。

以Nginx为例,我们知道Nginx中有两个关键的目录:

  • html:放置一些静态资源

  • conf:放置配置文件

如果我们要让Nginx代理我们的静态资源,最好是放到html目录;如果我们要修改Nginx的配置,最好是找到conf下的nginx.conf文件。

但遗憾的是,容器运行的Nginx所有的文件都在容器内部。所以我们必须利用数据卷将两个目录与宿主机目录关联,方便我们操作。

  • 我们创建了两个数据卷:conf、html

  • Nginx容器内部的conf目录和html目录分别与两个数据卷关联。

  • 而数据卷conf和html分别指向了宿主机的/var/lib/docker/volumes/conf/_data目录和/var/lib/docker/volumes/html/_data目录

这样以来,容器内的conf和html目录就 与宿主机的conf和html目录关联起来,我们称为挂载。此时,我们操作宿主机的/var/lib/docker/volumes/html/_data就是在操作容器内的/usr/share/nginx/html/_data目录。只要我们将静态资源放入宿主机对应目录,就可以被Nginx代理了。

数据卷的相关命令有:

命令

说明

文档地址

docker volume create

创建数据卷

docker volume create

docker volume ls

查看所有数据卷

docs.docker.com

docker volume rm

删除指定数据卷

docs.docker.com

docker volume inspect

查看某个数据卷的详情

docs.docker.com

docker volume prune

清除数据卷

docker volume prune

注意:容器与数据卷的挂载要在创建容器时配置,对于创建好的容器,是不能设置数据卷的。而且创建容器的过程中,数据卷会自动创建。

# 1.删除原来的MySQL容器
docker rm -f mysql

# 2.进入root目录
cd ~

# 3.创建并运行新mysql容器,挂载本地目录
docker run -d \
  --name mysql \
  -p 3306:3306 \
  -e TZ=Asia/Shanghai \
  -e MYSQL_ROOT_PASSWORD=123 \
  -v ./mysql/data:/var/lib/mysql \
  -v ./mysql/conf:/etc/mysql/conf.d \
  -v ./mysql/init:/docker-entrypoint-initdb.d \
  mysql

# 4.查看root目录,可以发现~/mysql/data目录已经自动创建好了
ls -l mysql
# 结果:
总用量 4
drwxr-xr-x. 2 root    root   20 5月  19 15:11 conf
drwxr-xr-x. 7 polkitd root 4096 5月  19 15:11 data
drwxr-xr-x. 2 root    root   23 5月  19 15:11 init

# 查看data目录,会发现里面有大量数据库数据,说明数据库完成了初始化
ls -l data

# 5.查看MySQL容器内数据
# 5.1.进入MySQL
docker exec -it mysql mysql -uroot -p123
# 5.2.查看编码表
show variables like "%char%";
# 5.3.结果,发现编码是utf8mb4没有问题
+--------------------------+--------------------------------+
| Variable_name            | Value                          |
+--------------------------+--------------------------------+
| character_set_client     | utf8mb4                        |
| character_set_connection | utf8mb4                        |
| character_set_database   | utf8mb4                        |
| character_set_filesystem | binary                         |
| character_set_results    | utf8mb4                        |
| character_set_server     | utf8mb4                        |
| character_set_system     | utf8mb3                        |
| character_sets_dir       | /usr/share/mysql-8.0/charsets/ |
+--------------------------+--------------------------------+

# 6.查看数据
# 6.1.查看数据库
show databases;
# 结果,hmall是黑马商城数据库
+--------------------+
| Database           |
+--------------------+
| hmall              |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)
# 6.2.切换到hmall数据库
use hmall;
# 6.3.查看表
show tables;
# 结果:
+-----------------+
| Tables_in_hmall |
+-----------------+
| address         |
| cart            |
| item            |
| order           |
| order_detail    |
| order_logistics |
| pay_order       |
| user            |
+-----------------+
# 6.4.查看address表数据
+----+---------+----------+--------+----------+-------------+---------------+-----------+------------+-------+
| id | user_id | province | city   | town     | mobile      | street        | contact   | is_default | notes |
+----+---------+----------+--------+----------+-------------+---------------+-----------+------------+-------+
| 59 |       1 | 北京     | 北京   | 朝阳区    | 13900112222 | 金燕龙办公楼   | 李佳诚    | 0          | NULL  |
| 60 |       1 | 北京     | 北京   | 朝阳区    | 13700221122 | 修正大厦       | 李佳红    | 0          | NULL  |
| 61 |       1 | 上海     | 上海   | 浦东新区  | 13301212233 | 航头镇航头路   | 李佳星    | 1          | NULL  |
| 63 |       1 | 广东     | 佛山   | 永春      | 13301212233 | 永春武馆       | 李晓龙    | 0          | NULL  |
+----+---------+----------+--------+----------+-------------+---------------+-----------+------------+-------+
4 rows in set (0.00 sec)

3.构建镜像(当前可以不学,后续需要再说,有点难)

而且现在主流的做法是用 谷歌的 Jib 或 Spring Boot 的 Maven/Gradle 插件。你不用写一行 Dockerfile,也不用安装 Docker 命令行,在 IDEA 里点一下或者在 pom.xml 里配一下,Maven 插件就直接帮你把 Java 应用打包成镜像并推送到镜像仓库了.

4.网络

上节课我们创建了一个Java项目的容器,而Java项目往往需要访问其它各种中间件,例如MySQL、Redis等。现在,我们的容器之间能否互相访问呢?可以

# 1.用基本命令,寻找Networks.bridge.IPAddress属性
docker inspect mysql
# 也可以使用format过滤结果
docker inspect --format='{{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}}' mysql
# 得到IP地址如下:
172.17.0.2

# 2.然后通过命令进入dd容器
docker exec -it dd bash

# 3.在容器内,通过ping命令测试网络
ping 172.17.0.2
# 结果
PING 172.17.0.2 (172.17.0.2) 56(84) bytes of data.
64 bytes from 172.17.0.2: icmp_seq=1 ttl=64 time=0.053 ms
64 bytes from 172.17.0.2: icmp_seq=2 ttl=64 time=0.059 ms
64 bytes from 172.17.0.2: icmp_seq=3 ttl=64 time=0.058 ms

但是,容器的网络IP其实是一个虚拟的IP,其值并不固定与某一个容器绑定,如果我们在开发时写死某个IP,而在部署时很可能MySQL容器的IP会发生变化,连接会失败。

所以,我们必须借助于docker的网络功能来解决这个问题,官方文档:

docker network | Docker Docs

常见命令有:

命令

说明

文档地址

docker network create

创建一个网络

docker network create

docker network ls

查看所有网络

docs.docker.com

docker network rm

删除指定网络

docs.docker.com

docker network prune

清除未使用的网络

docs.docker.com

docker network connect

使指定容器连接加入某网络

docs.docker.com

docker network disconnect

使指定容器连接离开某网络

docker network disconnect

docker network inspect

查看网络详细信息

docker network inspect

演示

# 1.首先通过命令创建一个网络
docker network create hmall

# 2.然后查看网络
docker network ls
# 结果:
NETWORK ID     NAME      DRIVER    SCOPE
639bc44d0a87   bridge    bridge    local
403f16ec62a2   hmall     bridge    local
0dc0f72a0fbb   host      host      local
cd8d3e8df47b   none      null      local
# 其中,除了hmall以外,其它都是默认的网络

# 3.让dd和mysql都加入该网络,注意,在加入网络时可以通过--alias给容器起别名
# 这样该网络内的其它容器可以用别名互相访问!
# 3.1.mysql容器,指定别名为db,另外每一个容器都有一个别名是容器名
docker network connect hmall mysql --alias db
# 3.2.db容器,也就是我们的java项目
docker network connect hmall dd

# 4.进入dd容器,尝试利用别名访问db
# 4.1.进入容器
docker exec -it dd bash
# 4.2.用db别名访问
ping db
# 结果
PING db (172.18.0.2) 56(84) bytes of data.
64 bytes from mysql.hmall (172.18.0.2): icmp_seq=1 ttl=64 time=0.070 ms
64 bytes from mysql.hmall (172.18.0.2): icmp_seq=2 ttl=64 time=0.056 ms
# 4.3.用容器名访问
ping mysql
# 结果:
PING mysql (172.18.0.2) 56(84) bytes of data.
64 bytes from mysql.hmall (172.18.0.2): icmp_seq=1 ttl=64 time=0.044 ms
64 bytes from mysql.hmall (172.18.0.2): icmp_seq=2 ttl=64 time=0.054 ms

5一般来说完整的创建并启动的容器这样:

# 先创建自定义网络(只需执行一次)
docker network create app-net

# 创建并启动容器,指定网络
docker run -d \
  --name my-mysql \
  --network app-net \
  -p 3306:3306 \
  -v mysql-data:/var/lib/mysql \
  -e MYSQL_ROOT_PASSWORD=root \
  --restart unless-stopped \
  mysql:8.0

6.部署Java项目(docker compose)

大家可以看到,我们部署一个简单的java项目,其中包含3个容器:

  • MySQL

  • Nginx

  • Java项目

而稍微复杂的项目,其中还会有各种各样的其它中间件,需要部署的东西远不止3个。如果还像之前那样手动的逐一部署,就太麻烦了。

Docker Compose 就是用来解决“一个 docker run 命令搞不定”的场景,比如你的商城项目需要同时启动 MySQL、Redis、Nginx、自己的 Java 应用等好几个容器,而且它们之间还要互通。

简单说:Docker Compose 让你在一个 YAML 文件里定义所有服务,然后一条命令全部启动。

docker-compose.yml 长什么样?以黑马商城为例

yaml

version: '3.8'   # Compose 文件版本

services:        # 定义所有服务
  mysql:         # 服务名,自己起的
    image: mysql:8.0
    container_name: hm_mysql
    ports:
      - "3306:3306"         # 宿主机端口:容器端口
    environment:
      MYSQL_ROOT_PASSWORD: root
    volumes:
      - ./data:/var/lib/mysql   # 数据持久化,防丢失
    networks:
      - app-network

  myapp:          # 你的 Java 商城应用
    build: .      # 从当前目录 Dockerfile 构建镜像(也可以直接用 image:)
    container_name: hm_app
    ports:
      - "8080:8080"
    depends_on:
      - mysql      # 保证 mysql 先启动
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/hm_db?useSSL=false
    networks:
      - app-network

networks:
  app-network:    # 自定义网络,容器间直接用服务名互相访问
    driver: bridge

重点:

  • 容器间通信不再需要 IP,直接用服务名(比如 mysql)当主机名。

  • depends_on 控制启动顺序(但不保证应用层准备好,Spring Boot 会自动重连,所以够用)。

  • volumes 把数据存到宿主机,删容器不丢数据。

进入yml文件后:

# 1. 启动所有服务(后台运行)
docker-compose up -d

# 2. 查看运行状态
docker-compose ps

# 3. 停止并删除所有容器(数据卷默认保留)
docker-compose down

你只需要这样对 AI 说:

"帮我写一个 Docker Compose 文件,包含以下服务:

  • MySQL 8.0,root 密码 123456,创建数据库 hm_mall,端口 3306,数据持久化到 ./mysql-data

  • Redis 7,端口 6379,数据持久化到 ./redis-data

  • 自定义网络叫 app-net,让它们用服务名互访"

三.微服务相关组件

关于拆分,去看视频就行,这里只讲我们在拆分过程中,遇到了问题,从而引出的相关的微服务的组件学习,具体怎么拆看视频就行了,这里只讲新技术.微服务其实百分之九十的公司都不会用到(笑,但是又是现在几乎必须掌握的(笑

1.Jemeter:可以模拟高并发请求.可以去apache的官网下载

开启500个线程并发请求http://localhost/hi这个接口。由于该接口存在执行耗时(500毫秒),这就服务端导致每秒能处理的请求数量有限,最终会有越来越多请求积压,直至Tomcat资源耗尽。这样,其它本来正常的接口(例如/search/list)也都会被拖慢,甚至因超时而无法访问了。

我们测试一下,启动测试脚本,然后在浏览器访问http://localhost:8080/search/list这个接口,会发现响应速度非常慢

如果进一步提高/hi这个接口的并发,最终会发现/search/list接口的请求响应速度会越来越慢。

即是在单体项目中.疯狂访问一个功能(比如某一个热点信息,那么其他接口的延迟就会变大)

而要想解决这些问题,就需要使用微服务架构了。

2.微服务架构

微服务架构,首先是服务化,就是将单体架构中的功能模块从单体应用中拆分出来,独立部署为多个服务。同时要满足下面的一些特点:

  • 单一职责:一个微服务负责一部分业务功能,并且其核心数据不依赖于其它模块。

  • 团队自治:每个微服务都有自己独立的开发、测试、发布、运维人员,团队人员规模不超过10人(2张披萨能喂饱)

  • 服务自治:每个微服务都独立打包部署,访问自己独立的数据库。并且要做好服务隔离,避免对其它服务产生影响

例如,黑马商城项目,我们就可以把商品、用户、购物车、交易等模块拆分,交给不同的团队去开发,并独立部署

综上所述,微服务架构解决了单体架构存在的问题,特别适合大型互联网项目的开发,因此被各大互联网公司普遍采用,分布式架构其实就是服务拆分的过程.

那么,微服务的拆分必然导致一些问题:

  • 如果出现跨服务的业务该如何处理?

  • 页面请求到底该访问哪个服务?

  • 如何实现各个服务之间的服务隔离?等等

我们学到的新技术就是要去解决这些微服务在拆分过程中遇到的问题

3.spring cloud

springCloud框架可以说是目前Java领域最全面的微服务组件的集合了。后续还会用到spring cloud alibaba,Spring Cloud 是规范,Spring Cloud Alibaba 是阿里实现;如果你用了 Nacos、Sentinel、Seata,就必须额外引入 Spring Cloud Alibaba.

4.微服务拆分

一般情况下,对于一个初创的项目,首先要做的是验证项目的可行性。因此这一阶段的首要任务是敏捷开发,快速产出生产可用的产品,投入市场做验证。为了达成这一目的,该阶段项目架构往往会比较简单,很多情况下会直接采用单体架构,这样开发成本比较低,可以快速产出结果,一旦发现项目不符合市场,损失较小。

如果这一阶段采用复杂的微服务架构,投入大量的人力和时间成本用于架构设计,最终发现产品不符合市场需求,等于全部做了无用功。

所以,对于大多数小型项目来说,一般是先采用单体架构,随着用户规模扩大、业务复杂后再逐渐拆分为微服务架构。这样初期成本会比较低,可以快速试错。但是,这么做的问题就在于后期做服务拆分时,可能会遇到很多代码耦合带来的问题,拆分比较困难(前易后难)。

而对于一些大型项目,在立项之初目的就很明确,为了长远考虑,在架构设计时就直接选择微服务架构。虽然前期投入较多,但后期就少了拆分服务的烦恼(前难后易)。

拆分的目标。具体可以从两个角度来分析:

  • 高内聚:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高。

  • 低耦合:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖,或者依赖接口的稳定性要强。

(就是职责要单一,要尽量保证这个服务能完整的完成这个功能的开发,并且要尽量减少对其他模块的依赖,耦合度要低,对于必须要依赖其他的模块的服务的时候,比如支付时要去修改订单状态,那么就要学习如何用微服务组件实现跨模块开发了,这也是为什么早期不建议用微服务的原因,跨模块通信的微服务组件很多,学习难度比较大,开发难度也比较大,早期的小项目用微服务,其实是一种过度设计,反而会让开发变得更加复杂,要考虑成本问题,根据公司具体规模吧)

我们在做服务拆分时一般有两种方式:

  • 纵向拆分

  • 横向拆分

所谓纵向拆分,就是按照项目的功能模块来拆分。例如黑马商城中,就有用户管理功能、订单管理功能、购物车功能、商品管理功能、支付功能等。那么按照功能模块将他们拆分为一个个服务,就属于纵向拆分。这种拆分模式可以尽可能提高服务的内聚性。

而横向拆分,是看各个功能模块之间有没有公共的业务部分,如果有将其抽取出来作为通用服务。例如用户登录是需要发送消息通知,记录风控数据,下单时也要发送短信,记录风控数据。因此消息发送、风控数据记录就是通用的业务功能,因此可以将他们分别抽取为公共服务:消息中心服务、风控管理服务。这样可以提高业务的复用性,避免重复开发。同时通用业务一般接口稳定性较强,也不会使服务之间过分耦合。

当然,由于黑马商城并不是一个完整的项目,其中的短信发送、风控管理并没有实现,这里就不再考虑了。而其它的业务按照纵向拆分,可以分为以下几个微服务:

  • 用户服务

  • 商品服务

  • 订单服务

  • 购物车服务

  • 支付服务

一般微服务项目有两种不同的工程结构:

  • 完全解耦:每一个微服务都创建为一个独立的工程,甚至可以使用不同的开发语言来开发,项目完全解耦。

    • 优点:服务之间耦合度低

    • 缺点:每个项目都有自己的独立仓库,管理起来比较麻烦

  • Maven聚合:整个项目为一个Project,然后每个微服务是其中的一个Module

    • 优点:项目代码集中,管理和运维方便(授课也方便)

    • 缺点:服务之间耦合,编译时间较长

每个微服务都是独立的project,各个project放在同一个文件夹中。适合特别大型的项目。

Maven 聚合项目中,各模块(如用户、商品等模块)都在同一个父项目下,通过 Maven 的依赖管理机制,可以方便地统一管理各模块的依赖版本,避免版本冲突 。同时,在构建项目时,可以通过父项目的 pom.xml 文件进行统一配置,一键构建所有模块,提高开发效率,所以方便管理。

依赖,配置文件,启动类,相关功能代码拷贝,独立数据库

5.服务调用

我们发现一个问题:就是购物车业务中需要查询商品信息,但商品信息查询的逻辑全部迁移到了item-service服务,导致我们无法查询。

最终结果就是查询到的购物车数据不完整,因此要想解决这个问题,我们就必须改造其中的代码,把原本本地方法调用,改造成跨微服务的远程调用(RPC,即Remote Produce Call)。

1.RestTemplate,向第三方去发送http请求(这个技术现在不推荐使用,看看就行,就算是一般的调用第三方接口也不推荐用,有更简洁的WebClient)

这里就是购物车模块向商品模块发送请求去查询商品

完整查询商品代码

private void handleCartItems(List<CartVO> vos) {
    // TODO 1.获取商品id
    Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
    // 2.查询商品
    // List<ItemDTO> items = itemService.queryItemByIds(itemIds);
    // 2.1.利用RestTemplate发起http请求,得到http的响应
    ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
            "http://localhost:8081/items?ids={ids}",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<List<ItemDTO>>() {
            },
            Map.of("ids", CollUtil.join(itemIds, ","))
    );
    // 2.2.解析响应
    if(!response.getStatusCode().is2xxSuccessful()){
        // 查询失败,直接结束
        return;
    }
    List<ItemDTO> items = response.getBody();
    if (CollUtils.isEmpty(items)) {
        return;
    }
    // 3.转为 id 到 item的map
    Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
    // 4.写入vo
    for (CartVO v : vos) {
        ItemDTO item = itemMap.get(v.getItemId());
        if (item == null) {
            continue;
        }
        v.setNewPrice(item.getPrice());
        v.setStatus(item.getStatus());
        v.setStock(item.getStock());
    }
}

item-service可以称为服务的提供者,而cart-service则称为服务的消费者或服务调用者。

2.注册中心(微服务核心组件之一!)

商品微服务被调用较多,为了应对更高的并发,我们进行了多实例部署

此时,每个item-service的实例其IP或端口不同,问题来了:

  • item-service这么多实例,cart-service如何知道每一个实例的地址?

  • http请求要写url地址,cart-service服务到底该调用哪个实例呢?

  • 如果在运行过程中,某一个item-service实例宕机,cart-service依然在调用该怎么办?

  • 如果并发太高,item-service临时多部署了N台实例,cart-service如何知道新实例的地址?

为了解决上述问题,就必须引入注册中心的概念了,接下来我们就一起来分析下注册中心的原理。

在微服务远程调用的过程中,包括两个角色:

  • 服务提供者:提供接口供其它微服务访问,比如item-service

  • 服务消费者:调用其它微服务提供的接口,比如cart-service

在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:

流程如下:

  • 服务启动时就会注册自己的服务信息(服务名、IP、端口)到注册中心

  • 调用者可以从注册中心订阅想要的服务,获取服务对应的实例列表(1个服务可能多实例部署)

  • 调用者自己对实例列表负载均衡,挑选一个实例

  • 调用者向该实例发起远程调用

当服务提供者的实例宕机或者启动新实例时,调用者如何得知呢?

  • 服务提供者会定期向注册中心发送请求,报告自己的健康状态(心跳请求)

  • 当注册中心长时间收不到提供者的心跳时,会认为该实例宕机,将其从服务的实例列表中剔除

  • 当服务有新实例启动时,会发送注册服务请求,其信息会被记录在注册中心的服务实例列表

  • 当注册中心服务列表变更时,会主动通知微服务,更新本地服务列表

服务的提供者会向注册中心注册自己的信息,调用者从实例列表中通过负载均衡算法,挑选一个实例,发起远程调用.而注册中心会监测服务的提供者的心跳(心跳机制),如果长时间收不到,就认为该实例宕机了,从注册中心删除掉,而服务新的实例可以被注册中心自动发现,注册中心还可以向订阅者推送实例变更消息

3.nacos注册中心

我们是基于docker来部署nacos的注册中心的

http://192.168.150.101:8848/nacos/,注意将192.168.150.101替换为你自己的虚拟机IP地址。

首次访问会跳转到登录页,账号密码都是nacos

接下来,我们把item-service注册到Nacos,步骤如下

一.添加nacos依赖

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

二.配置文件

spring:
  application:
    name: item-service # 服务名称
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848 # nacos地址

三.部署多个实例

4.服务发现

cart-service中

一.引入依赖

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

二.配置nacos地址

spring:
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848

三.发现并调用服务

服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。

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

  • 随机

  • 轮询

  • IP的hash

  • 最近最少访问

  • ...

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

前面已经说到,无论是在普通的第三方调用接口还是在微服务远程调用中,我们都不推荐RestTemplate.下面介绍openFeign.

5.openFeign

购物车模块

1.引入依赖

oadBalancer 是 Ribbon 的官方替代品,更适合现代 Spring Cloud 架构。

Ribbon 已经停更了

  <!--openFeign-->
  <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-openfeign</artifactId>
  </dependency>
  <!--负载均衡器-->
  <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-loadbalancer</artifactId>
  </dependency>
2.启用OpenFeign

3.在cart-service中,定义一个新的接口,编写Feign客户端:

其实要去调用item-service,就是要得到请求方式,请求参数.请求路径,返回值类型

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

  • @FeignClient("item-service") :声明服务名称

  • @GetMapping :声明请求方式

  • @GetMapping("/items") :声明请求路径

  • @RequestParam("ids") Collection<Long> ids :声明请求参数

  • List<ItemDTO> :返回值类型

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

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

我们只需要注入ItemClient这个接口就行了,然后直接调用方法itemClient.queryItemByIds(itemIds),一行代码解决跨模块调用方法问题!

feign替我们完成了服务拉取、负载均衡、发送http请求的所有工作😀

3.连接池

OpenFeign 本身是一个声明式 HTTP 客户端,默认使用 JDK 原生 HttpURLConnection(没有连接池,每次请求都新建连接)。
当替换为 Apache HttpClient 或 OkHttp 等支持连接池的实现时,主要好处有:

1.减少TCP握手开销

通过复用已经建立起的TCP连接,避免每次请求来一遍三次握手协议,减少开销

2.提升高并发下的性能

连接池能维护一定空闲数量的连接,避免频繁创建和销毁连接导致CPU和内存开销(数据库(笑....)

3.支持长连接

4.更丰富配置能力,跟数据库配置最大连接数.空闲连接数,连接超时,回收机制等一样

一.引入依赖

<!--OK http 的依赖 -->
<dependency>
  <groupId>io.github.openfeign</groupId>
  <artifactId>feign-okhttp</artifactId>
</dependency>

二:配置信息

feign:
  okhttp:
    enabled: true # 开启OKHttp功能
4.抽取为一个公共模块(hm-api)

如果拆分了交易微服务(trade-service),它也需要远程调用item-service中的根据id批量查询商品功能。这个需求与cart-service中是一样的。

因此,我们就需要在trade-service中再次定义ItemClient接口,这不是重复编码吗? 有什么办法能加避免重复编码呢?

其实.平常去调用第三方接口也是单独一个模块**-api

依赖如下,依旧是远程调用加负载均衡

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>hm-api</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!--open feign-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <!-- load balancer-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <!-- swagger 注解依赖 -->
        <dependency>
            <groupId>io.swagger</groupId>
            <artifactId>swagger-annotations</artifactId>
            <version>1.6.6</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

定义日志级别

package com.hmall.api.config;

import feign.Logger;
import org.springframework.context.annotation.Bean;

public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.FULL;
    }
}

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

在cart-service中的启动类上已经配置了@EnableFeignClients(basePackages = "com.hmall.api.client",

defaultConfiguration = DefaultFeignConfig.class),启动的时候会自动注入该配置类

)这里的意思就是没把这个日记级别的配置加上@Configuration,那么spring 容器怎么知道这个一个配置类呢?就这样设置

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

 @FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)

  • 全局生效:在@EnableFeignClients中配置,针对所有FeignClient生效。

 @EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)

现在把所有的模块都拆分了,有如下很多问题出现:

如何才能在每个微服务中都拿到用户信息?如何在微服务之间传递用户信息

nginx内部会将发向服务端请求全部代理到8080端口,但是现在拆分了N个微服务,8080不可用了。

6.网关

新的问题出现了.

单体架构时我们只需要完成一次用户登录、身份校验,就可以在所有业务中获取到用户信息。而微服务拆分后,每个微服务都独立部署,这就存在一些问题:

  • 每个微服务都需要编写登录校验、用户信息获取的功能吗?

  • 当微服务之间调用时,该如何传递用户信息

我问了ai几个关于网关的问题,回答的很好


一、网关的核心职责:它是系统的“大门”

想象一个大型写字楼,里面有许多公司(微服务)。如果没有一楼大堂的接待处和门禁,任何人都可以随意进出任一公司,每个公司都得自己雇保安、做访客登记,这会造成巨大的浪费和混乱。

网关就是这个“大堂与门禁”,核心职责是 “单一入口” ,所有外部请求都先经过它。它主要做三类事:

  • 路由转发
    根据请求的路径,转发给对应的微服务。比如 /order/* 转到订单服务,/user/* 转到用户服务。
    客户端只需要知道网关地址。

  • 横切面关注点处理
    处理所有微服务共通的、与业务逻辑无关的功能,避免重复实现。主要包括:

    • 认证授权:你问的登录校验就在这里。

    • 限流熔断:防止流量过大冲垮后端服务。

    • 日志监控:统一记录所有请求的访问日志。

    • 协议转换:如将外部的 HTTP 请求转为内部的 gRPC 调用。

  • 安全防护
    作为屏障,屏蔽内部服务的细节,隐藏真实地址;并能进行防SQL注入、防爬虫等操作。

认证授权是网关最重要且最集中的功能之一。它把安全校验的责任从各个微服务收拢到了一点。


二、登录校验后,如何在不同模块间传递用户信息?

这是个非常关键的设计。网关完成认证后,会将解析出的用户身份信息安全地传递给下游服务,主要有两种方式:

方式一:通过 HTTP 请求头传递(最常用)

  1. 用户请求带着 JWT Token 到达网关。

  2. 网关验证 Token 的合法性和有效性。

  3. 验证通过后,网关从 Token 中解析出 userId、username、roles 等信息。

  4. 网关将这些信息放到一个新的请求头(如 X-User-Id、X-User-Roles)里,透传给后端的订单服务、商品服务等。

  5. 后端服务直接从请求头中获取用户信息,无需再次验签。

这样做的前提是:你要确保后端服务只能通过网关访问,不能绕过网关直接访问。这在云原生架构中通常通过网络策略或服务网格来实现。

方式二:沿用 Token 本身传递

网关验证通过后,不做解析,直接把原始 Token 继续传递给下游服务。下游服务需要知道公钥,再次验证 Token 并从中解析信息。这种方式能避免传递明文头被篡改的风险,但会增加下游服务的开销和复杂性。实践中,方式一更为主流。


三、为什么安全性这么重要,以至于催生了网关技术?

网关并非由安全这一个因素催生,但安全是核心驱动力之一。

与其说“因为安全重要所以开发网关”,不如说:在微服务架构下,传统的安全做法失效了,而网关提供了一个优雅的“一石多鸟”的解决方案。

1. 解决了“安全责任分散”的难题

  • 传统单块应用:安全模块是应用的一部分,集中鉴权。

  • 微服务分散式处理:如果不加网关,每个服务都得自己集成安全模块,做登录校验、权限判断。这会造成巨大的代码冗余,并且一旦安全模块需要升级,所有服务都得跟着改,这是运维的噩梦。网关提供了唯一的、集中的安全策略实施点。

2. 实现了“零信任网络”的核心原则

现代安全架构不信任任何内部网络流量。网关作为所有请求的必经之路,实现了:

  • 认证的“门禁”作用:在请求到达业务逻辑前,就把非法的、未登录的请求挡在门外。

  • 授权的“过滤”作用:可以基于URL粒度实现粗粒度的权限控制(如 /admin/* 的路径需要管理员角色),这大大减轻了后端服务的授权压力。

3. 简化了客户端和下游服务(好好理解!!)

  • 客户端:只需关心如何对接网关,获取和使用Token,不用处理复杂的内部服务地址。

  • 下游服务:可以纯粹地专注于业务逻辑,从请求头里安全、便捷地获取用户身份,无需关心复杂的认证流程。

客户端,只向网关发送请求,由网关完成登录校验,然后再分发路由给下流的服务

在SpringCloud当中,提供了两种网关实现方案:

  • Netflix Zuul:早期实现,目前已经淘汰

  • SpringCloudGateway:基于Spring的WebFlux技术,完全支持响应式编程,吞吐能力更强

网关本身也是一个独立的微服务

1.引入依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>hm-gateway</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    <dependencies>
        <!--common-->
        <dependency>
            <groupId>com.heima</groupId>
            <artifactId>hm-common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!--nacos discovery-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <!--负载均衡-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2.启动类

3.路由配置

server:
  port: 8080
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 192.168.150.101:8848
    gateway:
      routes:
        - id: item # 路由规则id,自定义,唯一
          uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
          predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
            - Path=/items/**,/search/** # 这里是以请求路径作为判断规则
        - id: cart
          uri: lb://cart-service
          predicates:
            - Path=/carts/**
        - id: user
          uri: lb://user-service
          predicates:
            - Path=/users/**,/addresses/**
        - id: trade
          uri: lb://trade-service
          predicates:
            - Path=/orders/**
        - id: pay
          uri: lb://pay-service
          predicates:
            - Path=/pay-orders/**

gateway:

    routes:

        - id : #唯一自定义

          url: lb://item-service #当前模块的配置时起的服务名,注册到注册中心的名字

         predicates: #路由断言

             - Path=/items/**  #当请求的路径以/items开头,全部转发到对应的以/items开头的下流的服务模块

注意路由配置文件的缩进!注意看设置的端口号是8080,那么后续客户端,也就是浏览器每一个对服务端的请求,都是直接发到网关对应的8080端口,由配置好的路由转发转发到对应的下流的服务,所以前端不需要知道任何关于下流服务内部的端口信息,只需要知道网关的请求地址就行.

网关的登录校验

不过,这里存在几个问题:

  • 网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?

  • 网关校验JWT之后,如何将用户信息传递给微服务?

  • 微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?

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

spring:
  cloud:
    gateway:
      routes:
      - id: test_route
        uri: lb://test-service
        predicates:
          -Path=/test/**
        filters:
          - AddRequestHeader=key, value # 逗号之前是请求头的key,逗号之后是value
1.自定义GatewayFilter(只有部分路由需要这个逻辑,比如在请求路径中添特殊头,或者不同参数)

自定义GatewayFilter不是直接实现GatewayFilter,而是实现AbstractGatewayFilterFactory。最简单的方式是这样的:

@Component
public class PrintAnyGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    @Override
    public GatewayFilter apply(Object config) {
        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
                // 获取请求
                ServerHttpRequest request = exchange.getRequest();
                // 编写过滤器逻辑
                System.out.println("过滤器执行了");
                // 放行
                return chain.filter(exchange);
            }
        };
    }
}
2.开发过程中一般都是GlobalFilter
ServerWebExchange exchange —— 当前请求的“上下文”

你可以把它理解成 网关对一次 HTTP 请求和响应的封装,相当于 Spring MVC 里 HttpServletRequest + HttpServletResponse 的组合,但更强大。

从这个对象里,你可以拿到:

  • 请求信息:exchange.getRequest() → URL、请求头、参数等。

  • 响应对象:exchange.getResponse() → 用来直接写入响应状态码、响应头,甚至直接结束请求。

  • 属性/会话:可以在请求处理过程中存取一些临时数据(比如我们后面要传递的 userId)。

在过滤器中,网关就是通过操作这个 exchange 来决定请求的生死。

2. GatewayFilterChain chain —— 过滤器链

这个就是 责任链模式 的体现。
Spring Cloud Gateway 里可以有很多个过滤器(GlobalFilter 和 GatewayFilter),它们会组成一条链。
你当前的过滤器做完自己的事情后,如果决定放行,就调用:

java

return chain.filter(exchange);

这表示把请求交给下一个过滤器继续处理,直到最终被路由到后端服务。
如果决定拦截(比如 Token 无效),就不调用 chain.filter,而是直接操作 exchange.getResponse() 返回错误并终止整个链。

package com.hmall.gateway.filter;

import com.hmall.common.exception.UnauthorizedException;
import com.hmall.common.utils.CollUtils;
import com.hmall.gateway.config.AuthProperties;
import com.hmall.gateway.util.JwtTool;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final JwtTool jwtTool;

    private final AuthProperties authProperties;

    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取Request
        ServerHttpRequest request = exchange.getRequest();
        // 2.判断是否不需要拦截
        if(isExclude(request.getPath().toString())){
            // 无需拦截,直接放行
            return chain.filter(exchange);
        }
        // 3.获取请求头中的token
        String token = null;
        List<String> headers = request.getHeaders().get("authorization");
        if (!CollUtils.isEmpty(headers)) {
            token = headers.get(0);
        }
        // 4.校验并解析token
        Long userId = null;
        try {
            userId = jwtTool.parseToken(token);
        } catch (UnauthorizedException e) {
            // 如果无效,拦截
            ServerHttpResponse response = exchange.getResponse();
            response.setRawStatusCode(401);
            return response.setComplete();
        }

        // TODO 5.如果有效,传递用户信息
        System.out.println("userId = " + userId);
        // 6.放行
        return chain.filter(exchange);
    }

    private boolean isExclude(String antPath) {
        for (String pathPattern : authProperties.getExcludePaths()) {
            if(antPathMatcher.match(pathPattern, antPath)){
                return true;
            }
        }
        return false;
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

由于网关发送请求到微服务依然采用的是Http请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。

据图流程图如下:

因此,接下来我们要做的事情有:

  • 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务

  • 编写微服务拦截器,拦截请求获取用户信息,保存到ThreadLocal后放行

3.网关实现功能如下:网关的过滤器globalFilter的编写,有两个参数,一个是全局上下文,一个是过滤器链,这里的网关,在配置文件中就实现了分发路由的功能,那么在globalFilter之中,就是实现了拦截器的功能,只要有token,那么表示登录了,直接放行 chain.fliter(exchange) 过滤器链表示:上下文可以走了!而且诸如登录之类的功能,直接放行,就是我们以前编写的的拦截器的功能,而现在拦截器的功能,是拿到用户id并存入threadlocal中方便随时调用,我们将拦截器,userContext(存放上下文的),全部写入common模块,只要所有的模块都引用common模块,都可以拿到用户id,避免了重复代码的编写!要好好体会这个网关如何一步步实现分发路由,登录校验,拦截器又是怎么存储用户id的,怎么用threadlcoal实现的,需要注意common模块里的拦截器,只是需要把userID取出来放入userContext中,在api模块还需要编写一个RequestInterceptor,把userID取出来塞入下流的请求头!这样每次发起 openFeign都会带上用户信息!区分

每个微服务都有获取登录用户的需求,因此拦截器我们直接写在hm-common中,并写好自动装配。这样微服务只需要引入hm-common就可以直接具备拦截器功能,无需重复编写.

package com.hmall.common.interceptor;

import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class UserInfoInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的用户信息
        String userInfo = request.getHeader("user-info");
        // 2.判断是否为空
        if (StrUtil.isNotBlank(userInfo)) {
            // 不为空,保存到ThreadLocal
                UserContext.setUser(Long.valueOf(userInfo));
        }
        // 3.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserContext.removeUser();
    }
}

接着在hm-common模块下编写SpringMVC的配置类,配置登录拦截器:(springMVC拦截器需要配置@configuration,进行配置才能生效)

package com.hmall.common.config;

import com.hmall.common.interceptors.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserInfoInterceptor());
    }
}

注意:

@ConditionalOnClass是Spring Boot中的一个条件注解,它可以用来指定在类路径中存在特定的类时才加载某个配置类或Bean。这个注解可以用于自动配置类或普通的Spring组件中,以实现根据类路径中的情况来决定是否加载某个配置或Bean。

除了hm-gateway外底层都是springMVC,那要想不让网关得到这个WebMvcconfigurer配置,那就要;利用SpringMVC的特点去排除它,SprngMVC都有一个DispatcherServlet的api

简单来说,就是所有微服务都无法扫描到这个拦截器,我们可以给每一个微服务的启动类Application加上compinentscan指定扫描这个拦截器,但是,微服务数量庞大,显然不是个好办法,利用SpringBoot的自动装配原理就简单的多

在启动spring boot的自动装配

最后一个问题:

下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!

由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。

所以刚刚的拦截器要修改!

​
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
    return new RequestInterceptor() {
        @Override
        public void apply(RequestTemplate template) {
            // 获取登录用户
            Long userId = UserContext.getUser();
            if(userId == null) {
                // 如果为空则直接跳过
                return;
            }
            // 如果不为空则放入请求头中,传递给下游微服务
            template.header("user-info", userId.toString());
        }
    };
}

​

7.共享配置

这个共享配置后面需要使用时再学,一般配置不多,多人协作开发,个人的配置也不多,真的有这个共享配置的要求再学习,这里不再说明!后续分布式 mq ES seata等这博客一两天就会发出来😀

更多推荐