Laravel 實用小技巧 —— 如何優雅地設計方法傳參?

快樂的皮拉夫發表於2023-07-26

簡介

今天在閱讀專案中的老程式碼的時候,發現一些方法的引數特別多,有的甚至超過了十個以上。比如下面這個方法:

...
/**
 * 建立訂單
 *
 * @param int $storeId 店鋪ID
 * @param string $storeName 店鋪名稱
 * @param int $buyerId 購買者ID
 * @param string $buyerName 購買者名稱
 * @param int $goodsId 商品ID
 * @param string $goodsName 商品名稱
 * @param float $price 商品單價
 * @param float $amount 商品總價
 * @param int $quantity 商品數量
 */
public function createOrder (int $storeId, string $storeName, int $buyerId, string $buyerName, int $goodsId, string $goodsName, float $price, float $amount, int $quantity) {
    ...
}
...

這是一個建立訂單的方法。透過觀察我們不難發現,在這個方法中,傳遞給方法的引數特別多,這使得程式碼看上去有些「凌亂」,不知道螢幕前的小夥伴有沒有遇到過類似的程式碼呢?

今天這篇文章,我門就來討論下關於「最佳化方法傳參」的問題。

弊端

先不說如何最佳化,我們先來看一下這樣子傳參會有哪些問題。

可讀性

首先一個問題就是「可讀性」的問題。

想象一下,當我們作為方法的「呼叫者」來使用這個方法時我們會怎麼做?

我需要按照固定順序傳遞一堆的引數,每一個引數都必須與定義的型別相匹配。那傳遞這堆引數做什麼用呢?不曉得。

僅從字面意思我們可以大概瞭解一些引數的用途,但是如果我們想了解它的真正含義,我們必須閱讀方法的完整程式碼。

我們知道,方法就像一個「處理器」。簡單來講,主要由輸入引數、處理邏輯和輸出引陣列成。而在我們閱讀一個方法時,我們通常會根據方法名、輸入引數和輸出引數來瞭解方法的基本用途。而當我們面對一堆過於複雜的輸入引數時,我們往往會不知所措。

擴充套件性

可能有過「親身體會」的小夥伴會說:起初的方法其實並沒有這麼複雜,只不過隨著需求的調整,一步步「擴張」成現在這個樣子的。

沒錯,很多方法在設計的時候,可能只有幾個引數,看上去也是簡單易懂。但是當某一天,我們發現我們要實現的功能依賴於外部呼叫方的某個引數時,怎麼辦呢?直接加個引數傳進來唄。

今天加一個,明天加一個,加來加去,當某一天我們回頭來看時,發現我們的引數列表已經變成了一列望不盡頭「小火車」了。

不僅如此,想象一下:當某一天我們不想做「加法」了,想做個「減法」時,會是怎樣的場景呢?

比如,在我們長長的「小火車」中,我們中間的某一節「車廂」不需要了,應該怎麼辦呢?

可能你會說:去掉不就行了?額,如果我告訴你,這個方法有一百個「呼叫方」呢?(打個比方,別當真)

那不好意思了,只能去調整每一個呼叫方的傳參方式了(加油吧,少年)。

約束性

其實我們在說「擴充套件性」問題的時候,做「減法」這個問題也是一個「約束性」問題的體現。

作為「呼叫方」,傳參的順序必須和「定義方」保持一致。所以,當「定義方」引數做出調整時,如果引數是必傳引數,「呼叫方」也需要作出相應調整,正所謂,牽一髮而動全身。

而且,在眾多引數中,每個引數的「重要性」都是不盡相同的。有一些引數需要設計成必傳引數,而有一些引數則可以設計成可選引數,這就要求我們在設計引數順序的時候,必傳的引數往前放,可選的引數往後放。

那現在問題又來了:當我們在已經有了必傳引數和可選引數的情況下,又需要加一個必傳引數該怎麼辦呢?

沒辦法,必傳引數必須放在可選引數前,可這樣一來,整體的引數順序又發生變化了。這就意味著所有的「呼叫方」又該來一遍「大洗牌」了。

可能這就是為什麼我們有時候會看到一個方法設計了一堆引數,但是每個引數都是必傳了。因為雖然不夠合理,但是在增加引數的時候直接往最後邊追加就可以了。

依賴傳遞

還有一種情況,有時我們方法處理的邏輯比較複雜,導致方法體比較「龐大」。所以我們需要將程式碼中的部分邏輯抽離到單獨的方法中去,而抽離出去的方法實現又依賴於原方法的大部分引數,這就會出現以下這種「依賴傳遞」的情況:

...
/**
 * 建立訂單
 *
 * @param int $storeId 店鋪ID
 * @param string $storeName 店鋪名稱
 * @param int $buyerId 購買者ID
 * @param string $buyerName 購買者名稱
 * @param int $goodsId 商品ID
 * @param string $goodsName 商品名稱
 * @param float $price 商品單價
 * @param float $amount 商品總價
 * @param int $quantity 商品數量
 */
public function createOrder (int $storeId, string $storeName, int $buyerId, string $buyerName, int $goodsId, string $goodsName, float $price, float $amount, int $quantity) 
{
    // 檢查訂單引數
    $this->_checkOrderParameter($storeId, $storeName, $buyerId, $buyerName, $goodsId, $goodsName, $price, $amount, $quantity);
}

/**
 * 檢查訂單引數
 *
 * @param int $storeId 店鋪ID
 * @param string $storeName 店鋪名稱
 * @param int $buyerId 購買者ID
 * @param string $buyerName 購買者名稱
 * @param int $goodsId 商品ID
 * @param string $goodsName 商品名稱
 * @param float $price 商品單價
 * @param float $amount 商品總價
 * @param int $quantity 商品數量
 */
private function _checkOrderParameter (int $storeId, string $storeName, int $buyerId, string $buyerName, int $goodsId, string $goodsName, float $price, float $amount, int $quantity) 
{
    ...
}
...

如果拆分的引數過多的話,你會發現在一個類很快就變成了一個「火車站」:一排排的「小火車」你連著我,我連著他,相當壯觀。

當然,我也見過有些小夥伴為瞭解決「依賴傳遞」的問題,將引數都設計成了類變數。這樣看來,方法之間的依賴性是減少了,但是類卻變的更「複雜」了——依賴已經無形中從區域性轉移到全域性。這就好比,當你把兩個人溝通的問題搬到大工作群以後,解決效率是比以前更快了,但可能你同時也失去了一位信任你的朋友。

最佳化

那講了這麼多「問題」,應該怎麼去最佳化呢?

陣列傳參

其實相信很多「經歷」過的小夥伴都嘗試過以下這種方案:

既然主要問題就是引數傳遞的太多,那麼我們減少引數的個數不就行了麼?在保證「資訊量」的基礎上做瘦身不就妥了麼?—— 用更復雜的變數替代簡單型別的變數,比如使用「陣列變數」。

於是,原有的程式碼就被最佳化成了以下這樣:

...
/**
 * 建立訂單
 * 
 * @param array $buyerInfo 購買者資訊
 * @param array $goodsInfo 商品資訊
 * @param array $storeInfo 店鋪資訊
 */
public function createOrder (array $buyerInfo, array $goodsInfo, array $storeInfo)
{
    ...    
}
...

我們可以看到,在上面的最佳化中,我們將引數分別整理到三個陣列中,分別代表購買者資訊、商品資訊和店鋪資訊。

這樣一來,引數個數一下子就從原來的九個變成了現在的三個,看上去簡潔了很多。

但是這樣改造也是存在一些問題的。

首先,用陣列來代替普通型別的變數具有「不確定性」。

比如當我們需要使用購買者 ID($buyerId)這個欄位時,不能直接使用陣列下標的方式來獲取:

$buyerId = $buyerInfo['buyerId'];

因為 $buyerInfo 變數並不是在上下文中直接定義的,而是透過傳參的方式獲取的。這就意味著作為「接收方」不能完全信任來自「傳遞方」的資料:因為當 $buyerInfo 中沒有傳遞 buyerId 這個索引時,將會丟擲陣列索引未定義的異常。

所以,我們在使用時需要增加索引判斷的邏輯:

$buyerId = $buyerInfo['buyerId'] ?? '';

或者使用 Laravel 的陣列封裝的方法來獲取:

$buyerId = Arr::get($buyerInfo, 'buyerId', '');

如此,雖然解決了使用異常的問題。但是當我們將陣列引數傳遞給其他使用的方法時,原來的方法又成了引數的「傳地方」:這就意味著在新的「接收方」中,又要做一遍引數的驗證處理。

另外,因為 PHP 陣列本身比較靈活,可以隨時定義新的索引,這就意味著傳遞給方法的陣列變數可能比實際要「龐大」很多:因為在實際開發中,可能為了省事,$buyerInfo 這種變數是透過資料庫查詢之後,直接轉換成陣列就丟給了呼叫的方法。雖然陣列包含了方法需要的索引資訊,但同時也冗餘了很多其他資訊。

那有沒有更好一點的傳參方式呢?

物件傳參

其實,除了陣列可以「包含變數」,物件也可以。

而且,物件傳參這種方式並不罕見。在 Laravel 程式碼中隨處可見,比如下面這些場景:

app/Console/Kernel.php

/**
 * Define the application's command schedule.
 *
 * @param Schedule $schedule
 * @return void
 */
protected function schedule(Schedule $schedule)
{
    ...
}

app/Exceptions/Handler.php

/**
 * @param Throwable $e
 */
public function report(Throwable $e)
{
    ...
}

甚至是構造方法的引數:

/**
 * 構造方法
 * 
 * @param Request $request
 */
public function __construct(Request $request)
{
    ...
}

看到這裡,你是不是若有所思:這不就是「依賴注入」嗎?

其實,瞭解「依賴注入」的小夥伴應該都清楚它的核心思想:

依賴注入能夠消除程式開發中的硬編碼式的物件間依賴關係,使應用程式鬆散耦合、可擴充套件和可維護,將依賴性問題的解決從編譯時轉移到執行時。

「依賴注入」主要解決的是模組之間的依賴關係。而在我們今天討論的話題中,側重的是方法之間的呼叫。但是我們也可以借鑑這種處理思想:即透過物件或者介面的方式傳參,從而減少方法對實際使用引數的依賴性。

回到我們正文討論的這種場景上來,我們應該如何設計「物件引數」呢?

其實,物件是具體的實參,我們需要關心的是物件背後「類」的設計,這也是一個「抽象化」設計的過程。

在這個場景中,我們可以將引數進行「抽象化」處理,抽象成請求實體類的屬性。如下:

  • 購買者請求類: 購買者 ID、購買者名稱
  • 商品請求類: 商品 ID、商品名稱、商品單價、商品總價、商品數量
  • 店鋪請求類: 店鋪 ID、店鋪名稱

購買者請求類 為例,類程式碼設計如下:

/*
 |--------------------------------------------------------------------------
 | 購買者請求類
 |--------------------------------------------------------------------------
*/

namespace App\Entities\Request;

use http\Exception\InvalidArgumentException;

class BuyerRequest
{
    /**
     * @var string 購買者ID
     */
    private $buyerId;

    /**
     * @var string 購買者名稱
     */
    private $buyerName;

    /**
     * 獲取購買者名稱
     *
     * @return string
     */
    public function getBuyerId(): string
    {
        if(is_null($this->buyerId)){
            throw new InvalidArgumentException('購買者ID不得為空');
        }
        return $this->buyerId;
    }

    /**
     * 設定購買者ID
     *
     * @param string $buyerId 購買者ID
     * @return BuyerRequest
     */
    public function setBuyerId(string $buyerId): BuyerRequest
    {
        $this->buyerId = $buyerId;
        return $this;
    }

    /**
     * 獲取購買者名稱
     *
     * @return string
     */
    public function getBuyerName(): string
    {
        return $this->buyerName ?: '';
    }

    /**
     * 設定購買者名稱
     *
     * @param string $buyerName 購買者名稱
     * @return BuyerRequest
     */
    public function setBuyerName(string $buyerName): BuyerRequest
    {
        $this->buyerName = $buyerName;
        return $this;
    }
}

在類中我們提供了屬性的 gettersetter 方法,這也是物件導向中獲取內部屬性的常用做法。

原來的 createOrder 方法調整引數結構如下:

...
/**
 * 建立訂單
 * 
 * @param BuyerRequest $buyerRequest 購買者請求實體
 * @param GoodsRequest $goodsRequest 商品請求實體
 * @param StoreRequest $storeRequest 店鋪請求實體
 */
public function createOrder (BuyerRequest $buyerRequest, GoodsRequest $goodsRequest, StoreRequest $storeRequest)
{
    ...
}
...

在呼叫方法之前,我們需要先例項化請求物件,然後再傳遞給方法:

...
// 引數例項化
$buyerRequest = (new BuyerRequest())
    ->setBuyerId('123')
    ->setBuyerName('測試');

$goodsRequest = new GoodsRequest();
$storeRequest = new StoreRequest();

// 方法呼叫
$this->createOrder($buyerRequest, $goodsRequest, $storeRequest);
...

createOrder 方法中,可以透過物件的 getter 方法來獲取具體的引數:

$buyerId = $buyerRequest->getBuyerId();

因為在 getter 方法中包含了引數的處理邏輯,所以可以直接放心使用。

這樣,在開篇提到的那些「弊端」基本上都得到解決了。只不過,我們需要花更多的時間去「抽象化」我們的請求類,但是一旦我們設計好了,無論是對於上游的「呼叫者」還是對於下游的「使用者」來說,都是十分便捷的。

總結

本篇文章我們討論了關於程式開發中傳參的設計。透過分析幾種不同的傳參方式,我們大致瞭解了各種方式的利弊。雖然「物件傳參」的方式看上去更優雅,但是也要考慮實際情況的需要,避免陷入「過度設計」的怪圈。

所有的設計最終都要從實際生產環境出發。優雅值得關注,效能更不能忽視。

感謝大家的持續關注~

本作品採用《CC 協議》,轉載必須註明作者和本文連結
你應該瞭解真相,真相會讓你自由。

相關文章