前言
偏我來時不逢春
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
計算運費介面改為手機號最後一位為運費
訂單提交成功,跳到支付介面