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
显示到个人页面。