[ Laravel 从学徒到工匠系列 ] 依赖注入篇

问题引出

整个 Laravel 框架的基石是一个功能强大的 IoC 容器(控制反转容器),如果你想真正从底层理解 Laravel 框架,就必须好好掌握它。不过,也不要被这个名头吓住,要知道 IoC 容器只不过是一种用于方便我们实现「依赖注入」这种软件设计模式的工具。而且要实现依赖注入并不一定非要通过 IoC 容器,只是使用 IoC 容器会更容易一点儿。

首先,来看看我们为何要使用依赖注入,或者说它能为我们的软件开发带来什么好处。考虑下列代码中的类和方法:

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 等。把互联网想象成另一个插进应用的线缆接口,显示器的大部分功能都是与线缆接口无关的、互相独立的。线缆接口只是一种传输机制,就像 HTTP 只是你程序的一种传输机制一样。所以,我们不想把传输机制(控制器)和业务逻辑混在一起。这样做的好处是很多其他的传输层比如 API 接口、移动 App 等都可以访问我们的业务逻辑。

因此,以后开发代码就别再将控制器和 Eloquent ORM 耦合在一起了,咱们来注入一个仓库类吧。

建立约定

首先,我们来定义一个接口,然后实现该接口。

interface UserRepositoryInterface
{
    public function all(): array;
}

class DbUserRepository implements UserRepositoryInterface
{
    public function all(): array
    {
        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 和应用程序之间的中介者来提供服务的(用户浏览应用的时候,路由/控制器作为中介将其引导到对应的服务)。当编写大型应用程序时,不要将你的领域逻辑混杂在控制器或路由中。

为了巩固你对这一理念的理解,我们来写一个测试案例。首先,我们要通过 Mockery 动态模拟一个仓库类实例,并将其绑定到应用的 IoC 容器里。然后,发起一个请求,通过断言判定控制器是否正确地调用了这个仓库类:

public function testUserTest()
{
    $repository = \Mockery::mock(UserRepositoryInterface::class);
    $repository->shouldReceive('all')->once()->andReturn(['学院君']);
    $this->instance(UserRepositoryInterface::class, $repository);
    $response = $this->get('/users');

    $response->assertStatus(200);
    $response->assertViewHas('users', ['学院君']);
}

运行结果如下:

更进一步

让我们考虑另一个例子来巩固理解。当付费会员订阅的某项服务周期快结束了,可能需要去提醒用户该续费了。我们会定义两个接口,或者叫契约(这些契约使我们在更改实际实现时更加灵活),一个是支付接口,一个是通知接口:

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);
    }
} 

通过将责任划分到不同类中,我们现在可以很容易将不同的通知实现类注入到账单类里面。比如,我们可以注入一个 SmsNotifier 或者 EmailNotifier。账单类只需遵守了自己的契约即可(实现了账单接口方法),不需要考虑如何实现通知功能。只要是遵守账单通知契约(接口)的类,账单类都可以用。这不仅让我们的开发维护更加灵活,而且还可以通过模拟BillingNotifierInterface 实现类来进行账单类的隔离测试,就像我们在上一个测试用例里做的那样。

面向接口开发:编写接口看上去好像要多写一些代码,但是磨刀不误砍柴工,对于大型项目而言实际上反而能提升你的开发效率,这就是软件设计领域经常说的面向接口开发,而不是面向对象开发。从测试角度来说,你不用实现任何接口,就能通过 Mockery 库模拟接口实现实例,进而测试整个后端逻辑!

前面说了这么多,回到我们的主题,我们要如何做依赖注入呢?很简单:

$biller = new StripeBiller(new SmsNotifier);

这就是一个依赖注入。账单类 StripeBiller 不用考虑如何通知用户,我们直接传递给它一个通知实现类 SmsNotifier 的实例。从代码角度来说,这可能只是个微小的变动,但这种设计模式的引入,绝对会使你的整个应用架构焕然一新:因为明确指定了类的职责边界,实现了不同层和服务之间的解耦,你的代码变得更加容易维护;此外,从面向接口编程的角度来看,代码变得更加容易测试,你只需通过模拟注入依赖即可,不同类之间的测试完全可以隔离开来。

那么 IoC 容器呢?难道依赖注入不需要 IoC 容器了么?当然不需要!在接下来的章节里面你会了解到,IoC 容器使得依赖注入更易于管理,但是容器本身不是依赖注入所必须的。只要遵循本章提出的原则,你可以在任何项目里面实现依赖注入,而不必管该项目是否提供了容器。

太像 Java?

在 PHP 中使用接口的一个常见批评就是代码看上去太像 Java —— 意思是让代码显得太冗长,你必须定义接口然后实现它,要多按好多次键盘。

对于小而简单的应用来说,以上说法也对,在这种规模的应用中,接口通常是不必要的。将代码耦合到那些你认为不会改变的地方也是可以的,比如都放在控制器方法中。在你确定以后不会发生改变的地方就没有必要使用接口了,比如一次性的任务,或者一些原型或演示项目,毕竟这种灵活性会带来更多的代码量。

架构师可能会说「不会改变的地方是不存在的」。不过话说回来,有时候的确不会改。而且小型的应用也不需要架构师,架构师们都是为大型应用服务的。

在大型应用中,接口是很有帮助的。和提升的代码灵活性、可测试性相比,多敲几下键盘花费的时间就显得微不足道了。当你在不同的接口实现类之间切换如飞的时候,你的经理一定会被你的神速惊到。此外,你也能够写出更能适应变化的代码。

总而言之,记住本书提倡的「简单」架构。如果你在写小型应用的时候不想遵守接口原则,回退到原始模式,别觉得不好意思,那没什么不对。不管如何,我都希望你们牢记「Code Happy」,快乐撸码,这应该是我们的初心。如果你真的不喜欢写接口,那就怎么舒服怎么来吧,做人嘛,开心最重要,不过还是希望你闲暇的时候可以好好评估下这件事。

学院君 has written 1343 articles

Laravel学院院长,终身学习者

积分:187397 等级:P12 职业:手艺人 城市:杭州

17 条回复

  1. lukez lukez says:

    正如最后所言,适用于大型项目,小项目搞成这样就显得有些太复杂了,而且最主要的是意义不大

  2. 掘金者 掘金者 says:

    解耦可能理解,PHP 本身就是 面向对象编程,过度封装 ,然后面向接口编程,这样的话干嘛还选择PHP去开发项目。真 的 很迷。

  3. 613 613 says:

    在userController中 public function __construct(UserRepositoryInterface $users)

    这里的UserRepositoryInterface应该是 DbUserRepository吧?

    这个UserRepositoryInterface 是接口, 怎么能操作? 我这代码 运行就报错呀? 改成DbUserRepository 这个 就好了

  4. Daniel_shi Daniel_shi says:

    前段时间看了一遍不太懂。敲了几天代码后又来了,感觉不太一样了,好像有点想法。果然还是要手敲才有感觉

  5. marun marun says:

    在userController中 public function __construct(UserRepositoryInterface $users)

    这里的UserRepositoryInterface应该是 DbUserRepository吧? 还是说我理解的有问题啊.

    顺便问下,这个function all() : array;

    这里的: array的意思是对方法的返回值做了类型约束吗.?

登录后才能进行评论,立即登录?