Laravel 实作 Webhooks

前言

那时候找不到完全符合需求的可以直接用或改,所以最後自己写了一个,供大家参考。

根据我爬文,要用 Laravel 实作 Webhook 的方法应该不只一种,也有已经写好的套件可以使用,可以花点时间看,再根据自己需求选择用做好的或自开发。

需求

  • 产品、订单状态异动时要发 webhooks 通知分销商
  • 产品、订单状态异动为两个事件,要可以设定各自的 endpoints
  • 可以启用、关闭通知
  • 有发送纪录

整理起来的开发项目

  • 发送通知
  • 发送时纪录内容与回覆
  • 触发发送通知
  • 把纪录和控制面板整理到後台介面

本次只会分享「发送通知」和「触发发送通知」的写法

Notification & Channel

实作上会用到这两样,我的理解如下

  • Notification: 制作讯息、内容 (What to send)
  • Channel: 送讯息的方法 (How to send)

又因为需要控制送到哪、与是否启用,新增 subscribes table,去记录。
schema 设计如下,一个分销商(client)可以订阅多个事件(event),每个订阅可以设置一个endpoint(url)、且可以控制是否启用(active)

Subscribes
id
event_name
client_id
active
url
created_at
updated_at

Publish Subscribe Pattern

Notification

<?php

namespace App\Notifications;

use App\Channels\WebhookChannel;
use App\Models\Subscribe;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;


class WebhookNotification extends Notification implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public $tries = 10;   // 可以设定最多重试几次
    public $timeout = 45; // 等待分销商几秒後没有回覆算失败
    public $retryAfter = 100; // 若失败後几秒重试
    /**
     * Where the webhook notification will send to
     *
     * @var string
     */
    public $url;

    /**
     * Webhook event
     *
     * @var string
     */
    public $event;

    /**
     * Gds Client instance
     *
     * @var App\Models\Client
     */
    public $client;

    /**
     * Create a new notification instance.
     *
     * @return void
     */
    public function __construct(Subscribe $subscribe)
    {
        $this->url    = $subscribe->url;
        $this->event  = $subscribe->event;
        $this->client = $subscribe->client;
    }

    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        return [WebhookChannel::class];
    }

    /**
     * Get the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     */
    public function toWebhook($notifiable)
    {
        // 根据不同事件组讯息
        switch ($this->event) {
            case 'product_status_updated':
                return [
                    'event'        => $this->event,
                    'product_id'   => $notifiable->prod_id,
                    'product_name' => $notifiable->prod_name,
                    'new_status'   => $notifiable->prod_status,
                    // 时间资讯会想送「寄送」时间,所以在 Channel 再做
                ];
            case 'order_status_updated':
                return [
                    'event'         => $this->event,
                    'order_id'      => $notifiable->order_id,
                    'order_voucher' => $notifiable->order_voucher,
                    'new_status'    => $notifiable->order_status,
                    // 时间资讯会想送「寄送」时间,所以在 Channel 再做
                ];
            // ...之後有新事件可以加在下面
        }
    }

    /**
     * Get the array representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function toArray($notifiable)
    {
        return [
            //
        ];
    }
}

Channel

<?php
namespace App\Channels;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Notifications\Notifiable;
use Illuminate\Notifications\Notification;

class WebhookChannel
{
    public function __construct()
    {
        $this->client = new Client();
    }

    /**
     * @param Notifiable $notifiable
     * @param Notification $notification
     * @throws WebHookFailedException
     */
    public function send($notifiable, Notification $notification)
    {
        if (method_exists($notification, 'toWebhook')) {
            $body = (array) $notification->toWebhook($notifiable);
        } else {
            $body = $notification->toArray($notifiable);
        }
        
        // 放时间资讯
        $timestamp = now();
        $body['timestamp'] = $timestamp->format('Y-m-d H:i:s');

        $headers = [
            'Content-Type' => 'application/json'
        ];

        $url = $notification->url;

        $request = new Request('POST', $url, $headers, json_encode($body));

        try {
            $response = $this->client->send(
                $request,
                ['timeout' => 45.0]
            );

            // 这边看你觉得定义收到什麽回覆算成功,
            // 可以是收到 code 2XX、200、或特定讯息
            // 以下范例是只有收到 200 系统才是为成功
            if ($response->getStatusCode() == 200) {
                // Success
            } else {
                // Get a non 200 respones
                $notification->release(100); // 100秒後重新再跑一次
            }
        } catch (Throwable $th) {
            // handle exception
        }
    }
}

状态异动时触发发送

以产品状态异动为例,可以利用 model 在 saving 的时候触发发送

class Product extends Model
{
    use Notifiable; // 要加这个

    protected static function boot()
    {
        parent::boot();
        
        // 在储存时触发
        static::saving(function ($product) {

            // 检查状态是否有异动
            $prev_status = $product->getOriginal('prod_status');
            if ($prev_status != $product->prod_status) {
                // 对有该产品下所有的订阅发送通知
                $subscribes = $product->getSubscribes("product_status_updated");
                foreach ($subscribes as $subscribe) {
                    $product->notify(new WebhookNotification($subscribe));
                }
            }
        });
    }
    
    public function getSubscribes($event)
    {
        return Subscribe::where('active', 1)
        ->where('event_name', $event)
        ->get();
    }
}


<<:  数据中台:实施阶段

>>:  Day09 - 用 Cloud Run 部属 Serverless 容器应用

Day17 - GitLab CI 流水线建置

前言 从今天以及之後的几篇文章,将介绍如何打造 GitLab CI 流水线,以及如何透过 ArgoC...

Day4:有时遇到M365茶包射击(Troubleshooting)没灵感时该如何下手

身为一个第一线协助客户处理Microsoft 365大大小小的技术人员 如果遇到M365茶包射击(T...

Class and Style Bindings

透过昨天的范例我们知道要绑定HTML属性需要使用v-bind指令,而今天我们要介绍的是v-bind绑...

Day 29 | Keep Going 13 - Github page

嘿不知不觉的就来到倒数第二篇了呢!网页也写完了呢!是不是要发布了哇! 今天就来说说 Github p...

Python 练习

今天要来解APCS的题目,这次是105年10月29的实作题第二题,那我们就开始吧! 题目 解答 a=...