深入 Laravel 服务容器
简介
Laravel 的服务容器是一个安静的引擎,它将你的应用连接在一起。它处理依赖注入、自动连接以及使大型应用感觉简单的小决策。在本文中,我们将揭开它如何解析类的神秘面纱,如何以正确的方式注册绑定,如何根据上下文选择不同的实现,以及如何自信地测试和调试应用。
容器的核心是将“我需要 X”映射为“如何构建 X”。就是这样。一旦你得到了这个心理模型,其他一切都会到位。
概论
控制反转颠覆了通常的“自己动手 new 一切”的方式。不再由类构造他们自己的依赖关系,而是要求它们。依赖注入只是提供这些依赖关系的方式。Laravel 的服务容器是胶水:它知道如何构建对象,它们应该存活多久,以及在每种情况下分发哪个实现。
将容器视为智能工厂加上注册表。你说“我需要 X”,它会回答“给你 X,构建正确”。如果你给它明确的类型提示和合理的绑定,它会帮你完成繁重的工作。这里有一个小而现实的例子来锚定这个想法:
// PaymentGateway.php
interface PaymentGateway
{
public function charge(int $amountInCents, string $currency): string;
}
// HttpClient.php
interface HttpClient
{
public function post(string $url, array $payload): array;
}
// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
public function post(string $url, array $payload): array
{
// Execute HTTP request
}
}
// StripeGateway.php
final class StripeGateway implements PaymentGateway
{
public function __construct(
private HttpClient $http,
private string $apiKey, // primitive dependency
) {}
public function charge(int $amountInCents, string $currency): string
{
$response = $this->http->post(
'https://api.stripe.example/charge',
[
'amount' => $amountInCents,
'currency' => $currency,
'key' => $this->apiKey,
],
);
return $response['id'] ?? 'unknown';
}
}
// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(HttpClient::class, BasicHttpClient::class);
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->when(StripeGateway::class)
->needs('$apiKey')
->give(fn () => config('services.stripe.key'));
}
}
// CheckoutController.php
final class CheckoutController
{
public function __construct(private PaymentGateway $gateway) {}
public function __invoke(): string
{
// $gateway is auto‑resolved by the container
return $this->gateway->charge(2499, 'USD');
}
}当 Laravel 以一种简单的方式解析 CheckoutController 时,刚刚发生的事情是:
- 它看到一个控制器类,并要求容器构建它。
- 它使用构造函数的类型提示来解析依赖关系。
- 对于
PaymentGateway,它找到了与StripeGateway的绑定。 - 为了构建
StripeGateway,它递归解析HttpClient(绑定到BasicHttpClient)和$apiKey原语(通过上下文绑定提供)。 - 它返回了完全构建的控制器,一切准备就绪
你也可以手动触发相同的过程:
$gateway = app()->make(PaymentGateway::class);
$chargeId = $gateway->charge(1299, 'EUR');
// Override a specific argument at resolve time
$gatewayWithSandboxKey = app()->makeWith(
PaymentGateway::class,
['$apiKey' => 'sandbox-test-key']
);如果未被绑定,容器仍会尝试提供帮助。对于实体类(不是接口),它可以使用反射来自动连接嵌套依赖关系。对于接口和基元,它需要你通过绑定或上下文规则进行指引。当解析失败时,错误消息通常会告诉你哪个参数无法解析。如果你绑定了那个接口或提供了那个基元,一切都回到了正轨。
在应用中保持分辨率平滑的两个实用技巧:
保持构造函数显式和小。清晰的类型提示使容器在解析过程中防止意外。如果你需要运行时值(如API 键),推荐在闭包中的上下文绑定或配置调用,而不是硬编码。
请避免在应用中使用 new 代码。如果你手动实例化深度依赖图,你就会失去容器的魔力。依靠类型提示,让 Laravel 为你构建对象图。
当脑子中有了这个流程,即你提供类型提示,然后容器提供实例给你,你就得到了 Laravel中 IoC(控制反转)和 DI(依赖注入)背后的心智模型。
绑定入门
绑定告诉容器如何创建对象,以及对象存在多久。一旦你理解了这个概念,就会感觉容器的其他部分更加简单。
绑定的默认方法是 bind。每次解析时,它都会创建全新的实例。这很适合于轻量的、无状态服务或者其他不应该在跨调用时泄露状态的东西。
// UuidGenerator.php
interface UuidGenerator
{
public function generate(): string;
}
// RandomUuidGenerator.php
final class RandomUuidGenerator implements UuidGenerator
{
public function generate(): string
{
return (string) Str::uuid();
}
}
// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(UuidGenerator::class, RandomUuidGenerator::class);
}
}解析它两次,将会获得两个不同的对象:
$one = app()->make(UuidGenerator::class);
$two = app()->make(UuidGenerator::class);
// $one !== $two其中也有单例,即在应用生命周期中返回同一实例。典型如 HTTP 请求,也就是说“每次请求一个实例”。在长期存在的 worker(Laravel Octane、队列、守护进程)中,它可以跨多个请求/作业持久化,因此永远不要将请求/用户状态放在单例中。
// BasicHttpClient.php
final readonly class BasicHttpClient
{
public function __construct(public string $baseUrl) {}
}
// AppServiceProvider@register
$this->app->singleton(
BasicHttpClient::class,
fn () => new BasicHttpClient(config('services.api.base_url'))
);
// Usage
$first = app(BasicHttpClient::class);
$second = app(BasicHttpClient::class);
// $first === $second一个微妙的陷阱是:一旦构建了单例(Singleton),makeWith 参数就会被忽略。如果你需要每次调用的变化,不要使用单例进行抽象。
接下来是实例(instance),它允许你将已构造的对象交给容器。这非常适合外部客户端、预配置的 logger 或在测试中交换模拟。
// Build it however you like
$client = new BasicHttpClient('https://sandbox.example');
// Then hand that exact instance to the container
app()->instance(BasicHttpClient::class, $client);
// Anywhere later resolves the same object
$resolved = app(BasicHttpClient::class); // === $client同时,有一个 bindif 方法,它只有在键还未绑定时进行绑定。它适合于包及模块化代码:提供合理的默认值,但为应用提供一种覆盖它的方法。另外,Laravel 也使用同样的语义提供了 singletonIf 方法。
// In a reusable package's service provider:
$this->app->bindIf(PaymentGateway::class, StripeGateway::class);
// In the application (overrides the package default):
$this->app->bind(PaymentGateway::class, BraintreeGateway::class);你可以使用类名或闭包进行绑定。当你需要在解析时计算依赖关系或拉取配置时,闭包非常方便。容器将为你解析嵌套依赖关系。
// AppServiceProvider@register
$this->app->singleton(PaymentGateway::class, function ($app) {
$http = $app->make(BasicHttpClient::class);
$key = config('services.stripe.key');
return new StripeGateway($http, $key);
});如果一个服务是无状态的,并且构建起来有些开销(HTTP 客户端、序列化器、SDK),那么单例是一个很好的选择。如果服务携带特定于请求或特定于用户的数据,则首选 bind(或请求范围的绑定),这样就不会跨边界泄漏状态。当你必须在测试中提供一个精确的对象(如预配置的客户端)时,请使用实例(instance)。当你编写包时,默认为 bindIf 和/或 singletonIf,这样应用就可以覆盖而不会出现问题。
如果运行的是长期进程,请记住避免使用单例。它们将在该环境中持续存在,而这通常不是你想要的。
自动连线操作
自动连接指的是容器读取你的类型提示并帮你连线。如果它看到类或接口,它便知道如何创建。如果它遇到基元,则需要默认值或上下文规则。这样你就可以让 Laravel 为你构建对象图。
让我们从一个简单的构造函数注入开始,了解容器如何在无需我们亲自动手的情况下解析嵌套的依赖。
// ExchangeRateProvider.php
interface ExchangeRateProvider
{
public function rate(string $from, string $to): float;
}
// HttpClient.php
interface HttpClient
{
public function get(string $url, array $query = []): array;
}
// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
public function get(string $url, array $query = []): array
{
// Execute HTTP request
}
}
// ApiExchangeRates.php
final class ApiExchangeRates implements ExchangeRateProvider
{
public function __construct(
private HttpClient $http,
private string $apiKey,
) {}
public function rate(string $from, string $to): float
{
$data = $this->http->get('https://rates.example/api', [
'from' => $from,
'to' => $to,
'key' => $this->apiKey,
]);
return (float) ($data['rate'] ?? 1.0);
}
}
// InvoiceService.php
final class InvoiceService
{
public function __construct(private ExchangeRateProvider $rates) {}
public function totalIn(string $currency, int $cents): int
{
$rate = $this->rates->rate('USD', $currency);
return (int) round($cents * $rate);
}
}
// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(HttpClient::class, BasicHttpClient::class);
$this->app->bind(ExchangeRateProvider::class, ApiExchangeRates::class);
$this->app->when(ApiExchangeRates::class)
->needs('$apiKey')
->give(fn () => config('services.rates.key'));
}
}为解决任何涉及 InvoiceService 的问题,容器就会遍历树:它看到 InvoiceSeervice 需要一个 ExchangeRateProvider,该 Provider 映射到 ApiExchangeRates,ApiExchangeRate 需要一个 HttpClient 和一个 $apiKey。它构建了所有这些,并为你提供了一个即用型实例。
构造函数注入是日常模式,但方法注入是 Laravel 在控制器、路由、作业、监听器甚至普通可调用函数中大放异彩的地方。容器检查方法的参数,并按类型注入它所能注入的内容。
final class InvoiceController
{
public function __construct(private InvoiceService $invoices) {}
public function show(Request $request, LoggerInterface $logger): string
{
$total = $this->invoices->totalIn('EUR', 2999);
$logger->info('Invoice total computed', ['total_eur_cents' => $total]);
return (string) $total;
}
}Request 和 LoggerInterface 是方法注入。你无需自己绑定 LoggerInterface,因为 Laravel 已经将其别名链接到应用日志器。
路由闭包也是类似的处理。类型提示需要一个服务,然后生成,无需手动编写。
Route::get('/convert', function (InvoiceService $invoices) {
return $invoices->totalIn('GBP', 1999);
});使用 app()→call 可以注入任何的 callable,甚至按照名称重写标量,让容器解析其余部分。
final class ReportController
{
public function generate(InvoiceService $invoices, string $since = '2025-01-01'): array
{
return [
'from' => $since,
'sample' => $invoices->totalIn('EUR', 999),
];
}
}
$result = app()->call(
[ReportController::class, 'generate'],
['since' => '2025-06-01']
);可选依赖关系很容易。使用 nullable 类型或默认值,容器将在符合的时候注入,在不能的时候回退。
final class CachedRates implements ExchangeRateProvider
{
public function __construct(
private ApiExchangeRates $inner,
private ?CacheInterface $cache = null, // optional
) {}
public function rate(string $from, string $to): float
{
if (! $this->cache) {
return $this->inner->rate($from, $to);
}
$key = "rate:$from:$to";
$cached = $this->cache->get($key);
if ($cached !== null) {
return $cached;
}
$rate = $this->inner->rate($from, $to);
$this->cache->set($key, $rate, 3600);
return $rate;
}
}当你需要动态覆盖某些内容时,对构造函数使用 makeWith 方法,或者将一个命名参数数组传递给 app()->call 方法。如果需要,容器会填充类型提示,而由你提供其余部分。
// Override the primitive $apiKey just for this instance:
$rates = app()->makeWith(ApiExchangeRates::class, ['$apiKey' => 'sandbox-key']);
// Override a method’s scalar while still injecting services:
$result = app()->call(
[ReportController::class, 'generate'],
['since' => 'yesterday']
);几道护栏使自动链接保持可预测性。绑定接口和抽象类。容器可以反射具体的类,但它无法猜测你想要哪个接口实现。给基元一个默认值或上下文规则,否则解析失败(命名无法解析的参数时出现一个有用的错误)。记住,一旦构建了 Singleton,makeWith 方法就不会更改其构造函数参数。
上下文绑定
有时,一个接口需要多个不同的实现,具体取决于它的使用位置。这就是上下文绑定的作用。与其在代码库中添加 if/else 逻辑,不如教容器为每个消费者选择正确的实现,为每个类提供特定的原语,甚至在解析时计算依赖关系。
以 PaymentGateway 为例,我们再添加一个实现。我们使用 Stripe 作为其中一个工作流,而使用 Braintree 作为另一个流程。
// PaymentGateway.php
interface PaymentGateway
{
public function charge(int $amountInCents, string $currency): string;
}
// StripeGateway.php
final class StripeGateway implements PaymentGateway
{
public function __construct(
private HttpClient $http,
private string $apiKey,
) {}
public function charge(int $amountInCents, string $currency): string
{
$resp = $this->http->post('https://api.stripe.example/charge', [
'amount' => $amountInCents,
'currency' => $currency,
'key' => $this->apiKey,
]);
return $resp['id'] ?? 'unknown';
}
}
// BraintreeGateway.php
final class BraintreeGateway implements PaymentGateway
{
public function __construct(
private HttpClient $http,
private string $merchantId,
) {}
public function charge(int $amountInCents, string $currency): string
{
$resp = $this->http->post('https://api.braintree.example/sale', [
'amount' => $amountInCents,
'currency' => $currency,
'merchant_id' => $this->merchantId,
]);
return $resp['id'] ?? 'unknown';
}
}
// HttpClient.php
interface HttpClient
{
public function post(string $url, array $payload): array;
}
// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
public function post(string $url, array $payload): array
{
// Execute HTTP request
}
}现在,我们告诉容器为每个消费者注入哪个支付网关(gateway),以及如何提供他们的原语。
// CheckoutController.php
final class CheckoutController
{
public function __construct(private PaymentGateway $gateway) {}
public function __invoke(): string
{
return $this->gateway->charge(2499, 'USD');
}
}
// SubscriptionRenewal.php
final readonly class SubscriptionRenewal
{
public function __construct(public int $userId) {}
public function handle(PaymentGateway $gateway): void
{
$gateway->charge(999, 'USD');
}
}
// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// A shared HTTP client
$this->app->singleton(HttpClient::class, BasicHttpClient::class);
// Default binding
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// Different implementations per consumer
$this->app->when(CheckoutController::class)
->needs(PaymentGateway::class)
->give(StripeGateway::class);
$this->app->when(SubscriptionRenewal::class)
->needs(PaymentGateway::class)
->give(BraintreeGateway::class);
// Contextual primitives for each implementation
$this->app->when(StripeGateway::class)
->needs('$apiKey')
->give(fn () => (string) config('services.stripe.key'));
$this->app->when(BraintreeGateway::class)
->needs('$merchantId')
->give(fn () => (string) config('services.braintree.merchant_id'));
}
}有了这些规则,解析 CheckoutController 会注入 StripeGateway,而 SubscriptionRenewal 会获得 BraintreeGateway。每个网关都有自己的原语,没有任何条件逻辑泄漏到你的类中。
你还可以在解析时根据配置或运行时上下文计算依赖关系。传递给 give() 方法的闭包在构建目标时运行,因此它们非常适合动态选择实现。
// AppServiceProvider@register
$this->app->when(BillingFacade::class)
->needs(PaymentGateway::class)
->give(function ($app) {
$driver = config('billing.driver');
return match ($driver) {
'stripe' => $app->make(StripeGateway::class),
'braintree' => $app->make(BraintreeGateway::class),
default => $app->make(StripeGateway::class),
};
});一个小而强大的变体是方法注入中基元的上下文绑定。如果一个类需要一个标量值,而你不想对其进行硬编码,请将其与确切的参数名称(包括$ 符)上下文绑定。
// AppServiceProvider@register
$this->app->when(WebhookVerifier::class)
->needs('$secret')
->give(fn () => (string) config('services.webhooks.secret'));多个绑定
随着应用的增长,你通常需要“一堆实现相同契约(contract)的东西”。标签(Tag)和别名(Alias)使这变得容易。标签允许在一个名称下收集多个绑定,并将其作为一个组进行解析。别名为服务提供了友好的名称,因此你可以引用它们,而无需在任何地方重复长类字符串。
让我们构建一个现实世界中的小例子:一组应用于购物车的折扣规则。每个规则都是自己的类,但我们希望按顺序运行所有规则,而无需在服务中硬编码列表。
// DiscountRule.php
interface DiscountRule
{
public function apply(int $subtotalCents, int $userId): int;
public function name(): string;
}
// FirstPurchaseRule.php
final class FirstPurchaseRule implements DiscountRule
{
public function apply(int $subtotalCents, int $userId): int
{
// Check if it's first purchase
if (! $firstPurchase) {
return $subtotalCents;
}
return (int) round($subtotalCents * 0.9);
}
public function name(): string
{
return 'first_purchase';
}
}
// SeasonalRule.php
final readonly class SeasonalRule implements DiscountRule
{
public function __construct(private string $season = 'none') {}
public function apply(int $subtotalCents, int $userId): int
{
if ($this->season !== 'black_friday') {
return $subtotalCents;
}
return (int) round($subtotalCents * 0.8);
}
public function name(): string
{
return 'seasonal';
}
}现在,我们将标签化这些实现,并将它们作为一个可迭代数组注入。
// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(FirstPurchaseRule::class);
$this->app->bind(
SeasonalRule::class,
fn () => new SeasonalRule(config('shop.season'))
);
// Tag them so we can resolve "all discount rules" at once
$this->app->tag(
[FirstPurchaseRule::class, SeasonalRule::class],
'discount.rule'
);
// Feed the tagged collection into CartDiscountService automatically
// giveTagged() resolves all services with the given tag (in order)
$this->app->when(CartDiscountService::class)
->needs('$rules')
->giveTagged('discount.rule');
}
}
// CartDiscountService.php
final readonly class CartDiscountService
{
public function __construct(private array $rules) {}
public function total(int $subtotalCents, int $userId): int
{
$total = $subtotalCents;
foreach ($this->rules as $rule) {
$before = $total;
$total = $rule->apply($total, $userId);
logger()->debug('rule', [
'name' => $rule->name(),
'before' => $before,
'after' => $total
]);
}
return max(0, $total);
}
}你可以根据需要解析该集合,而无需上下文绑定:
$rules = app()->tagged('discount.rule');
$total = (new CartDiscountService($rules))->total(10000, 42);标签顺序很重要。传递到 tag()的数组设置解析顺序。如果你多次调用 tag(),Laravel 会保持注册顺序。这为你提供了可预测的规则执行,而无需在服务中硬编码类列表。
别名是一个独立但互补的特征。它们为绑定提供了备用名称,因此你可以通过一个简短的语义键来解析它。
// ReportExporter.php
interface ReportExporter
{
public function export(array $rows): string;
}
// CsvExporter.php
final class CsvExporter implements ReportExporter
{
public function export(array $rows): string
{
$out = fopen('php://temp', 'r+');
foreach ($rows as $row) {
fputcsv($out, $row);
}
rewind($out);
return stream_get_contents($out) ?: '';
}
}
// AppServiceProvider@register
$this->app->bind(ReportExporter::class, CsvExporter::class);
// Give it a friendly alias for quick resolution
$this->app->alias(ReportExporter::class, 'report.exporter');
// Usage
$exporter = app('report.exporter'); // same instance as app(ReportExporter::class)
$csv = $exporter->export([['id', 'name'], [1, 'Ada']]);别名只是同一抽象的另一个键。如果切换底层绑定,那么类及其别名现在都指向新的实现。这使得别名成为将“我想要的”与“它的构建方式”解耦的理想选择,尤其是在使用配置或环境切换实现时。
你可以将别名与标签结合起来创建命名集合。绑定一个解析到标签集的字符串键,并通过参数名在上下文中注入它。
// Bind a named collection
$this->app->bind('discount.rules', fn ($app) => $app->tagged('discount.rule'));
$this->app->alias('discount.rules', 'pricing.rules'); // extra alias
// Inject by parameter name using contextual binding
$this->app->when(CartController::class)
->needs('$rules')
->give(fn ($app) => $app->make('discount.rules'));
// ->give(fn ($app) => $app->make('pricing.rules')); - Would be the same需要记住的是,标签化你在集合中实际想要的具体类,标签化一个接口不会给你“所有的实现”,它只是指向该接口当前解析的任何内容。要小心传递给 tag() 的顺序,这样你的管道就是确定的。
服务提供者和生命周期
服务提供者是容器的大本营。它是你声明绑定、设置上下文规则、标签化服务和挂入应用启动过程的地方。了解注册和引导过程中发生的事情可以使你的容器代码可预测且易于维护。
Laravel 加载所有提供者,在每个提供者上注册调用,然后按顺序引导它们。在注册(register)过程中,你应该只定义绑定和配置。不要在此处解析服务或触摸请求特定状态。在启动(boot)过程中,使用解析服务、设置事件监听器、gate、morph 映射和路由或 Eloquent 宏(macro)是安全的,因为容器已经准备好了。
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
// Pure bindings
// Define how things are built
// Avoid resolving anything here
// shared, stateless -> a good singleton
$this->app->singleton(HttpClient::class, BasicHttpClient::class);
// default implementation
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// Contextual implementations
$this->app->when(CheckoutController::class)
->needs(PaymentGateway::class)
->give(StripeGateway::class);
$this->app->when(SubscriptionRenewal::class)
->needs(PaymentGateway::class)
->give(BraintreeGateway::class);
// Contextual primitives
$this->app->when(StripeGateway::class)
->needs('$apiKey')
->give(fn () => (string) config('services.stripe.key'));
$this->app->when(BraintreeGateway::class)
->needs('$merchantId')
->give(fn () => (string) config('services.braintree.merchant_id'));
// Scoped services: new instance per request without being a global singleton
$this->app->scoped(
RequestContext::class,
// This closure runs at resolve-time within the request scope
fn () => new RequestContext(request()->user()?->id)
);
// Aliases and tags keep larger compositions tidy
$this->app->alias(PaymentGateway::class, 'billing.gateway');
$this->app->bind(FirstPurchaseRule::class);
$this->app->bind(
SeasonalRule::class,
fn () => new SeasonalRule(config('shop.season'))
);
$this->app->tag(
[FirstPurchaseRule::class, SeasonalRule::class],
'discount.rule'
);
// Provide a named collection
$this->app->bind(
'discount.rules',
fn ($app) => $app->tagged('discount.rule')
);
}
public function boot(): void
{
// Safe to resolve services
// Register listeners/macros
// Use runtime info
// Example: feed tagged collection into a service via contextual primitive
$this->app->when(CartDiscountService::class)
->needs('$rules')
->give(fn ($app) => $app->make('discount.rules'));
// Other examples:
// Register an Event Listener
// Define an authorization gate
}
}将你的提供者添加到 bootstrap/proviers.php 中,这样 Laravel 就可以加载它,或者依赖于 vendor 包的包发现。当两个提供者绑定同一个抽象时,顺序很重要:后面的提供者可以覆盖前面的绑定。这对于想要覆盖包默认值的应用来说非常强大。
有时,你希望提供者仅在实际需要其服务之一时加载。可延迟的提供者通过声明他们提供的内容来让你做到这一点。Laravel 不会启动它们,直到其中一个抽象得到解决。
final class ReportsServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(ReportExporter::class, CsvExporter::class);
$this->app->alias(ReportExporter::class, 'report.exporter');
}
// Tells Laravel which bindings this provider is responsible for
public function provides(): array
{
return [ReportExporter::class, 'report.exporter'];
}
}保持注册无副作用。请勿在此处调用 request()、auth() 或 DB 或外部服务。若必须从运行时状态计算某些内容,请在解析时运行的闭包内执行,或将其移动到引导。
随着应用的增长,可以按域拆分提供商。示例:计费服务提供者(BillingServiceProvider)、搜索服务提供者(SearchServiceProvider)、报告服务提供者(ReportingServiceProvider)。这样,每个域都有自己的绑定、标签和钩子。这使你的容器配置易于发现、测试和维护。
扩展及钩子
有时,你希望更改服务的行为方式,而不编辑其类或触摸其使用的每个地方。容器为你提供了一些优雅的工具:继承(extend)以装饰现有的绑定,解析(resolving)和后解析(afterResolving)以在构建时运行逻辑,以及重新绑定/刷新以在依赖关系更改时保持长期单例同步。
让我们从一个经典的装饰器开始。我们将用日志记录来包装 PaymentGateway,不对控制器、Job 或原始网关进行更改。
// LoggingGateway.php
final class LoggingGateway implements PaymentGateway
{
public function __construct(
private PaymentGateway $inner,
private LoggerInterface $logger,
) {}
public function charge(int $amountInCents, string $currency): string
{
$this->logger->info('Charging', compact('amountInCents', 'currency'));
$id = $this->inner->charge($amountInCents, $currency);
$this->logger->info('Charge complete', ['id' => $id]);
return $id;
}
}
// AppServiceProvider.php
final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Base binding
$this->app->bind(PaymentGateway::class, StripeGateway::class);
// Decorate with logging
$this->app->extend(
PaymentGateway::class,
fn (PaymentGateway $core, $app) => new LoggingGateway($core, $app->make(LoggerInterface::class)),
);
}
}每个扩展都接收抽象的当前实例,并返回一个替代品。你注册扩展调用的顺序是包装的顺序。该示例生成日志(StripeGateway),而无需编辑 StripeGateway 或更改任何消费者代码。
你还可以在解析特定服务以设置默认值、附加检测或验证配置时进行监听。使用 resolving 进行安装,使用 afterResolving 进行应在所有解析回调后运行的安装后操作。
// BasicHttpClient.php
final class BasicHttpClient implements HttpClient
{
public function __construct(public string $baseUrl = '') {}
private array $headers = [];
public function setDefaultHeaders(array $headers): void
{
$this->headers = [...$this->headers, ...$headers];
}
public function post(string $url, array $payload): array
{
// Execute HTTP request
}
}
// AppServiceProvider@boot
$this->app->resolving(HttpClient::class, function (HttpClient $http, $app) {
if ($http instanceof BasicHttpClient) {
$http->setDefaultHeaders([
'X-App' => config('app.name'),
'X-Trace' => (string) Str::uuid(),
]);
}
});
// After full resolution
$this->app->afterResolving(PaymentGateway::class, function (PaymentGateway $gateway, $app) {
if ($gateway instanceof StripeGateway && empty(config('services.stripe.key'))) {
throw new LogicException('Stripe key is missing.');
}
});当 Singleton 依赖于另一个可能发生变化的服务时,你可以通过 refresh 或手动重新绑定(rebinding)挂钩来保持其新鲜度。这在测试中特别方便。
// ReportManager.php
final class ReportManager
{
public function __construct(private LoggerInterface $logger) {}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
public function export(array $rows): void
{
$this->logger->info('Exporting report', ['rows' => count($rows)]);
}
}
// AppServiceProvider@register
$this->app->singleton(
ReportManager::class,
fn ($app) => new ReportManager($app->make(LoggerInterface::class))
);
// Keep ReportManager's logger in sync if the logger binding changes
$this->app->refresh(LoggerInterface::class, ReportManager::class, 'setLogger');
// Alternatively, a manual hook with rebinding:
$this->app->rebinding(
LoggerInterface::class,
fn ($app, $newLogger) => $app->make(ReportManager::class)->setLogger($newLogger),
);现在,如果你在测试中这么做:
$fake = new \Monolog\Logger('fake');
$this->app->instance(LoggerInterface::class, $fake);
// The existing ReportManager singleton is updated automatically
app(ReportManager::class)->export([['id' => 1]]);Laravel 也允许你自定义特定可调用对象(callable)的方法注入。当你必须在容器注入服务的同时提供额外的参数时,bindMethod 是一种简洁的“解决方法”。
// SendInvoiceReport.php
final readonly class SendInvoiceReport
{
public function __construct(public int $userId) {}
public function handle(InvoiceService $invoices, string $since): void
{
$total = $invoices->totalIn('USD', 5000);
// generate a report since $since...
}
}
// AppServiceProvider@boot
$this->app->bindMethod(
[SendInvoiceReport::class, 'handle'],
fn ($job, $app) => $job->handle(
$app->make(InvoiceService::class),
since: now()->subMonth()->toDateString(),
);
);记住,在 Singleton 上,当抽象被解析时,继承者就会运行一次。因此,如果你使用长期 worker,请避免在扩展闭包中捕获每个请求的数据。当 Singleton 的依赖关系在运行时可能发生变化时,建议刷新或重新绑定,而不是手动重建 Singleton。
使用装饰器和解析挂钩,你可以从一个中心位置进化行为、添加可观察性并强制执行不变量。你的服务保持专注,容器在幕后悄悄地进行编排。
小结
服务容器将“我需要 X”映射为“如何构建 X”。有了这个心智模型,Laravel 的 IoC 中的其他一切都变得简单明了。你已经了解了如何绑定和选择生命周期,自动连接如何从类型提示中解析嵌套图,上下文绑定如何为每个消费者选择正确的实现,标签和别名如何保持大型组合的整洁,提供者如何塑造应用生命周期,以及装饰器和钩子如何演变行为。
今天就把它付诸实践,选择应用的一个区域,让容器为你完成艰巨的工作。用绑定替换新的,在以前有条件句的地方添加上下文规则,用标签对相关服务进行分组,并用基于扩展的装饰器包装核心服务。