iT邦幫忙

2

Redis 學習筆記(5)-客戶端整合 Redis

  • 分享至 

  • xImage
  •  

Redis 學習筆記(5)-客戶端整合 Redis

本文是有關 Redis 的學習筆記的一部分,相關目錄請參考 Redis 學習筆記(1)-簡介

在上一篇的說明中已瞭解 Redis 基本指令和功能,但是當時是透過 Redis 的互動介面輸入指令,在這一篇的介紹中,我們將納入 Java 客戶端元素,由 Java 客戶端連入 Redis 並由 Java 客戶端發出 Redis 指令。

為了更直觀瞭解 Java 客戶端整合 Redis 過程中的重要學習點,我們虛擬簡易的業務需求,在完成業務需求的過程中將瞭解每一個實作細節。

下面我們先列出虛擬業務需求。

1. 客戶的需求

客戶對一套系統的需求應是全面和完整的,但是我們基於學習的目的,僅列出有代表性的需求項目。

我們先列出需求的概述,先不涉及每個需求的細部資訊:

  • 建立使用者 API (為 Restful API)
  • 新增訂單 API
  • 儲值 API
  • 執行發貨排程
  • 查詢訂單 API
  • 搶購 API
  • 獨佔資源報表(任一時間點內僅能有一個程序在執行)

現在我們使用業務場景描述,將上述需求項目串接起來,描述如下:

這個系統為線上購買系統,相關使用場景有下列事項。

要使用這個系統的使用者需先註冊(建立使用者 API)。

使用者可以在自己的帳戶內儲值點數(儲值 API)。

使用者可以購買商品,但費用由使用者帳戶內的點數扣除(新增訂單 API)。

對於已成立但未出貨訂單,會有一支程式(執行發貨排程),將訂單執行出貨。

使用者可以查詢訂單狀態(查詢訂單 API)。

平台商會提供特價商品,供使用者搶購(搶購 API)。

平台商有特殊報表,不可同時運行(獨佔資源報表)。

已經瞭解客戶大概需求,接下來思考系統該採何種架構實作。

2. 系統架構

在思考系統架構前,先列出已成立的前題,也就是已確認的環境,如下:

  • 採用 Java 語言開發
  • 使用 Spring Boot 框架
  • 使用 IntelliJ IDEA IDE 開發

在業務需求中,有 Restful API 的接入點,先規劃一個 appWeb 的模組。另外還有執行發貨排程,這個功能可以和 appWeb 模組分開,再規劃另一個 appBackend 模組。針對 獨佔資源報表 需求,在簡化環境並易理解原則下,直接規劃二個相同的模組 appReport01 & appReport02,在驗證時僅需確認二個模組不可同時取到鎖即可。

最後再將上述提到的四個模組全收攏在一個 IDEA 專案內,其結構如下:
https://ithelp.ithome.com.tw/upload/images/20220614/20149259vyJyWgXWq5.png?o=2022-06-10_02.png

將四個模組和描述文字,以清單方式列出。

  • appWeb: 提供 Restful API 接入點。
  • appBackend: 模擬 執行發貨排程 功能。
  • appReport01: 模擬 取得 分佈式鎖 功能。
  • appReport02: 模擬 取得 分佈式鎖 功能。

已經完成測試環境的系統專案和模組的規劃,接下來思考每個模組如何和 Redis 伺服器連線。

下面將會紹介 Redis Java 客戶端的連結技術選型。

3. Redis Java 客戶端

Redis Java 客戶端可以是僅使用 Redis Java 客戶端函式庫(如: Redisson, Jedis, lettuce,...) 直接連入 Redis 伺服器,連入後發送 Redis 指令。另外也可以使用 Sprint Boot 加上 Spring Data Redis 整合功能,在這個環境中,Spring 會對底層的 Redis Java 端函式庫作包裝,提供更高階的 API 供上層程式使用。二者比較起來,使用 Sprint Boot + Spring Data Redis 會更直觀,所以在 Java 客戶端的選項上採用 Spring Boot + Spring Data Redis。

Spring Boot 環境中可以使用不同的 Redis Java 端函式庫,在此次學習過程中,實測過三種函式庫,分別是: Jedis, lettue 和 Redisson,前二種由 Spring Data Redis 整合,而 Redisson 實測也可以整合到 Spring Data Redis 中。這三個函式庫在簡單實測後,三個函式庫皆可滿足傳統指令需求,但 Redisson 另可提供額外有價值的功能(如:分佈式鎖),所以採用 Redisson 函式庫和 Spring Boot 搭配。

結合上面的描述,針對 Java 客戶端的架構可以用下面的圖來表達。
https://ithelp.ithome.com.tw/upload/images/20220614/20149259mtQVk009td.png?o=2022-06-10_01.png

已經完成系統規劃和連結技術選型,接下來可以架設 Redis 伺服器環境。

下面將會簡單描述 Redis 伺服器環境的起動。

4. Redis 伺服器環境

在之前的學習過程中,我們有在 Docker 環境中啟動 Redis 容器,我們在本次的學習中,依舊使用這個環境。

回顧 Redis 容器的架構圖,如下:
https://ithelp.ithome.com.tw/upload/images/20220610/20149259NX83zVRcFB.png?o=2022-06-09_01.png

在此假設該 Docker 環境還在,所需的操作為啟動 Redis 容器和清空 Redis 內的資料。

若該 Redis 容器已停止,則登入 c7a1 虛擬機上,在 Shell 指令模式下,直接將下列指令複製黏貼,可啟動容器。

cd

## 啟動 redis 容器
docker run --rm --name redis -d -p 6379:6379 \
-v ${PWD}/config:/etc/redis/ -v redis:/data/ \
redis:7 redis-server /etc/redis/redis.conf

若要檢視容器是否啟動,則在 Shell 指令模式下,直接將下列指令複製黏貼,檢視輸出結果。

## 檢視容器是否啟動
docker ps -f name=redis

Redis 容器啟動後,要執行的操作是清空 Redis 容器內的資料,只須進入 Redis 容器,連入 Reids 後執行清空資料庫操作。

進入 Redis 容器的操作指見下方指令,在 Shell 指令模式下,直接將下列指令複製黏貼,就可以進入容器。

## 進入 redis 容器
docker exec -it `docker ps -f name=redis -q` /bin/bash

進入該容器成功後,可依下列指令連入 Redis。

## 在容器內進入 redis-cli
redis-cli -h localhost -p 6379

連入 Redis 成功後,可依下列指令,清空資料庫。

# 輸入密碼取得授權
auth mypwd
# 清空 Redis 資料庫
flushdb

目前我們已經有了一個乾淨的 Redis 容器,接下來可以開發測試系統。

接下來將會描述在 IntelliJ IDEA IDE 中建立一個專案和四個模組。

5. 在 IDEA 中建立專案

依規劃我們要建立一個專案,其下含四個模組,我們依次來執行。

5.1 建立專案 s01_redis

在 IntelliJ IDEA IDE 中新增一個專案,相關欄位值設定請直接參考截圖。
https://ithelp.ithome.com.tw/upload/images/20220614/20149259mLtqCzwqME.png?o=2022-05-13_01.png

在專案名稱欄位上,輸入 s01_redis
https://ithelp.ithome.com.tw/upload/images/20220614/20149259OfQPSen0LE.png?o=2022-05-13_02.png

IntelliJ IDEA 會自動產出 pom.xml 檔,但該檔內的設定值並不滿足我們的需求,所以我們要對其作些修改。

打開 pom.xml 檔案,加入 parent & package 節點,修改完成後該檔內容如下。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <!-- 加入下列 parent 節點 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.7</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.example</groupId>
    <artifactId>s01_redis</artifactId>
    <version>1.0-SNAPSHOT</version>
    <!-- 加入下列 packaging 節點 -->
    <packaging>pom</packaging>
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
</project>

在這個專案僅管理模組,沒有程式碼,所以可以將 src 目錄刪除。

5.2 建立 AppWeb 模組

在 IntelliJ IDEA IDE s01_redis 專案中新增一個模組,相關欄位值設定請直接參考截圖。
https://ithelp.ithome.com.tw/upload/images/20220614/20149259YxUfrGaQ6u.png?o=2022-05-13_03.png

選擇 Maven & JDK8
https://ithelp.ithome.com.tw/upload/images/20220614/20149259wqTl7uEaF9.png?o=2022-05-13_04.png

輸入模組名稱 appWeb
https://ithelp.ithome.com.tw/upload/images/20220614/201492591AfVauvn9c.png?o=2022-05-13_05.png

IntelliJ IDEA 會自動產出 pom.xml 檔,但該檔內的設定值並不滿足我們的需求,所以我們要對其作些修改。

打開 pom.xml 檔案,加入 dependencies & build 節點,修改完成後該檔內容如下。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>s01_redis</artifactId>
        <groupId>org.example</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>appWeb</artifactId>
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <!-- 加入下列 dependencies 節點 -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.17.1</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
    <!-- 加入下列 build 節點 -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

5.3 建立其他三個模組

在 IntelliJ IDEA IDE s01_redis 專案中新增一個模組,建立方法和 appWeb 相同。

IntelliJ IDEA 會自動產出 pom.xml 檔,但該檔內的設定值並不滿足我們的需求,所以我們要對其作些修改。

打開 pom.xml 檔案,加入 dependencies & build 節點,修改完成後該檔內容如下。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>s01_redis</artifactId>
        <groupId>org.example</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <artifactId>appBackend</artifactId>
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>
    <!-- 加入下列 dependencies 節點 -->
    <dependencies>
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.17.1</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
    <!-- 加入下列 build 節點 -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

我們已經建立四個模組,接下將描述在這些模組加入 Redis 連接資訊和模組啟動程式。

5.4 加入 Redis 連接資訊和啟動程式

我們在這四個模組皆作如下設定:

  • 在 resources 目錄下加入 application.yml 內容如下:

    spring.redis.redisson.file: classpath:/redisson.yml
    
  • 在 resources 目錄下加入 redisson.yml 內容如下:

    singleServerConfig:
      password: mypwd
      address: "redis://127.0.0.1:6379"
    codec: !<org.redisson.codec.JsonJacksonCodec> {}
    
  • 在四個模組皆加入啟動程式

    • 建立 AppMain、AppBackend、AppReport01和AppReport02,內容皆如下:

      package org.example;
      // ...
      @SpringBootApplication // 觸發 SpringBoot 自動配置/物件掃瞄
      @Slf4j // 自動產出日誌相關物件
      public class AppMain  {
          public static void main(String[] args) {
              SpringApplication.run(AppMain.class, args);
          }
      }
      

我們已經建立四個模組,也設定好連接資訊,接下來就可以實作系統功能。

6. 實作系統功能

系統中含有許多功能,其新增使用者功能獨立性最高(沒有依賴其功能),所以我們先實作新增使用者這個功能。

以下開始描述新增使用者功能。

6.1 新增使用者

在一開始的虛擬業務需求中,對這個功能描述比較概略,但隨著架構和技術選型確認後,可以對這個需求作更實作化的描述,所以我們將重新描述新增使用者需求,如下:

  • 透過 Restful API 新增用戶
  • 用戶資料有 用戶編號、姓名、帳本 三個欄位。
  • 用戶編號為自動編號
  • 帳本初始值為 0
  • 用戶資料存放到 Redis 中

下面將對每個需求細項及其實作方式作描述。

  • 透過 Restful API 新增用戶:
    我們採用 Spring Boot 的 @PostMapping("/user") 的註解來實作
  • 用戶資料有 用戶編號、姓名、帳本 三個欄位:
    建立 User 物件存放三個欄位,使用 Srping Data Redis 的 @RedisHash("user") 註解
  • 用戶編號為自動編號:
    在 用戶編號欄位上使用 @Id 欄位。
  • 帳本初始值為 0:
    在 帳本欄位 使其初始值為 0。
  • 用戶資料存放到 Redis 中
    使用 Srpring Data Redis 的 CrudRepository 物件,將 User 物件儲存到 Redis 中。

這個新增使用者功能應該是要實作在 appWeb 這個模組,所以我們進入 appWeb 模組中開始實作。

6.1.1 實作新增使用者功能

在 appWeb 模組內我們需要完成三項操作,說明如下:

  • 建立 User 類別: 代表用戶資訊。
  • 建立 UserRepository 介面: 讓 Spring Data Redis 能將 User 物件存放到 Redis 中。
  • 修改 AppMain: 建立 Restful 接入點,和執行 User 儲存到 Redis 的操作。

接下來我們依序描述這三個操作。

建立 User 類別

我們在 appWeb 組模內建立 User 類別,其內含有指定的三個欄位,也加入 @RedisHash("user") 註解,其原始碼重點部份如下:

package org.example;
// ...
@Data
@Accessors(chain = true)
@RedisHash("user")
public class User {
    @Id
    private String uid;
    private String name;
    private Integer balance;
}
建立 UserRepository 介面

我們前面提到使用 CrudRepository 物件,將 User 物件儲存到 Redis 中,所以我們要建立對應的 UserRepository 介面,其原始碼重點部份如下:

package org.example;
//...
@Repository
public interface UserRepository extends CrudRepository<User, String> {
}
修改 AppMain 類別

在 AppMain 類別內,我們加入 /user 這個 Restful API 接入點,將用戶傳入的 User 類別填入流水號後,儲存到 Redis 內,其原始碼重點部份如下:

package org.example;
//...
@SpringBootApplication // 觸發 SpringBoot 自動配置/物件掃瞄
@RestController // 設定本類別可以處理 Restful 的請求
@Slf4j // 自動產出日誌相關物件
public class AppMain {
    //...

    @Autowired
    private StringRedisTemplate stringRedisTemplate; // 處理 redis 操作

    @Autowired
    private UserRepository userRepository; // 處理 JPA 操作

    @PostMapping("/user") // 宣告本方法為 POST 和其進入點
    @ResponseBody // 將回傳結果放入 HTTP Body 中
    public String post_user(@RequestBody User user) {
        // 取得新的流水號
        Long uid_no = stringRedisTemplate.opsForValue().increment("user.id", 1);
        // 將 user 修正後存到 redis
        User save = userRepository.save(user.setUid("uid_" + uid_no).setBalance(0));
        return "異動成功,新增資料:" + save.toString();
    }
}

完成這些操作後,我們已經完成新增使用者的實作,接下來會描述如何驗證。

6.1.2 驗證新增使用者功能

驗證新增使用者功能可以使用 Postman 工具,在啟動 appWeb 模組後,開啟 Postman 工具,輸入正確的 URI 和 BODY,在發送請求後得到符合預期的回應,即代表驗證成功。

在 Postman 輸入值和預期的回應值,如下圖:
https://ithelp.ithome.com.tw/upload/images/20220614/20149259mvA1Ei5Hp9.png?o=2022-05-13_06.png

除了在 Postman 得到預期回應值外,我們可以檢查在 Redis 上新增一些鍵,如下:

  • user:id : 用來記錄當前的流水號
  • user: 型別為 set,用來存放所有 user 的主鍵。
  • user:${user_id}: 型別為 hash,儲存一個 user 物件內所有的欄位

下圖為透過 redis-cli 檢查對應鍵的輸出:
https://ithelp.ithome.com.tw/upload/images/20220614/20149259B2Aht5n8jR.png?o=2022-05-13_07.png

完成新增使用者功能後,接下來準備完成新增訂單和儲值 API

6.2 新增訂單和儲值

基於已確認的架構和環境資訊,我們重新描述新增訂單和儲值需求,如下:

  • 關於新增訂單需求
    • 透過 Restful API 新增訂資
    • 訂單資料有 訂單編號、用戶編號、產品代碼、訂單金額、訂單狀態 五個欄位。
    • 用戶編號需為合法的編號
    • 訂單編號為自動編號
    • 訂單狀態初始值為待發貨
    • 訂單資料存放到 Redis 中
  • 關於儲值需求
    • 透過 Restful API 儲值
    • 儲值資料有 用戶編號、儲值金額 二個欄位。
    • 用戶編號需為合法的編號
    • 將儲值金額累加到用戶的帳本中。

新增訂單的操作大致類同新增用戶的操作,將輸入訂單資料封裝成物件,儲存在Redis內。除此之外,我們還將新增訂單資料放在隊列之中,因為我們還有一個執行發貨功能,該功能可以由這個隊列中取得待發貨資訊。下面為二模組間通訊的示意圖:
https://ithelp.ithome.com.tw/upload/images/20220614/20149259rI6sBjX4JG.png?o=2022-06-13_01.png

6.2.1 實作新增訂單和儲值

在 appWeb 模組內我們需要完成四項操作,說明如下:

  • 建立 Order 類別: 代表訂單資訊。
  • 建立 OrderRepository 介面: 讓 Spring Data Redis 能將 Order物件存放到 Redis 中。
  • 建立 Recharge 類別: 代表儲值資訊。
  • 修改 AppMain: 建立 Restful 接入點,和執行 Order 儲存到 Redis 和儲值操作。

接下來我們依序描述這四個操作。

建立 Order 類別

我們在 appWeb 組模內建立 Order 類別,其內含有指定的五個欄位,也加入 @RedisHash("order") 註解,其原始碼重點部份如下:

package org.example;
// ...
@Data
@Accessors(chain = true)
@RedisHash("order")
public class Order {
    @Id
    private String oid;
    private String uid;
    private String pid;
    private Integer amount;
    private String status;
}
建立 OrderRepository 介面

為了將 Order 物件儲存到 Redis 中,我們要建立對應的 OrderRepository 介面,其原始碼重點部份如下:

package org.example;
// ...
@Repository
public interface OrderRepository extends CrudRepository<Order, String> {
}
建立 Recharge 類別

我們在 appWeb 組模內建立 Recharge 類別,其內含有指定的二個欄位,因為該物件不需存入 Redis 中,所以不需加入 @RedisHash 註解,其原始碼重點部份如下:

package org.example;
// ...
@Data
public class Recharge {
    private String uid;
    private Integer amount;
}
修改 AppMain 類別

在 AppMain 類別內,我們加入 /order 這個 Restful API 接入點,將用戶傳入的 Order 類別檢查為合法用戶後,填入訂單流水號後,儲存到 Redis 內,也同步將訂單編號加到待發貨隊列中。

另外也加入 /recharge 這個 Restful API 接入點,將用戶傳入的 儲值資料,檢查為合法用戶後,將儲值金額累加到用戶的帳本中。

其原始碼重點部份如下:

package org.example;
// ...
public class AppMain {    
    // ...
    @Autowired
    private OrderRepository orderRepository;
    // ...
    @PostMapping("/order") // 宣告本方法為 POST 和其進入點
    @ResponseBody // 將回傳結果放入 HTTP Body 中
    public String post_order(@RequestBody Order order) {
        Optional<User> byId = userRepository.findById(order.getUid());
        if (!byId.isPresent()) {// 檢查是否有此使用者
            return "新增失敗,uid:" + order.getUid() + " 不存在";
        }
        Long balance = stringRedisTemplate.opsForHash().increment("user:" + order.getUid(), "balance", -1 * order.getAmount());
        if (balance < 0) {
            // 餘額不足,執行補償後返回
            stringRedisTemplate.opsForHash().increment("user:" + order.getUid(), "balance", order.getAmount());
            return "新增失敗,uid:" + order.getUid() + " 餘額不足";
        }
        // 儲值成功,執行新增訂單
        Long oid_no = stringRedisTemplate.opsForValue().increment("order.id", 1);
        Order save = orderRepository.save(order.setOid("oid_" + oid_no).setStatus("waiting"));
        // 新增請求到發貨隊列
        stringRedisTemplate.opsForList().rightPush("order.queue", save.getOid());
        return "異動成功,新增資料:" + save.toString();
    }

    @PostMapping("/recharge") // 宣告本方法為 POST 和其進入點
    @ResponseBody // 將回傳結果放入 HTTP Body 中
    public String post_recharge(@RequestBody Recharge recharge) {
        Optional<User> byId = userRepository.findById(recharge.getUid());
        if (!byId.isPresent()) {// 檢查是否有此使用者
            return "新增失敗,uid:" + recharge.getUid() + " 不存在";
        }
        Long balance = stringRedisTemplate.opsForHash().increment("user:" + recharge.getUid(), "balance", recharge.getAmount());
        return "異動成功,儲值後帳本:" + balance;
    }
}

6.2.2 驗證新增訂單和儲值

驗證新增使用者功能可以使用 Postman 工具,在啟動 appWeb 模組後,開啟 Postman 工具,輸入正確的 URI 和 BODY,在發送請求後得到符合預期的回應,即代表驗證成功。

在 Postman 輸入值和預期的回應值,如下圖:

  • 一開始嘗試新增訂單時,回應餘額不足:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259PjZ0vXsbBx.png?o=2022-05-14_01.png
  • 進行儲值操作,回應該用戶儲值後金額:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259eY82d5LZxo.png?o=2022-05-14_02.png
  • 再執行新增訂單,回應訂單建立成功:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259GA0kntywac.png

完成新增訂單和儲值功能後,接下來準備完成發貨排程功能

6.3 發貨排程

之前完成的功能皆實作在 appWeb 模組中,但是這個發貨排程和 appWeb 解耦合,該功能放在 appBackend 模組中,二個模組間的通訊是籍由 Redis 中以 List 型別實作的隊列來達成的。

6.3.1 實作發貨排程

在這個功能中,我們由待發貨隊列中取出資料後,就算是已執行完發貨操作,直接將該訂單狀態改為已發貨。

所以在 appBackend 模組內我們需要完成三項操作,說明如下:

  • 建立 Order 類別: 代表訂單資訊,其內容和 appWeb 內的 Order 相同。
  • 建立 OrderRepository 介面: 讓 Spring Data Redis 能取出並儲存 Redis 中的 Order 物件。
  • 修改 AppBackend : 以迴圈方式取出待處理訂單,並更新結果。

接下來我們依序描述這三個操作。

建立 Order 類別

和我們在 appWeb 組模內建立 Order 類別相同,其原始碼重點部份如下:

package org.example;
// ...
@Data
@Accessors(chain = true)
@RedisHash("order")
public class Order {
    @Id
    private String oid;
    private String uid;
    private String pid;
    private Integer amount;
    private String status;
}
建立 OrderRepository 介面

和我們在 appWeb 組模內建立 OrderRepository 介面相同,其原始碼重點部份如下:

package org.example;
// ...
@Repository
public interface OrderRepository extends CrudRepository<Order, String> {
}
修改 AppBackend 類別

在 AppBackend 類別內,我們使用 while 迴圈不斷嘗試由隊列取出待發貨訂單,若該值不為空則更新訂單資訊,其原始碼重點部份如下:

package org.example;
// ...
public class AppBackend implements CommandLineRunner {
    // ...
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @Override
    public void run(String... args) throws Exception {
        System.out.printf("hi...%n");
        while (true) {
            String oid = stringRedisTemplate.opsForList().leftPop("order.queue", 10, TimeUnit.SECONDS);
            if (oid != null) {
                orderRepository.findById(oid).ifPresent(order -> {
                    // 更新交易狀態
                    orderRepository.save(order.setStatus("delivered"));
                    log.info("進行發貨操作: order {}", order);
                });
            }
        }
    }
}

完成這些操作後,我們已經完成發貨排程的實作,接下來會描述如何驗證。

6.3.2 驗證發貨排程

驗證發貨排程可以在新增訂單後,啟動 appBackend 模組,觀察該模組啟動後是否有取出剛才的新增訂單,並輸出文字在控制台的輸出上,如下:
https://ithelp.ithome.com.tw/upload/images/20220614/20149259cJ0cvsDUEn.png?o=2022-05-14_04.png

完成發貨排程功能後,接下來準備完成查詢訂單功能

6.4 查詢訂單

查詢訂單功能是針對單筆訂單查詢狀態,但是我們模擬客戶後來加上一個限制條件,要求每個用戶在10秒內僅能查詢3次,超過3次時則回覆失敗訊息,這是因為有用戶查詢頻率過高造成系統效能負荷過高。

6.4.1 實作查詢訂單

在這個功能中,我們要加上 '限流' 這個能力,我們將使用 Redis 的 '無該鍵才新增' 和 '指定鍵的效期' 來達成。

我們將描述在 appWeb 模組內修改 AppMain 類別來實作 查詢訂單 功能。

修改 AppMain 類別

在 AppMain 類別內,我們加入 /order/{uid}/{oid} 這個 Restful API 接入點,依傳入的用戶編號查詢是否超過限流標準,若超過標準回應 401 錯誤,若沒有超過標準則執行查詢訂單操作。其原始碼重點部份如下:

package org.example;
// ...
public class AppMain {
    // ...
    @GetMapping("/order/{uid}/{oid}") // 宣告本方法為 GET 和其進入點
    public ResponseEntity<String> get_order(@PathVariable String uid, 
                                            @PathVariable String oid) {
        // 檢查查詢次數是否超過限制
        String keyName = String.format("query:%s:count", uid);
        Long count = 1L;
        // 使用 '無該鍵才新增' 方式指定鍵的效期為 10秒
        Boolean aBoolean = stringRedisTemplate
            .opsForValue()
            .setIfAbsent(keyName, String.valueOf(count), 
                         10, TimeUnit.SECONDS);
        if (!aBoolean) {
            count = stringRedisTemplate
                .opsForValue()
                .increment(keyName, 1);
        }
        if (count > 3) {
            // 若超過限流,則回應 401 錯誤
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("查詢次數過多,請稍後再試");
        }
        // 查詢訂單是否存在
        Optional<Order> byId = orderRepository.findById(oid);
        if (!byId.isPresent()) {
            return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body("查無訂單:" + oid);
        }
        // 回應訂單資訊
        return ResponseEntity
            .status(HttpStatus.OK)
            .body("訂單3:" + byId.get().toString());
    }
}

完成查詢訂單功能,接下來我們將描述如何驗證這個查詢功能。

6.4.2 驗證查詢訂單

我們使用 JMeter 工具來驗證,在大量查詢下,是否有達到限流這個要求。

我們先設定 JMeter 的測試計劃。

建立 JMeter 測試計劃

我們直接使用圖示來說明 JMeter 的測試參數設定值

  • 建立 ThreadGroup,啟動一個執行緒,每秒執行一次,總共執行60次
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259XoKRGu0g57.png?o=2022-05-14_05.png
  • 加入 HTTP 請求,設定相關 URI,如下:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259QGhdNx9nNH.png?o=2022-05-14_06.png
  • 在 HTTP 請求下加一個 Timer,每次執行前先延遲一秒,如下圖
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259s3SIEvrr1L.png?o=2022-05-14_07.png
  • 在 HTTP 請求後面加入 View Results Tree 和 View Results in Table
執行 JMeter 測試計劃

啟動 JMeter 測試計劃,可以看到每10秒內僅有三個查詢可以得到結果,如下圖
https://ithelp.ithome.com.tw/upload/images/20220614/20149259jbSkLiDsIk.png?o=2022-05-14_08.png

完成查詢訂單功能後,接下來準備完成搶購功能。

搶購功能可以有二個實作方式,分別是 function 和 Lua 腳本,下面我們先描述如何使用 Function 來實作。

6.5 搶購(Function)

若要以 Function 來實作搶購功能,則 Redis 版本必須是 7 以上。我們直接使用之前用到的粆殺Function,在建立該 function 後,由 Java 客戶端呼叫該功能,依該功能的回應判斷是否搶購成功。

6.5.1 實作搶購(Function)

在 appWeb 模組內我們需要完成二項操作,說明如下:

  • 在 Redis 上建立 搶購(秒殺) function
  • 修改 AppMain: 建立 Restful 接入點,呼叫 Redis 上的 function。

接下來我們依序描述這二個操作。

在 Redis 上建立 搶購(秒殺) function

在 redis 建立function時,需先進入 Redis 容器,在 Shell 中執行下列指令:

cat << EOF | redis-cli -a 'mypwd' -x FUNCTION LOAD REPLACE
#!lua name=mylib  
local function flash_sale_top3(keys, args)
  local myZset = keys[1] -- 取得 KEY 名稱
  local myZval = args[1] -- 取得 VALUE
  local keyType = redis.call('type', myZset) -- 取得 KEY 的型別
  keyType = keyType.ok or keyType -- 由 TABLE 取出結果
  -- redis.log(redis.LOG_NOTICE, 'KeyType:'..keyType) -- 在日誌中印出
  -- 若 KEY 存在但不為 ZSET 則回應錯誤
  if keyType ~= 'none' and keyType ~= 'zset' then 
    return redis.error_reply('輸入的 KEY 應為 zset 或不存在')
  end
  -- 若 VALUE 不存在 則回應錯誤
  if myZval == nil then
    return redis.error_reply('未輸入 '..myZset..' 對應的值')
  end
  -- 若 VALUE 已加入 KEY 中,則回應重覆
  local myScore = redis.call('zscore',myZset,myZval)
  if myScore then
    return redis.status_reply('duplicated-'..myScore)
  end
  -- 若 KEY 內的數目已達 3 則回應無額度
  local count = redis.call('zcard', myZset)
  if count >= 3 then
    return redis.status_reply('no quota')
  end
  -- 將 VALUE 加入 KEY 中,並回應其排名
  redis.call('zadd',myZset,count+1, myZval)
  -- return redis.status_reply(tostring(count+1))
  return redis.status_reply('success-'..(count+1))
end

redis.register_function('flash_sale_top3', flash_sale_top3)
EOF

修改 AppMain

在 AppMain 類別內,我們加入 /flash_sale/{uid} 這個 Restful API 接入點,呼叫 Redis 的 function 並將用戶編號以參數方式傳入,依回傳結果判讀是否搶購成功。

目前使用的 redisson 版本為 3.17.3,也是目前最新的版本,在實作中無法正確傳遞 KEYS 給 Redis,所以會使用其他解決方法。

在此仍先列出在 Redisson 中,標準的呼叫方法:

RFunction f = redisson.getFunction();
// load function
f.load("lib", "redis.register_function('myfun', function(keys, args) return args[1] end)");
// execute function
String value = f.call(RFunction.Mode.READ, "myfun", RFunction.ReturnType.STRING, Collections.emptyList(), "test");

採用其他解決方法來呼叫 Redis 上的 function,其原始碼重點部份如下

package org.example;
// ...
public class AppMain {
    // ...
    @Autowired
    private RedissonClient redissonClient;
    // ...
    @GetMapping("/flash_sale/{uid}") // 宣告本方法為 GET 和其進入點
    public ResponseEntity<String> get_flash_sale(@PathVariable String uid) {
        CommandAsyncExecutor commandExecutor = 
            ((Redisson) redissonClient).getCommandExecutor();
        String result = commandExecutor.get(
                commandExecutor.writeAsync(
                        (String) null,
                        commandExecutor.getConnectionManager().getCodec(),
                        FunctionResult.STRING.getCommand(),
                        "flash_sale_top3", 1, "flash_sale_result", uid));
        if (result.startsWith("success-")) {
            return ResponseEntity
                .status(HttpStatus.OK)
                .body("搶購成功:" + result.substring("success-".length()));
        } else if (result.startsWith("duplicated-")) {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("重覆搶購:" + result.substring("duplicated-".length()));
        } else if (result.startsWith("no quota")) {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("搶購失敗");
        } else {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body("搶購失敗:" + result);
        }
    }
}

完成搶購(Function)功能後,接下來我們將描述如何驗證這個搶購詢功能。

6.5.2 驗證搶購(Function)

驗證搶購功能,我們使用二個工具驗證。第一個方式是用 Postman 來驗證同一使用者重覆搶購及不同使用者搶購失敗的場景,主要是透過互動方式來感受只有三個使用者可以搶購成功。第二個方式是用 JMeter 檢驗證多使用者併發的情況下,仍只有三個使用者可以搶購成功。

使用 Postman 驗證

我們先使用 Postman 工作來驗證只有前三個請求可以搶購成功,如下:

  • 使用 uid_1 執行第一次搶購,回應成功:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259ULHwqMqQPE.png?o=2022-05-14_09.png
  • 使用 uid_1 執行第二次搶購,回應重覆請求:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259K595Ixp1ht.png?o=2022-05-14_10.png
  • 使用 uid_2/uid_3 執行搶購,皆回應成功
  • 使用 uid_4 執行搶購,回應失敗
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259gLY6Vtoqoi.png?o=2022-05-14_11.png

完成 Postman 驗證後,接下來我們要描述 JMeter 的驗證。

使用 JMeter 驗證

我們使用 JMeter 建立測試計劃,該計劃要同時併發 30 個執行緒執行搶購操作,執行後再驗證是否僅有三個使用者搶購成功。

我們先建立測試計劃,如下:

  • 建立測試計劃,設定併發30個執行緒。如下圖:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259f0VQMPuNHF.png?o=2022-05-14_12.png
  • 先建立計數器,變數名稱 my_counter,用來作用戶編號使用,如下圖:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259RTZ6qhcAb2.png?o=2022-05-14_13.png
  • 建立HTTP請求,設定URI參數,其中用戶編號使用my_counter變數,如下圖:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259UhSfLnkjn0.png?o=2022-05-14_14.png
  • 建立 View Results Tree 和 View Results in Table 以便觀察結果。

完成測試計劃後,啟動測試計劃,觀察結果是否為前三個成功,其餘失敗,如下圖:
https://ithelp.ithome.com.tw/upload/images/20220614/20149259tYFO9YCS0v.png?o=2022-05-14_15.png

到此我們已經完成搶購功能(使用Function實作),接下來我們要描述使用Lua腳本實作的搶購的功能。

6.6 搶購(Lua腳本)

只在Redis版本7以上才可以使用Function功能,若是使用版本6或以下的Redis就只能使用Lua腳本來實作搶購功能。接下來我們將描述如何透過Lua腳本實作搶購功能。

6.6.1 實作搶購(Lua腳本)

使用Lua腳本完成該功能,我們需要完成三件事,如下:

  • 建立Lua腳本,將該腳本放置在 resources/scripts/ 路徑下。
  • 建立MBean物件,將該腳本注入RedisScript物件中。
  • 修改appWeb模組下AppMain類別,建立Restful API接入點,和實作搶購功能。

接下來我們依序描述這三個操作。

建立Lua腳本

resources/scripts/ 路徑下建立 LUA 腳本,內容如下:

local myZset = KEYS[1] -- 取得 KEY 名稱
local myZval = ARGV[1] -- 取得 VALUE
local keyType = redis.call('type', myZset) -- 取得 KEY 的型別
keyType = keyType.ok or keyType -- 由 TABLE 取出結果
redis.log(redis.LOG_NOTICE, 'KeyType:'..keyType..',myZset:'..myZset) -- 在日誌中印出
-- 若 KEY 存在但不為 ZSET 則回應錯誤
if keyType ~= 'none' and keyType ~= 'zset' then 
  return 'error-輸入的 KEY 應為 zset 或不存在'
end
-- 若 VALUE 不存在 則回應錯誤
if myZval == nil then
  return 'error-未輸入 '..myZset..' 對應的值'
end
-- 若 VALUE 已加入 KEY 中,則回應重覆
local myScore = redis.call('zscore',myZset,myZval)
if myScore then
  return 'duplicated-'..myScore
end
-- 若 KEY 內的數目已達 3 則回應無額度
local count = redis.call('zcard', myZset)
if count >= 3 then
  return 'no quota'
end
-- 將 VALUE 加入 KEY 中,並回應其排名
redis.call('zadd',myZset,count+1, myZval)
return 'success-'..(count+1)
建立MBean物件

建立 MyBean 物件,用來注入 flashSaleScript,其內容如下:

@Component
public class MyBean {
    @Bean
    public RedisScript<String> flashSaleScript() {
        return RedisScript.of(new ClassPathResource("scripts/myfunc.lua"), String.class);
    }
}
修改appWeb模組下AppMain類別

在 AppMain 類別內,我們加入 /flash_sale2/{uid} 這個 Restful API 接入點,呼叫 Redis 的 function 並將用戶編號以參數方式傳入,依回傳結果判讀是否搶購成功。其原始碼重點部份如下:

package org.example;
//...
public class AppMain {
	//...
    @Autowired
    private RedisScript<String> flashSaleScript;
	//...
    @GetMapping("/flash_sale2/{uid}") // 宣告本方法為 GET 和其進入點
    public ResponseEntity<String> get_flash_sale2(@PathVariable String uid) {
        String result = stringRedisTemplate.execute(flashSaleScript, Collections.singletonList("flash_sale_result2"), uid);
        if (result.startsWith("success-")) {
            return ResponseEntity.status(HttpStatus.OK).body("搶購成功:" + result.substring("success-".length()));
        } else if (result.startsWith("duplicated-")) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("重覆搶購:" + result.substring("duplicated-".length()));
        } else if (result.startsWith("no quota")) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("搶購失敗");
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("搶購失敗:" + result);
        }
    }
}

完成搶購(Lua腳本)功能後,接下來我們將描述如何驗證這個搶購詢功能。

6.6.2 驗證搶購(Lua腳本)

我們這個功能主要是描述使用Lua腳本實作搶購功能,驗證部分僅使用Postman工具測試。

  • 使用 uid_1 執行第一次搶購,回應成功:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259o7xoPKiWuE.png?o=2022-06-08_01.png
  • 使用 uid_1 執行第二次搶購,回應重覆請求:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259MtycB6qB4V.png?o=2022-06-08_02.png
  • 使用 uid_2/uid_3 執行搶購,皆回應成功
  • 使用 uid_4 執行搶購,回應失敗
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259aZ1MtXcDOW.png?o=2022-06-08_03.png

到此我們已經完成搶購功能,接下來我們將實作分佈式鎖功能。

6.7 分佈式鎖功能

我們在這是透過獨佔資源報表這個描述來說明分佈式鎖這個功能,在實際系統中會如何運用這個功能,還需看實際需求而定。在這裡我們是實作二個模組,這個二模組在啟動後會請求同一把分佈式鎖,然後佔用該鎖60秒鐘後釋放,若沒有搶到這一把分佈式鎖,則直接在控制台中輸出請求分佈式鎖失敗。若我們同時啟動這二個模組,則應為較早啟動的模組正常執行,而較晚啟動的模組會輸出請求分佈式鎖失敗的訊息。

6.7.1 實作分佈式鎖

我們將修改appReport1 & appReport2這二模組的AppReport類別,讓其啟動後先請求同一把分佈式鎖,佔用60秒後釋放。其原始碼重點部份如下:

package org.example;
// ...
public class AppReport01 implements CommandLineRunner {
    // ...
    @Autowired
    private RedissonClient redissonClient;

    @Override
    public void run(String... args) throws Exception {
        RLock lock = redissonClient.getLock("reportLock");
        boolean res = lock.tryLock(3, TimeUnit.SECONDS);
        if (res) {
            log.info("分佈式鎖 reportLock 取得鎖定");
            try {
                log.info("分佈式鎖 reportLock 執行報表");
                Thread.sleep(1000L * 60L);
            } finally {
                log.info("分佈式鎖 reportLock 釋放鎖");
                lock.unlock();
            }
        } else {
            log.info("分佈式鎖 reportLock 取得失敗");
        }
        redissonClient.shutdown();
    }
}

6.7.2 驗證分佈式鎖可互斥

我們將啟動appReport1模組後再啟動appReport2模組,此時這二個模組應該運行在不同的JVM上,即使在不同的的JVM上,分佈式鎖仍可以發揮效果。

測試操作如下:

  • 啟動appReport1,觀察到成功取得分佈式鎖:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259b1e4mRMiTe.png?o=2022-05-16_01.png
  • 再啟動appReport2,觀察到請求分佈式鎖失敗:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259JDaVjLLict.png?o=2022-05-16_02.png
  • 進入Redis中,可以檢視其型別為hash,其欄位如下:
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259Xkw3YtFbpu.png?o=2022-05-16_03.png

6.7.3 驗證分佈式鎖可自行釋放

實作分佈式鎖時需考慮到若一個程序取得該鎖,但遇到異常無法解鎖,例如該程序被強制中斷或網路斷線且一時間無法恢復,該分佈式鎖應該可以被自動釋放,不應該永遠被佔用。

這個測試就是要驗證當appReport1啟動後,待其取得分佈式鎖後,將其強制中斷,觀察該分佈式鎖可以自動被釋放。測試操作如下:

  • 在啟動 AppReport01 後強行中斷,使其無法釋放鎖:
    https://ithelp.ithome.com.tw/upload/images/20220614/201492593tDhBkhmsl.png?o=2022-05-16_04.png
  • 查詢Redis內,可以見到該分佈式鎖存在,尚未被釋放。
  • 等待60秒後,再到Redis查詢,確認該分佈式鎖已不存在,該鎖已被釋放。
    https://ithelp.ithome.com.tw/upload/images/20220614/20149259OerVAnJyL7.png?o=2022-05-16_05.png

7. 結論

在本篇的學習中,我們是先虛擬客戶需求,基於客戶需求來設計系統架構,最後實作這些需求並驗證這些功能。

這些需求是基於Redis的特性而設計的,主要是用來說明Redis可以帶給我們系統那些功能。

我們也稍微說明Redis Java 客戶端的技術選型,我們採用Redisson主要的原因為其支援分佈鎖這個功能。

我們也快速說明如何在IntellJ IDEA IDE中快速建立一個專案和其下四個模組,若不知如何在IntellJ IDEA中如何建立數個模組時,可以作為參考資料。

最後我們實作了數個功能,這些功能是基於Redis亮點而設計的,若在實際專案中,有類似的功能需求,可以參考對應的實作方式。


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言