Webhooks وواجهات API القائمة على الأحداث
تمكّن Webhooks الاتصال في الوقت الفعلي القائم على الأحداث بين الأنظمة. بدلاً من الاستعلام المستمر عن API للتحقق من التغييرات، تدفع webhooks الإشعارات إلى تطبيقك عند حدوث الأحداث. يغطي هذا الدرس تصميم webhook، وآليات التسليم، ومنطق إعادة المحاولة، والتحقق من التوقيع، وهياكل حمولة الأحداث.
ما هي Webhooks؟
Webhook هو استدعاء HTTP: طلب HTTP POST يحدث عندما يحدث شيء ما؛ إشعار حدث بسيط عبر HTTP POST. Webhooks هي "استدعاءات HTTP يحددها المستخدم" يتم تشغيلها بأحداث معينة.
Webhooks مقابل استعلام API:
- الاستعلام: يسأل العميل بشكل متكرر "هل حدث شيء؟" كل بضع ثوانٍ/دقائق
- Webhooks: يقول الخادم "مرحباً، حدث شيء ما!" ويدفع البيانات إلى العميل
Webhooks أكثر كفاءة، وتوفر تحديثات في الوقت الفعلي، وتقلل من حمل الخادم.
مبادئ تصميم Webhook
1. أنواع الأحداث والتسمية
حدد أنواع أحداث واضحة ومتسقة يمكن للمشتركين تصفيتها ومعالجتها.
<?php
// اصطلاح تسمية الأحداث المصمم بشكل جيد
class WebhookEvents
{
// نمط المورد.الإجراء
const USER_CREATED = 'user.created';
const USER_UPDATED = 'user.updated';
const USER_DELETED = 'user.deleted';
const ORDER_PLACED = 'order.placed';
const ORDER_CONFIRMED = 'order.confirmed';
const ORDER_SHIPPED = 'order.shipped';
const ORDER_DELIVERED = 'order.delivered';
const ORDER_CANCELLED = 'order.cancelled';
const PAYMENT_SUCCEEDED = 'payment.succeeded';
const PAYMENT_FAILED = 'payment.failed';
const PAYMENT_REFUNDED = 'payment.refunded';
const SUBSCRIPTION_CREATED = 'subscription.created';
const SUBSCRIPTION_RENEWED = 'subscription.renewed';
const SUBSCRIPTION_CANCELLED = 'subscription.cancelled';
const SUBSCRIPTION_EXPIRED = 'subscription.expired';
}
// هيكل حمولة الحدث
class WebhookPayload
{
public string $id; // معرف الحدث الفريد
public string $type; // نوع الحدث (مثل 'order.placed')
public int $created; // الطابع الزمني
public string $api_version; // إصدار API
public array $data; // بيانات الحدث
public ?array $previous; // القيم السابقة (للتحديثات)
}
2. إدارة اشتراك Webhook
اسمح للمستخدمين بالاشتراك في أحداث معينة وإدارة نقاط نهاية webhook الخاصة بهم.
<?php
// database/migrations/create_webhooks_table.php
Schema::create('webhooks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('url'); // عنوان URL لنقطة النهاية
$table->json('events'); // مصفوفة من أنواع الأحداث المشترك بها
$table->string('secret'); // سر للتحقق من التوقيع
$table->boolean('is_active')->default(true);
$table->integer('failure_count')->default(0);
$table->timestamp('last_delivered_at')->nullable();
$table->timestamp('last_failed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'is_active']);
});
// app/Models/Webhook.php
class Webhook extends Model
{
protected $fillable = [
'user_id',
'url',
'events',
'secret',
'is_active'
];
protected $casts = [
'events' => 'array',
'is_active' => 'boolean',
'last_delivered_at' => 'datetime',
'last_failed_at' => 'datetime'
];
public function user()
{
return $this->belongsTo(User::class);
}
public function isSubscribedTo(string $eventType): bool
{
return in_array($eventType, $this->events) ||
in_array('*', $this->events); // اشتراك بأحرف البدل
}
public function incrementFailureCount(): void
{
$this->increment('failure_count');
$this->update(['last_failed_at' => now()]);
// التعطيل التلقائي بعد 10 فشل متتالي
if ($this->failure_count >= 10) {
$this->update(['is_active' => false]);
}
}
public function markAsDelivered(): void
{
$this->update([
'failure_count' => 0,
'last_delivered_at' => now()
]);
}
}
نقاط نهاية Webhook API
<?php
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function index(Request $request)
{
$webhooks = $request->user()
->webhooks()
->latest()
->get();
return WebhookResource::collection($webhooks);
}
public function store(Request $request)
{
$validated = $request->validate([
'url' => ['required', 'url', 'active_url'],
'events' => ['required', 'array', 'min:1'],
'events.*' => ['required', 'string', 'in:' . implode(',', array_values((array) WebhookEvents::class))]
]);
// توليد سر للتحقق من التوقيع
$secret = 'whsec_' . Str::random(32);
$webhook = $request->user()->webhooks()->create([
'url' => $validated['url'],
'events' => $validated['events'],
'secret' => $secret
]);
return new WebhookResource($webhook);
}
public function update(Request $request, Webhook $webhook)
{
$this->authorize('update', $webhook);
$validated = $request->validate([
'url' => ['sometimes', 'url', 'active_url'],
'events' => ['sometimes', 'array', 'min:1'],
'is_active' => ['sometimes', 'boolean']
]);
$webhook->update($validated);
return new WebhookResource($webhook);
}
public function destroy(Webhook $webhook)
{
$this->authorize('delete', $webhook);
$webhook->delete();
return response()->noContent();
}
// نقطة نهاية اختبار webhook
public function test(Webhook $webhook)
{
$this->authorize('update', $webhook);
$payload = [
'id' => 'evt_test_' . Str::random(16),
'type' => 'webhook.test',
'created' => time(),
'api_version' => 'v1',
'data' => [
'message' => 'هذا تسليم webhook اختباري'
]
];
dispatch(new DeliverWebhook($webhook, $payload));
return response()->json([
'message' => 'تم وضع webhook الاختباري في قائمة الانتظار للتسليم'
]);
}
}
نظام تسليم Webhook
قم بتنفيذ نظام تسليم قوي مع قوائم الانتظار وإعادة المحاولات ومعالجة الفشل.
<?php
// app/Jobs/DeliverWebhook.php
namespace App\Jobs;
use App\Models\Webhook;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DeliverWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5; // أعد المحاولة حتى 5 مرات
public $backoff = [60, 300, 900, 3600, 7200]; // 1د، 5د، 15د، 1س، 2س
protected Webhook $webhook;
protected array $payload;
public function __construct(Webhook $webhook, array $payload)
{
$this->webhook = $webhook;
$this->payload = $payload;
}
public function handle(): void
{
if (!$this->webhook->is_active) {
Log::info("Webhook {$this->webhook->id} غير نشط، تخطي التسليم");
return;
}
// توليد التوقيع
$signature = $this->generateSignature($this->payload);
try {
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Signature' => $signature,
'X-Webhook-Event' => $this->payload['type'],
'X-Webhook-Id' => $this->payload['id'],
'X-Webhook-Timestamp' => $this->payload['created'],
'User-Agent' => 'MyApp-Webhooks/1.0'
])
->post($this->webhook->url, $this->payload);
if ($response->successful()) {
$this->webhook->markAsDelivered();
// تسجيل التسليم الناجح
WebhookDelivery::create([
'webhook_id' => $this->webhook->id,
'event_type' => $this->payload['type'],
'payload' => $this->payload,
'response_status' => $response->status(),
'response_body' => $response->body(),
'delivered_at' => now()
]);
Log::info("تم تسليم Webhook {$this->webhook->id} بنجاح");
} else {
throw new \Exception("HTTP {$response->status()}: {$response->body()}");
}
} catch (\Exception $e) {
$this->webhook->incrementFailureCount();
// تسجيل التسليم الفاشل
WebhookDelivery::create([
'webhook_id' => $this->webhook->id,
'event_type' => $this->payload['type'],
'payload' => $this->payload,
'response_status' => null,
'error_message' => $e->getMessage(),
'delivered_at' => null
]);
Log::error("فشل تسليم Webhook {$this->webhook->id}: {$e->getMessage()}");
// إعادة الرفع لتشغيل إعادة المحاولة
throw $e;
}
}
protected function generateSignature(array $payload): string
{
$payloadJson = json_encode($payload);
return hash_hmac('sha256', $payloadJson, $this->webhook->secret);
}
public function failed(\Throwable $exception): void
{
Log::error("فشل Webhook {$this->webhook->id} بشكل دائم: {$exception->getMessage()}");
// إخطار مالك webhook
$this->webhook->user->notify(
new WebhookDeliveryFailed($this->webhook, $this->payload, $exception)
);
}
}
التحقق من التوقيع
يجب على مستقبلي Webhook التحقق من أن الطلبات تأتي بالفعل من API الخاص بك ولم يتم العبث بها.
<?php
// مستقبل Webhook (التحقق من جانب العميل)
class WebhookController
{
public function handle(Request $request)
{
// احصل على التوقيع من الرؤوس
$signature = $request->header('X-Webhook-Signature');
if (!$signature) {
return response()->json(['error' => 'التوقيع مفقود'], 400);
}
// احصل على سر webhook من قاعدة البيانات/التكوين
$secret = config('webhooks.secret');
// احسب التوقيع المتوقع
$payload = $request->getContent();
$expectedSignature = hash_hmac('sha256', $payload, $secret);
// مقارنة ثابتة الوقت لمنع هجمات التوقيت
if (!hash_equals($expectedSignature, $signature)) {
Log::warning('تم استلام توقيع webhook غير صالح', [
'ip' => $request->ip(),
'headers' => $request->headers->all()
]);
return response()->json(['error' => 'توقيع غير صالح'], 401);
}
// التحقق من الطابع الزمني لمنع هجمات إعادة التشغيل
$timestamp = $request->header('X-Webhook-Timestamp');
$age = time() - $timestamp;
if ($age > 300) { // رفض إذا كان أقدم من 5 دقائق
return response()->json(['error' => 'Webhook قديم جداً'], 400);
}
// معالجة webhook
$data = $request->json()->all();
$eventType = $data['type'];
match ($eventType) {
'order.placed' => $this->handleOrderPlaced($data),
'order.shipped' => $this->handleOrderShipped($data),
'payment.succeeded' => $this->handlePaymentSucceeded($data),
default => Log::info("حدث webhook غير معالج: {$eventType}")
};
// عُد دائماً 200 بسرعة
return response()->json(['received' => true]);
}
protected function handleOrderPlaced(array $data): void
{
// وضع المعالجة في قائمة الانتظار لتجنب المهلة
ProcessOrderPlacedWebhook::dispatch($data);
}
}
هياكل حمولة الأحداث
<?php
// تنسيق حمولة الحدث المتسق
class WebhookPayloadBuilder
{
public static function build(string $eventType, Model $resource, ?Model $previous = null): array
{
return [
'id' => 'evt_' . Str::random(24),
'type' => $eventType,
'created' => time(),
'api_version' => 'v1',
'data' => [
'object' => $resource->getTable(),
'id' => $resource->id,
'attributes' => new ResourceTransformer($resource)
],
'previous' => $previous ? [
'attributes' => new ResourceTransformer($previous)
] : null
];
}
}
// مثال: تشغيل webhooks من الأحداث
class OrderObserver
{
public function created(Order $order): void
{
$payload = WebhookPayloadBuilder::build(
WebhookEvents::ORDER_PLACED,
$order
);
$this->dispatchWebhooks($order->user, WebhookEvents::ORDER_PLACED, $payload);
}
public function updated(Order $order): void
{
if ($order->isDirty('status')) {
$eventType = match ($order->status) {
'confirmed' => WebhookEvents::ORDER_CONFIRMED,
'shipped' => WebhookEvents::ORDER_SHIPPED,
'delivered' => WebhookEvents::ORDER_DELIVERED,
'cancelled' => WebhookEvents::ORDER_CANCELLED,
default => null
};
if ($eventType) {
$previous = $order->replicate();
$previous->status = $order->getOriginal('status');
$payload = WebhookPayloadBuilder::build($eventType, $order, $previous);
$this->dispatchWebhooks($order->user, $eventType, $payload);
}
}
}
protected function dispatchWebhooks(User $user, string $eventType, array $payload): void
{
$webhooks = Webhook::where('user_id', $user->id)
->where('is_active', true)
->get()
->filter(fn($webhook) => $webhook->isSubscribedTo($eventType));
foreach ($webhooks as $webhook) {
DeliverWebhook::dispatch($webhook, $payload);
}
}
}
سجل تسليم Webhook
تتبع جميع تسليمات webhook لتصحيح الأخطاء والمراقبة.
<?php
// database/migrations/create_webhook_deliveries_table.php
Schema::create('webhook_deliveries', function (Blueprint $table) {
$table->id();
$table->foreignId('webhook_id')->constrained()->onDelete('cascade');
$table->string('event_type');
$table->json('payload');
$table->integer('response_status')->nullable();
$table->text('response_body')->nullable();
$table->text('error_message')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->index(['webhook_id', 'created_at']);
$table->index('event_type');
});
// نقطة نهاية API لعرض سجل التسليم
Route::get('/webhooks/{webhook}/deliveries', function (Webhook $webhook) {
$deliveries = WebhookDelivery::where('webhook_id', $webhook->id)
->latest()
->paginate(50);
return WebhookDeliveryResource::collection($deliveries);
})->middleware(['auth:sanctum']);
أفضل الممارسات
أفضل ممارسات Webhook:
- العمليات المتكافئة: صمم المستقبلين للتعامل مع التسليمات المكررة
- استجابة سريعة: أعد 200 فوراً، عالج بشكل غير متزامن
- التحقق من التوقيع: تحقق دائماً من صحة webhook
- منطق إعادة المحاولة: نفذ التراجع الأسي
- حماية المهلة: عيّن مهلات معقولة (10-30 ثانية)
- المراقبة: تتبع معدلات نجاح التسليم
- الإصدار: قم بتضمين إصدار API في الحمولات
- التوثيق: وفر توثيق webhook واضح مع أمثلة
اختبار Webhooks: استخدم أدوات مثل ngrok أو RequestBin أثناء التطوير لكشف نقاط النهاية المحلية وفحص حمولات webhook.
تمرين:
- قم بتنفيذ نظام اشتراك webhook كامل
- أنشئ وسيطة للتحقق من التوقيع
- ابنِ وظيفة تسليم webhook مع منطق إعادة المحاولة
- أضف تسجيل ومراقبة تسليم webhook
- نفذ مفاتيح التكافؤ لمنع المعالجة المكررة
- أنشئ نقطة نهاية اختبار webhook للمطورين
أخطاء Webhook الشائعة:
- عدم التحقق من التوقيعات - يسمح بـ webhooks مزيفة
- المعالجة المتزامنة - تسبب المهلات
- عدم وجود منطق إعادة المحاولة - يفقد الأحداث عند الفشل المؤقت
- كشف البيانات الحساسة في الحمولات
- عدم تنفيذ التكافؤ - يسبب معالجة مكررة