深入探讨依赖注入
依赖反转原则是SOLID 中最难理解的原则,而依赖注入则是单元测试的基石,本文将从测试角度探讨依赖反转与依赖注入,并将Laravel 的service container、constructor injection 与method injection 应用在实务上。 VersionPHP 7.0.0 Laravel 5.2.29 实际案例假设目前有3家货运公司,每家公司的计费方式不同,使用者可以动态选择不同的货运公司,将一步步的重构成依赖注入方式 传统写法 传统我们会使用 BlackCat.phpapp/Services/BlackCat.php namespace App Services ; class BlackCat { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 100 + $weight * 10 ; } } 黑猫的计费方式。 Hsinchu.phpapp/Services/Hsinchu.php namespace App Services ; class Hsinchu { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 80 + $weight * 15 ; } } 新竹货运的计费方式。 PostOffice.phpapp/Services/PostOffice.php namespace App Services ; class PostOffice { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 70 + $weight * 20 ; } } 邮局的计费方式。 ShippingService.phpapp/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' ); } } }
使用者可自行由 使用 ShippingService.phpapp/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' ); } } 将 使用Interface目前的写法,执行上没有什么问题,若以TDD开发,我们将得到第一个绿灯。 我们将继续重构成更好的程式。 目前我们是实际去 物件导向就是希望达到高内聚,低耦合的设计。所谓的低耦合,就是希望能减少相依于外部的class的数量。
简单的说,有2 种写法会产生相依 :
由于PHP 不用编译,所以可能较无法体会相依的严重性,但若是需要编译的程式语言,若你相依的class 的property 或method 改变,可能导致你的程式无法编译成功,也就是你必须配合相依的class 做相对应的修改才能通过编译,因此我们希望降低对其他class 的相依程度与数量。 GoF四人帮在设计模式曾说: Program to an Interface,not an Implementation。也就是程式应该只相依于interface,而不是相依于实际class,目的就是要藉由interface,降低对于实际class的相依程度。 若我们能将 若以编译的角度,由于 LogisticsInterface.phpapp/Services/LogisticsInterface.php namespace App Services ; interface LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) ; } 从 BlackCat.phpapp/Services/BlackCat.php namespace App Services ; class BlackCat implements LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 100 * $weight * 10 ; } }
Hsinchu.phpapp/Services/Hsinchu.php namespace App Services ; class Hsinchu implements LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 80 * $weight * 15 ; } }
PostOffice.phpapp/Services/PostOffice.php namespace App Services ; class PostOffice implements LogisticsInterface { /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return 70 * $weight * 20 ; } }
ShippingService.phpapp/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' ); } } }
工厂模式虽然已经将
比较好的方式是将 LogisticsFactory.phpapp/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模式使用了
ShippingService.phpapp/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 ); } } 将来有新的货运公司,也只要统一修改
程式的可测试性符合spec 的程式,并不代表是好的程式,一个好的程式还要符合5 个要求 :
根据单元测试的定义:
若要对 简单的说,interface + 工厂模式,仍然无法达到可测试性的要求,我们必须继续重构。 依赖反转为了可测试性,单元测试必须可决定待测物件的相依物件,如此才可由单元测试将待测物件的相依物件加以抽换隔离。 换句话说,我们不能让待测物件直接相依其他class,而应该由单元测试订出interface,让待测物件仅能相依于interface,而实际相依的物件可由单元测试来决定,如此我们才能对相依物件加以抽换隔离。 这也就是所谓的依赖反转原则 :
好像越讲越抽象XDD。 其中相依与依赖是相同的,只是翻译用字的问题。
高阶与低阶是相对的。 简单的说:
若以本例而言 :
在没有使用interface 前 :
使用了interface 之后 :
更简单的说,依赖反转就是要你使用interface 来写程式,而不要直接相依于class。 我们之前已经重构出 依赖注入有了依赖反转还不足以达成可测试性,依赖反转只确保了待测物件的相依物件相依于interface。 既然相依物件相依于interface,若单元测试可以产生该interface 的物件,并加以注入,就可以将相依物件加以抽换隔离,这就是依赖注入。 Constructor InjectionShippingService.phpapp/Services/ShippingService.php namespace AppServices; 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; } 原本相依的 原本使用interface +工厂模式,实质相依数为2,改用constructor injection之后,连 17行 /** * @param int $weight * @return int */ public function calculateFee ( $weight ) { return $this ->logistics->calculateFee( $weight ); } 将原本的 Service Container我们目前已经有了依赖注入,对于可测试性只剩下最后一哩路,若我们能将mock 出的假物件,透过依赖注入取代掉原来的相依物件,就能将相依物件加以抽换隔离,达成隔离测试的要求,service container 就是要帮我们将相依物件抽换隔离。 Laravel 4称为IoC container,Laravel 5称为service container。 单元测试ShippingService.phptests/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 ); 因为单元测试,我们只想测试
20行 App::instance(LogisticsInterface::class,$mock ); mock物件已经建立好,接着要告诉service container,当constructor injection的type hint遇到
22行 $target = App::make(ShippingService::class); 当mock与service container都准备好时,接着要建立待测物件准备测试,这里不能再使用new建立物件,而必须使用service container提供的 整合测试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,所以必须先使用 $target = App::make(ShippingService::class); 当 Method InjectionLaravel 4 提出了constructor injection 实现了依赖注入,而Laravel 5 更进一步提出了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注入,而是在 ShippingService.phpnamespace 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个参数为我们要注入的 单元测试ShippingService.phpuse 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 相同,就不再解释。 关键在于 之前我们使用constructor injection,就要搭配 现在我们使用method injection,就要搭配 第1个参数要传的字串,是class完整名称,加上 第2 个参数要传的是阵列,也就是我们自己要传的参数,其中参数名称为key,参数值为value。 整合测试ShippingService.phppublic 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 ]); 关键一样是使用
当初学习method injection时,我也非常兴奋,总算可以解决Laravel 4的constructor参数爆炸的问题,但发现只能用在controller,但无法用在自己的presenter、service或repository,一直学习到App::call ()时,问题才迎刃而解。 因为Laravel内部使用 再谈可测试性本文从头到尾,都是以可测试性的角度去谈依赖注入,而我个人也的确是在写单元测试之后,才领悟依赖反转与依赖注入的重要性。 若是不写测试,是否就不需要依赖反转与依赖注入呢? 之前曾经提到 :
根据之前的经验,我们可以发现待测物件的相依物件都是在测试的App::bind()所决定。 之前有提到所谓的高阶模组与低阶模组是相对的,单元测试相对于service,单元测试是高阶模组,而service 是低阶模组。 对照于实际状况,controller 相对??于service,controller是高阶模组,而service 是低阶模组。 我们可以在单元测试以 既然我们可以由controller去决定,去注入service的相依物件,我们就不再被底层绑死,不再依赖底层service,而是由低阶模组去依赖高阶模组所制定的interface,再由controller的 也就是说,若高层模组可以决定低阶模组的相依物件,那整个设计的弹性与扩充性会非常好,因为需求都来自于人,而人所面对的是高阶模组,而高阶模组可以透过依赖注入去决定低阶模组的相依物件,而不是被低阶模组绑死,可弹性地依照需求而改变。
生活中的依赖反转举个生活上实际的例子,事实上硬体产业就大量使用依赖反转。 比如电脑需要将画面送到显示器,系统厂对design house 发出需求,此时系统厂相当于高阶模组,而design house 相当于低阶模组。 Design house 当然可以设计出IC 符合系统厂需求,但由于系统厂没有规定任何传输介面规格,只提出显示需求,因此design house 可以使用自己设计的专属传输介面,系统厂的电路板只要符合design house 的专属传输介面规格,就可以将电脑画面传送到显示器。 这样虽然可以达成需求,但有几个问题:
所以系统厂很聪明,会联络各大系统厂一起制定传输介面规格,如VGA、HDMI、Display Port…等,如此deisgn house 就得乖乖的依照系统厂制定的传输介面规格来设计IC,这样系统厂就不再被单一design house 绑死,可以自行选择控制IC,还可以找替代料,增加议价空间,备料时间也更加弹性,这就是典型的低阶模组反过来依赖高阶模组所制定的规格,也就是依赖反转 Conclusion
Sample Code完整的范例 本文翻译转自:http://oomusou.io/tdd/tdd-di/#Method_Injection (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |