Laravel 11 Reverb 构建实时通知案例

作者: 温新

图书: 【Laravel 11 实战】

阅读: 70

时间: 2024-11-21 07:53:38

文档:

reverb:https://laravel.com/docs/11.x/reverb

广播:https://laravel.com/docs/11.x/broadcasting

在本篇文章中,将演示如何在 Laravel 11 应用中使用 Reverb 发送实时通知。本案例中,将实现一个“普通用户发布文章时管理员将收到通知的案例。”

实现介绍

  • 使用 Laravel UI 创建身份验证(auth)脚手架
  • 设置 2 种用户:管理员和普通用户,is_admin 用于区分是否为管理员
  • 创建一个文章表
  • 允许用户发布文章
  • 创建事件监听发送实时通知
  • 普通用户发送文章之后,管理员使用 Reverb 收到实时通知

创建 Laravel 11 项目

$ laravel new reverb-noticle
 ┌ Would you like to install a starter kit? ────────────────────┐
 │ No starter kit                                               │
 └──────────────────────────────────────────────────────────────┘
 ┌ Which testing framework do you prefer? ──────────────────────┐
 │ Pest                                                         │
 └──────────────────────────────────────────────────────────────┘
 ┌ Would you like to initialize a Git repository? ──────────────┐
 │ No                                                           │
 └─────────────────
 
$ cd reverb-noticle

使用 bootstrap auth

# 引入 UI 包
$ composer require laravel/ui 
# 生成 auth
$ php artisan ui bootstrap --auth 

$ pnpm install
$ pnpm run build

创建迁移文章

创建文章迁移表

$ php artisan make:migration create_posts_table

为 posts 表添加字段:database/migrations/2024_09_15_041935_create_posts_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

为 users 表添加 is_admin 字段:database/migrations/0001_01_01_000000_create_users_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->rememberToken();
            // 是否为管理员
            $table->tinyInteger('is_admin')->default(0);
            $table->timestamps();
        });
		
        ...
    }

    ...
};

执行迁移文件

$ php artisan migrate

创建和修改模型

生成文章模型和控制器

$ php artisan make:model Post -c

修改 Post 模型:app/Models/Post.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'body', 'user_id'];

    public  function  user()
    {
        return $this->belongsTo(User::class);
    }
}

修改 User 模型:app/Models/User.php

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
        'is_admin'	// 添加
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

安装 Reverb & Echo 服务器

1)安装广播。注意,Laravel 11 默认没有启动广播,执行如下命令之后,请会出现是否安装 Reverb,选择 Yes 安装。

$ php artisan install:broadcasting

2)接下来安装 Laravel echo 服务

$ pnpm install --save-dev laravel-echo

echo 服务安装之后,下面一起来看看都有什么内容:resources/js/echo.js

import Echo from 'laravel-echo';

import Pusher from 'pusher-js';
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb', // 这个很重要,制定了广播驱动使用 reverb
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

再来看看.env 中的配置内容:

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=729547
REVERB_APP_KEY=iwlrwqmzcka4zknocycw
REVERB_APP_SECRET=c45icidgb9gw79695s1a
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

3)再次构建 JS

$ pnpm run build

创建添加文章事件

$ php artisan make:event PostCreate

编辑广播事件代码:app/Events/PostCreate.php

<?php

namespace App\Events;

use App\Models\Post;
use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class PostCreate implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;


    /**
     * 创建一个新的事件实例
     *
     * @param Post $post 创建的文章实例
     * @param User $user 创建文章的用户实例
     */
    public function __construct(public Post $post, private readonly User $user)
    {
    }


    /**
     * 定义事件广播的频道
     *
     * @return Channel
     */
    public function broadcastOn(): Channel
    {
        // 返回事件广播的频道,这里是一个公共频道 posts
        return new Channel('posts');
    }

    /**
     * 指定广播事件的名称
     *
     * @return string
     */
    public function broadcastAs(): string
    {
        return 'create';
    }

    /**
     * 获取要广播的数据
     *
     * @return array
     */
    public function broadcastWith(): array
    {
        return [
            'message' => "{$this->user->name} 于 [{$this->post->created_at}] 创建新文章:'{$this->post->title}'."
        ];
    }
}

PostCreate 事件用于广播新文章创建的通知。

  • 通过 broadcastOn 方法指定了广播的频道('posts'),
  • 通过 broadcastAs 方法定义了广播事件的名称('create'),
  • 通过 broadcastWith 方法提供了广播的数据(包括用户的名字、文章创建时间和标题)。

这个事件会立即广播,确保前端能够实时接收到新文章创建的消息。

创建路由

routes/web.php

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');

// 文章路由
Route::get('posts', [\App\Http\Controllers\PostController::class, 'index'])->name('posts.index');
Route::post('posts', [\App\Http\Controllers\PostController::class, 'store'])->name('posts.store');

编写控制器方法

app/Http/Controllers/PostController.php

<?php

namespace App\Http\Controllers;

use App\Events\PostCreate;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $request)
    {
        $posts = Post::query()->latest()->get();

        return view('posts', compact('posts'));
    }

    public function store(Request $request)
    {
        $this->validate($request, [
            'title' => 'required',
            'body' => 'required'
        ]);

        $post = Post::create([
            'user_id' => auth()->id(),
            'title' => $request->title,
            'body' => $request->body
        ]);

        // 触发 PostCreate 事件,将新创建的文章和当前用户作为参数传递
        event(new PostCreate($post, auth()->user()));

        return back()->with('success','发布文章成功');
    }
}

编写视图文件

1)修改 app 模板文件:resources/views/layouts/app.blade.php

<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Fonts -->
    <link rel="dns-prefetch" href="//fonts.bunny.net">
    <link href="https://fonts.bunny.net/css?family=Nunito" rel="stylesheet">

    <!-- Scripts -->
    @vite(['resources/sass/app.scss', 'resources/js/app.js'])

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
    @yield('script')
</head>
<body>
<div id="app">
    <nav class="navbar navbar-expand-md navbar-light bg-white shadow-sm">
        <div class="container">
            <a class="navbar-brand" href="{{ url('/') }}">
                Laravel 使用 Reverb 发送实时通知
            </a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="{{ __('Toggle navigation') }}">
                <span class="navbar-toggler-icon"></span>
            </button>

            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <!-- 导航栏左侧-->
                <ul class="navbar-nav me-auto">
                </ul>

                <!-- 导航栏右侧 -->
                <ul class="navbar-nav ms-auto">
                    <!-- Authentication Links -->
                    <!-- 用户未登录 -->
                    @guest
                        @if (Route::has('login'))
                            <li class="nav-item">
                                <a class="nav-link" href="{{ route('login') }}">{{ __('Login') }}</a>
                            </li>
                        @endif

                        @if (Route::has('register'))
                            <li class="nav-item">
                                <a class="nav-link" href="{{ route('register') }}">{{ __('Register') }}</a>
                            </li>
                        @endif
                    @else
                        <!-- 用户已登录 -->
                        <li class="nav-item">
                            <a class="nav-link" href="{{ route('posts.index') }}">{{ __('Posts') }}</a>
                        </li>
                        <li class="nav-item dropdown">
                            <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre>
                                {{ Auth::user()->name }}
                            </a>

                            <div class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
                                <a class="dropdown-item" href="{{ route('logout') }}"
                                   onclick="event.preventDefault();
                                                     document.getElementById('logout-form').submit();">
                                    {{ __('Logout') }}
                                </a>

                                <form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
                                    @csrf
                                </form>
                            </div>
                        </li>
                    @endguest
                </ul>
            </div>
        </div>
    </nav>

    <main class="py-4">
        @yield('content')
    </main>
</div>
</body>
</html>

2)添加文章视图:resources/views/posts.blade.php

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-12">
            <div class="card">
                <div class="card-header"><i class="fa fa-list"></i> {{ __('Posts List') }}</div>

                <div class="card-body">
                    @session('success')
                    <div class="alert alert-success" role="alert">
                        {{ $value }}
                    </div>
                    @endsession

                    <div id="notification">

                    </div>

                    @if(!auth()->user()->is_admin)
                    <p><strong>创建文章</strong></p>
                    <form method="post" action="{{ route('posts.store') }}" enctype="multipart/form-data">
                        @csrf
                        <div class="form-group">
                            <label>标题:</label>
                            <input type="text" name="title" class="form-control" />
                            @error('title')
                            <div class="text-danger">{{ $message }}</div>
                            @enderror
                        </div>
                        <div class="form-group">
                            <label>内容:</label>
                            <textarea class="form-control" name="body"></textarea>
                            @error('body')
                            <div class="text-danger">{{ $message }}</div>
                            @enderror
                        </div>
                        <div class="form-group mt-2">
                            <button type="submit" class="btn btn-success btn-block"><i class="fa fa-save"></i> 发布</button>
                        </div>
                    </form>
                    @endif

                    <p class="mt-4"><strong>文章列表:</strong></p>
                    <table class="table table-bordered data-table">
                        <thead>
                        <tr>
                            <th width="70px">ID</th>
                            <th>标题</th>
                            <th>内容</th>
                        </tr>
                        </thead>
                        <tbody>
                        @forelse($posts as $post)
                        <tr>
                            <td>{{ $post->id }}</td>
                            <td>{{ $post->title }}</td>
                            <td>{{ $post->body }}</td>
                        </tr>
                        @empty
                        <tr>
                            <td colspan="5">There are no posts.</td>
                        </tr>
                        @endforelse
                        </tbody>
                    </table>

                </div>
            </div>
        </div>
    </div>
</div>
@endsection

@section('script')
@if(auth()->user()->is_admin)
<script type="module">
    // 重点看之类,收到服务器返回的数据后
    // 在页面中进行展示
    window.Echo.channel('posts')
        .listen('.create', (data) => {
            console.log('收到服务器返回的数据: ', data);
            let d1 = document.getElementById('notification');
            d1.insertAdjacentHTML('beforeend', '<div class="alert alert-success alert-dismissible fade show"><span><i class="fa fa-circle-check"></i>  '+data.message+'</span></div>');
        });
</script>
@endif
@endsection
  • Echo.channel('posts') 对应 PostCreatebroadcastOn 中定义的频道名称
  • listen('.create',)对应 PostCreatebroadcastAs 中定义的广告事件名称

填充管理员用户

生成填充文件

$ php artisan make:seeder CreateAdminUser

编写填充代码:database/seeders/CreateAdminUser.php

<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\User;

class CreateAdminUser extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        User::create([
            'name' => '管理员',
            'email' => 'admin@qq.com',
            'password' => bcrypt('123456'),
            'is_admin' => 1
        ]);
    }
}

执行填充

$ php artisan db:seed --class=CreateAdminUser

启动应用

启动项目应用

$ php artisan serve

启动 reverb 服务

$ php artisan reverb:start

测试实时通知

测试时,需要打开两个不同的客户端。

  • 注册一个普通用户
  • 登录管理员用户和普通用户,并打开文章列表
  • 管理员界面中打开 F12,观察控制台输出
  • 普通用户发布一篇文章,管理员即可收到通知

看到如图信息:测试成功

^_^

请登录后再评论