編寫具有描述性的 RESTful API (四): 通知系統

Max發表於2019-03-27

上一節講到了使用者行為,由使用者行為自然的便引出了通知系統。當使用者喜歡了一篇帖子,那麼該帖子的作者應該收到一條提醒。

laravel 提供了一套 Notification 元件,用於處理通知,其支援通過多種頻道傳送通知,包括郵件、簡訊 、通知還能儲存到資料庫(站內信)以便後續在 Web 頁面中顯示,本節將重點放在站內信。

laravel 為我們擺平了通知的推送問題,但是還有一個問題,即通知(資料庫)的儲存問題需要我們處理。通知的儲存通常有兩種做法。

  • 將通知的主體內容與部分附加內容一同冗餘儲存到資料庫中,即 laravel 的預設形式。
  • 將通知的主體內容的主鍵與其附加內容的主鍵 既 id 儲存到資料庫中。

前者的好處是較小的查詢壓力,且資料具有永續性,不會因為被刪帖等問題而影響到通知內容。缺點則是佔用儲存空間,且缺乏靈活性,後者則反之。

原始碼中選擇了前者,既預設形式。

資料分析

通過抽象可以得到,一條通知由三部分組成 行為的觸發者 trigger 、行為主體(可能攜帶 內容) target 、需要通知的使用者 notifiable

此處最讓人疑惑的應該是 行為的主體,根據實際需求稍微圖解一下。

已 「Comment Post」為例,在簡書中其實際的表現行為如下

根據上面的分析 notifications 表中的 data 需要冗餘如下資料,可以根據運營的實際需求調整

public function toArray($notifiable)
 {
   $data = [
     'trigger' => [
       'id' => 1, // default type users
       'type' => 'users',
       'nickname' => 'nickname',
       'avatar' => 'xxx',
     ],
     'target' => [
       'id' => 12,
       'type' => 'posts',
       'text' => 'xxx',
     ],
     'content' => [
       'id' => 1,
       'type' => 'comment',
       'text' => 'xxx',
       'call_user' => [
         'id' => 'xxx',
         'nickname' => 'xxx'
       ]
     ]
   ];

   return $data;
 }

建立通知

已上一次的 「Like Post」 行為為例,當使用者點贊文章後,需要給文章的作者傳送一條通知。

# PostLikerObserver

/**
 * @param PostLiker $postLiker
 */
public function created(PostLiker $postLiker)
{
  // ...

  // notify App\Notifications\LikePost
  User::findOrFail($postLiker->post->user_id)
    ->notify(new LikePost($user, $postLiker->post));
}
# LikerPost.php

namespace App\Notifications;

class LikePost extends Notification
{
    use Queueable;

    private $post;
    private $trigger;
    private $target;

    public function __construct(User $trigger, Post $target)
    {
        $this->trigger = $trigger;
        $this->target = $target;
    }

    // ...

    public function toArray($notifiable)
    {
        $data = [
            'trigger' => [
                'id' => $this->trigger->id,
                'type' => $this->trigger->getTable(),
                'nickname' => $this->trigger->nickname,
                'avatar' => $this->trigger->avatar,
            ],
            'target' => [
                'id' => $this->target->id,
                'type' => $this->target->getTable(),
                'text' => $this->target->title,
            ]
        ];

        return $data;
    }
}

這樣就成功建立了一條 Notification , 類似「Comment Post」等使用者行為依舊可以按照這種思路完成。無非「Comment Post」需要在其 data 中新增 content 而已,這裡就不做展示了。

這裡需要提一下程式碼優化,通過上面的「資料分析」,我們已經把通知抽象為 trigger / target / content ,因此並不需要再每種使用者行為都編寫一堆 重複的構造方法,toArray 等方法。

完全可以編寫一個 Notification 基類來編寫上面的大部分程式碼,從而減少重複的程式碼。

相關優化已經完成,歡迎參考原始碼。

通知的另一種儲存方式

這裡稍微提一下另外一種形式的通知儲存,即上文中提到的非冗餘形式。不過該方式需要修改 notifications 表的預設表結構。

Schema::create('notifications', function (Blueprint $table) {
  $table->uuid('id')->primary();
  $table->string('type');
  $table->morphs('notifiable');

  // $table->json('data')->nullable()->comment('target/content/trigger');
  $table->morphs('triggerable')->nullable();
  $table->morphs('targetable')->nullable()
  $table->morphs('contentable')->nullable();

  $table->timestamp('read_at')->nullable();
  $table->timestamps();
});

通過表結構相信你已經一目瞭然。萬變不離其宗,我們始終都是在圍繞著 trigger / target / content 轉圈圈。

當然如果你瞭解 「 MySQL 生成列 」的話,完全可以寫出下敘語句,將通知從冗餘形式平滑過渡到到非冗餘形式。

$table->string('targetable_id')->virtualAs('data->>"$.target.id"')->index();

有了這樣的表結構,關聯關係走起來,需要的資料如 文章的 點贊量 / 閱讀量 等等都能夠得到,這裡就不詳細描述程式碼編寫了。後續簡書的使用者動態模組會再次運用這種非冗餘結構的編碼,到時再深入講解相關的細節。

通知壓縮

我們的通知採用了冗餘形式儲存,所以資料儲存空間優化是必須考慮的一個點。尤其是在點贊這類通知中,對資料的浪費是非常巨大的。

因此可以通過類似下面這樣的方式壓縮未讀通知

當然,如果你選擇非冗餘形式儲存通知資料,那麼將難以進行資料壓縮。

Notification 元件提供了通知後 事件 ,所以相應的邏輯將會在該事件的監聽者中完成。邏輯比較簡單,直接看編碼吧

# App\Listeners\CompressNotification

class CompressNotification
{
    public function handle(NotificationSent $event)
    {
        $channel = $event->channel;

        if ($channel !== DatabaseChannel::class) {
            return;
        }

        $currentNotification = $event->response;
        if (!in_array($currentNotification->type, ['like_post', 'like_comment'])) {
            return;
        }

        $notifiable = $event->notifiable;

        // 查詢相同 target 的上一條通知
        $previousNotification = $notifiable->unreadNotifications()
            ->where('data->target->type', $currentNotification->data['target']['type'])
            ->where('data->target->id', $currentNotification->data['target']['id'])
            ->where('id', '<>', $currentNotification->id)
            ->first();

        if ($previousNotification) {
            $compressCount = $previousNotification->data['compress_count'] ?? 1;
            $triggers = $previousNotification->data['triggers'] ?? [$previousNotification->data['trigger']];

            $compressCount += 1;

            // 最多儲存三個觸發者
            if (count($triggers) < 3) {
                $triggers[] = $currentNotification->data['trigger'];
            }

            $previousNotification->delete();

            $data = $currentNotification->data;
            $data['compress_count'] = $compressCount;
            $data['triggers'] = $triggers;
            unset($data['trigger']);

            $currentNotification->data = $data;
            $currentNotification->save();
        }
    }
}

壓縮後的通知的 data 的 trigger Object 變成了 triggers Array ,並且增加了 compress_count 用來記錄壓縮條數,前端可以通過該欄位來判斷通知是否被壓縮過。

補充

  • 相應的 API 如通知列表,標記為已讀等已經完成,歡迎參考原始碼。
  • 通常會有一個與通知類似性質的功能,稱為訊息,或者說私信/聊天等。該功能依舊可以使用 Notification 來完成,因為其也是由 行為的觸發者 trigger 、行為主體(可能攜帶 內容) target 、需要通知的使用者 notifiable 構成。但是從解耦的角度來看,將其單獨成一個 「Chat 模組」,且訊息提醒依舊使用 「Notification」 來完成會是更好的選擇。
  • 通常會有一個與通知類似結構的功能,稱為使用者動態,或者說使用者日誌。上文中有提到該功能,後續會完成該功能。
  • 未讀訊息數在 users 表中冗餘 unread_notification_count ,而不是進行實時計數。

相關

相關文章