From Apprentice To Artisan 翻譯稿

白大米發表於2019-05-05

依賴注入

遇到的問題

Laravel框架的基礎是一個功能強大的控制反轉容器(IoC container)。 為了真正理解本框架,需要好好掌握該容器。但我們要搞清楚,控制反轉容器只是一種用於方便實現“依賴注入”的工具。要實現依賴注入並不一定需要控制反轉容器,只是用容器會更方便和容易一點兒。

首先來看看我們為何要使用依賴注入,它能帶來什麼好處。 考慮下列程式碼:

class UserController extends BaseController{
    public function getIndex()
    {
        $users= User::all();
        return View::make('users.index', compact('users'));
    }
}

這段程式碼很簡短,但我們要想測試這段程式碼的話就一定會和實際的資料庫發生聯絡。也就是說, Eloquent ORM 和該控制器有著緊耦合。如果不使用Eloquent ORM,不連線到實際資料庫,我們就沒辦法執行或者測試這段程式碼。這段程式碼同時也違背了“關注分離”這個軟體設計原則。簡單講:這個控制器知道的太多了。 控制器不需要去了解資料是從哪兒來的,只要知道如何訪問就行。控制器也不需要知道這資料是從 MySQL 或哪兒來的,只需要知道這資料目前是可用的。

關注分離

每一個類都應該有單獨的職責,並且該職責應完全被這個類封裝。

關注分離的好處就是能讓Web控制器和資料訪問解耦。這會使得實現儲存遷移更容易,測試也會更容易。“Web”就僅僅是為你真正的應用做資料的傳輸了。

想象一下你有一個類似於監視器的程式,有著很多線纜介面(HDMI,VGA,DVI等等)。 你可以通過不同的介面訪問不同的監視器。把Internet想象成另一個插進你程式線纜介面。大部分顯示器的功能是與線纜介面互相獨立的。線纜介面只是一 種傳輸機制就像HTTP是你程式的一種傳輸機制一樣。所以我們不想把傳輸機制(控制器)和業務邏輯混在一起。這樣的好處是很多其他的傳輸機制比如API調 用、移動應用等都可以訪問我們的業務邏輯。

那麼我們就別再將控制器和Eloquent ORM耦合在一起了。 我們們注入一個資料庫類。

建立約定

首先我們定義一個介面,然後實現該介面。

interface UserRepositoryInterface
{
    public function all();
}

class DbUserRepository implements UserRepositoryInterface
{
    public function all()
    {
        return User::all()->toArray();
    }
}

然後我們將該介面的實現注入我們的控制器。

class UserController extends BaseController
{
    public function __construct(UserRepositoryInterface $users)
    {
        $this->users = $users;
    }

    public function getIndex()
    {
        $users=$this->users->all();
        return View::make('users.index', compact('users'));
    }
}

現在我們的控制器就完全和資料層面無關了。我們的資料可能來自MySQL,MongoDB 或者 Redis。我們的控制器不知道也不需要知道他們的區別。僅僅做出了這麼小小的改變,我們就可以獨立於資料層來測試Web層了,將來切換儲存實現也會很容易。

嚴守邊界

記得要保持清晰的責任邊界。 控制器和路由是作為HTTP和你的應用程式之間的中介軟體來用的。當編寫大型應用程式時,不要將你的領域邏輯混雜在其中(控制器、路由)。

為了鞏固學到的知識,我們們來寫一個測試案例。首先,我們要模擬一個資料庫然後繫結到應用的IoC容器裡。 然後,我們要保證控制器正確的呼叫了這個資料庫:

public function testIndexActionBindsUsersFromRepository()
{    
    // Arrange...
    $repository = Mockery::mock('UserRepositoryInterface');
    $repository->shouldReceive('all')->once()->andReturn(array('foo'));
    App::instance('UserRepositoryInterface', $repository);
    // Act...
    $response  = $this->action('GET', 'UserController@getIndex');

    // Assert...
    $this->assertResponseOk();
    $this->assertViewHas('users', array('foo'));
}

你在模仿我麼?

在上面的例子裡, 我們使用了名為Mockery的模仿庫。 這個庫提供了一套整潔且富有表達力的方法,用來模仿你寫的類。 Mockery可以通過Composer安裝。

更進一步

讓我們考慮另一個例子來鞏固理解。 可能我們想要去提醒使用者該交錢了。 我們會定義兩個介面, 或者約定。這些約定使我們在更改實際實現時更加靈活。

interface BillerInterface {
    public function bill(array $user, $amount);
}

interface BillingNotifierInterface {
    public function notify(array $user, $amount);
}

接下來我們要寫一個BillerInterface的實現:

class StripeBiller implements BillerInterface
{
    public function __construct(BillingNotifierInterface $notifier)
    {
        $this->notifier = $notifier;
    }
    public function bill(array $user, $amount)
    {
        // Bill the user via Stripe...
        $this->notifier->notify($user, $amount);
    }
}

只要遵守了每個類的責任劃分,我們很容易將不同的提示器(notifier)注入到賬單類裡面。 比如,我們可以注入一個SmsNotifier或者EmailNotifier。賬單類只要遵守了約定,就不用再考慮如何實現提示功能。只要是遵守約定(介面)的類, 賬單類都能用。這不僅僅是方便了我們的開發,而且我們還可以通過模擬BillingNotifierInterface來進行無痛測試。

使用介面

寫介面可能看上去挺麻煩,但實際上能加速你的開發。你不用實現任何介面,就能使用模擬庫來模擬你的介面,進而測試整個後臺邏輯!

那我們如何做依賴注入呢?很簡單:

$biller = new StripeBiller(new SmsNotifier);

這就是依賴注入。 biller不需再考慮提醒使用者的事兒,我們直接傳給他一個提示器(notifier)。 這種微小的改動能使你的應用煥然一新。 你的程式碼馬上就變得更容易維護, 因為明確指定了類的職責邊界。 並且更容易測試, 你只需使用模擬依賴即可。

那 IoC 容器呢? 難道依賴注入不需要 IoC 容器麼?當然不需要!在接下來的章節裡面你會了解到,容器使得依賴注入更易於管理,但是容器不是依賴注入所必須的。只要遵循本章提出的原則, 你可以在你任何的專案裡面實施依賴注入,而不必管該專案是否使用了容器。

太像Java了?

有人會說使用介面讓PHP程式碼看上去太像Java了——即程式碼太羅嗦了——你必須定義介面然後實現它,要多按好多下鍵盤。

對於小而簡單的應用來說,以上說法也對。 介面通常是不必要的。將程式碼耦合到那些你認為不會改變的地方也是可以的。在你確信不會改變的地方就沒有必要使用介面了。架構師說“不會改變的地方是不存在的”。不過話說回來,有時候的確不會改。

在大型應用中介面是很有幫助的。和提升的程式碼靈活性、可測試性比起來,多敲鍵盤費的功夫就微不足道了。當你迅速的切換了程式碼實現的時候,你的經理一定會被你的神速嚇一跳的。你也可以寫出更適應變化的程式碼。

總而言之, 記住本書提倡“簡單”架構。如果你在寫小程式的時候無法遵守介面原則, 別覺得不好意思。 要記住做碼農呢,最重要就是開心。如果你不喜歡寫介面,那就先簡單的寫程式碼吧。日後再精進即可。

控制反轉容器

基礎繫結

我們已經學習了依賴注入,接下來我們們一起來探索“控制反轉容器”(IoC)。 IoC容器可以使你更容易管理依賴注入,Laravel框架擁有一個很強大的IoC容器。Laravel的核心就是這個IoC容器,這個IoC容器使得框架各個元件能很好的在一起工作。事實上Laravel的Application類就是繼承自Container類!

控制反轉容器

控制反轉容器使得依賴注入更方便。當一個類或介面在容器裡定義以後,如何處理它們——如何在應用中管理、注入這些物件?

在Laravel應用裡,你可以通過App來訪問控制反轉容器。容器有很多方法,不過我們從最基礎的開始。讓我們繼續使用上一章寫的BillerInterfaceBillingNotifierInterface,且假設我們使用了Stripe來進行支付操作。我們可以將Stripe的支付實現繫結到容器裡,就像這樣:

App::bind('BillerInterface', function()
{
    return new StripeBiller(App::make('BillingNotifierInterface'));
});

注意在我們處理BillingInterface時,我們額外需要一個BillingNotifierInterface的實現,也就是再來一個bind:

App::bind('BillingNotifierInterface', function()
{
    return new EmailBillingNotifier;
});

如你所見, 這個容器就是個用來儲存各種繫結的地方。一旦一個類在容器裡繫結了以後,我們可以很容易的在應用的任何位置呼叫它。我們甚至可以在bind函式內寫另外的bind。

Have Acne?

Laravel框架的Illuminate容器和另一個名為Pimple的IoC容器是可替換的。所以如果你之前用的是Pimple,你儘可以大膽的升級為Illuminate Container,後者還有更多新功能!

一旦我們使用了容器,切換介面的實現就是一行程式碼的事兒。 比如考慮以下程式碼:

class UserController extends BaseController
{
    public function __construct(BillerInterface $biller)
    {
        $this->biller = $biller;
    }
}

當這個控制器通被容器例項化後,包含著EmailBillingNotifierStripeBiller會被注入到這個控制器中(譯者注:見上文的兩個bind)。如果我們現在想要換一種提示方式,我們可以簡單的將程式碼改為這樣:

App::bind('BillingNotifierInterface', function()
{
    return new SmsBillingNotifier;
});

現在不管在應用的哪裡需要一個提示器,我們總會得到SmsBillingNotifier的物件。利用這種結構,我們的應用可以在不同的實現方式之間快速切換。

只改一行就能切換程式碼實現,這可是很厲害的能力。比如我們想把簡訊服務從原來的提供商替換為Twilio。我們可以開發一個新的Twilio的提示器類(譯者注:當然要繼承自BillingNotifierInterface)然後修改繫結語句。如果Twilio有任何閃失,我們只需修改一行程式碼就可以快速的切換回原來的簡訊提供商。看到了吧,依賴注入的好處多得很呢。你能再想出幾個使用依賴注入和控制反轉容器的好處麼?

想在應用中只例項化某類一次?沒問題,使用singleton方法吧:

App::singleton('BillingNotifierInterface', function()
{
    return new SmsBillingNotifier;
});

這樣只要這個容器生成了這個提示器物件一次, 在接下來的生成請求中容器都只會提供這同樣的一個物件。

容器的instance方法和singleton方法很類似,區別是instance可以繫結一個已經存在的物件。然後容器每次返回的都是這個物件了。

現在我們熟悉了容器的基礎用法,讓我們深入發掘它更強大的功能:依靠反射來處理類和介面。

容器獨立執行

你的專案沒有使用Laravel?但你依然可以使用Laravel的IoC容器!只要用Composer安裝了illuminate/container包就可以了。

反射解決方案

用反射來自動處理依賴是Laravel容器的一個最強大的特性。反射是一種執行時探測類和方法的能力。比如,PHP的ReflectionClass可以探測一個類的方法。method_exists某種意義上說也是一種反射。我們來把玩一下PHP的反射類,試試下面的程式碼吧(StripeBiller換成你自己定義好的類):

$reflection = new ReflectionClass('StripeBiller');
var_dump($reflection->getMethods());
var_dump($reflection->getConstants());

依靠這個強大的PHP特性, Laravel的IoC容器可以實現很有趣的功能!考慮接下來這個類:

class UserController extends BaseController
{
    public function __construct(StripBiller $biller)
    {
        $this->biller = $biller;
    }
}

注意這個控制器的建構函式暗示著有一個StripBiller型別的引數。使用反射就可以檢測到這種型別暗示。當Laravel的容器無法解決一個型別的明顯繫結時,容器會試著使用反射來解決。程式流程類似於這樣的:

  1. 已經有一個StripBiller的繫結了麼?
  2. 沒繫結?那用反射來探測一下StripBiller吧。看看他都需要什麼依賴。
  3. 解決StripBiller需要的所有依賴(遞迴處理)
  4. 使用ReflectionClass->newInstanceArgs()來例項化StripBiller

如你所見, 容器替我們做了好多重活,這能幫你省去寫大量繫結的麻煩。這就是Laravel容器最強大也是最獨特的特性。熟練掌握這種能力對構建大型Laravel應用是十分有益的。

下面我們修改一下控制器, 改成這樣會發生什麼事兒呢?

class UserController extends BaseController
{
    public function __construct(BillerInterface $biller)
    {
        $this->biller = $biller;
    }
}

假設我們沒有為BillerInterface做任何繫結, 容器該怎麼知道要注入什麼類呢?要知道,interface不能被例項化,因為它只是個約定。如果我們不提供更多資訊的話,容器是無法例項化這個依賴的。我們需要明確指出哪個類要實現這個介面,這就需要用到bind方法:

App::bind('BillerInterface','StripBiller');

這裡我們只傳了一個字串進去,而不是一個匿名函式。 這個字串告訴容器總是使用StripBiller來作為BillerInterface的實現類。 此外我們也獲得了只改一行程式碼即可輕鬆改變實現的能力。比如,假設我們需要切換到Balanced Payments作為我們的支付提供商,我們只需要新寫一個BalancedBiller來實現BillerInterface介面,然後這樣修改容器程式碼:

App::bind('BillerInterface', 'BalancedBiller');

我們的應用程式就裝載上了的新支付實現程式碼了!

你也可以使用singleton方法來實現單例模式。

App::singleton('BillerInterface', 'StripBiller');

掌握容器

想了解更多關於容器的知識? 去讀原始碼!容器只有一個類Illuminate\Container\Container. 讀完了你就對容器有更深的認識了。

介面約定

強型別和小鴨子

在之前的章節裡,涵蓋了依賴注入的基礎知識:什麼是依賴注入;如何實現依賴注入;依賴注入有什麼好處。 之前章節裡面的例子也模擬了將 interface 注入到 classes 裡面的過程。在我們繼續學習之前,有必要深入講解一下介面,而這正是很多PHP開發者所不熟悉的。

在我成為PHP程式設計師之前,我是寫.NET的。 你覺得我喜歡受虐麼?在.NET裡可到處都是介面。 事實上很多介面是定義在.NET框架核心中了,一個好的理由是:很多.NET語言比如C#和VB.NET都是強型別。 也就是說,你在給一個函式傳值,要麼傳原生型別物件,要麼就必須給這個物件一個明確的型別定義。比如考慮以下C#方法:

public int BillUser(User user)
{
    this.biller.bill(user.GetId(), this.amount)
}

注意在這裡, 我們不僅要定義傳進去的引數是什麼型別的,還要定義這個方法返回值是什麼型別的。 C#鼓勵型別安全。除了指定的User物件,它不允許我們傳遞其他型別的物件到BillUser方法中。

然而PHP是一種鴨子型別的語言。 所謂鴨子型別的語言, 一個物件可用的方法取決於使用方式, 而非這個方法從哪兒繼承或實現。來看個例子:

public function billUser($user)
{
    $this->biller->bill($user->getId(), $this->amount);
}

在PHP裡面,我們不必告訴一個方法需要什麼型別的引數。 實際上我們傳遞任何型別的物件都可以,只要這個物件能響應getId的呼叫。這裡有個關於鴨子型別(下文譯作:弱型別)的解釋:如果一個東西看起來像個鴨子,叫聲也像鴨子叫,那他就是個鴨子。 換言之在程式裡,一個物件看上去是個User,方法響應也像個User,那他就是個User。

不過PHP到底有沒有任何強型別功能呢?當然有!PHP混合了強型別和弱型別的結構。為了說明這點,我們們來重寫一下billUser方法:

public function billUser(User $user)
{
    $this->biller->bill($user->getId(), $amount);
}

給方法加上了加上了User型別提示後, 我們可以確信的說所有傳入billUser方法的引數,都是User類或是繼承自User類的一個例項。

強型別和弱型別各有優劣。 在強型別語言中, 編譯器通常能提供編譯時錯誤檢查的功能,這功能可是非常有用的。方法的輸入和輸出也更加明確。

與此同時,強型別的特性也使得程式僵化。比如Eloquent ORM中,類似whereEmailOrName的動態方法就不可能在C#這樣的強型別語言裡實現。我們不討論強型別弱型別哪種更好,而是要記住他們分別的優劣之處。在PHP裡面使用強型別標記不是錯誤,使用弱型別特性也不是錯誤。但是不加思索,不管實際情況去使用一種模式,這麼固執的使用就是錯的。

約定的範例

介面就是約定。介面不包含任何程式碼實現,只是定義了一個物件應該實現的一系列方法。如果一個物件實現了一個介面,那麼我們就能確信這個介面所定義的一系列方法都能在這個物件上使用。因為有約定保證了特定方法的實現標準,通過多型也能使型別安全的語言變得更靈活。

多什麼肽?

多型含義很廣,其本質上是說一個實體擁有多種形式。在本書中,我們講多型是一個介面有著多種實現。比如UserRepositoryInterface可以有MySQL和Redis兩種實現,每一種實現都是UserRepositoryInterface的一個例項。

為了說明在強型別語言中介面的靈活性,我們們來寫一個酒店客房預訂的程式碼。考慮以下介面:

interface ProviderInterface{
    public function getLowestPrice($location);
    public function book($location);
}

當使用者訂房間時,我們需要將此事記錄在系統裡。所以在User類裡面寫點方法:

class User
{
    public function bookLocation(ProviderInterface $provider, $location)
    {
        $amountCharged = $provider->book($location);
        $this->logBookedLocation($location, $amountCharged);
    }
}

因為我們寫出了ProviderInterface的型別提示,該User類的就可以放心大膽的認為book方法是可以呼叫的。這使得bookLocation方法有了重用性。當使用者想要換一家酒店提供商時也就更靈活。最後我們們來寫點程式碼來強化他的靈活性。

$location = 'Hilton, Dallas';

$cheapestProvider = $this->findCheapest($location, array(
    new PricelineProvider,
    new OrbitzProvider,
));

$user->bookLocation($cheapestProvider, $location);

太棒了!不管哪家是最便宜的,我們都能夠將他傳入User物件來預訂房間了。由於User物件只需要要有一個符合ProviderInterface約定的例項就可以預訂房間,所以未來有更多的酒店供應商我們的程式碼也可以很好的工作。

忘掉細節

記住,介面實際上不真正做任何事情。它只是簡單的定義了類們必須實現的一系列方法。

介面與團隊開發

當你的團隊在開發大型應用時,不同的部分有著不同的開發速度。比如一個開發人員在製作資料層,另一個開發人員在做前端和網站控制器層。前端開發者想測試他的控制器,不過後端開發較慢沒法同步測試。那如果兩個開發者能以介面的方式達成協議,後臺開發的各種類都遵循這種協議,就像這樣:

interface OrderRepositoryInterface {
    public function getMostRecent(User $user);
}

一旦建立了約定,就算約定還沒實現,前端開發者也可以測試他的控制器了!這樣應用中的不同元件就可以按不同的速度開發,並且單元測試也可以做。而且這種處理方法還可以使元件內部的改動不會影響到其他不相關元件。要記著無知是福。我們寫的那些類們不用知道別的類如何實現的,只要知道它們實現什麼。這下我們們有了定義好的約定,再來寫控制器:

class OrderController {
    public function __construct(OrderRepositoryInterface $orders)
    {
        $this->orders = $orders;
    }
    public function getRecent()
    {
        $recent = $this->orders->getMostRecent(Auth::user());
        return View::make('orders.recent', compact('recent'));
    }
}

前端開發者甚至可以為這介面寫個“假”實現,然後這個應用的檢視就可以用假資料填充了:

class DummyOrderRepository implements OrderRepositoryInterface 
{
    public function getMostRecent(User $user)
    {
        return array('Order 1', 'Order 2', 'Order 3');
    }
}

一旦假實現寫好了,就可以被繫結到IoC容器裡,然後整個程式都可以呼叫他了:

App::bind('OrderRepositoryInterface', 'DummyOrderRepository');

接下來一旦後臺開發者寫完了真正的實現程式碼,比如叫RedisOrderRepository。那麼IoC容器就可以輕易的切換到真正的實現上。整個應用就會使用從Redis讀出來的資料。

介面就是大綱

介面在開發程式的“骨架”時非常有用。 在設計元件時,使用介面進行設計和討論都是對你的團隊有益處的。比如定義一個BillingNotifierInterface然後討論他有什麼方法。在寫任何實現程式碼前先用介面討論好一套好的API!

服務提供者

他是載入程式

一個Laravel服務提供者就是一個用來進行IoC繫結的類。事實上,Laravel有好幾十個服務提供者,用於管理框架核心元件的容器繫結。幾乎框架裡每一個元件的IoC繫結都是靠服務提供者來做的。你可以在app/config/app.php這個檔案裡檢視目前有哪些服務提供者。

一個服務提供者必須有一個register方法。你可以在這個方法裡寫IoC繫結。當一個請求發過來,程式框架剛啟動時,所有在你配置檔案裡的服務提供者的register方法就會被呼叫。這在程式週期的很早的地方就會執行,所以在你自己的引導程式碼(比如那些在start目錄裡的檔案)裡所有的服務已經準備好了。

註冊 Vs 引導程式碼

永遠不要在register方法裡面使用任何服務。該方法只是用來進行IoC繫結的地方。所有關於繫結類後續的判斷、互動都要在boot方法裡進行。

你用Composer安裝的一些第三方包也會有服務提供者。在第三方包的安裝說明裡一般都會告訴你要在providers陣列裡加上一行。一旦你加上了,那這個服務就算安裝好了。

包提供者

不是所有的第三方包都需要服務提供者。事實上一個包並不需要服務提供者。因為服務提供者只是一個用來自動初始化服務元件的地方,一個方便管理引導程式碼和容器繫結的地方。

Deferred Providers 延遲載入的服務提供者

並非在你配置檔案中的providers陣列裡的所有提供者在每次請求都會被例項化。否則會對效能不利,尤其是這個服務的功能用不到的情況下。比如,QueueServiceProvider服務就不是每次都用得到。

為了達到只例項化需要的服務的提供者,Laravel生成了“服務清單”並且儲存在了app/storage/meta目錄下。這份清單列出了應用裡所有的服務提供者,包括容器繫結的名字也記錄了。這樣,當應用想讓容器取出一個名為queue的繫結時,Laravel知道需要先例項化並執行QueueServiceProvider因為在服務清單裡記錄著該服務提供者能提供queue的繫結。如此這般框架就能夠延遲載入每個請求需要的服務了,效能大大提高。

如何生成服務清單

當你在providers陣列裡新增一條,Laravel在下一次請求時就會自動重新生成服務清單。

如果你有時間,去看看服務清單檔案裡面的內容。理解這個檔案的結構有助於你對服務進行排錯。

作為管理工具

想製作一個結構優美的Laravel應用的話,就要去學習如何用服務提供者來管理程式碼。當你在註冊IoC繫結的時候,所有程式碼都雜亂的塞進了app/start路徑下的檔案裡。 別再這樣做了,使用服務提供者來註冊這些吧。

Get It Started 萬物之初

你應用的“啟動”檔案都儲存在app/start目錄下。根據不同的請求入口,系統會載入不同的啟動檔案。在全域性的start.php檔案載入後,系統會根據執行環境的不同來載入不同的啟動檔案。 此外,在執行命令列程式時,artisan.php檔案會被載入。

我們們來考慮這個例子。也許我們的應用正在使用Pusher 來為客戶推送訊息。為了將我們的應用和Pusher解耦,我們要定義EventPusherInterface介面和對應的實現類PusherEventPusher。這樣在需求變化或應用改進時,我們就可以隨時輕鬆的改變推送服務提供商。

interface EventPusherInterface{
    public function push($message, array $data = array());
}

class PusherEventPusher implements EventPusherInterface{
    public function __construct(PusherSdk $pusher)
    {
        $this->pusher = $pusher;
    }
    public function push($message, array $data = array())
    {
        // Push message via the Pusher SDK...
    }
}

接下來我們建立一個EventPusherServiceProvider

use Illuminate\Support\ServiceProvider;

class EventPusherServiceProvider extends ServiceProvider {
    public function register()
    {
        $this->app->singleton('PusherSdk', function()
        {
            return new PusherSdk('app-key', 'secret-key');
        }

        $this->app->singleton('EventPusherInterface', 'PusherEventPusher');
    }
}

很好! 我們對事件推送進行了清晰的抽象,同時我們也有了一個很不錯的地方進行註冊、繫結其他相關的東西到容器裡。最後一步只需要將EventPusherServiceProvider寫入app/config/app.php檔案內的providers陣列裡就可以了。現在這個應用裡的EventPusherInterface已經被繫結到了正確的實現類上。

要使用單例麼?

用不用單例可以這樣來考慮:如果在一次請求週期中該類只需要有一個例項,就使用singleton;否則就使用bind

Note that a service provider has an $app instance available via the base ServiceProvider class. This is a full Illuminate\Foundation\Application instance, which inherits from the Container class, so we can call all of the IoC container methods we are used to. If you preffer to use the App facade inside the service provider, you may do that as well:

    App::singleton('EventPusherInterface', 'PusherEventPusher');

當然服務提供者的功能不僅僅侷限於訊息推送。像是雲端儲存、資料庫訪問、自定義的檢視引擎比如Twig等等都可以用這種模式來設定。服務提供者就是你的應用裡的啟動程式碼和管理工具,沒什麼神奇的。

所以大膽的去建立你自己的服務提供者。並不是你非要釋出個什麼軟體包才需要服務提供者,他們只是非常好的管理程式碼的工具。使用它們的力量去管理好應用中的各個元件吧。

服務提供者的啟動過程

在所有服務提供者都註冊以後,他們就進入了“啟動”過程。該過程會觸發每個服務提供者的boot方法。這裡會發生一種常見的錯誤用法:在register方法裡面呼叫其他的服務。由於在register方法裡我們不能保證所有其他服務都已經被載入,所以在該方法裡呼叫別的服務有可能會出錯。所以如果你想在服務提供者裡呼叫別的服務,請在boot方法裡做這種事兒。register方法只能進行容器註冊。

在啟動方法裡面,你想做什麼都可以:註冊事件監聽,引入路由檔案,註冊過濾器,或者其他你能想象到的事兒。再強調一下,要發揮服務提供者的管理功能。可能你想將相關的多個事件監聽歸為一組?將他們放到一個服務提供者的boot方法裡,這會很管用的!或者你也可以引入單獨的“events”、“routes”PHP檔案:

public function boot()
{
    require_once __DIR__.'/events.php';
    require_once __DIR__.'/routes.php';
}

我們已經學習了依賴注入以及如何使用服務提供者來組織管理我們的專案。這樣我們的Laravel應用就有了一個很好的基礎,它結構優美並且易於維護和測試。接下來,我們將探索Laravel框架本身是如何使用服務提供者的,並且深究其原理!

不要讓條條框框限制你自己

記住,服務提供者不僅僅是專業的軟體包才能使用。 請大膽的使用它來組織管理你的應用服務吧。

核心也是服務提供者的模式

你可能已經注意到,在app配置檔案裡面已經有了很多服務提供者。每一個都負責啟動框架核心的一部分。比如MigrationServiceProvider負責啟動資料庫遷移的類,包括Artisan裡面的命令。EventServiceProvide負責啟動和註冊事件排程機制。不同的服務提供者有著不同的複雜度,但他們都負責啟動核心的一部分。

和服務提供者們見見面

理解Laravel核心的最好方法是去讀它的核心服務原始碼。如果你對這些服務的原始碼、容器註冊等都很熟悉,那麼你對Laravel是如何工作的將會有十分深刻的理解。

大部分的服務提供者是延遲載入的,意味著並非所有請求都會呼叫到他們;然而有一些很基礎的服務是每一次請求都會被載入的,比如FilesystemServiceProvideExceptionServiceProvider。有人會說核心服務提供者和應用程式容器就是Laravel。Laravel 其實是將這麼多不同部分聯絡起來,形成一個單一的、內聚的整體的這麼一個機制。拿建築來比喻,那些服務提供者就是框架的預製模組。

正如之前提到的那樣,如果你想更深的瞭解框架是如何執行的,請讀 Lravel 的核心服務的原始碼吧。讀過之後,你會對框架如何將各部分組合在一起、每一個服務是如何為你所用這些機制有更堅實的理解。此外,有了這些進一步的理解,你也可以為 Laravel 添磚加瓦!

應用結構

介紹

這個類要寫到哪兒?這是一個在用框架寫應用程式時十分常見的問題。大量的開發人員都有這個疑問。他們被灌輸“Model”就是“Database”,在控制器裡面處理HTTP請求,在模型裡運算元據庫,檢視裡包含了要顯示的HTML。不過,傳送電子郵件的類要寫到哪兒?資料驗證的類要寫到哪兒?呼叫外部API的類要寫到哪兒?在這一章節,我們將學習如何寫結構優美的Laravel應用,打破長久以來掣肘開發人員的普遍思維慣性這個攔路虎,最終做出好的設計。

MVC是慢性謀殺

為了做出好的程式設計,最大的攔路虎就是一個簡單的縮寫詞:M-V-C。模型、檢視、控制器主宰了Web框架的思想已經好多年了。這種思想的流行某種程度上是託了Ruby on Rails愈加流行的福。然而,如果你問一個開發人員“模型”的定義是什麼。通常你會聽到他嘟噥著什麼“資料庫”之類的東西。這麼說,模型就是資料庫了。不管這意味著什麼,模型裡包含了關於資料庫的一切。但是,你很快就會知道,你的應用程式需要的不僅僅是一個簡單的資料庫訪問類。他需要更多的邏輯如:資料驗證、呼叫外部服務、傳送電子郵件,等等更多。

模型是啥?

單詞"model"的含義太模糊了,很難說明白準確的含義。更具體來講,模型是用來將我們的應用劃分成更小、更清晰的類,使得各程式碼部分有著明確的權責。

所以怎麼解決這個問題(譯者注:上文中“更多的業務邏輯”)呢?很多開發者開始將業務邏輯包裝到控制器裡面。當控制器龐大到一定規模,他們將會需要重用業務邏輯。大部分開發人員沒有將這些業務邏輯提取到別的類裡面,而是錯誤的臆想他們需要在控制器裡面呼叫別的控制器。這種模式通常被稱為“HMVC”。不幸的是,這種模式通常也預示著糟糕的程式設計,並且控制器已經太複雜了。

HMVC(通常)預示著糟糕的設計。

你覺得需要在控制器裡面呼叫其他的控制器?這通常預示著糟糕的程式設計並且你的控制器裡面業務邏輯太多了。把業務邏輯抽出來放到一個新的類裡面,這樣你就可以在其他任何控制器裡面呼叫了。

有一種更好的程式結構。但首先我們要忘掉以往我們被灌輸的關於“模型”的一切。乾脆點,讓我們直接刪掉model目錄,重新開始吧!

再見,模型

刪掉你的models目錄了麼?還沒刪就趕緊刪了!我們將要在app目錄下建立個新的目錄,目錄名就以我們這個應用的名字來命名,這次我們就叫QuickBill吧。在後續的討論中,我們在前面寫的那些介面和類都會出現。

注意使用場景

記住,如果你在寫一個很小的Laravel應用,那在models目錄下寫幾個Eloquent模型其實挺合適的。但在本章節,我們主要關注如何開發更有合適“層次”架構的大型複雜專案。

這樣我們現在有了個app/QuickBill目錄,它和應用目錄下的其他目錄如controllers還有views都是平級的。在QuickBill目錄下我們還可以建立幾個其他的目錄。我們來在裡面建立個RepositoriesBilling目錄。目錄都建立好以後,別忘了在composer.json檔案里加入 PSR-0 的自動載入機制:

"autoload": {
    "psr-0":    {
        "QuickBill":    "app/"
    }
}

譯者注:psr-0 也可以改成 psr-4, "psr-4": { "QuickBill\": "app/QuickBill" } psr-4 是比較新的建議標準,和 psr-0 具體有什麼區別請自行檢索。

現在我們把繼承自 Eloquent 的模型類都放到QuickBill目錄下面。這樣我們就能很方便的以QuickBill\User, QuickBill\Payment的方式來使用它們。Repositories目錄屬於PaymentRepositoryUserRepository這種類,裡面包含了所有對資料的訪問功能比如getRecentPaymentsgetRichestUserBilling目錄應當包含呼叫第三方支付服務(如Stripe和Balanced)的類。整個目錄結構應該類似這樣:

// app
    // QuickBill
        // Repositories
            -> UserRepository.php
            -> PaymentRepository.php
        // Billing
            -> BillerInterface.php
            -> StripeBiller.php
        // Notifications
            -> BillingNotifierInterface.php
            -> SmsBillingNotifier.php
        User.php
        Payment.php
資料驗證怎麼辦?

在哪兒進行資料驗證常常困擾著開發人員。可以考慮將資料驗證方法寫進你的“實體”類裡面(好比User.phpPayment.php)。方法名可以設為validForCreationhasValidDomain。或者你也可以專門建立個驗證器類UserValidator,放到Validation名稱空間下,然後將這個驗證器類注入到你的repository類裡面。兩種方式你都可以試試,看哪個你更喜歡!

擺脫了models目錄後,你通常就能克服心理障礙,實現好的設計。使得你能建立一個更合適的目錄結構來為你的應用服務。當然,你建立的每一個應用程式都會有一定的相似之處,因為每個複雜的應用程式都需要一個資料訪問(repository)層,一些外部服務層等等。

別害怕目錄

不要懼怕建立目錄來管理應用。要常常將你的應用切割成小元件,每一個元件都要有十分專注的職責。跳出“模型”的框框來思考。比如我們之前就說過,你可以建立個Repositories目錄來存放你所有的資料訪問類。

核心思想就是分層

你可能注意到,優化應用的設計結構的關鍵就是責任劃分,或者說是建立不同的責任層次。控制器只負責接收和響應HTTP請求然後呼叫合適的業務邏輯層的類。你的業務邏輯/領域邏輯層才是你真正的程式。你的程式包含了讀取資料,驗證資料,執行支付,傳送電子郵件,還有你程式裡任何其他的功能。事實上你的領域邏輯層不需要知道任何關於“網路”的事情!網路僅僅是個訪問你程式的傳輸機制,關於網路和HTTP請求的一切不應該超出路由和控制器層。做出好的設計的確很有挑戰性,但好的設計也會帶來可持續發展的清晰的好程式碼。

舉個例子。與其在你業務邏輯類裡面直接獲取網路請求,不如你直接把網路請求從控制器傳給你的業務邏輯類。這個簡單的改動將你的業務邏輯類和“網路”分離開了,並且不必擔心怎麼去模擬網路請求,你的業務邏輯類就可以簡單的測試了:

class BillingController extends BaseController{
    public function __construct(BillerInterface $biller)
    {
        $this->biller = $biller;
    }
    public function postCharge()
    {
        $this->biller->chargeAccount(Auth::user(), Input::get('amount'));
        return View::make('charge.success');
    }
}

現在chargeAccount 方法更容易測試了。 我們把RequestInputBillingInterface裡提出來,然後在控制器裡把方法需要的支付金額直接傳過去。

編寫擁有高可維護性應用程式的關鍵之一,就是責任分割。要時常檢查一個類是否管得太寬。你要常常問自己“這個類需不需要關心XXX呢?”如果答案是否定的,那麼把這塊邏輯抽出來放到另一個類裡面,然後用依賴注入的方式進行處理。(譯者注:依賴注入的不同方式還記得麼?呼叫方法傳參、建構函式傳參、從IoC容器獲取等等。)

Single Reason To Change

如何判斷一個類是否管得太寬,有一個有用的方法就是檢查你為什麼要改這塊兒程式碼。舉個例子:當我們想調整通知邏輯的時候,我們需要修改Biller的實現程式碼麼?當然不需要,Biller的實現僅僅需要考慮支付,它與通知邏輯應當僅通過約定來進行互動。使用這種思路過一遍程式碼,會讓你很快找出應用中需要改進的地方。

東西都放哪兒?

當用 Laravel 開發應用時,你可能迷惑於應該把各種“東西”都放在哪兒。比如,輔助函式要放在哪裡?事件監聽器要放在哪裡?檢視元件要放在哪裡?答案可能出乎你的意料——“想放哪兒都行!”Laravel 並沒有很多在檔案系統上的約定。不過這個答案的確不能讓人滿意,所以下面我們就這個問題展開討論,一起探索這些“東西”究竟可以放在哪兒。

Helper Functions 輔助函式

Laravel 有一個檔案(support/helpers.php)裡面都是輔助函式。你或許希望建立一個類似的檔案來儲存你自己的輔助函式。“start”檔案是個不錯的入口,該檔案會在應用的每一次請求時被訪問。在start/global.php裡,你可以引入你自己寫的helpers.php檔案,就像這樣:

// Within app/start/global.php

require_once __DIR__.'/../helpers.php';
//譯者注: 該helpers.php檔案位於app目錄下,需要你自己建立。你想放到別的地方也可以。

Event Listeners 事件監聽器

事件監聽器當然不該放到routes.php檔案裡面,若直接放到“start”目錄下的檔案裡會比較亂,所以我們要找另外的地方來存放。服務提供者是個好地方。我們之前瞭解到,服務提供者可不僅僅是用來做依賴注入繫結,還可以幹其他事兒。可以將事件監聽器用服務提供者來管理起來,讓程式碼更整潔,不至於影響到你應用的主要邏輯程式碼。檢視元件其實和事件差不多,也可以類似的放到服務提供者裡面。

例如使用服務提供者進行事件註冊可以這樣:

<?php 
    namespace QuickBill\Providers;
    use Illuminate\Support\ServiceProvider;
    class BillingEventsProvider extends ServiceProvider{
        public function boot()
        {
            Event::listen('billing.failed', function($bill)
            {
                // Handle failed billing event...
            });
        }
    }

建立好服務提供者後,就可以將它加入到app/config/app.php 配置檔案的providers陣列裡。

注意啟動流程

記住在上面的例子裡面,我們在boot方法裡進行編寫是有原因的。register方法只能用來進行依賴注入繫結。

錯誤處理

如果你的應用裡面有很多自定義的錯誤處理方法,那你的“啟動”檔案可能會很臃腫。和剛才的事件監聽器一樣,錯誤處理方法也最好放到服務提供者裡面。這種服務提供者可以命名為像QuickBillErrorProvider這種。然後你在boot方法裡想註冊多少錯誤處理方法都可以了。重申一下精神:讓呆板的程式碼離你應用的業務邏輯越遠越好。下方展示了這種服務提供者的一種可能的書寫方法:

<?php 
namespace QuickBill\Providers;
use App, Illuminate\Support\ServiceProvider;
class QuickBillErrorProvider extends ServiceProvider {
    public function register()
    {    
        //
    }

    public function boot()
    {
        App::error(function(BillingFailedException $e)
        {
            // Handle failed billing exceptions ...
        });
    }
}
簡便做法

當然如果你只有一兩條簡單的錯誤處理方法,那麼都寫在“啟動”檔案裡面也是一種又快又好的簡便做法。

The Rest 其他

通常只要遵循 PSR-0(譯者注:或 PSR-4)就可以保持類的整潔。命令式的程式碼比如事件監聽器、錯誤處理器還有其他“註冊”性質的操作都可以放在服務提供者裡面。對於什麼程式碼要放在什麼地方這個問題,結合你目前為止學到的知識,應當可以給出一個有理有據的答案了。但永遠不要害怕試驗。Laravel 最美妙之處就是你可以做出最適合你自己的風格。去探索和發現最適合你自己應用的結構吧,別忘了和他人分享你的見解!

例如你可能注意到我們上面的例子,你可以建立個Providers的名稱空間來存放你自己寫的服務提供者,目錄就類似於這樣:

// app
    // QuickBill
        // Billing
        // Extensions
            //Pagination
                -> Environment.php
        // Providers
            -> EventPusherServiceProvider.php
        // Repositories
        User.php
        Payment.php

看上面的例子我們有ProvidersExtensions兩個名稱空間(譯者注:分別對應兩個同名目錄)。你自己寫的服務提供者可以放到Providers名稱空間下。那個Extensions名稱空間可以用來存放你對框架核心進行擴充套件的類。

實用方法:解耦處理函式

介紹

我們已經討論了用 Laravel 4 製作優美的程式架構的各個方面,讓我們再深入一些細節。在本章,我們將討論如何解耦各種處理函式:佇列處理函式、事件處理函式,甚至其他“事件型”的結構如路由過濾器。

不要堵塞傳輸層

大部分的“處理函式”可以被當作傳輸層元件。也就是說,佇列觸發器、被觸發的事件、或者外部發來的請求等都可能呼叫處理函式。可以把處理函式理解為控制器,避免在裡面堆積太多具體業務邏輯實現。

解耦處理函式

接下來我們看一個例子。考慮有一個佇列處理函式用來給使用者傳送手機簡訊。資訊傳送後,處理函式還要記錄訊息日誌來儲存給使用者傳送的訊息歷史。程式碼應該看起來是這樣:

class SendSMS
{
    public function fire($job, $data)
    {
        $twilio = new Twilio_SMS($apiKey);
        $twilio->sendTextMessage(array(
            'to'=> $data['user']['phone_number'],
            'message'=> $data['message'],
        ));
        $user = User::find($data['user']['id']);
        $user->messages()->create(array(
            'to'=> $data['user']['phone_number'],
            'message'=> $data['message'],
        ));
        $job->delete();
    }
}

簡單審查下這個類,你可能會發現一些問題。首先,它難以測試。在fire方法裡直接使用了Twilio_SMS類,意味著我們沒法注入一個模擬的服務(譯者注:即一旦測試則必須傳送一條真實的簡訊)。第二,我們直接使用了Eloquent,導致在測試時肯定會對資料庫造成影響。第三,我們沒法在佇列外面傳送簡訊,想在佇列外面發還要重寫一遍程式碼。也就是說我們的簡訊傳送邏輯和Laravel的佇列耦合太多了。

將裡面的邏輯抽出成為一個單獨的“服務”類,我們即可將簡訊傳送邏輯和Laravel的佇列解耦。這樣我們就可以在應用的任何位置傳送簡訊了。我們將其解耦的過程,也令其變得更易於測試。

那麼我們來稍微改一改:

class User extends Eloquent {
    /**
     * Send the User an SMS message
     *
     * [@param](https://my.oschina.net/u/2303379) SmsCourierInterface $courier
     * [@param](https://my.oschina.net/u/2303379) string $message
     * [@return](https://my.oschina.net/u/556800) SmsMessage
     */
    public function sendSmsMessage(SmsCourierInterface $courier, $message)
    {
        $courier->sendMessage($this->phone_number, $message);
        return $this->sms()->create(array(
            'to'=> $this->phone_number,
            'message'=> $message,
        ));
    }
}

在本重構的例子中,我們將簡訊傳送邏輯抽出到User模型裡。同時我們將SmsCourierInterface的實現注入到該方法裡,這樣我們可以更容易對該方法進行測試。現在我們已經重構了簡訊傳送邏輯,讓我們再重寫佇列處理函式:

class SendSMS {
    public function __construct(UserRepository $users, SmsCourierInterface $courier)
    {
        $this->users = $users;
        $this->courier = $courier;
    }
    public function fire($job, $data)
    {
        $user = $this->users->find($data['user']['id']);
        $user->sendSmsMessage($this->courier, $data['message']);
        $job->delete();
    }
}

你可以看到我們重構了程式碼,使得佇列處理函式更輕量化了。它本質上變成了佇列系統和你真正的業務邏輯之間的轉換層。這可是很了不起!這意味著我們可以很輕鬆的脫離佇列系統來傳送簡訊息。最後,讓我們為簡訊傳送邏輯寫一些測試程式碼:

class SmsTest extends PHPUnit_Framework_TestCase 
{
    public function testUserCanBeSentSmsMessages()
    {
        /**
         * Arrage ...
         */
        $user = Mockery::mock('User[sms]');
        $relation = Mockery::mock('StdClass');
        $courier = Mockery::mock('SmsCourierInterface');

        $user->shouldReceive('sms')->once()->andReturn($relation);

        $relation->shouldReceive('create')->once()->with(array(
            'to' => '555-555-5555',
            'message' => 'Test',
        ));

        $courier->shouldReceive('sendMessage')->once()->with(
            '555-555-5555', 'Test'
        );

        /**
         * Act ...
         */
        $user->sms_number = '555-555-5555'; //譯者注: 應當為 phone_number
        $user->sendMessage($courier, 'Test');
    }
}

其他處理函式

使用類似的方式,我們可以改進和解耦很多其他型別的“處理函式”。將這些處理函式限制在轉換層的狀態,你可以將你龐大的業務邏輯和框架解耦,並保持整潔的程式碼結構。為了鞏固這種思想,我們來看看一個路由過濾器。該過濾器用來驗證當前使用者是否是交過錢的高階使用者套餐。

Route::filter('premium', function()
{
    return Auth::user() && Auth::user()->plan == 'premium';
});

猛一看這路由過濾器沒什麼問題啊。這麼簡單的過濾器能有什麼錯誤?然而就是是這麼小的過濾器,我們卻將我們應用實現的細節暴露了出來。要注意我們在該過濾器裡是寫明瞭要檢查plan變數。這使得將“套餐方案”在我們應用中的代表值(譯者注:即plan變數的值)暴露在了路由/傳輸層裡面。現在我們若想調整“高階套餐”在資料庫或使用者模型的代表值,我們竟然就需要改這個路由過濾器!

讓我們簡單改一點兒:

Route::filter('premium', function()
{
    return Auth::user() && Auth::user()->isPremium();
});

小小的改變就帶來巨大的效果,並且代價也很小。我們將判斷使用者是否使用高階套餐的邏輯放在了使用者模型裡,這樣就從路由過濾器裡去掉了對套餐判斷的實現細節。我們的過濾器不再需要知道具體怎麼判斷使用者是不是高階套餐了,它只要簡單的把這個問題交給使用者模型。現在如果我們想調整高階套餐在資料庫裡的細節,也不必再去改動路由過濾器了!

誰負責?

在這裡我們又一次討論了責任的概念。記住,始終保持一個類應該有什麼樣的責任,應該知道什麼。避免在處理函式這種傳輸層直接編寫太多你應用的業務邏輯。

譯者注:本文多次出現transport layer, translation layer,分別譯作傳輸層和轉換層。其實他們應當指代的同一種東西。

擴充套件框架

介紹

為了方便你自定義框架核心元件,Laravel 提供了大量可以擴充套件的地方。你甚至可以完全替換掉舊元件。例如:雜湊器遵守了HasherInterface介面,你可以按照你自己應用的需求來重新實現。你也可以擴充套件Request物件,新增你自己用的順手的“helper”方法。你甚至可以新增全新的身份認證、快取和會話機制!

Laravel元件通常有兩種擴充套件方式:在IoC容器裡面繫結新實現,或者用Manager類註冊一個擴充套件,該擴充套件采用了工廠模式實現。 在本章中我們將探索不同的擴充套件方式並檢查我們都需要些什麼程式碼。

擴充套件方式

要記住 Laravel 通常有以下兩種擴充套件方式:通過IoC繫結和通過Manager類(下文譯作“管理類”)。其中管理類實現了工廠設計模式,負責元件的例項化。比如快取和會話機制。

管理者和工廠

Laravel有好多Manager類用來管理基於驅動的元件的生成過程。基於驅動的元件包括:快取、會話、身份認證、佇列元件等。管理類負責根據應用程式的配置,來生成特定的驅動例項。比如:CacheManager可以建立APC、Memcached、Native、還有其他不同的快取驅動的實現。

每個管理類都包含名為extend的方法,該方法可用於將新功能注入到管理類中。下面我們將逐個介紹管理類,為你展示如何注入自定義的驅動。

如何瞭解你的管理類

請花點時間看看Laravel中各個Manager類的程式碼,比如CacheManagerSessionManager。通過閱讀這些程式碼能讓你對Laravel的管理類機制更加清楚透徹。所有的管理類都繼承自Illuminate\Support\Manager基類,該基類為每一個管理類提供了一些有效且通用的功能。

快取

要擴充套件 Laravel 的快取機制,我們將使用CacheManager裡的extend方法來繫結我們自定義的快取驅動。擴充套件其他的管理類也是類似的。比如,我們想註冊一個新的快取驅動,名叫“mongo”,程式碼可以這樣寫:

Cache::extend('mongo', function($app)
{
    // Return Illuminate\Cache\Repository instance...
});

extend方法的第一個引數是你要定義的驅動的名字。該名字對應著app/config/cache.php配置檔案中的driver項。第二個引數是一個匿名函式(閉包),該匿名函式有一個$app引數是Illuminate\Foundation\Application的例項也是一個IoC容器,該匿名函式要返回一個Illuminate\Cache\Repository的例項。

要建立我們自己的快取驅動,首先要實現Illuminate\Cache\StoreInterface介面。所以我們用MongoDB來實現的快取驅動就可能看上去是這樣:

class MongoStore implements Illuminate\Cache\StoreInterface {
    public function get($key) {}
    public function put($key, $value, $minutes) {}
    public function increment($key, $value = 1) {}
    public function decrement($key, $value = 1) {}
    public function forever($key, $value) {}
    public function forget($key) {}
    public function flush() {}
}

我們只需使用MongoDB連結來實現上面的每一個方法即可。一旦實現完畢,就可以照下面這樣完成該驅動的註冊:

use Illuminate\Cache\Repository;
Cache::extend('mongo', function($app)
{
    return new Repository(new MongoStore);
}

你可以像上面的例子那樣來建立Illuminate\Cache\Repository的例項。也就是說通常你不需要建立你自己的倉庫類(Repository)。

如果你不知道要把自定義的快取驅動程式碼放到哪兒,可以考慮放到Packagist裡!或者你也可以在你應用的主目錄下建立一個Extensions目錄。比如,你的應用叫做Snappy,你可以將快取擴充套件程式碼放到app/Snappy/Extensions/MongoStore.php。不過請記住Laravel沒有對應用程式的結構做硬性規定,所以你可以按任意你喜歡的方式組織你的程式碼。

在哪兒呼叫Extend方法?

如果你還發愁在哪兒放註冊程式碼,先考慮放到服務提供者裡吧。我們之前就講過,使用服務提供者是一種非常棒的管理你應用程式碼的途徑。

會話

擴充套件 Laravel 的會話機制和上文的快取機制一樣簡單。和剛才一樣,我們使用extend方法來註冊自定義的程式碼:

Session::extend('mongo', function($app)
{
    // Return implementation of SessionHandlerInterface
});

注意我們自定義的會話驅動(譯者注:原文是 cache driver,應該是筆誤。正確應為 session driver)實現的是SessionHandlerInterface介面。這個介面在 PHP 5.4 以上版本才有。但如果你用的是 PHP 5.3 也別擔心,Laravel 會自動幫你定義這個介面的。該介面要實現的方法不多也不難。我們用 MongoDB 來實現就像下面這樣:

class MongoHandler implements SessionHandlerInterface {
    public function open($savePath, $sessionName) {}
    public function close() {}
    public function read($sessionId) {}
    public function write($sessionId, $data) {}
    public function destroy($sessionId) {}
    public function gc($lifetime) {}
}

這些方法不像剛才的StoreInterface介面定義的那麼容易理解。我們來挨個簡單講講這些方法都是幹啥的:

  • open方法一般在基於檔案的會話系統中才會用到。Laravel已經自帶了一個native的會話驅動,使用的就是PHP自帶的基於檔案的會話系統,你可能永遠也不需要在這個方法裡寫東西。所以留空就好。另外這也是一個介面設計的反面教材(稍後我們會繼續討論這一點)。
  • close方法和open方法通常都不是必需的。對大部分驅動來說都不必要實現。
  • read方法應該根據$sessionId引數來返回對應的會話資料的字串形式。在你的會話驅動裡,不論讀寫都不需要做任何資料序列化工作。因為Laravel會負責資料序列化的。
  • write方法應該將$sessionId對應的$data字串放置在一個持久化儲存系統中。比如MongoDB,Dynamo等等。
  • destroy方法應該將$sessionId對應的資料從持久化儲存系統中刪除。
  • gc方法應該將所有時間超過引數$lifetime的資料全都刪除,該引數是一個UNIX時間戳。如果你使用的是類似Memcached或Redis這種有自主到期功能的儲存系統,那該方法可以留空。

一旦SessionHandlerInterface實現完畢,我們就可以將其註冊進會話管理器:

Session::extend('mongo', function($app)
{
    return new MongoHandler;
});

註冊完畢後,我們就可以在app/config/session.php配置檔案裡使用mongo驅動了。

分享你的知識

你要是寫了個自定義的會話處理器,別忘了在 Packagist 上分享啊!

身份認證

身份認證模組的擴充套件方式和快取與會話的擴充套件方式一樣:使用我們熟悉的extend方法就可以進行擴充套件:

Auth::extend('riak', function($app)
{
    // Return implementation of Illuminate\Auth\UserProviderInterface
});

介面UserProviderInterface負責從各種持久化儲存系統——如MySQL,Riak等——中獲取資料,然後得到介面UserInterface的實現物件。有了這兩個介面,Laravel的身份認證機制就可以不用管使用者資料是如何儲存的、究竟哪個類來代表使用者物件這種事兒,從而繼續專注於身份認證本身的實現。

我們們來看一看UserProviderInterface介面的程式碼:

interface UserProviderInterface {
    public function retrieveById($identifier);
    public function retrieveByCredentials(array $credentials);
    public function validateCredentials(UserInterface $user, array $credentials);
}

方法retrieveById通常接受一個數字引數用來表示一個使用者,比如MySQL資料庫的自增ID。該方法要找到匹配該ID的UserInterface的實現物件,並且將該物件返回。

retrieveByCredentials方法接受一個引數作為登入帳號。該引數是在嘗試登入系統時從Auth::attempt方法傳來的。那麼該方法應該“查詢”底層的持久化儲存系統,來找到那些匹配到該帳號的使用者。通常該方法會執行一個帶有“where”條件的查詢來匹配引數裡的$credentials['username']該方法不應該做任何密碼驗證。

validateCredentials方法會通過比較$user引數和$credentials引數來檢測使用者是否通過認證。比如,該方法會呼叫$user->getAuthPassword();方法,將得到的字串與$credentials['password']經過Hash::make處理後的結果進行比對。

現在我們探索了UserProviderInterface介面的每一個方法,接下來我們們看一看UserInterface介面。別忘了UserInterface的例項應當是retrieveByIdretrieveByCredentials方法的返回值:

interface UserInterface {
    public function getAuthIdentifier();
    public function getAuthPassword();
}

這個介面很簡單。 getAuthIdentifier方法應當返回使用者的“主鍵”。就像剛才提到的,在MySQL中可能就是自增主鍵了。getAuthPassword方法應當返回經過雜湊處理的使用者密碼。有了這個介面,身份認證系統就可以不用關心使用者類到底使用了什麼ORM或者什麼儲存方式。Laravel已經在app/models目錄下,包含了一個預設的User類且實現了該介面。所以你可以參考這個類當例子。

當我們最後實現了UserProviderInterface介面後,我們可以將該擴充套件註冊進Auth裡面:

Auth::extend('riak', function($app)
{
    return new RiakUserProvider($app['riak.connection']);
});

使用extend方法註冊好驅動以後,你就可以在app/config/auth.php配置檔案裡面切換到新的驅動了。

使用容器進行擴充套件

Laravel框架內幾乎所有的服務提供者都會繫結一些物件到IoC容器裡。你可以在app/config/app.php檔案裡找到服務提供者列表。如果你有時間的話,你應該大致過一遍每個服務提供者的原始碼。這麼做你便可以對每個服務提供者有更深的理解,明白他們都往框架里加了什麼東西,對應的什麼鍵。那些鍵就用來聯絡著各種各樣的服務。

舉個例子,PaginationServiceProvider向容器內繫結了一個paginator鍵,對應著一個Illuminate\Pagination\Environment的例項。你可以很容易的通過覆蓋容器繫結來擴充套件重寫該類。比如,你可以建立一個擴充套件自Environment類的子類:

namespace Snappy\Extensions\Pagination;
class Environment extends \Illuminate\Pagination\Environment {
    //
}

子類寫好以後,你可以再建立個新的SnappyPaginationProvider服務提供者來擴充套件其boot方法,在裡面覆蓋 paginator:

class SnappyPaginationProvider extends PaginationServiceProvider {
    public function boot()
    {
        App::bind('paginator', function()
        {
            return new Snappy\Extensions\Pagination\Environment;
        }

        parent::boot();
    }
}

注意這裡我們繼承了PaginationServiceProvider,而非預設的基類ServiceProvider。擴充套件的服務提供者編寫完畢後,就可以在app/config/app.php檔案裡將PaginationServiceProvider替換為你剛擴充套件的那個類了。

這就是擴充套件繫結進容器的核心類的一般方法。基本上每一個核心類都以這種方式繫結進了容器,都可以被重寫。還是那一句話,讀一遍框架內的服務提供者原始碼吧。這有助於你熟悉各種類是怎麼繫結進容器的,都繫結的是哪些鍵。這是學習Laravel框架到底如何運轉的好方法。

請求的擴充套件

由於這玩意兒是框架裡面非常基礎的部分,並且在請求流程中很早就被例項化,所以要擴充套件Request類的方法與之前相比是有些許不同的。

首先還是要寫個子類:

namespace QuickBill\Extensions;
class Request extends \Illuminate\Http\Request {
    // Custom, helpful methods here...
}

子類寫好後,開啟bootstrap/start.php檔案。該檔案是應用的請求流程中最早被載入的幾個檔案之一。要注意被執行的第一個動作是建立Laravel的$app例項:

$app = new \Illuminate\Foundation\Application;

當新的應用例項建立後,它將會建立一個Illuminate\Http\Request的例項並且將其繫結到IoC容器裡,鍵名為request。所以我們需要找個方法來將一個自定義的類指定為“預設的”請求類,對不對?而且幸運的是,應用例項有一個名為requestClass的方法就是用來幹這事兒的!所以我們只需要在bootstrap/start.php檔案最上面加一行:

use Illuminate\Foundation\Application;
Application::requestClass('QuickBill\Extensions\Request');

一旦你指定了自定義的請求類,Laravel 將在任何時候都可以使用這個Request類的例項。並使你很方便的能隨時訪問到它,甚至單元測試也不例外!

單一職責原則

介紹

羅伯特“鮑勃叔叔”馬丁闡述了名為“堅實”的一些設計原則(譯者注:看下面五個原則的首字母正是 SOLID)。這些都是製作完善的程式設計的優秀基礎,一共有五個原則:

  • 單一職責原則
  • 開放封閉原則
  • 里氏替換原則
  • 介面隔離原則
  • 依賴反轉原則

讓我們深入探索一下,再看點程式碼樣例來說明各個原則。我們將看到,每個原則之間都有聯絡。如果其中一個原則沒有被遵循,那麼其他大部分(可能不會是全部)的原則也會出問題。

實踐

單一職責原則規定一個類有且僅有一個理由使其改變。換句話說,一個類的功能邊界和職責應當是十分狹窄且集中的。我們之前就提到過,在類的職責問題上,無知是福。一個類應當做它該做的事兒,並且不應當被它的依賴的任何變化所影響到。

考慮下列類:

class OrderProcessor {
    public function __construct(BillerInterface $biller)
    {
        $this->biller = $biller;
    }
    public function process(Order $order)
    {
        $recent = $this->getRecentOrderCount($order);
        if($recent > 0)
        {
            throw new Exception('Duplicate order likely.');
        }

        $this->biller->bill($order->account->id, $order->amount);

        DB::table('orders')->insert(array(
            'account'    =>    $order->account->id,
            'amount'    =>    $order->amount,
            'created_at'=>    Carbon::now()
        ));
    }
    protected function getRecentOrderCount(Order $order)
    {
        $timestamp = Carbon::now()->subMinutes(5);
        return DB::table('orders')->where('account', $order->account->id)
                                                ->where('created_at', '>=', $timestamps)
                                                ->count();
    }
}

上面這個類的職責是什麼?很顯然顧名思義,它是用來處理訂單的。不過由於getRecentOrderCount這個方法的存在,這個類就有了在資料庫中審查某帳號訂單歷史來看有沒有重複訂單的職責。這個額外的驗證職責意味著當我們的儲存方式改變或當訂單驗證規則改變時,我們的這個訂單處理器也要跟著改變。

我們必須將這個職責抽離出來放到另外的類裡面,比如放到OrderRepository

class OrderRepository {
    public function getRecentOrderCount(Account $account)
    {
        $timestamp = Carbon::now()->subMinutes(5);
        return DB::table('orders')->where('account', $account->id)
                                                ->where('created_at', '>=', $timestamp)
                                                ->count();
    }

    public function logOrder(Order $order)
    {
        DB::table('orders')->insert(array(
            'account'    =>    $order->account->id,
            'amount'    =>    $order->amount,
            'created_at'=>    Carbon::now()
        ));
    }
}

然後我們可以將我們的資料庫(譯者注:OrderRepository )注入到OrderProcessor裡,幫後者承擔起對賬戶訂單歷史的處理責任:

class OrderProcessor {
    public function __construct(BillerInterface $biller, OrderRepository $orders)
    {
        $this->biller = $biller;
        $this->orders = $orders;
    }

    public function process(Order $order)
    {
        $recent = $this->orders->getRecentOrderCount($order->account);

        if($recent > 0)
        {
            throw new Exception('Duplicate order likely.');
        }

        $this->biller->bill($order->account->id, $order->amount);

        $this->orders->logOrder($order);
    }
}

現在我們提取出了收集訂單資料的責任,當讀取和寫入訂單的方式改變時,我們不再需要修改OrderProcessor這個類了。我們的類的職責更加的專注和精確,這提供了一個更乾淨、更有表現力的程式碼,同時也是更容易維護的程式碼。

請記住,單一職責原則的關鍵不僅僅是讓函式變短,而是寫出職責更精確更高內聚的類,所以要確保類裡面所有的方法都屬於該類的職責之下的。在建立一個小巧、清晰且職責明確的類庫以後,我們的程式碼會更加解耦,更容易測試,並且更易於更改。

開放封閉原則

介紹

在一個應用的生命週期裡,大部分時間都花在了向現有程式碼庫增加功能,而非一直從零開始寫新功能。正像你所想的那樣,這會是一個繁瑣且令人痛苦的過程。當你修改程式碼的時候,你可能引入新的程式錯誤,或者將原來管用的功能搞壞掉。理想情況下,我們應該可以像寫全新的程式碼一樣,來快速且簡單的修改現有的程式碼。只要採用開放封閉原則來正確的設計我們的應用程式,那麼這是可以做到的!

開放封閉原則

開放封閉原則規定程式碼對擴充套件是開放的,對修改是封閉的。

實踐

為了演示開放封閉原則,我們來繼續編寫上一章節的OrderProcecssor。考慮下面的process方法:

$recent = $this->orders->getRecentOrderCount($order->account);

if($recent > 0)
{
    throw new Exception('Duplicate order likely.');
}

這段程式碼可讀性很高,且因為我們使用了依賴注入,變得很容易測試。然而,如果我們判斷訂單的規則改變了呢?如果我們又有新的規則了呢?更進一步,如果隨著我們的業務發展,要增加一大堆新規則呢?那我們的process方法會很快變成一坨難以維護的漿糊。因為這段程式碼必須隨著每次業務邏輯的改變而跟著改變,它對修改是開放的,這違反了開放封閉原則。記住,我們希望程式碼對擴充套件開放,而不是修改。

不必再把訂單驗證直接寫在process方法裡面,我們來定義一個新的介面:OrderValidator

interface OrderValidatorInterface {
    public function validate(Order $order);
}

下一步我們來定義一個實現介面的類,來預防重複訂單:

class RecentOrderValidator implements OrderValidatorInterface {
    public function __construct(OrderRepository $orders)
    {
        $this->orders = $orders;
    }
    public function validate(Order $order)
    {
        $recent = $this->orders->getRecentOrderCount($order->account);
        if($recent > 0)
        {
            throw new Exception('Duplicate order likely.');
        }
    }
}

很好!我們封裝了一個小巧的、可測試的單一業務邏輯。我們們來再建立一個來驗證賬號是否停用吧:

class SuspendedAccountValidator implements OrderValidatorInterface {
    public function validate(Order $order)
    {
        if($order->account->isSuspended())
        {
            throw new Exception("Suspended accounts may not order.");
        }
    }
}

現在我們有兩個不同的類實現了OrderValidatorInterface介面。我們們將在OrderProcessor裡面使用它們。我們只需簡單的將一個驗證器陣列注入進訂單處理器例項中。這將使我們以後修改程式碼時能輕鬆的新增和刪除驗證器規則。

class OrderProcessor {
    public function __construct(BillerInterface $biller, OrderRepository $orders, array $validators = array())
    {
        $this->biller = $bller;
        $this->orders = $orders;
        $this->validators = $validators;
    }
}

然後我們只要在process方法裡面迴圈這個驗證器陣列即可:

public function process(Order $order)
{
    foreach($this->validators as $validator)
    {
        $validator->validate($order);
    }

    // Process valid order...
}

最後我們在 IoC 容器裡面註冊OrderProcessor類:

App::bind('OrderProcessor', function()
{
    return new OrderProcessor(
        App::make('BillerInterface'),
        App::make('OrderRepository'),
        array(
            App::make('RecentOrderValidator'),
            App::make('SuspendedAccountValidator')
        )
    );
});

在現有程式碼裡付出些小努力,做一些小改動之後,我們現在可以新增刪除新的驗證規則而不必修改任何一行現有程式碼了。每一個新的驗證規則就是對OrderValidatorInterface的一個實現類,然後註冊進IoC容器裡。不必再為那個又大又笨的process方法做單元測試了,我們現在可以單獨測試每一個驗證規則。現在,我們的程式碼對擴充套件是開放的,對修改是封閉的。

抽象的漏洞

小心那些缺少實現細節的依賴(譯者注:比如上面的RecentOrderValidator)。當一個依賴的實現需要改變時,不應該要求它的呼叫者做任何修改。當需要呼叫者進行修改時,這就意味著該依賴遺漏了一些實現的細節。當你的抽象有漏洞的話,開放封閉原則就不管用了。

在我們繼續學習前,要記住這些原則不是法律。這不是說你應用中每一塊程式碼都應該是“熱插拔”式的。例如,一個僅僅從MySQL檢索幾條記錄的小應用程式,不值得去嚴格遵守每一條你想到的設計原則。不要盲目的應用設計原則,那樣你會造出一個“過度設計”的繁瑣的系統。記住這些設計原則是用來解決通用的架構問題,製造大型容錯能力強的應用。我就這麼一說,你可別把它當作懶惰的藉口!

里氏替換原則

介紹

別擔心,里氏替換原則讀起來嚇人學起來簡單。該原則要求:一個抽象的任意一個實現,可以被用在任何需要該抽象的地方。讀起來繞口,用普通人的話來解釋一下。該原則規定:如果某處程式碼使用了一個介面的一個實現類,那麼在這裡也可以直接使用該介面的任何其他實現類,不用做出任何修改。

里氏替換原則

該原則規定物件應該可以被該物件子類的例項所替換,並且不會影響到程式的正確性。

實踐

為了說明該原則,我們繼續編寫上一章節的OrderProcessor。看下面的方法:

public function process(Order $order)
{
    // Validate order...
    $this->orders->logOrder($order);
}

注意當我們的Order通過了驗證,就被OrderRepositoryInterface的實現物件儲存起來了。假設當我們的業務剛起步時,我們將訂單儲存在CSV格式的檔案系統中。我們的OrderRepositoryInterface的實現類是CsvOrderRepository。現在,隨著我們訂單增多,我們想用一個關聯式資料庫來儲存訂單。那麼我們來看看新的訂單資料庫類該怎麼編寫吧:

class DatabaseOrderRepository implements OrderRepositoryInterface {
    protected $connection;
    public function connect($username, $password)
    {
        $this->connection = new DatabaseConnection($username, $password);
    }

    public function logOrder(Order $order)
    {
        $this->connection->run('insert into orders values (?, ?)', array(
            $order->id, $order->amount
        ));
    }
}

現在我們來研究如何使用這個實現類:

public function process(Order $order)
{
    // Validate order...

    if($this->repository instanceof DatabaseOrderRepository)
    {
        $this->repository->connect('root', 'password');
    }
    $this->repository->logOrder($order);
}

注意在這段程式碼中,我們必須在資料庫外部檢查OrderRepositoryInterface的例項物件是不是用資料庫實現的。如果是的話,則必須先連線資料庫。在很小的應用中這可能不算什麼問題,但如果OrderRepositoryInterface被幾十個類呼叫呢?我們可能就要把這段“啟動”程式碼在每一個呼叫的地方複製一遍又一遍。這讓人非常頭疼難以維護,非常容易出錯誤。一旦我們忘了將所有呼叫的地方進行同步修改,那程式恐怕就會出問題。

很明顯,上面的例子沒有遵循里氏替換原則。如果不附加“啟動”程式碼來呼叫connect方法,則這段程式碼就沒法用。好了,我們已經找到問題所在,我們們修好他。下面就是新的DatabaseOrderRepository

class DatabaseOrderRepository implements OrderRepositoryInterface {
    protected $connector;
    public function __construct(DatabaseConnector $connector)
    {
        $this->connector = $connector;
    }
    public function connect()
    {
        return $this->connector->bootConnection();
    }
    public function logOrder(Order $order)
    {
        $connection = $this->connect();
        $connection->run('insert into orders values (?, ?)', array(
            $order->id, $order->amount
        ));
    }
}

現在DatabaseOrderRepository掌管了資料庫連線,我們可以把“啟動”程式碼從OrderProcessor移除了:

public function process(Order $order)
{
    // Validate order...

    $this->repository->logOrder($order);
}

這樣一改,我們就可以想用CsvOrderRepository也行,想用DatabaseOrderRepository也行,不用改OrderProcessor一行程式碼。我們的程式碼終於實現了里氏替換原則!要注意,我們討論過的許多架構概念都和知識相關。具體講,知識就是一個類和它所具有的周邊領域,比如用來幫助類完成任務的外圍程式碼和依賴。當你要製作一個容錯性強大的應用架構時,限制類的知識是一種常用且重要的手段。

還要注意如果不遵守里氏替換原則,那後果可能會影響到我們之前已經討論過的其他原則。不遵守里氏替換原則,那麼開放封閉原則一定也會被打破。因為,如果呼叫者必須檢查例項屬於哪個子類的,那一旦有個新的子類,呼叫者就得做出改變。(譯者注:這就違背了對修改封閉的原則。)

小心遺漏

你可能注意到這個原則和上一章節提到的“抽象的漏洞”密切相關。我們的資料庫資料庫的抽象漏洞就是沒有遵守里氏替換原則的第一跡象。要留意那些漏洞!

介面隔離原則

介紹

介面隔離原則規定在實現介面的時候,不能強迫去實現沒有用處的方法。你是否曾被迫去實現一些介面裡你用不到的方法?如果答案是肯定的,那你可能建立了一個空方法放在那裡。被迫去實現用不到的函式,這就是一個違背了介面隔離原則的例子。

在實際操作中,該原則要求介面必須粒度很細,且專注於一個領域。聽起來很耳熟?記住,所有五個“堅實”原則都是相關的,也就是說當打破一個原則時,你通常肯定打破了其他的原則。在這裡當你違背了介面隔離原則後,肯定也違背了單一職責原則。

“臃腫”的介面,有著很多不是所有的實現類都需要的方法。與其寫這樣的介面,不如將其拆分成多個小巧的介面,裡面的方法都是各自領域所需要的。這樣將臃腫介面拆成小巧、功能集中的介面後,我們就可以使用小介面來編碼,而不必為我們不需要的功能買單。

介面隔離原則

該原則規定,一個介面的一個實現類,不應該去實現那些自己用不到的方法。如果需要,那就是介面設計有問題,違背了介面隔離原則。

實踐

為了說明該原則,我們來思考一個關於會話處理的類庫。實際上我們將要考察 PHP 自己的SessionHandlerInterface。下面是該介面定義的方法,他們是從 PHP 5.4 版才開始有的:

interface SessionHandlerInterface {
    public function close();
    public function destroy($sessionId);
    public function gc($maxLifetime);
    public function open($savePath, $name);
    public function read($sesssionId);
    public function write($sessionId, $sessionData);
}

現在我們知道介面裡面都是什麼方法了,我們打算用Memcached來實現它。Memcached需要實現這個介面裡的所有方法麼?不,裡面一半的方法對於Memcached來說都是不需要實現的!

因為Memcached會自動清除儲存的過期資料,我們不需要實現gc方法。我們也不需要實現openclose方法。所以我們被迫去寫空方法來站著位子。為了解決在這個問題,我們來定義一個小巧的專門用來垃圾回收的介面:

interface GarbageCollectorInterface {
    public function gc($maxLifetime);
}

現在我們有了一個小巧的介面,功能單一而專注。需要垃圾清理的只用依賴這個介面即可,而不必去依賴整個會話處理。

為了更深入理解該原則,我們用另一個例子來強化理解。想象我們有一個名為Contact的Eloquent類,定義成這樣:

class Contact extends Eloquent {
    public function getNameAttribute()
    {
        return $this->attributes['name'];
    }
    public function getEmailAttribute()
    {
        return $this->attributes['email'];
    }
}

現在我們再假設我們應用裡還有一個叫PasswordReminder的類來負責給使用者傳送密碼找回郵件。下面是PasswordReminder的定義方式的一種:

class PasswordReminder {
    public function remind(Contact $contact, $view)
    {
        // Send password reminder e-mail...
    }
}

你可能注意到了,PasswordReminder依賴著Contact類,也就是依賴著Eloquent ORM。 對於一個密碼找回系統來說,依賴著一個特定的ORM實在是沒必要,也是不可取的。切斷對該ORM的依賴,我們就可以自由的改變我們後臺儲存機制或者說ORM,同時不會影響到我們的密碼找回元件。重申一遍,違背了“堅實”原則的任何一條,就意味著有個類它知道的太多了。

要切斷這種依賴,我們來建立一個RemindableInterface介面。事實上Laravel已經有了這個介面,並且預設由User模型實現了該介面:

interface RemindableInterface {
    public function getReminderEmail();
}

一旦介面定義好了,我們就可以在模型上實現它:

class Contact extends Eloquent implements RemindableInterface {
    public function getReminderEmail()
    {
        return $this->email;
    }
}

最終我們可以在PasswordReminder裡面依賴這樣一個小巧且專注的介面了:

class PasswordReminder {
    public function remind(RemindableInterface $remindable, $view)
    {
        // Send password reminder e-mail...
    }
}

通過這小小的改動,我們已經移除了密碼找回元件裡不必要的依賴,並且使它足夠靈活能使用任何實現了RemindableInterface的類或ORM。這其實正是Laravel的密碼找回元件如何保持與資料庫ORM無關的祕訣!

知識就是力量

我們再次發現了一個使類知道太多東西的陷阱。通過小心留意是否讓一個類知道了太多,我們就可以遵守所有的“堅實”原則。

依賴反轉原則

介紹

在整個“堅實”原則概述的旅途中,我們到達最後一站了!最後的原則是依賴反轉原則,它規定高等級的程式碼不應該依賴(遷就)低等級的程式碼。首先,高等級的程式碼應該依賴(遵從)著抽象層,抽象層就像是“中間人”一樣,負責連線著高等級和低等級的程式碼。其次,抽象定義不應該依賴(遷就)著具體實現,但具體實現應該依賴(遵從)著抽象定義。如果這些東西讓你極端困惑,別擔心。接下來我們會將這兩方面統統介紹給你。

依賴反轉原則

該原則要求高等級程式碼不應該遷就低等級程式碼,抽象定義不應該遷就具體實現。

實踐

如果你已經讀過了本書前面幾個章節,你就已經很好掌握了依賴反轉原則!為了說明本原則,讓我們考慮下面這個類:

    class Authenticator {
        public function __construct(DatabaseConnection $db)
        {
            $this->db = $db;
        }
        public function findUser($id)
        {
            return $this->db->exec('select * from users where id = ?', array($id));
        }
        public function authenticate($credentials)
        {
            // Authenticate the user...
        }
    }

你可能猜到了,Authenticator就是用來查詢和驗證使用者的。繼續研究它的建構函式。我們發現它使用了型別提示,要求傳入一個DatabaseConnection物件,所以該驗證類和資料庫被緊密的聯絡在一起。而且基本上講,這個資料庫還只能是關聯式資料庫。從而可知,我們的高階程式碼(Authenticator)直接的依賴著低階程式碼(DatabaseConnection)。

首先我們來談談“高階程式碼”和“低階程式碼”。低階程式碼用於實現基本的操作,比如從磁碟讀檔案,運算元據庫等。高階程式碼用於封裝複雜的邏輯,它們依靠低階程式碼來達到功能目的,但不能直接和低階程式碼耦合在一起。取而代之的是高階程式碼應該依賴著低階程式碼的頂層抽象,比如介面。不僅如此,低階程式碼應當依賴著抽象。 所以我們來寫個Authenticator可以用的介面:

    interface UserProviderInterface {
        public function find($id);
        public function findByUsername($username);
    }

接下來我們將該介面注入到Authenticator裡面:

    class Authenticator {
        public function __construct(UserProviderInterface $users, HasherInterface $hash)
        {
            $this->hash = $hash;
            $this->users = $users;
        }
        public function findUser($id)
        {
            return $this->users->find($id);
        }
        public function authenticate($credentials)
        {
            $user = $this->users->findByUsername($credentials['username']);
            return $this->hash->make($credentials['password']) == $user->password;
        }
    }

做了這些小改動後,Authenticator現在依賴於兩個高階抽象:UserProviderInterfaceHasherInterface。我們可以向Authenticator自由的注入這倆介面的任何實現類。比如,如果我們的使用者儲存在Redis裡面,我們只需寫一個RedisUserProvider來實現UserProviderInterface介面即可。Authenticator不再依賴著具體的低階別的儲存操作了。

此外,由於我們的低階別程式碼實現了UserProviderInterface介面,則我們說該低階程式碼依賴著這個介面。

    class RedisUserProvider implements UserProviderInterface {
        public function __construct(RedisConnection $redis)
        {
            $this->redis = $redis;
        }
        public function find($id)
        {
            $this->redis->get('users:'.$id);
        }
        public function findByUsername($username)
        {
            $id = $this->redis->get('user:id:'.$username);
            return $this->find($id);
        }
    }
反轉的思維

貫徹這一原則會反轉好多開發者設計應用的方式。不再將高階程式碼直接和低階程式碼以“自上而下”的方式耦合在一起,這個原則提出無論高階還是低階程式碼都要依賴於一個高層次的抽象。

在我們沒有反轉Authenticator的依賴之前,它除了使用資料庫儲存系統別無選擇。如果我們改變了儲存系統,Authenticator也需要被修改,這就違背了開放封閉原則。我們又一次看到,這些設計原則通常一榮俱榮一損俱損。

通過強制讓Authenticator依賴著一個儲存抽象層,我們就可以使用任何實現了UserProviderInterface介面的儲存系統,且不用對Authenticator本身做任何修改。傳統的依賴關係鏈已經被反轉了,程式碼變得更靈活,更加無懼變化!

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

相關文章