编程

Laravel 服务容器介绍

841 2023-12-30 00:47:00

Laravel 服务容器(Service Container)可能有点神秘,尤其是如果你不经常使用它的话。在这篇文章中,我将解释如何使用它,以及应该避免哪些常见错误。 

Laravel 服务容器的工作方式优美而强大,允许您轻松地注入依赖项,解析定制的服务,用假的服务替换实时服务进行测试,甚至可以创建在整个应用中持久化属性的单例(Singleton)。如果你不知道这些东西是什么意思,别担心,我会带你了解。

尽管这个话题可能有点令人生畏,但了解它是值得的,你会因此成为一名更好的开发人员。

服务容器是什么?

服务容器本质上是应用中所有注册服务的映射。典型的服务犹如 ClientRepositoryStatisticsService。某种在应用的许多位置中使用的类。

别担心,你不必设置或注册应用中使用的每一个服务类。Laravel 提供了一种使用服务容器来解决所需服务或注入依赖项的零配置方式,而且,更重要的是,你不必编写任何额外的代码来获得更干净的代码和组合的好处。

我可以一直谈论它有多棒,但最好还是看看它的实际效果。本文中,我将运行一些示例代码片段,你可以通过使用服务容器轻松改进这些代码片段。让我们开始吧!

服务类示例

下面是一个非常简单的 PHP 类,我们将在本文中使用它。SubscriptionService 是一个处理用户订阅的自定义和假设类。比如添加新的信用卡、收取订阅费或取消订阅用户。这里的实现并不重要,所以我没有包括它们,以便使代码更易于阅读并专注于当下的主题。现在,让我们来看看我们可以使用的几种不同方法——获取这个类并在 Laravel 控制器中使用它。

<?php

namespace App\Services;

use App\Models\User;
use App\Models\CreditCard;

class SubscriptionService
{
    public function addCreditCard(User $user, CreditCard $card)
    {
        // ...
    }

    public function removeCreditCard(User $user, CreditCard $card)
    {
        // ...
    }

    public function getCreditCards(User $user)
    {
        // ...
    }

    public function setupSubscription(User $user)
    {
        // ...
    }

    public function isSubscribed(User $user)
    {
        // ...
    }

    public function chargeSubscription(User $user)
    {
        // ...
    }

    public function removeSubscription(User $user)
    {
        // ...
    }
}

在控制器中使用服务

经典方式

如果你是 Laravel 或 Symfony 框架的新手,你可能觉得在控制器中使用服务的最佳方式是创建一个新实例。我们需要知道这个订阅与哪个用户相关,因此也会获取当前登录的用户。然后我们只返回视图,并从 SubscriptionService 向其传递一些数据。您可以在这里看到SubscriptionService 是这个控制器方法中的一个依赖项。如果没有它,我们将无法获得必要的数据。

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;

class UserBillingController extends Controller
{
    public function index()
    {
        $user = request()->user();
        $subscriptionService = new SubscriptionService();

        return view('billing.index', [
            'creditCards' => $subscriptionService->getCreditCards($user),
            'isSubscribed' => $subscriptionService->isSubscribed($user),
        ]);
    }
}

有两种更好的方法可以将这种依赖关系引入到控制器中,它们具有更多的配置能力和链式依赖注入,我稍后将讨论。但现在,让我们来看看一些例子。

从服务容器中解析服务

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;

class UserBillingController extends Controller
{
    public function index()
    {
        $user = request()->user();
        $subscriptionService = resolve(SubscriptionService::class);

        return view('billing.index', [
            'creditCards' => $subscriptionService->getCreditCards($user),
            'isSubscribed' => $subscriptionService->isSubscribed($user),
        ]);
    }
}

此处,我们做的只是使用 resolve() 函数调用来替换 new 关键字,将类名传入到该函数中。你也可以将 "App\Services\SubscriptionService" 字符串传给 resolve() 函数,这样就不用使用 use … 语句。

它是如何工作的呢?resolve() 是一个助手函数,它转到服务容器(所有的服务都在那里注册了),并询问服务容器是否有所请求类的绑定。如果有,它将返回绑定实例,或者根据配置的选项从头开始构建它。在我们的案例中,我们根本没有进行任何配置或绑定!这没关系,因为如果服务容器找不到请求的类绑定,它只会创建该类的一个新实例并将其返回给您。

好吧,这与使用 new 关键字自己创建新实例没有什么不同!这是真的,一旦服务变得更加复杂并有自己的依赖关系需要解决,那么稍后解决这样的实例的好处就会变得显而易见。现在,让我们检查使用服务容器的另一种首选方式。

依赖注入

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;

class UserBillingController extends Controller
{
    public function index(SubscriptionService $subscriptionService)
    {
        $user = request()->user();

        return view('billing.index', [
            'creditCards' => $subscriptionService->getCreditCards($user),
            'isSubscribed' => $subscriptionService->isSubscribed($user),
        ]);
    }
}

现在,我们已将 SubscriptionService 移到功能参数列表中。此控制器方法索引现在有一个参数,并且需要 SubscriptionService 类的实例。但它是怎么做到的呢?我们并不自己调用这个 “index” 方法,是吗?这是 Laravel 路由通过将请求的 URL 路径与相应的控制器及其方法匹配来自动完成的。你在路由文件中设置了这些,记得吗?那么,Laravel 怎么知道这个函数需要一个变量呢?

当 Laravel 将请求与控制器方法匹配时,它首先会检查它,这要归功于 PHP 的反射功能。然后它知道控制器方法所期望的参数,然后继续进行解析(还记得 resolve() 方法吗?)它们来自服务容器。一旦路由拥有所有必需的实例,它就会调用控制器方法,将实例作为参数传递下去。这就是所谓的依赖注入,Laravel 在这方面做得很好。

但有什么区别?当然,这只是为了获得这个类实例而进行额外的函数调用并来回访问服务容器,对吧?它甚至有效吗?它有什么比自己创建一个新的服务实例更好的呢?

首先,是的,它是高效的!如今的 PHP 如此之快,以至于你根本不会注意到其中的区别。此外,由于 PSR-4 自动加载器,类是延迟加载的,这意味着即使你的应用程序中有数百个不同的服务,服务容器也不会加载其中任何一个,直到你真正需要解析或配置它们;

其次,这有助于清洁代码并分离关注点。通过将服务作为函数参数注入,我们将其视为一个依赖项,然后让函数的主体实际执行所需的操作,而不是设置所有必要的类实例。

最后,这将允许你轻松设置依赖链条注入。这就是服务容器真正开始发光的地方,所以让我来解释一下!

依赖注入链条

现在,让我们扩展 SubscriptionService 的需求(就像在现实生活中经常发生的那样)。该公司已经决定,我们不能将信用卡数据保存在服务器上,因此我们需要使用不同的外部服务。也许我们已经决定使用 Stripe 这样的服务来存储用户的信用卡,这样我们就不必处理存储此类数据所带来的财务法规。在这种情况下,SubscriptionService 需要访问该外部服务才能执行这些任务。它需要自己的依赖。

因此我们引入了一个 StripeService:

<?php

namespace App\Services\External;

use App\Models\User;
use App\Models\CreditCard;

class StripeService
{
    public function addCreditCard(User $user, CreditCard $card)
    {
        // ...
    }

    public function removeCreditCard(User $user, CreditCard $card)
    {
        // ...
    }

    public function getCreditCards(User $user)
    {
        // ...
    }
}

这个类与 SubscriptionService 非常相似,不过其实现是从 Stripe 而非我们自己的数据库访问信用卡。

以下是具有 StripeService 依赖项的新版 SubscriptionService

<?php

namespace App\Services;

use App\Models\User;
use App\Models\CreditCard;
use App\Services\External\StripeService;

class SubscriptionService
{
    /** @var StripeService */
    protected $stripeService;

    public function __construct(StripeService $stripeService)
    {
        $this->stripeService = $stripeService;
    }

    public function addCreditCard(User $user, CreditCard $card)
    {
        $this->stripeService->addCreditCard($user, $card);

        // ...
    }

    // ...
}

现在,SubscriptionService 需要一个 StripeService 实例,用以从 Stripe 访问信用卡。很好!那么控制器呢?让我们先看看传统的方式。

传统方法

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;
use App\Services\External\StripeService;

class UserBillingController extends Controller
{
    public function index()
    {
        $user = request()->user();
        $stripeService = new StripeService;
        $subscriptionService = new SubscriptionService($stripeService);

        return view('billing.index', [
            'creditCards' => $subscriptionService->getCreditCards($user),
            'isSubscribed' => $subscriptionService->isSubscribed($user),
        ]);
    }
}

你可以看到,控制器随着实例数量的增加而变大,用以检索有关用户的一些数据。当然,这只是目前的两个新实例,但随着你使用更多依赖扩展服务,它可以很容易地变大。您将来可能会有其他依赖。例如,为一些选择使用 PayPal 帐户订阅的用户提供的 PayPal 服务。

现在,我们来看看能否使用服务容器清洁一些代码。

从服务容器中解析

我喜欢依赖注入方法,即通过将服务作为类型提示参数添加到控制器方法中来注入服务,所以让我们先将 SubscriptionService 移到参数列表中。

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;

class UserBillingController extends Controller
{
    public function index(SubscriptionService $subscriptionService)
    {
        $user = request()->user();

        return view('billing.index', [
            'creditCards' => $subscriptionService->getCreditCards($user),
            'isSubscribed' => $subscriptionService->isSubscribed($user),
        ]);
    }
}

我们可以删除实例化 StripeService 的代码,因为我们在这里没有实例化 SubscriptionService。但是等一下… SubscriptionService 构造函数需要一个 StripeService 实例,那么它将如何工作呢?

服务容器会处理它!而且不需要任何配置就可以实现。

以下是服务容器如何为您解析此服务以及它如何处理依赖链的分解。

  • 首先,得益于 PHP 反射功能,它可以看到控制器的 index() 方法需要一个参数,并且该参数的类型为 SubscriptionService
  • 然后,它尝试从服务容器解析此服务。因为我们没有做任何特殊的配置来向服务容器注册此服务,所以它只是尝试创建此服务的新实例。
  • 在创建类的新实例之前,服务容器会检查该类的 __constructor() 方法。重温一下,SubscriptionService 类的构造函数如下所示:
class SubscriptionService
{
    /** @var StripeService */
    protected $stripeService;

    public function __construct(StripeService $stripeService)
    {
        $this->stripeService = $stripeService;
    }

    // ...
}

服务容器发现构造器需要一个参数,并且它也有类型提示。这能帮助服务容器识别此处需要哪个类实例。

  • 现在服务容器知道了 SubscriptionService 需要一个 StripeService 的实例,它也会继续解析 StripeService!再一次,因为我们没有做任何特殊的配置来使用服务容器设置 StripeService,所以它只是创建一个新的实例并返回它。
  • 现在我们有了 StripeService 的实例,服务容器将其传递给 SubscriptionService__constructor() 方法,从而完成服务的初始化。

服务容器有了一个 SubscriptionService 工作实例,因此已经完成了控制器方法所要求的对它的解析。Laravel 路由现在终于可以调用控制器方法,将所需的 SubscriptionService 实例作为参数传递。

所有这些都在幕后自动完成。因此,实质上你可以将代码替换成这样:

$firstDependency = new FirstDependency;
$secondDependency = new SecondDependency;
$service = new Service($firstDependency, $secondDependency);

更简单的调用方式则如下:

$service = resolve(Service::class);

服务容器将自动解析该类的任何依赖,并将它们注入构造函数。这样,您就可以减少设置该服务的时间,并将更多时间花在应用的其他更重要方面。

注意事项

好吧,所有这些看起来都像是很多所谓的“Laravel 魔法”在幕后发生。我为你提供了一个关于幕后发生的事情的抽象、顶层视角,尽管你不需要了解服务容器的内部工作原理就能使用它,但确实需要了解一些关于服务容器的陷阱或规则,以帮助你在未来节省数小时的调试时间。

了解依赖注入实际的工作原理

尽管在哪里可以调用 resolve()app() 方法来解析具有所有依赖的类实例没有限制,但将依赖作为函数参数注入可能会有一些限制。简而言之,如果函数参数是由 Laravel 框架本身调用的方法,而不是由您调用的,那么您几乎可以保证它会自动解析。让我解释一下。

下面是一个 Laravel Job 类的例子。一个假设的类,用于使用 SubscriptionService 类(它有自己的依赖,还记得吗?)将给定的信用卡添加到用户的订阅中

<?php

namespace App\Jobs;

use App\Models\User;
use App\Models\CreditCard;
use Illuminate\Bus\Queueable;
use App\Services\SubscriptionService;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class AddCreditCardToSubscription implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /** @var User */
    public $user;

    /** @var CreditCard */
    public $creditCard;

    /** @var SubscriptionService */
    public $subscriptionService;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(User $user, CreditCard $creditCard)
    {
        $this->user = $user;
        $this->creditCard = $creditCard;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        $this->subscriptionService->addCreditCard($this->user, $this->creditCard);
        
        // ...
    }
}

现在您可以看到,这个 job 的构造函数需要两个参数——一个用户 user,以及添加到该用户订阅中的信用卡。请注意,handle() 方法要求我们拥有 SubscriptionService 的一个实例,我们使用它来处理所有与信用卡和订阅相关的活动。那么,我们如何通过使用依赖注入来正确地做到这一点呢?

你的第一直觉可能会告诉你把它注入 __constructor() 参数中,对吧?毕竟,服务容器会自动解析构造函数的依赖关系,对吧?并非总是如此。只有当服务容器正在解析类本身时,服务容器才会解析构造函数参数。换言之,如果我们要这样建立工作:

$job = resolve(App\Jobs\AddCreditCardToSubscription::class);

// 错误的方式 —— 我们如何传递用户及信用卡呢?

但是上例不是创建并派发 Laravel Job 的方式。我们来看看正确的方式:

use App\Jobs\AddCreditCardToSubscription;

// option 1 - 使用 dispatch() 辅助方法
dispatch(new AddCreditCardToSubscription($user, $creditCard));

// option 2
AddCreditCardToSubscription::dispatch($user, $creditCard);

这两种方法都很好,而且效果相同——只是看起来不同,这取决于你的偏好。不过,请记住,第二个选项要求您的 Job 类使用 Illuminate\Foundation\Bus\Dispatchable trait。

错误的方式

我们可以自己注入 SubscriptionService 实例码?当然可以,不过这就没用到 Laravel 的魔法了:

/**
 * Create a new job instance.
 *
 * @return void
 */
public function __construct(User $user, CreditCard $creditCard, SubscriptionService $subscriptionService)
{
    $this->user = $user;
    $this->creditCard = $creditCard;
    $this->subscriptionService = $subscriptionService;
}

因为我们没有要求 Laravel 解析一个新的 job 类,并且自己创建了 job 实例——我们就需要自己提供构造函数的参数。这意味着,我们需要一个 SubscriptionService 实例:

use App\Services\SubscriptionService;
use App\Jobs\AddCreditCardToSubscription;

// ...

$subscriptionService = resolve(SubscriptionService::class);
dispatch(new AddCreditCardToSubscription($user, $creditCard, $subscriptionService));

这不是最优雅的方式。我们能够改进吗?

正确的方式

还记得我说过 Laravel 将解析 Laravel 框架本身调用的函数的依赖吗?在 job 类中,有一个名为 handle() 的特定方法,当 Laravel 的 Worker 开始处理 job 时会调用它。我们可以利用这一事实,通过在此处添加函数参数来利用依赖注入:

<?php

namespace App\Jobs;

use App\Models\User;
use App\Models\CreditCard;
use Illuminate\Bus\Queueable;
use App\Services\SubscriptionService;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class AddCreditCardToSubscription implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /** @var User */
    public $user;

    /** @var CreditCard */
    public $creditCard;

    /**
     * Create a new job instance.
     *
     * @return void
     */
    public function __construct(User $user, CreditCard $creditCard)
    {
        $this->user = $user;
        $this->creditCard = $creditCard;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle(SubscriptionService $subscriptionService)
    {
        $subscriptionService->addCreditCard($this->user, $this->creditCard);
        
        // ...
    }
}

由于我们现在在 handle() 中有了 SubscriptionService 实例作为参数,我们不再需要构造函数中使用它,也不再需要在类属性 subscriptionService 中保存该实例了。

没有了额外的属性代码更加整洁了,同时也无需由我们自己提供服务实例。

现在,我们可以这样分发 Job:

use App\Jobs\AddCreditCardToSubscription;

// ...

dispatch(new AddCreditCardToSubscription($user, $creditCard));

服务容器会负责将正确的依赖项注入到该 Job 的 handle() 方法中。

那么,我还可以在哪些地方使用依赖注入呢?

以下 Laravel 中的一些你可以通过提供类型提示的函数参数来利用依赖注入的地方:

  • 控制器构造函数
  • 控制器操作/路由方法
  • 事件监听器
  • 队列 handler
  • View Composer 构造函数
  • 表单 Request 构造函数
  • 命令 handle
  • 命令闭包

对于其他,您可以很容易地使用 resolve()app() helper方法来解析给定类的完整实例,包括它的所有依赖。

它无法解析什么?

如果在依赖链中的任何位置,类需要一个非类型提示类的参数,或者该参数是标量类型(整数、字符串、布尔值、数组等),则服务容器将无法解析这些参数,并将引发 Illuminate\Contracts\Container\BindingResolutionException 异常,该异常为您提供了无法解析的类及其参数的详细信息。

最常见的修复方法是通过为这些标量类型提供默认值来使其可选:

class SubscriptionService
{
    /** @var StripeService */
    protected $stripeService;

    /** @var bool */
    protected $shouldNotifyUser;

    public function __construct(StripeService $stripeService, bool $shouldNotifyUser = true)
    {
        $this->stripeService = $stripeService;
    }

    // ...
}

注意路由参数绑定

您可能还看到了将路由参数绑定到相应 Eloquent 模型实例的“依赖注入”形式。例如:

<?php

// routes/web.php

Route::get('client/{client}', 'ClientsController@show');

此处需要注意的一点是,它的参数名。此处 URL 定义的参数名必须和控制器方法中的参数名相匹配。如果不匹配,模型绑定会失败,您将直接从服务容器中收到该模型的一个空实例。

<?php

namespace App\Http\Controllers;

class ClientsController
{
    public function show(Client $differentParamName)
    {
        // ...
    }
}

上述示例将不会生效,因为控制器方法参数名与路由中定义的参数名不同。参数名必须改为 $client 才能让模型绑定方案生效。

关于 Facade

Facade 是将 contract 与具体实现联系起来的另一种很好的方式。它带来了在测试时有用的附加功能,并允许在不更改 contract 使用的情况下轻松交换底层实现类。

例如,如果您有两个不同的驱动程序来处理用户的订阅,例如 StripeSubscriptionServiceArraySubscriptionService(用于测试目的),则您可能有一个 SubscriptionService facade,它在需要时解析成特定的绑定:

use Illuminate\Support\Facades\Facade;

class SubscriptionService extends Facade
{
    protected static function getFacadeAccessor()
    {
        return 'subscription-service';

        // Alternatively, you can just return the implementation
        // class name, but you'll need to use that when switching
        // the bindings.
        return StripeSubscriptionService::class;
    }
}

那么,你只需要在 AppServiceProvider 中像这样注册 subscription-service 绑定:

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->bind('subscription-service', StripeSubscriptionService::class);
    }
}

设置好后,你可以在实现类中轻松使用 facade 调用任何实例方法:

$isSubscribed = SubscriptionService::isSubscribed($user);

Laravel Facade 有一个注意事项,那就是解析的实例会被缓存并在未来的调用中返回。

总结

我知道这是一个很大的收获,但希望你学到了新的东西!到目前为止,我们已经讨论过使用零配置的服务容器——这是一个很好的方法,可以开始利用一些 “Laravel 的魔力” 让你的生活更轻松。通常情况下,你不需要配置任何内容。

但是,如果你的应用程序更复杂(而且在某个时候会更复杂!),并且你希望了解更多信息,请期待未来关于服务容器高级使用的文章。