25.构建动作流
- 本系列文章为
 laracasts.com的系列视频教程——Let's Build A Forum with Laravel and TDD 的学习笔记。若喜欢该系列视频,可去该网站订阅后下载该系列视频, 支持正版 ;- 视频源码地址:github.com/laracasts/Lets-Build-a-...;
 - 本项目为一个 forum(论坛)项目,与本站的第二本实战教程 《Laravel 教程 - Web 开发实战进阶》 类似,可互相参照。
 
本节说明
- 对应视频第 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表结构的主要字段有type,user_id,subject_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

我们看到其中的关键信息 MassAssignmentException ,即批量赋值异常:
在使用 Laravel 进行项目开发时,我们需要考虑到,当一些不怀好意的用户将类似
is_admin这样的字段也嵌入到表单中进行提交时,会有怎样的后果?其后果是用户能够将自己指定为管理员,并进行一些只有管理员才能执行的操作,如删除用户,删除帖子等,这也就是我们常说的『批量赋值』的错误。
修复的方法很简单,我们在Activity模型添加$guarded = []属性(此种做法存在隐患,后期会进行修复):
forum\app\Activity.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Activity extends Model
{
    protected $guarded = [];
}
再次测试:
因为我们还未修改迁移文件,前往修改:
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');
    }
}
再次测试:
测试已经通过了,但是我们仍有工作要做。思考一下,记录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)
    ] );
}
.
.
再次测试:
还可以更简洁一点:
.
.
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);
    }
}
再次测试:
然而,我们可以继续抽象。你应该知道,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}";
    }
}
运行测试,仍然通过:
我们现在知道,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);
    }
}
运行测试:
进行模型关联:
forum\app\Activity.php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Activity extends Model
{
    protected $guarded = [];
    public function subject()
    {
        return $this->morphTo();
    }
}
再次测试:
接下来我们可以进行下一步,测试创建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。运行一下测试:
可以看到只有 1 个activity,即thread的activity。得益于我们将创建activity的动作抽取成Trait,所以现在我们只需在Reply模型文件引用即可:
forum\app\Reply.php
.
.
class Reply extends Model
{
    use Favoritable,RecordsActivity;  -->此处加上引用即可
    .
    .
再次测试:
运行数据迁移,创建数据表:
$ php artisan migrate
因为我们的测试是通过的,所以即使我们没有通过浏览器进行验证,我们依然可以确信功能会通过。我们来进行验证:
我们新建了一个话题,并添加了一个回复,所以表中会存在两条记录:
仔细想一下,以后我们对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方法中,若要增加需监听的动作,只需在返回的数组中增加即可。运行测试:
运行全部测试:
可以看到报了很多错误,因为在这些测试当中我们测试地是不需要登录的情况,所以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);
        });
    }
}
.
.
再次运行全部测试:
测试通过,我们将在下一节将activity显示到个人页面。
          
TDD 构建 Laravel 论坛笔记
                    
                    
            
            
                关于 LearnKu
              
                    
                    
                    
 
推荐文章: