编写具有描述性的 RESTful API (四): 通知系统

上一节讲到了用户行为,由用户行为自然的便引出了通知系统。当用户喜欢了一篇帖子,那么该帖子的作者应该收到一条提醒。

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 ,而不是进行实时计数。

相关

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 4年前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 4

文章不错,学习了好多。 希望更新多点..

4年前 评论

不错 赞一个

4年前 评论

通知的另一种存储方式 这里,通知的文字形式应该还是需要落表的吧?后续获取通知列表要每次都现查吗?

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!