使用策略模式和簡單工廠模式重寫支付模組

zxr615發表於2021-03-18

最近接到一個涉及支付的需求,舊程式碼看的有點頭大,所以捋了捋邏輯,看了下時間,還是足夠的,所以就重寫了一遍支付模組,抽空記錄一下過程。

問題所在

  • 全部支付走統一的二維碼生成介面,導致需要通過 type 區分接收不同的欄位,隨著支付方式越來越多,引數判斷越來越多,難以維護
  • 程式碼解構混亂,一個 $data 變數貫通整個方法,導致最後不知道 $data 變數裡面什麼資料,開發、排錯越來越複雜
  • 異常處理,業務程式碼處處丟擲 \Exception 和捕獲 \Exception ,導致如果程式遇到了系統異常也不能及時的通知錯誤

改造前的一段虛擬碼

  1. 所有業務邏輯錯誤也丟擲 \Exception 異常,捕獲 \Exception 後返回 下單失敗 導致如果程式遇到真正錯誤時,無法及時排查錯誤
  2. 單看 checkVerifyType() 方法名會認為只是檢查支付 type 是否正確, 但卻不是,這個方法把所有該幹不該乾的事都幹完了
  3. 傳參用 01 也不能明確知道是代表什麼東西
  4. qrcode 介面引數也很複雜,例:type = 1時,必須要 code 引數; type = 2 時,必須要 price 引數;type = 3 時 ….
  5. $data 裡面各種資料,有:請求資料,訂單臨時資料,訂單預覽資料,根據購買商品的不同又放入不同的資料,結果 $data 就是個大雜燴,修改起來實在一言難盡
// 所有購買入口獲取二維碼的入口
public function qrcode(Request $request)
{
    try {
          // ...
        $key = $this->checkVerifyType(0, 1);
          // ...
        return $key;
    } catch (\Exception $e) {
        return '下單失敗';
    }
}

*/Service/PayService.php

public function checkVerifyType($payType1 = 0, $payType2 = 0)
{
    $data = request()->all();

    if (!ctype_digit(strval($data['type']))) {
        throw new \Exception('type err');
    }

      // ..... 還有一堆的引數驗證

    switch ($data['type']) {
        case 'vip':
            // ... 驗證
            $data['vip_info'] = Vip::where('code', $data['code'])->first();
            break;
        case 'recharge':
            // ... 驗證
            $data['money'] = $data['money'];
            break;
        // case...
    }

    // 優惠券判斷
    if ($data['coupon_id']) {
        $money = Coupon::where('id', $data['coupon_id'])->value("money");
        $data['reduce'] = $money;
        // ....
    }

    // 訂單預覽資訊
    $data['show_title'] = "購買一個會員";
    $data['show_money'] = 100;

    $key = "abcdefg";
    Redis::set($key, $data);

    return $key;
}

著手改造

  1. 涉及支付的模組有:開通會員、充值、購買單個商品等
  2. 開發支付流程:
    1. 生成二維碼(生成臨時訂單 redis,返回 redis 零時訂單 key
    2. 手機端確認購買資訊(展示購買商品資訊)
    3. 手機端確認支付 (通過臨時訂單的 key ,建立一條訂單資料到資料庫)
    4. 根據臨時訂單的 key 建立訂單
    5. 拉起支付
    6. 回撥
  3. 涉及到的設計模式
    1. 策略模式
    2. 簡單工廠模式

前期準備

原來的返回格式:

public function json($code, $msg, $data)
{
    return ['status' => $code, 'message' => $msg, 'data' => $data];
}
// 呼叫
json(200, "Ok", []);

雖然沒什麼大問題,但呼叫起來不太方便,也不直觀,每次還需要傳入一些不必要的引數,這裡增加一些常用的返回方法

BaseContrller 中增加幾個返回資料的方法,方便呼叫

const SUCCESS_CODE = 200;
const SUCCESS_FAIL = 100;

protected function success($msg = 'ok', $data = [], $code = self::SUCCESS_CODE)
{
    return ['status' => $code, 'message' => $msg, 'data' => $data];
}

protected function data($data = [], $msg = 'ok', $code = self::SUCCESS_CODE)
{
    return ['status' => $code, 'message' => $msg, 'data' => $data];
}

protected function fail($msg = 'ok', $data = [], $code = self::SUCCESS_FAIL)
{
    return ['status' => $code, 'message' => $msg, 'data' => $data];
}

按模組區分不同的下單連結

由原來的統一 qrcode 連結分出 3 個介面,每個介面只需要接收自己需要的引數就行,不需要原來的 type 來區分引數

  1. 開通會員: /buy/vip
  2. 充值:/buy/recharge
  3. 購買商品:/buy/goods

建立臨時訂單策略

  1. 建立一個訂單的 抽象策略,定義演算法的介面,所有策略必須實現臨時訂單的介面,

    app/Http/Services/PayOrder/PayOrderStrategy.php

     abstract class PayOrderStrategy
     {
         abstract function createTemporaryOrder($request);
     }
  2. 建立一個 Context

    app/Http/Services/PayOrder/PayOrderStrategy.php

     class PayOrderContext
     {
         private $strategy;
    
         public function __construct(PayOrderStrategy $payOrderStrategy)
         {
             return $this->strategy = $payOrderStrategy;
         }
    
         public function createOrder(Request $request)
         {
             return $this->strategy->createTemporaryOrder($request);
         }
     }
  3. 基礎的策略框架已經搭建好,現在就需要具體的策略了

    開通 vip 策略

    $request開通 vip 介面中傳入的 $request

    app/Http/Services/PayOrder/Strategy/VipStrategy.php

     // 開通 vip
     class VipStrategy extends PayOrderStrategy
     {
           // 組裝臨時訂單的資料,然後存入 redis
           // 這裡是 vip 策略,所以只專注 vip 需要的資料就好
         function createTemporaryOrder(Request $request)
         {
             $packageCode = $request['code'];
             $package     = app(PayOrderService::class)->getVipByCode($packageCode);
    
             // 臨時訂單資料
                $tmpOrder = [
                 'package_cope' => $package->toArray(),
                 'type'         => PayOrderService::TYPE_VIP,
                 'uid'          => 1,
                 'ip'           => $request->ip(),
                 // ....
             ];
    
             return app(PayOrderService::class)->saveTemporaryOrder($tmpOrder);
         }
     }
  4. 建立一個訂單服務類,寫一些建立訂單的公共方法

    app/Http/Services/PayOrderService.php

     use Ramsey\Uuid\Uuid;
    
     class PayOrderService
     {
         const TYPE_VIP      = 1; // 購買 vip
         const TYPE_RECHARGE = 2; // 充值
         const TYPE_GOODS    = 3; // 購買商品
    
         // 通過 code 查詢 vip 套餐資訊
         public function getVipByCode(string $code)
         {
             // 這裡應是從資料庫獲取資料返回
             return collect(['id' => 1, 'code' => 'vip1', 'price' => 100, 'vip_day' => 30]);
         }
    
         // 儲存臨時訂單
         public function saveTemporaryOrder(array $tmpOrder)
         {
             $key = Uuid::uuid4()->toString();
             Cache::set($key, $tmpOrder, 3);
    
             return $key;
         }
     }

    目前的目錄解構

    app/Http/Services/

     ├── PayOrder
     │   ├── PayOrderContext.php
     │   ├── PayOrderStrategy.php
     │   └── Strategy
     │       └── VipStrategy.php
     └── PayOrderService.php

實現開通vip介面

所有介面的資料都是通過 laravel 表單請求驗證

路由:routes/web.php

Route::get('/buy/vip', "PayController@vip")->name('vip');

app/Http/Controllers/PayController.php

public function vip(Request $request)
{
    $strategy = new VipStrategy();
    $tmpOrderKey = (new PayOrderContext($strategy))->createOrder($request);

    return $this->data(['key' => $tmpOrderKey]);
}
curl http://127.0.0.1:8000/buy/vip?code=vip1 | json
{
  "status": 200,
  "message": "ok",
  "data": {
    "key": "35349845-0e76-4973-b240-67e7b3cdda42"
  }
}

臨時訂單已生成,現在需要需要開發手機掃碼後的預覽介面

預覽訂單

正常來說預覽訂單是每個支付都需要有的功能,所以增加一個抽象方法

  1. app/Http/Services/PayOrder/PayOrderStrategy.php 新增一個 preview 的抽象方法

     abstract class PayOrderStrategy
     {
         // 建立臨時訂單
         abstract function createTemporaryOrder(Request $request);
    
            // 預覽訂單
         protected function preview(array $tmpOrder)
         {
             throw new UnsupportedOperationException("不支援的方法");
         }
     }

    你可能會好奇,這裡預覽訂單為什麼要丟擲一個異常呢?因為有些第三方支付沒有手機支付,只能 pc 端跳轉,所以就不會涉及預覽這一說

    如果定義成 abstract 下面繼承的方法有必須實現,這個非必須的就直接定義成 protected 並丟擲一個異常,開發的時候如果錯誤的呼叫了這個方法就會知道,當前支付方式不支援訂單的預覽

  2. 開通 vip 策略實現 preview 方法,引數是臨時訂單的資訊

    app/Http/Services/PayOrder/Strategy/VipStrategy.php

     function createTemporaryOrder(Request $request){ /*...*/ }
    
     function preview(array $tmpOrder)
     {
         $preview = [
             'title'   => '開通會員',
             'price'   => $tmpOrder['price'],
             'vip_day' => $tmpOrder['vip_day']
         ];
    
         return $preview;
     }
  3. 預覽訂單介面

    這個介面返回一個頁面,手機掃碼收可以預覽並且有下單按鈕

    路由:routes/web.php

     Route::get('/buy/preview', "PayController@preview")->name('preview');

    app/Http/Controllers/PayController.php

     // 預覽訂單介面
     public function preview(Request $request)
     {
         // 請求下單介面後返回的臨時訂單 key
         $tmpOrderKey = $request->get('key');
    
         // 獲取臨時訂單
         $tmpOrder = app(PayOrderService::class)->getTemporaryOrder($tmpOrderKey);
    
         if (!$tmpOrder) {
             throw new TemporaryOrderException("訂單已過期");
         }
    
         $strategy = new VipStrategy();
         $preview = (new PayOrderContext($strategy))->preview($tmpOrder);
    
         return view('preview', $preview);
     }
  4. 生成二維碼

    前端請求 vip 介面之後使用返回的臨時訂單 key,作為 query 引數請求 預覽訂單 介面

    http://127.0.0.1:8000/buy/preview?key=35349845-0e76-4973-b240-67e7b3cdda42

    下單二維碼 手機確認支付頁面
  1. 接下來就是立即支付了,但立即支付前還有個問題,預覽訂單介面的 策略選擇 似乎有點問題,這裡設計的預覽訂單介面不論是 開通 vip 還是 充值 都是請求這個介面,所以這裡還需要判斷一下購買的型別來呼叫不同的策略。

    這裡用臨時訂單中的 type 來判斷支付的型別,根據 type 來選擇策略

     public function preview(Request $request)
     {
         ...
    
         $strategy = new \stdClass();
         switch ($tmpOrder['type']) {
             case PayOrderService::TYPE_VIP:
                 $strategy = new VipStrategy();
                 break;
             case PayOrderService::TYPE_RECHARGE:
                 // $strategy = new RechargeStrategy();
                 break;
             // ...
         }
    
         $preview  = (new PayOrderContext($strategy))->preview($tmpOrder);
    
         return view('preview', $preview);
     }

    好嘛~,問題又來了,這 switch 看著有點不爽,再把它獨立出來吧,加一個獲取策略的 簡單工廠 ,接下來優化這段選擇策略的程式碼

    建立 PreviewFactory 簡單工廠

    touch app/Http/Services/PayOrder/PreviewFactory.php

     namespace App\Http\Services\PayOrder;
    
     use App\Exceptions\BusinessException;
     use App\Exceptions\TemporaryOrderException;
     use App\Http\Services\PayOrder\Strategy\VipStrategy;
     use App\Http\Services\PayOrderService;
    
     class PreviewFactory
     {
         public static function strategy(string $key)
         {
             // 獲取臨時訂單
             $tmpOrder = app(PayOrderService::class)->getTemporaryOrder($key);
    
             if (!$tmpOrder) {
                 throw new TemporaryOrderException("訂單已過期");
             }
    
             $strategy = new \stdClass();
             switch ($tmpOrder['type']) {
                 case PayOrderService::TYPE_VIP:
                     $strategy = new VipStrategy();
                     break;
                 case PayOrderService::TYPE_RECHARGE:
                     // return new Recharge();
                     break;
                 // ...
                 default:
                     throw new BusinessException('訂單型別錯誤.');
             }
    
             return $strategy;
         }
     }

    現在再來看看 preview 介面

     public function preview(Request $request)
     {
         $tmpOrderKey = $request->get('key');
    
         try {
             $preview = PreviewFactory::strategy($tmpOrderKey)->preview($tmpOrderKey);
         } catch (TemporaryOrderException $e) {
             return $this->fail($e->getMessage());
         }
    
         return view('preview', $preview);
     }

發起支付

  1. 和建立訂單策略同樣,我們也建立一個支付策略

    /Users/tuju/Project/pay/app/Http/Services/Payment

     Payment
     ├── PaymentContext.php
     ├── PaymentFactory.php
     ├── PaymentStrategy.php
     └── Strategy
         ├── AlipayStrategy.php
         ├── UnionStrategy.php
         └── WechatStrategy.php
  2. 定義支付介面

    PaymentStrategy.php

     interface PaymentStrategy
     {
         public function pay(array $order);
     } 
  3. 上下文聯絡

    PaymentContext.php

     class PaymentContext
     {
         private $strategy;
    
         public function __construct(PaymentStrategy $paymentStrategy)
         {
             return $this->strategy = $paymentStrategy;
         }
    
         public function pay(array $order)
         {
             return $this->strategy->pay($order);
         }
     }
  4. 獲取支付策略的工廠

    PaymentFactory.php

     class PaymentFactory
     {
         public static function strategy(string $payType)
         {
             switch ($payType) {
                 case 'wechat':
                     $strategy = new WechatStrategy();
                     break;
                 case 'alipay':
                     $strategy = new AlipayStrategy();
                     break;
                 case 'union':
                     $strategy = new UnionStrategy();
                     break;
                 // case...
                 default:
                     throw new BusinessException("支付方式不存在");
             }
    
             return $strategy;
         }
     }
  5. 制定具體支付策略

    1. 支付寶策略
      Strategy/AlipayStrategy.php

       class AlipayStrategy implements PaymentStrategy
       {
           public function pay(array $order)
           {
               /**
               * 向支付寶請求
               * @see 支付寶官方sdk https://github.com/alipay/alipay-easysdk/tree/master/php
               * @see 第三方sdk https://github.com/lokielse/omnipay-alipay
               */
               return "https://www.alipay.com/";
           }
       }
    2. 微信策略
      Strategy/WechatStrategy.php

      class WechatStrategy implements PaymentStrategy
      {
       public function pay(array $order)
       {
           /**
           *
           * @see 微信官方 https://github.com/wechatpay-apiv3/wechatpay-guzzle-middleware
           * @see 官方文件 https://pay.weixin.qq.com/wiki/doc/apiv3/open/pay/chapter2_6_2.shtml
           * @see 第三方sdk https://github.com/lokielse/omnipay-wechatpay
           */
           return "https://pay.weixin.qq.com/";
       }
      }
  6. 確認支付介面

    app/Http/Controllers/PayController.php

     public function pay(Request $request)
     {
         $tmpOrderKey = $request->get('key');
         // pay_type=wechat|alipay|union
         $payType = $request->get('pay_type');
    
         // 處理訂單資料、建立訂單
         $tmpOrder = app(PayOrderService::class)->getTemporaryOrder($tmpOrderKey);
         $order = [ /** ... */];
         $created = app(PayOrderService::class)->createOrder($order);
    
         if (!$created) {
             return $this->fail("支付失敗, 請重新生成訂單.");
         }
    
         // 發起支付
         try {
             // 前面我們定義了一個支付策略工廠模式,幫助我們例項化策略,所以這裡傳入我們的支付方式
             // 工廠就會幫我們對應支付策略返回回來,然後我們再統一呼叫 pay() 這個方法
             $strategy = PaymentFactory::strategy($payType);
             // 一般第三方會返回一個支付跳轉連結,點選確認支付的時候使用者是已經在手機頁面了
             // 所以直接跳轉連結就可以拉起對應的支付了。
             $url = (new PaymentContext($strategy))->pay($created);
         } catch (BusinessException $e) {
             $this->fail($e->getMessage());
         }
    
         // 跳轉
         return redirect($url);
     }
  7. 最後列出下最終策略模組的樹狀圖

     ├── PayOrder 支付相關策略集合
     │   ├── PayOrderContext.php
     │   ├── PayOrderStrategy.php
     │   ├── PreviewFactory.php
     │   └── Strategy 具體策略,如果要充值,則新建一個充值策略即可,新增的方式也不會影響到開通會員的相關功能
     │       └── VipStrategy.php 開通 vip 策略
     ├── PayOrderService.php 一些公用方法
     └── Payment 支付相關策略集合
         ├── PaymentContext.php
         ├── PaymentFactory.php
         ├── PaymentStrategy.php
         └── Strategy 具體策略,可以增加各種第三方支付
             ├── AlipayStrategy.php 支付寶
             ├── UnionStrategy.php 微信
             └── WechatStrategy.php 銀聯

解決的問題

  • 將介面細分,不再是所有訂單都進入同一個方法,解決了引數混亂問題

  • 把大雜燴 $data 中的資料全部切分到每個不同的策略中去,而不是在方法中使用大量的 ifswitch 來處理,再增加型別時只需要關注新增的策略即可

  • 把介面資料用 laravel 表單請求驗證 來判斷,而不是在 controllerservice 層用 if 來判斷

  • \Exception 程式碼全部替換成相應的業務異常

不足之處

  • $request 我認為還是不要往下傳遞比較好,最好在控制器中處理,但整體支付邏輯還是比較複雜,傳參的話又需要傳入很多引數,暫時也沒有想出什麼好的方法,所以還是決定將 $request 往下傳遞了。

  • 建立訂單中的零時訂單存入到 redis 後再獲取,還是不能明確知道陣列裡具體存入了什麼資料,在 GO 中在序列化 json 時需要一個 struct 來支援,明確表名 json 中有什麼欄位,這樣開發時既不容易出錯,也減少很多梳理程式碼的時間;我認為可以新建一個 class 來模擬 GO 中的 struct 來明確 json 裡面有什麼資料。

總結

  1. 不要丟擲 \Exception 異常,業務上的錯誤異常應該丟擲自定義異常

  2. 儘量不要去捕獲 \Exception 異常,\Exception 異常應該由頂層的 Handel 去處理;如遇到事務需要 rollback 的話,捕獲 Exception 後,在返回錯誤資訊前,需要手動記錄下異常的詳細資訊。

  3. 一段程式碼如果有兩處以上用到,應該獨立出一個公共方法。

  4. 引數的驗證在控制層面就校驗完成,不要再傳到 service 中處理。

  5. 不要用 0, 1, 2 傳參、判段等,不梳理上下文程式碼,實在是不知道什麼意思,如果改變了其代號意思,則所有涉及到的地方都需要修改判斷,可以用常量來管理各種代號。

     public const PAY_STATUS_FAIL = 0;
     public const PAY_STATUS_OK   = 1;
     public const PAY_STATUS_WAIT = 2;
    
     public function give($payStatus)
     {
         // 最不明確的方法, 如果沒註釋真不知道什麼意思
         if ($payStatus == 1) {
             // ...
         }
    
         // 比較好的方法,即便沒有註釋,意思也比較明確
         if ($payStatus == self::PAY_STATUS_OK) {
             // ...
         }
    
         // 我更喜歡用的方法,定義一個方法,看方法名知其意
         if ($this->isPaid($payStatus)) {
             // ...
         }
     }
    
     // 是否已支付完成
     public function isPaid($payStatus)
     {
         return $payStatus == self::PAY_STATUS_OK;
     }
  6. 善用設計模式

最後,大家有什麼改進之處,或者疑問之處歡迎大家提出、指正。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章