1. 前言
像多态一对多的这种关系,在开发过程中很容易遇到。假设我们会发表文章和视频两种类型的作品,用户可以对它们进行评论,在 Laravel 中可以这样实现:
表结构
posts
id - integer
title - string
body - text
videos
id - integer
title - string
url - string
comments
id - integer
body - text
commentable_id - integer
commentable_type - string
模型结构
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Comment extends Model
{
/**
* Get the parent commentable model (post or video).
*/
public function commentable(): MorphTo
{
return $this->morphTo();
}
}
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Post extends Model
{
/**
* Get all of the post's comments.
*/
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Video extends Model
{
/**
* Get all of the video's comments.
*/
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
如果我们采用下面这样文章和视频分开的方式,优点是很容易获取 commentable
对象,缺点也显而易见,代码繁琐,每个模型都要实现一次,如果后面增加其他类型的可评论作品,工作量会更大:
Route::post('posts/{post}/comments', ...);
Route::delete('posts/{post}/comments/{comment}', ...);
Route::post('videos/{video}/comments', ...);
Route::delete('videos/{video}/comments/{comment}', ...);
另一种实现方式如下:
Route::post('comments', ...);
Route::delete('comments/{comment}', ''');
commentable
的传参不放在路由中,转而使用表单参数 commentable_type
和 commentable_id
实现,这样的好处是只需要在一个地方统一处理评论。但是出现了一个问题:如何在表单验证中对提交的 commentable
信息进行有效验证。
2. 解决方案
为了可复用性,我们使用自定义验证规则 PolyExists
来实现:
PolyExists.php
<?php
namespace App\Rules;
use App\Models\Post;
use App\Models\Video;
use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PolyExists implements DataAwareRule, ValidationRule
{
protected array $data = [];
protected array $allowedClasses = [];
public function __construct(array $allowedClasses)
{
$this->allowedClasses = $allowedClasses;
}
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$polyType = Str::replace('_id', '_type', $attribute);
$objectType = Arr::get($this->data, $polyType, false);
// 如果模型类不是允许的类,或者数据库中找不到对应的对象实例,那么验证不通过
if (! in_array($objectType, $this->allowedClasses) || ! resolve($objectType)->where('id', $value)->exists()) {
$fail('validation.unprocessable')->translate();
}
}
public function setData(array $data)
{
$this->data = $data;
return $this;
}
}
使用方式如下:
<?php
namespace App\Http\Requests\Comment;
use App\Rules\PolyExists;
use Illuminate\Foundation\Http\FormRequest;
class CommentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'commentable_id' => ['required', 'integer', new PolyExists([Post::class, Video::class])],
];
}
protected function passedValidation()
{
// 验证通过后,在请求中注入 commentable 对象实例,方便在控制器或其他地方使用
$commentable = resolve($this->commentable_type)::where('id', $this->commentable_id)->first();
$this->merge(['commentable' => $commentable]);
}
}