也說工廠模式

林子er發表於2022-03-15

問 100 個面試者會哪些設計模式,至少 99 個都會提工廠模式。這說明工廠模式確實是一般開發人員最常遇到的設計模式之一,另外也說明它是最直觀最容易理解的設計模式之一(無論是從概念上還是實現上)。

不過,如果繼續往下追問,比如工廠模式用來解決什麼痛點?怎麼解決的?它有什麼缺點?什麼時候不該用工廠模式?並非每個人都能說得明白的。


軟體設計的複雜性

軟體的本質是人類通過指令指導機器來處理人類世界的事務,因而人類世界的複雜性必然會反映到軟體上。

高中物理告訴我們,運動是絕對的,靜止是相對的,“唯一不變的就是變化本身”。正是事物的運動(動態性)造成了複雜性——用更容易聽得懂的話講叫“未來的不確定性”。需求總是無休止地改變,沒有哪個軟體是一成不變的。

運動的自然結果是熵增——就好像你家客廳總是越來越亂一樣,隨著時間的推移,軟體系統不可避免地逐漸趨向混亂:隨著舊邏輯的變動和新邏輯的增加(以及既有 bug 的“修復”),系統邏輯越來越複雜且難以理解,bug 越來越多,修改功能越來越困難。

人們一個直觀但錯誤的認知是:通過修復 bug 能逐漸減少系統的 bug,最終將 bug 歸零——除非該系統自發布後不用做任何迭代,否則無論你鍵盤敲多快都趕不上 bug 的增長速度(這一點在上層業務系統中表現得尤為明顯)。

程式設計師們應對軟體系統熵增的手段主要是重構和使用設計模式

重構是在未來對系統進行重新構建,以消除或減少系統的混亂,其依據是人們在未來會比過去對系統/業務有更加深刻的認知,另一方面過去的設計已經達到其承載變化能力的極限——實踐中重構(特別是大規模的)往往發生在系統崩潰邊緣。

設計模式是在現在對系統進行預構建,以應對未來的複雜性(變化)。所謂設計模式,就是根據過往經驗,將系統設計中共通的東西抽取出來,加以標準化(形成模式),以追求系統設計上的相對靜止性(穩定性),以不變應對未來的變化(不確定性)。

設計模式解決複雜性的主要手段是抽象和隔離


三種工廠

任何設計模式都是用來解決軟體設計的複雜性問題,追求設計上的穩定性。工廠模式屬於建立者模式之一(其它的建立者模式如單例、原型、建造者模式),用來解決物件建立的複雜性

什麼樣的物件建立具有複雜性?

  1. 只需要一句 new 以及傳幾個簡單引數的基本不存在建立複雜性,也就用不上工廠模式;
  2. 物件建立具有多型性:如需要根據配置建立不同的子型別物件,建立程式碼中有多個 if else 的;
  3. 物件建立過程比較複雜:雖然不需要建立不同的子類物件,但物件建立本身較複雜,必須需要讀取配置檔案、獲取遠端資料等;

在進一步討論之前,我們先簡單提下工廠模式的三種形式:簡單工廠、工廠方法和抽象工廠(和 GoF 的劃分有點不一樣)。

簡單工廠:在目標類中建立一個靜態方法用於建立目標類物件,或者進一步,將該靜態方法抽離成一個單獨的工廠類;

工廠方法:對簡單工廠的升級。定義一個工廠介面,利用多型,每個工廠實現類返回目標類/介面的一種特定子類例項。工廠方法解決了簡單工廠可能過於複雜的問題,使得工廠本身更符合開閉原則和單一職責原則;

抽象工廠:對工廠方法的升級。它是用來解決目標例項本身的多維複雜性,防止工廠類隨著目標類數量一起爆炸增長。工廠方法是一個工廠類只負責建立一種目標例項,而抽象工廠是一個工廠可以建立多種目標例項。

單聽理論有點懵,大致只需要知道從工廠自身的複雜性以及用來解決問題的複雜性來說,簡單工廠 < 工廠方法 < 抽象工廠(前者是後者的特例,後者是前者的升級)——注意,這裡提到了兩個複雜性(工廠自身複雜性和它需要解決的問題複雜性),這也預示著使用工廠的原則:儘可能使用簡單工具解決問題,除非問題真的很複雜,簡單工具不好解決。

接下來我們看看工廠模式是如何通過抽象隔離來解決物件建立的複雜性的。


簡單工廠

假如我們要給加油站開發個線上交易系統,車主可以通過該系統購買燃油、便利店商品,還可以購買虛擬商品(如優惠券)。其中下單環節包含三個類(簡化版。為方便討論,假設一個訂單隻能購買一種商品):交易類 Transaction、訂單類 Order 和 商品類 Goods。交易類 Transaction 中會建立訂單 Order,而 Order 依賴商品 Goods:

image-20220314152450481

假設 Goods 的建立很複雜,需要組合多個相關物件,還要讀取外部資訊(如獲取商品基本資訊、庫存資訊)。

Order 依賴 Goods 的實現方式有兩種:

  1. 在 Order 內部直接建立 Goods(外部傳入 goodsId);
  2. 通過建構函式將 Goods 物件注入進去。

Order 內部建立 Goods(本文使用 PHP 實現虛擬碼,讀者自行腦補成自己熟悉的語言即可):

class Order
{
    private $goods;
  
    public function __construct($goodsId, ...)
    {
        // 這裡是一大坨建立 Goods 的程式碼
        ...
        $this->goods = $goods;
    }
}

這裡的建構函式包含了大段的建立程式碼,不符合最佳實踐,因而一般我們會抽取一個單獨的方法來建立 Goods:

class Order
{
    private $goods;
  
    public function __construct($goodsId, ...)
    {
        $this->goods = self::createGoods($goodsId);
    }

    private static function createGoods($goodsId): Goods
    {
        // 一大坨建立 Goods 的程式碼
        ...
        return $goods;
    }
}

稍微好點了,但這裡至少有三個問題:

  1. Order 承擔了建立 Goods 的職責(而且做的事情還挺複雜的,以後可能還要修改的),違背了單一職責原則
  2. Order 不得不去了解 Goods 的構成細節,違背了迪米特法則,破壞了封裝性
  3. 其他地方要建立 Goods 時也要這樣做,降低了程式碼的可維護性(或者將 Order 的 createGoods() 公有化讓其他類呼叫,但這增加了不必要的依賴關係,而且使得程式碼難以理解)。

迪米特法則(Law of Demeter):又叫作最少知識原則(The Least Knowledge Principle),一個類對於其他類知道的越少越好,即一個物件應當對其他物件有儘可能少的瞭解。

為了解決上述問題,我們將 createGoods() 從 Order 中移到 Goods 裡面,讓 Goods 自己負責建立自己,這樣便對外封裝了建立細節(複雜的建立細節由 Goods 自己內部消化掉):

class Goods
{
    ...
      
    public static function create($id): self
    {
          // 一大坨程式碼邏輯,不過沒關係,除了它自己,外面沒人知道,亂就亂吧
          ...
          return $goods;
    }
}

class Order
{
    private $goods;
  
    public function __construct($goodsId,...)
    {
        $this->goods = Goods::create($goodsId);
    }
}

這裡解決了封裝性問題,今後要修改 Goods 的建立邏輯,只需要修改 Goods 本身即可。很多專案也是通過這種方式實現簡單工廠的,因為它足夠簡單,同時也較好的保證了設計穩定性。

在進一步討論之前,我們看看 Order 依賴 Goods 的另一種實現:通過依賴注入將 Goods 注入到 Order 中。此時必須在 Transaction 中建立 Goods。我們看看 Transaction 應該如何建立這個“麻煩”的物件。

最直接的實現方式:

class Goods
{
    ...
}

class Order
{
    private $goods;
  
    public function __construct(Goods $goods, ...)
    {
        $this->goods = $goods;
    }
}

class Transaction
{
    // 下單方法,生成訂單物件
    public function order()
    {
        // 這裡是一大坨建立 Goods 的程式碼
        ...
        $goods = new Goods(...);
        $order = new Order($goods, ...);
    }
}

這和第一種實現存在一樣的問題:Transaction 承擔了建立 Goods 的工作,違背了單一職責原則,以後要改 Goods 的建立邏輯時,需要動 Transaction 的程式碼(而且更糟糕的是可能還存在 ClassX,ClassY 都在建立 Goods,多個地方都要改),解決方法也是將建立邏輯抽離到 Goods 中。

那麼,將 Goods 的建立邏輯放到 Goods 中自我消化是否就萬事大吉了呢?

得看具體情況。如果 Goods 僅僅是因為建立較複雜而已(複雜程度尚可接受,不會對 Goods 造成嚴重汙染),今後預計不會修改其建立邏輯,那麼放在裡面是沒有問題的(實際中有大量專案確實是這樣做的)。但是,如果 Goods 的建立涉及到非常複雜的組裝行為,而且今後預計會修改其建立邏輯,那麼這些複雜性就影響到 Goods 自身的穩定性了。從本質上說,讓 Goods 建立自身是違背單一職責原則的,“建立”行為屬於使用者的職責,而非功能提供者的。

如此,一方面,我們不想讓使用者(Transaction 和 Order)知道被使用者(Goods)的細節,影響使用者的設計穩定性,另一方面又不想讓功能提供者自身(Goods)建立自己,影響提供者的設計穩定性,那如何解決該矛盾呢?

答案是引入第三者:工廠類,將物件建立的複雜性封裝到獨立的、功能單一的類中。

如下:

// 此處省略 Goods 和 Order 類定義
...

// 商品工廠類,封裝 Goods 的建立細節
class GoodsFactory
{
    public function create($goodsId): Goods
    {
       // 一大坨建立 Goods 物件的程式碼
        ...
        return $goods;
    }
}

// 在交易類中通過工廠建立 Goods 物件
class Transaction
{
    public function order($goodsId, ...)
    {
        $factory = new GoodsFactory();
        $order = new Order($factory->create($goodsId), ...);
    }
}

今後要修改 Goods 的建立邏輯時,只需要修改 GoodsFactory 即可,不需要修改 Transaction、Order 或者 Goods。以上便是簡單工廠的實現。

至此,我們看到工廠模式通過隔離解決了物件建立的複雜性:將 Goods 物件建立的複雜性(不確定性、未來變化性)放到單獨的、職責單一的工廠類 GoodsFactory 中,從而將其帶來的不穩定性從 Transaction、Order 和 Goods 中隔離開來,保證這三個類的設計穩定性。


工廠方法

上面的例子並沒有很好地體現抽象(雖然從廣義上說,工廠本身即提供了建立邏輯的抽象,但我們更多的是關注狹義上的抽象性)。

上面提到,車主可以購買燃油,也可以購買便利店非油品,還可購買券等虛擬商品,所以更可取的設計是將 Goods 抽象成介面並提供不同的商品實現類:

image-20220314155513737

我們將 Order 對 Goods 的依賴改成了對介面 GoodsInterface 的依賴,由油品(Fuel)、非油品(NonOil)、券(Coupon)實現該介面。這樣修改後,一般我們會通過依賴注入將 GoodsInterface 的實現類例項注入到 Order 中,如下:

// Goods 介面定義
interface GoodsInterface
{
  ...
}

// 油品
class Fuel implements GoodsInterface
{
  ...
}

// 非油品
class NonOil implements GoodsInterface
{
  ...
}

// 券
class Coupon implements GoodsInterface
{
  ...
}
  
// 訂單,依賴 GoodsInterface 介面
class Order
{
    private $goods;
  
    public function __construct(GoodsInterface $goods, ...)
    {
        $this->goods = $goods;
    }
}

// 交易
class Transaction
{
  // 下單
    public function order($goodsType, $goodsId, ...)
    {
        $goods = null;
        if ($goodsType == 'fuel') {
            // 一坨建立程式碼
            ...
            $goods = new Fuel(...);
        } elseif ($goodsType == 'nonOil') {
            ...
            $goods = new NonOil(...);
        } elseif ($goodsType == 'coupon') {
            ...
            $goods = new Coupon(...);
        } else {
            // 其他邏輯
           ...
        }

        $order = new Order($goods, ...);
        ...
    }
}

我們發現上面 Transaction 的程式碼有一大段 if else 用來建立 GoodsInterface 例項,根據前面的討論我們知道應該要建立一個工廠類來解決此問題:

class GoodsFactory
{
    public function create($goodsType, $goodsId): GoodsInterface
    {
        $goods = null;
        if ($goodsType == 'fuel') {
            // 一坨建立程式碼
            ...
            $goods = new Fuel(...);
        } elseif ($goodsType == 'nonOil') {
            ...
            $goods = new NonOil(...);
        } elseif ($goodsType == 'coupon') {
            ...
            $goods = new Coupon(...);
        } else {
            // 其他邏輯
           ...
        }

        return $goods;
    }
}

上面的簡單工廠有什麼問題呢?

當我們把 Goods 向上抽離出介面 GoodsInterface 時,往往意味著商品本身在未來的變數很大(變化性很強,比如未來可能會有其它商品型別),需要增加抽象層(介面)來提高設計的穩定性(讓其它類依賴穩定的介面 GoodsInterface 而不是不穩定的實現類)——這也就意味著未來我們可能要不斷的修改工廠類 GoodsFactory,在裡面加很多 if else。也就是說,GoodsFactory 是不穩定的。特別是當每個實現類的建立都很複雜時,工廠本身也會變得很複雜。

和處理 Goods 的思路一樣,我們也通過增加抽象層(介面)的方式來解決工廠自身面臨的問題。

我們從 GoodsFactory 抽象出介面 FactoryInterface:

// 定義工廠介面
interface FactoryInterface
{
    // create 返回 介面型別
    public function create($goodsId): GoodsInterface;
}

// 油品工廠
class FuelFactory implements FactoryInterface
{
    public function create($goodsId): GoodsInterface
    {
        // 建立 Fuel,此處邏輯可能很複雜
        ...
        return new Fuel(...);
    }
}

// 非油品工廠
class NonOilFactory implements FactoryInterface
{
    public function create($goodsId): GoodsInterface
    {
        ...
        return new NonOil(...);
    }
}

// 券工廠
class CouponFactory implements FactoryInterface
{
    public function create($goodsId): GoodsInterface
    {
        ...
        return new Coupon(...);
    }
}

等等!如此工廠是符合開閉原則了,怎麼用?像下面這樣?

class Transaction
{
    public function order($goodsType, $goodsId, ...)
    {
        // 根據商品型別建立不同的工廠
        $factory = null;
        if ($goodsType == 'fuel') {
            $factory = new FuelFactory();
        } elseif ($goodsType == 'nonOil') {
            $factory = new NonOilFactory();
        } elseif ($goodsType == 'coupon') {
            $factory = new CouponFactory();
        } else {
            ...
        }

        // 通過工廠建立商品物件
        $order = new Order($factory->create($goodsId));
        ...
    }
}

開玩笑嗎?Transaction 沒有什麼本質變化啊?為了建立商品工廠仍然在 Transaction 中引入了一堆 if else,而這些邏輯跟下單沒有什麼關係(屬於物件建立邏輯),使 Transaction 類違反了單一職責原則,引入了設計上的不穩定性(以後每加一個工廠就要改 Transaction,但 Transaction 跟商品以及商品工廠的變化本應沒有必然關係才對)。

所以,工廠方法模式還得有個“工廠的工廠”,將“工廠的建立”邏輯封裝起來:

// 工廠容器(工廠的工廠)
class GoodsFactoryContainer
{
    private static $container = [];
  
    // 根據商品型別獲取相應的商品工廠
    public static function get($goodsType): FactoryInterface
    {
        // 先從快取中獲取
        if (isset(self::$container[$goodsType])) {
            return self::$container[$goodsType];
        }
      
        // 建立工廠
        $factory = null;
        if ($goodsType == 'fuel') {
            $factory = new FuelFactory();
        } elseif ($goodsType == 'nonOil') {
            $factory = new NonOilFactory();
        } else {
            $factory = new CouponFactory();
        }
      
        // 儲存到快取中
        self::$container[$goodsType] = $factory;
      
        return $factory;
    }
}

// 在交易類中使用工廠
class Transaction
{
    public function order($goodsType, $goodsId, ...)
    {
        // 此時交易類不需要關注工廠和商品的建立細節,終於乾淨了
        $goodsFactory = GoodsFactoryContainer::get($goodsType);
        $goods = $goodsFactory->create($goodsId);
        $order = new Order($goods, ...);
        ...
    }
}

以上便是工廠方法模式的實現。

至此 Transaction 和各個工廠算是穩定了,而且各自的職責也足夠單一,可擴充套件性也不錯——唯一不穩定的是 GoodsFactoryContainer 了,確切的說是我們將不穩定因素從 Transaction 和 Order 中轉移到了 GoodsFactoryContainer 中。在系統設計中,不穩定性只能轉移,無法徹底消除,我們真正要做的是讓不穩定性帶來的修改最小化,從而讓系統設計的整體穩定性趨向最大。

既然不穩定性只能轉移,無法徹底消除,那這種轉移又有什麼意義呢?

在上面的例子中,商品的建立過程屬於不穩定因素(該不穩定性來自現實世界未來業務的不確定性),由商品種類的不穩定又導致商品工廠的不穩定——我們要做的是將這些不穩定性從相對穩定的下單邏輯中抽離出來,並將它們抽象成穩定的介面,業務邏輯類依賴於這些介面,然後通過一個容器將實現細節封裝起來。如此,商品建立邏輯上的變化將不會影響下單邏輯的程式碼。

我們注意 Transaction 類中的程式碼:

$goodsFactory = GoodsFactoryContainer::get($goodsType);
$goods = $goodsFactory->create($goodsId);
$order = new Order($goods, ...);

這幾行程式碼詮釋瞭如何面向介面(而不是實現)程式設計。

Order 類依賴 GoodsInterface 介面——那誰來提供 GoodsInterface 介面的實現呢?

答案是 FactoryInterface 介面——那誰來提供 FactoryInterface 介面的實現呢?

答案是 GoodsFactoryContainer 容器。

未來當我們需要調整商品建立邏輯時,所有的修改不會越過 GoodsFactoryContainer 的邊界——也就是說只需要修改 GoodsFactoryContainer(及其內部依賴),而 GoodsFactoryContainer 就是幹這件事的,所以因商品建立邏輯的變動而帶來的修改並不會違反單一職責原則和開放封閉原則(相反,如果這些變動帶來了 Transaction 或 Order 類的修改則違反了開放封閉原則,因為商品建立邏輯和這兩個類的職責沒有必然關聯)。

至此,工廠模式從抽象隔離兩個維度很好地解決了物件建立的複雜性——除了一點:如果目標類/介面因本身的多維性而導致實現類非常多,從而對應的工廠類也非常多,導致類爆炸,嚴重影響系統的可維護性。比如目標介面有 2 個維度,每個維度有 3 種可能,那麼實際類的數量就是 3^2 = 9 個類,要建立 9 個工廠。此時就要用到抽象工廠。抽象工廠將 2 維降成 1 維,具體實現方式是每個工廠類負責其中一個維度的所有類例項的建立(工廠方法是隻建立一種類例項),比如此處每個工廠負責建立 3 種類例項,那麼就只需要 3 個工廠即可。

反正我在實踐中是沒有用過這麼複雜的模式(因而這裡也不再給出抽象工廠的實現)。如果一個事物需要從多個維度去建立繼承關係,很容易造成類爆炸(這也是繼承特性被詬病的原因之一),此時更可取的方案是採用組合代替繼承(比如使用裝飾器模式)。


總結

工廠模式在實踐中使用很多,我相信大家都遇到過的一個經典場景就是 IoC 容器。IoC 容器實際上就是一個超級工廠,它負責整個系統的物件的建立。一方面 IoC 容器幫我們自動解決物件之間複雜的依賴關係,另一方面我們可以在最外層(配置檔案)中決定某個介面使用哪個實現類——這些正是工廠擅長做的事情。

我們實際使用工廠模式時,要遵循 KISS 原則(Keep it Simple and Stupid),能簡單就不要搞複雜。建議從簡單工廠開始,因為在系統的第一版設計中,我們對很多概念、結構的認知都不會太深,此時一上來就使用“牛刀”,往往會造成過度設計,要相信軟體設計“沒有銀彈”,任何設計模式都可能帶來可讀性、易用性甚至是可維護性上的損失,工廠模式也不例外(比如在工廠方法模式中,我們要引入“工廠的工廠”,這本身增加了複雜性)。隨著系統的迭代重構,我們的認知逐漸深入,真的發現問題了,才去逐漸採用更復雜的解決方案。

最後我們總結下:

  1. 軟體系統設計的複雜性源於真實世界的複雜性;
  2. 軟體系統遵循熵增定律,隨著時間推移會越來越趨向混亂,而設計模式重構則是用來解決複雜性的,是逆熵的過程;
  3. 工廠模式用來解決物件建立的複雜性;
  4. 具體地,工廠模式是通過抽象隔離來解決物件建立的複雜性的;
  5. 從複雜性來說,簡單工廠 < 工廠方法 < 抽象工廠,實踐中要從簡單的開始用,避免過度設計;
  6. 系統設計的目標是追求設計的穩定性;
  7. 系統中的不穩定因素只能轉移,無法徹底消除,我們要做的是隔離不穩定因素,讓變化帶來的影響最小化,力求設計穩定性趨向最大化;

相關文章