从测试角度探讨依赖注入
依赖反转原则是SOLID 中最难理解的原则,而依赖注入则是单元测试的基石,本文将从测试角度探讨依赖反转与依赖注入,并将Laravel 的service container、constructor injection 与method injection 应用在实务上。
Version
PHP 7.0.0
Laravel 5.2.29
实际案例
假设目前有3家货运公司,每家公司的计费方式不同,使用者可以动态选择不同的货运公司,将一步步的重构成依赖注入方式
传统写法
传统我们会使用 if else
与 new
来建立物件。
BlackCat.PHP
app/Services/BlackCat.PHP namespace App \ Services ; class BlackCat { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 100 + $weight * 10 ; } }
黑猫的计费方式。
Hsinchu.PHP
app/Services/Hsinchu.PHP namespace App \ Services ; class Hsinchu { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 80 + $weight * 15 ; } }
新竹货运的计费方式。
PostOffice.PHP
app/Services/PostOffice.PHP namespace App \ Services ; class PostOffice { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 70 + $weight * 20 ; } }
邮局的计费方式。
ShippingService.PHP
app/Services/ShippingService.PHP namespace App \ Services ; use Exception ; class ShippingService { /** * @param string $companyName * @param int $weight * @return int * @throws Exception */ public function calculateFee ( $companyName,$weight ) { if ( $companyName == 'BlackCat' ) { $blackCat = new BlackCat(); return $blackCat ->calculateFee( $weight ); } elseif ( $companyName == 'Hsinchu' ) { $hsinchu = new Hsinchu(); return $hsinchu ->calculateFee( $weight ); } elseif ( $companyName == 'PostOffice' ) { $postOffice = new PostOffice(); return $postOffice ->calculateFee( $weight ); } else { throw new Exception ( 'No company exception' ); } } }
calculateFee()
传入2个参数: $companyName
与 $weight
。
使用者可自行由 $companyName
挑选货运公司,并传入 $weight
计算运费。
使用 if else
判断 $companyName
字串,并 new
出相对应物件,这是初学者学习物件导向时的写法。
ShippingService.PHP
app/Services/ShippingService.PHP /** * @param string $companyName * @param int $weight * @return int * @throws Exception */ public function calculateFee ( $companyName,$weight ) { switch ( $companyName ) { case 'BlackCat' : $blackCat = new BlackCat(); return $blackCat ->calculateFee( $weight ); case 'Hsinchu' : $hsinchu = new Hsinchu(); return $hsinchu ->calculateFee ( $weight ); case 'PostOffice' : $postOffice = new PostOffice(); return $postOffice ->calculateFee( $weight ); default : throw new Exception ( 'No company exception' ); } }
将 if else
重构成 switch
,可稍微改善程式码的可读性。
使用Interface
目前的写法,执行上没有什么问题,若以TDD开发,我们将得到第一个绿灯。
我们将继续重构成更好的程式。
目前我们是实际去 new Blackcat()
、new Hsinchu()
与 new PostOffice()
,也就是说 ShippingService
将直接相依于 BlackCat
、Hshinchu
与PostOffice
3个class。
物件导向就是希望达到高内聚,低耦合的设计。所谓的低耦合,就是希望能减少相依于外部的class的数量。
何谓相依 ?
简单的说,有2 种写法会产生相依 :
去new 其他class。
去extends 其他class。
由于PHP 不用编译,所以可能较无法体会相依的严重性,但若是需要编译的程式语言,若你相依的class 的property 或method 改变,可能导致你的程式无法编译成功,也就是你必须配合相依的class 做相对应的修改才能通过编译,因此我们希望降低对其他class 的相依程度与数量。
GoF四人帮在设计模式曾说: Program to an Interface,not an Implementation。也就是程式应该只相依于interface,而不是相依于实际class,目的就是要藉由interface,降低对于实际class的相依程度。
若我们能将 BlackCat
、Hshinchu
与 PostOffice
3个class抽象化为1个 interface
,则 ShippingService
将从相依3个class,降低成只相依于1个interface,
将大大降低 ShippingService
与其他class的相依程度。
若以编译的角度,由于 ShippingService
只相依于 interface
,因此 BlackCat
、Hshinchu
与 PostOffice
做任何修改都不会影响我 ShippingService
的编译。
LogisticsInterface.PHP
app/Services/LogisticsInterface.PHP namespace App \ Services ; interface LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) ; }
从 BlackCat
抽取出 LogisticsInterface
,将 BlackCat
、Hsinchu
与 PostOffice
抽象化成 LogisticsInterface
。
BlackCat.PHP
app/Services/BlackCat.PHP namespace App \ Services ; class BlackCat implements LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 100 * $weight * 10 ; } }
BlackCat
实现 LogisticsInterface
Hsinchu.PHP
app/Services/Hsinchu.PHP namespace App \ Services ; class Hsinchu implements LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 80 * $weight * 15 ; } }
Hsinchu
实现 LogisticsInterface
。
PostOffice.PHP
app/Services/PostOffice.PHP namespace App \ Services ; class PostOffice implements LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 70 * $weight * 20 ; } }
PostOffice
实现 LogisticsInterface
。
ShippingService.PHP
app/Services/ShippingService.PHP namespace App \ Services ; use Exception ; class ShippingService { /** * @param string $companyName * @param int $weight * @return int * @throws Exception */ public function calculateFee ( $companyName,$weight ) { switch ( $companyName ) { case 'BlackCat' : $logistics = new BlackCat(); return $logistics ->calculateFee( $weight ); case 'Hsinchu' : $logistics = new Hsinchu(); return $logistics ->calculateFee( $weight ); case 'PostOffice' : $logistics = new PostOffice(); return $logistics ->calculateFee( $weight ); default : throw new Exception ( 'No company exception' ); } } }
$logistics
的型别都是 LogisticsInterface
,目前PHP 7对于变数还没有支援type hint,所以程式码看起来差异不大,
但藉由PHPDoc,在PHPStorm打 $logistics->
,已经可以得到语法提示: calculateFee( )
,
表示PHPStorm已经知道 BlackCat
、Hsinchu
与 PostOffice
都是 LogisticsInterface
型别的物件,
也就是对于 ShippingService
来说,BlackCat
、Hsinchu
与 PostOffice
都已经抽象化成 LogisticsInterface
工厂模式
虽然已经将 BlackCat
、Hsinchu
与 PostOffice
抽象化成 LogisticsInterface
,但是在 ShoppingService
中,仍看到 new Blackcat()
、new Hsinchu()
与new PostOffice()
,对于ShoppingService
而言,我们看到了3个问题:
违反单一职责原则 : calculateFee()原本应该只负责计算运费,现在却还要负责建立货运公司物件。
违反开放封闭原则 :将来若有新的货运公司供使用者选择,势必修改switch。
实质相依数为3 :虽然已经重构出interface,但实际上却还必须new 3个class。
比较好的方式是将 new
封装在 LogisticsFactory
中
LogisticsFactory.PHP
app/Services/LogisticsFactory.PHP namespace App \ Services ; use Exception ; class LogisticsFactory { /** * @param string $companyName * @return LogisticsInterface * @throws Exception */ public static function create (string $companyName ) { switch ( $companyName ) { case 'BlackCat' : return new BlackCat(); case 'Hsinchu ' : return new Hsinchu(); case 'PostOffice' : return new PostOffice(); default : throw new Exception ( 'No company exception' ); } } }
Simple Factory模式使用了 static create()
,专门负责建立货运公司物件:
专门负责建立货运公司的逻辑,符合单一职责原则。
ShippingService.PHP
app/Services/ShippingService.PHP namespace App \ Services ; use Exception ; class ShippingService { /** * @param string $companyName * @param int $weight * @return int * @throws Exception */ public function calculateFee ( $companyName,$weight ) { $logistics = LogisticsFactory::create( $companyName ); return $logistics ->calculateFee( $weight ); } }
将来有新的货运公司,也只要统一修改 LogisticsFactory
即可,将其变化封装在 LogisticsFactory
,对于 ShoppingService
开放封闭。
ShoppingService
从相依于3个class降低成仅相依于 LogisticsInterface
与 LogisticsFactory
,实质相依数降为2。
程式的可测试性
符合spec 的程式,并不代表是好的程式,一个好的程式还要符合5 个要求 :
容易维护。
容易新增功能。
容易重复使用。
容易上Git,不易与其他人冲突。
容易写测试。
使用interface + 工厂模式,已经达到以上前4点要求,算是很棒的程式。
根据单元测试的定义:
单元测试必须与外部环境、类别、资源、服务独立,而不能直接相依。这样才是单纯的测试目标物件本身的逻辑是否符合预期。
若要对 ShippingService
进行单元测试,势必将 BlackCat
、Hsinchu
与 PostOffice
加以抽换隔离,但使用了工厂模式之后,ShippingService
依然直接相依了 LogisticsFactory
,而 LogisticsFactory
又直接相依 BlackCat
、Hsinchu
与 PostOffice
,
当我们对 ShippingService
做单元测试时,由于无法对 LogisticsFactory
做抽换隔离,因此无法对 ShippingService
做单元测试。
简单的说,interface + 工厂模式,仍然无法达到可测试性的要求,我们必须继续重构。
依赖反转
为了可测试性,单元测试必须可决定待测物件的相依物件,如此才可由单元测试将待测物件的相依物件加以抽换隔离。
换句话说,我们不能让待测物件直接相依其他class,而应该由单元测试订出interface,让待测物件仅能相依于interface,而实际相依的物件可由单元测试来决定,如此我们才能对相依物件加以抽换隔离。
这也就是所谓的依赖反转原则 :
高阶模组不该依赖低阶模组,两者都应该要依赖其抽象。
抽象不要依赖细节,细节要依赖抽象。
好像越讲越抽象XDD。
其中相依与依赖是相同的,只是翻译用字的问题。
何谓高阶模组? 何谓低阶模组?
高阶与低阶是相对的。
简单的说:
当A class去new B class,A就是高阶模组,B就是低阶模组。
若以本例而言 :
ShippingService
相对于BlackCat
,ShippingService
是高阶模组,BlackCat
是低阶模组,单元测试相对于
ShippingService
,单元测试是高阶模组,ShippingService
是低阶模组。ShippingController
相对于ShippingService
,ShippingController
是高阶模组,ShippingService
是低阶模组。
何谓抽象? 何谓细节?
interface 为抽象,abstract class 为抽象。
class 为细节去implement interface,class 为细节去extends abstract class。
若以本例而言 :
在没有使用interface 前 :
ShippingService
直接new BlackCat()
。ShippingService
直接相依于BlackCat
。也就是高阶模组依赖低阶模组。
使用了interface 之后 :
ShippingService
没有相依于BlackCat
,也就是高阶模组没有依赖于低阶模组。ShippingService
改成相依于LogisticsInterface
,也就是高阶模组依赖其抽象(因为new而相依)。BlackCat
改成相依于LogisticsInterface
,也就是低阶模组也依赖其抽象(因为implements而相依)。也就是目前高阶模组与低阶模组都改成依赖其抽象。
高阶模组
ShippingService
原本依赖的是低阶模组BlackCat
的calculateFee()
,有了interface
之后,变成反过来低阶模组BlackCat
要依赖高阶模组所定义LogisticsInterface的calculateFee()
,所以称为依赖反转。
更简单的说,依赖反转就是要你使用interface 来写程式,而不要直接相依于class。
我们之前已经重构出 LogisticsInterface
,事实上已经符合依赖反转。
依赖注入
有了依赖反转还不足以达成可测试性,依赖反转只确保了待测物件的相依物件相依于interface。
既然相依物件相依于interface,若单元测试可以产生该interface 的物件,并加以注入,就可以将相依物件加以抽换隔离,这就是依赖注入。
Constructor Injection
ShippingService.PHP
app/Services/ShippingService.PHP namespace App\Services; class ShippingService { /** @var LogisticsInterface */ private $logistics; /** * ShippingService constructor. * @param LogisticsInterface $logistics */ public function __construct(LogisticsInterface $logistics) { $this->logistics = $logistics; } /** * @param int $weight * @return int */ public function calculateFee($weight) { return $this->logistics->calculateFee($weight); } }
12行
/** @var LogisticsInterface */ private $logistics; /** * ShippingService constructor. * @param LogisticsInterface $logistics */ public function __construct(LogisticsInterface $logistics) { $this->logistics = $logistics; }
原本相依的 LogisticsInterface
型别的物件,改由 constructor
注入,藉由PHP的 type hint,描述要注入的物件型别为 LogisticsInterface
。
原本使用interface +工厂模式,实质相依数为2,改用constructor injection之后,连 LogisticsFactory
都不需要了,仅相依于 LogisticsInterface
,实质相依数降为1。
17行
/** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return $this ->logistics->calculateFee( $weight ); }
将原本的 logistics
物件改成field。
Service Container
我们目前已经有了依赖注入,对于可测试性只剩下最后一哩路,若我们能将mock 出的假物件,透过依赖注入取代掉原来的相依物件,就能将相依物件加以抽换隔离,达成隔离测试的要求,service container 就是要帮我们将相依物件抽换隔离。
Laravel 4称为IoC container,Laravel 5称为service container。
17以下句子来自于30天快速上手TDD Day 5:如何隔离相依性-基本的可测试性
事实上IoC (Inversion of Conttrol)与DI (Dependency Inversion)讲的是同一件事情,也就是由单元测试决定待测物件的相依物件。
单元测试
ShippingService.PHP
tests/Services/ShippingServiceTest.PHP use App \ Services \ BlackCat ; use App \ Services \ LogisticsInterface ; use App \ Services \ ShippingService ; class ShippingServiceTest extends TestCase { /** @test */ public function黑猫单元测试() { /** arrange */ $expected = 110 ; $weight = 1 ; $mock = Mockery::mock(BlackCat::class); $mock ->shouldReceive( 'calculateFee' ) ->once() ->withAnyArgs() ->andReturn( $expected ); App::instance(LogisticsInterface::class,$mock ); $target = App::make(ShippingService::class); /** act */ $actual = $target ->calculateFee( $weight ); /** assert */ $this ->assertEquals( $expected,$actual ); } }
14行
$mock = Mockery::mock(BlackCat::class); $mock ->shouldReceive( 'calculateFee' ) ->once() ->withAnyArgs() ->andReturn( $expected );
因为单元测试,我们只想测试 ShippingService
,因此想将其相依的 LogisticsInterface
物件抽换隔离,因此利用 Mockery
根据 BlackCat
建立假物件$mock,
并定义 calculateFee()
回传的期望值为 $expected
。
once()
为预期 calculateFee()
会被执行一次,且只会被执行一次,若完全没被执行,或执行超过一次,PHPUnit会显示红灯。
withAngArgs()
为不特别在乎 calculateFee()
的参数型别与个数,一般来说,单元测试在乎的是被mock method是否被正确执行,以及其回传值是否如预期,至于参数则不太重要。
20行
App::instance(LogisticsInterface::class,$mock );
mock物件已经建立好,接着要告诉service container,当constructor injection的type hint遇到 LogisticsInterface
时,该使用我们刚建立的 $mock
物件抽换隔离,而不是原来的相依物件。
App::instance()
用到的地方不多,一般就是用在需要mock时。
22行
$target = App::make(ShippingService::class);
当mock与service container都准备好时,接着要建立待测物件准备测试,这里不能再使用new建立物件,而必须使用service container提供的 App::make()
来建立物件,因为我们就是希望靠service container帮我们将mock物件抽换隔离原来的相依物件,因此必须改用service container提供的 App::make()
。
整合测试
ShippingService.PHP
/** @test */ public function黑猫整合测试() { /** arrange */ $expected = 110 ; $weight = 1 ; App::bind(LogisticsInterface::class,BlackCat::class); $target = App::make(ShippingService::class); /** act */ $actual = $target ->calculateFee( $weight ); /** assert */ $this ->assertEquals( $expected,$actual ); }
当执行整合测试时,我们会希望实际执行相依物件的功能,而不再使用mock 将其相依物件抽换隔离。
第8行
App::bind(LogisticsInterface::class,BlackCat::class);
当constructor injection配合type hint时,若是class,Laravel的service container会自动帮我们注入其相依物件,但若type hint为interface时,因为可能有很多class implements该interface,所以必须先使用 App::bind( )
告诉service container,当type hint遇到 LogisticsInterface
时,实际上要注入的是 BlackCat
物件。
10行
$target = App::make(ShippingService::class);
当 App::bind()
完成后,就可以使用 App::make()
建立待测物件,service container也会根据刚刚 App::bind()
的设定,自动依赖注入 BlackCat
物件。
Method Injection
Laravel 4 提出了constructor injection 实现了依赖注入,而Laravel 5 更进一步提出了method injection。
有constructor injection 不就已经可测试了吗? 为什么还需要method injection 呢?
由于Laravel 4 只有constructor injection,所以只要class 要实现依赖注入,唯一的管道就是constructor injection,若有些相依物件只有单一method 使用一次,也必须使用constructor injection,这将导致最后constructor 的参数爆炸而难以维护。
对于一些只有单一method 使用的相依物件,若能只在method 的参数加上type hint,就可自动依赖注入,而不需要动用constructor,那就太好了,这就是method injection。
public function store (StoreBlogPostRequest $request ) { // The incoming request is valid... }
如大家熟悉的form request,就是使用method injection,相依的StoreBlogPostRequest物件并不是透过constructor注入,而是在 store()
注入。
ShippingService.PHP
namespace App \ Services ; class ShippingService { /** * @param LogisticsInterface $logistics * @param int $weight * @return int */ public function calculateFee (LogisticsInterface $logistics,$weight ) { return $logistics ->calculateFee( $weight ); } }
重构成method injection后,就不必再使用constructor与field,程式更加精简。
第1个参数为我们要注入的 LogisticsInterface
物件,第2个参数为我们原本要传的 $weight
参数
单元测试
ShippingService.PHP
use App \ Services \ BlackCat ; use App \ Services \ LogisticsInterface ; use App \ Services \ ShippingService ; class ShippingServiceTest extends TestCase { /** @test */ public function 黑猫单元测试() { /** arrange */ $expected = 110 ; $weight = 1 ; $mock = Mockery::mock(BlackCat::class); $mock ->shouldReceive( 'calculateFee' ) ->once() ->withAnyArgs() ->andReturn( $expected ); App::instance(LogisticsInterface::class,$mock ); /** act */ $actual = App::call(ShippingService::class . '@calculateFee',[ 'weight' => $weight ]); /** assert */ $this ->assertEquals( $expected,$actual ); } }
20行
/** act */ $actual = App::call(ShippingService::class . '@calculateFee',[ 'weight' => $weight ]);
之前mock 的部分,与constructor injection 相同,就不再解释。
关键在于 App::call()
,这是一个在Laravel官方文件没有介绍的method,但Laravel内部却到处在用。
之前我们使用constructor injection,就要搭配 App::make()
才能自动依赖注入。
现在我们使用method injection,就要搭配 App::call()
才能自动依赖注入。
第1个参数要传的字串,是class完整名称,加上@
与method名称。
第2 个参数要传的是阵列,也就是我们自己要传的参数,其中参数名称为key,参数值为value。
整合测试
ShippingService.PHP
public function 黑猫整合测试() { /** arrange */ $expected = 110 ; $weight = 1 ; App::bind(LogisticsInterface::class,BlackCat::class); /** act */ $actual = App::call(ShippingService::class . '@calculateFee',[ 'weight' => $weight ]); /** assert */ $this ->assertEquals( $expected,$actual ); }
10行
/** act */ $actual = App::call(ShippingService::class . '@calculateFee',[ 'weight' => $weight ]);
关键一样是使用 App::call()
。
为什么只能在controller 使用method injection,而无法在自己的presenter、service 或repository 使用method injection?
当初学习method injection时,我也非常兴奋,总算可以解决Laravel 4的constructor参数爆炸的问题,但发现只能用在controller,但无法用在自己的presenter、service或repository,一直学习到App::call ()时,问题才迎刃而解。
因为Laravel内部使用 App::call()
呼叫controller的method,因此你可以在controller无痛使用method injection,但若你自己的presenter、service或repository要使用method injection,就必须在controller搭配 App::call( )
,如此service containter才会帮你自动依赖注入相依物件。
再谈可测试性
本文从头到尾,都是以可测试性的角度去谈依赖注入,而我个人也的确是在写单元测试之后,才领悟依赖反转与依赖注入的重要性。
若是不写测试,是否就不需要依赖反转与依赖注入呢?
之前曾经提到 :
IoC (Inversion of Conttrol) 与DI (Dependency Inversion) 讲的是同一件事情,也就是由单元测试决定待测物件的相依物件。
根据之前的经验,我们可以发现待测物件的相依物件都是在测试的App::bind()所决定。
之前有提到所谓的高阶模组与低阶模组是相对的,单元测试相对于service,单元测试是高阶模组,而service 是低阶模组。
对照于实际状况,controller 相对于service,controller是高阶模组,而service 是低阶模组。
我们可以在单元测试以 App::bind()
决定service的相依物件,同样的,我们也可以在controller以 App::bind()
去决定service的相依物件。
既然我们可以由controller去决定,去注入service的相依物件,我们就不再被底层绑死,不再依赖底层service,而是由低阶模组去依赖高阶模组所制定的interface,再由controller的 App::bind()
来决定低阶模组的相依物件,这就是所谓的依赖反转。
也就是说,若高层模组可以决定低阶模组的相依物件,那整个设计的弹性与扩充性会非常好,因为需求都来自于人,而人所面对的是高阶模组,而高阶模组可以透过依赖注入去决定低阶模组的相依物件,而不是被低阶模组绑死,可弹性地依照需求而改变。
若程式符合可测试性的要求,表示其具有低耦合的特性,也就是物件导向强调的高内聚,低耦合,因此程式将更容易维护,更容易新增功能,更容易重复使用,更容易上Git,不易与其他人冲突,也就是说我们可以将程式的可测试性,当成是否为好程式的指标之一。
生活中的依赖反转
举个生活上实际的例子,事实上硬体产业就大量使用依赖反转。
比如电脑需要将画面送到显示器,系统厂对design house 发出需求,此时系统厂相当于高阶模组,而design house 相当于低阶模组。
Design house 当然可以设计出IC 符合系统厂需求,但由于系统厂没有规定任何传输介面规格,只提出显示需求,因此design house 可以使用自己设计的专属传输介面,系统厂的电路板只要符合design house 的专属传输介面规格,就可以将电脑画面传送到显示器。
这样虽然可以达成需求,但有几个问题:
传输介面由design house 规定,只要design house 传输介面更改,系统厂的电路板就得跟着修改。
Design house 的专属传输介面,需要搭该公司的控制IC,因此系统厂还被绑死要使用该design house 的控制IC。
由于使用专属传输介面,因此系统厂无法使用替代料,只能乖乖使用该design house 的IC,没有议价空间,且备料时间也被绑死。
这就是典型的高阶模组依赖低阶模组,也就是系统厂被design house 绑死了。
所以系统厂很聪明,会联络各大系统厂一起制定传输介面规格,如VGA、HDMI、Display Port…等,如此deisgn house 就得乖乖的依照系统厂制定的传输介面规格来设计IC,这样系统厂就不再被单一design house 绑死,可以自行选择控制IC,还可以找替代料,增加议价空间,备料时间也更加弹性,这就是典型的低阶模组反过来依赖高阶模组所制定的规格,也就是依赖反转
Conclusion
Interface + 工厂模式无法达成可测试性的要求,因此才有了依赖注入与service container。
若很多method 都使用相同相依物件,可使用constructor injection,若只有单一method 使用的相依物件,建议改用method injection。
Method Injection必须搭配
App::call()
,除了自动依赖注入相依物件外,也可以自行传入其他参数。可测试性与物件导向是相通的,我们可以藉由程式的可测试性,当成是否为好程式的指标之一。