缓存优化

  • 问题说明:
    • 当用户数量足够多的时候,系统访问量大
    • 频繁的访问数据库,系统性能下降,用户体验差
    • 所以一些通用、常用的数据,我们可以使用Redis来缓存,避免用户频繁访问数据库

环境搭建

导入SpringDataRedis的maven坐标

  • 这里我们就还是用SpringDataRedis来开发了

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

配置文件

  • 配置连接redis的数据,我这里配置的是我的云服务器上装的Redis

    1
    2
    3
    4
    5
    redis:
    host: 101.XXX.XXX.160 #这里换成localhost或者你自己的linux上装的redis
    password: root
    port: 6379
    database: 0

配置类

  • 配置一下序列化器,方便我们在图形化界面中查看我们存入的数据,在config包下新建RedisConfig类

  • 但是也可以不配置RedisConfig,而是直接用

    1
    SpringRedisConfig

    ,它的默认序列化器就是

    1
    StringRedisSerializer
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Configuration
    public class RedisConfig extends CachingConfigurerSupport {
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
    //默认key序列化器为:JdkSerializationRedisSerializer
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setConnectionFactory(connectionFactory);
    return redisTemplate;
    }
    }

缓存短信验证码

实现思路

  • 先来回顾一下我们之前的邮件验证码是储存在哪儿的
    • 之前我们是存的Session,你答对了吗
  • 那现在我们学了Redis的基础应用,我们现在就可以把它缓存在Redis里
  • 具体实现思路如下
    1. 在服务端UserController中注入RedisTemplate对象,用于操作Redis;
    2. 在服务端UserController的sendMsg方法中,将随机生成的验证码缓存到Redis中,并设置有效期为5分钟;
    3. 在服务端UserController的login方法中,从Redis中获取缓存的验证码,如果登录成功则删除Redis中的验证码;

代码改造

  1. 在UserController中注入RedisTemplate或StringRedisTemplate对象,用于操作Redis

    1
    2
    @Autowired
    private RedisTemplate redisTemplate;
  2. 修改UserController中的sendMsg方法,将随机生成的验证码缓存到Redis中,并设置有效期为5分钟

    • DIFF
    • 修改后代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @PostMapping("/sendMsg")
    public R<String> sendMsg(@RequestBody User user, HttpSession session) throws MessagingException {
    String phone = user.getPhone();
    if (!phone.isEmpty()) {
    //随机生成一个验证码
    String code = MailUtils.achieveCode();
    log.info(code);
    //这里的phone其实就是邮箱,code是我们生成的验证码
    MailUtils.sendTestMail(phone, code);
    - //验证码存session,方便后面拿出来比对
    - session.setAttribute(phone, code);
    + //验证码缓存到Redis,设置存活时间5分钟
    + redisTemplate.opsForValue().set(phone, code,5, TimeUnit.MINUTES);
    return R.success("验证码发送成功");
    }
    return R.error("验证码发送失败");
    }
  3. 在服务端的UserController的login方法中,从Redis获取验证码,如果登录成功则删除Redis中的验证码

    • DIFF
    • 修改后代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    @PostMapping("/login")
    public R<User> login(@RequestBody Map map, HttpSession session) {
    log.info(map.toString());
    String phone = map.get("phone").toString();
    String code = map.get("code").toString();
    - //把刚刚存进去的验证码拿出来
    - String codeInSession = session.getAttribute(phone).toString();
    + //把Redis中缓存的code拿出来
    + Object codeInRedis = redisTemplate.opsForValue().get(phone);
    - //看看接收到用户输入的验证码是否和session中的验证码相同
    - log.info("你输入的code{},session中的code{},计算结果为{}", code, codeInSession, (code != null && code.equals(codeInSession)));
    + //看看接收到用户输入的验证码是否和redis中的验证码相同
    + log.info("你输入的code{},session中的code{},计算结果为{}", code, codeInRedis, (code != null && code.equals(codeInRedis)));
    - if (code != null && code.equals(codeInSession)) {
    + if (code != null && code.equals(codeInRedis)) {
    LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(User::getPhone, phone);
    User user = userService.getOne(queryWrapper);
    if (user == null) {
    user = new User();
    user.setPhone(phone);
    - user.setName("用户" + codeInSession);
    + user.setName("用户" + codeInRedis);
    userService.save(user);
    }
    session.setAttribute("user", user.getId());
    + //如果登录成功,则删除Redis中的验证码
    + redisTemplate.delete(phone);
    return R.success(user);
    }
    return R.error("登录失败");
    }

缓存菜品数据

  • 菜品数据是我们登录移动端之后的展示页面
  • 所以每当我们访问首页的时候,都会调用数据库查询一遍菜品数据
  • 对于这种需要频繁访问的数据,我们可以将其缓存到Redis中以减轻服务器的压力

实现思路

  • 移动端对应的菜品查看功能,是DishController中的list方法,此方法会根据前端提交的查询条件进行数据库查询操作(用户选择不同的菜品分类)。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增长。所以现在我们需要对此方法进行缓存优化,提高系统性能
  • 但是还有存在一个问题,我们是将所有的菜品缓存一份,还是按照菜品/套餐分类,来进行缓存数据呢?
  • 答案是后者,当我们点击某一个分类时,只需展示当前分类下的菜品,而其他分类的菜品数据并不需要展示,所以我们在缓存的时候,根据菜品的分类,缓存多分数据,页面在查询时,点击某个分类,则查询对应分类下的菜品的缓存数据
  • 具体实现思路如下
    1. 修改DishController中的list方法,先从Redis中获取分类对应的菜品数据,如果有,则直接返回;如果无,则查询数据库,并将查询到的菜品数据存入Redis
    2. 修改DishController的save、update和delete方法,加入清理缓存的逻辑,避免产生脏数据(我们实际已经在后台修改/更新/删除了某些菜品,但由于缓存数据未被清理,未重新查询数据库,用户看到的还是我们修改之前的数据)

代码改造

  1. 先在DishController中注入RedisTemplate

    1
    2
    @Autowired
    private RedisTemplate redisTemplate;
  2. 修改DishController的list方法,先从Redis中获取菜品数据

    • 如果有,则直接返回

    • 如果无,则查询数据库,并将查询到的菜品数据让Redis缓存

      • DIFF
      • 修改后代码
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      @GetMapping("/list")
      public R<List<DishDto>> get(Dish dish) {
      + List<DishDto> dishDtoList;
      //动态构造key
      + String key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus();
      //先从redis中获取缓存数据
      + dishDtoList = (List<DishDto>) redisTemplate.opsForValue().get(key);
      + //如果有,则直接返回
      + if (dishDtoList != null){
      //如果存在,直接返回,无需查询数据库
      + return R.success(dishDtoList);
      + }
      + //如果无,则查询
      //构造查询条件
      LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
      //根据传进来的categoryId查询
      queryWrapper.eq(dish.getCategoryId() != null, Dish::getCategoryId, dish.getCategoryId());
      //只查询状态为1的菜品(在售菜品)
      queryWrapper.eq(Dish::getStatus, 1);
      //简单排下序,其实也没啥太大作用
      queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);
      //获取查询到的结果作为返回值
      List<Dish> list = dishService.list(queryWrapper);
      log.info("查询到的菜品信息list:{}",list);
      //item就是list中的每一条数据,相当于遍历了
      - List<DishDto> dishDtoList = list.stream().map((item) -> {
      + dishDtoList = list.stream().map((item) -> {
      //创建一个dishDto对象
      DishDto dishDto = new DishDto();
      //将item的属性全都copy到dishDto里
      BeanUtils.copyProperties(item, dishDto);
      //由于dish表中没有categoryName属性,只存了categoryId
      Long categoryId = item.getCategoryId();
      //所以我们要根据categoryId查询对应的category
      Category category = categoryService.getById(categoryId);
      if (category != null) {
      //然后取出categoryName,赋值给dishDto
      dishDto.setCategoryName(category.getName());
      }
      //然后获取一下菜品id,根据菜品id去dishFlavor表中查询对应的口味,并赋值给dishDto
      Long itemId = item.getId();
      //条件构造器
      LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper<>();
      //条件就是菜品id
      lambdaQueryWrapper.eq(itemId != null, DishFlavor::getDishId, itemId);
      //根据菜品id,查询到菜品口味
      List<DishFlavor> flavors = dishFlavorService.list(lambdaQueryWrapper);
      //赋给dishDto的对应属性
      dishDto.setFlavors(flavors);
      //并将dishDto作为结果返回
      return dishDto;
      //将所有返回结果收集起来,封装成List
      }).collect(Collectors.toList());
      + //将查询的结果让Redis缓存,设置存活时间为60分钟
      + //如果不存在,需要查询数据库,将查询到的菜品数据缓存到Redis
      + redisTemplate.opsForValue().set(key,dishDtoList,60, TimeUnit.MINUTES);
      return R.success(dishDtoList);
      }
  3. 修改DishController里的save、update和批量修改方法(status),加入清理缓存的逻辑

    • save DIFF
    • 修改后的save
    • update DIFF
    • 修改后的update
    • status DIFF
    • 修改后的status
    1
    2
    3
    4
    5
    6
    7
    8
    @PostMapping
    public R<String> save(@RequestBody DishDto dishDto) {
    log.info("接收到的数据为:{}", dishDto);
    dishService.saveWithFlavor(dishDto);
    + String key = "dish_" + dishDto.getCategoryId() + "_1";
    + redisTemplate.delete(key);
    return R.success("添加菜品成功");
    }

    注意:这里并不需要我们对删除操作也进行缓存清理,因为删除操作执行之前,必须先将菜品状态修改为停售,而停售状态也会帮我们清理缓存,同时也看不到菜品,随后将菜品删除,仍然看不到菜品,故删除操作不需要进行缓存清理

  4. 修改完了之后,我们来测试一下

    • 由于我们现在还没有编写套餐数据的缓存,所以我们现在可以用菜品数据和套餐数据做对比
    • 先手动点击一遍所有的分类,让Redis缓存(包括菜品分类和套餐分类)
    • 之后去控制台清空输出,方便我们后续对比
    • 随后再次点击菜品分类,控制台日志不会输出SQL语句的日志
    • 但是点击套餐分类时,控制台会输出SQL语句的日志
    • 当我们对菜品数据进行任意形式的修改(修改/添加/删除/改状态)时,缓存数据将被清理,同时重新查询,避免出现脏数据

SpringCache

SpringCache介绍

  • SpringCache是一个框架,实现了基本注解的缓存功能,只需要简单的添加一个注解,就能实现缓存功能
  • SpringCache提供了一层抽象,底层可以切换不同的cache实现,具体就是通过CacheManager接口来统一不同的缓存技术
  • 针对不同的缓存技术,需要实现不同的CacheManager
CacheManger 描述
EhCacheCacheManager 使用EhCache作为缓存技术
GuavaCacheManager 使用Googke的GuavaCache作为缓存技术
RedisCacheManager 使用Rdis作为缓存技术

SpringCache常用注解

注解 说明
@EnableCaching 开启缓存注解功能
@Cacheable 在方法执行前spring先查看缓存中是否有数据。如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
@CachePut 将方法的返回值放到缓存中
@CacheEvict 将一条或者多条数据从缓存中删除

@Cacheable

@Cacheable的作用主要针对方法配置,能够根据方法的请求参数对其结果进行缓存,其主要参数说明如下

注解 说明 举例
value 缓存的名称,在 spring 配置文件中定义,必须指定至少一个 例如:@Cacheable(value=”mycache”)或者@Cacheable(value=(“cache7”, “cache2”]
key 缓存的key,可以为空,如果指定要按照 SpEL表达式编写,如果不指定,则缺省按照方法的所有参数进行组合 例如:@Cacheable(value=”testcache”,key=”#userName”)
condition 缓存的条件,可以为空,使用SpEL编写,返回true或者false,只有为true 才进行缓存 例如:@Cacheable(value=”testcache”,condition=”#userName.length()>2”)

@CachePut

@CachePut的作用主要针对方法配置,能够根据方法的请求参数对其结果进行缓存,和@Cacheable不同的是,它每次都会触发真实方法的调用,其主要参数说明如下(其实跟@Cacheable一样)

注解 说明 举例
value 缓存的名称,在 spring 配置文件中定义,必须指定至少一个 例如:@Cacheable(value=”mycache”)或者@Cacheable(value=(“cache7”, “cache2”]
key 缓存的key,可以为空,如果指定要按照 SpEL表达式编写,如果不指定,则缺省按照方法的所有参数进行组合 例如:@Cacheable(value=”testcache”,key=”#userName”)
condition 缓存的条件,可以为空,使用SpEL编写,返回true或者false,只有为true 才进行缓存 例如:@Cacheable(value=”testcache”,condition=”#userName.length()>2”)

@CachEvict

@CachEvict的作用主要针对方法配置,能够根据一定的条件对缓存进行清空

注解 说明 举例
value 缓存的名称,在 spring配置文件中定义,必须指定至少一个 例如:@Cacheable(value=”mycache”)或者@Cacheable(value={“cache1”, “cache2”]
key 缓存的key,可以为空,如果指定要按照SpEL表达式编写,如果不指定,则缺省按照方法的所有参数进行组合 例如:@Cacheable(value=”testcache”,key=”#userName”)
condition 缓存的条件,可以为空,使用SpEL编写,返回true或者false,只有为true 才进行缓存 例如:@Cacheable(value=”testcache”,condition=”#userName.length()>2”)
allEntries 是否清空所有缓存内容,缺省为false,如果指定为true,则方法调用后将立即清空所有缓存 例如:@CachEvict(value=”testcache”,allEntries=true)
beforelnvocation 是否在方法执行前就清空,缺省为false,如果指定为true,则在方法还没有执行的时候就清空缓存,缺省情况下,如果方法执行抛出异常,则不会清空缓存 例如:@CachEvict(value=”testcache”, beforelnvocation=true)

SpringCache使用方式

  • 在SpringBoot项目中,使用缓存技术只需要在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存技术支持即可。

  • 这里我们使用Redis作为缓存技术,只需要导入Spring data Redis的maven坐标即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
  • 随后配置application.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    spring:
    redis:
    host: 101.XXX.XXX.160 #这里换成localhost或者你自己的linux上装的redis
    password: root
    port: 6379
    database: 0
    cache:
    redis:
    time-to-live: 3600000 #设置存活时间为一小时,如果不设置,则一直存活

缓存套餐数据

实现思路

  • 前面我们已经实现了移动端查看套餐的功能,对应SetmealController中的list方法
  • 此方法会根据前端提交的查询条件进行数据库查询操作
  • 在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增强
  • 现在需要对此方法进行缓存优化,提高系统性能
  • 具体实现思路如下
    1. 修改SetmealController中的list方法,先从Redis缓存中获取套餐数据
      • 如果有,则直接返回
      • 如果无,则查询数据库,并将查询到的套餐数据存入Redis
    2. 修改SetmealController的save、update方法,加入清理缓存的逻辑,避免产生脏数据(我们实际已经在后台修改/更新/删除了某些套餐,但由于缓存数据未被清理,未重新查询数据库,用户看到的还是我们修改之前的数据)

代码修改

  1. 导入SpringCache和Redis相关的maven坐标

    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
  2. 在appilcation.yml中配置缓存数据的过期时间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    spring:
    redis:
    host: 101.XXX.XXX.160 #这里换成localhost或者你自己的linux上装的redis
    password: root
    port: 6379
    database: 0
    cache:
    redis:
    time-to-live: 3600000 #设置存活时间为一小时
  3. 在启动类上加上

    1
    @EnableCaching

    注解,开启缓存注解功能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Slf4j
    @SpringBootApplication
    @ServletComponentScan
    @EnableTransactionManagement
    @EnableCaching //开启缓存注解功能
    public class ReggieApplication {
    public static void main(String[] args) throws Exception {
    SpringApplication.run(ReggieApplication.class,args);
    log.info("项目启动成功...");
    }
    }
  4. 再SetmealController的list方法上加上

    1
    @Cacheale

    注解

    该注解的功能是:在方法执行前,Spring先查看缓存中是否有数据;如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @GetMapping("/list")
    @Cacheable(value = "setmealCache", key = "#setmeal.categoryId + '_' + #setmeal.status")
    public R<List<Setmeal>> list(Setmeal setmeal) {
    //条件构造器
    LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
    //添加条件
    queryWrapper.eq(setmeal.getCategoryId() != null, Setmeal::getCategoryId, setmeal.getCategoryId());
    queryWrapper.eq(setmeal.getStatus() != null, Setmeal::getStatus, 1);
    //排序
    queryWrapper.orderByDesc(Setmeal::getUpdateTime);
    List<Setmeal> setmealList = setmealService.list(queryWrapper);
    return R.success(setmealList);
    }
  5. 修改SetmealController的save、update和status方法,加入清理缓存的逻辑
    至于为什么不用修改delete方法,在前面我们已经说明过了
    实现手段也只需要加上@CacheEvict注解,该注解的功能是:将一条或者多条数据从缓存中删除

    • save
    • update
    • status

    当我们对套餐进行修改操作时,清空名为setmealCache的所有缓存

    1
    2
    3
    4
    5
    6
    7
    8
    @PostMapping
    //设置allEntries为true,清空缓存名称为setmealCache的所有缓存
    @CacheEvict(value = "setmealCache", allEntries = true)
    public R<String> save(@RequestBody SetmealDto setmealDto) {
    log.info("套餐信息:{}", setmealDto);
    setmealService.saveWithDish(setmealDto);
    return R.success("套餐添加成功");
    }
    • 在做完这一步之后,会发现报错:DefaultSerializer requires a Serializable payload but received an object of type

    • 这是因为要缓存的JAVA对象必须实现

      1
      Serializable

      接口,因为Spring会先将对象序列化再存入Redis,将缓存实体类继承

      1
      Serializable
      1
      2

      public class R<T> implements Serializable
  6. 修改完毕之后,我们重启服务器测试看看有没有效果,如果有效果的话,我们push一下代码,继续做别的优化

读写分离

问题分析

  • 目前我们所有的读和写的压力都是由一台数据库来承担,
  • 如果数据库服务器磁盘损坏,则数据会丢失(没有备份)
  • 解决这个问题,就可以用MySQL的主从复制,写操作交给主库,读操作交给从库
  • 同时将主库写入的内容,同步到从库中

img

MySQL主从复制

介绍

  • MySQL主从复制是一个异步的复制过程,底层是基于Mysql数据库自带的二进制日志功能。就是一台或多台NysQL数据库(slave,即从库)从另一台MySQL数据库(master,即主库)进行日志的复制然后再解析日志并应用到自身,最终实现从库的数据和主库的数据保持一致。MySQL主从复制是MySQL数据库自带功能,无需借助第三方工具。
  • MySQL复制过程分成三步:
    1. master将改变记录到二进制日志(binary log)
    2. slavemasterbinary log拷贝到它的中继日志(relay log)
    3. slave重做中继日志中的事件,将改变应用到自己的数据库中

img

配置

  • 前置条件
    准备好两台服务器,分别安装MySQL并启动服务成功,我这里用的两台虚拟机(另一台是克隆的,记得修改克隆虚拟机的MySQL的UUID)

  • 修改克隆机的MySQL的uuid

    1. 登录克隆机的MySQL

    2. 执行SQL语句,记住生成的uuid,待会需要用

      1
      2
      3
      4
      5
      6
      mysql> select uuid();
      +--------------------------------------+
      | uuid() |
      +--------------------------------------+
      | 26532364-4f8d-11ed-a300-005056307198 |
      +--------------------------------------+
    3. 查看配置文件目录

      1
      2
      3
      4
      5
      6
      mysql> show variables like 'datadir';
      +---------------+-----------------+
      | Variable_name | Value |
      +---------------+-----------------+
      | datadir | /var/lib/mysql/ |
      +---------------+-----------------+
    4. 编辑配置文件目录,修改uuid为刚刚我们生成的uuid

      1
      vi /var/lib/mysql/auto.cnf
    5. 重启服务

      1
      service mysqld restart
  • 配置主库,我这里就用虚拟机上的mysql当主库了

    1. 修改MySQL数据库的配置文件,虚拟机是

      1
      /etc/my.cnf
      • 找到

        1
        [mysqld]

        ,在下面插入两行

        log_bin=mysql-bin #[必须]启用二进制日志
        server-id=128 #[必须]服务器唯一ID,只需要确保其id是唯一的就好

    2. 重启mysql服务

      1
      systemctl restart mysqld
    3. 登录
      
      1
      2
      3
      4
      5

      Mysql数据库,执行下面的SQL

      ```SQL
      grant replication slave on *.* to 'Kyle'@'%' identified by 'root';
      上面的SQL的作用是创建一个用户
      1
      Kyle
      ,密码为
      1
      root
      ,并且给
      1
      Kyle
      用户授予
      1
      replication slave   //复制
      权限,常用语建立复制时所需要用到的用户权限,也就是
      1
      slave
      必须被
      1
      master
      授权具有该权限的用户,才能通过该用户复制,这是因为主库和从库之间需要互相通信,处于安全考虑,只有通过验证的从库才能从主库中读取二进制数据
    4. 登录Mysql数据库,执行下面的SQL

      1
      show master status;

      记录下结果中File和Position的值

      1
      2
      3
      4
      5
      6
      BASH
      +------------------+----------+--------------+------------------+-------------------+
      | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
      +------------------+----------+--------------+------------------+-------------------+
      | mysql-bin.000005 | 154 | | | |
      +------------------+----------+--------------+------------------+-------------------+
  • 配置从库,我这里就用我的另一台克隆的虚拟机了

    1. 修改MySQL数据库的配置文件

      1
      /etc/my.cnf
      • 找到

        1
        [mysqld]

        ,在下面插入一行

        server-id=127 #[必须]服务器唯一ID,只需要确保其id是唯一的就好

    2. 重启mysql服务

      1
      systemctl restart mysqld
    3. 登录Mysql数据库,执行下面的SQL,将参数修改为你自己的

      1
      2
      change master to master_host='192.168.238.131',master_user='Kyle',master_password='root',master_log_file='mysql-bin.000005',master_log_pos=154;
      start slave;

      上面的SQL的作用是创建一个用户

      1
      Kyle

      ,密码为

      1
      root

      ,并且给

      1
      Kyle

      用户授予

      1
      replication slave

      权限,常用语建立复制时所需要用到的用户权限,也就是

      1
      slave

      必须被

      1
      master

      授权具有该权限的用户,才能通过该用户复制,这是因为主库和从库之间需要互相通信,处于安全考虑,只有通过验证的从库才能从主库中读取二进制数据

    4. 登录Mysql数据库,执行SQL,查看从库的状态

      1
      show slave status;

      看到如下如下三行配置相同,则主从连接成功

      Slave_IO_State: Waiting for master to send event
      Slave_IO_Running: Yes
      Slave_SQL_Running: Yes

读写分离案例

背景

  • 面对日益增加的系统访问量,数据库的吞吐量面临着巨大的瓶颈。
  • 对于同一时刻有大量并发读操作较少的写操作类型的应用系统来说,将数据库拆分为主库从库
  • 主库主要负责处理事务性的增删改操作
  • 从库主要负责查询操作
  • 这样就能有效避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善

Sharding-JDBC介绍

  • Sharding-JDBC定位为轻量级的JAVA框架,在JAVA的JDBC层提供额外的服务,它使得客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架
  • 使用Sharding-JDBC可以在程序中轻松的实现数据库读写分离
    • 适用于任何基于JDBC的ORM框架
    • 支持任何第三方的数据库连接池
    • 支持任意实现JDBC规范的数据库
  • 使用Sharding-JDBC框架的步骤
    1. 导入对应的maven坐标
    2. 在配置文件中配置读写分离规则
    3. 在配置文件中配置允许bean定义覆盖配置项

项目实现读写分离

  • 前面我们已经配置好了主从数据库,那么我们现在就用瑞吉外卖试试读写分离

    1. 导入瑞吉外卖的SQL数据

    2. Git创建一个新分支v1.1,便于我们提交维护

    3. 导入

      1
      Sharding-JDBC

      的maven坐标

      1
      2
      3
      4
      5
      <dependency>
      <groupId>org.apache.shardingsphere</groupId>
      <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
      <version>4.0.0-RC1</version>
      </dependency>
    4. 在配置文件中配置读写分离规则,配置允许bean定义覆盖配置项

      配置项可能会爆红,但是不影响影响项目启动,是IDEA的问题

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      spring:
      shardingsphere:
      datasource:
      names:
      master,slave
      master:
      type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://192.168.238.131:3306/reggie?serverTimezone=UTC&useSSL=false
      username: root
      password: root
      slave:
      type: com.alibaba.druid.pool.DruidDataSource
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://192.168.238.132:3306/reggie?serverTimezone=UTC&useSSL=false
      username: root
      password: root
      masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin
      # 最终的数据源名称
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
      props:
      sql:
      show: true #开启SQL显示,默认false
      main:
      allow-bean-definition-overriding: true

可能遇到的问题

  • 启动时不报错,但是登陆功能报500异常
  • 查看控制台出现SQLFeatureNotSupportedException异常

解决方案

  • 修改pom.xml中druid的maven坐标为

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.20</version>
    </dependency>

Nginx

简介

  • Nginx是一款轻量级的Web/反向代理服务器以及电子邮件(IMAP/POP3)代理服务器,其特点是占有内存少,并发能力强。
  • 事实上Nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用Nginx的网站有:百度、京东、新浪、网易、腾讯、淘宝等。
  • Nginx是由伊戈尔·赛索耶夫为俄罗斯访问量第二的Rambler.ru站点(俄文:Pam6nep)开发的,第一个公开版本0.1.0发布于2004年10月4日。
  • 官网:https://nginx.org/

Nginx的下载和安装

  • 官网下载链接:https://nginx.org/en/download.html

  • 安装过程:

    1. Nginx是C语言开发的,所以需要先安装依赖

      1
      yum -y install gcc pcre-devel zlib-devel openssl openssl-devel
    2. 下载Nginx安装包

      1
      wget https://nginx.org/download/nginx-1.22.1.tar.gz
    3. 解压,我习惯放在

      1
      /usr/local

      目录下

      1
      tar -zxvf nginx-1.22.1.tar.gz -C /usr/local/
    4. 进入到我们解压完毕后的文件夹内

      1
      cd /usr/local/nginx-1.22.1/
    5. 建安装路径文件夹

      1
      mkdir /usr/local/nginx
    6. 安装前检查工作

      1
      ./configure --prefix=/usr/local/nginx
    7. 编译并安装

      1
      make && make install

Nginx目录结构

  • 安装完Nginx后,我们先来熟悉一下Nginx的目录结构

  • 重点目录/文件:

    • conf/nginx.conf
      • nginx配置文件
    • html
      • 存放静态文件(html、css、Js等)
    • logs
      • 日志目录,存放日志文件
    • sbin/nginx
      • 二进制文件,用于启动、停止Nginx服务
  • 文件目录树状图如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    .
    ├── conf <-- Nginx配置文件
    │ ├── fastcgi.conf
    │ ├── fastcgi.conf.default
    │ ├── fastcgi_params
    │ ├── fastcgi_params.default
    │ ├── koi-utf
    │ ├── koi-win
    │ ├── mime.types
    │ ├── mime.types.default
    │ ├── nginx.conf <-- 这个文件我们经常操作
    │ ├── nginx.conf.default
    │ ├── scgi_params
    │ ├── scgi_params.default
    │ ├── uwsgi_params
    │ ├── uwsgi_params.default
    │ └── win-utf
    ├── html <-- 存放静态文件,我们后期部署项目,就要将静态文件放在这
    │ ├── 50x.html
    │ └── index.html <-- 提供的默认的页面
    ├── logs <-- 日志目录,由于我们新装的Nginx,所以现在还没有日志文件
    └── sbin
    └── nginx <-- 这个文件我们也经常操作

Nginx配置文件结构

  • Nginx配置文件(conf/nginx.conf)整体分为三部分
    • 全局块 和Nginx运行相关的全局配置
    • events块 和网络连接相关的配置
    • http块 代理、缓存、日志记录、虚拟主机配置
      • http全局块
      • Server块
        • Server全局块
        • location块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
worker_processes  1;                              <-- 全局块

events { <-- events块
worker_connections 1024;
}

http { <-- http块
include mime.types; <-- http全局块
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;

server { <-- Server块
listen 80; <-- Server全局块
server_name localhost;
location / { <-- location块
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}

注意:http块中可以配置多个Server块,每个Server块中可以配置多个location块

Nginx命令

  • 查看版本
    
    1
    2
    3

    - 进入sbin目录,输入

    ./nginx -v
    1
    2
    3
    4

    ```BASH
    [root@localhost sbin]# ./nginx -v
    nginx version: nginx/1.22.1
  • 检查配置文件正确性
    
    1
    2
    3

    - 进入sbin目录,输入

    ./nginx -t
    1
    2
    3
    4
    5
    6
    7

    ,如果有错误会报错,而且也会记日志

    ```BASH
    [root@localhost sbin]# ./nginx -t
    nginx: the configuration file /usr/local/nginx/conf/nginx.conf syntax is ok
    nginx: configuration file /usr/local/nginx/conf/nginx.conf test is successful
  • 启动与停止
    
    1
    2
    3

    - 进入sbin目录,输入

    ./nginx
    1
    2
    3
    4
    5
    6
    7
    8
    9

    ,启动完成后查看进程

    ```BASH
    [root@localhost sbin]# ./nginx
    [root@localhost sbin]# ps -ef | grep nginx
    root 89623 1 0 22:08 ? 00:00:00 nginx: master process ./nginx
    nobody 89624 89623 0 22:08 ? 00:00:00 nginx: worker process
    root 89921 1696 0 22:08 pts/0 00:00:00 grep --color=auto nginx
    - 如果想停止Nginx服务,输入
    1
    ./nginx -s stop
    ,停止服务后再次查看进程
    1
    2
    3
    [root@localhost sbin]# ./nginx -s stop
    [root@localhost sbin]# ps -ef | grep nginx
    root 93772 1696 0 22:11 pts/0 00:00:00 grep --color=auto nginx
  • 重新加载配置文件

    • 当修改Nginx配置文件后,需要重新加载才能生效,可以使用下面命令重新加载配置文件:./nginx -s reload
  • 上面的所有命令,都需要我们在sbin目录下才能运行,比较麻烦,所以我们可以将Nginx的二进制文件配置到环境变量中,这样无论我们在哪个目录下,都能使用上面的命令

  • 使用

    1
    vim /etc/profile

    命令打开配置文件,并配置环境变量,保存并退出

    1
    2
    - PATH=$JAVA_HOME/bin:$PATH
    + PATH=/usr/local/nginx/sbin:$JAVA_HOME/bin:$PATH
  • 之后重新加载配置文件,使用source /etc/profile命令,然后我们在任意位置输入nginx即可启动服务,nginx -s stop即可停止服务

  • 查看自己IP,启动服务后,浏览器输入ip地址就可以访问Nginx的默认页面

    • ip addr

Nginx具体应用

部署静态资源

  • Nginx可以作为静态web服务器来部署静态资源。静态资源指在服务端真实存在并且能够直接展示的一些文件,比如常见的html页面、css文件、js文件、图片、视频等资源。
  • 相对于Tomcat,Nginx处理静态资源的能力更加高效,所以在生产环境下,一般都会将静态资源部署到Nginx中。
  • 将静态资源部署到Nginx非常简单,只需要将文件复制到Nginx安装目录下的html目录中即可。

反向代理

  • 正向代理

    • 正向代理是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。
    • 正向代理的典型用途是为在防火墙内的局域网客户端提供访问Internet的途径。
    • 正向代理一般是在客户端设置代理服务器,通过代理服务器转发请求,最终访问到目标服务器。
      img
  • 反向代理

    • 反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源,反向代理服务器负责将请求转发给目标服务器。
    • 用户不需要知道目标服务器的地址,也无须在用户端作任何设定。
      img
  • 举个例子

    • 正向代理:你让舍友去给你带三楼卖的煎饼(你最终会得到一个三楼的煎饼)
    • 反向代理:你让舍友去给你买煎饼(你最终只会得到一个煎饼,但你不知道煎饼是哪儿卖的)
    • 和正向代理不同,反向代理相当于是为目标服务器工作的,当你去访问某个网站时,你以为你访问问的是目标服务器,其实不然,当你访问时,其实是由一个代理服务器去接收你的请求,正向代理与反向代理最简单的区别: 正向代理隐藏的是用户(卖煎饼的不知道是你要买),反向代理隐藏的是服务器(你不知道煎饼是谁卖的)。
    • 正向代理侧重的是用户,用户知道可以通过代理访问无法访问的资源,而反向代理侧重点在服务器这边,用户压根不知道自己访问的是资源时通过代理人去转发的。
    • 正向是指给客户端做代理,反向是指给服务器做代理
    • 比如我让我室友帮我去餐厅买饭,这个室友是我命令的那么就是正向代理,如果餐厅有人给我送饭我不知道这个人是谁就是反向代理
    • 正向代理其实是客户端的代理,帮助客户端访问其无法访问的服务器资源。反向代理则是服务器的代理,帮助服务器做负载均衡,安全防护等。
  • 配置反向代理
    这里是在192.168.238.131上配置的,那么访问流程如下
    客户端 —> 192.168.238.131:82 —> 192.168.238.132/50x.html
    客户端访问反向代理服务器的82端口,而82端口又将请求转发给web服务器的50x.html资源
    注意这里需要开启反向代理服务器的82端口

    1
    2
    3
    4
    5
    6
    7
    8
    server {
    listen 82;
    server_name localhost;

    location / {
    proxy_pass http://http://192.168.238.132/50x.html;
    }
    }

负载均衡

  • 早期的网站流量和业务功能都比较简单,单台服务器就可以满足基本需求,但是随着互联网的发展,业务流量越来越大并且业务逻辑也越来越复杂,单台服务器的性能及单点故障问题就凸显出来了,因此需要多台服务器组成应用集群,进行性能的水平扩展以及避免单点故障出现。
  • 应用集群:将同一应用部署到多台机器上,组成应用集群,接收负载均衡器分发的请求,进行业务处理并返回响应数据。
  • 负载均衡器:将用户请求根据对应的负载均衡算法分发到应用集群中的一台服务器进行处理。

img

  • 配置负载均衡
    默认是轮询算法,第一次访问是192.168.238.132,第二次访问是101.XXX.XXX.160
    也可以改用权重方式,权重越大,几率越大,现在的访问三分之二是第一台服务器接收,三分之一是第二台服务器接收
    server 192.168.238.132 weight=10
    server 101.XXX.XXX.160 weight=5
1
2
3
4
5
6
7
8
9
10
11
12
upstream targetServer{
server 192.168.238.132;
server 101.XXX.XXX.160;
}
server {
listen 82;
server_name localhost;

location / {
proxy_pass http://targetServer;
}
}
  • 负载均衡策略
名称 说明
轮询 默认方式
weight 权重方式
ip_hash 依据ip分配方式
least_conn 依据最少连接方式
url_hash 依据url分配方式
fair 依据响应时间方式

Nginx的特点

  1. 跨平台:Nginx可以在大多数操作系统中运行,而且也有Windows的移植版本
  2. 配置异常简单:非常容易上手。配置风格跟程序开发一样,神一般的配置
  3. 非阻塞、高并发:数据复制时,磁盘I/O的第一阶段是非阻塞的。官方测试能够支撑5万并发连接,在实际生产环境中跑到2-3万并发连接数(这得益于Nginx使用了最新的epoll模型)
  4. 事件驱动:通信机制采用epoll模式,支持更大的并发连接数
  5. 内存消耗小:处理大并发的请求内存消耗非常小。在3万并发连接下,开启的10个Nginx进程才消耗150M内存(15M*10=150M)
  6. 成本低廉:Nginx作为开源软件,可以免费试用。而购买F5 BIG-IP、NetScaler等硬件负载均衡交换机则需要十多万至几十万人民币
  7. 内置健康检查功能:如果Nginx Proxy后端的某台Web服务器宕机了,不会影响前端访问。
  8. 节省带宽:支持GZIP压缩,可以添加浏览器本地缓存的Header头。
  9. 稳定性高:用于反向代理,宕机的概率微乎其微。

前后端分离开发

  • 开发人员同时负责前端和后端代码开发,分工不明确,开发效率低
  • 前后端代码混合在一个工程中,不便于管理
  • 对开发人员要求高,人员招聘困难
  • 所以衍生出了一种前后端分离开发

前后端分离开发

介绍

  • 前后端分离开发,就是在项目开发过程中,对前端代码的开发,专门由前端开发人员负责,后端代码由后端开发人员负责,这样可以做到分工明确,各司其职,提高开发效率,前后端代码并行开发,可以加快项目的开发速度。目前,前后端分离开发方式已经被越来越多的公司采用了,成为现在项目开发的主流开发方式。
  • 前后端分离开发后,从工程结构上也会发生变化,即前后端代码不再混合在同一个maven工程中,而是分为前端工程和后端工程

img

开发流程

  • 前后端开发人员都参照接口API文档进行开发
  • 接口(API接口) 就是一个http的请求地址,主要就是去定义:请求路径、请求方式、请求参数、响应参数等内容。

img

YApi

介绍

  • YApi是高效、易用、功能强大的api管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。可以帮助开发者轻松创建、发布、维护API,YApi还为用户提供了优秀的交互体验,开发人员只需要利用平台提供的接口数据写入工具以及简单的点击操作就可以实现接口的管理。
  • YApi让接口开发更简单高效,让接口的管理更具有可读性、可维护性,让团队协作更合理。
  • Git仓库:https://github.com/YMFE/yapi

使用

  • 使用YApi,可以执行下面操作:
    • 添加项目
    • 添加分类
    • 添加接口
    • 编辑接口
    • 查看接口

Swagger

介绍

  • 使用Swagger你只需要按照它的规范去定义接口及接口相关的信息,再通过Swagger衍生出来的一系列项目和工具,就可以做成各种格式的接口文档,以及在线接口调试页面等。
  • 官网:https://swagger.io/

使用方式

  1. 导入对应的maven坐标

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.3</version>
    </dependency>
  2. 导入knife4j相关配置,并配置静态资源映射,否则接口文档页面无法访问,注意将controller的包路径修改为你自己的

    • 导入knife4j相关配置 1
    • 修改后代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
        @Configuration
    @Slf4j
    + @EnableSwagger2
    + @EnableKnife4j
    public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    log.info("开始进行静态资源映射...");
    registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
    registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
    + registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
    + registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
    //设置对象转化器,底层使用jackson将java对象转为json
    messageConverter.setObjectMapper(new JacksonObjectMapper());
    //将上面的消息转换器对象追加到mvc框架的转换器集合当中(index设置为0,表示设置在第一个位置,避免被其它转换器接收,从而达不到想要的功能)
    converters.add(0, messageConverter);
    }

    + @Bean
    + public Docket createRestApi() {
    + //文档类型
    + return new Docket(DocumentationType.SWAGGER_2)
    + .apiInfo(apiInfo())
    + .select()
    + .apis(RequestHandlerSelectors.basePackage("com.blog.controller"))
    + .paths(PathSelectors.any())
    + .build();
    + }
    +
    + private ApiInfo apiInfo() {
    + return new ApiInfoBuilder()
    + .title("瑞吉外卖")
    + .version("1.0")
    + .description("瑞吉外卖接口文档")
    + .build();
    + }
    }
  3. 在拦截器在中设置不需要处理的请求路径

    • DIFF
    • 修改后代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
        //定义不需要处理的请求
    String[] urls = new String[]{
    "/employee/login",
    "/employee/logout",
    "/backend/**",
    "/front/**",
    "/common/**",
    //对用户登陆操作放行
    "/user/login",
    "/user/sendMsg",
    +
    + "/doc.html",
    + "/webjars/**",
    + "/swagger-resources",
    + "/v2/api-docs"
    };
  4. 启动服务,访问 http://localhost/doc.html 即可看到生成的接口文档,我这里的端口号用的80,根据自己的需求改

img

常用注解

注解 说明
@Api 用在请求的类上,例如Controller,表示对类的说明
@ApiModel 用在类上,通常是个实体类,表示一个返回响应数据的信息
@ApiModelProperty 用在属性上,描述响应类的属性
@ApiOperation 用在请求的方法上,说明方法的用途、作用
@ApilmplicitParams 用在请求的方法上,表示一组参数说明
@ApilmplicitParam 用在@ApilmplicitParams注解中,指定一个请求参数的各个方面
  • 加上这些注解,可以将我们生成的接口文档更规范

    • User
    • UserController
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @Data
    @ApiModel("用户")
    public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty("主键")
    private Long id;


    //姓名
    @ApiModelProperty("姓名")
    private String name;


    //手机号
    @ApiModelProperty("手机号")
    private String phone;


    //性别 0 女 1 男
    @ApiModelProperty("性别 0 女 1 男")
    private String sex;


    //身份证号
    @ApiModelProperty("身份证号")
    private String idNumber;


    //头像
    @ApiModelProperty("头像")
    private String avatar;


    //状态 0:禁用,1:正常
    @ApiModelProperty("状态 0:禁用,1:正常")
    private Integer status;
    }
  • 重新启动服务器,访问 http://localhost/doc.html 查看新生成的接口文档,可读性比之前提高了

img

项目部署

配置环境说明

一共需要三台服务器

  • 192.168.238.131(服务器A)
    • Nginx:部署前端项目、配置反向代理
    • MySql:主从复制结构中的主库
  • 192.168.238.132(服务器B)
    • jdk:运行java项目
    • git:版本控制工具
    • maven:项目构建工具
    • jar:Spring Boot 项目打成jar包基于内置Tomcat运行
    • MySql:主从复制结构中的从库
  • 101.xxx.xxx.160(服务器C,我用的我的云服务器)
    • Redis:缓存中间件

部署前端项目

  1. 在服务器A中安装Nginx,将前端项目打包目录上传到Nginx的html目录下

  2. 修改Nginx配置文件nginx.conf,新增如下配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    server {
    listen 80;
    server_name localhost;

    location / {
    root html/dist;
    index index.html;
    }
    location ^~ /api/ {
    rewrite ^/api/(.*)$ /$1 break;
    proxy_pass http://192.168.238.132;
    }
    }
  3. 启动Nginx服务器测试,看到如下画面则说明没错
    img

部署后端项目

  • 在服务器B中安装JDK,Git,MySql
  • 将项目打成jar包,手动上传并部署(当然你也可以选择git拉取代码,然后shell脚本自动部署)
  • 部署完后端项目之后,我们就能完成正常的登录功能了,也能进入到后台系统进行增删改查操作
    img

完结撒花