微服务总结

目录

微服务

Nacos

将项目注册到Nacos

Nacos心跳机制

Dubbo

什么是RPC

什么是Dubbo

Dubbo对协议的支持

Dubbo服务的注册与发现

Dubbo实现微服务调用

负载均衡

Dubbo生产者消费者配置小结

Seata

为什么需要Seata

Seata的运行原理

配置Seata

Seata其他模式介绍

TCC模式

SAGA模式

XA模式

Sentinel

什么是Sentinel

为什么需要Sentinel

Sentinel启动

Sentinel配置

QPS与并发线程数

自定义限流方法,自定义降级方法

SpringGateway网关

什么是网关

简单网关演示

网关多路由配置

动态路由

内置断言

内置过滤器

路由配置的设计规则

网关项目的knife4j配置

Gateway和SpringMvc依赖冲突问题和解决

ES

什么是Elasticsearch

为什么需要Elasticsearch

数据库索引简介

Elasticsearch运行原理

Elasticsearch的启动

ES基本使用

ik分词插件的种类

使用ES操作数据

SpringBoot 操作 Elasticsearch

Spring Data简介

添加依赖和配置

创建和ES关联的实体类

创建操作ES的持久层

测试ES

SpringData自定义查询

排序查询

分页查询

开发分类功能

分类功能实现逻辑

业务分析

实施开发

分页查询

分页查询的优点

PageHelper实现分页查询原理

PageHelper的基本使用

使用JsonPage返回结果

Spring Security

关于单点登录

开发购物车功能

新增sku到购物车

开发持久层

开发业务逻辑层

开发控制层

开发查询购物车功能

开发持久层

开发业务逻辑层

开发控制层

删除\清空购物车

删除购物车的持久层

删除购物车的业务逻辑层

删除购物车的控制层

修改购物车的商品数量

清空购物车的功能

新增订单

新增订单业务逻辑分析

开发删除选中的购物车商品的功能

开始编写新增订单功能

编写新增order_item的持久层

编写新增order的持久层

Leaf

什么是Leaf

为什么需要Leaf

Leaf的工作原理

开发新增订单功能

开发新增订单的业务逻辑层

开发新增订单的控制层

Seata使用常见错误

静态资源服务器

什么是静态资源服务器

前端项目部署

订单查询功能

确定关联查询语句

开发查询订单的持久层

开发查询订单业务逻辑层

开发查询订单的控制层代码

开发更新订单状态的功能

订单的状态码

开发更新订单状态的持久层

开发修改订单状态的业务逻辑层

开发修改订单状态的控制层

搜索功能

Elasticsearch加载数据

确认实体类

开发ES的持久层

product模块提供的查询功能

search模块执行加载

验证ES中的数据

搜索功能的实现

编写SpringData自定义查询

续搜索功能的实现

开发搜索功能的业务逻辑层

开发控制层代码

Quartz

什么是Quartz

为什么需要Quartz

Quartz核心组件

Cron表达式

SpringBoot使用Quartz

Redis 强化

缓存使用原则

缓存淘汰策略

缓存穿透

缓存击穿

缓存雪崩

Redis持久化

RDB:(Redis Database Backup)

AOF:(Append Only File)

Redis存储原理

Redis集群

秒杀业务准备

准备工作概述

开发查询秒杀商品列表功能

开发持久层

开发业务逻辑层

开发秒杀商品列表的控制层

开发根据SpuId查询秒杀Sku列表信息

根据当前时间查询正在进行秒杀的商品

根据SpuId查询秒杀商品信息

查询所有秒杀商品的SpuId

缓存预热思路

设置定时任务

将库存和随机码保存到Redis

配置Quartz触发

开发查询秒杀商品详情的功能

根据SpuId查询秒杀商品详情

完成根据SpuId查询商品detail详情

根据SpuId查询sku列表

开发业务逻辑层

编写控制层

消息队列

Dubbo远程调用的性能问题

什么是消息队列

消息队列的特征

常见消息队列软件

消息队列的事务处理

Kafka

什么是Kafka

kafka软件结构

Kafka的特征与优势

Kafka的安装和配置

启动kafka

Zookeeper介绍

Zookeeper启动

kafka启动

Kafka使用演示

消息的发送

消息的接收

RabbitMQ

什么是RabbitMQ

RabbitMQ特征

下载软件

配置Erlang的环境变量

启动RabbitMQ

RabbitMQ的工作模式

RabbitMQ路由模式的结构

利用RabbitMQ完成消息的收发

接收RabbitMQ中的消息

开发酷鲨商城秒杀业务

创建流控和降级的处理类

开发执行秒杀的业务逻辑层

开发执行秒杀的业务逻辑层

开发控制层

success秒杀成功信息的处理

开发持久层

开发消息的接收功能

虚拟机使用准备

酷鲨商城前台业务总结

"我负责的功能"

项目的模块和我负责的模块

三级分类树

如何实现spu列表

如何显示一个商品的详情

如何实现购物车的管理

生成订单功能如何实现

搜索功能如何实现

如何实现秒杀

秒杀前准备

秒杀信息的查询

提交秒杀信息

布隆过滤器

什么是布隆过滤器

为什么使用布隆过滤器

布隆过滤器原理

设计布隆过滤器

虚拟机的基本使用

什么是虚拟机

什么是Linux

虚拟机网络配置

Virtualbox加载虚拟机镜像

配置镜像参数

启动虚拟机

切换到root用户

客户端软件连接Linux

Docker概述

什么是Docker

为什么使用Docker

Docker相关资料

安装Docker

测试Docker

docker名词解释

Docker基础命令

Docker命令格式

images命令

search命令

pull命令

rmi命令

run命令

启动redis

ps命令

stop\rm命令

关闭防火墙

酷鲨商城前台虚拟机

酷鲨商城前台项目配置修改

布隆过滤器的测试

秒杀业务完善

秒杀准备时加载布隆过滤器

布隆过滤器判断spuId是否存在

ELK简介

什么是ELK

为什么需要ELK

Logstash

什么是logstash

logstash实现数据库和ES数据的同步

实现虚拟机ES搜索功能

添加新的持久层

实现数据同步

业务逻辑层的修改

修改控制层代码

Logstash下ES的运行流程

配置中心

什么是配置中心

配置中心的使用

Nacos数据结构

Nacos添加配置

项目读取配置

RestTemplate远程调用

什么是web服务器

Nginx

Nginx的优势

正向代理

Nginx的使用

Nginx和Gateway的区别

Linux部署java项目

启动虚拟机

安装java环境

创建java项目

项目命名

学习的网站

建议自学内容


微服务

Nacos

将项目注册到Nacos

pom文件中添加依赖

<!--  支持项目注册到Nacos注册中心的依赖  discovery 是发现的意思(微服务项目的发现)   -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

nacos注册的配置信息

spring:
  application:
    # 设置当前应用的名称,这个名字会提交到Nacos,作为当前微服务模块的名称
    name: nacos-business
  cloud:
    nacos:
      discovery:
        # 配置nacos的位置,用于提交当前项目的注册信息
        server-addr: localhost:8848

Nacos心跳机制

心跳:周期性的操作,来表示自己是健康可用的机制

注册到Nacos的微服务项目(模块)都是会遵循这个心跳机制的

心跳机制的目的

1.是表示当前微服务模块运行状态正常的手段

2.是表示当前微服务模块和Nacos保持沟通和交换信息的机制

默认情况下,服务启动开始每隔5秒会向Nacos发送一个"心跳包",这个心跳包中包含了当前服务的基本信息

Nacos接收到这个心跳包,首先检查当前服务在不在注册列表中,如果不在按新服务的业务进行注册,如果在,表示当前这个服务是健康状态

如果一个服务连续3次心跳(默认15秒)没有和Nacos进行信息的交互,就会将当前服务标记为不健康的状态

如果一个服务连续6次心跳(默认30秒)没有和Nacos进行信息的交互,Nacos会将这个服务从注册列表中剔除

这些时间都是可以通过配置修改的

实例类型分类

实际上Nacos的服务类型还有分类

  • 临时实例(默认)

  • 持久化实例(永久实例)

默认每个服务都是临时实例

如果想标记一个服务为永久实例

cloud:
  nacos:
    discovery:
      # ephemeral设置当前项目启动时注册到nacos的类型 true(默认):临时实例 false:永久实例
      ephemeral: false 

持久化实例启动时向nacos注册,nacos会对这个实例进行持久化处理

心跳包的规则和临时实例一致,只是不会将该服务从列表中剔除

一般情况下,我们创建的服务都是临时实例

只有项目的主干业务才会设置为永久实例

Dubbo

什么是RPC

RPC是Remote Procedure Call的缩写 翻译为:远程过程调用

目标是为了实现两台(多台)计算机\服务器,相互调用方法\通信的解决方案

RPC只是实现远程调用的一套标准

该标准主要规定了两部分内容

1.通信协议

2.序列化协议

通信协议

通信协议指的就是远程调用的通信方式

实际上这个通知的方式可以有多种

例如:除了用手机还可以写信,飞鸽传书,发电报

在程序中,通信方法实际上也是有多种的,每种通信方式会有不同的优缺点

序列化协议

序列化协议指通信内容的格式,双方都要理解这个格式

上面的图片中,老婆给老公发信息,一定是双方都能理解的信息

发送信息是序列化过程,接收信息需要反序列化

程序中,序列化的方式也是多种的,每种序列化方式也会有不同的优缺点

什么是Dubbo

上面对RPC有基本认识之后,再学习Dubbo就简单了

Dubbo是一套RPC框架。既然是框架,我们可以在框架结构层面,定义Dubbo中使用的通信协议,使用的序列化框架技术,而数据格式由Dubbo定义,我们负责配置之后直接通过客户端调用服务端代码。

可以说Dubbo就是RPC概念的实现

Dubbo是SpringCloudAlibaba提供的框架

能够实现微服务相互调用的功能!

Dubbo对协议的支持

RPC框架分通信协议和序列化协议

Dubbo框架支持多种通信协议和序列化协议,可以通过配置文件进行修改

Dubbo支持的通信协议

  • dubbo协议(默认)

  • rmi协议

  • hessian协议

  • http协议

  • webservice

  • .....

支持的序列化协议

  • hessian2(默认)

  • java序列化

  • compactedjava

  • nativejava

  • fastjson

  • dubbo

  • fst

  • kryo

Dubbo默认情况下,支持的协议有如下特征

  • 采用NIO单一长链接

  • 优秀的并发性能,但是处理大型文件的能力差

Dubbo方便支持高并发和高性能

Dubbo服务的注册与发现

在Dubbo的调用过程中,必须包含注册中心的支持

项目调用服务的模块必须在同一个注册中心中

注册中心推荐阿里自己的Nacos,兼容性好,能够发挥最大性能

但是Dubbo也支持其它软件作为注册中心(例如Redis,zookeeper等)

服务发现,即消费端自动发现服务地址列表的能力,是微服务框架需要具备的关键能力,借助于自动化的服务发现,微服务之间可以在无需感知对端部署位置与 IP 地址的情况下实现通信。

上面RPC的示例中,老婆就是服务的消费端,她能发现老公具备的服务

如果老婆调用了老公的服务,就是完成了Dubbo调用

consumer服务的消费者,指服务的调用者(使用者)也就是老婆的位置

provider服务的提供者,指服务的拥有者(生产者)也就是老公的位置

在Dubbo中,远程调用依据是服务的提供者在Nacos中注册的服务名称

一个服务名称,可能有多个运行的实例,任何一个空闲的实例都可以提供服务

常见面试题:Dubbo的注册发现流程

1.首先服务的提供者启动服务时,将自己的具备的服务注册到注册中心,其中包括当前提供者的ip地址和端口号等信息,Dubbo会同时注册该项目提供的远程调用的方法

2.消费者(使用者)启动项目,也注册到注册中心,同时从注册中心中获得当前项目具备的所有服务列表

3.当注册中心中有新的服务出现时,(在心跳时)会通知已经订阅发现的消费者,消费者会更新所有服务列表

4.RPC调用,消费者需要调用远程方法时,根据注册中心服务列表的信息,只需服务名称,不需要ip地址和端口号等信息,就可以利用Dubbo调用远程方法了

Dubbo实现微服务调用

具体看第五阶段Day03,Day04笔记

pom文件添加依赖

<!--   Dubbo 在springCloud中使用的依赖  -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-dubbo</artifactId>
        </dependency>
        <!-- 当前项目需要在业务逻辑层实现类中实现业务逻辑层接口
                所以业务逻辑层接口的依赖也要添加 -->
        <dependency>
            <groupId>cn.tedu</groupId>
            <artifactId>csmall-stock-service</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

yml配置消息

dubbo:
  protocol:
    # dubbo配置port设置为-1,含义是表示使用dubbo框架自动寻找端口的功能
    # 生成端口号的规则是从20880开始寻找可用端口号,如果这个端口号被占用,就向后+1,直到找到可用端口号为止
    port: -1
    # 定义连接的名称,一般都是dubbo
    name: dubbo
  registry:
    # 指定当前服务注册到注册中心的种类和地址
    address: nacos://localhost:8848
  consumer:
    # 设置当前项目启动时,是否检查本项目需要的所有服务都可用
    # 设置它的值为false,表示不检查,以减少启动是因为需要的服务不可用而导致的错误
    check: false

相关注解:

  1. @DubboService

  2. @EnableDubbo

  3. @DubboReference

将业务逻辑层实现类方法声明为Dubbo可调用的方法

当前stock模块是单纯的生产者

StockServiceImpl修改代码

// @DubboService注解,标记的业务逻辑层实现类,其中的所有方法都会被注册到Nacos
// 其他模块(服务)启动后可以"订阅",就会"发现"当前类中所有的服务,随时可以调用
@DubboService
@Service
@Slf4j
public class StockServiceImpl implements IStockService {
        // ....  内容略
}

如果当前项目是服务的提供者(生产者)

还需要在SpringBoot启动类上添加@EnableDubbo的注解,才能真正让Dubbo功能生效

@SpringBootApplication
// 如果当前项目是dubbo中的生产者,必须添加@EnableDubbo注解
// 添加之后,在服务启动时,当前项目的所有服务才能正确注册到Nacos
@EnableDubbo
public class CsmallStockWebapiApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(CsmallStockWebapiApplication.class, args);
    }
​
}

添加生产者的注解,同时利用Dubbo消费stock和cart模块的方法

Reference:引用

// order模块是具备生产者特征的,它会被business模块调用,所以也要加@DubboService注解
@DubboService
@Service
@Slf4j
public class OrderServiceImpl implements IOrderService {
    @Autowired
    private OrderMapper orderMapper;
    // 添加@DubboReference注解,表示当前业务逻辑层中要消费其他模块的服务了
    // 注解后声明的应该是Dubbo注册到Nacos其他模块声明的业务逻辑层接口
    // 业务逻辑层接口的实现类会在Dubbo框架下自动获取
    @DubboReference
    private IStockService stockService;
    @DubboReference
    private ICartService cartService;
​
    @Override
    public void orderAdd(OrderAddDTO orderAddDTO) {
        // 1.先减去订单中商品的库存数(调用Stock模块减少库存的方法)
        // 库存减少方法需要参数类型是StockReduceCountDTO,我们需要先实例化它
        StockReduceCountDTO countDTO=new StockReduceCountDTO();
        countDTO.setCommodityCode(orderAddDTO.getCommodityCode());
        countDTO.setReduceCount(orderAddDTO.getCount());
        // 利用Dubbo调用stock模块减少库存的业务逻辑层方法实现功能
        stockService.reduceCommodityCount(countDTO);
​
        // 2.从购物车中删除用户选中的商品(调用Cart模块删除购物车中商品的方法)
        // 利用dubbo调用cart模块删除购物车中商品的方法实现功能
        cartService.deleteUserCart(orderAddDTO.getUserId(),
                                    orderAddDTO.getCommodityCode());
​
        // 3.新增当前订单信息
        Order order=new Order();
        BeanUtils.copyProperties(orderAddDTO,order);
        // 下面执行新增
        orderMapper.insertOrder(order);
        log.info("新增订单信息为:{}",order);
    }
}

注意运行前,数据库的数据状态和运行后的比较一下

启动服务时,可能发生RpcException或RemotingException异常,如果不影响运行,是可以无视掉的,出现的原因可能是电脑的防火墙\杀毒软件不让Dubbo访问某些网络资源导致的

也有和无线wifi网卡驱动冲突造成的

Dubbo调用常见错误

No provider available from registry localhost:8848 for service cn.tedu.csmall.stock.service.IStockService on consumer 192.168.126.1 use dubbo version 2.7.8, please check status of providers(disabled, not registered or in blacklist).

(disabled, not registered or in blacklist)是这个错误信息的特征

发生这个错误原因是消费者无法在指定的位置找到需要的服务

  • 检查调用目标的服务是否启动(上面示例中可能是因为stock模块没有启动导致的)

  • 检查被调用的目标服务SpringBoot启动类是否编写的@EnableDubbo注解

  • 检查被调用的模块的业务逻辑层实现类是否编写了@DubboService注解

负载均衡

什么是负载均衡

在实际项目中,一个服务基本都是集群模式的,也就是多个功能相同的项目在运行,这样才能承受更高的并发

这时一个请求到这个服务,就需要确定访问哪一个服务器

在Dubbo实现远程调用的过程中,调用的关系可能如下图

Dubbo框架内部支持负载均衡算法,能够尽可能的让请求在相对空闲的服务器上运行

在不同的项目中,可能选用不同的负载均衡策略,以达到最好效果

Loadbalance:就是负载均衡的意思

Dubbo内置负载均衡策略算法

  • random loadbalance:随机分配策略(默认)

  • round Robin Loadbalance:权重平均分配

  • leastactive Loadbalance:活跃度自动感知分配

  • consistanthash Loadbalance:一致性hash算法分配

实际运行过程中,每个服务器性能不同

在负载均衡时,都会有性能权重,这些策略算法都考虑权重问题

随机分配策略

假设我们当前3台服务器,经过测试它们的性能权重比值为5:3:1

下面可以生成一个权重模型

5:3:1

生成随机数

在哪个范围内让哪个服务器运行

优点:

算法简单,效率高,长时间运行下,任务分配比例准确

缺点:

偶然性高,如果连续的几个随机请求发送到性能弱的服务器,会导致异常甚至宕机

权重平滑分配

如果几个服务器权重一致,那么就是依次运行

但是服务器的性能权重一致的可能性很小

所以我们需要权重平滑分配

一个优秀的权重分配算法,应该是让每个服务器都有机会运行的

如果一个集群服务器性能比为5:3:1服务为A,B,C

1>A 2>A 3>A 4>A 5>A

6>B 7>B 8>B

9>C

10>A ......

上面的安排中,连续请求一个服务器肯定是不好的,我们希望所有的服务器都能够穿插在一起运行

Dubbo2.7之后更新了这个算法使用"平滑加权算法"优化权重平均分配策略

优点:

能够尽可能的在权重要求的情况下,实现请求的穿插运行(交替运行),不会发生随机策略中的偶发情况

缺点

服务器较多时,可能需要减权和复权的计算,需要消耗系统资源

活跃度自动感知

记录每个服务器处理一次请求的时间

按照时间比例来分配任务数,运行一次需要时间多的分配的请求数较少

一致性Hash算法

根据请求的参数进行hash运算

以后每次相同参数的请求都会访问固定服务器

因为根据参数选择服务器,不能平均分配到每台服务器上

使用的也不多

Dubbo生产者消费者配置小结

Dubbo生产者消费者相同的配置

pom文件添加dubbo依赖,yml文件配置dubbo信息

生产者

  • 要有service接口项目

  • 提供服务的业务逻辑层实现类要添加@DubboService注解

  • SpringBoot启动类要添加@EnableDubbo注解

消费者

  • pom文件添加消费模块的service依赖

  • 业务逻辑层远程调用前,模块使用@DubboReference注解获取业务逻辑层实现类对象

Seata

为什么需要Seata

我们之前学习了单体项目中的事务

使用的技术叫Spring声明式事务

能够保证一个业务中所有对数据库的操作要么都成功,要么都失败,来保证数据库的数据完整性

但是在微服务的项目中,业务逻辑层涉及远程调用,当前模块发生异常,无法操作远程服务器回滚

这时要想让远程调用也支持事务功能,就需要使用分布式事务组件Seata

Seata保证微服务远程调用业务的原子性

Seata将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

Seata的运行原理

观察下面事务模型

上面结构是比较典型的远程调用结构

如果account操作数据库失败需要让order模块和storage模块撤销(回滚)操作

声明式事务不能完成这个操作

需要使用Seata来解决

Seata构成部分包含

  • 事务协调器TC

  • 事务管理器TM

  • 资源管理器RM

我们项目使用AT(自动)模式完成分布式事务的解决

AT模式运行过程

1.事务的发起方(TM)会向事务协调器(TC)申请一个全局事务id,并保存

2.Seata会管理事务中所有相关的参与方的数据源,将数据操作之前和之后的镜像都保存在undo_log表中,这个表是seata组件规定的表,没有它就不能实现效果,依靠它来实现提交(commit)或回滚(roll back)的操作

3.事务的发起方(TM)会连同全局id一起通过远程调用,运行资源管理器(RM)中的方法

4.RM接收到全局id,去运行指定方法,并将运行结果的状态发送给TC

5.如果所有分支运行都正常,TC会通知所有分支进行提交,真正的影响数据库内容,

反之如果所有分支中有任何一个分支发生异常,TC会通知所有分支进行回滚,数据库数据恢复为运行之前的内容

配置Seata

pom文件

<!--   Seata和SpringBoot整合依赖     -->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
</dependency>
<!--  Seata 完成分布式事务的两个相关依赖(Seata会自动使用其中的资源)  -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
</dependency>

yml文件

seata:
  # 定义事务的分组名称,一般是以项目为单位,用于区分其他不同项目用的
  tx-service-group: csmall_group
  service:
    vgroup-mapping: 
      # 设置csmall_group分组使用seata默认(default)的配置内容
      csmall_group: default
    grouplist: 
      # 设置seata的ip地址和端口号
      default: localhost:8091

注意同一个事务必须在同一个tx-service-group中

同时指定相同的seata地址和端口

相关注解:

@GlobalTransactional

business模块的配置

business模块作为当前分布式事务模型的触发者

它应该是事务的起点,但是它不连接数据库,所以配置稍有不同

pom文件seata依赖仍然需要,但是只需要seata依赖

<!--   Seata和SpringBoot整合依赖     -->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
</dependency>

application-dev.yml是一样的

seata:
  # 定义事务的分组名称,一般是以项目为单位,用于区分其他不同项目用的
  tx-service-group: csmall_group
  service:
    vgroup-mapping: 
      # 设置csmall_group分组使用seata默认(default)的配置内容
      csmall_group: default
    grouplist: 
      # 设置seata的ip地址和端口号
      default: localhost:8091

添加完必要的配置之后

要想激活Seata功能非常简单,只要在起点业务的业务逻辑方法上添加专用的注解即可

添加这个注解的模块就是模型中的TM

他调用的所有远程模块都是RM

business模块添加订单的业务逻辑层开始的方法

@Service
@Slf4j
public class BusinessServiceImpl implements IBusinessService {

    // Dubbo调用order模块的新增订单的功能
    // business是单纯的消费者,不需要在类上添加@DubboService注解
    @DubboReference
    private IOrderService dubboOrderService;

    // Global:全局  Transactional:事务
    // 一旦这个方法标记为@GlobalTransactional
    // 就相当于设置了分布式事务的起点,相当于AT事务模型中TM(事务管理器)
    // 最终效果就是当前方法开始运行后,所有远程调用操作数据库和本模块操作数据库的业务
    // 会被管理在同一个事务中,也就是这些数据库操作要么都执行要么都不执行
    @GlobalTransactional
    @Override
    public void buy() {
        //  代码略....
    }


}

先启动nacos,再启动seata

然后按顺序启动四个服务 cart\stock\order\business

Seata其他模式介绍

上次课我们讲解了Seata软件AT模式的运行流程

AT模式的运行有一个非常明显的前提条件,这个条件不满足,就无法使用AT模式

这个条件就是事务分支都必须是操作关系型数据库(Mysql\MariaDB\Oracle)

因为关系型数据库才支持提交和回滚,其它非关系型数据库都是直接影响数据(例如Redis)

所以如果我们在业务过程中有一个节点操作的是Redis或其它非关系型数据库时,就无法使用AT模式

除了AT模式之外还有TCC、SAGA 和 XA 事务模式

TCC模式

简单来说,TCC模式就是自己编写代码完成事务的提交和回滚

在TCC模式下,我们需要为参与事务的业务逻辑编写一组共3个方法

(prepare\commit\rollback)

prepare:准备

commit:提交

rollback:回滚

  • prepare方法是每个模块都会运行的方法

  • 当所有模块的prepare方法运行都正常时,运行commit

  • 当任意模块运行的prepare方法有异常时,运行rollback

这样的话所有提交或回滚代码都由自己编写

优点:虽然代码是自己写的,但是事务整体提交或回滚的机制仍然可用(仍然由TC来调度)

缺点:每个业务都要编写3个方法来对应,代码冗余,而且业务入侵量大

SAGA模式

SAGA模式的思想是对应每个业务逻辑层编写一个新的类,可以设置指定的业务逻辑层方法发生异常时,运行当新编写的类中的代码

相当于将TCC模式中的rollback方法定义在了一个新的类中

这样编写代码不影响已经编写好的业务逻辑代码

一般用于修改已经编写完成的老代码

缺点是每个事务分支都要编写一个类来回滚业务,

会造成类的数量较多,开发量比较大

XA模式

支持XA协议的数据库分布式事务,使用比较少

Sentinel

什么是Sentinel

Sentinel英文翻译"哨兵\门卫"

Sentinel也是Spring Cloud Alibaba的组件

随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。

为什么需要Sentinel

为了保证服务器运行的稳定性,在请求数到达设计最高值时,将过剩的请求限流,保证在设计的请求数内的请求能够稳定完成处理

  • 丰富的应用场景

    双11,秒杀,12306抢火车票

  • 完备的实时状态监控

    可以支持显示当前项目各个服务的运行和压力状态,分析出每台服务器处理的秒级别的数据

  • 广泛的开源生态

    很多技术可以和Sentinel进行整合,SpringCloud,Dubbo,而且依赖少配置简单

  • 完善的SPI扩展

    Sentinel支持程序设置各种自定义的规则

Sentinel启动

windows同学直接双击start-sentinel.bat文件

启动之后

打开浏览器http://localhost:8080/

会看到下面的界面

用户名和密码都是

sentinel

Sentinel配置

pom文件

<!--  Sentinel整合SpringCloud依赖  -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba- sentinel</artifactId>
</dependency>

yml文件

spring:
  cloud:
    sentinel:
      transport:
        # 配置Sentinel的提供的仪表台的位置
        dashboard: localhost:8080
        # 执行限流的端口号,每个项目必须设置不同的限流端口号,例如cart模块要限流就不能和下面端口号重复
        port: 8721
    nacos:
      discovery:
        # 配置nacos的位置,用于提交当前项目的注册信息
        server-addr: localhost:8848

相关注解:

@SentinelResource(value = "减少库存数", blockHandler = "blockError", fallback = "fallbackError")

QPS与并发线程数

  • QPS:是每秒请求数

    单纯的限制在一秒内有多少个请求访问控制器方法

  • 并发线程数:是当前正在使用服务器资源请求线程的数量

    限制的是使用当前服务器的线程数

自定义限流方法,自定义降级方法

@PostMapping("/reduce/count")
@ApiOperation("减少指定商品的库存数")
// @SentinelResource注解需要标记在控制层方法上,在该方法运行一次后,
// 会被Sentinel仪表台检测到,并显示在仪表台中
// 如果这个方法不运行,仪表台中不会显示这个方法
// 括号中"减少库存数",会作为方法的名称出现在仪表台上,代表这个方法
// blockHandler可以设置当前控制器方法被限流时,要运行的自定义限流方法,blockError是方法名称
@SentinelResource(value = "减少库存数",
        blockHandler = "blockError",
        fallback = "fallbackError")
public JsonResult reduceCommodityCount(
        StockReduceCountDTO stockReduceCountDTO){
    // 调用业务逻辑层方法
    stockService.reduceCommodityCount(stockReduceCountDTO);
    if(Math.random()<0.5){
        throw new CoolSharkServiceException(
                ResponseCode.INTERNAL_SERVER_ERROR,"发生随机异常!");
    }
    return JsonResult.ok("库存减少完成!");
}
​
// Sentinel自定义限流方法定义规则
// 1.访问修饰符必须是public
// 2.返回值类型必须和被限流的控制器方法一致
// 3.方法名必须和控制器限流注解中blockHandler属性定义的方法名称一致
// 4.方法参数,先编写和控制器方法一致的参数信息,再额外添加BlockException类型的参数
public JsonResult blockError(StockReduceCountDTO stockReduceCountDTO,
                             BlockException e){
    // 这个方法运行,表示当前控制器访问数已经超过设计的最大值
    // 我们需要给前端反馈,服务器忙,本次请求被限流
    return JsonResult.failed(
            ResponseCode.INTERNAL_SERVER_ERROR,"服务器忙,请稍后再试");
​
}
​
// 自定义降级方法: 由@SentinelResource注解的fallback属性指定的方法
// 自定义降级方法和自定义限流方法的格式基本是一致的
// 区别是降级方法参数额外的异常类型要使用Throwable
// 这个方法会在原有控制器方法运行发生异常是触发
// 实际开发中,可能调用一些老版本代码,所以称之为降级
public JsonResult fallbackError(StockReduceCountDTO stockReduceCountDTO,
                                Throwable throwable){
    // 先将异常信息输出到日志(控制台)
    throwable.printStackTrace();
    // 返回降级方法运行的信息
    return JsonResult.failed(ResponseCode.INTERNAL_SERVER_ERROR,
                            "因为发生异常,服务降级");
}

SpringGateway网关

什么是网关

"网"指网络,"关"指关口或关卡

网关:就是指网络中的关口\关卡

网关就是当前微服务项目的"统一入口"

程序中的网关就是当前微服务项目对外界开放的统一入口

所有外界的请求都需要先经过网关才能访问到我们的程序

提供了统一入口之后,方便对所有请求进行统一的检查和管理

网关的主要功能有

  • 将所有请求统一经过网关

  • 网关可以对这些请求进行检查

  • 网关方便记录所有请求的日志

  • 网关可以统一将所有请求路由到正确的模块\服务上

路由的近义词就是"分配"

简单网关演示

SpringGateway网关是一个依赖,不是一个软件

所以我们要使用它的话,必须先创建一个SpringBoot项目

这个项目也要注册到Nacos注册中心,因为网关项目也是微服务项目的一个组成部分

beijing和shanghai是编写好的两个项目

gateway项目就是网关项目,需要添加相关配置

<dependencies>
    <!--   SpringGateway的依赖   -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <!--   Nacos依赖   -->
    <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>

我们从yml文件配置开始添加

server:
  port: 9000
spring:
  application:
    name: gateway
  cloud:
    nacos:
      discovery:
        # 网关也是微服务项目的一个模块,需要注册到Nacos才能生效
        server-addr: localhost:8848
    gateway:
      # routes是路由的意思,表示开始配置路由设置,它是一个数组的类型
      routes:
          # 数组类型赋值时,每个数组元素都要以"-"开头,一个"-"之后的所有内容,都是这个元素包含的内容
          # id表示当前路由的名称,这个名称和之前我们学习的任何名称没有关联,
          # 唯一的要求就是不能和之后出现的路由id重复
        - id: gateway-beijing
          # uri是设置当前路由配置的目标服务器名称,"beijing"就是目标服务器注册到nacos的名称
          # lb就是LoadBalance的缩写,意思是当前路由支持负载均衡
          uri: lb://beijing
          # predicates是断言的意思,断言指满足某些条件时执行某些操作的设置
          predicates:
            # predicates属性也是数组类型的设计,赋值也要以"-"开头
            # 这个断言的含义就是如果访问当前网关项目路径以/bj/开头,那么就路由访问beijing服务器
            # ↓ P要大写!!!!!   **是通配任何路径
            - Path=/bj/**

先启动nacos

再启动beijing

最后启动gateway

访问

localhost:9001/bj/show

然后使用网关路由访问

localhost:9000/bj/show 等价于访问 localhost:9001/bj/show

网关多路由配置

上面只配置了一个beijing的路由设置

下面我们修改yml文件也实现shanghai的路由设置

gateway:
  # routes是路由的意思,表示开始配置路由设置,它是一个数组的类型
  routes:
    - id: gateway-shanghai
      uri: lb://shanghai
      predicates:
        - Path=/sh/**
      # 数组类型赋值时,每个数组元素都要以"-"开头,一个"-"之后的所有内容,都是这个元素包含的内容
      # id表示当前路由的名称,这个名称和之前我们学习的任何名称没有关联,
      # 唯一的要求就是不能和之后出现的路由id重复
    - id: gateway-beijing
      # uri是设置当前路由配置的目标服务器名称,"beijing"就是目标服务器注册到nacos的名称
      # lb就是LoadBalance的缩写,意思是当前路由支持负载均衡
      uri: lb://beijing
      # predicates是断言的意思,断言指满足某些条件时执行某些操作的设置
      predicates:
        # predicates属性也是数组类型的设计,赋值也要以"-"开头
        # 这个断言的含义就是如果访问当前网关项目路径以/bj/开头,那么就路由访问beijing服务器
        # ↓ P要大写!!!!!   **是通配任何路径
        - Path=/bj/**

在保证nacos启动的情况下

beijing服务器如果启动无需重启

启动shanghai项目

最后重启网关gateway模块

测试网关路由到两个模块的效果

http://localhost:9000/bj/show可以访问beijing服务器的资源

http://localhost:9000/sh/show可以访问shanghai服务器的资源

以此类推,再有很多服务器时,我们都可以仅使用9000端口号来将请求路由到正确的服务器

就实现了gateway成为项目的统一入口的效果

动态路由

网关项目的配置会随着微服务模块数量增多而变得复杂,维护的工作量也会越来越大

所以我们希望gateway能够设计一套默认情况下自动路由到每个模块的路由规则

这样的话,不管当前项目有多少个路由目标,都不需要维护yml文件了

这就是我们SpringGateway的动态路由功能

配置文件中开启即可

server:
  port: 9000
spring:
  application:
    name: gateway
  cloud:
    nacos:
      discovery:
        # 网关也是微服务项目的一个模块,需要注册到Nacos才能生效
        server-addr: localhost:8848
    gateway:
      discovery:
        locator:
          # 开始SpringGateway动态路由功能的配置
          # 动态路由的默认路由规则: 在网关路径端口号后,先编写要路由的目标服务器名称
          #                     也就是注册到Nacos的名称,再编写要求访问的具体路径
          #例如要访问localhost:9001/bj/show->localhost:9000/beijing/bj/show
          enabled: true

按上面修改完配置之后

我们可以重启gateway来测试动态路由路径是否生效

动态路由生成规则为:在网关端口号后先,写要路由到的目标服务器在nacos注册的名称,再编写具体路径

内置断言

我们上次课在网关配置中使用了predicates(断言)的配置

断言的意思就是判断某个条件是否满足,并运行指定的操作

我们之前使用了Path断言,判断请求的路径是不是满足条件,例如是不是/sh/** /bj/**

如果路径满足这个条件,就路由到指定的服务器

但是Path实际上只是SpringGateway提供的多种内置断言中的一种

还有很多其它断言

  • After

  • Before

  • Between

  • Cookie

  • Header

  • Host

  • Method

  • Path

  • Query

  • Remoteaddr

时间相关

After,Before,Between

判断当前时间在指定时间之前,之后或之间的操作

如果条件满足可以执行路由操作,否则拒绝访问

表示时间的格式比较特殊,先使用下面代码获得时间

ZonedDateTime.now()

运行程序输出,可获得当前时间,这个时间的格式可能是

2022-12-28T17:30:59.345+08:00[Asia/Shanghai]

下面在yml配置中添加新的断言配置

使用After设置必须在指定时间之后访问

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      # After是内置的时间断言,判断访问发生的时间是不是指定时间之后
      # 如果早于这个时间,会报404错误,必须晚于这个时间才能正常访问
      # 和上面Path配置的断言是"与"的关系
      - After=2022-12-28T17:38:10.345+08:00[Asia/Shanghai]

必须在指定时间之后才能访问服务

否则发生404错误拒绝访问

需要注意测试时,先启动Nacos,再启动shanghai之后启动gateway

测试时必须通过9000端口访问才能有效果

使用Before设置必须在指定时间之前访问

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      - Before=2022-12-29T09:45:55.345+08:00[Asia/Shanghai]

使用Between设置必须在指定时间之间访问

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      - Between=2022-12-29T09:51:35.345+08:00[Asia/Shanghai],2022-12-29T09:51:50.345+08:00[Asia/Shanghai]
      

要求指定参数的请求

Query断言,判断是否包含指定的参数名称,包含参数名称才能通过路由

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    predicates:
      - Path=/sh/**
      # Query断言判断请求中是否包含指定的参数名称(username),如果不包含就无法访问,发送404错误
      - Query=username

重启gateway测试

必须是包含username参数的请求才能访问到指定的页面

例如:http://localhost:9000/sh/show?username=tom

内置过滤器

Gateway还提供的内置过滤器

不要和我们学习的filter混淆

内置过滤器允许我们在路由请求到目标资源的同时,对这个请求进行一些加工或更改

常见过滤器也有一些

我们给大家演示一下AddRequestParameter过滤器

它的作用是在请求中添加参数和它对应的值

routes:
  - id: gateway-shanghai
    uri: lb://shanghai
    filters:
      - AddRequestParameter=age,18
    predicates:
      - Path=/sh/**
      # Query断言判断请求中是否包含指定的参数名称(username),如果不包含就无法访问,发送404错误
      - Query=username

在shanghai的控制器方法中添加代码接收username,age的值

@GetMapping("/show")
public String show(String username,Integer age){
    System.out.println(ZonedDateTime.now());
    return "这里是上海!"+username+","+age;
}

重启shanghai和gateway进行测试

http://localhost:9000/sh/show?username=tom

因为过滤器的存在,控制器可以获取网关过滤器添加的参数值

其他内置过滤器和自定义过滤器的使用,同学们可以查阅相关文档自己了解

路由配置的设计规则

路由规则解释

路由规则一定是在开发之前就设计好的

一般可以使用约定好的路径开头来实现的

网关项目的knife4j配置

我们希望配置网关之后,在使用knife4j测试时

就不来回切换端口号了

我们需要在网关项目中配置Knife4j才能实现

而这个配置是固定的,

只要是网关项目配置各个子模块的knife4j功能,就直接复制这几个类即可

csmall-finish中直接复制config\controller\filter

cn.tedu.gateway.config

SwaggerProvider

@Component
public class SwaggerProvider implements SwaggerResourcesProvider {
    /**
     * 接口地址
     */
    public static final String API_URI = "/v2/api-docs";
    /**
     * 路由加载器
     */
    @Autowired
    private RouteLocator routeLocator;
    /**
     * 网关应用名称
     */
    @Value("${spring.application.name}")
    private String applicationName;
​
    @Override
    public List<SwaggerResource> get() {
        //接口资源列表
        List<SwaggerResource> resources = new ArrayList<>();
        //服务名称列表
        List<String> routeHosts = new ArrayList<>();
        // 获取所有可用的应用名称
        routeLocator.getRoutes().filter(route -> route.getUri().getHost() != null)
                .filter(route -> !applicationName.equals(route.getUri().getHost()))
                .subscribe(route -> routeHosts.add(route.getUri().getHost()));
        // 去重,多负载服务只添加一次
        Set<String> existsServer = new HashSet<>();
        routeHosts.forEach(host -> {
            // 拼接url
            String url = "/" + host + API_URI;
            //不存在则添加
            if (!existsServer.contains(url)) {
                existsServer.add(url);
                SwaggerResource swaggerResource = new SwaggerResource();
                swaggerResource.setUrl(url);
                swaggerResource.setName(host);
                resources.add(swaggerResource);
            }
        });
        return resources;
    }
}

cn.tedu.gateway.controller

SwaggerController类

@RestController
@RequestMapping("/swagger-resources")
public class SwaggerController {
    @Autowired(required = false)
    private SecurityConfiguration securityConfiguration;
    @Autowired(required = false)
    private UiConfiguration uiConfiguration;
    private final SwaggerResourcesProvider swaggerResources;
    @Autowired
    public SwaggerController(SwaggerResourcesProvider swaggerResources) {
        this.swaggerResources = swaggerResources;
    }
    @GetMapping("/configuration/security")
    public Mono<ResponseEntity<SecurityConfiguration>> securityConfiguration() {
        return Mono.just(new ResponseEntity<>(
                Optional.ofNullable(securityConfiguration).orElse(SecurityConfigurationBuilder.builder().build()), HttpStatus.OK));
    }
    @GetMapping("/configuration/ui")
    public Mono<ResponseEntity<UiConfiguration>> uiConfiguration() {
        return Mono.just(new ResponseEntity<>(
                Optional.ofNullable(uiConfiguration).orElse(UiConfigurationBuilder.builder().build()), HttpStatus.OK));
    }
    @GetMapping("")
    public Mono<ResponseEntity> swaggerResources() {
        return Mono.just((new ResponseEntity<>(swaggerResources.get(), HttpStatus.OK)));
    }
}

cn.tedu.gateway.filter

SwaggerHeaderFilter类

@Component
public class SwaggerHeaderFilter extends AbstractGatewayFilterFactory {
    private static final String HEADER_NAME = "X-Forwarded-Prefix";
​
    private static final String URI = "/v2/api-docs";
​
    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();
            if (!StringUtils.endsWithIgnoreCase(path,URI )) {
                return chain.filter(exchange);
            }
            String basePath = path.substring(0, path.lastIndexOf(URI));
            ServerHttpRequest newRequest = request.mutate().header(HEADER_NAME, basePath).build();
            ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
            return chain.filter(newExchange);
        };
    }
}

测试网关路由效果,和knife4j效果

Gateway和SpringMvc依赖冲突问题和解决

之前网关的演示项目我们添加的网关依赖

<!-- Spring Gateway 网关依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

当前csmall项目需要配置knife4j的路由配置,需要编写一个控制器

所以我们添加了SpringMvc的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

这两个依赖在同一个项目中时,默认情况下启动会报错

SpringMvc框架依赖中自带一个Tomcat服务器

而SpringGateway框架中自带一个Netty的服务器

在启动项目时,两个框架中包含的服务器都想占用相同端口,因为争夺端口号的主动权而发生冲突

导致启动服务时报错

要想能够正常启动必须在yml文件配置

spring:
  main:
    web-application-type: reactive

reactive:反应的

添加这个配置之后,会Tomcat服务器会变成非阻塞的运行

ES

什么是Elasticsearch

elastic:富有弹性的

search:搜索

我们可以把它简称为ES,但是搜索它的资料时(例如百度)还是使用Elasticsearch进行搜索更准确

这个软件不再是SpringCloud提供的,它也不针对微服务环境的项目来开发

Elasticsearch和redis\mysql一样,不仅服务于java语言,其它语言也可以使用

它的功能也类似一个数据库,能高效的从大量数据中搜索匹配指定关键字的内容

它也将数据保存在硬盘中

这样的软件有一个名称全文搜索引擎

它本质就是一个java项目,使用它进行数据的增删改查就是访问这个项目的控制器方法(url路径)

ES的底层技术

ES使用了java的一套名为Lucene的API

这个API提供了全文搜索引擎核心操作的接口,相当于搜索引擎的核心支持,ES是在Lucene的基础上进行了完善,实现了开箱即用的搜索引擎软件

市面上和ES功能类似的软件有

Solr/MongoDB

为什么需要Elasticsearch

数据库进行模糊查询效率严重低下

所有关系型数据库都有这个缺点(mysql\mariaDB\oracle\DB2等)

在执行类似下面模糊查询时

select * from spu where spu_name like '%鼠标%'

测试证明一张千万级别的数据表进行模糊查询需要20秒以上

当前互联网项目要求"三高"的需求下,这样的效率肯定不能接受

Elasticsearch主要是为了解决数据库模糊查询性能低下问题的

ES进行优化之后,从同样数据量的ES中查询相同条件数据,效率能够提高100倍以上

数据库索引简介

所谓的索引(index)其实就是数据目录

通常情况下,索引是为了提高查询效率的

数据库索引分两大类

  • 聚集索引

  • 非聚集索引

聚集索引就是数据库保存数据的物理顺序依据,默认情况下就是主键id,所以按id查询数据库中的数据效率非常高

非聚集索引:如果想在非主键列上添加索引,就是非聚集索引了

例如我们在数据库表中存在一个姓名列,我们为姓名列创建索引

在创建索引时,会根据姓名内容来创建索引

例如"张三丰" 这个姓名,创建索引后查询效率就会明显提升

如果没有索引,这样的查询就会引起效率最低的"逐行搜索",就是一行一行的查这个数据的姓名是不是张三丰,效率就会非常低

模糊查询时因为'%鼠标%',使用的是前模糊条件,使用索引必须明确前面的内容是什么,前模糊查询是不能使用索引的,只能是全表的逐行搜索,所以效率非常低

所以当我们项目中设计了根据用户输入关键字进行模糊查询时,需要使用全文搜索引擎来优化

索引面试题

1.创建的索引会占用硬盘空间

2.创建索引之后,对该表进行增删改操作时,会引起索引的更新,所以效率会降低

3.对数据库进行批量新增时,先删除索引,增加完毕之后再创建

4.不要对数据样本少的列添加索引

5.模糊查询时,查询条件前模糊的情况,是无法启用索引的

6.每次从数据表中查询的数据的比例越高,索引的效果越低

Elasticsearch运行原理

要想使用ES提高模糊查询效率

首先要将数据库中的数据复制到ES中

在新增数据到ES的过程中,ES可以对指定的列进行分词索引保存在索引库中

形成倒排索引结构

Elasticsearch的启动

课程中使用7.6.2的版本

压缩包280M左右,复制到没有中文,没有空格的目录下解压

双击bin\elasticsearch.bat运行

双击之后可能会看到下面的dos界面

这个界面不能关闭,一旦关闭ES就停止了

验证ES的运行状态

浏览器输入地址:localhost:9200看到如下内容即可

mac系统启动

tar -xvf elasticsearch-7.6.2-darwin-x86_64.tar.gz 
cd elasticsearch-7.6.2/bin 
./elasticsearch

linux:

tar -xvf elasticsearch-7.6.2-linux-x86_64.tar.gz
cd elasticsearch-7.6.2/bin
./elasticsearch

ES基本使用

ES启动完成后,我们要学习如何操作它

我们已经讲过,操作ES是对ES发送请求

我们创建一个子项目search,在这个子项目中创建一个专门发送各种类型请求的文件来操作ES

创建search项目也要父子相认

然后子项目pom文件如下

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>search</name>
    <description>Demo project for Spring Boot</description>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

下面创建一个能够向ES发送请求的文件

这种能够向指定url发送请求的文件格式称之为http client(http 客户端)

文件类型叫HTTP Request文件

我们可以起名为elasticsearch

我们先从最简单的请求开始

向es发送指令

### 三个#开头,表示注释,也是分割符,http文件要求每个请求必须以分隔符开始,否则运行就会混乱报错
GET http://localhost:9200

下面要测试ES的分词功能

### 测试ES的分词功能,运行请求,查看分词结果
POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "my name is hanmeimei",
  "analyzer": "standard"
}

analyze:分析

analyzer:分析者(分词器)

standard是ES默认的分词器,"analyzer": "standard"是可以省略的

standard这个分词器只能对英文等西文字符(用空格分隔单词的),进行正确分词

但是中文分词不能按空格分,按这个分词器分词,每个字都会形成分词,这样的结果不能满足我们日常的搜索需要

我们解决中文不能正确分词的问题

实际上要引入一个中文常见词语的词库,分词时按照词库中的词语分词即可

我们可以使用免费的中文分词器词库插件IK来实现中文分词效果

安装插件之后要重启ES才能生效

关闭Es窗口之后再双击elasticsearch.bat文件运行即可

ES启动之后,将中文分词器设置完成,在运行分词

{
  "text": "罗技激光鼠标",
  "analyzer": "ik_smart"
}

再次运行分词测试,应该看到正常的中文分词效果

但是词库的容量有限,比较新的网络名词和较新出现的人名是不在词库中的

ik分词插件的种类

我们安装的ik实际上不只一个分词器

实际上除了ik_smart之外还有ik_max_word

POST http://localhost:9200/_analyze
Content-Type: application/json

{
  "text": "北京冬季奥林匹克运动会顺利闭幕",
  "analyzer": "ik_smart"
}
POST http://localhost:9200/_analyze
Content-Type: application/json
​
{
  "text": "北京冬季奥林匹克运动会顺利闭幕",
  "analyzer": "ik_max_word"
}

上面的两个分词器运行分词,结果会有非常明显的区别

总结区别如下

ik_smart

  • 优点:特征是粗略快速的将文字进行分词,占用空间小,查询速度快

  • 缺点:分词的颗粒度大,可能跳过一些重要分词,导致查询结果不全面,查全率低

ik_max_word

  • 优点:特征是详细的文字片段进行分词,查询时查全率高,不容易遗漏数据

  • 缺点:因为分词太过详细,导致有一些无用分词,占用空间较大,查询速度慢

使用ES操作数据

ES是一个数据库性质的软件

可以执行增删改查操作,只是他操作数据不使用sql,数据的结构和关系型数据库也不同

我们先了解一下ES保存数据的结构

  • ES启动后,ES服务可以创建多个index(索引),index可以理解为数据库中表的概念

  • 一个index可以创建多个保存数据的document(文档),一个document理解为数据库中的一行数据

  • 一个document中可以保存多个属性和属性值,对应数据库中的字段(列)和字段值

项目csmall-finish项目中

node文件夹下共享了ES文档,命令都在里面,可以测试

所有的代码都在"ES文档"中, 笔记略

SpringBoot 操作 Elasticsearch

Spring Data简介

原生状态下,我们使用JDBC连接数据库,因为代码过于繁琐,所以改为使用Mybatis框架

在ES的原生状态下,我们java代码需要使用socket访问ES,但是也是过于繁琐,我们可以使用SpringData框架简化

Spring Data是Spring提供的一套连接各种第三方数据源的框架集

我们需要使用的是其中连接ES的Spring Data Elasticseatrch

官方网站:Spring Data

官网中列出了SpringData支持连接操作的数据源列表

下面我们就按照SpringDataElasticsearch的步骤对ES进行操作

添加依赖和配置

就使用我们之前创建的search模块来操作ES

pom文件添加依赖

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>cn.tedu</groupId>
        <artifactId>csmall</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.tedu</groupId>
    <artifactId>search</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>search</name>
    <description>Demo project for Spring Boot</description>
​
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
​
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
​
        <!-- Spring Data Elasticsearch 整合SpringBoot的依赖   -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>
    </dependencies>
​
</project>

application.properties添加配置

# 配置ES的ip地址和端口号
spring.elasticsearch.rest.uris=http://localhost:9200
​
# 设置日志门槛
logging.level.cn.tedu.search=debug
# SpringDataElasticsearch框架中日志输出的专用类也要设置日志门槛
logging.level.org.elasticsearch.client.RestClient=debug

创建和ES关联的实体类

和数据库一样

我们操作ES时也需要一个类似实体类的数据类,作为操作ES的数据载体

search项目创建entity包

在包中创建Item(商品)类

相关注解:

  1. @Document(indexName=)

  2. @Id

  3. @Field(type= )

@Data
@Accessors(chain=true)   // 支持链式set赋值
@AllArgsConstructor      // 自动生成全参构造器
@NoArgsConstructor       // 自动生成无参构造器

// @Document注解标记当前类是ES框架对应的实体类
// 属性indexName指定ES中对应索引的名称,运行时,如果这个索引不存在,SpringData会自动创建这个索引
@Document(indexName = "items")
public class Item implements Serializable {

    // SpringData通过@Id注解标记当前实体类的主键属性
    @Id
    private Long id;
    // @Field注解是SpringData提供的标记普通属性的注解
    // type用于指定属性的类型,FieldType是一个枚举,其中.Text指支持分词的字符串类型
    // 如果是需要中文分词的,还需要设置中文分词器
    @Field(type = FieldType.Text,
            analyzer = "ik_max_word",
            searchAnalyzer = "ik_max_word")
    private String title;
    // Keyword类型指不分词的字符串类型
    @Field(type = FieldType.Keyword)
    private String category;
    @Field(type = FieldType.Keyword)
    private String brand;
    @Field(type = FieldType.Double)
    private Double price;
    // imgPath是图片路径,路径不会称为搜索条件,所以这个列不需要创建索引,以节省空间
    // index = false就是不创建索引的设置
    // 但是需要注意,虽然不创建索引,但是ES还是保存这个数据的
    @Field(type = FieldType.Keyword,index = false)
    private String imgPath;

    // images/2022/12/30/18239adc-8ae913-abbf91.jpg

}

创建操作ES的持久层

我们使用SpringData连接ES

需要知道SpringData框架对持久层的命名规则

持久层规范名称为repository(仓库),创建这个包,包中创建接口ItemRepository

持久层需要继承springData提供的父接口ElasticsearchRepository

ElasticsearchRepository接口后面跟的泛型含义<[实体类类型],[实体类主键类型]

一旦继承,当前接口就可以使用父接口中定义的方法来连接\操作ES了

继承效果就是当前接口直接自动生成对指定实体类类型对应索引的基本增删改查方法

// Repository这个单词就是对Spring家族持久层包名,类名,接口名的规范
@Repository
public interface ItemRepository 
                extends ElasticsearchRepository<Item,Long> {
    // ItemRepository接口要继承springData提供的父接口ElasticsearchRepository
    // ElasticsearchRepository接口后面跟的泛型含义<[实体类类型],[实体类主键类型]>
    // 一旦继承,当前接口就可以使用父接口中定义的方法来连接\操作ES了
    // 继承效果就是当前接口直接自动生成对指定实体类类型对应索引的基本增删改查方法
    // 因为当前指定的实体类泛型(Item)类中编写了对应索引名称,所以当前接口是可以找到指定索引的
    
}

测试ES

如果没有测试包,创建test测试包

如果没有测试类,创建测试类

编写测试

springData提供的父接口ElasticsearchRepository提供的方法有:

  1. save():新增

  2. findById():按id查询ES中数据的方法,返回值是一个Optional类型的对象,Optional指定的泛型,就是保存的元素的类型

  3. saveAll():批量增加

  4. findAll():全查方法,返回对应索引中的全部数据

@SpringBootTest
class SearchApplicationTests {

    @Autowired
    private ItemRepository itemRepository;
    // 单增
    @Test
    void addOne() {
        Item item=new Item()
                .setId(1L)
                .setTitle("罗技激光无线游戏鼠标")
                .setCategory("鼠标")
                .setBrand("罗技")
                .setPrice(168.0)
                .setImgPath("/1.jpg");
        // 这里的新增是ItemRepository接口继承的父接口提供的方法
        itemRepository.save(item);
        System.out.println("ok");

    }
    // 单查
    @Test
    void getOne(){
        // SpringDataElasticsearch提供了按id查询ES中数据的方法
        // 返回值是一个Optional类型的对象,我们理解为只能保存一个元素的集合
        // Optional指定的泛型,就是保存的元素的类型
        Optional<Item> optional = itemRepository.findById(1L);
        Item item=optional.get();
        System.out.println(item);
    }

    // 批量增
    @Test
    void addList(){
        // 实例化一个List,把要保存到Es中的数据都添加到这个List中
        List<Item> list=new ArrayList<>();
        list.add(new Item(2L,"罗技激光有线办公鼠标","鼠标",
                            "罗技",58.0,"/2.jpg"));
        list.add(new Item(3L,"雷蛇机械无线游戏键盘","键盘",
                            "雷蛇",268.0,"/3.jpg"));
        list.add(new Item(4L,"微软有线静音办公鼠标","鼠标",
                            "微软",156.0,"/4.jpg"));
        list.add(new Item(5L,"罗技机械有线背光键盘","键盘",
                            "罗技",203.0,"/5.jpg"));
        // 执行批量新增
        itemRepository.saveAll(list);
        System.out.println("ok");

    }

    // 全查
    @Test
    void getAll(){
        // SpringDataElasticsearch提供了全查方法,返回对应索引中的全部数据
        Iterable<Item> items = itemRepository.findAll();
        for(Item item : items){
            System.out.println(item);
        }
        System.out.println("------------------------------------------");
        items.forEach(item -> System.out.println(item));
    }
}

SpringData自定义查询

SpringData框架提供的基本增删改查方法并不能完全满足我们的业务需要

如果是针对当前Es数据,进行个性化的自定义查询,那还是需要自己编写查询代码

就像我们要实现根据关键词查询商品信息一样,完成类似数据库中的模糊查询.

单条件查询

我们查询需求为输出所有数据中title属性包含"游戏"这个分词的商品信息

参考数据库中模糊查询

select * from item where title like '%游戏%'

我们使用SpringDataES进行查询,本质上还是相当于ES文档中执行的查询语句

在SpringData框架下,ItemRepository接口中实现更加简单

// SpringData自定义查询
// 我们要编写的查询要遵循SpringData给定的格式,来定义一个方法名
// SpringData会根据方法名自动推测出查询意图,生成能够完成该查询的语句
// query(查询):表示当前方法是一个查询方法,类似sql语句中的select
// Item/Items:表示要查询的实体类名称,返回的如果是集合需要在实体类名称后加s
// By(通过\根据):表示开始设置查询的条件,等价于sql语句中的where
// Title:表示要查询的字段,可以设置任何Item类中的字段
// Matches(匹配):表示执行查询的条件,matches是匹配字符串的关键字,
//          如果字符串是支持分词的,就按照分词匹配,类似于sql语句中的like

Iterable<Item> queryItemsByTitleMatches(String title);

下面可以开始在测试类中进行测试查询

// 单条件自定义查询
@Test
void queryOne(){
    // 查询ES中items索引中,title字段包含"游戏"分词的数据
    Iterable<Item> items = itemRepository.queryItemsByTitleMatches("游戏");
    items.forEach(item -> System.out.println(item));
}

上面代码运行时底层运行的查询语句为:

### 单条件搜索
POST http://localhost:9200/items/_search
Content-Type: application/json
​
{
  "query": {"match": { "title":  "游戏" }}
}

多条件查询

在相对复杂的查询逻辑下

经常使用多个条件来定位查询需要的数据

这样就需要逻辑运算符"and"/"or"

ItemRepository接口中添加多条件的查询方法

// 多条件自定义查询
// 多个条件之间我们要使用and或or来分隔,以表示多个条件间的逻辑运算关系
// 下面我们使用title和brand字段进行多条件查询
// 多个条件时,方法名会按照规则编写多个条件,参数也要根据条件的数量来变化
// 声明参数时,要按照方法名中需要参数的次序声明对应的参数,参数对应规则和参数名称无关
Iterable<Item> queryItemsByTitleMatchesAndBrandMatches(
                                    String title,String brand);

测试代码如下

// 多条件自定义查询
@Test
void queryTwo(){
    // 查询ES中,item索引中title字段包含"游戏分词"并且品牌是"罗技"的数据
    Iterable<Item> items=itemRepository
            .queryItemsByTitleMatchesAndBrandMatches("游戏","罗技");
    items.forEach(item -> System.out.println(item));
}

底层运行的请求

### 多字段搜索
POST http://localhost:9200/items/_search
Content-Type: application/json
​
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "游戏"}},
        { "match": { "brand": "罗技"}}
      ]
    }
  }
}

当查询条件关系为And时,查询语句关键字为must

当查询条件关系为Or时,查询语句关键字为should

排序查询

默认情况下从ES中查询获得的数据排序依据是ES查询得出的相关性分数(score)

但是如果想改变这个排序就需要在查询方法上添加新的关键字

在ItemRepository接口添加具备排序功能的查询方法

// 排序查询
// 方法名称中添加OrderBy关键字,指定排序的字段和排序的方向
Iterable<Item> queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
        String title,String brand);

测试代码如下

// 排序查询
@Test
void queryOrder(){
    Iterable<Item> items=itemRepository
            .queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                                    "游戏","罗技");
    items.forEach(item -> System.out.println(item));
}

底层运行的代码

### 多字段搜索
POST http://localhost:9200/items/_search
Content-Type: application/json
​
{
  "query": {
    "bool": {
      "should": [
        { "match": { "title": "游戏"}},
        { "match": { "brand": "罗技"}}
      ]
    }
  },"sort":[{"price":"desc"}]
}

分页查询

SpringData框架支持完成分页查询

需要在ItemRepository接口中修改方法的参数和返回值就可以实现

分页查询

  1. 参数方面:执行分页查询必须指定要查询的页码和每页条数

    这两个数据可以封装在Pageable类型的参数中,框架规定,这个参数必须放在列表的最后一个

  2. 返回值方面:Page类型中包含分页信息:当前页码,每页条数,总页数,总条数,有没有上一页有没有下一页...等

// 分页查询
// 参数方面:执行分页查询必须指定要查询的页码,和每页的条数
// 这两个数据可以封装在Pageable类型的参数中,框架规定,这个参数必须放在参数列表中最后一个
// 返回值方面:分页查询时,我们返回给前端的数据不但要包含查询到的当前页数据,还要包含分页信息
// 分页信息指:当前页码,每页条数,总页数,总条数,有没有上一页有没有下一页...等
// 返回值修改为Page类型,就能满足这个要求
​
Page<Item> queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
        String title, String brand, Pageable pageable);

测试代码

ES分页查询调用方法中的参数写法:

例:方法("游戏","罗技",PageRequest.of(page-1,pageSize))

注意page需减1

// 分页查询
@Test
void queryPage(){
    int page= 1;      // 要查询的页码
    int pageSize=2;   // 每页条数的设置
    Page<Item> pages=itemRepository
            .queryItemsByTitleMatchesOrBrandMatchesOrderByPriceDesc(
                    "游戏","罗技", PageRequest.of(page-1,pageSize));
    pages.forEach(item -> System.out.println(item));

    // pages对象包含的分页信息的输出
    System.out.println("总页数:"+pages.getTotalPages());
    System.out.println("总条数:"+pages.getTotalElements());
    System.out.println("当前页:"+(pages.getNumber()+1));
    System.out.println("每页条数:"+pages.getSize());
    System.out.println("是否是首页:"+pages.isFirst());
    System.out.println("是否是末页:"+pages.isLast());

}

开发分类功能

分类功能实现逻辑

我们数据库mall_pms的category表使用自关联实现了三级分类

当前酷鲨商城项目使用固定的三级分类

1.从数据库中查询出所有分类信息,一次性全查

2.构建分类信息的父子结构,实现查询返回父子结构的分类信息

3.将查询到的结果保存在Redis中,以备后续用户直接获取

代码中要判断Redis中是否包含全部分类数据,不包含的话做上面操作

包含分类数据的话直接获得之后返回

注意:

开发过程中,Redis一般不使用List做值

原因:Redis保存对象的效率更高

另一方面保存List容易出现数据冗余,导致内存浪费

举例:在线数据库中有数据:1,2,3,4,5,6,7,8

正常情况下,Redis最终保存{1,2,3,4,5,6,7,8}

但是如果Redis中经常保存List类型,就可能出现下面的情况

{1,2,3,4}

{1,3,5,7}

{2,4,6,8}

{4,5,6,7,8}

{1,2,3,5}

业务分析

查询全部分类的业务重点在构建三级分类树结构

我们需要将从数据库中查询出的分类对象构成下面的结构

[
    {id:1,name:"手机/运行商/数码",parentId:0,depth:1,children:[
        {id:2,name:"手机通讯",parentId:1,depth:2,children:[
            {id:3,name:"智能手机",parentId:2,depth:3,children:null},
            {id:4,name:"非智能手机",parentId:2,depth:3,children:null}
        ]},
    ]},
    {id:5,name:"电脑/办公",parentId:0,depth:1,children:[....]}
]

上面是我们需要获得的对象的结构可以理解为下图

在数据库mall_pms中

有pms_category表,这个表就是保存全部分类信息的表格

id:主键

name:显示在页面上的分类名称

parentId:父分类的id 如果是一级分类父分类id为0

depth:分类深度,当前项目就是3级分类,1\2\3 分别代表它的等级

keyword:搜索关键字

sort:排序依据 正常查询时,根据此列进行排序,数字越小越出现在前面(升序)

icon:图标地址

enable:是否可用

isparent:是否为父分类 0 假 1真

isdisplay:是否显示在导航栏 0不显示 1显示

实施开发

front:前台

在csmall-front-webapi项目中开发

无需编写持久层代码,因为mall_pms数据库的所有操作均在product模块编写完成了

我们front模块只需dubbo调用即可

创建service.impl包

包中编写业务逻辑层实现类FrontCategoryServiceImpl 实现IFrontCategoryService

@Service
@Slf4j
public class FrontCategoryServiceImpl implements IFrontCategoryService {
​
    // front模块要dubbo调用product模块的方法,获取数据库中所有的分类信息
    @DubboReference
    private IForFrontCategoryService dubboCategoryService;
​
    // 装配操作Redis的对象
    @Autowired
    private RedisTemplate redisTemplate;
​
    // 在开发时,使用Redis规范:使用Key时这个Key必须是一个常量,避免拼写错误
    public static final String CATEGORY_TREE_KEY="category_tree";
​
    @Override
    public FrontCategoryTreeVO categoryTree() {
        // 先检查Redis中是否已经保存了三级分类树对象
        if(redisTemplate.hasKey(CATEGORY_TREE_KEY)){
            // redis中如果已经有了这个key直接获取即可
            FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                    (FrontCategoryTreeVO<FrontCategoryEntity>)
                    redisTemplate.boundValueOps(CATEGORY_TREE_KEY).get();
            // 将从Redis中查询出的对象返回
            return treeVO;
        }
        // Redis中如果没有三级分类树信息,表示本次情况可能是首次访问
        // 就需要从数据库中查询分类对象结合后,构建三级分类树,再保存到Redis中了
        // Dubbo调用查询所有分类对象的方法
        List<CategoryStandardVO> categoryStandardVOs =
                        dubboCategoryService.getCategoryList();
        // CategoryStandardVO是没有children属性的,FrontCategoryEntity是有children属性的
        // 下面编写一个专门的方法,用于构建三级分类树对象
        // 大概思路就是先将CategoryStandardVO类型对象转换为FrontCategoryEntity
        // 然后在进行正确的父子关联
        // 整个转换的过程比较复杂,所以我们单独编写一个方法
        FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                                        initTree(categoryStandardVOs);
        // 上面方法完成了三级分类树的构建,返回包含树结构的treeVO对象
        // 下面要将这个对象保存在Redis中,在后面的请求中直接从Redis中获取treeVO提高效率
        redisTemplate.boundValueOps(CATEGORY_TREE_KEY)
                .set(treeVO,1, TimeUnit.MINUTES);
        // 上面的方法定义了保存到Redis数据的内容和有效期
        // 我们代码中建议定义较小的有效期,例如1分钟,在上线的项目中保存时间会长,例如24小时甚至更长
        // 别忘了最后也返回treeVO
        return treeVO;
    }
​
    private FrontCategoryTreeVO<FrontCategoryEntity>
                    initTree(List<CategoryStandardVO> categoryStandardVOs) {
        // 第一步:
        // 确定所有分类id包含的子分类对象
        // 我们可以以分类id作为Key,这个分类对象包含的所有子分类作为Value,保存到Map中
        // 因为一个分类对象可以包含多个子分类,所以这个Map的value是List类型
        Map<Long,List<FrontCategoryEntity>> map=new HashMap<>();
        log.info("准备构建三级分类树,节点数量为:{}",categoryStandardVOs.size());
        // 遍历当前方法参数,也就是数据库查询出的所有分类对象集合
        for(CategoryStandardVO categoryStandardVO : categoryStandardVOs){
            // 需要将categoryStandardVO对象中同名属性赋值给FrontCategoryEntity对象
            // 因为需要FrontCategoryEntity类型对象中的childrens属性才能实现父子的关联
            FrontCategoryEntity frontCategoryEntity=new FrontCategoryEntity();
            BeanUtils.copyProperties(categoryStandardVO,frontCategoryEntity);
            // 获取当前对象的父分类id值,以备后续使用
            Long parentId=frontCategoryEntity.getParentId();
            // 判断这个父分类id值,是否已经在map中有对应的Key
            if(!map.containsKey(parentId)){
                // 如果当前map中不包含当前分类对象的父分类id
                // 那么就要新建这个元素,就要确定key和value
                // key就是parentId的值,value是个List对象,list中保存当前分类对象(有childrens的)
                List<FrontCategoryEntity> value=new ArrayList<>();
                value.add(frontCategoryEntity);
                // 在map中添加元素
                map.put(parentId,value);
            }else{
                // 如果map中已经包含当前遍历对象父分类id的Key
                // 那么久就将当前分类对象追加到这个元素的value集合中
                map.get(parentId).add(frontCategoryEntity);
            }
        }
        // 第二步:
        // 将子分类对象添加到对应的分类对象的children属性中
        // 先从获取一级分类对象开始,我们项目设定父分类id为0的是一级分类(Long类型要写0L)
        List<FrontCategoryEntity> firstLevels = map.get(0L);
        // 判断所有一级分类集合如果为null(没有一级分类),直接抛出异常终止程序
        if(firstLevels==null || firstLevels.isEmpty()){
            throw new CoolSharkServiceException(
                    ResponseCode.INTERNAL_SERVER_ERROR,"没有一级分类对象!");
        }
        // 遍历一级分类集合
        for(FrontCategoryEntity oneLevel : firstLevels){
            // 一级分类对象的Id就是二级分类对象的父id
            Long secondLevelParentId=oneLevel.getId();// getId()!!!!!!!!!!
            // 根据上面二级分类的父Id,获得这个一级分类包含的所有二级分类对象集合
            List<FrontCategoryEntity> secondLevels = map.get(secondLevelParentId);
            // 判断二级分类对象中是否有元素
            if(secondLevels==null || secondLevels.isEmpty()){
                // 二级分类缺失不抛异常,日志输出警告即可
                log.warn("当前分类没有二级分类内容:{}",secondLevelParentId);
                // 如果二级分类对象缺失,可以直接跳过本次循环剩余的内容,直接开始下次循环
                continue;
            }
            // 确定二级分类对象非空后,开始遍历二级分类对象集合
            for(FrontCategoryEntity twoLevel : secondLevels){
                // 获取二级分类的id,作为三级分类的父id
                Long thirdLevelParentId=twoLevel.getId();// getId()!!!!!!!!!!
                // 根据这个二级分类的父id,获取它对应的所有三级分类的集合
                List<FrontCategoryEntity> thirdLevels=map.get(thirdLevelParentId);
                // 判断thirdLevels是否为null
                if(thirdLevels==null || thirdLevels.isEmpty()){
                    log.warn("当前二级分类没有三级分类内容:{}",thirdLevelParentId);
                    continue;
                }
                // 将三级分类对象集合添加到二级分类对象的childrens属性中
                twoLevel.setChildrens(thirdLevels);
            }
            // 将二级分类对象集合添加到一级分类对象的childrens属性中
            oneLevel.setChildrens(secondLevels);
        }
        // 到此为止,所有的分类对象都应该确认了自己和父\子分类对象的关联关系
        // 最后我们要将firstLevels集合赋给FrontCategoryTreeVO类型对象
        // 实例化这个类型对象,给它的list属性赋值并返回即可
        FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                new FrontCategoryTreeVO<>();
        treeVO.setCategories(firstLevels);
        // 千万别忘了返回!!!!
        return treeVO;
    }
}

创建控制层

controller包

CategoryController类

代码如下

@RestController
@RequestMapping("/front/category")
@Api(tags = "前台分类查询")
public class CategoryController {
    @Autowired
    private IFrontCategoryService categoryService;
​
    @GetMapping("/all")
    @ApiOperation("查询获取三级分类树对象")
    public JsonResult<FrontCategoryTreeVO<FrontCategoryEntity>> getTreeVO(){
        FrontCategoryTreeVO<FrontCategoryEntity> treeVO=
                categoryService.categoryTree();
        return JsonResult.ok(treeVO);
    }
​
​
}

启动nacos\seata\redis

分页查询

分页查询的优点

所谓分页,就是查询结果数据较多时,采用按页显示的方法,而不是一次性全部显示

分页的优点:

  1. 服务器:一次性查询所有信息,服务器压力大,分页查询服务器压力小

  2. 客户端:一次性显示所有信息,需要更多流量,加载时间也会更长,分页显示没有这个问题

  3. 用户体验上:一般最有价值的信息都会在前几页显示,也方便用户记忆,多查询出来的数据使用几率很低

实现分页查询需要我们开发过程中多几个步骤

PageHelper实现分页查询原理

我们可以使用sql语句中添加limit关键字的方法实现分页查询

但是查询分页内容时,我们要自己计算相关的分页信息和参数

limit 0,10 limit 10,10

分页逻辑无论什么业务都是类似的,所以有框架帮助我们高效实现分页功能

PageHelper框架可以实现我们提供页码和每页条数,自动实现分页效果,收集分页信息

PageHelper的分页原理就是在程序运行时,在sql语句尾部添加limit关键字,并按照分页信息向limit后追加分页数据

要想使用,首先还是添加依赖

我们在之前搭建的微服务项目中先编写学习,建议使用csmall-order模块

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>

在添加seata支持时已经添加了pagehepler依赖

PageHelper的基本使用

编写持久层

我们使用csmall-order-webapi模块来完成分页的测试

首先编写分页的持久层mapper,持久层功能是全查所有订单信息

OrderMapper添加方法

// 分页显示所有订单的方法
// PageHelper框架完成分页的原理是在运行的sql语句后自动添加limit关键字
// 所以我们在编写查询方法之前,无需关注分页操作,持久层编写分页查询和普通查询没有区别
// 无论注解还是xml文件都没有区别
@Select("select id,user_id,commodity_code,count,money from order_tbl")
List<Order> findAllOrders();

注意这个方法并不需要任何分页的参数或返回值,sql也不需要编写limit

都是在业务逻辑层中由PageHelper框架处理的

编写业务逻辑层

下面就转到业务逻辑层实现类,先编写一个方法使用PageHelper的功能

先不用写接口,直接在业务逻辑层实现类中写方法

OrderServiceImpl添加方法

PageHelper调用的方法:

PageHelper设置page为1就是查询第一页** PageHelper.startPage(page,pageSize); // 下面开始持久层方法的调用 // 此方法运行时因为上面设置了分页条件,sql语句中会自动出现limit关键字 List<Order> list = orderMapper.findAllOrders();**

返回值:

查询结果list中包含的就是分页查询范围的数据了 ​ // 但是这个数据不包含分页信息(总页数,总条数,是否是首页,是否是末页等) ​ // 我们要利用PageHelper框架提供的PageInfo类型,来进行返回 ​ // PageInfo对象可以既包含分页数据,又包含分页信息 ​ // 这些信息会在PageInfo对象实例化时自动计算,并赋值到PageInfo对象中 ​ return new PageInfo<>(list);

// 分页查询所有订单的业务逻辑层方法
// page是页码,pageSize是每页条数
public PageInfo<Order> getAllOrderByPage(Integer page,Integer pageSize){
    // PageHelper框架实现分页的核心操作:
    // 在要执行分页的查询运行之前,设置分页的条件
    // 设置的方式如下(固定的格式,PageHelper框架设计的)
    // PageHelper设置page为1就是查询第一页
    PageHelper.startPage(page,pageSize);
    // 下面开始持久层方法的调用
    // 此方法运行时因为上面设置了分页条件,sql语句中会自动出现limit关键字
    List<Order> list = orderMapper.findAllOrders();
    // 查询结果list中包含的就是分页查询范围的数据了
    // 但是这个数据不包含分页信息(总页数,总条数,是否是首页,是否是末页等)
    // 我们要利用PageHelper框架提供的PageInfo类型,来进行返回
    // PageInfo对象可以既包含分页数据,又包含分页信息
    // 这些信息会在PageInfo对象实例化时自动计算,并赋值到PageInfo对象中
    return new PageInfo<>(list);
}

编写控制层

打开OrderController新建方法

@Autowired
//      ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
private OrderServiceImpl orderService;
​
//...
​
@GetMapping("/page")
@ApiOperation("分页查询所有订单")
@ApiImplicitParams({
        @ApiImplicitParam(value = "页码",name="page",example ="1" ),
        @ApiImplicitParam(value = "每页条数",name="pageSize",example ="10" )
})
public JsonResult<PageInfo<Order>> pageOrder(Integer page,Integer pageSize){
    PageInfo<Order> pageInfo=
            orderService.getAllOrdersByPage(page,pageSize);
    return JsonResult.ok("查询完成",pageInfo);
}

启动Nacos\Seata

启动order

进行knife4j测试http://localhost:20002/doc.html#/home

可以观察控制台输出的运行的sql语句(会自动添加limit关键字)

PageInfo对象既包含查询数据结果,又包含分页信息

数据结构如下图

附:PageInfo全部分页信息属性

//当前页
private int pageNum;
//每页的数量
private int pageSize;
//当前页的行数量
private int size;
//当前页面第一个元素在数据库中的行号
private int startRow;
//当前页面最后一个元素在数据库中的行号
private int endRow;
//总页数
private int pages;
//前一页页号
private int prePage;
//下一页页号
private int nextPage;
//是否为第一页
private boolean isFirstPage;
//是否为最后一页
private boolean isLastPage;
//是否有前一页
private boolean hasPreviousPage;
//是否有下一页
private boolean hasNextPage;
//导航条中页码个数
private int navigatePages;
//所有导航条中显示的页号
private int[] navigatepageNums;
//导航条上的第一页页号
private int navigateFirstPage;
//导航条上的最后一页号
private int navigateLastPage;

使用JsonPage返回结果

我们在开发的过程中,可能使用不同技术对不同的数据库进行分页查询

例如我们使用SpringDataElasticsearch分页查询ES,会得到返回值类型Page

我们使用PageHelper分页查询数据库,会得到返回值类型PageInfo

这两个返回值类型中,有类似的属性(当前页,每页条数,总页数,总条数等)

但是他们的属性名不同,导致返回给前端时,同样是分页业务,前端程序员需要用不同的属性来控制

给前端的工作带来额外的复杂度

所以实际开发中,我们会将分页的返回值统一为一个类型,这个类型是我们自己定义的

下面我们就在commons模块中创建一个JsonPage类型,用于统一分页的返回值

先在commons模块中添加支持pagehelper的依赖,因为在转换时需要PageInfo类

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

在csmall-commons模块的restful包中新建一个JsonPage类

代码如下

@Data
public class JsonPage<T> implements Serializable {
​
    // 当前类时Page\PageInfo类的代替,也是需要既能保存分页数据,也能保存分页信息
    // 分页信息方面,Page\PageInfo分页信息属性很多,原则是声明我们能用到的
    // 分页信息其实主要是前端需要,我们后端代码只负责查询,我们先声明几个最基本的,以后有需要再添加
    @ApiModelProperty(value = "总页数",name="totalPages")
    private Integer totalPages;
    @ApiModelProperty(value = "总条数",name="totalCount")
    private Long totalCount;
    @ApiModelProperty(value = "页码",name="page")
    private Integer page;
    @ApiModelProperty(value = "每页条数",name="pageSize")
    private Integer pageSize;
​
    // JsonPage中保存分页数据对象
    @ApiModelProperty(value = "分页数据",name="list")
    private List<T> list;
​
    // 下面要编写一个方法,实现将PageInfo类型转换为JsonPage类型
    // 如果还需要别的转换方法(例如将SpringData分页中的Page类型转换成JsonPage),再另外编写即可
    public static <T> JsonPage<T> restPage(PageInfo<T> pageInfo){
        // 因为BeanUtils.copyProperties方法是为同名属性赋值的
        // 又因为JsonPage类和PageInfo类中的属性名基本都不同,所以我们使用手动编写代码赋值转换
        JsonPage<T> jsonPage=new JsonPage<>();
        jsonPage.setTotalCount(pageInfo.getTotal());
        jsonPage.setTotalPages(pageInfo.getPages());
        jsonPage.setPage(pageInfo.getPageNum());
        jsonPage.setPageSize(pageInfo.getPageSize());
        jsonPage.setList(pageInfo.getList());
        // 转换完成后返回jsonPage对象
        return jsonPage;
​
    }
​
​
}

下面去使用这个类

csmall-order-service项目的IOrderService业务逻辑层接口添加方法

返回值使用JsonPage定义一个分页查询的方法

// 返回JsonPage类型的分页查询所有订单的方法
JsonPage<Order> getAllOrdersByPage(Integer page,Integer pageSize);

csmall-order-webapi项目OrderServiceImpl实现类中进行修改

//     ↓↓↓↓↓↓↓↓
public JsonPage<OrderTb> getAllOrdersByPage(Integer pageNum, Integer pageSize){
​
    PageHelper.startPage(pageNum,pageSize);
​
    List<OrderTb> list= orderMapper.findAllOrders();
​
    //     ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    return JsonPage.restPage(new PageInfo<>(list));
}

业务逻辑层返回值的修改影响控制器方法的调用

再去修改OrderController中方法调用的位置

@Autowired
//      ↓↓↓↓↓↓↓↓↓↓↓↓
private IOrderService orderService;
​
//...
//                ↓↓↓↓↓↓↓↓
public JsonResult<JsonPage<Order>> pageOrders(Integer page, Integer pageSize){
      // 分页调用
      //↓↓↓↓↓↓        ↓↓↓↓↓↓↓↓↓  
      JsonPage<Order> jsonPage=orderService.getAllOrdersByPage(
          page,pageSize);
      //                            ↓↓↓↓↓↓↓↓↓↓
      return JsonResult.ok("查询完成",jsonPage);
}

保证Nacos\Seata已经启动

Spring Security

Spring Security框架用于实现登录,内置加密,验证,放行等各种功能,可靠性强

同时还可以将当前登录用户的信息保存

特别的,对于用户具备的权限,有特殊的管理

在控制器运行前,可以使用注解来判断当前登录用户是否具备某个权限

@PreAuthorize("[权限名称]")

SpringSecurity在运行该方法之前进行核查

如果不具备这个权限会返回403状态码

关于单点登录

普通登录的问题

SSO是单点登录的缩写:SSO(Single Sign On)

微服务架构下,要解决单点登录实现会话保持的问题

首先我们分析一下普通登录和微服务登录的区别

先是单体项目登录之后的操作流程

主要依靠服务器的session保存用户信息

客户端发请求时,将sessionid同时发往服务器,根据sessionid就能确认用户身份

分布式或微服务项目中,服务器不再只有一个

那么就会出现下面的问题

上面的图片,表示我们在微服务系统中登录时遇到的问题

我们在用户模块中登录,只是将用户信息保存在用户模块的session中

而这个session不会和其他模块共享

所以在我们访问购物车模块或其他模块时,通过sessionid并不能获得在用户模块中登录成功的信息

这样就丢失了用户信息,不能完成业务,会话保持就失败了

市面上现在大多使用JWT来实现微服务架构下的会话保持

也就是在一个服务器上登录成功后,微服务的其他模块也能识别用户的登录信息

这个技术就是单点登录

单点登录解决方案

Session共享

Session共享是能够实现单点登录效果的

这种方式的核心思想是将用户的登录信息共享给其他模块

适用于小型的,用户量不大的微服务项目

上面这个结构实现起来比较简单,Spring有框架直接支持,添加配置和依赖即可实现单点登录

这样就能将登录成功的用户信息共享给Redis

其他模块根据sessionId获得Redis中保存的用户信息即可

  • 这样做最大的缺点就是内存严重冗余,不适合大量用户的微服务项目

JWT单点登录

Json Web Token(令牌)

这种登录方式,最大的优点就是不占用内存

生成的JWT由客户端自己保存,不占用服务器内存

在需要表明自己用户身份\信息时,将JWT信息保存到请求头中发送请求即可

Jwt登录流程图

SSO(Single Sign On):单点登录

开发购物车功能

新增sku到购物车

我们开发完成了显示商品详情的功能

可以通过选中具体规格之后确定要购买的sku信息

再点击"添加到购物车"按钮

就应该将选中的sku的信息保存在购物车中

打开mall-order-webapi模块

创建mapper\service.impl\controller包

当前mall-order模块,管理的数据库是mall-oms数据库

业务逻辑分析

  • 判断用户是否登录,只有登录后才能将商品新增到购物车

  • 验证购物车信息的完整性(SpringValidation)

  • 业务逻辑层要判断新增的sku是否在当前用户的购物车表中已经存在

    • 如果不存在是新增sku流程

    • 如果已经存在,是修改数量的流程

开发持久层

上面的内容中,我们完成了新增sku到购物车的业务分析

持久层要按之前分析的业务逻辑,开发多个方法

1.判断当前登录用户购物车中是否包含指定skuId商品的方法

2.新增sku到购物车表中

3.修改购物车指定sku数量的方法

在mapper包中创建OmsCartMapper接口,编写代码如下

@Repository
public interface OmsCartMapper {
​
    // 判断当前用户的购物车中是否存在商品
    OmsCart selectExistsCart(@Param("userId")Long userId,
                             @Param("skuId")Long skuId);
    
    // 新增sku信息到购物车
    int saveCart(OmsCart omsCart);
    
    // 修改购物车中sku的商品数量
    int updateQuantityById(OmsCart omsCart);
    
}

对应的OmsCartMapper.xml文件

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.tedu.mall.order.mapper.OmsCartMapper">
​
    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="cn.tedu.mall.pojo.order.model.OmsCart">
        <id column="id" property="id" />
        <result column="user_id" property="userId" />
        <result column="sku_id" property="skuId" />
        <result column="title" property="title" />
        <result column="main_picture" property="mainPicture" />
        <result column="price" property="price" />
        <result column="quantity" property="quantity" />
        <result column="gmt_create" property="gmtCreate" />
        <result column="gmt_modified" property="gmtModified" />
        <result column="bar_code" property="barCode"/>
        <result column="data" property="data"/>
    </resultMap>
    <!--  定义查询omsCart表时使用的列名sql片段  -->
    <sql id="SimpleQueryFields">
        <if test="true">
            id,
            user_id,
            sku_id,
            title,
            main_picture,
            price,
            quantity,
            gmt_create,
            gmt_modified
        </if>
    </sql>
    <!--   判断当前用户的购物车中是否存在指定商品  -->
    <select id="selectExistsCart" resultMap="BaseResultMap">
        select
            <include refid="SimpleQueryFields" />
        from
            oms_cart
        where
            user_id=#{userId}
        and
            sku_id=#{skuId}
    </select>
​
    <!--   新增sku信息到购物车  -->
    <insert id="saveCart" useGeneratedKeys="true" keyProperty="id">
        insert into oms_cart(
            user_id,
            sku_id,
            title,
            main_picture,
            price,
            quantity
        )values(
            #{userId},
            #{skuId},
            #{title},
            #{mainPicture},
            #{price},
            #{quantity}
        )
    </insert>
​
    <!--   修改购物车中的sku商品数量   -->
    <update id="updateQuantityById" >
        update
            oms_cart
        set
            quantity=#{quantity}
        where
            id=#{id}
    </update>
</mapper>

开发业务逻辑层

上面完成了持久层的代码

下面开发业务逻辑层

创建OmsCartServiceImpl类实现IOmsCartService接口

@Service
@Slf4j
public class OmsCartServiceImpl implements IOmsCartService {
​
    @Autowired
    private OmsCartMapper omsCartMapper;
​
    @Override
    public void addCart(CartAddDTO cartDTO) {
        // 要查询当前登录用户的购物车中是否已经包含指定商品,要先获取当前登录用id
        // 单独编写一个方法,从SpringSecurity上下文中获取用户id
        Long userId=getUserId();
        // 先按照参数中用户id和skuId进行查询
        OmsCart omsCart=omsCartMapper
                .selectExistsCart(userId,cartDTO.getSkuId());
        // 判断当前用户购物车中是否已经有这个商品
        if(omsCart == null){
            // 如果不存在这个商品进行新增操作
            // 新增购物车需要的参数类型是OmsCart,所以要先实例化一个OmsCart对象
            OmsCart newCart=new OmsCart();
            // 将参数cartDTO中的同名属性赋值到newCart对象中
            BeanUtils.copyProperties(cartDTO,newCart);
            // cartDTO对象中没有userId属性,需要单独为newCart赋值
            newCart.setUserId(userId);
            // 执行新增操作,新增sku信息到购物车表
            omsCartMapper.saveCart(newCart);
        }else{
            // 如果已经存在执行数量的修改
            // 运行到这里omsCart一定不是null
            // 我们需要做的就是将购物车中原有的数量和本次新增的商品数量相加
            // 再赋值到当前的omsCart属性中去执行修改
            // 我们需要将omsCart对象的getQuantity()和cartDTO对象的getQuantity()相加
            omsCart.setQuantity(omsCart.getQuantity()+cartDTO.getQuantity());
            // 确定了数量之后,执行持久层方法进行修改
            omsCartMapper.updateQuantityById(omsCart);
        }
    }
​
    @Override
    public JsonPage<CartStandardVO> listCarts(Integer page, Integer pageSize) {
        return null;
    }
​
    @Override
    public void removeCart(Long[] ids) {
​
    }
​
    @Override
    public void removeAllCarts() {
​
    }
​
    @Override
    public void removeUserCarts(OmsCart omsCart) {
​
    }
​
    @Override
    public void updateQuantity(CartUpdateDTO cartUpdateDTO) {
​
    }
​
    // 业务逻辑层方法中需要获得用户id
    // 我们的项目会在控制器方法运行前运行的过滤器代码中,对前端传入的表明用户身份的JWT解析
    // 解析后保存到SpringSecurity上下文中,我们可以从SpringSecurity上下文中获取用户信息
    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity获取的用户信息是否为null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录");
        }
        // 从SpringSecurity上下文中获取用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo)
                authenticationToken.getCredentials();
        // 最后别忘了将用户信息返回
        return csmallAuthenticationInfo;
    }
    //  业务逻辑层中实际需求都是获取用户的id
    // 我们再一个方法,直接返回用户id,方便业务调用
    public Long getUserId(){
        return getUserInfo().getId();
    }
​
}

开发控制层

创建OmsCartController

@RestController
@RequestMapping("/oms/cart")
@Api(tags = "购物车管理模块")
public class OmsCartController {
    @Autowired
    private IOmsCartService omsCartService;
​
    @PostMapping("/add")
    @ApiOperation("新增sku信息到购物车")
    // 在运行本控制器方法前,已经经过了过滤器的代码, 过滤器中解析的前端传入的JWT
    // 解析正确后,会将用户信息保存在SpringSecurity上下文中
    // 酷鲨商城前台用户登录时,登录代码中会给用户赋予一个固定的权限名称ROLE_user
    // 下面的注解就是在判断登录的用户是否具备这个权限,其实主要作用还是判断用户是否登录
    // 这个注解的效果是从SpringSecurity中判断当前登录用户权限,
    // 如果没登录返回401,权限不匹配返回403
    @PreAuthorize("hasAuthority('ROLE_user')")
    // @Validated注解是激活SpringValidation框架用的
    // 参数CartAddDTO中,有多个属性设置了非空的验证规则,如果有设置了规则的属性为null
    // 会抛出BindException异常,终止方法调用,运行全局异常处理类中对应的方法
    public JsonResult addCart(@Validated CartAddDTO cartAddDTO){
        omsCartService.addCart(cartAddDTO);
        return JsonResult.ok("新增sku到购物车完成");
    }
​
}

先注意sso模块application-test.yml的地址和端口号和密码(密码有两个)

也要注意order模块application-test.yml的地址和端口号和密码

都保证正确的前提下

先启动Nacos/Seata

启动 passport/order

sso:10002

order:10005

先访问10002前台用户登录获得JWT 用户名jackson密码123456

先登录看到JWT 然后复制JWT

转到10005 order模块 文档管理->全局参数设置->添加参数

参数名:Authorization

参数值:Bearer [粘贴JWT]

然后刷新当前10005的界面

然后进行发送请求即可成功!

如果测试结果中包含一个错误

错误信息里有"xml/bind"的错误信息

order/sso模块需要添加下面依赖即可解决

<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.0</version>
</dependency>

开发查询购物车功能

开发持久层

OmsCartMapper添加方法如下

// 根据用户id查询该用户购物车中的sku信息
List<CartStandardVO> selectCartsByUserId(Long userId);

OmsCartMapper.xml添加对应内容

<!-- 根据用户id查询该用户购物车中的sku信息  -->
<!--
    我们使用resultType声明返回值类型时
    Mybatis内部会按照驼峰命名法的命名规则,自动生成表的列和类中属性名称的映射
    列名          属性
    id           id
    user_id      userId
    sku_id       skuId
    title        title
-->
<select id="selectCartsByUserId"
        resultType="cn.tedu.mall.pojo.order.vo.CartStandardVO">
    select
        <include refid="SimpleQueryFields" />
    from
        oms_cart
    where
        user_id=#{id}
</select>

开发业务逻辑层

OmsCartServiceImpl业务实现

返回值支持分页结果,按分页条件查询

// 根据用户id分页查询该用户购物车中商品
@Override
public JsonPage<CartStandardVO> listCarts(Integer page, Integer pageSize) {
    // 首先要确定当前登录用的id,调用写好的方法从SpringSecurity上下文中获取
    Long userId=getUserId();
    // 执行查询之前,先设置分页条件
    PageHelper.startPage(page, pageSize);
    // 执行查询,在PageHelper的设置下,会自动在sql语句后添加limit关键字,实现分页查询
    List<CartStandardVO> list=omsCartMapper.selectCartsByUserId(userId);
    // list是分页数据,要将它保存到PageInfo对象中,再将PageInfo对象转换为JsonPage返回
    return JsonPage.restPage(new PageInfo<>(list));
}

开发控制层

下面开发控制层,调用方法进行测试

OmsCartController添加方法如下

@GetMapping("/list")
@ApiOperation("根据用户id分页查询购物车中sku列表")
@ApiImplicitParams({
        @ApiImplicitParam(value = "页码",name="page",example = "1"),
        @ApiImplicitParam(value = "每页条数",name="pageSize",example = "1")
})
@PreAuthorize("hasAuthority('ROLE_user')")
public JsonResult<JsonPage<CartStandardVO>> listCartsByPage(
  @RequestParam(required = false,defaultValue = WebConsts.DEFAULT_PAGE)
                                                Integer page,
  @RequestParam(required = false,defaultValue = WebConsts.DEFAULT_PAGE_SIZE)
                                                Integer pageSize
){
    JsonPage<CartStandardVO> jsonPage=
            omsCartService.listCarts(page,pageSize);
    return JsonResult.ok(jsonPage);
}

在上面测试了新增购物车环境的基础上

重启order模块

再次测试http://localhost:10005/doc.html

删除\清空购物车

删除购物车的持久层

我们删除购物车的功能支持同时删除一个或多个购物车中的商品

基本思路就是将要删除的购物车商品的id数组传入到Mapper中进行删除

在OmsCartMapper中添加方法

// 根据用户选中的一个或多个id,删除购物车中的商品(支持批量删除)
int deleteCartsByIds(Long[] ids);

OmsCartMapper.xml新增代码

<!-- 根据用户选中的一个或多个id,删除购物车中的商品(支持批量删除)  -->
<!--
    foreach标签中collection属性赋值为数组时,使用array提取数组
    item是定义遍历数组元素的名称,必须和循环体重#{}中的内容一致
    separator是分隔符,负责进行元素间值的填充
    open和close是循环开始前和循环结束后再sql中添加的内容
-->
<delete id="deleteCartsByIds">
    delete from
        oms_cart
    where
        id in
    <foreach collection="array" item="id" separator=","
                        open="(" close=")">
        #{id}
    </foreach>
</delete>

删除购物车的业务逻辑层

OmsCartServiceImpl添加方法

// 支持批量删除的删除购物车信息的方法
@Override
public void removeCart(Long[] ids) {
    // 调用mapper中编写的按数组参数删除购物车信息的方法
    int row=omsCartMapper.deleteCartsByIds(ids);
    if(row==0){
        throw new CoolSharkServiceException(
                ResponseCode.NOT_FOUND,"您要删除的商品已经删除了");
    }
}

删除购物车的控制层

OmsCartController

@PostMapping("/delete")
@ApiOperation("根据id数组删除购物车sku信息")
@ApiImplicitParam(value = "要删除的id数组",name="ids",
                            required = true,dataType = "array")
@PreAuthorize("hasAuthority('ROLE_user')")
public JsonResult removeCartsByIds(Long[] ids){
    omsCartService.removeCart(ids);
    return JsonResult.ok("删除完成");
}

重启Order模块,测试删除功能

修改购物车的商品数量

开发修改购物车数量的业务逻辑层

因为之前开发新增购物车功能时,我们已经完成了修改购物车数量的持久层,所以不需要再编写了,直接从业务层开始

OmsCartServiceImpl

@Override
public void updateQuantity(CartUpdateDTO cartUpdateDTO) {
    // 当前方法参数是CartUpdateDTO,执行修改需要的是OmsCart
    // 所以要先实例化OmsCart对象,并给它赋值
    OmsCart omsCart=new OmsCart();
    // cartUpdateDTO只有id和quantity属性,赋值给omsCart,执行修改的参数齐全了
    BeanUtils.copyProperties(cartUpdateDTO,omsCart);
    // 执行修改
    omsCartMapper.updateQuantityById(omsCart);

}

控制层OmsCartController

@PostMapping("/update/quantity")
@ApiOperation("修改购物车中sku的数量")
@PreAuthorize("hasAuthority('ROLE_user')")
public JsonResult updateQuantity(@Validated CartUpdateDTO cartUpdateDTO){
    omsCartService.updateQuantity(cartUpdateDTO);
    return JsonResult.ok("修改完成!");
}

先启动Nacos/Seata

再启动order(我们上次课使用的JWT有消息被设置为7天,7天内无需再次复制)

最后10005测试修改购物车sku数量

课上练习

开发清空当前登录用户购物车的功能

<delete id="deleteCartsByUserId">
    delete from
        oms_cart
    where
        user_id=#{userId}
</delete>
@Override
public void removeAllCarts() {

}

清空购物车的功能

OmsCartMapper

// 清空指定用户购物车中所有sku商品
int deleteCartsByUserId(Long userId);

OmsCartMapper.xml

<!--   清空指定用户购物车中所有sku商品 -->
<delete id="deleteCartsByUserId">
    delete from
        oms_cart
    where
        user_id=#{userId}
</delete>

OmsCartServiceImpl

@Override
public void removeAllCarts() {
    Long userId=getUserId();
    int rows=omsCartMapper.deleteCartsByUserId(userId);
    if(rows==0){
        throw new CoolSharkServiceException(
                ResponseCode.NOT_FOUND,"您的购物车是空的!");
    }
}

OmsCartController

@PostMapping("/delete/all")
@ApiOperation("清空当前登录用户购物车信息")
// SpringSecurity框架用一个数组保存权限和角色,规范上所有角色都用ROLE_开头来标识
// @PreAuthorize("hasAuthority('xxx')")是来判断这个数组中是否具备某个权限或角色的
// @PreAuthorize("hasRole('yyy')")是专门用来判断数组中是否具备某个角色的
// 实际上判断的是在数组中是否有ROLE_yyy的资格
// 所以下面两个注解在运行判断时是等价的
// @PreAuthorize("hasAuthority('ROLE_user')")
@PreAuthorize("hasRole('user')")
public JsonResult removeCartsByUserId(){
    omsCartService.removeAllCarts();
    return JsonResult.ok("购物车已清空");
}

重启order测试

新增订单

新增订单业务逻辑分析

用户选中购物车中的商品后,点击添加订单(去结算)

我们要收集订单信息(sku商品信息,价格信息,优惠和运费信息等)然后才能执行生成订单操作

具体步骤如下

1.首先将用户选中的sku库存减少相应的数量

2.用户购物车要删除对应的商品

3.对应oms_order表执行新增,也就是创建一个订单

4.在新增订单成功后,我们还要将订单中的每种商品和订单关系添加在oms_order_item表中

除了理解业务之外我们还要确定要使用的技术

除了之前一直使用的Nacos\Dubbo之外,创建订单的业务在减少库存时,是Dubbo调用的pms中的sku表,这就涉及了分布式事务seata,删除购物车,新增订单和新增订单项是order模块的功能

减少库存的功能是product模块写好的

开发删除选中的购物车商品的功能

本次删除我们使用用户id和skuId来指定要删除的购物车商品

之前没有写过,在OmsCartMapper编写

// 根据用户id和skuId删除购物车中商品
int deleteCartByUserIdAndSkuId(OmsCart omsCart);

OmsCartMapper.xml

<!--  根据用户id和skuId删除购物车中商品  -->
<delete id="deleteCartByUserIdAndSkuId">
    delete from
        oms_cart
    where
        user_id=#{userId}
    and 
        sku_id=#{skuId}
</delete>

当前删除购物车商品的功能是为生成订单准备的

所以只需要开发出业务逻辑层即可,不需要控制层的代码

OmsCartServiceImpl实现方法

@Override
public void removeUserCarts(OmsCart omsCart) {
    // 直接调用删除购物车中商品方法即可
    // 我们电商网站不会因为购物车中商品不存在,就不让用户购买
    // 所以这个删除不判断是否成功,也不抛出异常
    omsCartMapper.deleteCartByUserIdAndSkuId(omsCart);
}

开始编写新增订单功能

编写新增order_item的持久层

order_item表中保存每张订单包含什么商品的信息

我们新增这个表,要包含订单号,商品id和相关信息

mapper下创建OmsOrderItemMapper

@Repository
public interface OmsOrderItemMapper {

    // 新增订单向(oms_order_item)的方法
    // 一个订单可能包含多个商品,每件商品都单独新增到数据库的话,连库次数多,效率低
    // 我们这里尽可能减少连库次数,实现一次将一个集合中所有的对象新增到数据库中
    // 所以我们当前方法的参数就设置为了List<OmsOrderItem>类型
    int insertOrderItemList(List<OmsOrderItem> omsOrderItems);


}

OmsOrderItemMapper.xml文件添加内容

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.tedu.mall.order.mapper.OmsOrderItemMapper">
    <!--  新增订单向(oms_order_item)的方法 -->
    <insert id="insertOrderItemList">
        insert into oms_order_item(
            id,
            order_id,
            sku_id,
            title,
            bar_code,
            data,
            main_picture,
            price,
            quantity
        )values
        <foreach collection="list" item="ooi" separator=",">
        (
            #{ooi.id},
            #{ooi.orderId},
            #{ooi.skuId},
            #{ooi.title},
            #{ooi.barCode},
            #{ooi.data},
            #{ooi.mainPicture},
            #{ooi.price},
            #{ooi.quantity}
        )
        </foreach>
    </insert>

</mapper>

编写新增order的持久层

上面我们完成了新增OrderItem对象的方法

下面要继续完成Order的方法

mapper包下再创建OmsOrderMapper

添加新增Order的方法

@Repository
public interface OmsOrderMapper {

    // 新增订单的方法
    int insertOrder(OmsOrder omsOrder);
}

OmsOrderMapper.xml中添加方法

<!-- 新增订单的方法  -->
<insert id="insertOrder" >
    insert into oms_order(
        id,
        sn,
        user_id,
        contact_name,
        mobile_phone,
        telephone,
        province_code,
        province_name,
        city_code,
        city_name,
        district_code,
        district_name,
        street_code,
        street_name,
        detailed_address,
        tag,
        payment_type,
        state,
        reward_point,
        amount_of_original_price,
        amount_of_freight,
        amount_of_discount,
        amount_of_actual_pay,
        gmt_pay,
        gmt_order,
        gmt_create,
        gmt_modified
    ) values(
                #{id},
                #{sn},
                #{userId},
                #{contactName},
                #{mobilePhone},
                #{telephone},
                #{provinceCode},
                #{provinceName},
                #{cityCode},
                #{cityName},
                #{districtCode},
                #{districtName},
                #{streetCode},
                #{streetName},
                #{detailedAddress},
                #{tag},
                #{paymentType},
                #{state},
                #{rewardPoint},
                #{amountOfOriginalPrice},
                #{amountOfFreight},
                #{amountOfDiscount},
                #{amountOfActualPay},
                #{gmtPay},
                #{gmtOrder},
                #{gmtCreate},
                #{gmtModified}
            )
</insert>

Leaf

什么是Leaf

leaf是叶子的意思

我们使用的Leaf是美团公司开源的一个分布式序列号(id)生成系统

我们可以在Github网站上下载项目直接使用

为什么需要Leaf

上面的图片中

是一个实际开发中常见的读写分离的数据库部署格式

专门进行数据更新(写)的有两个数据库节点

它们同时新增数据可能产生相同的自增列id

一旦生成相同的id,数据同步就会有问题

会产生id冲突,甚至引发异常

我们为了在这种多数据库节点的环境下能够产生唯一id

可以使用Leaf来生成

Leaf的工作原理

Leaf底层支持通过"雪花算法"生成不同id

我们使用的是单纯的序列

要想使用,需要事先设置好leaf的起始值和缓存id数

举例,从1000开始缓存500

也就是从id1000~1499这些值,都会保存在Leaf的内存中,当有服务需要时,直接取出下一个值

取出过的值不会再次生成,当缓存的数据取完时,会往后再缓存500个,从1500-1999

leaf要想设置起始值和缓存数

需要给leaf创建一个指定格式的数据库表

运行过程中会从数据库表获取信息

我们当前的信息保存在leafdb.leaf_alloc表中

开发新增订单功能

开发新增订单的业务逻辑层

我们完成订单的新增业务是比较复杂的

可以将整个业务分成三大部分

第一部分是信息的收集

主要是参数类型数据的完整性验证,计算以及转换

第二部分是数据库操作

减少库存,删除购物车,新增订单,和新增订单项

第三部分是收集需要的返回值

我们新增订单成功后,要返回给前端一些信息,例如订单号,实际支付金额等

创建OmsOrderServiceImpl类,代码如下

// 订单管理模块的业务逻辑层实现类,因为后期秒杀模块也是要生成订单的,需要dubbo调用这个方法
@DubboService
@Service
@Slf4j
public class OmsOrderServiceImpl implements IOmsOrderService {

    // dubbo调用减少库存的方法
    @DubboReference
    private IForOrderSkuService dubboSkuService;
    @Autowired
    private IOmsCartService omsCartService;
    @Autowired
    private OmsOrderMapper omsOrderMapper;
    @Autowired
    private OmsOrderItemMapper omsOrderItemMapper;

    // 新增订单的方法
    // 这个方法中利用Dubbo远程调用了product模块的数据库操作,有分布式事务需求
    // 所以使用注解激活Seata分布式事务的功能
    @GlobalTransactional
    @Override
    public OrderAddVO addOrder(OrderAddDTO orderAddDTO) {
        // 第一部分:收集信息,准备数据
        // 先实例化OmsOrder对象,最终实现新增订单到数据库的对象就是它
        OmsOrder order=new OmsOrder();
        // 将参数orderAddDTO中的同名属性赋值到order对象中
        BeanUtils.copyProperties(orderAddDTO,order);
        // orderAddDTO中包含的属性并不齐全,还有一些是可空内容,
        // order对象现在还缺失属性,我们可以编写一个方法来专门收集或生成
        loadOrder(order);
        // 运行完上面的方法,order对象的所有属性就都有值了
        // 下面开始整理收集参数orderAddDTO中包含的订单项集合:orderItems属性
        // 首先从参数中获得这个集合
        List<OrderItemAddDTO> itemAddDTOs=orderAddDTO.getOrderItems();
        if(itemAddDTOs ==null  || itemAddDTOs.isEmpty()){
            // 如果订单参数中没有订单项信息,直接抛出异常,终止程序
            throw new CoolSharkServiceException(
                    ResponseCode.BAD_REQUEST,"订单中至少包含一件商品");
        }
        // 我们需要完成订单项信息新增到数据库的功能,而操作数据库方法的参数是List<OmsOrderItem>
        // 但是现在集合的类型是List<OrderItemAddDTO>,先需要将这个集合中的元素转换,保存到新集合
        List<OmsOrderItem> omsOrderItems=new ArrayList<>();
        // 编写从参数中获取的集合
        for(OrderItemAddDTO addDTO : itemAddDTOs){
            // 先实例化最终需要的类型对象OmsOrderItem
            OmsOrderItem orderItem=new OmsOrderItem();
            // 将正在遍历的addDTO对象的同名属性赋值到orderItem
            BeanUtils.copyProperties(addDTO,orderItem);
            // addDTO对象中没有id属性和orderId属性,需要单独赋值
            // 当前对象的id属性仍然从leaf中获取
            Long itemId=IdGeneratorUtils.getDistributeId("order_item");
            orderItem.setId(itemId);
            // 赋值当前正要新增的订单id
            orderItem.setOrderId(order.getId());
            // orderItem所有值都赋值完成了,将它保存到集合中
            omsOrderItems.add(orderItem);
            // 第二部分:执行数据库操作指令
            // 1.减少库存
            // 当前正在标记的对象就是一个包含SkuId和减少库存数的对象
            // 获取skuId
            Long skuId=orderItem.getSkuId();
            // dubbo调用减少库存的方法
            int row=dubboSkuService.reduceStockNum(
                                    skuId,orderItem.getQuantity());
            // 判断row的值
            if(row==0){
                // 如果row的值为0,表示库存没有变化,库存不足导致的
                log.error("商品库存不足,skuId:{}",skuId);
                // 抛出异常,终止程序,会触发seata分布式事务的回滚
                throw new CoolSharkServiceException(
                        ResponseCode.BAD_REQUEST,"您要购买的商品库存不足!");
            }
            // 2.删除勾选的购物车商品信息
            OmsCart omsCart=new OmsCart();
            omsCart.setUserId(order.getUserId());
            omsCart.setSkuId(skuId);
            // 执行删除
            omsCartService.removeUserCarts(omsCart);
        }
        // 3.执行新增订单
        omsOrderMapper.insertOrder(order);
        // 4.新增订单项(批量新增集合中的所有订单项数据)
        omsOrderItemMapper.insertOrderItemList(omsOrderItems);
        // 第三部分:准备返回值,返回给前端
        // 实例化返回值类型对象
        OrderAddVO addVO=new OrderAddVO();
        // 给各个属性赋值
        addVO.setId(order.getId());
        addVO.setSn(order.getSn());
        addVO.setCreateTime(order.getGmtCreate());
        addVO.setPayAmount(order.getAmountOfActualPay());
        // 别忘了返回addVO
        return addVO;
    }

    // 为order对象补齐属性的方法
    private void loadOrder(OmsOrder order) {
        // order对象中的id和sn是没有被赋值的可能的,因为参数中根本就没有同名属性
        // 先给id赋值,这个id是从Leaf分布式序列号生成系统中获取的
        Long id= IdGeneratorUtils.getDistributeId("order");
        order.setId(id);
        // 再给sn赋值,sn赋值的原则是生成一个UUID,是给用户看的订单号
        order.setSn(UUID.randomUUID().toString());
        // 给userId赋值
        // 后期的秒杀业务也会调用当前类的新增订单方法,userId属性会在秒杀业务中被赋值
        // 因为dubbo远程调用时不能同时发送当前登录用户信息,
        // 所以我们要判断一下order中的userId是否为null
        if(order.getUserId() ==null){
            // 如果userId为null 就需要从SpringSecurity上下文获取用户id
            order.setUserId(getUserId());
        }

        // 可以将OrderAddDTO类中未被设置为非空的属性,进行null的验证
        // 这里以state属性为例,如果state为null 默认值设置为0
        if(order.getState()==null){
            order.setState(0);
        }

        // 为下单时间赋值,赋值当前时间即可
        // 为了保证gmt_order和gmt_create时间一致
        // 这里为它们赋值相同的时间
        LocalDateTime now=LocalDateTime.now();
        order.setGmtOrder(now);
        order.setGmtCreate(now);
        order.setGmtModified(now);

        // 最后处理实际支付金额的计算,返回给前端,用于和前端计算的金额进行验证
        // 实际支付金额=原价-优惠+运费
        // 所有和金额价格等钱相关的数据,为了防止浮点偏移,都要使用BigDecimal类型
        BigDecimal price=order.getAmountOfOriginalPrice();
        BigDecimal freight=order.getAmountOfFreight();
        BigDecimal discount=order.getAmountOfDiscount();
        BigDecimal actualPay=price.add(freight).subtract(discount);
        // 计算得到的实际支付金额,赋值给order对象
        order.setAmountOfActualPay(actualPay);

    }


    @Override
    public void updateOrderState(OrderStateUpdateDTO orderStateUpdateDTO) {

    }

    @Override
    public JsonPage<OrderListVO> listOrdersBetweenTimes(OrderListTimeDTO orderListTimeDTO) {
        return null;
    }

    @Override
    public OrderDetailVO getOrderDetail(Long id) {
        return null;
    }

    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                        SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity获取的用户信息是否为null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录");
        }
        // 从SpringSecurity上下文中获取用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo)
                        authenticationToken.getCredentials();
        // 最后别忘了将用户信息返回
        return csmallAuthenticationInfo;
    }
    //  业务逻辑层中实际需求都是获取用户的id
    // 我们再一个方法,直接返回用户id,方便业务调用
    public Long getUserId(){
        return getUserInfo().getId();
    }
}

开发新增订单的控制层

下面开始编写控制层

新建OmsOrderController

@RestController
@RequestMapping("/oms/order")
@Api(tags = "订单管理模块")
public class OmsOrderController {

    @Autowired
    private IOmsOrderService omsOrderService;

    @PostMapping("/add")
    @ApiOperation("执行新增订单的方法")
    @PreAuthorize("hasRole('user')")
    public JsonResult<OrderAddVO> addOrder(@Validated OrderAddDTO orderAddDTO){
        OrderAddVO orderAddVO=omsOrderService.addOrder(orderAddDTO);
        return JsonResult.ok(orderAddVO);
    }
}

启动Nacos\seata

依次启动服务Leaf\product\[passport]\order

访问10005执行新增

Seata使用常见错误

Seata在开始工作时,会将方法相关对象序列化后保存在对应数据库的undo_log表中

但是Seata我们序列化的方式支持很多中,常见的jackson格式序列化的情况下,不支持java对象LocalDataTime类型的序列化,序列化运行时会发送错误:

如果见到这样的错误, 就是因为jackson不能序列化LocalDataTime导致的

要想解决,两方面思路,

1.将序列化过程中LocalDataTime类型转换为Date

2.将Seata序列化转换为kryo类型,但是需要在pom文件中添加依赖(我们的项目中有)

<!--解决seata序列化问题-->
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-serializer-kryo</artifactId>
</dependency>

yml文件使用kryo序列化对象的配置

#seata服务端
seata:
  tx-service-group: csmall_group
  service:
    vgroup-mapping:
      csmall_group: default
    grouplist:
      default: ${my.server.addr}:8091
  client:
    undo:
      log-serialization: kryo

静态资源服务器

什么是静态资源服务器

我们无论做什么项目,都会有一些页面中需要显示的静态资源,例如图片,视频,文档等

我们一般会创建一个单独的项目,这个项目中保存静态资源

其他项目可以通过我们保存资源的路径访问

使用静态资源服务器的原因是静态资源服务器可以将项目需要的所有图片统一管理起来

当其他模块需要图片时,可以从数据库中直接获得访问静态资源的路径即可

方便管理所有静态资源

前端项目部署

在提供给大家"第五阶段软件和项目.zip"的压缩包中

有csmall-mobile-repo.zip

解压这个压缩包到硬盘

用idea软件open这个解压后文件夹的位置

打开之后要运行这个前端项目

运行前可以参考项目的README.md文件

要运行项目首先需要在Idea的Terminal中输入

npm install
C:\Users\TEDU\IdeaProjects\csmall-mobile-repo>npm install

运行过程中有警告无视掉

运行完毕之后启动项目

npm run serve
C:\Users\TEDU\IdeaProjects\csmall-mobile-repo>npm run serve

之后就可以使用localhost:8080访问了

启动Nacos\Seata\Redis

启动leaf\product\passport\front\order\resource

登录页面:http://localhost:8080/user/login

用户名:jackson

密码:123456

订单查询功能

在新增订单成功之后,用户会看到订单列表

可以按时间查询一段时间范围内的订单列表

我们默认查询当前时间一个月以内的所有订单信息

订单信息要包括oms_order和oms_order_item两个表的信息

所以是一个连表查询

确定关联查询语句

SELECT 
	oo.id,
	oo.sn,
	oo.user_id,
	oo.contact_name,
	oo.state,
	oo.amount_of_actual_pay,
	oo.gmt_order,
	oo.gmt_pay,
	oo.gmt_create,
	oo.gmt_modified,
	ooi.id ooi_id,
	ooi.order_id,
	ooi.sku_id,
	ooi.title,
	ooi.price,
	ooi.quantity
FROM oms_order oo
JOIN oms_order_item ooi ON ooi.order_id=oo.id
WHERE
	oo.user_id=1
AND
	oo.gmt_create > '2023-1-1'
AND
	oo.gmt_create < NOW()
ORDER BY oo.gmt_modified DESC

开发查询订单的持久层

确定了sql语句之后,要在xml文件中使用

OmsOrderMapper添加方法

// 查询当前用户指定时间范围内的所有订单信息(关联订单项)
List<OrderListVO> selectOrdersBetweenTimes(OrderListTimeDTO orderListTimeDTO);

OmsOrderMapper.xml文件添加对应的内容

<resultMap id="OrderOrderItemMap" 		
           type="cn.tedu.mall.pojo.order.vo.OrderListVO">
        <id column="id" property="id" />
        <result column="sn" property="sn" />
        <result column="user_id" property="userId" />
        <result column="contact_name" property="contactName" />
        <result column="state" property="state" />
        <result column="amount_of_actual_pay" property="amountOfActualPay" />
        <result column="gmt_order" property="gmtOrder" />
        <result column="gmt_pay" property="gmtPay" />
        <result column="gmt_create" property="gmtCreate" />
        <result column="gmt_modified" property="gmtModified" />
        <!-- 当查询结果包含对象包含另外类型的集合时,按照如下配置编写映射  -->
        <!--    collection标签,表示对应一个集合对象 -->
        <!--
            property(必须):属性指定对应的集合的属性名称
            javaType(可选):属性指定集合的类型,因为默认就是java.util.List,所有本次配置省略了
            ofType(必须):属性指定集合的泛型类型,根据实际情况编写即可
        -->
        <collection property="orderItems"
                    ofType="cn.tedu.mall.pojo.order.vo.OrderItemListVO">
            <id column="ooi_id" property="id" />
            <result column="order_id" property="orderId" />
            <result column="sku_id" property="skuId" />
            <result column="title" property="title" />
            <result column="price" property="price" />
            <result column="quantity" property="quantity" />
        </collection>
</resultMap>
<!-- 查询当前登录用户指定时间范围内的所有订单(是关联订单项表的查询)  -->
<select id="selectOrdersBetweenTimes" resultMap="OrderOrderItemMap">
    SELECT
        oo.id,
        oo.sn,
        oo.user_id,
        oo.contact_name,
        oo.state,
        oo.amount_of_actual_pay,
        oo.gmt_order,
        oo.gmt_pay,
        oo.gmt_create,
        oo.gmt_modified,
        ooi.id ooi_id,
        ooi.order_id,
        ooi.sku_id,
        ooi.title,
        ooi.price,
        ooi.quantity
    FROM oms_order oo
    JOIN oms_order_item ooi ON ooi.order_id=oo.id
    WHERE
        oo.user_id=#{userId}
    AND
        oo.gmt_create &gt; #{startTime}
    AND
        oo.gmt_create &lt; #{endTime}
    ORDER BY oo.gmt_modified DESC
</select>

开发查询订单业务逻辑层

OmsOrderServiceImpl实现类添加实现方法

// 分页查询当前登录用户,在指定时间范围内的所有订单
// 默认情况下查询最近一个月的订单,查询的返回值OrderListVO,它既包含订单信息也包含订单中的商品信息
// 实现上面查询效果需求持久层编写特殊的关联查询和关联关系配置
@Override
public JsonPage<OrderListVO> listOrdersBetweenTimes(OrderListTimeDTO orderListTimeDTO) {
    // 方法开始第一步,先确定要查询的时间范围
    // 编写一个方法,判断orderListTimeDTO参数中时间的各种情况
    validateTimeAndLoadTime(orderListTimeDTO);
    // 获取userId赋值到参数中
    orderListTimeDTO.setUserId(getUserId());
    // 设置分页条件
    PageHelper.startPage(orderListTimeDTO.getPage(),
                         orderListTimeDTO.getPageSize());
    List<OrderListVO> list = omsOrderMapper.selectOrdersBetweenTimes(orderListTimeDTO);
    // 别忘了返回
    return JsonPage.restPage(new PageInfo<>(list));
}

private void validateTimeAndLoadTime(OrderListTimeDTO orderListTimeDTO) {
    // 获取参数中开始时间和结束时间
    LocalDateTime start=orderListTimeDTO.getStartTime();
    LocalDateTime end=orderListTimeDTO.getEndTime();
    // 为了不让业务更复杂,我们设计当start和end任意一个是null值时,就差最近一个月订单
    if (start == null || end == null){
        // start设置为一个月前的时间
        start=LocalDateTime.now().minusMonths(1);
        // end设置为当前时间即可
        end=LocalDateTime.now();
        // 将开始时间和结束时间赋值到参数中
        orderListTimeDTO.setStartTime(start);
        orderListTimeDTO.setEndTime(end);
    }else{
        // 如果start和end都非null
        // 就要判断start是否小于end,如果不小于就要抛出异常
        // if( end.isBefore(start))
        if(end.toInstant(ZoneOffset.of("+8")).toEpochMilli()<
            start.toInstant(ZoneOffset.of("+8")).toEpochMilli()){
            // 上面的判断是有时区修正的
            // 如果结束时间小于开始时间就要抛出异常
            throw new CoolSharkServiceException(ResponseCode.BAD_REQUEST,
                    "结束时间应大于开始时间");
        }
    }
}

开发查询订单的控制层代码

OmsOrderController

@GetMapping("/list")
@ApiOperation("分页查询当前登录用户指定时间内订单")
@PreAuthorize("hasRole('user')")
public JsonResult<JsonPage<OrderListVO>> listUserOrders(
                            OrderListTimeDTO orderListTimeDTO){
    JsonPage<OrderListVO> jsonPage=
            omsOrderService.listOrdersBetweenTimes(orderListTimeDTO);
    return JsonResult.ok(jsonPage);

}

启动Nacos\Seata

如果没有登录的话启动sso(passport)做jwt复制

如果有可用的jwt

直接启动order即可

订单的新增和订单的查询一定要多练习几次

面试时一定要会讲述业务流程

开发更新订单状态的功能

订单的状态码

我们电商上面订单的状态修改是非常普通的业务

随着商品的购买流程,订单的状态有

状态:

0=未支付

1=已关闭(超时未支付)

2=已取消

3=已支付

4=已签收

5=已拒收

6=退款处理中

7=已退款

开发更新订单状态的持久层

修改订单状态就是根据订单id修改订单的state

我们随着业务的发展,订单可能需要更多修改的需求

订单的列(字段)比较多,如果每个字段修改,都需要编写一个方法的话,那么方法的数量会非常多

如果我们编写一个方法,能够接收订单对象的实体类参数(OmsOrder)

我们要实现可以根据OmsOrder对象的实际数据来实现动态的修改要修改的字段

Mybatis中可以通过编写动态修改sql语句完成这个需求

OmsOrderMapper接口添加方法

// 利用动态sql语句,实现对订单字段的修改
// 参数是OmsOrder类型,必须包含id属性值,id属性值不能修改
int updateOrderById(OmsOrder order);

OmsOrderMapper.xml编写sql

<!--
利用动态sql语句,实现对订单字段的修改
参数是OmsOrder类型,必须包含id属性值,id属性值不能修改
-->
<!--
    OmsOrder对象动态修改数据库中数据的sql语句
    Mybatis框架根据OmsOrder对象属性的非空状态,生成sql语句,使用<set>标签
    1.在<set>标签的位置生成set关键字
    2.在<set></set>标签之间,动态生成的sql语句,如果生成内容最后字符是","就会删除它
-->
<update id="updateOrderById" >
    update oms_order
    <set>
        <if test="contactName!=null">
            contact_name=#{contactName},
        </if>
        <if test="mobilePhone!=null">
            mobile_phone=#{mobilePhone},
        </if>
        <if test="telephone!=null">
            telephone=#{telephone},
        </if>
        <if test="streetCode!=null">
            street_code=#{streetCode},
        </if>
        <if test="streetName!=null">
            street_name=#{streetName},
        </if>
        <if test="detailedAddress!=null">
            detailed_address=#{detailedAddress},
        </if>
        <if test="tag!=null">
            tag=#{tag},
        </if>
        <if test="paymentType!=null">
            payment_type=#{paymentType},
        </if>
        <if test="state!=null">
            state=#{state},
        </if>
        <if test="rewardPoint!=null">
            reward_point=#{rewardPoint},
        </if>
        <if test="amountOfOriginalPrice!=null">
            amount_of_original_price=#{amountOfOriginalPrice},
        </if>
        <if test="amountOfFreight!=null">
            amount_of_freight=#{amountOfFreight},
        </if>
        <if test="amountOfDiscount!=null">
            amount_of_discount=#{amountOfDiscount},
        </if>
        <if test="amountOfActualPay!=null">
            amount_of_actual_pay=#{amountOfActualPay},
        </if>
        <if test="gmtPay!=null">
            gmt_pay=#{gmtPay},
        </if>
    </set>
    where
        id=#{id}
</update>

开发修改订单状态的业务逻辑层

OmsOrderServiceImpl

// 根据订单id,修改订单状态
@Override
public void updateOrderState(OrderStateUpdateDTO orderStateUpdateDTO) {
    // 实例化OmsOrder对象
    OmsOrder order=new OmsOrder();
    // 将orderStateUpdateDTO中同名属性赋值到order
    BeanUtils.copyProperties(orderStateUpdateDTO,order);
    // 调用动态修改订单的方法,修改订单状态
    omsOrderMapper.updateOrderById(order);
}

开发修改订单状态的控制层

OmsOrderController

@PostMapping("/update/state")
@ApiOperation("修改订单状态的方法")
@PreAuthorize("hasRole('user')")
public JsonResult updateOrderState(
        @Validated OrderStateUpdateDTO orderStateUpdateDTO){
    omsOrderService.updateOrderState(orderStateUpdateDTO);
    return JsonResult.ok("修改完成!");

}

重启order模块

测试时根据实际数据库订单id,修改knife4j的数据然后再运行

运行后查看数据库中订单状态列是否修改

搜索功能

Elasticsearch加载数据

我们要想完成高效的搜索任务,需要ES的支持

因为数据库的模糊查询效率太低了

我们在前端页面中完成的搜索是从ES中搜索数据

这样就要求,我们在查询之前,需要先将商品信息(spu)保存到ES中

一开始我们使用最原始的办法:从数据库查询出数据之后新增到ES中

确认实体类

搜索功能编写在mall-search模块中

它使用的实体类在cn.tedu.mall.pojo.search.eneity包下SpuForElastic

这个类有四个字段是具备分词功能的

所以支持我们使用这4个字段进行查询

/**
 * SPU名称
 */
@Field(name = "name",type = FieldType.Text,
        analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="SPU名称")
private String name;

//.....

/**
     * 标题
     */
@Field(name="title",type = FieldType.Text,
       analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="标题")
private String title;

/**
     * 简介
     */
@Field(name="description",type = FieldType.Text,
       analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="简介")
private String description;

//.....

/**
 * 类别名称(冗余)
 */
@Field(name="category_name",type = FieldType.Text,
       analyzer = "ik_max_word",searchAnalyzer = "ik_max_word")
@ApiModelProperty(value="类别名称(冗余)")
private String categoryName;

//.....

开发ES的持久层

我们仍然使用SpringDataElasticsearch框架来操作ES

按照SpringData的规范,我们创建包repository

在这个包中创建SpuForElasticRepository接口,代码如下

// SpuForElastic实体类操作ES的持久层接口
// 继承的父接口ElasticsearchRepository,对应的实体类基本的增删改查方法
@Repository
public interface SpuForElasticRepository extends
                            ElasticsearchRepository<SpuForElastic,Long> {

}

这个接口提供了批量新增数据到ES的方法

但是要想获得数据库中的所有pms_spu表的数据,必须连接数据库查询这些数据

但是search模块是负责管理ES的,所以需要Dubbo调用Product模块获取所有数据

product模块提供的查询功能

我们需要使用Dubbo调用product的业务逻辑层获得数据库pms_spu表的数据

经过观察发现业务逻辑逻辑层调用ForFrontSpuServiceImpl类中

具有一个getSpuByPage的方法

他分页查询所有spu信息

@Override
public JsonPage<Spu> getSpuByPage(Integer pageNum, Integer pageSize) {
    PageHelper.startPage(pageNum,pageSize);
    List<Spu> list=spuMapper.findAllList();
    return JsonPage.restPage(new PageInfo<>(list));
}

search模块执行加载

mall-search-webapi模块创建service.impl包

包中创建SearchServiceImpl类,用于将数据库中的数据加载到ES中

代码如下

@Service
@Slf4j
public class SearchServiceImpl implements ISearchService {
​
    // dubbo调用product模块查询所有spu的方法
    @DubboReference
    private IForFrontSpuService dubboSpuService;
    // 操作ES的接口
    @Autowired
    private SpuForElasticRepository spuRepository;
​
    @Override
    public void loadSpuByPage() {
        // 这个方法需要循环调用分页查询spu数据的方法
        // 每次循环查询出一页数据,然后新增到ES中
        // 直到把所有数据新增到ES中为止,但是我们要确定总页数,才能知道循环次数
        // 我们需要先运行一次查询,获取返回的JsonPage对象,才知道总页数,所以是先执行后判断,推荐do-while循环
        int i=1; // 循环变量,从1开始,可以直接当做页码使用
        int page; // 总页数,也是循环次数条件,先声明,后面循环中赋值
        //do-while循环结构
        do{
            // dubbo调用查询spu表中数据
            JsonPage<Spu> spus=dubboSpuService.getSpuByPage(i,2);
            // 实例化一个集合,泛型类型是SpuForElastic
            List<SpuForElastic> esSpus=new ArrayList<>();
            // 遍历查询出的spus集合,把其中的元素转换为SpuForElastic类型添加到esSpus
            for(Spu spu : spus.getList()){
                SpuForElastic esSpu=new SpuForElastic();
                BeanUtils.copyProperties(spu,esSpu);
                // 添加到esSpus集合中
                esSpus.add(esSpu);
            }
            // esSpus集合中包含了本页的数据,下面新增到Es中
            spuRepository.saveAll(esSpus);
            log.info("成功加载了第{}页数据",i);
            // 为下次循环做准备
            i++;
            // 给page赋值
            page=spus.getTotalPage();
​
        }while(i<=page);
    }
​
    @Override
    public JsonPage<SpuForElastic> search(String keyword, Integer page, Integer pageSize) {
        return null;
    }
​
​
}

创建测试类运行上面的业务逻辑层方法即可

// 下面注解必须加!!!!
@SpringBootTest
public class SpuElasticTest {

    @Autowired
    private ISearchService searchService;
    @Test
    void loadData(){
        searchService.loadSpuByPage();
        System.out.println("ok");
    }
}

运行测试前保证

Nacos\Seata\ES启动

启动product模块

运行测试,没有报错即可

验证ES中的数据

我们再通过连接ES来进行全查

检验上面执行的加载工作是否达到效果

仍然在测试类中,在编写一个方法,使用SpringData提供的全查方法查询后遍历输出

检查输出内容,代码如下

@Autowired
private SpuForElasticRepository spuRepository;
@Test
void showData(){
    Iterable<SpuForElastic> spus=spuRepository.findAll();
    spus.forEach(spu -> System.out.println(spu));
}

搜索功能的实现

电商网站一定会有按用户输入的关键字进行搜索的功能

这样的搜索都是搜索ES查询到的结果

上面章节中,我们已经将所有spu信息保存到了ES中

下面通过查询逻辑将搜索结果显示出来

编写SpringData自定义查询

如果我们按照关键字"手机"进行搜索

可以在Repository接口中编写自定义方法搜索title字段包含"手机"的数据

@Repository
public interface SpuForElasticRepository extends
                            ElasticsearchRepository<SpuForElastic,Long> {

    // 查询title字段中包含指定关键字的spu数据
    Iterable<SpuForElastic> querySpuForElasticsByTitleMatches(String title);
    
    
}

上面的查询可以通过测试类测试

@Test
void showTitle(){
    Iterable<SpuForElastic> spus=
            repository.querySpuForElasticsByTitleMatches("手机");
    spus.forEach(spu -> System.out.println(spu));
}

尤其需要关注ES是否已经启动

不需要其它项目的支持,直接运行测试即可


我们业务中需要4个字段的条件查询,是可以通过方法名称的编写实现的

SpringData也支持我们在代码中编写查询语句,以避免过长的方法名

@Query("{\n" +
            "    \"bool\": {\n" +
            "      \"should\": [\n" +
            "        { \"match\": { \"name\":  \"?0\" }},\n" +
            "        { \"match\": { \"title\": \"?0\"}},\n" +
            "        { \"match\": { \"description\": \"?0\"}},\n" +
            "        { \"match\": { \"category_name\": \"?0\"}}\n" +
            "      ]\n" +
            "    }" +
            "}")
// 上面使用了指定搜索语句的方式来进行查询,下面的方法名就可以随意定义了
Iterable<SpuForElastic> querySearch(String keyword);

测试代码

@Test
void showQuery(){
    // 自定义查询4个字段包含指定关键字的方法
    Iterable<SpuForElastic> spus=spuRepository.querySearch("手机");
    spus.forEach(spu -> System.out.println(spu));
}

续搜索功能的实现

上次课完成了根据关键字进行搜索的效果

实际运行查询的逻辑是需要分页的

所以要按照SpringData支持的分页查询格式修改上面的查询代码

@Query("{\n" +
        "    \"bool\": {\n" +
        "      \"should\": [\n" +
        "        { \"match\": { \"name\":  \"?0\" }},\n" +
        "        { \"match\": { \"title\": \"?0\"}},\n" +
        "        { \"match\": { \"description\": \"?0\"}},\n" +
        "        { \"match\": { \"category_name\": \"?0\"}}\n" +
        "      ]\n" +
        "    }" +
        "}")
// 上面使用了指定搜索语句的方式来进行查询,下面的方法名就可以随意定义了
//↓↓↓                                           ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
Page<SpuForElastic> querySearch(String keyword, Pageable pageable);

修改了方法的定义,原有的调用会报错,注释掉测试中的调用代码即可!

@Test
void showQuery(){
    // 查询四个字段中包含指定关键字的方法
    //Iterable<SpuForElastic> spus=repository.querySearch("手机");
    Page spus=repository.querySearch("手机", PageRequest.of(0,2));
    spus.forEach(spu-> System.out.println(spu));
}

在实际开发中

我们数据库中的数据和Elasticsearch中的数据还存在同步问题

为了保持数据库中的数据和Elasticsearch中的数据一致

我们可以使用下面的办法

1.在所有对spu表进行增删改的操作代码运行后,也对ES中的数据进行相同的操作

但是会有比较多的代码要编写,而且有比较明显的事务处理问题

2.实际上业界使用Elasticsearch有一个组合叫ELK,其中L(logstash)可以实现自动同步数据库和ES的信息

后面学习过程中,我们会在Linux虚拟机的学习中使用它

开发搜索功能的业务逻辑层

SearchServiceImpl类添加实现方法如下

// 根据用户输入的关键字进行分页搜索的方法
@Override
public JsonPage<SpuForElastic> search(String keyword, Integer page, Integer pageSize) {
    // 根据参数中的分页数据进行分页查询,注意SpringData=页码从0开始
    Page<SpuForElastic> spus=spuRepository.querySearch(
                            keyword, PageRequest.of(page-1,pageSize));
    // 分页查询调用结束,返回Page对象,我们要转换为JsonPage对象返回
    JsonPage<SpuForElastic> jsonPage=new JsonPage<>();
    // 赋值相关数据
    jsonPage.setPage(page);
    jsonPage.setPageSize(pageSize);
    jsonPage.setTotalPage(spus.getTotalPages());
    jsonPage.setTotal(spus.getTotalElements());
    jsonPage.setList(spus.getContent());
    // 最后返回!!!
    return jsonPage;
}

开发控制层代码

创建controller包

包中创建SearchController编写搜索方法,代码如下

@RestController
@RequestMapping("/search")
@Api(tags = "搜索模块")
public class SearchController {

    @Autowired
    private ISearchService searchService;

    // @GetMapping后如果什么都不写,就是直接使用类上声明的路径
    // 例如访问下面方法的url就是 localhost:10008/search
    // 这样路径可以尽量的简短
    @GetMapping
    @ApiOperation("根据用户输入的关键字分页搜索商品信息")
    @ApiImplicitParams({
            @ApiImplicitParam(value = "搜索关键字",name="keyword",example = "手机"),
            @ApiImplicitParam(value = "页码",name="page",example = "1"),
            @ApiImplicitParam(value = "每页条数",name="pageSize",example = "2")
    })
    public JsonResult<JsonPage<SpuForElastic>> searchByKeyword(
            String keyword, Integer page, Integer pageSize){
        JsonPage<SpuForElastic> jsonPage=
                searchService.search(keyword, page, pageSize);
        return JsonResult.ok(jsonPage);
    }


}

保证Nacos\seata\ES启动

因为当前search项目过滤器解析JWT所以需要登录才能访问

启动search模块

建议启动passport模块去进行登录获得jwt

复制JWT后,粘贴到10008模块的全局参数,再测试运行search模块

测试路径http://localhost:10008/doc.html

Quartz

什么是Quartz

quartz:石英钟的意思

是一个当今市面上流行的高效的任务调度管理工具

所谓"调度"就是制定好的什么时间做什么事情的计划

由OpenSymphony开源组织开发

Symphony:交响乐

是java编写的,我们使用时需要导入依赖即可

为什么需要Quartz

所谓"调度"就是制定好的什么时间做什么事情的计划

我们使用过的最简单的调度方法就是Timer

但是Timer的调度功能过于单一,只能是指定时间的延时调用和周期运行

而Quartz可以更详细的指定时间,进行计划调用

Quartz核心组件

调度器:Scheduler

任务:job

触发器:Trigger

调度器来配置\计划什么时间触发什么任务

简单来说就是调度器规定什么时间做什么事情

  • job(工作\任务):Quartz 实现过程中是一个接口,接口中有一个方法execute(执行的意思)

我们创建一个类,实现这个接口,在方法中编写要进行的操作(执行具体任务)

我们还需要一个JobDetail的类型的对象,Quartz每次执行job时

会实例化job类型对象,去调用这个方法,JobDetail是用来描述Job实现类的静态信息,

比如任务运行时在Quartz中的名称

  • Trigger(触发器):能够描述触发指定job的规则,分为简单触发和复杂触发

    简单触发可以使用SimplTrigger实现类.功能类似timer

    复杂触发可以使用CronTrigger实现类,内部利用cron表达式描述各种复杂的时间调度计划

  • Scheduler(调度器):一个可以规定哪个触发器绑定哪个job的容器

    在调度器中保存全部的Quartz 保存的任务

    SpringBoot框架下,添加Quartz依赖后,调度器由SpringBoot管理,我们不需要编写

Cron表达式

Cron表达式是能够制定触发时间的一个格式

表示2023年2月3日凌晨4点触发的cron表达式

0 0 4 3 2 ? 2023

  • * 表示任何值,如果在分的字段上编写*,表示每分钟都会触发

  • , 是个分割符如果秒字段我想20秒和40秒时触发两次就写 20,40

  • - 表示一个区间 秒字段5-10 表示 5,6,7,8,9,10

  • / 表示递增触发 秒字段 5/10表示5秒开始每隔10秒触发一次

    日字段编写1/3表示从每月1日起每隔3天触发一次

  • ? 表示不确定值, 因为我们在定日期时,一般确定日期就不确定是周几,相反确定周几时就不确定日期

  • L 表示last最后的意思,我们可以设置当月的最后一天,就会在日字段用L表示,

    周字段使用L表示本月的最后一个周几,一般会和1-7的数字组合

    例如6L表示本月的最后一个周五

  • W (work)表示最近的工作日(单纯的周一到周五) 如果日字段编写15W表示

    每月15日最近的工作日触发,如果15日是周六就14日触发,如果15日是周日就16日触发

    LW通常一起使用,表示本月的最后一个工作日

  • # 表示第几个,只能使用在周字段上 6#3表示每月的第三个周五

    如果#后面数字写大了,是一个不存在的日期,那就不运行了

    适合设计在母亲节或父亲节这样的日期运行

推荐一个Cron - 在线Cron表达式生成器

每年的母亲节(5月份第三个周日)早上9点触发

0 0 9 ? 5 1#3

每个月10日发工资如果10日是休息日,就在最近的工作日发,早上9点触发

0 0 9 10W * ?

每年双11运行 上午11点运行

0 0 11 11 11 ?

SpringBoot使用Quartz

SpringBoot框架下使用Quartz格式还是非常固定的

我们选用之前学习微服务的项目csmall减少对大项目的影响

首先添加依赖

我们选择csmall-stock-webapi模块pom文件

<!-- Spring Boot 整合 Quartz的依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

先编写要执行的任务

当前项目模块中创建quartz包

包中创建一个QuartzJob的类,实现Job接口

代码如下

@Slf4j
public class QuartzJob implements Job {

    // 这个方法就是当前job要定时执行的任务方法
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 因为当前是简单演示功能,所以就输出一个当前时间即可
        log.info("-----------------"+ LocalDateTime.now() +"-------------------");
    }
}

上面编写的是Job接口的实现类,要想运行还需要将它封装为JobDetail对象保存在Spring容器中

还有要创建一个Trigger设置要运行的时机,也保存到Spring容器中

在quartz包下,再创建一个QuartzConfig类,其中编写它们的调度绑定关系

这个格式是固定的,后面再需要绑定,直接套用即可

// QuartzConfig类用于配置绑定调用的方法和触发的关系
// 这个触发实际上会由Spring容器中的Scheduler对象调度
// 下面开始配置,Spring所有配置类都需要添加@Configuration
@Configuration
public class QuartzConfig {

    // 配置本身的目的就是将Job对象和Trigger对象保存到Spring容器中让Scheduler来调度
    // 可以利用@Bean注解,将上述两个对象保存Spring容器中
    @Bean
    public JobDetail showTime(){
        // JobDetail对象配置时,需要将欲触发的Job接口实现类配置出来
        // JobBuilder.newJob方法是可以创建JobDetail对象的,方法参数就是Job接口实习类的反射
        // 后面触发这个方法时,每次触发都会实例化一个新的QuartzJob对象运行
        return JobBuilder.newJob(QuartzJob.class)
                // 给当前任务起名,不要和其它任务名称重复即可
                .withIdentity("dateTime")
                // 默认情况下JobDetail对象生成后,如果没有触发器绑定就会自动移除
                // 在实际运行时,JobDetail实例化出来,没有绑定就被移除了
                // 设置storeDurably方法之后,JobDetail即使没有被绑定,也不会被删除了
                .storeDurably()
                .build();
    }

    // 要想实现"什么时间做什么事情"还需要配置时间的计划
    // 下面开始配置Trigger触发器的对象,绑定上面的JobDetail
    @Bean
    public Trigger showTimeTrigger(){
        // 声明Corn表达式,定义触发时间
        CronScheduleBuilder cron=
                CronScheduleBuilder.cronSchedule("0 38 14 2 2 ?");
        return TriggerBuilder.newTrigger()
                // 设置要绑定的JobDetail对象
                .forJob(showTime())
                // 当前触发器也要起名字,名字也是不要重复出现的
                .withIdentity("dateTimeTrigger")
                // 绑定cron表达式
                .withSchedule(cron)
                .build();
    }


}

Nacos\Seata需要启动

其他服务和软件都可以关闭

然后启动csmall-stock-webapi模块

观察控制台输出

可以尝试设计每隔10每秒运行一次的cron表达式

Redis 强化

缓存使用原则

什么时候,什么样的数据能够保存在Redis中?

1.数据量不能太大

2.使用越频繁,Redis保存这个数据越值得

3.保存在Redis中的数据一般不会是数据库中频繁修改的

缓存淘汰策略

Redis将数据保存在内存中, 内存的容量是有限的

如果Redis服务器的内存已经全满,现在还需要向Redis中保存新的数据,如何操作,就是缓存淘汰策略

  • noeviction:返回错误(默认)

如果我们不想让它发生错误,就可以设置它将满足某些条件的信息删除后,再将新的信息保存

  • allkeys-random:所有数据中随机删除数据

  • volatile-random:有过期时间的数据中随机删除数据

  • volatile-ttl:删除剩余有效时间最少的数据

  • allkeys-lru:所有数据中删除上次使用时间距离现在最久的数据

  • volatile-lru:有过期时间的数据中删除上次使用时间距离现在最久的数据

  • allkeys-lfu:所有数据中删除使用频率最少的

  • volatile-lfu:有过期时间的数据中删除使用频率最少的

Time To Live (ttl)

Least Recently Used (lru)

Least Frequently Used (lfu)

缓存穿透

所谓缓存穿透,就是一个业务请求先查询redis,redis没有这个数据,那么就去查询数据库,但是数据库也没有的情况

正常业务下,一个请求查询到数据后,我们可以将这个数据保存在Redis

之后的请求都可以直接从Redis查询,就不需要再连接数据库了

但是一旦发生上面的穿透现象,仍然需要连接数据库,一旦连接数据库,项目的整体效率就会被影响

如果有恶意的请求,高并发的访问数据库中不存在的数据,严重的,当前服务器可能出现宕机的情况

解决方案:业界主流解决方案:布隆过滤器

布隆过滤器的使用步骤

1.针对现有所有数据,生成布隆过滤器,保存在Redis中

2.在业务逻辑层,判断Redis之前先检查这个id是否在布隆过滤器中

3.如果布隆过滤器判断这个id不存在,直接返回

4.如果布隆过滤器判断id存在,在进行后面业务执行

缓存击穿

一个计划在Redis保存的数据,业务查询,查询到的数据Redis中没有,但是数据库中有

这种情况要从数据库中查询后再保存到Redis,这就是缓存击穿

但是这个情况也不是异常情况,因为我们大多数数据都需要设置过期时间,而过期时间到时,这个数据就会从Redis中移除,再有请求查询这个数据,就一定会从数据库中再次同步

缓存击穿本身并不是灾难性的问题,也不是不允许发生的现象

缓存雪崩

上面讲到击穿现象

同一时间发生少量击穿是正常的

但是如果出现同一时间大量击穿现象就会如下图

所谓缓存雪崩,指的就是Redis中保存的数据,短时间内有大量数据同时到期的情况

如上图所示,本应该由Redis反馈的信息,由于雪崩都去访问了Mysql,mysql承担不了,非常可能导致异常

要想避免这种情况,就需要避免大量缓存同时失效

大量缓存同时失效的原因:通常是同时加载的数据设置了相同的有效期导致的

我们可以通过在设置有效期时添加一个随机数,这样就能够防止大量数据同时失效了

Redis持久化

Redis将信息保存在内存

内存的特征就是一旦断电,所有信息都丢失,对于Redis来讲,所有数据丢失后,再重新加载数据,就需要从数据库重新查询所有数据,这个操作不但耗费时间,而且对数据库的压力也非常大

而且有些业务是先将数据保存在Redis,隔一段时间和数据库同步的

如果Redis断电,这段时间的数据就完全丢失了!

为了防止Redis的重启对数据库带来额外的压力和数据的丢失,Redis支持了持久化的功能

所谓持久化就是将Redis中保存的数据,以指定方式保存在Redis当前服务器的硬盘上

如果存在硬盘上,那么断电数据也不会丢失,再启动Redis时,利用硬盘中的信息来恢复数据

Redis实现持久化有两种策略

RDB:(Redis Database Backup)

RDB本质上就是数据库快照(就是当前Redis中所有数据转换成二进制的对象,保存在硬盘上)

默认情况下,每次备份会生成一个dump.rdb的文件

当Redis断电或宕机后,重新启动时,会从这个文件中恢复数据,获得dump.rdb中所有内容

实现这个效果我们可以在Redis的配置文件中添加如下信息

save 60 5

上面配置中60表示秒

5表示Redis的key被更新的次数

配置效果:1分钟内如果有5个及以上的key被更新,就启动rdb数据库快照程序

优点:

  • 因为是整体Redis数据的二进制格式,数据恢复是整体恢复的

缺点:

  • 生成的rdb文件是一个硬盘上的文件,读写效率是较低的

  • 如果突然断电,只能恢复到最后一次生成的rdb中的数据

AOF:(Append Only File)

AOF策略是将Redis运行过的所有命令(日志)备份下来,保存在硬盘上

这样即使Redis断电,我们也可以根据运行过的日志,恢复为断电前的样子

我们可以在Redis的配置文件中添加如下配置信息

appendonly yes

经过这个设置,就能保存运行过的指令的日志了

理论上任何运行过的指令都可以恢复

但是实际情况下,Redis非常繁忙时,我们会将日志命令缓存之后,整体发送给备份,减少io次数以提高备份的性能 和对Redis性能的影响

实际开发中,配置一般会采用每秒将日志文件发送一次的策略,断电最多丢失1秒数据

优点:

相对RDB来讲,信息丢失的较少

缺点:

因为保存的是运行的日志,所以占用空间较大

实际开发中RDB和AOF是可以同时开启的,也可以选择性开启

Redis的AOF为减少日志文件的大小,支持AOF rewrite

简单来说就是将日志中无效的语句删除,能够减少占用的空间

Redis存储原理

我们在编写java代码业务时,如果需要从多个元素的集合中寻找某个元素取出,或检查某个Key在不在的时候,推荐我们使用HashMap或HashSet,因为这种数据结构的查询效率最高,因为它内部使用了

"散列表"

槽位越多代表元素多的时候,查询性能越高,HashMap默认16个槽

Redis底层保存数据用的也是这样的散列表的结构

Redis将内存划分为16384个区域(类似hash槽)

将数据的key使用CRC16算法计算出一个值,取余16384

得到的结果是0~16383

这样Redis就能非常高效的查找元素了

Redis集群

Redis最小状态是一台服务器

这个服务器的运行状态,直接决定Redis是否可用

如果它离线了,整个项目就会无Redis可用

系统会面临崩溃

为了防止这种情况的发生,我们可以准备一台备用机

主从复制

也就是主机(master)工作时,安排一台备用机(slave)实时同步数据,万一主机宕机,我们可以切换到备机运行

缺点,这样的方案,slave节点没有任何实质作用,只要master不宕机它就和没有一样,没有体现价值

读写分离

这样slave在master正常工作时也能分担Master的工作了

但是如果master宕机,实际上主备机的切换,实际上还是需要人工介入的,这还是需要时间的

那么如果想实现发生故障时自动切换,一定是有配置好的固定策略的

哨兵模式:故障自动切换

哨兵节点每隔固定时间向所有节点发送请求

如果正常响应认为该节点正常

如果没有响应,认为该节点出现问题,哨兵能自动切换主备机

如果主机master下线,自动切换到备机运行

但是如果哨兵判断节点状态时发生了误判,那么就会错误将master下线,降低整体运行性能

所以要减少哨兵误判的可能性

哨兵集群

我们可以将哨兵节点做成集群,由多个哨兵投票决定是否下线某一个节点

哨兵集群中,每个节点都会定时向master和slave发送ping请求

如果ping请求有2个(集群的半数节点)以上的哨兵节点没有收到正常响应,会认为该节点下线

当业务不断扩展,并发不断增高时

分片集群

只有一个节点支持写操作无法满足整体性能要求时,系统性能就会到达瓶颈

这时我们就要部署多个支持写操作的节点,进行分片,来提高程序整体性能

分片就是每个节点负责不同的区域

Redis0~16383号槽,

例如

MasterA负责0~5000

MasterB负责5001~10000

MasterC负责10001~16383

一个key根据CRC16算法只能得到固定的结果,一定在指定的服务器上找到数据

有了这个集群结构,我们就能更加稳定和更加高效的处理业务请求了

为了节省哨兵服务器的成本,有些公司在Redis集群中直接添加哨兵功能,既master/slave节点完成数据读写任务的同时也都互相检测它们的健康状态

有额外精力的同学,可以自己搜索Redis分布式锁的解决方案(redission)

秒杀业务准备

秒杀产品分析:秒杀产品分析| ProcessOn免费在线作图,在线流程图,在线思维导图

准备工作概述

学习秒杀的目的是让同学们了解高并发在微服务项目中的处理流程

指定一些基本的高并发处理标准动作

酷鲨商城定时秒杀业务就是一个模拟高并发的业务场景

每秒请求数8000

并发500~800

网站在线用户20000(到30000)

日活跃用户50000(到80000)

学习完秒杀业务,我们能具备处理一般高并发业务的基本逻辑

开发查询秒杀商品列表功能

秒杀模块是mall-seckill,这个模块操作的数据库是mall-seckill

数据库中包含秒杀spu信息(seckill_spu)和秒杀sku信息(seckill_sku)以及秒杀成功记录(success)

首先我们先将秒杀列表的功能开发

开发持久层

mall-seckill-webapi项目

创建mapper包,创建SeckillSpuMapper,代码如下

@Repository
public interface SeckillSpuMapper {

    // 查询秒杀商品列表的方法
    List<SeckillSpu> findSeckillSpus();
}

SeckillSpuMapper.xml编写对应查询

<!--  秒杀spu表的sql片段  -->
<sql id="SimpleFields">
    <if test="true">
        id,
        spu_id,
        list_price,
        start_time,
        end_time,
        gmt_create,
        gmt_modified
    </if>
</sql>
<!--  查询秒杀商品列表的方法 -->
<select id="findSeckillSpus" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_spu
</select>

开发业务逻辑层

创建包service.impl

包中创建SeckillSpuServiceImpl实现ISeckillSpuService

代码如下

@Service
@Slf4j
public class SeckillSpuServiceImpl implements ISeckillSpuService {

    // 装配查询秒杀表信息的Mapper
    @Autowired
    private SeckillSpuMapper seckillSpuMapper;
    // 秒杀列表信息中,返回值为SeckillSpuVO,这个类型包含了商品的常规信息和秒杀信息
    // 我们需要通过product模块才能查询pms数据库的商品常规信息,所有要Dubbo调用
    @DubboReference
    private IForSeckillSpuService dubboSeckillSpuService;

    // 分页查询秒杀商品列表
    // 返回值泛型:SeckillSpuVO,这个类型包含了商品的常规信息和秒杀信息
    @Override
    public JsonPage<SeckillSpuVO> listSeckillSpus(Integer page, Integer pageSize) {
        // 设置分页条件
        PageHelper.startPage(page,pageSize);
        // 执行查询
        List<SeckillSpu> seckillSpus=seckillSpuMapper.findSeckillSpus();
        // 先声明匹配返回值类型的泛型集合,以用于最后的返回
        List<SeckillSpuVO> seckillSpuVOs=new ArrayList<>();
        // 遍历seckillSpus(没有常规信息的集合)
        for(SeckillSpu seckillSpu : seckillSpus){
            // 取出当前商品的spuId
            Long spuId=seckillSpu.getSpuId();
            // 利用dubbo根据spuId查询商品常规信息
            SpuStandardVO standardVO = dubboSeckillSpuService.getSpuById(spuId);
            // 秒杀信息在seckillSpu对象中,常规信息在standardVO对象里
            // 先实例化SeckillSpuVO,然后向它赋值常规信息和秒杀信息
            SeckillSpuVO seckillSpuVO=new SeckillSpuVO();
            // 将常规信息中同名属性赋值到seckillSpuVO
            BeanUtils.copyProperties(standardVO,seckillSpuVO);
            // 秒杀信息单独手动赋值即可
            seckillSpuVO.setSeckillListPrice(seckillSpu.getListPrice());
            seckillSpuVO.setStartTime(seckillSpu.getStartTime());
            seckillSpuVO.setEndTime(seckillSpu.getEndTime());
            // 到此为止seckillSpuVO就赋值完成了
            // 将它添加到返回值的集合中
            seckillSpuVOs.add(seckillSpuVO);
        }
        // 最后别忘了返回
        return JsonPage.restPage(new PageInfo<>(seckillSpuVOs));
    }

    @Override
    public SeckillSpuVO getSeckillSpu(Long spuId) {
        return null;
    }

    @Override
    public SeckillSpuDetailSimpleVO getSeckillSpuDetail(Long spuId) {
        return null;
    }
}

开发秒杀商品列表的控制层

创建controller包

创建SeckillSpuController类

代码如下

@RestController
@RequestMapping("/seckill/spu")
@Api(tags = "秒杀Spu模块")
public class SeckillSpuController {

    @Autowired
    private ISeckillSpuService seckillSpuService;

    @GetMapping("/list")
    @ApiOperation("分页查询秒杀spu商品列表")
    @ApiImplicitParams({
            @ApiImplicitParam(value = "页码", name="page",example = "1"),
            @ApiImplicitParam(value = "每页条数", name="pageSize",example = "10")
    })
    public JsonResult<JsonPage<SeckillSpuVO>> listSeckillSpus(
            Integer page,Integer pageSize){
        JsonPage<SeckillSpuVO> jsonPage=seckillSpuService
                .listSeckillSpus(page,pageSize);
        return JsonResult.ok(jsonPage);
    }


}

启动服务

Nacos\Seata\Redis

启动我们的项目

product\passport\seckill

测试端口10007

开发根据SpuId查询秒杀Sku列表信息

我们将秒杀的商品Spu列表查询出来

当用户选择一个商品时

我们要将这个商品的sku也查询出来

也就是根据SpuId查询Sku的列表

创建SeckillSkuMapper

@Repository
public interface SeckillSkuMapper {
    // 根据spuId查询秒杀sku列表
    List<SeckillSku> findSeckillSkusBySpuId(Long spuId);
}

SeckillSkuMapper.xml文件添加内容

<sql id="SimpleFields">
    <if test="true">
        id,
        sku_id,
        spu_id,
        seckill_stock,
        seckill_price,
        gmt_create,
        gmt_modified,
        seckill_limit
    </if>
</sql>
<!-- 根据spuId查询秒杀sku列表 -->
<select id="findSeckillSkusBySpuId" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_sku
    where
        spu_id=#{spuId}
</select>

根据当前时间查询正在进行秒杀的商品

根据给定时间查询出正在进行秒杀的商品列表

在秒杀过程中,一定会将当前时间正在进行秒杀商品查询出来的

首先保证数据库中的seckill_spu表的数据正在秒杀时间段(检查数据,如果不在秒杀时间段,将结束时间后移如2024年)

SeckillSpuMapper添加方法

// 根据给定时间,查询正在进行秒杀的商品
List<SeckillSpu> findSeckillSpusByTime(LocalDateTime time);

SeckillSpuMapper.xml

<!-- 根据给定时间,查询正在进行秒杀的商品  -->
<select id="findSeckillSpusByTime" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_spu
    where
        start_time &lt; #{time}
    and
        end_time &gt; #{time}
</select>

根据SpuId查询秒杀商品信息

SeckillSpuMapper接口添加方法

// 根据spuId查询秒杀spu信息
SeckillSpu findSeckillSpuById(Long spuId);

SeckillSpuMapper.xml添加内容

<!--  根据spuId查询秒杀spu信息 -->
<select id="findSeckillSpuById" resultMap="BaseResultMap">
    select
        <include refid="SimpleFields" />
    from
        seckill_spu
    where
        spu_id=#{spuId}
</select>

查询所有秒杀商品的SpuId

这个查询是为了后面布隆过滤器加载数据库中包含的所有SpuId时使用

因为布隆过滤器的特性,只需要查询出所有商品的spu_id即可

SeckillSpuMapper接口添加方法

// 查询秒杀表中所有商品的spuId,由于后面保存到布隆过滤器防止缓存穿透
Long[] findAllSeckillSpuIds();

SeckillSpuMapper.xml添加内容

<!-- 查询秒杀表中所有商品的spuId,由于后面保存到布隆过滤器防止缓存穿透  -->
<!--                              ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  -->
<select id="findAllSeckillSpuIds" resultType="long">
    select spu_id from seckill_spu
</select>

缓存预热思路

在即将发生高并发业务之前,我们将一些高并发业务中需要的数据保存到Redis中,这种操作,就是"缓存预热",这样发生高并发时,这些数据就可以直接从Redis中获得,无需查询数据库了

我们要利用Quartz定时的将每个批次的秒杀商品,预热到Redis

例如每天的12:00 14:00 16:00 18:00进行秒杀

那么就在 11:55 13:55 15:55 17:55 进行预热

我们预热的内容有

  1. 我们预热的内容是将参与秒杀商品的sku查询出来,根据skuid将该商品的库存保存在Redis中

    还要注意为了预防雪崩,在向Redis保存数据时,都应该添加随机数

  2. (待完善).在秒杀开始前,生成布隆过滤器,访问时先判断布隆过滤器,如果判断商品存在,再继续访问

  3. 在秒杀开始之前,生成每个商品对应的随机码,保存在Redis中,随机码可以绑定给Spu,保存在前端页面,用户提交时,验证随机码的正确性,只有正确的随机码才能购买商品

设置定时任务

将库存和随机码保存到Redis

1.创建Job接口实现类

2.创建配置类,配置JobDetail和Trigger

在seckill包下创建timer.job包

在seckill包下创建timer.config包

首先我们编写缓存预热的操作,在job包下创建类SeckillInitialJob

@Slf4j
public class SeckillInitialJob implements Job {

    // 查询spu相关信息的mapper
    @Autowired
    private SeckillSpuMapper spuMapper;
    // 查询sku相关信息的mapper
    @Autowired
    private SeckillSkuMapper skuMapper;
    // 操作Redis的对象
    @Autowired
    private RedisTemplate redisTemplate;

    /*
    RedisTemplate对象在保存数据到Redis时,会将数据进行序列化后保存
    这样做,对java对象或类似的数据再Redis中的读写效率是高的,但缺点是不能再redis中对数据进行修改
    要想修改,必须从redis中获取后修改属性,在添加\覆盖到Redis中,这样的操作在多线程时就容易产生线程安全问题
    我们现在保存的库存数,如果也用redisTemplate保存,高并发时就会产生超卖
    解决办法是操作一个能够直接在Redis中对数据进行修改的对象,来保存它的库存数,防止超卖

    SpringDataRedis提供了StringRedisTemplate类型,它可以直接操作Redis中的字符串值
    使用StringRedisTemplate向Redis保存数据,可以直接保存字符串,没有序列化过程的
    它支持使用java代码直接向redis发送修改库存数值的方法,适合当下管理库存的业务需求
    最后结合Redis操作数据是单线程的特征,避免线程安全问题,防止超卖

     */
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // 在秒杀开始前5分钟,进行秒杀信息的预热工作,将秒杀过程中的热点数据保存到Redis
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 先将5分钟后要开始进行秒杀的商品信息查询出来,所以要先获得一个5分钟后的时间对象
        LocalDateTime time=LocalDateTime.now().plusMinutes(5);
        // 查询这个时间进行秒杀的商品列表
        List<SeckillSpu> seckillSpus = spuMapper.findSeckillSpusByTime(time);
        // 遍历查询出的所有商品集合
        for (SeckillSpu spu : seckillSpus){
            // 要预热当前spu对应的所有秒杀sku中的库存数到Redis
            // 所以要根据spuId查询出它对应的sku列表
            List<SeckillSku> seckillSkus =
                            skuMapper.findSeckillSkusBySpuId(spu.getSpuId());
            // 再遍历seckillSkus集合,获取库存数
            for(SeckillSku sku: seckillSkus){
                log.info("开始将{}号sku商品的库存数预热到redis",sku.getSkuId());
                // 要想将库存数保存到Redis,先确定使用的key
                // SeckillCacheUtils.getStockKey是获取库存字符串常量的方法
                // 方法参数传入sku.getSkuId(),会追加在字符串常量之后
                // 最终skuStockKey的实际值可能是:   mall:seckill:sku:stock:1
                String skuStockKey=SeckillCacheUtils.getStockKey(sku.getSkuId());
                // 获取了key之后,检查Redis中是否已经包含这个key
                if(redisTemplate.hasKey(skuStockKey)){
                    // 如果这个Key已经存在了,证明之前已经完成了缓存,直接跳过即可
                    log.info("{}号sku的库存数已经缓存过了",sku.getSkuId());
                }else{
                    stringRedisTemplate.boundValueOps(skuStockKey).set(
                            sku.getSeckillStock()+"",
                            // 秒杀时间+提前的5分钟+防雪崩随机数(30秒)
                            // 1000*60*60*2+1000*60*5+ RandomUtils.nextInt(30000),
                            1000*60*5+RandomUtils.nextInt(30000),
                            TimeUnit.MILLISECONDS);
                    log.info("{}号sku商品库存成功预热到Redis!",sku.getSkuId());
                }
            }
            // 在内层循环结束后,外层循环结束前,编写spu对应的随机码的预热
            // 随机码就是一个随机数,随机范围自定即可
            // 随机生成后保存到Redis中即可,使用方式后面会讲
            // 随机码key  mall:seckill:spu:url:rand:code:2
            String randCodeKey=SeckillCacheUtils.getRandCodeKey(spu.getSpuId());
            // 判断当前随机码key是否已经存在
            if (redisTemplate.hasKey(randCodeKey)){
                // 如果已经存在了,不需要任何其它操作
                // 为了方法今后的测试,我们取出这个随机码输出到控制台用于测试
                int randCode=(int)redisTemplate.boundValueOps(randCodeKey).get();
                log.info("{}号spu的商品随机码已经缓存了,值为:{}",spu.getSpuId(),randCode);
            }else{
                // 如果不存在,生成随机码
                // 生成的随机码这里范围定为1000000~9999999
                int randCode=RandomUtils.nextInt(9000000)+1000000;
                // 将生成的随机码保存到redis中
                redisTemplate.boundValueOps(randCodeKey).set(
                        randCode,
                        1000*60*5+RandomUtils.nextInt(30000),
                        TimeUnit.MILLISECONDS);
                log.info("{}号spu的随机码预热完成!值为:{}",spu.getSpuId(),randCode);
            }
        }
    }
}

RedisTemplate对象在保存数据到Redis时,会将数据进行序列化后保存 这样做,对java对象或类似的数据再Redis中的读写效率是高的,但缺点是不能再redis中对数据进行修改 要想修改,必须从redis中获取后修改属性,在添加\覆盖到Redis中,这样的操作在多线程时就容易产生线程安全问题 我们现在保存的库存数,如果也用redisTemplate保存,高并发时就会产生超卖 解决办法是操作一个能够直接在Redis中对数据进行修改的对象,来保存它的库存数,防止超卖

SpringDataRedis提供了StringRedisTemplate类型,它可以直接操作Redis中的字符串值 使用StringRedisTemplate向Redis保存数据,可以直接保存字符串,没有序列化过程的 它支持使用java代码直接向redis发送修改库存数值的方法,适合当下管理库存的业务需求 最后结合Redis操作数据是单线程的特征,避免线程安全问题,防止超卖

配置Quartz触发

上面的类中的代码只是编写了预热操作

我们需要在Quartz中配置才能触发生效

在time.config包中创建QuartzConfig类编写Job的触发

@Configuration
public class QuartzConfig {
    @Bean
    public JobDetail initJobDetail(){
        return JobBuilder.newJob(SeckillInitialJob.class)
                .withIdentity("initSeckill")
                .storeDurably()
                .build();
    }
    @Bean
    public Trigger initTrigger(){
        // 为了方便学习过程中的测试和运行的观察,cron表达式设置每分钟触发一次
        CronScheduleBuilder cron=
                CronScheduleBuilder.cronSchedule("0 0/1 * * * ?");
        return TriggerBuilder.newTrigger()
                .forJob(initJobDetail())
                .withIdentity("initTrigger")
                .withSchedule(cron)
                .build();
    }
}

启动Nacos\Redis\Seata

项目启动seckill

每分钟0秒时,观察日志输出状态

保证数据库中有数据在秒杀时间段内!!!!!!!

开发查询秒杀商品详情的功能

上面章节我们完成了缓存预热

下面要根据SpuId查询正在秒杀的商品

和普通的SpuId查询商品详情相比

它的业务判断更复杂

1.页面上显示秒杀价和剩余秒杀时间等信息

2.判断请求的spuId是否在布隆过滤器中(后续完成)

3.判断Redis 中是否包含商品信息

4.如果一切正常在返回详情信息前,要为url属性赋值,其实就是固定路径+随机码

根据SpuId查询秒杀商品详情

之前的章节已经完成了根据SpuId查询Spu信息的mapper

下面我们直接从业务逻辑层开始编写即可

SeckillSpuServiceImpl业务逻辑层实现类

// 操作Redis的对象
@Autowired
private RedisTemplate redisTemplate;
@Override
public SeckillSpuVO getSeckillSpu(Long spuId) {
    // 在后面完整版代码中,这里是要编写经过布隆过滤器判断的
    // 只有布隆过滤器中存在的id才能继续运行,否则发生异常
	
    // 当前方法的返回值SeckillSpuVO又是既包含秒杀信息又包含常规信息的对象
    // 目标是查询两方面的信息
    // 先判断Redis中是否已经有这个对象,先获取key
    // spuVOKey =  "mall:seckill:spu:vo:2"
    String spuVOKey= SeckillCacheUtils.getSeckillSpuVOKey(spuId);
    // 可以在判断前先声明返回值类型,赋值null即可
    SeckillSpuVO seckillSpuVO=null;
    // 判断 spuVOKey 是否已经在Redis中
    if(redisTemplate.hasKey(spuVOKey)){
        // 如果Redis已经存在这个Key,直接获取用于返回即可
        seckillSpuVO=(SeckillSpuVO) redisTemplate
                            .boundValueOps(spuVOKey).get();
    }else{
        // 如果Redis不存在这个Key,就需要从数据库查询了
        SeckillSpu seckillSpu=seckillSpuMapper.findSeckillSpuById(spuId);
        // 判断一下这个seckillSpu是否为null(因为布隆过滤器有误判)
        if(seckillSpu==null){
            throw new CoolSharkServiceException(
                    ResponseCode.NOT_FOUND,"您访问的商品不存在");
        }
        SpuStandardVO spuStandardVO =
                dubboSeckillSpuService.getSpuById(spuId);
        // 将秒杀信息和常规信息都赋值到seckillSpuVO对象
        // 要先实例化seckillSpuVO对象才能赋值,否则报空指针
        seckillSpuVO=new SeckillSpuVO();
        BeanUtils.copyProperties(spuStandardVO,seckillSpuVO);
        // 手动赋值秒杀信息
        seckillSpuVO.setSeckillListPrice(seckillSpu.getListPrice());
        seckillSpuVO.setStartTime(seckillSpu.getStartTime());
        seckillSpuVO.setEndTime(seckillSpu.getEndTime());
        // 将seckillSpuVO保存到Redis,以便后续请求直接从Redis中获取
        redisTemplate.boundValueOps(spuVOKey).set(
                seckillSpuVO,
                1000*60*5+ RandomUtils.nextInt(30000),
                TimeUnit.MILLISECONDS);
    }
    // 到此为止,seckillSpuVO对象一定是除url之外所有属性都被赋值了
    // url属性的作用是发送给前端后,前端使用它来向后端发起秒杀订单请求的
    // 所以我们给url赋值,就相当于允许用户购买当前商品的许可
    // 要求判断当前时间是否在允许的秒杀时间范围内
    // 获取当前时间
    LocalDateTime nowTime=LocalDateTime.now();
    // 因为再次连接数据库会消耗更多时间,高并发程序要避免不必要的数据库连接
    // 我们从seckillSpuVO对象中获取开始和结束时间进行判断即可
    if (seckillSpuVO.getStartTime().isBefore(nowTime) &&
            nowTime.isBefore(seckillSpuVO.getEndTime())){
        // 表示当前时间在秒杀时间段内,可以为url赋值
        // 要获取redis中预热的随机码
        String randCodeKey=SeckillCacheUtils.getRandCodeKey(spuId);
        // 判断随机码的key是否在redis中
        if(!redisTemplate.hasKey(randCodeKey)){
           // 如果不存在,直接抛异常
           throw new CoolSharkServiceException(
                   ResponseCode.NOT_FOUND,"当前随机码不存在");
        }
        // 获取随机码
        String randCode=redisTemplate.boundValueOps(randCodeKey).get()+"";
        // 将随机码赋值到url
        seckillSpuVO.setUrl("/seckill/"+randCode);
        log.info("被赋值的url为:{}",seckillSpuVO.getUrl());
    }
    // 千万别忘了返回seckillSpuVO!!!
    return seckillSpuVO;
}

开发控制层SeckillSpuController

// localhost:10007/seckill/spu/2
@GetMapping("/{spuId}")
@ApiOperation("根据spuId查询spu信息")
@ApiImplicitParam(value = "spuId",name="spuId",example = "2")
public JsonResult<SeckillSpuVO> getSeckillSpuVO( @PathVariable Long spuId){
    SeckillSpuVO seckillSpuVO=seckillSpuService.getSeckillSpu(spuId);
    return JsonResult.ok(seckillSpuVO);
}

启动Nacos\Redis\Seata

启动product\Seckill模块

要等当前随机码预热完成再发出测试请求,否则随机码不存在会报错

测试10007端口

可以观察一下有无缓存的时间区别

完成根据SpuId查询商品detail详情

业务逻辑层SeckillSpuServiceImpl类中编写新的方法

// 项目中没有定义SpuDetail的Key的常量,我们可以自己声明一个
public static final String SECKILL_SPU_DETAIL_PREFIX="seckill:spu:detail:";

// 根据spuId查询spuDetail
@Override
public SeckillSpuDetailSimpleVO getSeckillSpuDetail(Long spuId) {
    String spuDetailKey=SECKILL_SPU_DETAIL_PREFIX+spuId;
    // 声明一个返回值类型对象
    SeckillSpuDetailSimpleVO simpleVO=null;
    // 判断redis中是否包含这个key
    if(redisTemplate.hasKey(spuDetailKey)){
        // 如果redis中有这个key
        simpleVO=(SeckillSpuDetailSimpleVO)
                    redisTemplate.boundValueOps(spuDetailKey).get();
    }else{
        // 如果Redis中不存在这个key
        // 需要从数据库查询,利用dubbo查询product模块即可
        SpuDetailStandardVO spuDetailStandardVO =
                dubboSeckillSpuService.getSpuDetailById(spuId);
        // 实例化simpleVO对象
        simpleVO=new SeckillSpuDetailSimpleVO();
        BeanUtils.copyProperties(spuDetailStandardVO,simpleVO);
        // 保存到Redis中
        redisTemplate.boundValueOps(spuDetailKey).set(
                simpleVO,
                1000*60*5+RandomUtils.nextInt(30000),
                TimeUnit.MILLISECONDS);
    }
    // 返回simpleVO
    return simpleVO;
}

完成控制层代码

SeckillSpuController控制器添加方法如下

// localhost:10007/seckill/spu/2/detail
@GetMapping("/{spuId}/detail")
@ApiOperation("根据spuId查询spuDetail")
@ApiImplicitParam(value = "spuId",name="spuId",example = "2")
public JsonResult<SeckillSpuDetailSimpleVO> getSeckillSpuDetail(
                                @PathVariable Long spuId){
    SeckillSpuDetailSimpleVO simpleVO=
            seckillSpuService.getSeckillSpuDetail(spuId);
    return JsonResult.ok(simpleVO);
}

在前面方法启动的软件和服务基础上

重启seckill模块进行测试即可

根据SpuId查询sku列表

之前编写加载数据的Mapper时,完成了根据SpuId查Sku列表的功能

下面我们从业务逻辑层开始编写

开发业务逻辑层

我们也需要将SpuId对应的Sku信息保存到Redis

在service.impl包中创建SeckillSkuServiceImpl类中编写代码如下

@Service
@Slf4j
public class SeckillSkuServiceImpl implements ISeckillSkuService {
    @Autowired
    private SeckillSkuMapper skuMapper;
    @Autowired
    private RedisTemplate redisTemplate;

    // sku常规信息的查询还是dubbo调用product模块获取
    @DubboReference
    private IForSeckillSkuService dubboSkuService;

    @Override
    public List<SeckillSkuVO> listSeckillSkus(Long spuId) {
        // 执行查询, 根据spuId查询sku列表
        List<SeckillSku> seckillSkus=skuMapper.findSeckillSkusBySpuId(spuId);
        // 上面查询返回值泛型为SeckillSku,是秒杀信息集合
        // 当前方法的返回值是SeckillSkuVO是包含秒杀信息和常规信息的对象
        // 我们先实例化返回值类型泛型的集合,以备后续返回时使用
        List<SeckillSkuVO> seckillSkuVOs=new ArrayList<>();
        // 遍历秒杀信息集合对象
        for(SeckillSku sku : seckillSkus){
            // 获取skuId后面会经常使用
            Long skuId=sku.getSkuId();
            // 获取sku对的key
            String skuVOKey= SeckillCacheUtils.getSeckillSkuVOKey(skuId);
            SeckillSkuVO seckillSkuVO=null;
            // 判断当前Redis中是否已经有这个key
            if(redisTemplate.hasKey(skuVOKey)){
                seckillSkuVO=(SeckillSkuVO)redisTemplate
                                 .boundValueOps(skuVOKey).get() ;
            }else{
                // 如果redis中存在这个key,就要查询数据库
                // dubbo调用查询sku常规信息
                SkuStandardVO skuStandardVO=dubboSkuService.getById(skuId);
                // 实例化SeckillSkuVO对象
                seckillSkuVO=new SeckillSkuVO();
                BeanUtils.copyProperties(skuStandardVO,seckillSkuVO);
                // 秒杀信息手动赋值
                seckillSkuVO.setSeckillPrice(sku.getSeckillPrice());
                seckillSkuVO.setStock(sku.getSeckillStock());
                seckillSkuVO.setSeckillLimit(sku.getSeckillLimit());
                // seckillSkuVO保存到Redis
                redisTemplate.boundValueOps(skuVOKey).set(
                       seckillSkuVO,
                        1000*60*5+ RandomUtils.nextInt(30000),
                        TimeUnit.MILLISECONDS);
            }
            // if-else结构结束后,seckillSkuVO是一定被赋值的(redis或数据库查询出的)
            // 要将它添加到seckillSkuVOs这个集合中
            seckillSkuVOs.add(seckillSkuVO);
        }
        // 返回集合!!!
        return seckillSkuVOs;
    }
}

编写控制层

新建SeckillSkuController添加方法

@RestController
@RequestMapping("/seckill/sku")
@Api(tags = "秒杀sku模块")
public class SeckillSkuController {

    @Autowired
    private ISeckillSkuService seckillSkuService;

    // localhost:10007/seckill/sku/list/2
    @GetMapping("/list/{spuId}")
    @ApiOperation("根据spuId查询sku列表")
    @ApiImplicitParam(value = "spuId" ,name="spuId",example = "2")
    public JsonResult<List<SeckillSkuVO>> listSeckillSkus(
                                            @PathVariable Long spuId){
        List<SeckillSkuVO> list = seckillSkuService.listSeckillSkus(spuId);
        return JsonResult.ok(list);
    }
}

保证Nacos\Redis\Seata启动

保证product是启动的

重启seckill

端口10007测试

消息队列

Dubbo远程调用的性能问题

Dubbo调用普遍存在于我们的微服务项目中

这些Dubbo调用全部是同步的操作

这里的"同步"指:消费者A调用生产者B之后,A的线程会进入阻塞状态,等待生产者B运行结束返回之后,A才能运行之后的代码

Dubbo消费者发送调用后进入阻塞状态,这个状态表示该线程仍占用内存资源,但是什么动作都不做

如果生产者运行耗时较久,消费者就一直等待,如果消费者利用这个时间,那么可以处理更多请求,业务整体效率会提升

实际情况下,Dubbo有些必要的返回值必须等待,但是不必要等待的服务返回值,我们可以不等待去做别的事情

这种情况下我们就要使用消息队列

什么是消息队列

消息队列(Message Queue)简称MQ,也称:"消息中间件"

消息队列是采用"异步(两个微服务项目并不需要同时完成请求)"的方式来传递数据完成业务操作流程的业务处理方式

消息队列的特征

常见面试题:消息队列的特征(作用)

  • 利用异步的特性,提高服务器的运行效率,减少因为远程调用出现的线程等待\阻塞时间

  • 削峰填谷:在并发峰值超过当前系统处理能力时,我们将没处理的信息保存在消息队列中,在后面出现的较闲的时间中去处理,直到所有数据依次处理完成,能够防止在并发峰值时短时间大量请求而导致的系统不稳定

  • 消息队列的延时:因为是异步执行,请求的发起者并不知道消息何时能处理完,如果业务不能接受这种延迟,就不要使用消息队列

常见消息队列软件

  • Kafka:性能好\功能弱:适合大数据量,高并发的情况,大数据领域使用较多

  • RabbitMQ:功能强\性能一般:适合发送业务需求复杂的消息队列,java业务中使用较多

  • RocketMQ:阿里的

  • ActiveMQ:前几年流行的,老项目可能用到

  • .....

消息队列的事务处理

当接收消息队列中信息的模块运行发生异常时,怎么完成事务的回滚?

当消息队列中(stock)发生异常时,在异常处理的代码中,我们可以向消息的发送者(order)发送消息,然后通知发送者(order)处理,消息的发送者(order)接收到消息后,一般要手写代码回滚,如果回滚代码过程中再发生异常,就又要思考回滚方式,如果一直用消息队列传递消息的话,可能发生异常的情况是无止境的

所以我们在处理消息队列异常时,经常会设置一个"死信队列",将无法处理的异常信息发送到这个队列中

死信队列没有任何处理者,通常情况下会有专人周期性的处理死信队列的消息

Kafka

什么是Kafka

Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写。该项目的目标是为处理实时数据提供一个统一、高吞吐、低延迟的平台。Kafka最初是由LinkedIn开发,并随后于2011年初开源。

kafka软件结构

Kafka是一个结构相对简单的消息队列(MQ)软件

kafka软件结构图

Kafka Cluster(Kafka集群)

Producer:消息的发送方,也就是消息的来源,Kafka中的生产者

order就是消息的发送方,在Dubbo中order是消费者,这个身份变化了

Consumer:消息的接收方,也是消息的目标,Kafka中的消费者

stock就是消息的接收方,在Dubbo中stock是生产者,这个身份变化了

Topic:话题或主题的意思,消息的收发双方要依据同一个话题名称,才不会将信息错发给别人

Record:消息记录,就是生产者和消费者传递的信息内容,保存在指定的Topic中

Kafka的特征与优势

Kafka作为消息队列,它和其他同类产品相比,突出的特点就是性能强大

Kafka将消息队列中的信息保存在硬盘中

Kafka对硬盘的读取规则进行优化后,效率能够接近内存

硬盘的优化规则主要依靠"顺序读写,零拷贝,日志压缩等技术"

Kafka处理队列中数据的默认设置:

  • Kafka队列信息能够一直向硬盘中保存(理论上没有大小限制)

  • Kafka默认队列中的信息保存7天,可以配置这个时间,缩短这个时间可以减少Kafka的磁盘消耗

Kafka的安装和配置

必须将我们kafka软件的解压位置设置在一个根目录,文件夹名称尽量短(例如:kafka)

然后路径不要有空格和中文

我们要创建一个空目录用于保存Kafka运行过程中产生的数据

本次创建名称为data的空目录

下面进行Kafka启动前的配置

先到F:\kafka\config下配置有文件zookeeper.properties

找到dataDir属性修改如下

dataDir=F:/data

修改完毕之后要Ctrl+S进行保存,否则修改无效!!!!

注意F盘和data文件夹名称,匹配自己电脑的真实路径和文件夹名称

还要修改server.properties配置文件

log.dirs=F:/data

修改注意事项和上面相同

启动kafka

要想启动Kafka必须先启动Zookeeper

Zookeeper介绍

zoo:动物园

keeper:园长

可以引申为管理动物的人

Linux服务器中安装的各种软件,很多都是有动物形象的

如果这些软件在Linux中需要修改配置信息的话,就需要进入这个软件,去修改配置,每个软件都需要单独修改配置的话,工作量很大

我们使用Zookeeper之后,可以创建一个新的管理各种软件配置的文件管理系统

Linux系统中各个软件的配置文件集中到Zookeeper中

实现在Zookeeper中,可以修改服务器系统中的各个软件配置信息

长此以往,很多软件就删除了自己写配置文件的功能,而直接从Zookeeper中获取

Kafka就是需要将配置编写在Zookeeper中的软件之一

所以要先启动zookeeper才能启动kafka

Zookeeper启动

进入路径F:\kafka\bin\windows

输入cmd进入dos命令行

F:\kafka\bin\windows>zookeeper-server-start.bat ..\..\config\zookeeper.properties

kafka启动

总体方式一样,输入不同指令

F:\kafka\bin\windows>kafka-server-start.bat ..\..\config\server.properties

附录

Mac系统启动Kafka服务命令(参考):

# 进入Kafka文件夹
cd Documents/kafka_2.13-2.4.1/bin/
# 动Zookeeper服务
./zookeeper-server-start.sh -daemon ../config/zookeeper.properties 
# 启动Kafka服务
./kafka-server-start.sh -daemon ../config/server.properties 

Mac系统关闭Kafka服务命令(参考):

# 关闭Kafka服务
./kafka-server-stop.sh 
# 启动Zookeeper服务
./zookeeper-server-stop.sh

在启动kafka时有一个常见错误

wmic不是内部或外部命令

这样的提示,需要安装wmic命令,安装方式参考

我在cmd输入wmic,提示说不是内部或外部命令,也不是可运行的程序或批处理文件,请问如何解决_百度知道

如果启动kafka无响应

在“环境变量”的“用户变量路径”中Path属性添加一行后

%SystemRoot%\System32\Wbem;%SystemRoot%\System32\;%SystemRoot%

Kafka使用演示

启动的zookeeper和kafka的窗口不要关闭

我们在csmall项目中编写一个kafka使用的演示

csmall-cart-webapi模块

添加依赖

<!--  SpringBoot整合Kafka的依赖  -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
<!--   google提供的可以将java对象和json格式字符串转换的工具   -->
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
</dependency>

修改yml文件进行配置

spring:
  kafka:
    bootstrap-servers: localhost:9092
    # consumer.group-id是Spring-Kafka框架要求的必须配置的内容,不配置启动会报错
    # 作用是给话题分组,防止不同项目恰巧相同的话题名称混淆
    # 本质上,在当前项目发送给kafka消息时,会使用这个分组作为话题名称的前缀
    # 例如发送一个message的话题名称,实际上会发送的话题名称是csmall_message
    consumer:
      group-id: csmall

在SpringBoot启动类中添加启动Kafka的注解

@SpringBootApplication
@EnableDubbo
// 项目启动时启用对kafka的支持
@EnableKafka
// 我们为了测试kafka消息的收发效果
// 利用SpringBoot自带的定时任务工具,实现周期性向kafka发送消息的功能
// 明确我们SpringBoot自带定时任务和Kafka没有必然联系
@EnableScheduling
public class CsmallCartWebapiApplication {

    public static void main(String[] args) {
        SpringApplication.run(CsmallCartWebapiApplication.class, args);
    }

}

下面我们就可以实现周期性的向kafka发送消息并接收的操作了

编写消息的发送

cart-webapi包下创建kafka包

包中创建Producer类来发送消息

// SpringBoot启动时,将当前类对象实例化后保存到Spring容器,才能实现周期运行
@Component
public class Producer {
    // 配置Spring-Kafka框架提供的能够连接kafka操作的对象
    // 这个对象需要指定泛型<String,String>
    // KafkaTemplate<[话题类型],[消息类型]>
    @Autowired
    private KafkaTemplate<String,String> kafkaTemplate;

    // 发送消息的方法
    // 实现每隔10秒(10000毫秒)运行一次,需要添加下面的注解
    @Scheduled(fixedRate = 10000)
    public void sendMessage(){
        // 实例化一个要发送的对象
        Cart cart=new Cart();
        cart.setId(1);
        cart.setCommodityCode("PC100");
        cart.setUserId("UU100");
        cart.setPrice(10);
        cart.setCount(3);
        // 因为当前kafka连接只能发送字符串类型对象
        // 所以我们需要将上面的cart对象转换为json格式字符串
        // 例如 {"id":"1","userId":"UU100","price":"10",...}
        Gson gson=new Gson();
        // 使用gson转换cart对象称为json格式字符串
        String json=gson.toJson(cart);
        System.out.println("要发送的json格式字符串为:"+json);
        // 执行发送
        kafkaTemplate.send("myCart",json);
    }

}

消息的发送

在上次课的基础上修改了一些细节

// SpringBoot启动时,将当前类对象实例化后保存到Spring容器,才能实现周期运行
@Component
public class Producer {
    // 配置Spring-Kafka框架提供的能够连接kafka操作的对象
    // 这个对象需要指定泛型<String,String>
    // KafkaTemplate<[话题类型],[消息类型]>
    @Autowired
    private KafkaTemplate<String,String> kafkaTemplate;

    // 定义话题常量
    public static final String TOPIC_KEY="myCart";
    
    int i=1;
    // 发送消息的方法
    // 实现每隔10秒(10000毫秒)运行一次,需要添加下面的注解
    @Scheduled(fixedRate = 10000)
    public void sendMessage(){
        // 实例化一个要发送的对象
        Cart cart=new Cart();
        cart.setId(i++);
        cart.setCommodityCode("PC100");
        cart.setUserId("UU100");
        cart.setPrice(10+ RandomUtils.nextInt(90));
        cart.setCount(1+RandomUtils.nextInt(10));
        // 因为当前kafka连接只能发送字符串类型对象
        // 所以我们需要将上面的cart对象转换为json格式字符串
        // 例如 {"id":"1","userId":"UU100","price":"10",...}
        Gson gson=new Gson();
        // 使用gson转换cart对象称为json格式字符串
        String json=gson.toJson(cart);
        System.out.println("要发送的json格式字符串为:"+json);
        // 执行发送
        kafkaTemplate.send(TOPIC_KEY,json);
    }

}

消息的接收

下面开始接收

kafka包中创建一个叫Consumer的类来接收消息

接收消息的类可以是本模块的类,也可以是其它模块的类,编写的代码是完全一致

// 当前Consumer是用于接收kafka消息的类
// 要求将这个类也保存到Spring容器中,因为SpringKafka框架使用Spring容器中的对象
@Component
public class Consumer {

    // Spring-Kafka接收消息,使用了"监听机制"
    // 框架设计了一条线程,实时关注Kafka话题接收的情况
    // 我们可以指定一个话题名称(myCart),设置一旦这个话题中有消息,监听线程就通知下面方法运行
    @KafkaListener(topics = Producer.TOPIC_KEY)
    // 上面注解就实现了监听的机制
    // 当前Kafka的myCart话题出现消息时,会自动运行下面方法
    // 下面方法的参数和返回值是不能修改的(方法名随意)
    public void received(ConsumerRecord<String,String> record){
        // 方法的返回值必须是void ,参数必须是ConsumerRecord
        // ConsumerRecord<[话题类型],[消息类型]>
        // 从record对象中获取消息内容
        String json=record.value();
        // json可能是这样的字符串:{"id":"1","userId":"UU100","price":"10",...}
        // 最终要活的Cart对象,需要将json字符串转换为java对象
        // 仍然使用Gson工具类实现
        Gson gson=new Gson();
        Cart cart=gson.fromJson(json, Cart.class);
        // 转换完成输出cart,测试验证消息的接收效果
        System.out.println(cart);

    }
}

Nacos\Seata\Zookeeper\Kafka启动

启动cart模块

观察是否10秒出现一次输出

有发送消息和接收消息的效果

RabbitMQ

什么是RabbitMQ

RabbitMQ 是一个由 Erlang 语言开发的 AMQP 的开源实现。 AMQP :Advanced Message Queue,高级消息队列协议。它是应用层协议的一个开放标准,为面向消息的中间件设计,基于此协议的客户端与消息中间件可传递消息,并不受产品、开发语言等条件的限制。 RabbitMQ 最初起源于金融系统,用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

RabbitMQ特征

1.可靠性(Reliability) RabbitMQ 使用一些机制来保证可靠性,如持久化、传输确认、发布确认。

2.灵活的路由(Flexible Routing) 在消息进入队列之前,通过 Exchange 来路由消息的。对于典型的路由功能,RabbitMQ已经提供了一些内置的 Exchange 来实现。针对更复杂的路由功能,可以将多个Exchange 绑定在一起,也通过插件机制实现自己的 Exchange 。

3.消息集群(Clustering) 多个 RabbitMQ 服务器可以组成一个集群,形成一个逻辑 Broker

4.高可用(Highly Available Queues) 队列可以在集群中的机器上进行镜像,使得在部分节点出问题的情况下队列仍然可用。

5.多种协议(Multi-protocol) RabbitMQ 支持多种消息队列协议,比如 STOMP、MQTT 等等。

6.多语言客户端(Many Clients) RabbitMQ 几乎支持所有常用语言,比如 Java、.NET、Ruby 等等。

7.管理界面(Management UI) RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker 的许多方面。

8.跟踪机制(Tracing) 如果消息异常,RabbitMQ 提供了消息跟踪机制,使用者可以找出发生了什么。

9.插件机制(Plugin System) RabbitMQ 提供了许多插件,来从多方面进行扩展,也可以编写自己的插件。

下载软件

苹果mac系统的同学直接苍老师网站看MacOS安装RabbitMQ的技术贴

RabbitMQ是Erlang语言开发的,所以要先安装Erlang语言的运行环境

下载Erlang的官方路径

OTP Versions Tree

安装的话就是双击

安装过程中都可以使用默认设置,需要注意的是

不要安装在中文路径和有空格的路径下!!!

下载RabbitMQ的官方网址

Installing on Windows — RabbitMQ

安装也是双击即可

不要安装在中文路径和有空格的路径下!!!

配置Erlang的环境变量

要想运行RabbitMQ必须保证系统有Erlang的环境变量

配置Erlang环境变量

把安装Erlang的bin目录配置在环境变量Path的属性中

环境变量配置完,可以打开cmd使用下面的命令测试环境变量效果

C:\Users\TEDU>erl -version
Erlang (SMP,ASYNC_THREADS) (BEAM) emulator version 12.3.2

启动RabbitMQ

找到RabbitMQ的安装目录

例如

D:\tools\rabbit\rabbitmq_server-3.10.1\sbin

具体路径根据自己的情况寻找

地址栏运行cmd

输入启动指令如下

D:\tools\rabbit\rabbitmq_server-3.10.1\sbin>rabbitmq-plugins enable rabbitmq_management

结果如下

运行完成后,验证启动状态

RabbitMQ自带一个管理的界面,所以我们可以访问这个界面来验证它的运行状态

http://localhost:15672

登录界面用户名密码

guest

guest

登录成功后看到RabbitMQ运行的状态

如果启动失败,可以手动启动RabbitMQ

参考路径如下

百度安全验证

RabbitMQ的工作模式

常见面试题

RabbitMQ的工作模式有六种:simple简单模式、work工作模式、publish/subscribe订阅模式、routing路由模式、topic 主题模式、RPC模式。

simple简单模式为一个队列中一条消息,只能被一个消费者消费。

Work工作模式为一个生产者,多个消费者,每个消费者获取到的消息唯一。

publish/subscribe订阅模式为一个生产者发送的消息被多个消费者获取。

routing路由模式为生产者发送的消息主要根据定义的路由规则决定往哪个队列发送。

topic 主题模式为生产者,一个交换机(topicExchange),模糊匹配路由规则,多个队列,多个消费者。

RPC模式为客户端 Client 先发送消息到消息队列,远程服务端 Server 获取消息,然后再写入另一个消息队列,向原始客户端 Client 响应消息处理结果。

RabbitMQ路由模式的结构

RabbitMQ软件支持很多种工作模式,我们学习其中的路由模式

路由模式比较常用,而且功能强大,但是结构比Kafka的主题模式复杂

和Kafka不同,Kafka是使用话题名称来收发信息,结构简单

RabbitMQ路由模式是使用交换机\路由key指定要发送消息的队列

消息的发送者发送消息时,需要指定交换机和路由key名称

消息的接收方接收消息时,只需要指定队列的名称

在编写代码上,相比于Kafka,每个业务要编写一个配置类

这个配置类中要绑定交换机和路由key的关系,以及路由Key和队列的关系

利用RabbitMQ完成消息的收发

csmall-stock-webapi项目中测试RabbitMQ

可以利用之前我们使用Quartz实现的每隔一段时间输出当前日期信息的方法改为发送消息

添加依赖

<!--  RabbitMQ的依赖   -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

yml文件配置

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    # 设置虚拟host 单机模式下固定编写"/"即可
    virtual-host: /

交换机\路由Key\队列的配置类

RabbitMQ要求我们在java代码级别设置交换机\路由Key\队列的关系

我们在quartz包下,创建config包

包中创建配置信息类RabbitMQConfig

// 当前配置类配置RabbitMQ中 交换机\路由Key\队列,以及它们的绑定关系
// 因为它们的关系要保存到Spring容器管理,所以这个类要添加@Configuration注解
@Configuration
public class RabbitMQConfig {

    // 将业务中需要的交换机\路由Key\队列的名称声明为常量
    public static final String STOCK_EX="stock_ex";
    public static final String STOCK_ROUT="stock_rout";
    public static final String STOCK_QUEUE="stock_queue";

    // 交换机实体对象,保存到Spring容器
    @Bean
    public DirectExchange stockDirectExchange(){
        return new DirectExchange(STOCK_EX);
    }
    // 队列实体对象,保存到Spring容器
    @Bean
    public Queue stockQueue(){
        return new Queue(STOCK_QUEUE);
    }
    // 路由Key不是实体,是交换机和队列对象的绑定关系,所以编写比较特殊
    @Bean
    public Binding stockBinding(){
        return BindingBuilder
                .bind(stockQueue()).to(stockDirectExchange()).with(STOCK_ROUT);
    }
    

}

RabbitMQ发送消息

我们在QuartzJob类中输出时间的代码后继续编写代码

实现RabbitMQ消息的发送

@Slf4j
public class QuartzJob implements Job {

    // 装配框架提供的操作RabbitMQ的对象
    @Autowired
    private RabbitTemplate rabbitTemplate;
    // 这个方法就是当前job要定时执行的任务方法
    static int x=1;
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 因为当前是简单演示功能,所以就输出一个当前时间即可
        log.info("-----------------"+ LocalDateTime.now() +"-------------------");
        //System.out.println(x++);
        // 实例化一个Stock对象用于发送
        Stock stock=new Stock();
        stock.setId(x++);
        stock.setCommodityCode("PC100");
        stock.setReduceCount(1+ RandomUtils.nextInt(20));
        // 下面开始编写发送操作
        rabbitTemplate.convertAndSend(
                RabbitMQConfig.STOCK_EX,
                RabbitMQConfig.STOCK_ROUT,
                stock);
        log.info("发送消息完成:{}",stock);
    }
}

我们可以通过修改QuartzConfig类中的Cron表达式修改调用的周期**

CronScheduleBuilder cron=
        CronScheduleBuilder.cronSchedule("0/10 * * * * ?");

按上面的cron修改之后,会每隔10秒运行一次发送消息的操作

如果有其它周期运行的配置,建议先注释

启动服务,观察是否每隔10秒发送一条消息

启动Nacos\RabbitMQ\Seata

启动stock-webapi

根据Cron表达式,消息会在0/10/20/30/40/50秒数时运行

接收RabbitMQ中的消息

quartz包下再创建一个新的类用于接收信息

RabbitMQConsumer代码如下

@Component
@Slf4j
// 和Kafka不同,RabbitMQ监听器注解要求写在类上
@RabbitListener(queues = RabbitMQConfig.STOCK_QUEUE)
public class RabbitMQConsumer {
​
    // 类上添加监听器注解,指定的监听的队列,当前队列有消息时,运行@RabbitHandler标记的方法
    // 每个类只允许编写一个@RabbitHandler标记的方法
    // 注解下编写方法,参数直接编写传递的对象即可
    @RabbitHandler
    public void process(Stock stock){
        // stock就是从消息的发送方,发来的对象,直接使用即可
        log.info("接收到消息:{}",stock);
    }
}

其他项目不动,继续保持运行

重启stock-webapi模块

观察消息的接收

开发酷鲨商城秒杀业务

创建流控和降级的处理类

秒杀业务肯定是一个高并发的处理,并发数超过程序设计的限制时,就需要对请求的数量进行限流

Sentinel是阿里提供的SpringCloud组件,主要用于外界访问当前服务器的控制器方法的限流操作

之前的课程中,我们已经比较详细的学习的Sentinel使用的方式

下面我们要先编写Sentinel限流和服务降级时,运行的自定义异常处理类

我们酷鲨前台项目seckill-webapi模块

先来编写限流异常处理类

创建一个exception包,包中新建SeckillBlockHandler代码如下

// 秒杀业务限流异常处理类
@Slf4j
public class SeckillBlockHandler {
​
    // 声明限流方法,返回值必须和控制器方法一致
    // 参数要包含全部的控制器方法参数,在最后再额外添加BlockException
    // 如果在其他类中声明限流方法,要求限流方法必须是static修饰的
​
    public static JsonResult seckillBlock(String randCode, 
                                          SeckillOrderAddDTO seckillOrderAddDTO,
                                          BlockException e){
        log.error("一个请求被限流了!");
        return JsonResult.failed(
                ResponseCode.INTERNAL_SERVER_ERROR,"服务器忙,请稍候再试");
    }
}

exception包,包中新建降级类SeckillFallBack

// 秒杀业务降级处理类
@Slf4j
public class SeckillFallBack {
​
    // 降级方法编写规则基本和限流方法一致,只是最后的参数类型更换为Throwable
    public static JsonResult seckillFallBack(String randCode,
                                             SeckillOrderAddDTO seckillOrderAddDTO,
                                             Throwable e){
        // 输出异常信息,以便调试和找错
        e.printStackTrace();
        log.error("一个请求发送了降级");
        return JsonResult.failed(
                ResponseCode.INTERNAL_SERVER_ERROR,
                "发生异常,异常信息为:"+e.getMessage());
    }
​
}

开发执行秒杀的业务逻辑层

我们之前完成了秒杀的预热,预热中完成了秒杀商品sku库存数,spu随机码(布隆过滤器)保存在redis中的操作

也完成了查询秒杀商品列表,和显示秒杀商品详情的方法

下面要开始进行秒杀商品生成订单的操作

如果用户选择商品规格(sku)提交订单,那么就要按照提交秒杀订单的业务流程处理

秒杀提交订单和普通订单的区别

1.判断用户是否为重复购买和Redis中该Sku是否有库存

2.秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法

3.使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中

4.秒杀订单信息返回

创建一个SeckillServiceImpl业务逻辑层实现类,完成上面的业务

@Service
@Slf4j
public class SeckillServiceImpl implements ISeckillService {
​
    // 秒杀业务中,需要使用redis的是判断用户重复购买和判断库存数,都是操作字符串的
    // 所以使用stringRedisTemplate
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 秒杀生成订单直接调用普通订单生成的方法即可,dubbo调用order模块
    @DubboReference
    private IOmsOrderService dubboOrderService;
    // 将秒杀成功信息发送给rabbitMQ
    @Autowired
    private RabbitTemplate rabbitTemplate;
    /*
    1.判断用户是否为重复购买和Redis中该Sku是否有库存
    2.秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法
    3.使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中
    4.秒杀订单信息返回
     */
​
    @Override
    public SeckillCommitVO commitSeckill(SeckillOrderAddDTO seckillOrderAddDTO) {
        // 第一阶段:判断用户是否为重复购买和Redis中该Sku是否有库存
        // 要从方法参数seckillOrderAddDTO中获取skuId
        Long skuId=seckillOrderAddDTO.getSeckillOrderItemAddDTO().getSkuId();
        // 从SpringSecurity上下文中获取用户Id
        Long userId=getUserId();
        // 我们明确了本次请求是哪个用户要购买哪个sku商品(userId和skuId的值)
        // 根据秒杀业务规则,一个用户每件sku只能购买1次
        // 所以我们可以结合userId和skuId生成一个检查重复购买的key
        // mall:seckill:reseckill:2:1
        String reSeckillCheckKey= SeckillCacheUtils.getReseckillCheckKey(skuId,userId);
        // 用上面的key向redis中发送命令,利用stringRedisTemplate的increment()方法
        // 可以实现下面效果
        // 1.如果上面的key在Redis中不存在,redis中会创建这个key,并生成一个值,值为1
        // 2.如果上面的key在Redis中存在,那么就会在当前数值的基础上再加1后保存
        //      例如已经是1了,就变为2保存起来
        // 3.无论这个key存在与否,都会将最后的值返回给程序
        // 综上,只有用户之前没有调用这个方法,返回值才为1,为1才表示用户是第一次购买这个sku
        Long seckillTimes=stringRedisTemplate
                .boundValueOps(reSeckillCheckKey).increment();
        // 如果seckillTimes值大于1,就是用户已经购买过了
        if(seckillTimes>1){
            // 抛出异常,提示不能重复购买,终止程序
            throw new CoolSharkServiceException(
                    ResponseCode.FORBIDDEN,"您已经购买过这个商品了,谢谢您的支持");
        }
        // 程序运行到此处,表示当前用户是第一次购买这个商品
        // 下面判断当前sku是否还有库存
        // 库存数是在缓存预热是加载到Redis中的,要获取对应sku的key
        // mall:seckill:sku:stock:2
        String skuStockKey=SeckillCacheUtils.getStockKey(skuId);
        // 判断这个key是否存在
        if(!stringRedisTemplate.hasKey(skuStockKey)){
            // 如果key不存在,抛出异常
            throw new CoolSharkServiceException(
                    ResponseCode.INTERNAL_SERVER_ERROR,"缓存中没有库存信息,购买失败");
        }
        // 下面判断库存数是否允许购买,使用stringRedisTemplate的decrement()方法
        // 这里的decrement()和increment()向反,效果是能够将key对应的值减1,然后返回
        Long leftStock=stringRedisTemplate.boundValueOps(skuStockKey).decrement();
        // leftStock是当前库存数减1之后返回的
        // 返回0时,表示当前用户购买到了最后一件库存商品
        // 只有返回值小于0,为负值时,才表示已经没有库存了
        if(leftStock<0){
            // 没有库存了,抛出异常终止
            // 但是要先将用户购买这个商品的记录恢复为0
            stringRedisTemplate.boundValueOps(reSeckillCheckKey).decrement();
            throw new CoolSharkServiceException(
                    ResponseCode.BAD_REQUEST,"对不起您购买的商品暂时售罄");
        }
        // 到此为止,用户通过了重复购买和库存数的判断,可以开始生成订单了
        // 第二阶段:秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法
        // 目标是将参数SeckillOrderAddDTO转换成OrderAddDTO
        // 要观察这两个类的不同,然后编写转换方法完成转换
        // 转换方法代码较多,需要单独编写一个方法
        OrderAddDTO orderAddDTO=convertSeckillOrderToOrder(seckillOrderAddDTO);
        // 完成转换操作,订单的所有属性就都赋值完毕了
        // 但是userId要单独赋值,前端传入的参数中不会包含userId
        orderAddDTO.setUserId(userId);
        // dubbo调用order模块生成订单的方法,完成订单的新增
        OrderAddVO orderAddVO = dubboOrderService.addOrder(orderAddDTO);
        // 第三阶段:使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中
        // 业务要求我们记录秒杀成功的信息,但是它并不需要立即运行,可以由消息队列完成
        // 我们要创建Success秒杀记录对象,然后将它发送给RabbitMQ
        Success success=new Success();
        // Success大部分属性和sku对象相同,可以做同名属性赋值
        BeanUtils.copyProperties(seckillOrderAddDTO.getSeckillOrderItemAddDTO(),
                                    success);
        // 把未赋值的必要信息补全(有些非必要信息已忽略)
        success.setUserId(userId);
        success.setSeckillPrice(seckillOrderAddDTO
                            .getSeckillOrderItemAddDTO().getPrice());
        success.setOrderSn(orderAddVO.getSn());
        // success对象赋值完备后,将发送给RabbitMQ
        rabbitTemplate.convertAndSend(
                RabbitMqComponentConfiguration.SECKILL_EX,
                RabbitMqComponentConfiguration.SECKILL_RK,
                success);
        // 第四阶段:秒杀订单信息返回
        // 返回值SeckillCommitVO和提交订单获得的返回值OrderAddVO属性完全一致
        // 直接把同名属性赋值之后返回即可
        SeckillCommitVO commitVO=new SeckillCommitVO();
        BeanUtils.copyProperties(orderAddVO,commitVO);
        // 修改返回值为commitVO
        return commitVO;
    }
    
    private OrderAddDTO convertSeckillOrderToOrder(SeckillOrderAddDTO seckillOrderAddDTO) {
        // 先实例化要返回的对象
        OrderAddDTO orderAddDTO=new OrderAddDTO();
        // 将参数seckillOrderAddDTO的同名属性赋值到orderAddDTO
        BeanUtils.copyProperties(seckillOrderAddDTO,orderAddDTO);
        // 经过观察两个对象的属性需要我们处理的实际上只有当前包含的订单项信息
        // 秒杀订单中只有一个SeckillOrderItemAddDTO属性
        // 常规订单中有一个List泛型是OrderItemAddDTO
        // 所以我们下面的操作时将SeckillOrderItemAddDTO转换成OrderItemAddDTO,并添加到list集合中
        OrderItemAddDTO orderItemAddDTO=new OrderItemAddDTO();
        // 同名属性赋值
        BeanUtils.copyProperties(seckillOrderAddDTO.getSeckillOrderItemAddDTO(),
                                    orderItemAddDTO);
        // 在向最终集合赋值前,先实例化普通订单项的泛型集合
        List<OrderItemAddDTO> list=new ArrayList<>();
        // 把赋值好的订单项对象,添加到这个集合中
        list.add(orderItemAddDTO);
        // 将集合对象赋值到orderAddDTO对象的orderItems属性中
        orderAddDTO.setOrderItems(list);
        // 最后别忘了返回
        return orderAddDTO;
    }
​
​
    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                        SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity获取的用户信息是否为null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录");
        }
        // 从SpringSecurity上下文中获取用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo)
                        authenticationToken.getCredentials();
        // 最后别忘了将用户信息返回
        return csmallAuthenticationInfo;
    }
    //  业务逻辑层中实际需求都是获取用户的id
    // 我们再一个方法,直接返回用户id,方便业务调用
    public Long getUserId(){
        return getUserInfo().getId();
    }
​
}

开发执行秒杀的业务逻辑层

我们之前完成了秒杀的预热,预热中完成了秒杀商品sku库存数,spu随机码(布隆过滤器)保存在redis中的操作

也完成了查询秒杀商品列表,和显示秒杀商品详情的方法

下面要开始进行秒杀商品生成订单的操作

如果用户选择商品规格(sku)提交订单,那么就要按照提交秒杀订单的业务流程处理

秒杀提交订单和普通订单的区别

1.判断用户是否为重复购买和Redis中该Sku是否有库存

2.秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法

3.使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中

4.秒杀订单信息返回

创建一个SeckillServiceImpl业务逻辑层实现类,完成上面的业务

@Service
@Slf4j
public class SeckillServiceImpl implements ISeckillService {

    // 秒杀业务中,需要使用redis的是判断用户重复购买和判断库存数,都是操作字符串的
    // 所以使用stringRedisTemplate
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 秒杀生成订单直接调用普通订单生成的方法即可,dubbo调用order模块
    @DubboReference
    private IOmsOrderService dubboOrderService;
    // 将秒杀成功信息发送给rabbitMQ
    @Autowired
    private RabbitTemplate rabbitTemplate;
    /*
    1.判断用户是否为重复购买和Redis中该Sku是否有库存
    2.秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法
    3.使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中
    4.秒杀订单信息返回
     */

    @Override
    public SeckillCommitVO commitSeckill(SeckillOrderAddDTO seckillOrderAddDTO) {
        // 第一阶段:判断用户是否为重复购买和Redis中该Sku是否有库存
        // 要从方法参数seckillOrderAddDTO中获取skuId
        Long skuId=seckillOrderAddDTO.getSeckillOrderItemAddDTO().getSkuId();
        // 从SpringSecurity上下文中获取用户Id
        Long userId=getUserId();
        // 我们明确了本次请求是哪个用户要购买哪个sku商品(userId和skuId的值)
        // 根据秒杀业务规则,一个用户每件sku只能购买1次
        // 所以我们可以结合userId和skuId生成一个检查重复购买的key
        // mall:seckill:reseckill:2:1
        String reSeckillCheckKey= SeckillCacheUtils.getReseckillCheckKey(skuId,userId);
        // 用上面的key向redis中发送命令,利用stringRedisTemplate的increment()方法
        // 可以实现下面效果
        // 1.如果上面的key在Redis中不存在,redis中会创建这个key,并生成一个值,值为1
        // 2.如果上面的key在Redis中存在,那么就会在当前数值的基础上再加1后保存
        //      例如已经是1了,就变为2保存起来
        // 3.无论这个key存在与否,都会将最后的值返回给程序
        // 综上,只有用户之前没有调用这个方法,返回值才为1,为1才表示用户是第一次购买这个sku
        Long seckillTimes=stringRedisTemplate
                .boundValueOps(reSeckillCheckKey).increment();
        // 如果seckillTimes值大于1,就是用户已经购买过了
        if(seckillTimes>1){
            // 抛出异常,提示不能重复购买,终止程序
            throw new CoolSharkServiceException(
                    ResponseCode.FORBIDDEN,"您已经购买过这个商品了,谢谢您的支持");
        }
        // 程序运行到此处,表示当前用户是第一次购买这个商品
        // 下面判断当前sku是否还有库存
        // 库存数是在缓存预热是加载到Redis中的,要获取对应sku的key
        // mall:seckill:sku:stock:2
        String skuStockKey=SeckillCacheUtils.getStockKey(skuId);
        // 判断这个key是否存在
        if(!stringRedisTemplate.hasKey(skuStockKey)){
            // 如果key不存在,抛出异常
            throw new CoolSharkServiceException(
                    ResponseCode.INTERNAL_SERVER_ERROR,"缓存中没有库存信息,购买失败");
        }
        // 下面判断库存数是否允许购买,使用stringRedisTemplate的decrement()方法
        // 这里的decrement()和increment()向反,效果是能够将key对应的值减1,然后返回
        Long leftStock=stringRedisTemplate.boundValueOps(skuStockKey).decrement();
        // leftStock是当前库存数减1之后返回的
        // 返回0时,表示当前用户购买到了最后一件库存商品
        // 只有返回值小于0,为负值时,才表示已经没有库存了
        if(leftStock<0){
            // 没有库存了,抛出异常终止
            // 但是要先将用户购买这个商品的记录恢复为0
            stringRedisTemplate.boundValueOps(reSeckillCheckKey).decrement();
            throw new CoolSharkServiceException(
                    ResponseCode.BAD_REQUEST,"对不起您购买的商品暂时售罄");
        }
        // 到此为止,用户通过了重复购买和库存数的判断,可以开始生成订单了
        // 第二阶段:秒杀订单转换成普通订单,需要使用dubbo调用order模块的生成订单方法
        // 目标是将参数SeckillOrderAddDTO转换成OrderAddDTO
        // 要观察这两个类的不同,然后编写转换方法完成转换
        // 转换方法代码较多,需要单独编写一个方法
        OrderAddDTO orderAddDTO=convertSeckillOrderToOrder(seckillOrderAddDTO);
        // 完成转换操作,订单的所有属性就都赋值完毕了
        // 但是userId要单独赋值,前端传入的参数中不会包含userId
        orderAddDTO.setUserId(userId);
        // dubbo调用order模块生成订单的方法,完成订单的新增
        OrderAddVO orderAddVO = dubboOrderService.addOrder(orderAddDTO);
        // 第三阶段:使用消息队列(RabbitMQ)将秒杀成功记录信息保存到success表中
        // 业务要求我们记录秒杀成功的信息,但是它并不需要立即运行,可以由消息队列完成
        // 我们要创建Success秒杀记录对象,然后将它发送给RabbitMQ
        Success success=new Success();
        // Success大部分属性和sku对象相同,可以做同名属性赋值
        BeanUtils.copyProperties(seckillOrderAddDTO.getSeckillOrderItemAddDTO(),
                                    success);
        // 把未赋值的必要信息补全(有些非必要信息已忽略)
        success.setUserId(userId);
        success.setSeckillPrice(seckillOrderAddDTO
                            .getSeckillOrderItemAddDTO().getPrice());
        success.setOrderSn(orderAddVO.getSn());
        // success对象赋值完备后,将发送给RabbitMQ
        rabbitTemplate.convertAndSend(
                RabbitMqComponentConfiguration.SECKILL_EX,
                RabbitMqComponentConfiguration.SECKILL_RK,
                success);
        // 第四阶段:秒杀订单信息返回
        // 返回值SeckillCommitVO和提交订单获得的返回值OrderAddVO属性完全一致
        // 直接把同名属性赋值之后返回即可
        SeckillCommitVO commitVO=new SeckillCommitVO();
        BeanUtils.copyProperties(orderAddVO,commitVO);
        // 修改返回值为commitVO
        return commitVO;
    }
    
    private OrderAddDTO convertSeckillOrderToOrder(SeckillOrderAddDTO seckillOrderAddDTO) {
        // 先实例化要返回的对象
        OrderAddDTO orderAddDTO=new OrderAddDTO();
        // 将参数seckillOrderAddDTO的同名属性赋值到orderAddDTO
        BeanUtils.copyProperties(seckillOrderAddDTO,orderAddDTO);
        // 经过观察两个对象的属性需要我们处理的实际上只有当前包含的订单项信息
        // 秒杀订单中只有一个SeckillOrderItemAddDTO属性
        // 常规订单中有一个List泛型是OrderItemAddDTO
        // 所以我们下面的操作时将SeckillOrderItemAddDTO转换成OrderItemAddDTO,并添加到list集合中
        OrderItemAddDTO orderItemAddDTO=new OrderItemAddDTO();
        // 同名属性赋值
        BeanUtils.copyProperties(seckillOrderAddDTO.getSeckillOrderItemAddDTO(),
                                    orderItemAddDTO);
        // 在向最终集合赋值前,先实例化普通订单项的泛型集合
        List<OrderItemAddDTO> list=new ArrayList<>();
        // 把赋值好的订单项对象,添加到这个集合中
        list.add(orderItemAddDTO);
        // 将集合对象赋值到orderAddDTO对象的orderItems属性中
        orderAddDTO.setOrderItems(list);
        // 最后别忘了返回
        return orderAddDTO;
    }


    public CsmallAuthenticationInfo getUserInfo(){
        // 编写获取SpringSecurity上下文的代码
        UsernamePasswordAuthenticationToken authenticationToken=
                (UsernamePasswordAuthenticationToken)
                        SecurityContextHolder.getContext().getAuthentication();
        // 为了逻辑严谨,判断一下SpringSecurity获取的用户信息是否为null
        if(authenticationToken == null){
            throw new CoolSharkServiceException(
                    ResponseCode.UNAUTHORIZED,"您没有登录");
        }
        // 从SpringSecurity上下文中获取用户信息
        CsmallAuthenticationInfo csmallAuthenticationInfo=
                (CsmallAuthenticationInfo)
                        authenticationToken.getCredentials();
        // 最后别忘了将用户信息返回
        return csmallAuthenticationInfo;
    }
    //  业务逻辑层中实际需求都是获取用户的id
    // 我们再一个方法,直接返回用户id,方便业务调用
    public Long getUserId(){
        return getUserInfo().getId();
    }

}

开发控制层

随机码判断流程

controller包下创建SeckillController

@RestController
@RequestMapping("/seckill")
@Api(tags = "执行秒杀模块")
public class SeckillController {

    @Autowired
    private ISeckillService seckillService;

    @Autowired
    private RedisTemplate redisTemplate;

    @PostMapping("/{randCode}")
    @ApiOperation("验证随机码并提交秒杀订单")
    @ApiImplicitParam(value = "随机码",name="randCode",required = true)
    @PreAuthorize("hasRole('user')")
    @SentinelResource(value = "seckill",
        blockHandlerClass = SeckillBlockHandler.class,blockHandler = "seckillBlock",
        fallbackClass = SeckillFallBack.class,fallback = "seckillFallBack")
    public JsonResult<SeckillCommitVO> commitSeckill(
            @PathVariable String randCode,
            @Validated SeckillOrderAddDTO seckillOrderAddDTO){
        // 先获取SpuId
        Long spuId=seckillOrderAddDTO.getSpuId();
        // 获取这个SpuId对应的随机码的Key
        String randCodeKey= SeckillCacheUtils.getRandCodeKey(spuId);
        // 先判断这个Key是否存在
        if (redisTemplate.hasKey(randCodeKey)){
            // 如果Key存在,获取redis中预热的随机码的值
            String redisRandCode=redisTemplate.boundValueOps(randCodeKey).get()+"";
            // 判断前端发来的随机码和redis中的随机码是否一致
            if(! randCode.equals(redisRandCode)){
                // 前端的随机码和redis中的随机码不一致,抛出异常
                throw new CoolSharkServiceException(
                        ResponseCode.NOT_FOUND,"没有找到指定商品(随机码不匹配)");
            }
            // 运行到此处,表示随机码匹配,调用业务逻辑层
            SeckillCommitVO commitVO=
                    seckillService.commitSeckill(seckillOrderAddDTO);
            return JsonResult.ok(commitVO);
        }else{
            // 如果Key不存在,要抛出异常,终止程序
            throw new CoolSharkServiceException(
                    ResponseCode.NOT_FOUND,"没有找到指定商品");
        }

    }

}

启动Nacos\Seata\RabbitMQ\Redis\Sentinel

项目Leaf\product\passport\order\seckill

注意yml配置文件中的RabbitMQ的用户名和密码

如果说已经购买过,就修改允许购买的数量 >1为 >100

如果说没有库存,检查数据库库存,也可以把判断库存的if注释掉

测试成功即可

还可以测试sentinel的限流

success秒杀成功信息的处理

我们在上面章节提交秒杀信息业务最后

向RabbitMQ队列中,输出了添加秒杀成功信息的消息

但是我们没有任何处理

将秒杀成功信息发送到消息队列的原因:

秒杀成功信息用于统计秒杀数据,是秒杀结束后才需要统计的

所以在秒杀并发高时,消息队列的发送可以延缓,在服务器不忙时,再运行(削峰填谷)

开发持久层

秒杀数据库中有success表

其中的信息就是保存秒杀成功的数据(userId,skuId等)

我们要连接数据库,对这个表进行新增

还有对秒杀数据库sku库存数的修改

SeckillSkuMapper接口中添加方法来修改指定skuId的库存数

// 根据skuId减少库存数的方法
int updateReduceStockBySkuId(@Param("skuId") Long skuId,
                             @Param("quantity") Integer quantity);

SeckillSkuMapper.xml

<!--  根据skuId减少库存数的方法  -->
<update id="updateReduceStockBySkuId">
    update
        seckill_sku
    set
        seckill_stock=seckill_stock-#{quantity}
    where
        sku_id=#{skuId}
</update>

下面再编写新增Success的方法

创建SuccessMapper接口编写方法

@Repository
public interface SuccessMapper {
    // 新增Success类型对象到数据库的方法
    int saveSuccess(Success success);
}

SuccessMapper.xml

<!--  新增Success类型对象到数据库的方法  -->
<insert id="saveSuccess">
    insert into success(
        user_id,
        user_phone,
        sku_id,
        title,
        main_picture,
        seckill_price,
        quantity,
        bar_code,
        data,
        order_sn
    ) values (
        #{userId},
        #{userPhone},
        #{skuId},
        #{title},
        #{mainPicture},
        #{seckillPrice},
        #{quantity},
        #{barCode},
        #{data},
        #{orderSn}
    )
</insert>

开发消息的接收功能

我们当前触发新增Success的方法并不是常规的业务逻辑层

而是由RabbitMQ消息收发机制中接收消息的对象来调用

所以我们编写一个接收消息的监听器类来完成这个操作

创建consumer包,包中创建类SeckillQueueConsumer代码如下

@Component
@Slf4j
@RabbitListener(queues = RabbitMqComponentConfiguration.SECKILL_QUEUE)
public class SeckillQueueConsumer {

    @Autowired
    private SeckillSkuMapper skuMapper;
    @Autowired
    private SuccessMapper successMapper;

    // 下面方法是队列接收到消息时,运行的方法
    @RabbitHandler
    public void process(Success success){
        // 先减少库存
        skuMapper.updateReduceStockBySkuId(
                success.getSkuId(),success.getQuantity());
        // 新增success对象到数据库
        successMapper.saveSuccess(success);
        // 如果上面两个数据库操作发送异常,引发了事务问题
        // 1.如果不要求精确统计,不处理也可以
        // 2.如果要求精确统计,首先可以编写try-catch块进行连库操作重试
        //      如果重试再失败,可以将失败的情况汇总后,提交到死信队列
        // 因为死信队列是人工处理,所以效率不能保证,实际开发中要慎重的使用死信队列

    }
}

环境方面

Nacos\Sentinel\Seata\redis\RabbitMQ

服务方面

Leaf\product\order\seckill

如果之前的测试没有关闭环境

只需要重启seckill即可

虚拟机使用准备

首先检查自己计算机的虚拟化状态

win10为例

如果虚拟化不是"已启用"

需要自己上网百度自己的电脑如何开启虚拟化

一般都是在开机时进入BIOS去调试,每个品牌或主板有不同的开启方式,需要自己查询

准备虚拟机的安装软件(发的资料里有)

RockyLinux有一个600M的镜像压缩包,已经发了网盘连接

链接:百度网盘 请输入提取码 提取码:egno

VirtualBox清华大学个版本下载路径

Index of /virtualbox/ | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror

安装失败的同学可以尝试下载7.0.0以上的版本

VirtualBox安装过程正常,各种下一步即可

酷鲨商城前台业务总结

"我负责的功能"

登录(SSO),注册

显示商品分类(自关联三级分类树)

显示商品列表

显示商品详情

购物车管理(显示购物车列表,添加购物车,删除购物车,修改购物车数量)

生成订单(减少库存,删除购物车,新增订单,新增订单项,修改订单状态等,查询订单列表)

搜索商品(使用ES完成根据关键字进行全文搜索)

商品秒杀功能(缓存预热库存\随机码\布隆过滤器,检查重复购买和防止超卖,生成订单,消息队列,流控)

项目的模块和我负责的模块

分类信息模块和商品显示模块(front)

购物车和订单(order)

搜索模块(search)

秒杀模块(seckill)

单点登录SSO(passport)

没做的模块(不建议写):

支付模块,

物流模块,

客服模块,

评论模块

三级分类树

本项目使用固定的三级分类树

是自关联分类(所有分类信息在一张表中)

实现思路

1.一次性查询出所有分类对象(List集合)

2.遍历集合将当前分类对象以父分类id为Key,以当前对象作为值,保存在一个Map中,这个Map对象的Key(父级分类ID)对应的value,会包含它的所有子分类对象

3.遍历所有分类对象,以当前分类对象id为key,从Map中获取它的子分类,关联到三级分类树对象中,最后返回包含三级分类树结构的集合

4.查询返回之前,将三级分类树保存到Redis,以便以后的请求高效获取

如何实现spu列表

可能是通过分类id查询出spu列表

也可能是ES搜索功能搜索出的spu列表

显示它们的注意事项就是分页(JsonPage)

分类id查询数据库,分页是PageHelper

搜索查询是ES,分页SpringData

如何显示一个商品的详情

商品详情页面有4个查询

1.SpuId查询spu表中信息显示的内容有默认价格\title\name\默认图片等

2.SpuId查询spu_detail表中信息,内容是商品详情大图片

3.根据SpuId查询商品的所有属性

是先用spuid关联到分类id,再由分类id关联到属性id,在获得属性id包含的所有属性,是一个关联查询

如果是一个智能手机分类下的spu,能够查询到例如内存\处理器\颜色等规格属性

4.根据spuId查询Sku列表

只有查询到Sku列表,才知道具体的真实价格\图片\库存的情况

当选择对应规格属性时,才能知道有货无货

如何实现购物车的管理

用户在商品详情页选择属性之后,能够确定sku

将用户选中的sku保存在购物车中,

需要用户登录,因为所有购物车操作都需要用户身份

在控制器方法前添加@PreAuthorize("hasRole('user')") SpringSecurity单点登录

我们新增到购物车中的商品要检查是否已经在购物车中,如果不在新增到购物车,如果在的话,修改数量即可

删除或清空购物车功能就是按照购物车id进行操作即可

修改购物车中商品数量时,可以判断一下库存是否允许,如果没有库存就修改失败

生成订单功能如何实现

用户选好了商品,或勾选了购物车中购买的商品

就可以进行订单的生成了,在用户已经登录的前提下

首先减少库存数,如果用户从购物车勾选,删除用户勾选购物车的商品

然后开始收集各种数据,使用Leaf生成唯一的id,

单价和购买的数量,生成订单对象同时也生成订单项对象

一个订单中可能包含多个商品,计算总价,包含运费和优惠的处理

所有数据收集完毕之后,新增到数据库

我们利用Dubbo去修改sku库存信息,其他修改都是本模块的功能

任何数据库操作失败都要抛出发生异常

我们可以利用分布式事务seata来根据运行状态决定最终要提交还是回滚

保证订单生成之后数据的完整性

搜索功能如何实现

我们使用Elasticsearch全文搜索引擎实现搜索功能

先创建\配置关联Es的实体类

我们可以使用logstash实现数据库和Es信息的同步

(也可以编写代码分页查询所有表中信息在分批增到ES中,只是后续同步数据比较麻烦)

搜索功能本身使用SpringDataElasticsearch实现

将用户输入的关键字获取到Es中进行分页查询

将查询到的Spu列表返回给前端即可

如何实现秒杀

对于秒杀业务,我们首先要考虑的是怎么能够在有限的设备上达到最高的并发

因为秒杀是典型的高并发高性能的业务需求

所以我们要尽可能的使用能够提升性能和并发的组件或功能

同时保证服务器的稳定运行

例如:Redis,ES,Sentinel,消息队列等

具体实现秒杀分为几个步骤

秒杀前准备

我们可以利用任务调度工具Quartz在指定的时间进行缓存预热准备工作

主要两方面

1.在秒杀开始前指定的时间,Redis缓存预热,将每个sku参与秒杀的库存数保存在Redis中

而且为了避免黄牛通过技术手段频繁访问,可以生成一个随机码,也保存在Redis中,用于验证是否为正常链接购买秒杀商品

2.在每个批次秒杀开始前,将本批次所有秒杀商品的spuid保存在布隆过滤器中,减少缓存穿透的情况

秒杀信息的查询

秒杀开始,用户在秒杀商品的规定时间内可以查询秒杀商品详情

所有秒杀商品spu查询时,都先查询布隆过滤器是否包含这个spuId,如果包含允许访问,如果不包含抛出异常,也要考虑布隆过滤器误判的情况,

每当业务中查询spu和sku时,都需要先检查redis中是否包含这个数据,如果包含直接从redis中获得,如果不包含再从数据库中查,但是同时也注意,查询完了要保存到Redis中,以便之后的查询直接从redis中获取,在保存到Redis时,为了减少缓存雪崩的几率,我们为每个Spu和Sku对象都添加了过期时间随机数

查询返回前,可以在判断一下当前时间是否在可秒杀该商品的时间段内,如果不在秒杀时间段内,抛出异常

只有返回了完整信息,前端才可能获得包含随机码的提交路径,否则是无法完成正常连接购买的

提交秒杀信息

在用户购买秒杀商品时,保证用户登录的前提下

验证用户是否重复秒杀(业务要求秒杀相同商品只能购买一次),我们使用userId和skuId,向Redis中保存一个key,如果没有这个key就是用户没有秒杀过,否则发生异常提示

我们要保证用户购买时,这个商品有库存,减少库存后,获得剩余库存信息

只要剩余库存不小于0,就可以为当前用户生成订单,否则发生异常

生成订单直接Dubbo调用Order模块编写的生成订单的方法即可

订单提交后,还需要修改秒杀sku库存数和生成秒杀成功记录保存在数据库

但是这个业务非迫切运行,我们可以将信息发送给消息队列,削峰填谷

然后再编写接收消息队列的代码,完成修改秒杀库存和生成秒杀成功记录的操作

在控制层方法上添加注解实现Sentinel的限流,保证这个业务在非常大的并发下,也能稳定运行

控制器方法中还要判断用户请求路径中的随机码,是否和Redis中保存的随机码一致,防止非正常链接购买

布隆过滤器

什么是布隆过滤器

布隆过滤器能够实现使用较少的空间来判断一个指定的元素是否包含在一个集合中

布隆过滤器并不保存这些数据,所以只能判断是否存在,而并不能取出该元素

使用情景:凡是判断一个元素是否在一个集合中的操作,都可以使用它

布隆过滤器常见使用场景

  1. idea中编写代码,一个单词是否包含在正确拼写的词库中(拼写不正确划绿线的提示)

  2. 公安系统,根据身份证号\人脸信息,判断该人是否在追逃名单中

  3. 爬虫检查一个网址是否被爬取过

    ......

为什么使用布隆过滤器

常规的检查一个元素是否在一个集合中的思路是遍历集合,判断元素是否相等

这样的查询效率非常低下

要保证快速确定一个元素是否在一个集合中,我们可以使用HashMap

因为HashMap内部的散列机制,保证更快更高效的找到元素

所以当数据量较小时,用HashMap或HashSet保存对象然后使用它来判定元素是否存在是不错的选择

但是如果数据量太大,每个元素都要生成哈希值来保存,我们也要依靠哈希值来判定是否存在,一般情况下,我们为了保证尽量少的哈希值冲突需要8字节哈希值做保存

long取值范围:-9223372036854775808-----9223372036854775807

5亿条数据 每条8字节计算后结果为需要3.72G内存,随着数据数量增长,占用内存数字可能更大

所以Hash散列或类似算法可以保证高效判断元素是否存在,但是消耗内存较多

所以我们使用布隆过滤器实现,高效判断是否存在的同时,还能节省内存的效果

但是布隆过滤器的算法天生会有误判情况,需要能够容忍,才能使用

布隆过滤器原理

  • 巴顿.布隆于⼀九七零年提出

  • ⼀个很长的⼆进制向量(位数组)

  • ⼀系列随机函数 (哈希)

  • 空间效率和查询效率⾼(又小又快)

  • 有⼀定的误判率(哈希表是精确匹配)

如果我们向布隆过滤器中保存一个单词

semlinker

我们使用3个hash算法,找到布隆过滤器的位置

算法1:semlinker--> 2

算法2:semlinker--> 4

算法3:semlinker--> 6

会在布隆过滤器中产生如下影响

假设要查询 "Good" 这个单词在不在布隆过滤器中

算法1:Good-->7

算法2:Good-->3

算法3:Good-->6

我们判断Good单词生成的3,6,7三个位置,只要有一个位置是0

就表示当前集合中没有Good这个单词

一个布隆过滤器中不可能只存一个单词,一般布隆过滤器都是保存大量数据的

如果有新的元素保存在布隆过滤器中

kakuqo

算法1:kakuqo-->3

算法2:kakuqo-->4

算法3:kakuqo-->7

新的单词生成3,4,7三个位置

那么现在这个布隆过滤器中2,3,4,6,7都是1了

假如现在有单词bad,判断是否在布隆过滤器中

算法1:bad-->2

算法2:bad-->3

算法3:bad-->6

判断布隆过滤器2,3,6都是1,所以布隆过滤器会认为bad是存在于这个集合中的

误判就是这样产生的

布隆过滤器误判的效果:

  • 布隆过滤器判断不存在的,一定不在集合中

  • 布隆过滤器判断存在的,有可能不在集合中

过短的布隆过滤器如果保存了很多的数据,可能造成二进制位置值都是1的情况,一旦发送这种情况,布隆过滤器就会判断任何元素都在当前集合中,布隆过滤器也就失效了

所以我们要给布隆过滤器一个合适的大小才能让它更好的为程序服务

  • 优点

空间效率和查询效率⾼

  • 缺点

    • 有⼀定误判率即可(可以控制在可接受范围内)。

    • 删除元素困难(不能将该元素hash算法结果位置修改为0,因为可能会影响其他元素)

    • 极端情况下,如果布隆过滤器所有位置都是1,那么任何元素都会被判断为存在于集合中

设计布隆过滤器

我们在启动布隆过滤器时,需要给它分配一个合理大小的内存

这个大小应该满足

1.内存占用在一个可接受范围

2.不能有太高的误判率(<1%)

内存约节省,误判率越高

内存越大,误判率越低

数学家已经给我们了公式计算误判率

上面是根据误判率计算布隆过滤器长度的公式

n 是已经添加元素的数量;

k 哈希的次数;

m 布隆过滤器的长度(位数的大小)

Pfp计算结果就是误判率

如果我们已经确定可接受的误判率,想计算需要多少位数布隆过滤器的长度

布隆过滤器计算器

Bloom filter calculator

windows安装redisbloom布隆过滤器

windows 中使用redisbloom布隆过滤器_windows怎么安装安装布隆过滤器,布隆过滤器可以装在redis的下面吗_周荀的博客-CSDN博客

虚拟机的基本使用

安装虚拟机

直接安装即可

只需要保证安装路径没有中文没有空格

win11的装不上的先去清华网站下载7.0.x版本的最新virtualbox

Index of /virtualbox/ | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror

还不行的去下载安装VMware

安装VMware的同学,需要去搜索下载一个CentOS 7.5以上或RockyLinux的镜像

不会操作的话找项目经理老师

mac系统同学,只需要掌握Homebrew即可

什么是虚拟机

所谓的虚拟机,就是在当前计算机系统中,又开启了一个虚拟系统

这个虚拟系统,我们要安装Linux系统

我们开发的java项目最终也都会运行在Linux系统上

开发使用windows是正常的

什么是Linux

linux也是一个计算机操作系统

相较于windows系统,Linux系统更加注重系统的权限,安全性和性能

但是windows易用性好,Linux易用性差

因为Linux系统原生状态下,基本没有任何软件,易用性差

又因为Linux是开源的,所以很多个人或组织对原生Linux进行了增强,后放出各种Linux的增强版

这些增强版会默认安装好很多常用软件或驱动,让Linux系统变的易用

现在市面上比较多的常见的Linux增强版有

CentOS

Ubantu

RockyLinux

RedHat

....等

虚拟机网络配置

配置网络共享连接

如果共享中出现下拉框,一定要选择Virtualbox的网卡选项

给大家共享的镜像资源

600多M的资源,纯净版的LockyLinux(Virtualbox专用)

3.5多G的资源,VMware版包含全部软件的镜像

4.1多G的资源,Virtualbox版包含全部软件的镜像

Virtualbox加载虚拟机镜像

解压RockyLinux_8.5_VBM.7z(600多M的)

获得1.78GB的文件夹

打开这个文件夹

双击蓝色图标

会自动开启virtualbox虚拟机,并加载当前镜像

必须保证当前镜像文件所在全部路径都没有中文

建议启动Virtualbox时使用单击右键->管理员方式运行

virtualbox启动有问题解决不了的,可以参考下面文章安装VMware

超详细VMware安装CentOs图文教程_Sq夏颜的博客-CSDN博客

配置镜像参数

选中镜像,点击设置

修改usb设置

修改网络设置

桥接的网卡必须是具备网络连接的网卡

启动虚拟机

上面配置完成之后

可以启动虚拟机

启动虚拟机之后,等待出现登录页面

开机后会自动选择第一项,不用修改

开机后如果鼠标被虚拟机捕获,使用右侧Ctrl键解除

用户名密码默认都是rockylinux

密码输入时没有任何提示,但是可以正常识别

登录之后,我们的用户是rockylinux用户

我们尝试连接网络

ping www.baidu.com

如果有周期响应,证明网络畅通,虚拟机可以使用当前计算机的网络功能

Ctrl+C可以随时退出当前运行的程序 返回到命令符

如果没有响应,尝试重新调整网卡网络共享配置和虚拟机的网卡桥接配置

切换到root用户

上次课我们成功登录了Linux系统,同时正在操作的用户是rockylinux

这个用户并不具备系统所有权限,所以后面的内容可能因为权限不足受阻

需要切换到root系统管理员权限

输入

sudo su -

就能切换到root用户

然后为root用户设置一个密码,有了密码才能登录root用户

passwd

可以进入设置密码的流程

建议不要使用数字做密码,推荐学习过程中就使用root做密码

当前虚拟机也是网络中的一台计算机,我们要想访问它,必须有一个可用的ip地址

ifconfig

ifconfig | more 

逐行显示信息

通过上面的命令可以看到自己虚拟机的ip地址

这个ip地址可能是

192.168.56.101
10.1.6.78

记住这个IP

后面使用这个ip来访问它

客户端软件连接Linux

下载Bitvise SSH Client软件

安装各种下一步即可

安装后桌面出现下面图标

这个软件是远程链接linux的客户端

双击这个图标进入虚拟机连接界面

我们使用这个软件连接成功后

就可以用这个软件提供的界面来操作Linux了

无论是虚拟机还是实际的物理服务器

我们想远程的控制这个机器的话,都需要使用专门的客户端连接服务器,并向服务器发送指令

这个软件就能完成这个功能

Docker概述

什么是Docker

我们要学习在Linux(RockyLinux)中安装使用Docker来配置软件的功能

Docker是一个用来开发、运输和运行应用程序的开放平台。使用Docker可以将应用程序与基础结构分离,以便快速交付软件。使用Docker,您可以以管理应用程序的方式管理基础架构。通过利用Docker的方法快速传送、测试和部署代码,可以显著减少编写代码和在生产中运行代码之间的延迟。

为什么使用Docker

  • 更快速的应用交付和部署:

传统的应用开发完成后,需要提供一堆安装程序和配置说明文档,安装部署后需根据配置文档进行繁杂的配置才能正常运行。Docker化之后只需要交付少量容器镜像文件,在正式生产环境加载镜像并运行即可,应用安装配置在镜像里已经内置好,大大节省部署配置和测试验证时间。

  • 更便捷的升级和扩缩容:

随着微服务架构和Docker的发展,大量的应用会通过微服务方式架构,应用的开发构建将变成搭乐高积木一样,每个Docker容器将变成一块“积木”,应用的升级将变得非常容易。当现有的容器不足以支撑业务处理时,可通过镜像运行新的容器进行快速扩容,使应用系统的扩容从原先的天级变成分钟级甚至秒级。

  • 更简单的系统运维:

应用容器化运行后,生产环境运行的应用可与开发、测试环境的应用高度一致,容器会将应用程序相关的环境和状态完全封装起来,不会因为底层基础架构和操作系统的不一致性给应用带来影响,产生新的BUG。当出现程序异常时,也可以通过测试环境的相同容器进行快速定位和修复。

  • 更高效的计算资源利用:

Docker是内核级虚拟化,其不像传统的虚拟化技术一样需要额外的Hypervisor [管理程序] 支持,所以在一台物理机上可以运行很多个容器实例,可大大提升物理服务器的CPU和内存的利用率。

Docker运行架构图

Docker相关资料

Docker官网:Docker: Accelerated, Containerized Application Development

Docker Hub官网(镜像仓库):Docker

所有步骤,均参考官方文档:Install Docker Engine on CentOS

安装Docker

官方网站提供的3种安装方式

  • 设置docker仓库,并且从仓库安装所需内容。

  • 下载RPM安装包,手动更新安装。

  • 为测试和开发环境使用自定义脚本安装。

三种方式中第一种,从docker仓库中安装是比较简单的

这种方式需要当前虚拟机有网络环境

确定具备网络环境再运行下面命令

RockyLinux支持使用yum命令安装各种程序

yum命令安装程序类似手机中的应用商店一样

先安装yum-utils包,实现更方便的安装"应用商店"中提供的程序

yum install -y yum-utils

指定docker仓库路径

yum-config-manager \
    --add-repo \
    https://download.docker.com/linux/centos/docker-ce.repo

阿里仓库路径

http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo

yum-config-manager \
    --add-repo \
    http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo

执行安装Docker

也是使用yum命令

yum -y install docker-ce docker-ce-cli containerd.io

下载和安装需要一些时间,如果下载慢,配置上面的阿里Docker仓库地址

运行完成后,当前Linux系统就安装好的Docker

我们需要输入启动Docker的命令,真正让Docker运作起来

systemctl start docker

测试Docker

为了保证我们Docker运行的正常

Docker提供了一个专门测试Docker功能的镜像

docker run hello-world

运行可能需要较短时间

运行结果中出现如下内容,表示一切正常

Hello from Docker!

This message shows that your installation appears to be working correctly.

docker名词解释

  • 容器(container)

首先需要了解什么是容器,容器就是一个进程,内部是独立运行的一个或者是一组应用。它可以被启动、开始、停止、删除。每个容器都是相互隔离的,保证安全的平台。

  • 镜像(image)

镜像(Image)就是一个只读的模板文件。镜像可以用来创建 Docker 容器,一个镜像可以创建很多容器。 就好似 Java 中的 类和对象,类就是镜像,对象就是容器!也可以把镜像看成是模具,而镜像创建出来的容器就是通过这个模具创建的一个一个的实际产品。

  • 宿主机(host)

宿主机就是我们调用命令使用镜像创建容器的服务器(linux)。

  • 镜像仓库(repository)

一个用来容纳多个镜像的仓库,可以链接仓库获取你想要的内部镜像,一般一个镜像仓库中包含多个不同tag的镜像。

  • 镜像服务器(registry)

镜像仓库占用的服务器,这里注意一个镜像服务器未必只有一个仓库,可以有很多仓库,每个仓库又保管的是不同镜像。

  • 客户端(docker-client)

调用docker命令,操作镜像,容器的进程。只要能链接宿主机,操作docker的进程都是docker-client。

Docker基础命令

Docker命令格式

Docker命令的语法结构

docker 子命令 [选项]

docker都有哪些子命令呢,我们可以使用docker的helper子命令查看

docker --help

如果想查询具体的子命令的使用方式

docker 子命令 --help

我们最常用的子命令之一:启动docker容器的run的相关帮助可以

docker run --help

当然也可以查询官方文档或百度了解更多内容

Reference documentation

images命令

docker images命令主要能够完成查看当前本地镜像仓库的功能

docker images

这个命令的返回结果显示:

  • REPOSITORY:镜像仓库名,也叫作镜像名。

  • TAG:标签,常用版本号标识仓库,如果是latest就是最新版本。

  • IMAGE ID:镜像id。

  • CREATED:创建镜像时间。

  • SIZE:大小。

docker images命令的常用选项如下

  • -a: 显示所有信息

  • -q: 只显示镜像id,在镜像较多的时候比较常用

search命令

在拉取镜像之前,我们要先明确正确的镜像名称

我们可以输入查询关键字,对镜像仓库进行搜索

search命令,可以搜索当前系统绑定的远程镜像服务器中的内容

docker search mysql

这个命令的返回结果显示:

  • NAME:镜像名称。

  • DESCRIPTION:镜像描述。

  • STARS:镜像星级,越高表示越热,使用人越多。

  • OFFICIAL:是否官方镜像。

  • AUTOMATED:是否支持自动化部署。

pull命令

根据我们上面搜索得到的结果

确认关键字之后

我们就可以使用pull命令将我们查询出的软件拉取到本地仓库了

docker pull [镜像名称]
docker pull mysql

默认下载最新版本

如果要指定版本号可以在名称后指定

docker pull mysql:5.7.35

至于要指定的版本号有什么

可以在相关的官方网站查询

rmi命令

删除本地镜像的命令

一般当本地镜像较多,过多的占用空间时,可以删除一些不会再使用的镜像文件,节省空间

删除镜像需要明确镜像的id,所以一般都会配合docker images使用

rmi: remove image

docker rmi feb5d9fea6a5

如果当前要删除的镜像有实例正在运行,使用上面命令删除会有报错

意思就是正在使用的镜像无法删除

我们如果需要强制将当前镜像删除,并同时停止这个服务的话,就需要加-f选项

docker rmi feb5d9fea6a5 -f

rmi命令的选项:

  • -f:强制删除这个镜像,无论是否正在使用。

run命令

docker run命令,可以指定当前本地镜像文件启动,

启动镜像文件会在Docker中生成新的容器,容器中来运行镜像中包含的软件

docker run的命令,选项比较多,例如设置端口号,设置运行模式,调整运行参数,运行位置等

最后的mysql是镜像名称默认最新版,如果需要指定版本号可以编写下面代码

docker run --name mysql -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -v /root/data:/var/lib/mysql mysql:5.7.35

1: --name mysql:该容器启动后的名字:(自定义命名)如果没有设置,系统会自动设置一个,毕竟如果开启太多的容器,记不住就很尴尬,建议加上见名知意。

2:-d 代表后台启动该服务

3:-p 3306(这是liunx的端口号,我习惯说成宿主机,如果我们想要远程服务的话,访问的端口就是这个端口):3306(docker容器的端口,每一个容器都是独立的,可理解成操作系统层面的系统),访问这个端口就是先通过远程访问宿主机的端口,再映射到docker容器的端口访问mysql。

4:-e MYSQL_ROOT_PASSWORD=123456 这是说mysql启动需要的开机密码,默认的账号是root ,密码就是上面设置的:123456

5:-v /root/data:/var/lib/mysql /root/data/:这是宿主机的数据存放路径(你也可以自定义), /var/lib/mysql:这是mysql容器存放数据的地方。也是为了同步数据,防止,容器被删除以后,数据就不存在了。

6:启动成功后就返回一个容器ID

启动之后,我们就可以使用数据库连接工具访问数据库了

启动redis

docker search 搜索"redis"

拉取镜像参考之前的笔记

docker pull redis

自己搜docker启动Redis的命令

docker run -itd --name redis-test -p 6379:6379 redis

启动之后可以本地访问客户端

docker exec -it redis-test redis-cli

ps命令

docker ps

可以查看当前docker中运行的所有容器的状态

ps命令中常见的选项如下:

  • -a:显示所有容器,如果不加只显示正在启动运行的容器,停止的不会显示。

  • -l:显示最近的启动创建的容器。

  • -n=[数字]:显示最近n个容器。

  • -q:只显示容器id。经常和-a一起使用,获得当前宿主机所有容器id参数集合。

ps命令显示的标题内容如下

  • container id:容器id,很多操作容器命令都需要用到的参数。

  • image:容器创建使用的镜像。

  • command:容器中在运行的进程或者命令。

  • created:创建时间。

  • status:容器状态。

  • ports:容器的端口映射情况,这里没有用到端口。

  • names:容器的名字,启动没有指定--name选项,会默认使用一个名字。

stop\rm命令

docker stop 可以停止正在运行的容器

stop只是停止容器.并不会删除容器

如果想删除容器需要使用rm命令

注意:这里rm删除的是容器,不是本地镜像,和rmi命令要区分

首先使用docker ps命令查询所有正在运行的容器

docker stop [容器id]
docker rm [容器id]

rm的选项有:

-f:强制删除容器,无论是否运行都可以删除该容器,如果不加,运行的容器无法删除。

关闭防火墙

如果当前windows系统要连接Linux中的资源

一般都要关闭Linux的防火墙

实际开发中,不会彻底关闭防火墙,而是开放指定的端口号

systemctl stop firewalld

system:系统

ctl:control:控制

酷鲨商城前台虚拟机

给大家的大压缩包

Virtualbox 4.15G

VMware 3.55G

解压之后启动

Linux:

用户名:root

密码:12345678(不要用小键盘)

启动后使用

ifconfig | more

观察ip地址

可以利用HS尝试连接虚拟机的数据库

一般情况下ip地址为:192.168.137.150, 10.1.6.75

数据库密码是:

tarena2017Up;

注意分号也是密码的一部分,不写连不上

注意如果需要停止虚拟机,选择快速休眠,不要关机

酷鲨商城前台项目配置修改

因为现版本酷鲨商城连接的都是本地软件(mysql\redis\nacos等)

下面我们要连接的是虚拟机软件,所以要修改一下java项目的配置文件

front

addr: 192.168.137.150

mall-leaf

leaf.properties

leaf.jdbc.url=jdbc:mysql://192.168.137.150:3306/leafdb?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true

leaf.jdbc.password=tarena2017Up;

mall-order

addr: 192.168.137.150

password: tarena2017Up;

mall-product

addr: 192.168.137.150

password: tarena2017Up;

mall-search

addr: 192.168.137.150

mall-seckill

addr: 192.168.137.150

password: tarena2017Up;

rabbitmq:
    host: ${my.server.addr}
    port: 5672
    username: user
    password: 123456
    virtual-host: /

mall-sso

addr: 192.168.137.150
# 有两个密码要改!!!!
    admin
      password: tarena2017Up;
    user
      password: tarena2017Up;

设置完毕之后,我们可以尝试测试其中的功能

例如新增订单

无需启动任何windows的软件(nacos\seata都不需要启动)

只需要启动模块内容即可

Leaf\product\passport\order

测试没问题表示虚拟机在正常工作

布隆过滤器的测试

因为上面我们启用了虚拟机

我们在虚拟机中安装的redis是一个特殊版本的Redis

这个版本内置了操作布隆过滤器的lua脚本,支持布隆过滤的方法

我们可以直接使用,实现布隆过滤器

csmall-stock-webapi的pom文件 添加依赖

<!--  redis依赖   -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

在dev-yml文件中添加redis的配置

spring:
  redis:
    host: 192.168.137.150
    port: 6379
    password:

操作布隆过滤器有一个专门的类

实现对布隆过滤器的新增元素,检查元素等方法的实现

在酷鲨前台大项目中的seckill-webapi下的utils包里

RedisBloomUtils类复制到需要使用布隆过滤器的项目中

当前Stock模块

有一个周期输出时间的方法

我们可以借助这个运行,测试布隆过滤器的功能(如果这个方法已经编写了别的代码可以先注释掉)

quartz包下QuartzJob

// 装配操作布隆过滤器的对象
    @Autowired
    private RedisBloomUtils redisBloomUtils;
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 因为当前是简单演示功能,所以就输出一个当前时间即可
        log.info("-----------------"+ LocalDateTime.now() +"-------------------");
        // 定义一个数组,这里使用字符串值做元素,保存到布隆过滤器中
        String[] colors={"red","origin","yellow","green","blue","while","pink"};
        final String COLOR_BLOOM="color_bloom";
        // 下面代码实现将数组元素保存到布隆过滤器
        redisBloomUtils.bfmadd(COLOR_BLOOM,colors);
        // 我们使用的redisBloomUtils是包含操作布隆过滤器脚本的类,需要时直接复制使用就可以
        // 这个对象创建的布隆过滤器默认情况下是100个元素是误判率1%,如果需要修改,可以调用它的api
        // 声明一个元素
        String el="blue";
        // 判断是否在布隆过滤器中
        System.out.println(el+"是否在colors数组中:"+
                redisBloomUtils.bfexists(COLOR_BLOOM,el));
    }
}

秒杀业务完善

秒杀准备时加载布隆过滤器

我们在开发秒杀业务时,事先进行了准备工作

在秒杀开始前5分钟,预热了sku的库存数和spu的随机码

但是没有将当前批次的spuId保存在布隆过滤器中

导致业务有漏洞(缓存穿透)

现在,我们准备了支持布隆过滤器的Redis

我们可以将允许用户查询到的秒杀商品,保存到布隆过滤器中

这样非当前秒杀商品,用户就不能查询数据库了,防止缓存穿透

布隆过滤器要想使用,需要先经历生成阶段,再经历判断阶段

我们对布隆过滤器的生成也是预热性质的,在秒杀开始之前编写Quartz框架的Job实现类

在seckill-webapi模块中

seckill.timer.job包中,新建SeckillBloomJob

@Slf4j
public class SeckillBloomJob implements Job {

    // 装配操作布隆过滤器的类
    @Autowired
    private RedisBloomUtils redisBloomUtils;
    // 装配查询数据库中所有秒杀spuId的mapper
    @Autowired
    private SeckillSpuMapper seckillSpuMapper;

    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 这个方法也是缓存预热,运行时机在秒杀开始前
        // 先获得布隆过滤器的key
        // spu:bloom:filter:2023-02-09
        String bloomKey= SeckillCacheUtils.getBloomFilterKey(LocalDate.now());
        // 将数据库中所有参与秒杀的商品查询出来直接查询数据库即可
        Long[] spuIds=seckillSpuMapper.findAllSeckillSpuIds();
        // redisBloomUtils操作数组要求时字符串类型的,所以要转换一下
        String[] spuIdsStr=new String[spuIds.length];
        // 遍历spuIds数组,将其中元素转换为String类型然后赋值到spuIdsStr数组中
        for(int i=0;i<spuIds.length;i++){
            spuIdsStr[i]=spuIds[i]+"";
        }
        // 将赋好值的spuIdsStr添加到布隆过滤器中
        redisBloomUtils.bfmadd(bloomKey,spuIdsStr);
        log.info("布隆过滤器加载完成!");

    }
}

下面在seckill.timer.config包中添加布隆过滤器相关的调度配置

继续在QuartzConfig类中添加绑定信息

@Bean
public JobDetail bloomJobDetail(){
    return JobBuilder.newJob(SeckillBloomJob.class)
            .withIdentity("bloomJobDetail")
            .storeDurably()
            .build();
}
​
@Bean
public Trigger bloomTrigger(){
    CronScheduleBuilder cron=
            CronScheduleBuilder.cronSchedule("0 0/1 * * * ?");
    return TriggerBuilder.newTrigger()
            .withSchedule(cron)
            .forJob(bloomJobDetail())
            .withIdentity("bloomTrigger")
            .build();
}

下面可以测试布隆过滤器的运行

保证虚拟机启动正常

启动product\seckill

如果没有虚拟机的同学,敲一遍代码熟悉即可

布隆过滤器判断spuId是否存在

现在Redis中保存了布隆过滤器

我们需要用户根据SpuId查询商品时,进行判断和过滤

如果spuId不存在,就应该发生异常,给出提示

SeckillSpuServiceImpl类中getSeckillSpu进行修改,添加布隆过滤器的判断

// 判断布隆过滤器中是否包含指定元素的对象
@Autowired
private RedisBloomUtils redisBloomUtils;

@Override
public SeckillSpuVO getSeckillSpu(Long spuId) {
    // 在后面完整版代码中,这里是要编写经过布隆过滤器判断的
    // 只有布隆过滤器中存在的id才能继续运行,否则发生异常
    // 获得布隆过滤器的key
    String bloomKey=SeckillCacheUtils.getBloomFilterKey(LocalDate.now());
    log.info("当前布隆过滤器中key为:{}",bloomKey);
    if(!redisBloomUtils.bfexists(bloomKey,spuId+"")){
        // 进入这个if表示当前商品id不在布隆过滤器中
        // 防止缓存穿透,抛出异常
        throw new CoolSharkServiceException(
                ResponseCode.NOT_FOUND,"您访问的商品不存在(布隆过滤器生效)");
    }
    // 之后代码无修改!
    //.....
}

重启Seckill模块

访问10007

查询SpuId如果不存在于秒杀表中,是否能被过滤器拦截

ELK简介

什么是ELK

ELK:

E:Elasticsearch 全文搜索引擎

L:logstash 日志采集工具

K:Kibana ES的可视化工具

ELK是当今业界非常流行的日志采集保存和查询的系统

我们编写的程序,会有很多日志信息,但是日志信息的保存和查询是一个问题

idea控制台是临时显示的位置,我们可以将它保存在文件中

但是即使保存在文件中,海量日志信息要想查询需要的条目也是问题

所以我们使用ELK来保存

为什么需要ELK

保存并能够快速便捷的查询查看日志信息就是新出现的需求了

ELK这个组合可以完成这个任务

Elasticsearch负责将日志信息保存,查询时可以按关键字快速查询

那么这些日志怎么收集呢?

利用logstash这个软件可以监听一个文件,将这个文件中出现的内容经过处理发送到指定端口

我们就可以监听我们程序输出的日志文件,然后将新增的日志信息保存到ES中

Kibana来负责进行查询和查看结果

日志的管理工具还有一套叫链路追踪

和ELK有类似的效果,感兴趣的同学可以自己搜索

Logstash

什么是logstash

Logstash是一款开源的日志采集,处理,输出的软件,每秒可以处理数以万计条数据,可以同时从多个来源采集数据,转换数据,然后将数据输出至自己喜欢的存储库中(官方推荐的存储库为Elasticsearch)

上面图片数据源可以是任何产生数据的介质,数据库,redis,java的日志文件均可

输出目标一般也是能够保存数据的媒体,数据库,redis,ES等

Logstash内部有3个处理数据的步骤

  • input 将数据源的数据采集到Logstash

  • filter (非必要)如果需要可以对采集到的数据进行处理

  • output 将处理好的数据保存到目标(一般就是ES)

其中采集数据的用法比较多样,还支持各种插件

logstash实现数据库和ES数据的同步

logstash还有一个非常常见的用法

就是能够自动完成数据库数据和ES中数据的同步问题

实现原理

我们可以配置logstash监听数据库中的某个表

一般设计为监听表中数据的变化,在规范的数据表结构中,logstash可能监听gmt_modified列

只要gmt_modified列数据有变化,就收集变化的数据行,将这行数据的信息更新到ES

下面我们就在虚拟机环境下实现搜索操作

实现虚拟机ES搜索功能

之前我们已经修改了yml文件,将搜索的目标更换为虚拟机中的ES

在虚拟机的连接环境中,我们使用SpuEntity来实现ES的连接

我们可以看到SpuEntity类中没有任何编写分词的属性

原因是为了更高效的实现分词,logstash将所有需要分词的列拼接组合成了一个新列search_text

当需要查询时只需要查询search_text字段即可

添加新的持久层

在search-webapi模块中的repository包下,创建新的持久层接口SpuEntityRepository

@Repository
public interface SpuEntityRepository extends ElasticsearchRepository<SpuEntity,Long> {
    // 要实现根据用户输入的关键字,查询ES中的商品列表
    // logstash将所有商品的spu信息(name,title,description,category_name)拼接成了一个字段
    // 这个字段叫search_text,我们搜索时只需要搜索这一个字段,就满足了之前设计的搜索需求
    // 因为search_text字段并没有在SpuEntity中声明,所以不能用方法名称查询,只能使用查询语句
    @Query("{\"match\":{\"search_text\":{\"query\":\"?0\"}}}")
    Page<SpuEntity> querySearchByText(String keyword, Pageable pageable);
}

实现数据同步

业务逻辑层的修改

上次课完成了ES数据同步中,SpuEntity使用到的持久层接口SpuEntityRepository

下面要开发业务逻辑层

因为业务使用的实体类变化,所以要先到业务逻辑层接口中,修改接口方法的返回值泛型

ISearchService接口中的返回值泛型也要修改一下

public interface ISearchService {
​
    // ES分页查询spu的方法
    //       ↓↓↓↓↓↓↓↓↓
    JsonPage<SpuEntity> search(String keyword, Integer page, Integer pageSize);
​
    // 向ES中加载数据的方法
    void loadSpuByPage();
}

删除或注释原有的业务逻辑层代码

新建SearchRemoteServiceImpl实现ISearchService接口,代码如下

@Service
@Slf4j
public class SearchRemoteServiceImpl implements ISearchService {
​
    // 装配包含查询方法的持久层
    @Autowired
    private SpuEntityRepository spuEntityRepository;
​
    @Override
    public JsonPage<SpuEntity> search(String keyword, Integer page, Integer pageSize) {
        Page<SpuEntity> spus=spuEntityRepository
                .querySearchByText(keyword, PageRequest.of(page-1,pageSize));
        // 分页查询调用结束,返回Page对象,我们要转换为JsonPage对象返回
        JsonPage<SpuEntity> jsonPage=new JsonPage<>();
        // 赋值相关数据
        jsonPage.setPage(page);
        jsonPage.setPageSize(pageSize);
        jsonPage.setTotalPage(spus.getTotalPages());
        jsonPage.setTotal(spus.getTotalElements());
        jsonPage.setList(spus.getContent());
        // 最后别忘了返回!
        return jsonPage;
    }
​
    @Override
    public void loadSpuByPage() {
​
    }
}

修改控制层代码

修改SearchController中的代码

//                        ↓↓↓↓↓↓↓↓↓↓↓
public JsonResult<JsonPage<SpuEntity>> searchByKeyword(
        String keyword,Integer page, Integer pageSize){
    //      ↓↓↓↓↓↓↓↓↓↓↓
    JsonPage<SpuEntity> jsonPage=
            searchService.search(keyword,page,pageSize);
    return JsonResult.ok(jsonPage);
}

测试前先保证启动支持酷鲨商城项目的虚拟机镜像

启动product/passport/search

先进行jwt登录的复制,复制到search模块的全局参数中

可以搜索手机查询效果

如果不能查询出数据库中正确的对应的信息

需要在数据库工具软件中运行下面代码

USE mall_pms;
UPDATE pms_spu SET gmt_modified=NOW() WHERE 1=1;

以激活logstash对spu表的监听,并向ES中保存数据

这个操作可能有些延迟,稍等即可

再次查询,就有能够正确搜索信息了!

Logstash下ES的运行流程

安装配置好相关软件后

logstash会自动监听指定的表(一般指定监听gmt_modified列)

当gmt_modified列值变化时,logstash就会收集变化的行的信息

周期性的向ES进行提交

数据库中变化的数据就会自动同步到ES中了

这样,我们在程序中,就无需编写任何同步ES和数据库的代码

配置中心

什么是配置中心

所谓配置中心:在微服务的环境下,将项目需要的配置信息保存在配置中心,需要读取时直接从配置中心读取,方便配置管理的微服务工具

我们可以将部分yml文件的内容保存在配置中心

一个微服务项目有很多子模块,这些子模块可能在不同的服务器上,如果有一些统一的修改,我们要逐一修改这些子模块的配置,由于它们是不同的服务器,所以修改起来很麻烦

如果将这些子模块的配置集中在一个服务器上,我们修改这个服务器的配置信息,就可以修改所有子模块的信息,这个服务器就是配置中心

使用配置中心的原因就是能够达到高效的修改各模块配置的目的

配置中心的使用

Nacos既可以做注册中心,也可以做配置中心

Nacos做配置中心,支持各种格式\类型的配置文件

properties\yaml(yml)\txt\json\xml等

Nacos数据结构

namespace:命名空间

group:分组

Service/DataId:具体数据

命名空间

namespace是Nacos提供的最大的数据结构

一个Nacos可以创建多个命名空间

一个命名空间能够包含多个group

每一个group中又可以包含多条配置信息

在nacos中新建命名空间

在上图连接的位置可以新增命名空间,填写命名空间名称和描述即可

Nacos有默认的命名空间public不能删除和修改

添加命名空间后,我们在Nacos中注册的服务或添加的配置就可以指定命名空间了

因为多个命名空间可以隔离项目,每个项目使用自己的命名空间,互不干扰

分组

一个命名空间中可以有多个分组,进行进一步分离

我们使用时,如果不需要进一步分组,推荐使用group名称:DEFAULT_GROUP

服务或配置

确定了命名空间和分组之后

我们就可以添加服务或配置了

之前我们启动的各种模块都是服务,这些服务都是默认保存在public命名空间中

下面我们主要使用配置中心的功能,在命名空间中添加配置

添加配置就是设置DataId

实际在Nacos中定位一个配置的结构为

Namespace>Group>DataId

Nacos添加配置

完成cart模块的数据库配置信息添加到nacos的操作

启动nacos(建议使用本地nacos,删除配置列表中所有配置信息,再进行添加)

Nacos首页->配置管理->配置列表->添加配置(右侧的大"+"号)

添加cart模块数据库连接配置

我们删除了cart模块原有的连接数据库信息的配置

下面开始,我们尝试连接nacos配置中心,读取数据库配置信息,令cart模块仍然能够正常连接数据库

项目读取配置

csmall-cart-webapi模块要读取连接数据库的配置

如果要读取配置中心的信息,首先要添加依赖

<!--  Nacos配置中心的依赖  -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 支持SpringCloud项目加载\读取系统配置的依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

如果当前我们使用的SpringCloud版本不是2020.x以后的版本

那么上面添加的依赖会有不一样的情况,要想去了解就要查阅相关资料


像其他程序一样,在添加完依赖之后,还需要添加yml文件的配置

借此机会讲解一下SpringCloud项目的配置文件加载顺序

我们学习过的配置文件大体有application.properties和application.yml两种配置文件添加配置

这两个文件如果同时存在,他们的配置都可以同时生效

但是如果两个文件配置有冲突(对同一个属性配置两次)

那么就要看加载顺序了

  • 先加载application.yml

  • 后加载application.properties

如果两个配置文件同时设置了同一个属性,后加载的覆盖掉先加载的


在添加上面的pom文件依赖之后,SpringCloud项目就又多了一组配置文件

它们是bootstrap.yml和bootstrap.properties

这组配置文件是SpringCloud项目才能使用的

它的作用是实际开发时,主要配置系统内容,一般都是不轻易修改的

所以这组配置文件的加载时机,整体早于application这一组

一个SpringCloud项目加载配置文件的顺序最终可能如下图

因为配置文件的特性,bootstrap这一组是加载系统配置的

所以我们读取配置中心的配置信息,最好添加在bootstrap.yml\properties中

下面我们就在cart-webapi项目中resources文件夹中添加bootstarp.yml文件

spring:
  cloud:
    nacos:
      config:
        # 设置配置中心的ip和端口
        server-addr: localhost:8848
        # namespace: 是可以设置命名空间的,默认public,可以省略
        # group默认DEFAULT_GROUP默认也可以省略
        group: DEFAULT_GROUP
        # 指定配置文件的后缀名
        file-extension: yaml
        # 配置中心约定,当确定命名空间和分组名称以及后缀名之后
        # 当前项目会从配置中心中自动读取配置文件[模块注册名称].[配置的后缀名]的配置信息
        # 当前项目注册到nacos的名称是nacos-cart,后缀名为yaml
        # 所以会自动从配置中心读取配置名称为nacos-cart.yaml的信息

如果一切顺利,那么cart模块是可以通过knife4j测试操作连接数据库的

我们可以新增购物车信息,或删除购物车信息后检查数据库是否有对应操作

因为我们已经删除了本地yml文件中数据库的配置,所以,它能连接操作数据库一定是配置中心生效了!

RestTemplate远程调用

我们现在项目中使用的RPC远程调用技术是Dubbo

实际上除了Dubbo技术之外,还有很多远程调用的方法

它们有些调用的思想都和Dubbo完全不同

Dubbo是SpringCloudAlibaba提供的功能强大的RPC框架

但是Dubbo功能也有限制,如果我们想调用的方法不是我们当前项目的组件或功能,甚至想调用的方法不是java编写的,那么Dubbo就无能为力了

我们可以使用RestTemplate来调用任何语言编写的公开的Rest路径

也就是只要能够使用浏览器访问的路径,我们都可以使用RestTemplate发送请求,接收响应

使用步骤如下

步骤1:

无需添加任何pom依赖和yml文件配置

只需在调用的发起方,也就是使用RestTemplate发起请求的项目配置文件中(指支持@Configuration注解的类或SpringBoot启动类),向Spring容器注入一个该类型对象

所有SpringBoot配置类都可以添加下面代码,我们选择SpringBoot启动类添加如下代码

在CartWebapi启动类中修改

@SpringBootApplication
//  略....
public class CsmallCartWebapiApplication {

    public static void main(String[] args) {
        SpringApplication.run(CsmallCartWebapiApplication.class, args);
    }

    @Bean
    // 启动负载均衡的注解,因为Dubbo自带负载均衡,但是RestTemplate是代替Dubbo的,需要单独设置
    @LoadBalanced
    public RestTemplate restTemplate(){
        return new RestTemplate();
    }

}

步骤2:

RestTemplate是一个能够发送请求到指定url路径的工具

我们项目中有若干控制器方法,都可以作为它调用的目标

我们可以实现在cart模块运行删除购物车商品的同时减少库存

这样就要求我们stock模块具备一个能够减少库存的控制器方法,

现在这个方法是有的,url为:/base/stock/reduce/count

但是RestTemplate调用时请求以get方法居多,post方法调用代码比较繁琐,所以将现在stock模块减少库存的方法由之前的@PostMapping修改为@GetMapping

@GetMapping("/reduce/count")

步骤3:

回到cart-webapi模块

开始调用

我们可以在当前CartController控制器方法deleteUserCart中

添加RestTemplate的调用,实现库存的减少

// 装配RestTemplate调用远程方法
@Autowired
private RestTemplate restTemplate;
​
@PostMapping("/delete")
@ApiOperation("删除购物车中的商品")
@ApiImplicitParams({
   @ApiImplicitParam(value = "用户Id",name="userId",example = "UU100"),
   @ApiImplicitParam(value = "商品编号",name="commodityCode",example = "PC100")
})
public JsonResult deleteUserCart(String userId,String commodityCode){
    cartService.deleteUserCart(userId,commodityCode);
    // RestTemplate调用减少库存的方法
    // 我们设计删除购物车之后,减少库存的操作
    // 要调用的stock的/base/stock/reduce/count,所以调用前先确定url
    String url="http://localhost:20003/base/stock/reduce/count?" +
            "commodityCode={1}&reduceCount={2}";
    // 发起调用
    // getForObject方法参数和返回值的解释
    // 参数分3个部分
    // 1.第一个参数:请求的url
    // 2.第二个参数:调用的方法的返回值类型,要求编写类型的反射
    // 3.第三个参数开始:往后的每一次参数都是在给url中{x}的占位符赋值
    //      第三个参数赋值给{1},第四个参数赋值给{2}  以此类推
    JsonResult jsonResult = restTemplate.getForObject(
            url, JsonResult.class, commodityCode, 5);
    System.out.println(jsonResult);
    return JsonResult.ok("删除购物车完成!");
}

步骤4:

发送测试

将相关的模块都启动

Nacos\Seata

cart\stock

调用cart模块删除购物车中的方法

运行后检查对应商品减少库存和购物车中数据的删除效果

如果一切正常证明调用成功了

SpringCloudNetflix系统下,RestTemplate实现有一个别名叫Ribbon

如果说"Ribbon"调用,指的就是RestTemplate对象的调用过程

什么是web服务器

简单来说

Web服务器就是一个能够接收http请求并作出响应的程序

webServer项目(二阶段项目)就是一个我们手写的Web服务器

我们现在开发的标准SpringBoot项目启动时内置的Web服务器叫Tomcat

实际上我们业界中还有很多Web服务器,它们具备很多不同的特征

网关Gateway项目使用Netty服务器,Netty服务器内部是NIO的所以性能更好

下图以Tomcat为例,解释请求响应流程

大多数情况我们会使用Tomcat作为Web服务器

它是我们请求\响应流程中的核心组件

Tomcat是也有缺点

常规情况下,一个tomcat并发数在100多一点

一般情况下,一个网站要1000人在线,并发数是2%~5% 也就是20~50并发

如果需要一个支持更高并发的服务器,就是需要使用Nginx

Nginx

Nginx ("engine x") 是一个高性能的 HTTP 和 反向代理 服务器,也是一个IMAP/POP3/SMTP 代理服务器。 Nginx 是由 Igor Sysoev 为俄罗斯访问量第二的Rambler.ru 站点开发的,第一个公开版本 0.1.0 发布于 2004 年 10 月 4 日。其将源代码以类 BSD 许可证的形式发布,因它的稳定性、丰富的功能集、示例配置文件和低系统资源的消耗而闻名。

Nginx的优势

  1. 高并发响应性能非常好,官方 Nginx 处理静态文件 5万/秒

  2. 反向代理性能非常强。(可用于负载均衡)

  3. 内存和 cpu 占用率低。(为 Apache(也是一个服务器) 的 1/5-1/10)

又小又快

Nginx快速的原因

常见面试题:Nginx为什么快

Nginx使用NIO来实现,是它能快速的主要原因之一

从Nginx内部的结构上和运行流程上,它内部是一个主进程(Master)多个工作进程(Worker)

Master负责统筹管理配置和Worker的分工

Worker来负责处理请求,作出响应

而且使用NIO既非阻塞式的,异步的来完成工作

简单来说,就是一个Worker接到从Master分配来的一个请求后,会立即对请求进行处理,但是在请求发送完成后,还没有返回响应前,Worker会继续处理别的请求,直到返回响应时,这个Worker才会去处理响应,最终每条Worker进程全程无阻塞

正向代理

要想了解反向代理,首先了解正向代理

正向代理,当我们访问的目标服务器无法连通时,可以借助代理服务器,间接访问该目标服务器

关系类似于生活中的介绍人反向代理

而反向代理的模式为下图

请求反向代理服务器的特点是,我们请求的是代理服务器的地址,真正提供服务的服务器地址我们不需要知道,这样做的好处是反向代理服务器后可能是一个服务器集群,方便负载均衡

有点类似于生活中的代理人(中介),有什么事情直接找这个人就能完成需求,它怎么完成的我们不用管

Nginx的使用

实际开发中,Nginx可以用于反向代理服务器,

实际处理请求的是Tomcat服务器

因为Nginx优秀的静态内容并发性能

我们常常使用它做静态资源服务器

在Nginx中保存图片,文件视频等静态资源

经常和FastDFS组合使用

FastDFS是一个开源的轻量级分布式文件系统,它对文件进行管理,功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等,解决了大容量存储和负载均衡的问题。特别适合以文件为载体的在线服务,如相册网站、视频网站等等。

Nginx和Gateway的区别

首先明确Nginx和Gateway并不冲突

他们都是统一入口的概念,它们可以同时开启

也可以开启其中一个

只不过Nginx不属于java程序(不属于微服务模块),而Gateway是java程序,而且是微服务的一部分

Nginx是服务器程序我们不可编辑,

Gateway是我们自己创建的项目,依赖和配置都由我们自己完成,能实现的功能更多

最终如果想做反向代理服务器,就使用Nginx

如果是微服务项目的网关就是Gateway

Linux部署java项目

启动虚拟机

这部分的操作之前学习虚拟机时已经做过,可以参照之前的笔记即可

推荐大家重新解压纯净版的RockyLinux来实现

启动后登录rockylinux

sudo su -

修改root用户密码

passwd

下面就切换到客户端软件连接虚拟机

ifconfig
ifconfig | more

查看ip地址使用Bvssh软件连接

安装java环境

连接成功后,打开命令行界执行安装java的命令

yum install java

运行过程中出现了y/N的选择,要输入y之后按回车

安装需要时间,等待即可

yum安装好的java会自动配置环境变量

验证安装

java -version

创建java项目

按照创建普通SpringBoot项目的流程创建即可

注意需要勾选 Spring Web依赖

项目创建好之后,在项目的resources目录下static下创建html文件(建议叫index.html)或复制需要的资源

内容编写完毕

在侧边栏选中maven运行package打包命令

打包成功后找到jar包生成的位置

进入这个位置,找到生成的jar包

将生成的jar包拖入Bvssh软件提供的复制文件的工具中

转到linux命令行

输入ll(两个小写的L) 或者是ls

ll

观察当前目录是否有我们拖入的jar文件

如果存在执行运行jar文件的命令

java -jar [jar包名称]

[jar包名称]可以敲前面几个字母,按Tab自动补全

如果顺利启动,会看到Spring的图形Logo

下面关闭防火墙(启动Springboot项目的界面不能关,可以新开一个界面输入命令)

systemctl stop firewalld

然后可以在windows中打开浏览器访问项目

如果你愿意购买云服务器

就可以将当前项目用同样的办法,部署到云服务器中,可以让全世界人访问了

项目命名

玉洁摄影社团

摄影为主导的同好会网站,包含交流互动,知识问答,电商购物等功能

我负责的是电商的模块

使用的技术

SpringCloud微服务分布式项目

Nacos\Dubbo\Seata\Sentinel\Gateway\Elasticsearch\Redis\RabbitMQ

我负责的模块

  • 登录注册及鉴权

  • 商品分类

  • 商品详情

  • 购物车管理

  • 新增订单,修改订单状态

  • Elasticsearch搜索功能

  • 秒杀

常见问题:

这个项目上线没有?

上线了

这个地址是什么?

不知道

这个项目多少人在线,多少并发?

20000人在线,每秒请求数2000左右 500并发,日活跃用户50000人左右

这个项目搭建了多少台服务器?

25~30台

你的项目有多少张表?

电商相关的表大概30张左右,我常用的20张

你在做这个项目的过程中遇到了什么困难?

[一定要事先想好这个问题怎么答]

遇到了高并发的问题(秒杀以及解决方案)

面试忌讳:

1.我技术一般,工资低点没事,能学东西就行

2.谦逊而不卑微

3.格子衬衫牛仔裤,双肩电脑包

学习的网站

初级入门教程:可以百度

项目中遇到了具体问题,可以问CSDN

视频教程找B站

疑难杂症国际技术社区:Stack Overflow - Where Developers Learn, Share, & Build Careers

建议自学内容

  • SpringCloud Netflix 提供的组件

  • 除了Redis 最好对memcache也有些了解

  • SpringSecurity实现权限管理也有一个类似的框架Shiro

  • 数据结构,二叉树,红黑树,B树,B+树

  • k8s(偏运维)Kubernetes

  • MongoDB

​​​

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值