پرش به مستندات
PHP

Laravel و Symfony

یک کلاینت در کانتینر، کاری پس‌زمینه که نمی‌تواند دو بار بفرستد، یک کنترلر وب‌هوک، و آزمون‌هایی که هرگز به شبکه نمی‌رسند.

راه‌اندازی

این بسته یکپارچگی ویژه‌ای با فریم‌ورک‌ها ندارد: نه service provider، نه bundle و نه mail transport. یک کلاینت را در کانتینر ثبت می‌کنید و API را از یک کار پس‌زمینه، یک کنترلر یا یک سرویس فراخوانی می‌کنید، همان‌طور که از هر برنامهٔ PHP دیگری. نگه داشتن کلاینت در تمام عمر یک فرایند بی‌خطر است، پس آن را یک بار به‌عنوان سرویس مشترک ثبت کنید.

config/services.php
<?php return [    'openemail' => [        'key' => env('OPENEMAIL_API_KEY'),        'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'),    ],];
app/Providers/AppServiceProvider.php
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider;use OpenEmail\OpenEmail; final class AppServiceProvider extends ServiceProvider{    public function register(): void    {        $this->app->singleton(OpenEmail::class, fn(): OpenEmail => new OpenEmail(            apiKey: (string) config('services.openemail.key'),            timeout: 15,        ));    }}

در Laravel، کلید را در .env نگه دارید و از راه config/services.php بخوانید، تا پس از php artisan config:cache هم باقی بماند. singleton نخستین باری که چیزی آن را بخواهد ساخته می‌شود، و به همین دلیل فرمانی مانند php artisan migrate بدون کلید هم به‌خوبی اجرا می‌شود. از آن پس هر سازنده یا متد handle که OpenEmail را نام ببرد همان کلاینت را دریافت می‌کند.

config/services.yaml
services:    OpenEmail\OpenEmail:        arguments:            $apiKey: '%env(OPENEMAIL_API_KEY)%'            $timeout: 15

در Symfony، کلاینت را با نام کلاسش به‌عنوان یک سرویس اعلام کنید، و autowiring آن را به هر سازنده‌ای که OpenEmail را بخواهد می‌دهد. سرویس‌ها به‌طور پیش‌فرض مشترک‌اند، پس کل کانتینر از یک کلاینت استفاده می‌کند.

نمونه‌ها از Laravel 12 یا جدیدتر و Symfony 6.4 یا جدیدتر استفاده می‌کنند. خود کلاینت به هیچ فریم‌ورکی وابسته نیست، پس در نسخه‌های قدیمی‌تر هم، با روش خود آن نسخه‌ها برای ثبت یک سرویس، کار می‌کند.

ارسال از یک کار پس‌زمینه

به‌جای درون درخواست، از یک کار پس‌زمینهٔ صف‌شده بفرستید، تا ارسالی کند یا ناموفق هرگز یک صفحه را معطل نکند. idempotencyKey: را از رکوردی مشتق کنید که ارسال دربارهٔ آن است. کاری که دوباره اجرا شود، چون صف دوباره امتحانش کرده یا یک worker پس از پاسخ API از کار افتاده، آنگاه با همان کلید می‌فرستد، و API به‌جای فرستادن پیام دوم، پیامی را که پیش‌تر فرستاده بازپخش می‌کند.

app/Jobs/SendInvoiceEmail.php
<?php namespace App\Jobs; use App\Models\Invoice;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Support\Facades\Storage;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail; final class SendInvoiceEmail implements ShouldQueue{    use Queueable;     public int $tries = 5;     public int $backoff = 30;     public function __construct(public Invoice $invoice) {}     public function handle(OpenEmail $openemail): void    {        try {            $sent = $openemail->emails->send([                'from' => 'Acme Billing <[email protected]>',                'to' => $this->invoice->customer_email,                'subject' => 'Invoice ' . $this->invoice->number,                'html' => '<p>Your invoice ' . $this->invoice->number . ' is attached.</p>',                'attachments' => [[                    'filename' => $this->invoice->number . '.pdf',                    'content' => OpenEmail::toBase64((string) Storage::get($this->invoice->pdf_path)),                    'contentType' => 'application/pdf',                ]],            ], idempotencyKey: 'invoice:' . $this->invoice->id . ':email');        } catch (ValidationException $error) {            $this->fail($error);             return;        }         $this->invoice->update(['email_id' => $sent['id'], 'email_status' => $sent['status']]);    }}

یک NetworkException آزاد گذاشته می‌شود تا بیرون برود، پس صف پس از $backoff ثانیه کار را دوباره امتحان می‌کند، و اگر اجرای نخست واقعاً به API رسیده باشد، همان کلید اجرای بعدی را به یک بازپخش تبدیل می‌کند. یک ValidationException کار را بی‌درنگ ناموفق می‌کند، چون پیامی که API به همان شکل نوشته‌شده رد کرده با فرستادن دوباره نمی‌تواند موفق شود. تا وقتی یک NetworkException به کار پس‌زمینه برسد، کلاینت خودش ارسال را با همان کلید دوباره امتحان کرده است، به‌طور پیش‌فرض دو بار.

کلید را برای آن رکورد و آن هدف ثابت نگه دارید، همان‌طور که invoice:42:email ثابت است. کلیدی که از یک برچسب زمانی یا از uniqid() ساخته شود در هر اجرا تازه است، و آنگاه کاری که دوباره تلاش شود دو بار می‌فرستد. استفادهٔ دوباره از یک کلید با بدنه‌ای متفاوت، مثلاً فاکتوری که میان دو اجرا تغییر کرده، به‌جای فرستاده شدن با یک 422 idempotency_key_reuse رد می‌شود.

یک handler در Symfony Messenger هم به همین شکل کار می‌کند. Messenger پیام ناموفق را خودش دوباره امتحان می‌کند، و پرتاب UnrecoverableMessageHandlingException به آن می‌گوید این کار را نکند.

src/MessageHandler/SendWelcomeEmailHandler.php
<?php namespace App\MessageHandler; use App\Message\SendWelcomeEmail;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail;use Symfony\Component\Messenger\Attribute\AsMessageHandler;use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; #[AsMessageHandler]final class SendWelcomeEmailHandler{    public function __construct(private readonly OpenEmail $openemail) {}     public function __invoke(SendWelcomeEmail $message): void    {        try {            $this->openemail->emails->send([                'from' => 'Acme <[email protected]>',                'to' => $message->email,                'subject' => 'Welcome to Acme',                'text' => 'Glad you are here.',            ], idempotencyKey: 'welcome:' . $message->userId);        } catch (ValidationException $error) {            throw new UnrecoverableMessageHandlingException($error->getMessage(), 0, $error);        }    }}

Laravel یک ValidationException مخصوص خودش دارد. وقتی یک فایل به هر دو نیاز دارد، یکی را با نام دیگری import کنید، مانند use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.

دریافت وب‌هوک‌ها

هر تحویل را پیش از آنکه بر اساسش کاری کنید راستی‌آزمایی کنید. امضا بایت‌های خام بدنه را پوشش می‌دهد، پس $request->getContent() را، نه یک نسخهٔ تجزیه‌شده یا دوباره کدگذاری‌شده، همراه با $request->headers، که سرآیند امضا را از آن می‌خواند، به وارسی‌کننده بدهید.

app/Http/Controllers/OpenEmailWebhookController.php
<?php namespace App\Http\Controllers; use App\Jobs\HandleOpenEmailEvent;use Illuminate\Http\Request;use Illuminate\Http\Response;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; final class OpenEmailWebhookController{    public function __invoke(Request $request): Response    {        try {            $event = OpenEmail::verifyWebhookSignature(                $request->getContent(),                $request->headers,                (string) config('services.openemail.webhook_secret'),            );        } catch (WebhookSignatureException) {            return response('bad signature', 400);        }         HandleOpenEmailEvent::dispatch($event);         return response()->noContent();    }}
routes/web.php
<?php use App\Http\Controllers\OpenEmailWebhookController;use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;use Illuminate\Support\Facades\Route; Route::post('/webhooks/openemail', OpenEmailWebhookController::class)    ->withoutMiddleware([ValidateCsrfToken::class]);

تحویل هیچ توکن CSRF ندارد، پس مسیری در routes/web.php باید آن middleware را کنار بگذارد، وگرنه Laravel پیش از اجرای کنترلر شما POST را با یک 419 رد می‌کند. مسیری در routes/api.php از اساس بررسی CSRF ندارد.

src/Controller/OpenEmailWebhookController.php
<?php namespace App\Controller; use App\Message\OpenEmailEvent;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\AsController;use Symfony\Component\Messenger\MessageBusInterface;use Symfony\Component\Routing\Attribute\Route; #[AsController]final class OpenEmailWebhookController{    public function __construct(        private readonly MessageBusInterface $bus,        #[Autowire(env: 'OPENEMAIL_WEBHOOK_SECRET')]        private readonly string $webhookSecret,    ) {}     #[Route('/webhooks/openemail', methods: ['POST'])]    public function __invoke(Request $request): Response    {        try {            $event = OpenEmail::verifyWebhookSignature($request->getContent(), $request->headers, $this->webhookSecret);        } catch (WebhookSignatureException) {            return new Response('bad signature', Response::HTTP_BAD_REQUEST);        }         $this->bus->dispatch(new OpenEmailEvent($event));         return new Response(null, Response::HTTP_NO_CONTENT);    }}

رویداد را به صف بسپارید و بی‌درنگ پاسخ دهید: تحویلی که ظرف 5 ثانیه پاسخی نگیرد شکست‌خورده به حساب می‌آید و بعداً دوباره فرستاده می‌شود. id رویداد در هر تلاش دوباره و بازپخش آن یکسان است، پس شناسه‌هایی را که رسیدگی کرده‌اید ذخیره کنید و از شناسه‌ای که دیده‌اید بگذرید.

فقط به تحویل جعلی یا کهنه 400 پاسخ دهید، نه به چیز دیگری. نبودن secret شکست دیگری است: OpenEmail::verifyWebhookSignature() برای آن InvalidArgumentException را پرتاب می‌کند، که کنترلرهای بالا آن را نمی‌گیرند، پس برنامه‌ای که بد پیکربندی شده 500 پاسخ می‌دهد و تحویل پس از آنکه مشکل را رفع کردید دوباره امتحان می‌شود، به‌جای آنکه همهٔ رویدادها به‌عنوان جعلی رد شوند.

workerهای طولانی‌مدت

زیر PHP-FPM، کانتینر برای هر درخواست ساخته می‌شود، و کلاینت هم همین‌طور. زیر Laravel Octane، RoadRunner، FrankenPHP در حالت worker یا یک worker صف، singleton به اندازهٔ عمر worker زنده می‌ماند، پس یک کلاینت و اتصال بازش به همهٔ درخواست‌هایی که worker رسیدگی می‌کند خدمت می‌کنند. هیچ وضعیتی را که به یک درخواست تعلق داشته باشد نگه نمی‌دارد: یک apiKey: مخصوص یک فراخوانی، اعتبارنامه را فقط برای همان فراخوانی جایگزین می‌کند.

workerی که fork می‌کند، همان‌طور که pcntl_fork می‌کند، به فرایند فرزند اتصالی از آنِ خودش می‌دهد: فرزند استفاده از اتصالی را که به ارث برده کنار می‌گذارد و در نخستین درخواستش اتصال تازه‌ای باز می‌کند. با این حال فرزندی که پایان می‌یابد اتصال به‌ارث‌رسیده را برای والد هم می‌بندد، پس پیش از fork کردن $client->close() را فراخوانی کنید تا والد اتصال تازه‌ای باز کند که هیچ فرزندی در آن شریک نیست.

آزمون

برای مدت یک آزمون، کلاینتی با یک کلاینت HTTP ساختگی در کانتینر بگذارید تا هیچ چیزی از دستگاه بیرون نرود. هر چیزی که OpenEmail\Http\HttpClient را پیاده‌سازی کند کار می‌کند، از جمله یک کلاس بی‌نام، و دقیقاً همان چیزی را می‌بیند که بیرون می‌رفت.

tests/Feature/SendInvoiceEmailTest.php
<?php namespace Tests\Feature; use App\Jobs\SendInvoiceEmail;use App\Models\Invoice;use Illuminate\Foundation\Testing\RefreshDatabase;use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail;use Tests\TestCase; final class SendInvoiceEmailTest extends TestCase{    use RefreshDatabase;     public function testARetriedJobSendsUnderOneKey(): void    {        $api = new class implements HttpClient {            public array $requests = [];             public function send(HttpRequest $request): HttpResponse            {                $this->requests[] = $request;                 return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}');            }        };         $this->app->instance(OpenEmail::class, new OpenEmail(            apiKey: 'oe_test_fake',            httpClient: $api,            maxRetries: 0,            disableUpdateNotice: true,        ));         $invoice = Invoice::factory()->create();        SendInvoiceEmail::dispatchSync($invoice);        SendInvoiceEmail::dispatchSync($invoice);         $keys = array_map(fn(HttpRequest $request): ?string => $request->header('Idempotency-Key'), $api->requests);         $this->assertSame(array_fill(0, 2, 'invoice:' . $invoice->id . ':email'), $keys);        $this->assertSame('/emails', parse_url($api->requests[0]->url, PHP_URL_PATH));    }}

هر درخواست ثبت‌شده یک OpenEmail\Http\HttpRequest با method، url، headers، body و timeout است، و body آن همان JSONی است که فرستاده می‌شد، پس json_decode($request->body, true) خودِ پیام را نشان می‌دهد. برای آزمودن اینکه کدتان با رد شدن چگونه رفتار می‌کند، یک وضعیت خطا با پاکت خطای API برگردانید. در Symfony، سرویس را برای محیط آزمون در config/services_test.yaml دوباره تعریف کنید و یک کلاینت HTTP ساختگی را به‌عنوان آرگومان $httpClient آن بدهید.

tests/FakeOpenEmailHttpClient.php
<?php namespace App\Tests; use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse; final class FakeOpenEmailHttpClient implements HttpClient{    public array $requests = [];     public function send(HttpRequest $request): HttpResponse    {        $this->requests[] = $request;         return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}');    }}
config/services_test.yaml
services:    App\Tests\FakeOpenEmailHttpClient: ~     OpenEmail\OpenEmail:        arguments:            $apiKey: 'oe_test_fake'            $httpClient: '@App\Tests\FakeOpenEmailHttpClient'            $maxRetries: 0            $disableUpdateNotice: true