iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0
Modern Web

30 天上手! PHP 微服務入門與開發系列 第 20

第二十章、Anser-Saga:替協作器加上補償 - PHP 微服務入門與開發

  • 分享至 

  • xImage
  •  

如果你是跟著文章一天接著一天實作的讀者,那麼你需要確保你的 :

當一個交易涉及多個服務時,我們需要一個可靠的機制來確保所有的步驟都成功執行,或在遇到失敗時能夠恢復到原先的狀態。而 Anser-Saga 為此而設計,它是一個基於Anser-Orchestration 元件的協作器交易設計,主要負責在分散式交易中管理和執行每個步驟的補償方法。

在本章的範例程式碼中,我們將展示如何利用 Anser-Saga 來設計和實現一個用於建立訂單的協作器。在這個例子中,我們會看到如何定義各個交易步驟,以及如何為每個步驟設定補償策略來保證資料的一致性和交易的完整性。

撰寫 Saga 補償邏輯

建立訂單的協作器與可能的錯誤

我們重新再拿出第十六章的協作器設計圖,會發現除了第一與第二步驟外,剩下的步驟都涉及資料的敏感操作。協作器是依步驟順序執行的,也就是說從商品庫存扣除開始,只要有任何一個步驟的行動出現錯誤,那麼整個協作器將會停擺,已經執行的更動將會造成資料不一致的結果。

為了要解決上述問題,在 Anser-Saga 元件中,你能夠獨立撰寫每個步驟的補償方法。請在專案中建立起 {Anser-Tutorial-Service}/Orchestrators/Sagas/CreateOrderSaga.php :

<?php

namespace Orchestrators\Sagas;

use SDPMlab\Anser\Orchestration\Saga\SimpleSaga;
use SDPMlab\Anser\Service\ConcurrentAction;
use Orchestrators\CreateOrderOrchestrator;

class CreateOrderSaga extends SimpleSaga
{

    public function rollbackInventory()
    {
        /** @var CreateOrderOrchestrator  */
        $runTimeOrchestrator = $this->getOrchestrator();
        $orderProducts = $runTimeOrchestrator->orderProducts;
        $concurrentAction = new ConcurrentAction();
        foreach ($orderProducts as $product) {
            $concurrentAction->addAction(
                'rollbackInventory_' . $product->p_key,
                $runTimeOrchestrator->productionService->addInventoryCompensateAction($product->p_key, $runTimeOrchestrator->orderId, $product->amount)
            );
        }
        $concurrentAction->send();
    }

    public function rollbackOrder()
    {
        /** @var CreateOrderOrchestrator  */
        $runTimeOrchestrator = $this->getOrchestrator();
        $userKey = $runTimeOrchestrator->getStepAction('userInfo')->getMeaningData()['data']['u_key'];
        $runTimeOrchestrator->orderService->compensateOrderAction($userKey, $runTimeOrchestrator->orderId)->do();
    }

    public function rollbackUserWalletCharge()
    {
        /** @var CreateOrderOrchestrator  */
        $runTimeOrchestrator = $this->getOrchestrator();
        $userKey = $runTimeOrchestrator->getStepAction('userInfo')->getMeaningData()['data']['u_key'];
        $total = $runTimeOrchestrator->getStepAction('createOrder')->getMeaningData()['total'];
        $runTimeOrchestrator->userService->walletCompensateAction($userKey, $runTimeOrchestrator->orderId, $total)->do();
    }

}

CreateOrderSaga 類別繼承了 SimpleSaga,這意味著我們可以在這個協作器中以 $this->getOrchestrator() 取得在執行週期內的協作器實體。你可以關注一下這個寫法:

/** @var CreateOrderOrchestrator  */
$runTimeOrchestrator = $this->getOrchestrator();

雖然 getOrchestrator() 會回傳符合 OrchestratorInterface 介面的協作器實體,但由我們自己撰寫的協作器有著許多與業務邏輯圍繞的成員變數、方法,以及服務抽象化後的 Service 類別。因此,我們可以透過 /** @var CreateOrderOrchestrator */ 來提醒 IDE 這個變數並非單純的 OrchestratorInterface ,而是 CreateOrderOrchestrator 類別的實體。這將幫助我們在撰寫上能夠享有程式碼自動完成、提示,甚至是通過靜態語法檢查。

rollbackInventory()rollbackOrder(),以及 rollbackUserWalletCharge() 是為不同協作器步驟所定義的補償方法,它們都是公開方法並且只能是公開方法。同時,它們分別負責恢復庫存、取消訂單和退還使用者錢包餘額。

  • rollbackInventory()
    此方法的目的是在庫存處理步驟失敗時,還原已完成的庫存變更。因為庫存處理的步驟涉及並行連線,一次修改了很多商品的庫存,所以在這個範例中我們遍歷 orderProducts 陣列,為每個產品建立一個新的行動,並將它們添加到 ConcurrentAction 實體中,最後以並行的方式進行大規模的補償。
  • rollbackOrder()
    透過從 CreateOrderOrchestrator 實體中取得已經完成的 Action 並拿到使用者的 key 和訂單 ID。使用這些資訊,我們就能夠呼叫 compensateOrderAction 方法來還原已建立的訂單。
  • rollbackUserWalletCharge()
    如果在使用者錢包扣款過程中出現問題,我們就能夠透過 getOrchestrator 方法取得當前的 CreateOrderOrchestrator 實體,並從中取得 key、訂單 ID 和訂單總金額,最後將它們用於使用者錢包補償。

替協作器加上補償邏輯

在 Anser 協作器設計模式中,允許你在合適的地方加上交易的保護,你可以依照下列範例的參考改寫你的 {project_root}/Orchestrators/CreateOrderOrchestrator.php :

<?php
namespace Orchestrators;

use SDPMlab\Anser\Orchestration\Orchestrator;
use Services\OrderService;
use Services\ProductionService;
use Services\UserService;
use Services\Models\OrderProductDetail;
use Orchestrators\Sagas\CreateOrderSaga;

class CreateOrderOrchestrator extends Orchestrator
{
    public UserService $userService;
    public OrderService $orderService;
    public ProductionService $productionService;

    protected string $authToken;

    /**  
     * @var OrderProductDetail[]
     */
    public array $orderProducts;

    public ?string $orderId = null;

    /**
     * CreateOrderOrchestrator
     *
     * @param string $authToken
     * @param OrderProductDetail[] $orderProducts
     */
    public function __construct(string $authToken, array $orderProducts)
    {
        $this->authToken = $authToken;
        $this->orderProducts = $orderProducts;
        $this->userService = new UserService();
        $this->orderService = new OrderService();
        $this->productionService = new ProductionService();
        $this->orderId = $this->generateOrderId();
    }

    /**
     * definition of orchestrator
     *
     * @return void
     */
    protected function definition()
    {
        //Step0 取得使用者資訊(驗證使用者)
        $this->setStep()->addAction(
            alias: 'userInfo',
            action: $this->userService->userInfoAction($this->authToken)
        );

        //Step1 取得產品最新資訊(用於取得最新售價)
        $step1 = $this->setStep();
        foreach ($this->orderProducts as $index => $orderProduct) {
            $step1->addAction(
                alias: 'product_' . ($orderProduct->p_key),
                action: $this->productionService->productInfoAction($orderProduct->p_key)
            );
        }

        $this->transStart(transactionClass: CreateOrderSaga::class);

        //Step2 扣商品庫存
        $step2= $this->setStep()->setCompensationMethod('rollbackInventory');
        foreach ($this->orderProducts as $index => $orderProduct) {
            $step2->addAction(
                alias: 'product_' . ($orderProduct->p_key) . '_reduceInventory',
                action: $this->productionService->reduceInventory($orderProduct->p_key,  $this->orderId, $orderProduct->amount)
            );
        }

        //Step3 建立訂單(將會取得訂單總價 total)
        $this->setStep()->setCompensationMethod('rollbackOrder')
            ->addAction(
                alias: 'createOrder',
                action: static function (CreateOrderOrchestrator $runtimeOrch) {
                    $userKey = $runtimeOrch->getStepAction('userInfo')->getMeaningData()['data']['u_key'];
                    //將最新商品售價更新至訂單資訊
                    foreach ($runtimeOrch->orderProducts as &$product ) {
                        $product->price = (int)$runtimeOrch->getStepAction('product_' . $product->p_key)->getMeaningData()['data']['price'];
                    }                
                    return $runtimeOrch->orderService->createOrderAction($userKey, $runtimeOrch->orderId, $runtimeOrch->orderProducts);
                }
            );
        
        //Step4 使用者錢包扣款
        $this->setStep()->setCompensationMethod('rollbackUserWalletCharge')
            ->addAction(
                alias: 'walletCharge',
                action: static function (CreateOrderOrchestrator $runtimeOrch) {
                    $userKey = $runtimeOrch->getStepAction('userInfo')->getMeaningData()['data']['u_key'];
                    $total = $runtimeOrch->getStepAction('createOrder')->getMeaningData()['total'];
                    return $runtimeOrch->userService->walletChargeAction($userKey, $runtimeOrch->orderId, $total);
                }
            );

        $this->transEnd();
    }

    protected function defineResult(): array
    {
        $orderInfo = $this->getStepAction('createOrder')->getMeaningData();

        $data = [
            "success" => true,
            "message" => "訂單建立成功",
            "data" => [
                "order_id" => $this->orderId,
                "total" => $orderInfo['total'],
            ]
        ];

        return $data;
    }

    protected function defineFailResult(): array
    {
        $failActions = $this->getFailActions();
        $failMessages = [];
        foreach ($failActions as $failAction) {
            $failMessages[] = $failAction->getMeaningData();
        }
        $data = [
            "success" => false,
            "message" => "訂單建立失敗",
            "data" => [
                "order_id" => $this->orderId,
                "fail_messages" => $failMessages,
            ]
        ];
        return $data;
    }

    protected function generateOrderId(): string
    {
        return sprintf(
            '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
            random_int(0, 0xffff),
            random_int(0, 0xffff),
            random_int(0, 0xffff),
            random_int(0, 0x0fff) | 0x4000,
            random_int(0, 0x3fff) | 0x8000,
            random_int(0, 0xffff),
            random_int(0, 0xffff),
            random_int(0, 0xffff)
        );
    }

}
  • 取得使用者資訊 (Step0):
    在這一步,系統會先進行使用者驗證,並再這個步驟中我們能夠取得正確的使用者 ID。
  • 取得產品最新資訊 (Step1):
    協作器會取得每個產品的最新資訊,特別是最新的售價,以保證訂單的價格準確。
  • 開始交易 (Transaction Start):
    這裡,$this->transStart(string $className) 是 Saga 模式的開始,你需要在這個方法中傳入 Class Name。在這個狀況下,transStart 表示開始一個需要多個步驟,並且可能需要還原的過程。如果在這個過程中的任何一步失敗,系統會執行相應的補償策略來還原先前的操作。
  • 扣商品庫存 (Step2):
    這個步驟中,協作器會通知微服務減少相應產品的庫存。由於這個步驟有可能失敗(例如,庫存不足),因此需要定義一個補償方法 rollbackInventory 來處理可能的失敗。
    $this->setStep()->setCompensationMethod(string $methodName) 其中的 $methodName 指的就是 transStart 類別中的公開方法的名稱。
  • 建立訂單 (Step3):
    系統會創建一個新訂單,並計算訂單的總價。這個步驟也定義了一個補償方法 rollbackOrder,以處理可能的失敗。
  • 使用者錢包扣款 (Step4):
    最後,系統會從使用者的錢包中扣除相應的金額。這個步驟也定義了一個補償方法 rollbackUserWalletCharge,以處理可能的失敗。
  • 交易結束 (Transaction End):
    呼叫$this->transEnd()通知協作器交易已結束。

透拓 transStarttransEnd 方法,可以明確地標記出需要保證一致性的操作範圍。如果在這個範圍內的任何操作失敗,Saga 模式會觸發相應的補償方法來還原先前的操作,以保證系統的一致性。

Step2 開始前才呼叫 $this->transStart 是因為,只有在進行庫存扣減、訂單建立和使用者錢包扣款等操作時,才需要考慮可能的失敗和補償。而在此之前的步驟,例如取得使用者資訊和產品資訊,不涉及到可能需要回滾的操作。

觸發補償與驗證

庫存不足

你可以將建立商品的 Raw json 其中一個商品寫入一個極大的數字,這將觸發商品的庫存不足以扣除而進行還原:

[POST] {{main_service}}/create_order.php
[ { "p_key": 1, "price": 450, "amount": 5 }, { "p_key": 2, "price": 70, "amount": 4 }, { "p_key": 55, "price": 70, "amount": 400000 } ]

此時,前往 Production Service 的資料庫,你會看到除了無法建立的 "p_key": 55 商品以外,其餘的商品都經歷了建立並且還原的過程。

餘額不足

接著,你可以登入錢包餘額不足的使用者帳號,比如說:
{ "email" : "user2@anser.io", //使用者帳號 "password" : "password" //使用者密碼 } 取得 Token 後進行一個正常的建立流程,並觀察整體的還原狀態:

[POST] {{main_service}}/create_order.php
[ { "p_key": 1, "price": 450, "amount": 5 }, { "p_key": 2, "price": 70, "amount": 4 } ]

此時,你可以到 Order Service 看到訂單被建立後又被 deleted_at 軟刪除。這意味著這筆訂單將會永遠消失於使用者的視野。

同時,你也可以看到好不容易被扣庫存成功的兩個商品又被補償了庫存。

結語

在本章中,我們學習了如何依據需要撰寫正確的 Saga 補償類別,並且也學會了如何與現有的協作器進行整合,使你能夠替你的協作器輕鬆地加上補償機制。隨著對 Saga 設計模式的理解加深,我們將能夠更有效地處理微服務架構中的長期事務和一致性問題。


上一篇
第十九章、Anser-Saga:協作器交易設計理念 - PHP 微服務入門與開發
下一篇
第二十一章、Anser-Saga: 高可用性設計理念 - PHP 微服務入門與開發
系列文
30 天上手! PHP 微服務入門與開發30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言