25.构建动作流

未匹配的标注

本节说明

  • 对应视频第 25 小节:How to Construct an Activity Feeds

本节内容

在之前的章节中,我们在个人中心页面展示的是创建过的话题。本节我们把个人中心显示的内容改为用户的活动记录,例如发表话题、新建回复等类似的动作。首先我们新建单元测试文件:
forum\tests\Unit\ActivityTest.php

<?php

namespace Tests\Unit;

use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class ActivityTest extends TestCase
{
    use DatabaseMigrations;

    /** @test */
    public function it_records_activity_when_a_thread_is_created()
    {
        $this->signIn();

        $thread = create('App\Thread');

        $this->assertDatabaseHas('activities',[
           'type' => 'created_thread',
           'user_id' => auth()->id(),
           'subject_id' => $thread->id,
           'subject_type' => 'App\Thread'
        ]);
    }
}

我们建立了第一个测试,当新建一个话题时,也会同时在数据库的activities表中存入一条记录。可以看到我们对activities表结构的主要字段有typeuser_idsubject_id,subject_type。接下来我们建立Activity模型并生成迁移文件:

$ php artisan make:model Activity -m

我们利用模型的监听功能,当创建thread后,再创建一条记录,即activity
forum\app\Thread.php

.
.
static::deleting(function ($thread) {
    $thread->replies()->delete();
});

static::created(function ($thread){
   Activity::create([
       'user_id' => auth()->id(),
       'type' => 'created_thread',
       'subject_id' => $thread->id,
       'subject_type' => 'App\Thread'
   ] );
});
.

运行一下测试:

$ APP_ENV=testing phpunit --filter it_records_activity_when_a_thread_is_created

file
我们看到其中的关键信息 MassAssignmentException ,即批量赋值异常:

在使用 Laravel 进行项目开发时,我们需要考虑到,当一些不怀好意的用户将类似 is_admin 这样的字段也嵌入到表单中进行提交时,会有怎样的后果?其后果是用户能够将自己指定为管理员,并进行一些只有管理员才能执行的操作,如删除用户,删除帖子等,这也就是我们常说的『批量赋值』的错误。

修复的方法很简单,我们在Activity模型添加$guarded = []属性(此种做法存在隐患,后期会进行修复):
forum\app\Activity.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Activity extends Model
{
    protected $guarded = [];
}

再次测试:
file
因为我们还未修改迁移文件,前往修改:
forum\database\migrations{timestamp}_create_activities_table.php

<?php

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

class CreateActivitiesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('activities', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('user_id')->index();
            $table->unsignedInteger('subject_id')->index();
            $table->string('subject_type',50);
            $table->string('type',50);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('activities');
    }
}

再次测试:
file
测试已经通过了,但是我们仍有工作要做。思考一下,记录activity这个动作我们在其他地方也会用到。如果我们像这样写代码:

.
.
static::created(function ($thread){
   Activity::create([
       'user_id' => auth()->id(),
       'type' => 'created_thread',
       'subject_id' => $thread->id,
       'subject_type' => 'App\Thread'
   ] );
});
.
.

以后势必要重复写类似的,为了避免重复,我们将记录activity这个动作抽象成一个可复用的方法:

.
.
protected static function boot()
{
    parent::boot();

    static::addGlobalScope('replyCount',function ($builder){
       $builder->withCount('replies');
    });

    static::deleting(function ($thread) {
        $thread->replies()->delete();
    });

    static::created(function ($thread){
       $thread->recordActivity('created');
    });
}

protected function recordActivity($event)
{
    Activity::create([
        'user_id' => auth()->id(),
        'type' => $event . '_' . strtolower((new \ReflectionClass($this))->getShortName()),
        'subject_id' => $this->id,
        'subject_type' => get_class($this)
    ] );
}
.
.

再次测试:
file
还可以更简洁一点:

.
.
protected static function boot()
{
    parent::boot();

    static::addGlobalScope('replyCount',function ($builder){
       $builder->withCount('replies');
    });

    static::deleting(function ($thread) {
        $thread->replies()->delete();
    });

    static::created(function ($thread){
       $thread->recordActivity('created');
    });
}

protected function recordActivity($event)
{
    Activity::create([
        'user_id' => auth()->id(),
        'type' => $this->getActivityType($event),
        'subject_id' => $this->id,
        'subject_type' => get_class($this)
    ] );
}

protected function getActivityType($event)
{
    return $event . '_' . strtolower((new \ReflectionClass($this))->getShortName());
}
.
.

前面说过,我们抽象记录activity这个动作的目的是为了可复用,所以我们将代码片段抽取到一个Trait中:
forum\app\RecordsActivity.php

<?php

namespace App;

trait RecordsActivity
{

    protected static function bootRecordsActivity()
    {
        static::created(function ($thread){
            $thread->recordActivity('created');
        });
    }

    protected function recordActivity($event)
    {
        Activity::create([
            'user_id' => auth()->id(),
            'type' => $this->getActivityType($event),
            'subject_id' => $this->id,
            'subject_type' => get_class($this)
        ]);
    }

    protected function getActivityType($event)
    {
        return $event . '_' . strtolower((new \ReflectionClass($this))->getShortName());
    }
}

forum\app\Thread.php

<?php

namespace App;

use function foo\func;
use Illuminate\Database\Eloquent\Model;

class Thread extends Model
{
    use RecordsActivity; -->与之前相比,仅仅多了一行引用

    protected $guarded = [];
    protected $with = ['creator','channel'];

    protected static function boot()
    {
        parent::boot();

        static::addGlobalScope('replyCount',function ($builder){
           $builder->withCount('replies');
        });

        static::deleting(function ($thread) {
            $thread->replies()->delete();
        });
    }

    public function path()
    {
        return "/threads/{$this->channel->slug}/{$this->id}";
    }

    public function replies()
    {
        return $this->hasMany(Reply::class);
    }

    public function creator()
    {
        return $this->belongsTo(User::class,'user_id'); // 使用 user_id 字段进行模型关联
    }

    public function channel()
    {
        return $this->belongsTo(Channel::class);
    }

    public function addReply($reply)
    {
        $this->replies()->create($reply);
    }

    public function scopeFilter($query,$filters)
    {
        return $filters->apply($query);
    }
}

再次测试:
file
然而,我们可以继续抽象。你应该知道,Activity模型和其他模型实际上是存在 多态关联 的,基于此,我们继续抽象:
forum\app\RecordsActivity.php

<?php

namespace App;

trait RecordsActivity
{

    protected static function bootRecordsActivity()
    {
        static::created(function ($thread){
            $thread->recordActivity('created');
        });
    }

    protected function recordActivity($event)
    {
        $this->activity()->create([
            'user_id' => auth()->id(),
            'type' => $this->getActivityType($event)
        ]);
    }

    protected function activity()
    {
        return $this->morphMany('App\Activity','subject');
    }

    protected function getActivityType($event)
    {
        $type = strtolower((new \ReflectionClass($this))->getShortName());

        return "{$event}_{$type}";
    }
}

运行测试,仍然通过:
file
我们现在知道,Activity模型与Thread模型存在多态关联,所以我们让编写的测试更加严谨:创建thread之后创建activity,并且验证创建的activity是该thread关联的那个。
forum\tests\Unit\ActivityTest.php

<?php

namespace Tests\Unit;

use App\Activity;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class ActivityTest extends TestCase
{
    use DatabaseMigrations;

    /** @test */
    public function it_records_activity_when_a_thread_is_created()
    {
        $this->signIn();

        $thread = create('App\Thread');

        $this->assertDatabaseHas('activities',[
           'type' => 'created_thread',
           'user_id' => auth()->id(),
           'subject_id' => $thread->id,
           'subject_type' => 'App\Thread'
        ]);

        $activity = Activity::first(); // 当前测试中,表里只存在一条记录

        $this->assertEquals($activity->subject->id,$thread->id);
    }
}

运行测试:
file
进行模型关联:
forum\app\Activity.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Activity extends Model
{
    protected $guarded = [];

    public function subject()
    {
        return $this->morphTo();
    }
}

再次测试:
file
接下来我们可以进行下一步,测试创建reply后也创建activity
forum\tests\Unit\ActivityTest.php

.
.
/** @test */
public function it_records_activity_when_a_reply_is_created()
{
    $this->signIn();

    $reply = create('App\Reply');

    $this->assertEquals(2,Activity::count());
}
.

这里需要解释一下为什么我们期望的activity的数量是 2 。看一下我们的模型工厂:
forum\database\factories\ModelFactory.php

.
.
$factory->define(App\Reply::class,function ($faker){
    return [
        'thread_id' => function () {
            return factory('App\Thread')->create()->id;
        },
        'user_id' => function () {
            return factory('App\User')->create()->id;
        },
        'body' => $faker->paragraph,
    ];
});

我们新建一个reply的同时,会新建一个thread,所以就会存在 2 个activity。运行一下测试:
file
可以看到只有 1 个activity,即threadactivity。得益于我们将创建activity的动作抽取成Trait,所以现在我们只需在Reply模型文件引用即可:
forum\app\Reply.php

.
.
class Reply extends Model
{
    use Favoritable,RecordsActivity;  -->此处加上引用即可
    .
    .

再次测试:
file
运行数据迁移,创建数据表:

$ php artisan migrate

因为我们的测试是通过的,所以即使我们没有通过浏览器进行验证,我们依然可以确信功能会通过。我们来进行验证:
file
我们新建了一个话题,并添加了一个回复,所以表中会存在两条记录:
file
仔细想一下,以后我们对activity的监听不仅仅是create动作,以后还有别的监听动作。我们现在重构下RecordsActivity

.
.
trait RecordsActivity
{

    protected static function bootRecordsActivity()
    {
        foreach (static::getActivitiesToRecord() as $event){
            static::$event(function ($model) use ($event){
               $model->recordActivity($event);
            });
        }
    }

    protected static function getActivitiesToRecord()
    {
        return ['created'];
    }
    .
    .

我们将需要监听的动作放在getRecordEvents方法中,若要增加需监听的动作,只需在返回的数组中增加即可。运行测试:
file
运行全部测试:
file
可以看到报了很多错误,因为在这些测试当中我们测试地是不需要登录的情况,所以user_id为空。我们修复它:
forum\app\RecordsActivity.php

.
.
protected static function bootRecordsActivity()
{
    if(auth()->guest()) return ;

    foreach (static::getActivitiesToRecord() as $event){
        static::$event(function ($model) use ($event){
           $model->recordActivity($event);
        });
    }
}
.
.

再次运行全部测试:
file
测试通过,我们将在下一节将activity显示到个人页面。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。