Site icon Soul & Shell Blog

用 PHP 實現 IoC/DI (控制反轉與依賴注射) 設計模式

IoC/DI 依賴注射與控制反轉

在前一篇古時候的文章 (淺談 IoC/DI 依賴注射與控制反轉) 已經有大致介紹 IoC/DI 的概念,好幾年前我曾經接觸過 Java Spring 這樣的框架,那時候用來開發一些桌面應用、Linux Daemon、Web Application 等等系統,對於利用 IoC/DI 進行系統高度解耦頗有感觸。反觀在 PHP 的程式語言中,比較少見到框架在這樣的設計模式中著墨,相關的應用也不多。但說到 Web 開發,我還是最喜歡不需編譯的 Script Language,像是 PHP 就是很適合開發 Web 的程式語言。以往寫 PHP 時都會利用一些現成的 MVC 框架 (Framework) 來實現功能,幾年下來也接觸過不少熱門框架,但一直都沒有在 PHP 看過像是 Java Spring 一樣高度解耦的框架設計。除此之外,PHP 大多數的熱門框架都是「依賴性框架」,依賴性框架指的是你的程式碼必須與框架深深結合,舉例像是 MVC 都需要繼承框架附屬的 Controller, Model, View 等等類別,如此的設計造成程式碼與框架之間的耦合性太高,程式很難抽換到其他的框架,一但使用就被綁住了 (Lock-in)。然而,透過 DI 或 AOP (Aspect-Oriented Programming) 等等技巧來實現的非依賴式框架就比較自由,可以讓你在實作業務邏輯或 MVC 時,完全感覺不到框架的存在,達到高度解耦的效果。

PHP DI Context 設計概念

其實一開始的想法,是想要設計一個可以自動注射 Object 的 Context 管理器,當類別物件受到容器進行管理時,就能夠自動在容器中尋找適合的 Object 進行對應與注射。為了要事先告知 Context 每個類別內的 Member 需要注射哪些類別物件,因此就需要規範與設計對應的設定檔,因此我將設定檔PHP Doc 結合。其實如果各位平常寫 PHP 有很好的 Document 習慣,或者你的 IDE 可以自動完成這些工作,那麼這樣 PHP Doc 其實就是一個很好的類別設定檔描述方式。Context 只要讀取 PHP Doc 進行解析,就可以從 Context 中尋找適合的 Object Instance 進行注射,一舉兩得。

利用 PHP Document 設計 Dependency Injection 依賴注射模型

如果各位對每個語言有一定的認識,所有程式語言幾乎都有慣用的文件撰寫規範 Java Doc, JS Doc, PHP Doc 都是如此。回頭看看 PHP 程式語言,核心採用弱型別設計,為了提升程式碼的可讀性與維護性,我們往往會透過 PHP Doc 描述變數的型態,像是常用的 @var 描述。以下面的例子來看:

class Zoo
{
    /**
     * 很明顯這是一個用來存放 Cat 類別的變數
     * @var Cat
     */
    private $_cat;
}

PHP Doc 透過 @var Cat 這樣的注譯 (Annotation) 來描述 $_cat 這個變數將會被指向一個實體化的類別型態,這樣的描述同樣讓 IDE (整合開發環境) 進行解讀,好實現許多自動完成 (Auto Complete) 與程式碼追蹤等等相關功能,開發起來也比較方便。

以往我們為了讓程式可以順利運作,總需要在某個時機點寫下 $this->_cat = new Cat() 這樣的依賴語法,為什麼說是依賴性呢?因為寫下的同時,會讓這份程式碼與進行實體化的 Class 進行相依,構成了單向的依賴關係。

想想我們整個系統的 Scope 與 Runtime,大部分的情況會有兩個以上相同的 Cat Class 實作嗎?正常的情況下我相信不會!如果有兩個以上的實作,以物件導向來設計,我們會改用介面來描述變數型態。既然絕大數的情況不會有第二個 Cat Class 的實踐,那為什麼還需要寫 new Cat() 呢?我們不是已經在 PHP Doc 指定了 @var Cat 嗎?為什麼?為什麼?不知道,因為我只想到我自己?幾年前我無意間思考到這樣的問題,為什麼 PHP 沒有一種架構可以像 Java Sprint 一樣自動注射我們需要的實體物件資源呢?既然沒有找到喜歡的,所以就自己開發試試。最近吃飽沒事把這個很久以前的程式碼整理一下,也加上了測試程式,順便取個名字叫做 point-core 放到 GitHubComposer 刷刷存在感,有興趣的人可以看看 (但我想應該沒有.......)。目前比較多的 PHP Framework 都是透過 Mixing 的技巧載入其他的類別作為函式來呼叫,像是前幾年熱門的 CI Framework 就是這樣的例子,用起來有時候缺少了一點物件導向的 Feel。

如果是透過 point-core Context 來管理物件,就可以不需要寫 new 來實體化類別,可以直接透過 PHP Doc 加入「@Autowired」來描述這個 Member 需要自動注射 Object,好讓底層的 Context 自動完成類別的實體化與注射,範例如下:

class Zoo
{
    /**
     * 很明顯這是一個用來存放 Cat 類別的變數
     * @Autoqired
     * @var Cat
     */
    private $_cat;
}

範例代碼可參考 GitHub Example

如何實現 Depend Injection (自動注射) 與 Lazyload (延遲載入)

由於一開始就是希望能夠遵循既有的 PHP 撰寫習慣作為出發點,為了追求這樣的理念,才會把物件注射相關設定,盡可能用 PHP Doc 來設定,我們寫程式都要 DRY (Don't Repeat Yourself) 不是嗎?在技術上的實現其實不難,只要透過 PHP「反射」技巧即可完成,實現的過程並沒有像 Java Spring 透過底層 Byte Code 進行控制的高難度技巧。實務上主要先透過 PHP ReflectionClass::getDocComment() 來取得我們在程式碼中事先定義的 Doc Annotation 設定,經由 Context 進行解析後,同樣地透過 ReflectionClass 注射對應的 Object 到 Member 變數中。其實在 point-core 最初一開始的版本,是透過 getter/setter 來注射 Object,後來覺得其實在 PHP 沒有這個必要,能少寫一點 Code 才是最方便的。此外,如果將 getter/setter 透過 PHP Magic Method 實現,為了 DRY 原則也免不了要繼承框架提供的 Base Class,但是這樣就違反一開始想要做到「非依賴式」框架的特型,透過反射直接注射資源就變成是最乾淨的解法。

在實作的過程中,有另一個特性在實現上遇到了比較複雜的問題,就是期望能夠做到延遲載入 Class File 的特性 (事後注射),因為如果還沒用到就載入 Class File 就會多耗費資源來解析 PHP Code 與 I/O 存取,如果能夠搭配透過 PHP SPL Autoload 機制來動態載入 Class File 是最好了。主要的問題在於目前需要被注射的相依類別,由於順序因素在當下的 Runtime 中還沒有被載入與定義,如果一但在解析 Class 時同時載入相依的 File,如果最後沒用到,這個動作就浪費了系統資源。此外,也會遇到當下的 Context 不認得需要注射的 Class 在哪裡?因為可能會在之後的解析動作才會被載入與識別。

為了解決類別相依順序與實現延遲載入, point-code 核心設計利用了 PHP 雙指標特性,一開始在 Context 發現需要載入的 Class 不存在時,就會建立一個類別專用的指標,交由核心進行管理,好讓後續才載入的相依 Class 時,可以快速將資源注射到之前的需求者。延遲載入的實現範例如下 (GitHub Sample Code):

class Foo
{
  /**
   * @Autowired
   * @var Bar
   */
  private $_bar;

  public function getBar()
  {
      return $this->_bar;
  }
}

$context = new Context();

$context->addConfiguration(array(
  array(
    Bean::CLASS_NAME => 'Foo'
  )
));

$foo = $context->getBeanByClassName('Foo');

var_dump($foo->getBar());  // print NULL on unload Bar Class

// load Bar class
class Bar
{
}

// set configuration and auto inject to $foo object
$context->addConfiguration(array(
    array(
        Bean::CLASS_NAME => 'Bar'
    )
));

var_dump($foo->getBar());  // print Class Bar

如此就能夠實現類別相依關係的載入順序不被限制,讓 Context 在進行設定檔解析時減少載入實際檔案的 I/O 動作,節省記憶體的消耗並提升速度。其實要實作這個特性還有一個主要的原因,就是想要實現可以動態耦合的模組化架構,一個和 Eclipse RCP (Spring-DM) 與 OSGi 類似的架構,但是這個實作目前還在測試階段,只能下次再介紹了。

其他功能

目前實作的 Annotation 注射物件有兩種方法:

對於 Bean Configuration 也有實作一些設定選項:

舉一個實際應用的小例子,假設常用的資料庫連線函式 PDODB 需要在兩個 Controller 中使用,透過 point-core 整合可以像下面這樣做:

<?php
include_once(__DIR__ . '/../Autoloader.php');

use point\core\Context;
use point\core\Bean;

class MyControllerA
{
    /**
    * @Autowired
    * @var PDO
    */
    private $_pdo;
    public function getPdo()
    {
        return $this->_pdo;
    }
}

class MyControllerB
{
    /**
    * @Autowired
    * @var PDO
    */
    private $_pdo;
    public function getPdo()
    {
        return $this->_pdo;
    }
}

$context = new Context();
$context->addConfiguration(array(
    array(
        Bean::CLASS_NAME => 'MyControllerA'
    ),
    array(
        Bean::CLASS_NAME => 'MyControllerB'
    ),
    array(
        Bean::CLASS_NAME => 'PDO',
        Bean::CONSTRUCTOR_ARG => ['mysql:host=localhost;dbname=mysql', 'root', 'password!']
    )
));

$ctrlA = $context->getBeanByClassName('MyControllerA');
var_dump($ctrlA->getPdo());  // print: class PDO#11 (0)...

$ctrlB = $context->getBeanByClassName('MyControllerB');
var_dump($ctrlB->getPdo());  // print: class PDO#11 (0)...

執行後就可以在 MyControllerA 與 MyControllerB 自動注射 PDO 的實例,實現了 Singleton Pattern。在我們寫了那十幾行 Bean Configuration 設定檔後,就可以少寫一行 new PDO() 了ㄝ (好像沒有比較快,誤+羞),程式碼也放在 GitHub,沒事的人可以玩看看。

後記

其實這個 IoC/DI 的發展還是圍繞在模組化的 Web 應用,目前 GitHub 上的版本其實已經實作類似 Eclipse RCP (Rich Client Platform) OSGi Bundle 管理機制,主要發展以模組外掛為中心的框架。去年補上了測試程式,目前已經有 95% 以上的覆蓋率 (自動化測試才是王道啦!),未來會陸續整理更多模組化的工具,不然單純一個 Container 一點用也沒有。想要安裝可以透過 Composer 命令直接安裝,如下:

composer require samejack/point-core

這篇文章實在躺在草稿區很久了 (最少有五年以上),由於這樣的技術很冷門,一直在想要不要完成它。每次要寫都覺得很難下筆,今天終於整理好了,歐ㄝ ~~

Exit mobile version