使用 Laravel 事件和队列自动更新 sitemap


sitemap 是一种 xml 格式的文本文件,是提供给搜索引擎使用的。搜索引擎可以通过定期读取 sitemap 文件来更新相关网站的索引。因此,sitemap 文件实际上以 xml 格式包含了网站的页面信息。本站的 sitemap 文件是这个样子的,片段如下:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
    <url>
        <loc>https://www.gryen.com</loc>
        <lastmod>2020-03-31T00:00:00+08:00</lastmod>
        <changefreq>daily</changefreq>
        <priority>0.1</priority>
    </url>
    <url>
        <loc>https://www.gryen.com/articles</loc>
        <lastmod>2020-03-31T00:00:00+08:00</lastmod>
        <changefreq>monthly</changefreq>
        <priority>0.1</priority>
    </url>
    <url>
        <loc>https://www.gryen.com/articles/show/10.html</loc>
        <lastmod>2017-05-23T10:47:31+08:00</lastmod>
        <changefreq>monthly</changefreq>
        <priority>0.2</priority>
    </url>
    ...
</urlset>

sitemap 应该是搜索引擎抓取时当下网站的状态的,反映整个网站当时的全貌,是整个网站的一个概览。

一种做法是在搜索引擎读取 sitemap 时动态生成内容,与动态网站用户访问时从数据库抓取内容填充到页面一样。

但是这样做有两个问题:一是网站内容比较多的时候,遍历所有数据生成 sitemap 比较慢,可能会影响收录;二是 sitemap 既然是提供当前网站网页概览的,那么只需要在网站发生变更,比如增加或者删除网页时更新就可以了,网站更新频率不高(低于搜索引擎读取 sitemap 的频率)的情况下,每次搜索引擎读取时都动态生成同样的 sitemap 文件,太浪费资源了。

比较好的做法是生成一份静态的 sitemap.xml 文件,在网站发生变更时,更新这个 sitemap.xml 文件就可以了。

利用 Laravel 事件和队列,实现在创建、删除文章等情况下更新 sitemap.xml

事件的使用

事件机制提供了不影响正常业务流程,做一些额外处理的能力。像是更新 sitemap.xml 文件,它不属于我正常发表一篇文章的流程,它的成功与失败,并不影响到我将一篇文章发表到网站上。所以,只需要在特定的条件下,触发这个事件就可以了,至于它什么时间执行完成,甚至成功与否,都不在发表文章的过程中关心。Laravel 也提供给了这样的一种事件处理机制。

Laravel 项目里面有两个文件夹看起来和事件有关系:Events 和 Listeners,一个负责事件定义,一个提供事件监听器。此外,EventServiceProvider 中还定义了事件和监听器之间的映射。具体实施起来比想象中更容易:

  1. 在 EventServiceProvider 中定义事件和监听器的映射
    EventServiceProvider.php

    /**
      * The event listener mappings for the application.
      *
      * @var array
      */
     protected $listen = [
         Registered::class => [
             SendEmailVerificationNotification::class,
         ],
         'App\Events\PublishArticle' => [
             'App\Listeners\GenSiteMapListener',
         ],
     ];
    
  2. 执行 php artisan event:generate 事件和监听器就都生成好了。
    PublishArticle.php

    <?php
    
    namespace App\Events;
    
    use Illuminate\Broadcasting\InteractsWithSockets;
    use Illuminate\Foundation\Events\Dispatchable;
    use Illuminate\Queue\SerializesModels;
    
    class PublishArticle { 
        use Dispatchable, InteractsWithSockets, SerializesModels;
    
        /**
        *   Create a new event instance.*
        *   @return void
        */
        public function __construct()
        { 
            //
        }
    
        /**
        *   Get the channels the event should broadcast on.
        *   @return \Illuminate\Broadcasting\Channel|array
        */
        public function broadcastOn()
        { 
            return [];
        }
    }
    

    GenSiteMapListener.php

    <?php
    
    namespace App\Listeners;
    
    use App\Article;
    use App\Events\PublishArticle;
    use Carbon\Carbon;
    use Illuminate\Contracts\Queue\ShouldQueue;
    use Spatie\Sitemap\Sitemap;
    use Spatie\Sitemap\Tags\Url;
    
    class GenSiteMapListener implements ShouldQueue {
    
        /**
        *   Create the event listener.*
        *   @return void
        */
        public function __construct()
        { 
            //
        }
    
        /**
        *   Handle the event.
        *   @param PublishArticle $event
        *   @return void
        */
        public function handle(PublishArticle $event)
        { 
            $publicPath = public_path('sitemap.xml');
            $siteMap = Sitemap::create();
    
            $siteMap
                 ->add(Url::create(action('HomeController@index'))
                 ->setLastModificationDate(Carbon::yesterday())
                 ->setChangeFrequency(\Spatie\Sitemap\Tags\Url::CHANGE_FREQUENCY_DAILY)
                 ->setPriority(0.1))
                 ->add(Url::create(action('ArticlesController@index'))
                 ->setLastModificationDate(Carbon::yesterday())
                 ->setChangeFrequency(\Spatie\Sitemap\Tags\Url::CHANGE_FREQUENCY_MONTHLY)
                 ->setPriority(0.1));
    
            Article::all()->each(function ($article) use ($siteMap) {
                if ($article->status === 1) {
                    $siteMap->add(Url::create(action('ArticlesController@show', [$article->id]))
                        ->setLastModificationDate($article->updated_at)
                        ->setChangeFrequency(\Spatie\Sitemap\Tags\Url::CHANGE_FREQUENCY_MONTHLY)
                        ->setPriority(0.2));
                }
            });
    
            $siteMap->writeToFile($publicPath);
            $siteMap = null;
        }
    }
    

上面代码中包含了生成 sitemap.xml 具体逻辑的实现,用到了 spatie/laravel-sitemap 这个库。

触发事件只一行代码:

event(new PublishArticle());

把它放到创建文章或者删除文章等需要更新 sitemap.xml 的操作里面,保证能触发就可以了,参考:ArticlesController.php#L103

队列的使用

实现了事件之后,实际上只是将代码从正常发表文章的逻辑中抽离了,实际执行起来,它还是同步的。也就是说,如果生成 sitemap.xml 的过程需要 1 分钟,那么,在发表文章的时候,就需要多等上 1 分钟,这显然还是对我们发表文章的过程产生了影响。我们想要的是让生成 sitemap.xml 的过程异步去执行,我们只是在发表文章时触发它,它自己选择合适的时机去执行,我们不用多等 1 分钟就能将文章发表了。

这就用到了队列,我们将这个任务放入队列中,队列来决定什么时候执行这个任务。

Laravel 中使用队列只需要在事件监听器中实现 ShouldQueue 接口就可以了,代码见上面 GenSiteMapListener.php,然后我们还要对队列做一些配置工作,配置 Laravel 采用哪种方式实现队列和采取什么样的策略。

队列的配置

除了同步执行的队列 sync 以外,Laravel 支持 database、beanstalkd、sqs 以及 redis 多种队列实现策略,这里只介绍 redis 策略的配置,其他配置方式可参考 Laravel 的队列配置文件 config/queue.php 和官方相关说明文档。使用 redis 方式的队列,需要在 .env 文件中配置:

QUEUE_CONNECTION=redis

config/queue.php 中 redis 的配置节点保持默认就可以了,需要注意的是失败队列的配置,也就是以下部分;

'failed' => [
        'driver' => env('QUEUE_FAILED_DRIVER', 'database'),
        'database' => env('DB_CONNECTION', 'mysql'),
        'table' => 'failed_jobs',
    ],

这里面的配置也不需要更改,需要留意的是 table 中的 failed_jobs 这个表,laravel 默认是不会生成的,需要我们执行 php artisan queue:failed-table 生成 migration,然后执行 php artisan migrate 来生成 failed_jobs 表。

以上,采用 redis 的 laravel 队列配置完成。要让它开始工作,还得执行:

php artisan queue:work --tries=3

其中,--tries=3 参数给出了默认尝试的次数,失败后会记录到上一步生成的 failed-table 表中,否则队列将不停尝试。

另外,这是个需要在后台一直执行的任务,需要我们给它配置守护进程。laravel 提供了使用 Supervisor 实现守护进程的方法,当然也可以使用其他方式实现,比如 ubuntu 系统的 service

技术 Laravel 事件 队列 queue event sitemap