最近接到一個涉及支付的需求,舊程式碼看的有點頭大,所以捋了捋邏輯,看了下時間,還是足夠的,所以就重寫了一遍支付模組,抽空記錄一下過程。
問題所在
- 全部支付走統一的二維碼生成介面,導致需要通過 type 區分接收不同的欄位,隨著支付方式越來越多,引數判斷越來越多,難以維護
- 程式碼解構混亂,一個
$data
變數貫通整個方法,導致最後不知道$data
變數裡面什麼資料,開發、排錯越來越複雜 - 異常處理,業務程式碼處處丟擲
\Exception
和捕獲\Exception
,導致如果程式遇到了系統異常也不能及時的通知錯誤
改造前的一段虛擬碼
- 所有業務邏輯錯誤也丟擲
\Exception
異常,捕獲\Exception
後返回下單失敗
導致如果程式遇到真正錯誤時,無法及時排查錯誤 - 單看
checkVerifyType()
方法名會認為只是檢查支付type
是否正確, 但卻不是,這個方法把所有該幹不該乾的事都幹完了 - 傳參用
0
,1
也不能明確知道是代表什麼東西 qrcode
介面引數也很複雜,例:type
= 1時,必須要code
引數;type
= 2 時,必須要price
引數;type
= 3 時 ….$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;
}
著手改造
- 涉及支付的模組有:開通會員、充值、購買單個商品等
- 開發支付流程:
- 生成二維碼(生成臨時訂單
redis
,返回redis
零時訂單key
)- 手機端確認購買資訊(展示購買商品資訊)
- 手機端確認支付 (通過臨時訂單的
key
,建立一條訂單資料到資料庫)- 根據臨時訂單的
key
建立訂單- 拉起支付
- 回撥
- 涉及到的設計模式
- 策略模式
- 簡單工廠模式
前期準備
原來的返回格式:
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
來區分引數
- 開通會員:
/buy/vip
- 充值:
/buy/recharge
- 購買商品:
/buy/goods
建立臨時訂單策略
建立一個訂單的
抽象策略
,定義演算法的介面,所有策略必須實現臨時訂單的介面,app/Http/Services/PayOrder/PayOrderStrategy.php
abstract class PayOrderStrategy { abstract function createTemporaryOrder($request); }
建立一個
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); } }
基礎的策略框架已經搭建好,現在就需要具體的策略了
開通 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); } }
建立一個訂單服務類,寫一些建立訂單的公共方法
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"
}
}
臨時訂單已生成,現在需要需要開發手機掃碼後的預覽介面
預覽訂單
正常來說預覽訂單是每個支付都需要有的功能,所以增加一個抽象方法
在
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
並丟擲一個異常,開發的時候如果錯誤的呼叫了這個方法就會知道,當前支付方式不支援訂單的預覽開通 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; }
預覽訂單介面
這個介面返回一個頁面,手機掃碼收可以預覽並且有下單按鈕
路由: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); }
生成二維碼
前端請求 vip 介面之後使用返回的臨時訂單 key,作為
query
引數請求預覽訂單
介面http://127.0.0.1:8000/buy/preview?key=35349845-0e76-4973-b240-67e7b3cdda42
接下來就是立即支付了,但立即支付前還有個問題,預覽訂單介面的
策略選擇
似乎有點問題,這裡設計的預覽訂單介面不論是開通 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); }
發起支付
和建立訂單策略同樣,我們也建立一個支付策略
/Users/tuju/Project/pay/app/Http/Services/Payment
Payment ├── PaymentContext.php ├── PaymentFactory.php ├── PaymentStrategy.php └── Strategy ├── AlipayStrategy.php ├── UnionStrategy.php └── WechatStrategy.php
定義支付介面
PaymentStrategy.php
interface PaymentStrategy { public function pay(array $order); }
上下文聯絡
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); } }
獲取支付策略的工廠
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; } }
制定具體支付策略
支付寶策略
Strategy/AlipayStrategy.phpclass 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/"; } }
微信策略
Strategy/WechatStrategy.phpclass 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/"; } }
確認支付介面
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); }
最後列出下最終策略模組的樹狀圖
├── 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
中的資料全部切分到每個不同的策略中去,而不是在方法中使用大量的if
和switch
來處理,再增加型別時只需要關注新增的策略即可把介面資料用
laravel
表單請求驗證 來判斷,而不是在controller
和service
層用if
來判斷把
\Exception
程式碼全部替換成相應的業務異常
不足之處
$request
我認為還是不要往下傳遞比較好,最好在控制器中處理,但整體支付邏輯還是比較複雜,傳參的話又需要傳入很多引數,暫時也沒有想出什麼好的方法,所以還是決定將$request
往下傳遞了。建立訂單中的零時訂單存入到 redis 後再獲取,還是不能明確知道陣列裡具體存入了什麼資料,在 GO 中在序列化
json
時需要一個struct
來支援,明確表名 json 中有什麼欄位,這樣開發時既不容易出錯,也減少很多梳理程式碼的時間;我認為可以新建一個 class 來模擬 GO 中的struct
來明確 json 裡面有什麼資料。
總結
不要丟擲
\Exception
異常,業務上的錯誤異常應該丟擲自定義異常儘量不要去捕獲
\Exception
異常,\Exception
異常應該由頂層的Handel
去處理;如遇到事務需要rollback
的話,捕獲Exception
後,在返回錯誤資訊前,需要手動記錄下異常的詳細資訊。一段程式碼如果有兩處以上用到,應該獨立出一個公共方法。
引數的驗證在控制層面就校驗完成,不要再傳到 service 中處理。
不要用
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; }
善用設計模式
最後,大家有什麼改進之處,或者疑問之處歡迎大家提出、指正。
本作品採用《CC 協議》,轉載必須註明作者和本文連結