04谷粒商城-高级篇四
前言
偏我来时不逢春
11.商城业务-消息队列
11.1MQ简介
主要步骤:
- 异步处理
- 应用解耦
- 流量控制
异步处理
应用解耦
流量控制
11.2RabbitMQ简介
11.2.1概述
消息代理(message broker)
消息代理:指安装了消息中间件的服务器,用于接收消息和发送消息
目的地(destination)
通俗解释:消息代理接收到消息后会将消息继续发给目的地(生产者发送消息)
目的地主要有两种形式:队列、主题
队列(queue)
- 点对点消息通信(point-to-point)
- 1.消息发送者发送消息,消息代理将其放入一个队列中,消息接受者从队列中获取消息内容,消息读取后被移出队列
- 2.队列可以被多个消费者监听,但一条消息只会被一个消费者成功消费
主题(topic)
- 发布(publish)/订阅(subscribe)消息通信
- 1.发送者发送消息到主题,多个订阅者订阅该主题,多个消费者会同时收到消息
11.2.3两种规范
JMS(JAVA消息服务)
JMS:(Java Message Service)
JAVA消息服务,基于JVM信息代理的规范。ActiveMQ、HornetMQ是JMS实现
AMQP(高级消息队列协议)
AMQP:(Advanced Message Queuing Protocol)
高级消息队列协议,也是一个消息代理的规范,兼容JMS
RabbitMQ是AMQP的实现
Spring支持与SpringBoot自动装配
11.3RabbitMQ工作流程
RabbitMQ简介: RabbitMQ是一个由erlang开发的AMQP(Advanved Message Queue Protocol)的开源实现。
Message
消息,消息是不具名的,它由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成, 这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可 能需要持久性存储)等。
Publisher
消息的生产者,也是一个向交换器发布消息的客户端应用程序
Exchange
交换器,用来接收生产者发送的消息并将这些消息路由给服务器中的队列。 Exchange有4种类型:direct(默认),fanout, topic, 和headers,不同类型的Exchange转发消息的策略有所区别
Queue
消息队列,用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直 在队列里面,等待消费者连接到这个队列将其取走。
Binding
绑定,用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交 换器理解成一个由绑定构成的路由表。 Exchange 和Queue的绑定可以是多对多的关系。
Connection
网络连接,比如一个TCP连接。
Channel
信道,多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内的虚拟连接,AMQP 命令都是通过信道 发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都 是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接。
Consumer
消息的消费者,表示一个从消息队列中取得消息的客户端应用程序。
Virtual Host
虚拟主机,表示一批交换器、消息队列和相关对象。虚拟主机是共享相同的身份认证和加 密环境的独立服务器域。每个 vhost 本质上就是一个 mini 版的 RabbitMQ 服务器,拥 有自己的队列、交换器、绑定和权限机制。vhost 是 AMQP 概念的基础,必须在连接时 指定,RabbitMQ 默认的 vhost 是 / 。
Broker
表示消息队列服务器实体
11.4RabbitMQ安装
官网地址:https://www.rabbitmq.com/networking.html
端口映射:
-
**4369, 25672 (Erlang发现&集群端口) **
-
**5672, 5671 (AMQP端口) **
-
**15672 (web管理后台端口) **
-
**61613, 61614 (STOMP协议端口) **
-
**1883, 8883 (MQTT协议端口) **
11.5Exchange类型
RabbitMQ运行机制
AMQP 中的消息路由
AMQP 中消息的路由过程和 Java 开 发者熟悉的 JMS 存在一些差别, AMQP 中增加了 Exchange 和 Binding 的角色。生产者把消息发布 到 Exchange 上,消息最终到达队列 并被消费者接收,而 Binding 决定交 换器的消息应该发送到那个队列
Exchange 类型
Exchange分发消息时根据类型的不同分发策略有区别,目前共四种类型:direct、 fanout、topic、headers 。headers 匹配 AMQP 消息的 header 而不是路由键, headers 交换器和 direct 交换器完全一致,但性能差很多,目前几乎用不到了,所以直接 看另外三种类型
Direct Exchange
消息中的路由键(routing key)如果和 Binding 中的 binding key 一致, 交换器 就将消息发到对应的队列中。路由键与队 列名完全匹配,如果一个队列绑定到交换 机要求路由键为“dog”,则只转发 routing key 标记为“dog”的消息,不会转发 “dog.puppy”,也不会转发“dog.guard” 等等。它是完全匹配、单播的模式。
Fanout Exchange
每个发到 fanout 类型交换器的消息都 会分到所有绑定的队列上去。fanout 交 换器不处理路由键,只是简单的将队列 绑定到交换器上,每个发送到交换器的 消息都会被转发到与该交换器绑定的所 有队列上。很像子网广播,每台子网内 的主机都获得了一份复制的消息。 fanout 类型转发消息是最快的。
Topic Exchange
topic 交换器通过模式匹配分配消息的 路由键属性,将路由键和某个模式进行 匹配,此时队列需要绑定到一个模式上。 它将路由键和绑定键的字符串切分成单 词,这些单词之间用点隔开。它同样也 会识别两个通配符:符号“#”和符号 “”。#匹配0个或多个单词,匹配一 个单词。
11.6Direct-Exchange
创建队列
pengmall
pengmall.news
pengmall.emps
other.news
创建direct
类型交换机,并绑定创建的4个队列,Routing key
就是队列名
pengmall.exchange.direct
给Routing key
为pengmall
发送消息,发现队列pengmall
收到消息
Get Message
获取消息,Nack messahe requeue true
获取消息但是消息不丢失
可以选择Automatic ack
,获取消息自动回复ack
11.7Fanout-Exchange
创建fanout
类型交换机,并绑定4个队列,Routing key
就是队列名
pengmall.exchange.fanout
发送消息,发现4个队列都收到了消息
指定Routing key
4个队列依然可以收到消息
11.8Topic-Exchange
创建topic
类型交换机,并绑定4个队列,需要设置4个队列的Routing key
pengmall.exchange.topic
*.news
pengmall.#
pengmall.exchange.topic
发送消息的时候设置Routing key
为pengmall.news
,发现匹配4个的队列的Routing key
,4个队列都收到了消息
pengmall.exchange.topic
发送消息的时候设置Routing key
为hello.news
,只有other.news
队列匹配Routing key
11.9Spring整合Fanout-Exchange
主要步骤:
- 1.导入依赖
- 2.
RabbitAutoConfiguration
生效后自动注入多个容器CachingConnectionFactory
RabbitTemplate
AmqpAdmin
RabbitMessagingTemplate
- 3.配置
application.yaml
导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
配置application.yaml
spring:
rabbitmq:
host: 192.168.188.180
port: 5672
virtual-host: /
username: guest
password: guest
11.10AmqpAdmin使用
主要步骤:
- 创建交换机
- 创建队列
- 创建绑定关系
代码
@Test
public void createExchange(){
Exchange directExchange = new DirectExchange("hello.exchange.direct", true, false);
amqpAdmin.declareExchange(directExchange);
log.info("Exchange[{}]创建成功:","hello.exchange.direct");
}
@Test
public void createQueue() {
Queue queue = new Queue("hello.queue",true,false,false);
amqpAdmin.declareQueue(queue);
log.info("Queue[{}]创建成功:","hello.queue");
}
@Test
public void createBinding() {
Binding binding = new Binding("hello.queue",
Binding.DestinationType.QUEUE,
"hello.exchange.direct",
"hello",
null);
amqpAdmin.declareBinding(binding);
log.info("Binding[{}]创建成功:","hello.binding");
}
11.11RabbitTemplate使用
主要步骤:
- 发送字符串消息
- 发送序列化消息,对象必须实现
Serializable
接口 - 发送
json
消息
发送字符串消息
@Test
public void sendStringMessage() {
String msg = "Hello World";
rabbitTemplate.convertAndSend("hello.exchange.direct","hello.queue",
msg,new CorrelationData(UUID.randomUUID().toString()));
log.info("消息发送完成:{}",msg);
}
发送序列化消息,对象必须实现Serializable
接口
@Test
public void sendEntityMessage() {
OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
reasonEntity.setId(1L);
reasonEntity.setCreateTime(new Date());
reasonEntity.setName("reason");
reasonEntity.setStatus(1);
reasonEntity.setSort(2);
String msg = "Hello World";
//1、发送消息,如果发送的消息是个对象,会使用序列化机制,将对象写出去,对象必须实现Serializable接口
//2、发送的对象类型的消息,可以是一个json
rabbitTemplate.convertAndSend("hello.exchange.direct","hello", reasonEntity);
log.info("消息发送完成:{}",reasonEntity);
}
添加rabbitmq
配置
@Configuration
public class MyRabbitConfig {
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
}
再次发送消息,发现消息变成了json
字符串
11.12RabbitListener&RabbitHandler接收消息
@RabbitListener
- 1.用于标注在监听类或监听方法上,接收消息,需要指定监听的队列(数组)
- 2.使用该注解之前,需要在启动类加上该注解:@EnableRabbit
- 3.@RabbitListener即可以标注在方法上又可以标注在类上
- 标注在类上:表示该类是监听类,使得@RabbitHandler注解生效
- 标注在方法上:表示该方法时监听方法,会监听指定队列获得消息
- 4.一般只标注在方法上,并配合@RabbitHandler使用,重载的方式接收不同消息对象
测试集群多客户端监听接收消息
- 1.多个客户端可以共同监听同一队列
- 2.一条消息同时只能被一个客户端接收
- 3.同一个客户端接收消息是串行的,revieveMessage方法执行完后才会继续接收下一条消息
@RabbitHandler
- 1.用于标注在监听方法上,接收消息,不需要指定监听的队列
- 2.使用该注解之前,需要在启动类加上该注解:@EnableRabbit
- 3.@RabbitListener只可以标注在方法,重载的方式接收不同消息对象
主要步骤:
@RabbitListener
接收消息@RabbitListener
的Message
- 多个服务监听同一个队列,同一个消息只能一个客户端收到,只有一个消息完全处理完,才可以接收下一个消息
@RabbitHandler
@RabbitListener
接收消息
@RabbitListener(queues = {"hello.queue"})
public void receiveMsg(Object msg) {
System.out.println("接收到消息,内容:" + msg + ",类型:" + msg.getClass());
}
@RabbitListener
的Message
@RabbitListener(queues = {"hello.queue"})
public void receiveMsg(Message msg, OrderReturnReasonEntity content) {
byte[] body = msg.getBody();
MessageProperties properties = msg.getMessageProperties();
System.out.println("接收到消息,内容:" + msg + ",类型:" + content);
}
在启动一个gulimall-order
在OrderItemServiceImpl
添加@RabbitListener(queues = {"hello.queue"})
添加2个监听方法,但接收消息的类型不一样
多个服务监听同一个队列,同一个消息只能一个客户端收到,只有一个消息完全处理完,才可以接收下一个消息
@RabbitHandler
处理不同类型参数的消息
添加一个发送消息的RabbitMqController
方便测试
@RestController
public class RabbitMqController {
@Autowired
RabbitTemplate rabbitTemplate;
@GetMapping("/sendMq")
public String sendMq(@RequestParam(value = "num", defaultValue = "10") Integer num) {
// for (int i = 0; i < num; i++) {
// OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
// reasonEntity.setId(1L);
// reasonEntity.setCreateTime(new Date());
// reasonEntity.setName("hello" + i);
// rabbitTemplate.convertAndSend("hello.exchange.direct", "hello", reasonEntity);
// }
for (int i = 0; i < num; i++) {
if (i % 2 == 0) {
OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
reasonEntity.setId(1L);
reasonEntity.setCreateTime(new Date());
reasonEntity.setName("hello"+i);
rabbitTemplate.convertAndSend("hello.exchange.direct","hello", reasonEntity);
}else{
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(UUID.randomUUID().toString());
rabbitTemplate.convertAndSend("hello.exchange.direct","hello", orderEntity);
}
}
return "ok";
}
}
11.13可靠投递-发送端确认
可靠抵达
- 保证消息不丢失,可靠抵达,可以使用事务消息,性能下降250倍,为此引入确认机制
- publisher confirmCallback 确认
- publisher returnCallback 未投递到 queue 退回模式
- consumer ack机制
主要步骤:
- 配置
rabbitmq
- 设置确认回调
setConfirmCallback
- 发送消息时设置
new CorrelationData(UUID.randomUUID().toString())
- 设置失败回调
setReturnCallback
,只要消息没有投递给指定的队列,就触发这个失败回调setReturnCallback
配置rabbitmq
spring:
rabbitmq:
host: 192.168.188.180
port: 5672
virtual-host: /
username: guest
password: guest
# 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】
publisher-confirm-type: correlated
# 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】
publisher-returns: true
# 消息在没有被队列接收时是否强行退回
template:
mandatory: true
设置确认回调setConfirmCallback
设置失败回调setReturnCallback
只要消息抵达Broker就ack=true
- correlationData:当前消息的唯一关联数据(这个是消息的唯一id)
- ack:消息是否成功收到
- cause:失败的原因
发送消息时设置new CorrelationData(UUID.randomUUID().toString())
并且设置设置错误的routingKey
模拟未投递到 queue 退回模式
11.14可靠投递-消费端确认
主要步骤:
消费端确认(保证每个消息被正确消费,此时才可以broker删除这个消息)
- 默认是自动确认的,只要消息接收到,客户端会自动确认,服务端就会移除这个消息
- 问题:我们收到很多消息,自动回复给服务器ack,只有一个消息处理成功,宕机了。发生消息丢失;
- 手动确认模式。只要我们没有明确告诉
MQ
,货物被签收。没有Ack
,消,息就一直是unacked
状态。即使consumer
宕机。消息不会丢失,会重新变为Ready
,下一次有新的consumer
连接进来就发给他
- 如何签收
channel.basicAck(deliveryTag, false);
:签收;业务成功完成就应该签收channel.basicNack(deliveryTag, false,true);
:拒签
配置,手动签收
rabbitmq:
host: 192.168.188.180
port: 5672
virtual-host: /
username: guest
password: guest
# 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】
publisher-confirm-type: correlated
# 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】
publisher-returns: true
# 消息在没有被队列接收时是否强行退回
template:
mandatory: true
listener:
simple:
acknowledge-mode: manual
代码
@RabbitHandler
public void receiveMsg(Message message,
OrderReturnReasonEntity content,
Channel channel) throws InterruptedException, IOException {
System.out.println("接收到消息... " + content);
byte[] body = message.getBody();
// 消息头属性信息
MessageProperties properties = message.getMessageProperties();
Thread.sleep(3000);
System.out.println("消息处理完成=>" + content.getName());
// channel内按顺序自增的。
long deliveryTag = message.getMessageProperties().getDeliveryTag();
System.out.println("deliveryTag==>" + deliveryTag);
// 签收货物,非批量模式
try {
if (deliveryTag % 2 == 0) {
channel.basicAck(deliveryTag, false);
System.out.println("签收了货物..." + deliveryTag);
} else
System.out.println("没有收了货物..." + deliveryTag);
} catch (Exception e) {
// 网络中断
// 拒签
channel.basicNack(deliveryTag, false,true);
}
}
12.商城业务-订单服务
12.1页面环境搭建
主要步骤:
- 上传等待付款(
detail
)、订单页(list
)、结算页(confirm
)、收银页(pay
)静态资源到/root/mall/nginx/html/static/order/
- 把等待付款(
detail
)、订单页(list
)、结算页(confirm
)、收银页(pay
)的index
页面拷贝到订单服务项目gulimall-order
的src/main/resources/templates
目录下 - 修改
nginx
配置,这里之前都配置好了,只是检查一下 - 管理员启动
SwitchHosts
,添加订单服务gulimall-order
的域名域名映射 - 添加订单服务
gulimall-order
的网关配置 gulimall-order
添加thymeleaf
的依赖,并配置取消页面缓存- 添加页面测试
Controller
- 访问页面测试
上传等待付款(detail
)、订单页(list
)、结算页(confirm
)、收银页(pay
)静态资源到/root/mall/nginx/html/static/order/
上传完成后查看一下
把等待付款(detail
)、订单页(list
)、结算页(confirm
)、收银页(pay
)的index
页面拷贝到订单服务项目gulimall-order
的src/main/resources/templates
目录下
修改nginx
配置,这里之前都配置好了,只是检查一下
管理员启动SwitchHosts
,添加订单服务gulimall-order
的域名域名映射
192.168.188.180 order.gulimall.com
添加订单服务gulimall-order
的网关配置,注意不要有空格
gulimall-order
添加thymeleaf
的依赖,并配置取消页面缓存
添加静态页面thymeleaf
命名空间,修改页面静态资源地址
confirm
页面
href="
href="/static/order/confirm/
src="
src="/static/order/confirm/
detail
页面
href="
href="/static/order/detail/
src="
src="/static/order/detail/
list
页面
href="
href="/static/order/list/
src="
src="/static/order/list/
pay
页面
href="
href="/static/order/pay/
src="
src="/static/order/pay/
添加页面测试Controller
@Controller
public class IndexController {
@GetMapping("/{page}.html")
public String getPage(@PathVariable("page") String page) {
return page;
}
}
detail
页面的错误,搜索xuanxiangka
,然后删除多余"
即可
confirm
页面的错误,搜索/*
,然后删除即可
访问页面测试
- http://order.gulimall.com/detail.html
- http://order.gulimall.com/list.html
- http://order.gulimall.com/confirm.html
- http://order.gulimall.com/pay.html
12.2整合SpringSession
主要步骤:
- 导入
SpringSession
依赖,EnableRedisHttpSession
开启EnableRedisHttpSession
配置Session
application.yaml
配置Redis
、SpringSession
、线程池- 创建
GulimallSessionConfig
、MyRedisConfig
、MyThreadConfig
、ThreadPoolConfigProperties
配置 - 修改页面登录信息
- 测试,登录之后查看页面登录信息展示即可
导入SpringSession
依赖,EnableRedisHttpSession
开启EnableRedisHttpSession
配置Session
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
application.yaml
配置Redis
、SpringSession
、线程池
server:
port: 8203
servlet:
session:
timeout: 30m
spring:
redis:
host: 192.168.188.180
port: 6379
session:
store-type: redis
gulimall:
thread:
coreSize: 20
maxSize: 200
keepAliveTime: 10
创建GulimallSessionConfig
、MyRedisConfig
、MyThreadConfig
、ThreadPoolConfigProperties
配置
之前gulimall-product
服务都有,拷贝过来即可
修改页面登录信息
confirm.html
detail.html
pay.html
测试,登录之后查看页面登录信息展示即可
12.3订单基本概念
订单中心
电商系统涉及到 3 流,分别时信息流,资金流,物流,而订单系统作为中枢将三者有机的集 合起来。 订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这 些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。
订单构成
订单状态
- 1.待付款 用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支 付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超 时后将自动取消订单,订单变更关闭状态。
- 2.已付款/待发货 用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到 WM系统,仓库进行调拨,配货,分拣,出库等操作。
- 3.待收货/已发货 仓储将商品出库后,订单进入物流环节,订单系5统需要同步物流信息,便于用户实时知悉物 品物流状态
- 4.已完成 用户确认收货后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态
- 5.已取消 付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。
- 6.售后中 用户在付款后申请退款,或商家发货后用户申请退换货。
订单流程
12.4订单登录拦截
主要步骤:
- 1.购物车结算时跳转
confirm.html
- 2.购物车结算需要登录
- 添加
LoginUserInterceptor
拦截器 OrderWebConfig
使用LoginUserInterceptor
拦截器处理所有gulimall-order
的请求
- 添加
gulimall-cart
的toTrade
方法跳转到gulimall-order
服务
function toTrade() {
// window.location.href = "http://cart.gmall.com:8087/toTrade";
window.location.href = "http://order.gulimall.com/toTrade";
}
OrderWebController
处理toTrade
请求,跳转到确认页confirm
@Controller
public class OrderWebController {
/**
* 去结算确认页
* @return
* @throws ExecutionException
* @throws InterruptedException
*/
@GetMapping(value = "/toTrade")
public String toTrade() {
return "confirm";
}
}
添加LoginUserInterceptor
拦截器
OrderWebConfig
使用LoginUserInterceptor
拦截器处理所有gulimall-order
的请求
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
MemberResponseVo attribute = (MemberResponseVo) request.getSession().getAttribute(LOGIN_USER);
if (attribute != null) {
// 把登录后用户的信息放在ThreadLocal里面进行保存
loginUser.set(attribute);
return true;
} else {
// 未登录,返回登录页面
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
return false;
}
}
}
12.5订单确认页模型抽取
主要步骤:
- 收货地址
- 所有选中的购物项
- 发票记录
- 优惠券信息
- 订单总额
- 应付价格
//订单确认页需要用的数据
@Data
public class OrderConfirmVo {
//// 收货地址,ums member receive address表
List<MemberAddressVo>address;
//所有选中的购物项
List<OrderItemVo> items;
//发票记录....
//优惠券信息.
Integer integration;
BigDecimal total;//订单总额
BigDecimal payPrice;//应付价格
}
12.6订单确认页数据获取
主要步骤:
- 1.远程查询用户的收货地址列表
- 2.远程查询购物车里所有选中的购物项
- 获取购物车选中购物项时需要查询最新价格
- 3.查询用户积分
- 4.其他数据自动计算
- 5.防重令牌
@Override
public OrderConfirmVo confirmOrder(){
OrderConfirmVo confirmVo =new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address):
//2、远程查询购物车所有选中的购物项
List<OrderItemVo>items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//3、查询用户积分
Integer integration= memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TOD0 |5、防重令牌
return confirmVo;
}
远程查询用户的收货地址列表
MemberReceiveAddressController
/**
* 根据会员id查询会员的所有地址
* @param memberId
* @return
*/
@GetMapping(value = "/{memberId}/address")
public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memberId") Long memberId) {
List<MemberReceiveAddressEntity> addressList = memberReceiveAddressService.getAddress(memberId);
return addressList;
}
MemberReceiveAddressServiceImpl
@Override
public List<MemberReceiveAddressEntity> getAddress(Long memberId) {
return this.baseMapper.selectList
(new QueryWrapper<MemberReceiveAddressEntity>().eq("member_id", memberId));
}
远程查询购物车里所有选中的购物项
CartController
/**
* 获取当前用户的购物车商品项
* @return
*/
@GetMapping(value = "/currentUserCartItems")
@ResponseBody
public List<CartItemVo> getCurrentCartItems() {
List<CartItemVo> cartItemVoList = cartService.getUserCartItems();
return cartItemVoList;
}
CartServiceImpl
@Override
public List<CartItemVo> getUserCartItems() {
List<CartItemVo> cartItemVoList = new ArrayList<>();
//获取当前用户登录的信息
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
//如果用户未登录直接返回null
if (userInfoTo.getUserId() == null) {
return null;
} else {
//获取购物车项
String cartKey = CART_PREFIX + userInfoTo.getUserId();
//获取所有的
List<CartItemVo> cartItems = getCartItems(cartKey);
if (cartItems == null) {
throw new CartExceptionHandler();
}
//筛选出选中的
cartItemVoList = cartItems.stream()
.filter(items -> items.getCheck())
.map(item -> {
//更新为最新的价格(查询数据库)
BigDecimal price = productFeignService.getPrice(item.getSkuId());
item.setPrice(price);
return item;
})
.collect(Collectors.toList());
}
return cartItemVoList;
}
获取购物车选中购物项时需要查询最新价格
/**
* 根据skuId查询当前商品的价格
* @param skuId
* @return
*/
@GetMapping(value = "/{skuId}/price")
public BigDecimal getPrice(@PathVariable("skuId") Long skuId) {
//获取当前商品的信息
SkuInfoEntity skuInfo = skuInfoService.getById(skuId);
//获取商品的价格
BigDecimal price = skuInfo.getPrice();
return price;
}
结算确认页
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberResponseVo memberRespVo = LoginUserInterceptor.loginUser.get();
// 1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setMemberAddressVos(address);
// 2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentCartItems();
confirmVo.setItems(items);
// 3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
// 4、其他数据自动计算
// TOD0 |5、防重令牌
return confirmVo;
}
12.7Feign远程调用丢失请求头问题
主要步骤:
Feign
远程调用时需要同步老请求的Cookie
等请求头
原因:
- 浏览器请求时会带上Cookie: GULISESSION
- 默认使用feign调用时,会根据拦截器构造请求参数RequestTemplate,而此时请求头没有带上Cookie,导致springsession无法获取用户信息
解决:
拦截器构造请求头
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor() {
RequestInterceptor requestInterceptor = new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
//1、使用RequestContextHolder拿到刚进来的请求数据
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
//老请求
HttpServletRequest request = requestAttributes.getRequest();
if (request != null) {
//2、同步请求头的数据(主要是cookie)
//把老请求的cookie值放到新请求上来,进行一个同步
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
}
};
return requestInterceptor;
}
}
12.8Feign异步调用丢失请求头问题
主要步骤:
原因:
- 使用异步编排时,非同一线程无法取到
RequestContextHolder
(上下文环境保持器)
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象
空指针异常
解决:
- 获取主线程ServletRequestAttributes,给每个异步线程复制一份
代码
/**
* 获取结算页(confirm.html)VO数据
*/
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
OrderConfirmVo result = new OrderConfirmVo();
// 获取当前用户
MemberResponseVo member = LoginUserInterceptor.loginUser.get();
// 获取当前线程上下文环境器
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
// 1.查询封装当前用户收货列表
// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVo> address = memberFeignService.getAddress(member.getId());
result.setMemberAddressVos(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
// 2.查询购物车所有选中的商品
// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
// 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)
List<OrderItemVo> items = cartFeignService.getCurrentCartItems();
result.setItems(items);
}, executor);
// 3.查询用户积分
Integer integration = member.getIntegration();// 积分
result.setIntegration(integration);
// 4.金额数据自动计算
// 5.TODO 防重令牌
// 阻塞等待所有异步任务返回
CompletableFuture.allOf(addressFuture, cartFuture).get();
return result;
}
12.9bug修改
获取价格接口返回对象改为R
SkuInfoController
/**
* 根据skuId查询当前商品的价格
* @param skuId
* @return
*/
@GetMapping(value = "/{skuId}/price")
public R getPrice(@PathVariable("skuId") Long skuId) {
//获取当前商品的信息
SkuInfoEntity skuInfo = skuInfoService.getById(skuId);
//获取商品的价格
return R.ok().setData(skuInfo.getPrice().toString());
}
CartServiceImpl
R price = productFeignService.getPrice(item.getSkuId());
String data = (String)price.get("data");
item.setPrice(new BigDecimal(data));
return item;
getCurrentCartItems
返回json
数据,所以加上ResponseBody
注解
12.10订单确认页渲染
有货这里可以先固定
12.11订单确认页库存查询
主要步骤:
gulimall-order
添加远程调用库存接口(之前gulimall-ware
写过,这里Feign
直接调用即可)gulimall-order
的confirmOrder
方法在查询完购物车之后使用thenRunAsync
在查询库存confirm.html
页面渲染
gulimall-order
添加远程调用库存接口(之前gulimall-ware
写过,这里Feign
直接调用即可)
gulimall-order
的confirmOrder
方法在查询完购物车之后使用thenRunAsync
在查询库存
.thenRunAsync(() -> {
// 3.批量查询库存(有货/无货)
List<Long> skuIds = result.getItems().stream().map(item -> item.getSkuId()).collect(Collectors.toList());
R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);
List<SkuStockVo> skuStockVos = skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {
});
if (skuStockVos != null && skuStockVos.size() > 0) {
// 将skuStockVos集合转换为map
Map<Long, Boolean> skuHasStockMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
result.setStocks(skuHasStockMap);
}
}, executor);
confirm.html
页面渲染
界面
12.12订单确认页模拟运费效果
主要步骤:
mall_ums
的ums_member_receive_address
添加测试地址数据,默认设置一个默认地址gulimall-ware
添加获取运费接口- 远程调用
gulimall-member
获取收货地址信息 - 计算运费,我这里是随机5-15作为运费
- 远程调用
- 前端渲染
- 运费
p
标签自定义特性,绑定地址id
和默认地址defaultStatus
- 运费
p
标签默认选中状态,并在点击时计算运费
- 运费
mall_ums
的ums_member_receive_address
添加测试地址数据,默认设置一个默认地址
INSERT INTO mall_ums.ums_member_receive_address (member_id,name,phone,post_code,province,city,region,detail_address,areacode,default_status) VALUES
(2,'peng','15766668888',NULL,NULL,'上海市',NULL,'上海市黄浦区',NULL,1);
INSERT INTO mall_ums.ums_member_receive_address (member_id,name,phone,post_code,province,city,region,detail_address,areacode,default_status) VALUES
(2,'peng','15766668888',NULL,NULL,'上海市',NULL,'上海市黄浦区',NULL,1);
gulimall-ware
添加获取运费接口
- 远程调用
gulimall-member
获取收货地址信息 - 计算运费,我这里是随机5-15作为运费
@Override
public FareVo getFare(Long addrId) {
FareVo fareVo = new FareVo();
//收获地址的详细信息
R addrInfo = memberFeignService.info(addrId);
MemberAddressVo memberAddressVo = addrInfo.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {});
if (memberAddressVo != null) {
String phone = memberAddressVo.getPhone();
//截取用户手机号码最后一位作为我们的运费计算
//1558022051
// String fare = phone.substring(phone.length() - 10, phone.length()-8);
// BigDecimal bigDecimal = new BigDecimal(fare);
//
// fareVo.setFare(bigDecimal);
// 随机5-15作为运费
Random random = new Random();
// 生成 5 到 15 之间的随机数
int randomNumber = 5 + random.nextInt(11);
BigDecimal bigDecimal = new BigDecimal(randomNumber);
fareVo.setFare(bigDecimal);
fareVo.setAddress(memberAddressVo);
return fareVo;
}
return null;
}
前端渲染
- 运费
p
标签自定义特性,绑定地址id
和默认地址defaultStatus
- 运费
p
标签默认选中状态,并在点击时计算运费
<div class="top-3 addr-item" th:each="addr:${confirmOrderData.memberAddressVos}">
<p th:attr="def=${addr.defaultStatus},addrId=${addr.id}">[[${addr.name}]]</p><span>[[${addr.name}]] [[${addr.province}]] [[${addr.city}]] [[${addr.region}]] [[${addr.detailAddress}]] [[${addr.phone}]]</span>
</div>
12.13订单确认页细节显示
主要步骤:
- 接口添加收货地址详细信息
- 界面加载渲染
- 接口获取数据后渲染界面
接口添加收货地址详细信息
界面加载渲染
接口获取数据后渲染界面
12.14接口幂等性讨论
什么是幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因 为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结 果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结 果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口 的幂等性。
哪些情况需要防止
用户多次点击按钮
用户页面回退再次提交
微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制
其他业务情况
什么情况下需要幂等
以 SQL 为例,有些操作是天然幂等的。
SELECT * FROM table WHER id=?,无论执行多少次都不会改变状态,是天然的幂等。
UPDATE tab1 SET col1=1 WHERE col2=2,无论执行成功多少次状态都是一致的,也是幂等操作。
DELETE from user where userid=1,多次操作,结果一样,具备幂等性
INSERT into user(userid,name) values(1,'a') 如 userid 为唯一主键,即重复操作上面的业务,只 会插入一条用户数据,具备幂等性。
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,不是幂等的。 insert into user(userid,name) values(1,'a') 如 userid 不是主键,可以重复,那上面业务多次操 作,数据都会新增多条,不具备幂等性。
幂等解决方案
token 机制
- 服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis中。
- 然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
- 服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业 务。
- 如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样 就保证了业务代码,不被重复执行。
- 我们最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。
- Token 获取、比较和删除必须是原子性,可以在 redis 使用 lua 脚本完成。
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
各种锁机制
- 数据库悲观锁
- select * from xxxx where id = 1 for update;
- 悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。 另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会 非常麻烦。
- 数据库乐观锁
- 这种方法适合在更新的场景中
- update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1 根据 version 版本,也就是在操作库存前先获取当前商品的 version 版本号,然后操作的时候 带上此 version号。我们梳理下,我们第一次操作库存时,得到 version 为 1,调用库存服务 version 变成了 2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订 单服务传如的 version 还是 1,再执行上面的 sql 语句时,就不会执行;因为 version 已经变 为 2 了,where 条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。 乐观锁主要使用于处理读多写少的问题
业务层分布式锁
- 如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数 据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断 这个数据是否被处理过。
各种唯一约束
- 数据库唯一约束
- 插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。 我们在数据库层面防止重复。
- 这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键 的要求不是自增的主键,这样就需要业务生成全局唯一的主键。
- 如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要 不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
- redis set 防重
- 很多数据需要处理,只能被处理一次,比如我们可以计算数据的 MD5 将其放入 redis 的 set, 每次处理数据,先看这个 MD5 是否已经存在,存在就不处理。
防重表
- 使用订单号 orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且 他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避 免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个 事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。 之前说的 redis
全局请求唯一 id
- 调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。
- 可以使用 nginx 设置每一个请求的唯一 id; proxy_set_header X-Request-Id $request_id
12.15订单确认页完成
主要步骤:
- 服务生成防重令牌,并保存在redis中,然后返回给前端,前端下单时带上防重令牌
- 渲染提交订单的表单,主要包含地址、应付总额、防重令牌
- 需要在选择地址和计算总额时重新赋值表单数据
服务生成防重令牌,并保存在redis中,然后返回给前端,前端下单时带上防重令牌
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(USER_ORDER_TOKEN_PREFIX+member.getId(),token,30, TimeUnit.MINUTES);
result.setOrderToken(token);
渲染提交订单的表单,主要包含地址、应付总额、防重令牌
需要在选择地址和计算总额时重新赋值表单数据
12.16原子验令牌
主要步骤:
- 使用
redis
执行lue
脚本保证令牌的对比和删除的原子性
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
// 登录信息
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
// 返回结果
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
// 1、验证令牌是否合法【令牌的对比和删除必须保证原子性】
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
// 通过lure脚本原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()), orderToken);
if (result == 0L) {
// 令牌验证失败
responseVo.setCode(1);
return responseVo;
} else {
// 令牌验证成功
}
return null;
}
12.17构造订单数据
主要步骤:
createOrder
生成订单,生成唯一订单号builderOrderItems
生成订单项- 远程调用
gulimall-cart
获取当前用户购物车 - 遍历购物车,远程调用
gulimall-ware
获取收货地址信息和运费信息
- 远程调用
createOrder
生成订单,生成唯一订单号
远程调用gulimall-cart
获取当前用户购物车
遍历购物车,远程调用gulimall-ware
获取收货地址信息和运费信息
12.18构造订单项数据
主要步骤:
spu
信息,远程调用gulimall-product
根据skuId
获取spu
信息sku
信息- 商品的优惠信息
- 商品的积分信息
- 订单项的价格信息
- 当前订单项的实际金额
12.19订单验价
主要步骤:
- 每个订单项优惠卷分解金额
- 每个订单项商品促销分解金额
- 每个订单项积分优惠分解金额
- 每个订单项优惠后的分解金额
- 应付总额 = 总额 + 运费
- 如果计算后的应付金额和前端传来的应付金额相减取绝对值小于
0.01
验价成功
遍历订单项,获取每个订单项优惠后的价格
如果计算后的应付金额和前端传来的应付金额相减取绝对值小于0.01
验价成功
12.20保存订单数据
主要步骤:
- 保存订单数据
- 远程调用
gulimall-ware
锁定库存
保存订单数据
远程调用gulimall-ware
锁定库存
12.21锁定库存
主要步骤:
- 找到每个商品在哪个仓库都有库存
- 库存 - 锁定库存 > 0 代表当前仓库有该商品库存
- 锁定商品库存
- 库存 - 锁定库存 > 要锁定数量 代表当前仓库可以锁定该商品库存
//2、找到每个商品在哪个仓库都有库存
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map((item) -> {
SkuWareHasStock stock = new SkuWareHasStock();
Long skuId = item.getSkuId();
stock.setSkuId(skuId);
stock.setNum(item.getCount());
//查询这个商品在哪个仓库有库存
List<Long> wareIdList = wareSkuDao.listWareIdHasSkuStock(skuId);
stock.setWareId(wareIdList);
return stock;
}).collect(Collectors.toList());
//2、锁定库存
for (SkuWareHasStock hasStock : collect) {
boolean skuStocked = false;
Long skuId = hasStock.getSkuId();
List<Long> wareIds = hasStock.getWareId();
if (org.springframework.util.StringUtils.isEmpty(wareIds)) {
//没有任何仓库有这个商品的库存
throw new NotStockException(skuId.toString());
}
//1、如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
//2、锁定失败。前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所有就不用解锁
for (Long wareId : wareIds) {
//锁定成功就返回1,失败就返回0
Long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
if (count == 1) {
skuStocked = true;
break;
} else {
//当前仓库锁失败,重试下一个仓库
}
}
if (skuStocked == false) {
//当前商品所有仓库都没有锁住
throw new NotStockException(skuId.toString());
}
查询有商品库存的仓库和锁定库存sql
库存 - 锁定库存 > 0 代表当前仓库有该商品库存
库存 - 锁定库存 > 要锁定数量 代表当前仓库可以锁定该商品库存
12.22提交订单的问题
主要步骤:
-
创建完订单后复制订单和订单项
-
锁定库存失败后抛出异常,保证当前方法的事务回滚,锁定库存失败不能创建订单
-
submitOrder
方法根据状态码回复前端消息 -
计算运费接口改为手机号最后一位为运费
-
订单提交成功,跳到支付界面
创建完订单后复制订单和订单项
锁定库存失败后抛出异常,保证当前方法的事务回滚,锁定库存失败不能创建订单
submitOrder
方法根据状态码回复前端消息
order_sn
改为char(64)
oms_order
oms_order_item
计算运费接口改为手机号最后一位为运费
订单提交成功,跳到支付界面