进阶篇(七):Eloquent 模型关联关系(上)


我们所熟知的 MySQL、SQL Server、Oracle 都是关系型数据库,何谓关系型数据库?简单来说就是数据表之间存在关联关系。到目前为止,我们介绍的所有 Eloquent 模型操作都是针对单表的,接下来我们将花三篇左右的篇幅来给大家介绍如何在 Eloquent 模型类中建立模型之间的各种关联关系,以及如何实现关联查询和更新。

Eloquent 模型支持的关联关系包括以下七种:

  • 一对一
  • 一对多
  • 多对多
  • 远层一对多
  • 多态关联(一对一)
  • 多态关联(一对多)
  • 多态关联(多对多)

下面我们将以设计一个简单的博客系统数据库为例一一介绍上述关联关系。

一对一

建立关联关系

一对一是最简单的关联关系,一般可用于某张数据表的扩展表与主表之间的关联关系。比如在大型系统中,我们的用户表通常用于最基本信息的存储,如邮箱、用户名、密码等,然后像用户爱好、标签、个性签名、所在地等信息都存到另一张扩展表中,需要的时候才会去扩展表取数据,从而提高查询性能。针对这样的场景,我们就可以在两张表对应模型之间建立一对一关联。

在开始之前,我们先通过数据库迁移创建一张 user_profiles 数据表,并创建对应模型 UserProfile,这可以通过以下 Artisan 命令一次完成:

 php artisan make:model UserProfile -m

在生成的 create_user_profiles 迁移文件中编写迁移类的 up 方法如下:

public function up()
{
    Schema::create('user_profiles', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('user_id')->unsigned()->default(0)->unique();
        $table->string('bio')->nullable()->comment('个性签名');
        $table->string('city')->nullable()->comment('所在城市');
        $table->json('hobby')->nullable()->comment('个人爱好');
        $table->timestamps();
    });
}

注意,我们在 user_profiles 表中添加了一个 user_id 字段用于指向所属用户,从而建立于 users 表的关联。运行 php artisan migrate 在数据库创建这张数据表。

准备好数据表之后,接下来,我们来通过模型类建立 users 表和 user_profiles 表之间的关联,Eloquent 模型类底层提供了相应的 API 方法帮助我们建立模型之间的关联。首先,我们在 User 模型类中通过 hasOne 方法定义其与 UserProfile 的一对一关联:

public function profile()
{
    return $this->hasOne(UserProfile::class);
}

我们通过数据库填充技术user_profiles 插入一些数据,这样就可以在 User 模型实例上通过关联方法名作为动态属性访问与其对应的 UserProfile 模型实例了:

$user = User::findOrFail(1);
$profile = $user->profile;

打印 $profile 结果如下:

Eloquent 底层约定

需要指出的是,在关联关系的建立过程中,Eloquent 也遵循了「约定大于配置」的原则。你可能注意到了我们在定义关联关系时,仅仅指定了模型类名,并没有指定通过哪些数据表字段建立关联,这并不是说 Laravel 神通广大,能知过去未来之事,而是因为 Eloquent 对此做了默认的约定。hasOne 方法的完整签名是:

public function hasOne($related, $foreignKey = null, $localKey = null)

其中,第一个参数是关联模型的类名,第二个参数是关联模型类所属表的外键,这里对应的是 user_profiles 表的 user_id 字段,第三个参数是关联表的外键关联到当前模型所属表的哪个字段,这里对应的是 users 表的 id 字段。为什么我们不需要指定 Laravel 就能完成这种关联呢,这是因为如果没有指定 $foreignKey,Eloquent 底层会通过如下方法去拼接:

public function getForeignKey()
{
    return Str::snake(class_basename($this)).'_'.$this->getKeyName();
}

你可以看到,在本例中,拼接的结果正好是 user_id

同样,没有指定 $localKey 的话,Eloquent 底层会返回主键 ID:

public function getKeyName()
{
    return $this->primaryKey;
}

在本例中,就是 id 了。

遵循这种默认的约定,可以帮我们少写很多代码,减少很多额外的配置,所以如果不是迫不得已(比如从其他系统迁移过来),建议你在使用 Eloquent 的话,尽量遵循这些默认约定。如果数据表没有遵循这种约定的话,只能手动传参了。

建立相对的关联关系

通常我们都是通过 User 模型获取 UserProfile 模型,但是有时候我们可能需要反过来通过 UserProfile 反查所属的 User 模型,Eloquent 底层也为我们提供了相应的 belongsTo 方法来建立相对的一对一关联关系,我们在 UserProfile 模型类定义其与 User 模型的关联如下:

public function user()
{
    return $this->belongsTo(User::class);
}

同样,采用关联关系方法名作为动态属性即可访问该模型所属 User 模型实例:

$profile = UserProfile::findOrFail(2);
$user = $profile->user;

打印 $user 用户信息如下:

同样,和 hasOne 方法一样,belongsTo 方法也是遵循了默认的约定规则,其完整方法签名如下:

public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null)

其中第一个参数是关联模型的类名。

第二个参数是当前模型类所属表的外键,在本例中是 user_profiles 表的 user_id 字段,拼接规则和 hasOne 那里类似,只不过这里是基于第四个参数关联关系名称 $relation

if (is_null($relation)) {
    $relation = $this->guessBelongsToRelation();
}

...

if (is_null($foreignKey)) {
    $foreignKey = Str::snake($relation).'_'.$instance->getKeyName();
}

$relation 默认约定是对应关联关系方法名,这里的是 user。如果你这里定义的方法名不是 user,则需要手动指定外键参数。

第三个参数是关联模型类所属表的主键:

$instance = $this->newRelatedInstance($related);
...
$ownerKey = $ownerKey ?: $instance->getKeyName();

第四个参数前面已经说过,默认约定就是关联关系方法名,也是关联关系动态属性名。

还是和之前一样,建议遵循这些默认约定,可以少写很多代码,避免配置出错导致程序bug。如果实在满足不了这些默认的约定,只能指定按照上述约定指定传入自己的参数了。

一对一关联很简单,但是我们还是花了很长的篇幅来讨论,因为后面其它关联的实现思路、访问方式、底层约定都是类似的,掌握了一对一关联,就能更好的理解和掌握其它关联关系的创建和使用。

一对多

建立关联关系

一对多关联是我们日常开发中经常碰到的一种关联关系。以博客系统为例,一个用户可以发布多篇文章,反过来,一篇只能归属于一个用户,那么用户和文章之间就是一对多的关系,同样,用户可以发布多条评论,一条评论只能归属于一个用户,用户与评论之间也是一对多关系。

要定义用户文章之间的一对多关联,可以在 User 模型类中通过 Eloquent 底层提供的 hasMany 方法来实现:

public function posts()
{
    return $this->hasMany(Post::class);
}

由于我们之间已经创建过 users 表和 posts 表,并且初始化过数据,所以我们可以直接通过动态属性的方式来调用用户模型上的文章:

$user = User::findOrFail(1);
$posts = $user->posts;

hasOne 返回的是单个模型实例不一样,hasMany 返回的是模型类集合:

Eloquent 底层约定

hasOne 方法一样,hasMany 方法底层也对如何建立关联关系做了约定,而且 hasMany 方法和 hasOne 方法的签名一样:

public function hasMany($related, $foreignKey = null, $localKey = null)

$foreignKey$localKey 默认获取逻辑也和 hasOne 完全一样,这里不再赘述。其实你完全可以把一对一关联看作一对多关联的简化版本,只不过一对一退化为只返回一条记录,所以实现逻辑一样也不难理解了。

如果你的数据表结构不符合这种默认约定,可以自定义传入对应字段参数值。

建立相对的关联关系

与一对一一样,我们可以在文章模型中建立于用户模型之间的相对关联关系,而且这种使用场景很普遍,比如在文章详细页或列表页显示文章作者信息。还是通过 Eloquent 提供的 belongsTo 方法来实现:

public function user()
{
    return $this->belongsTo(User::class);
}

这样,我们就可以在文章模型实例上通过动态属性 user 来访问对应的用户信息:

$post = Post::findOrFail(29);
$author = $post->user;

belongsTo 方法的底层约定我们在前面一对一关联中已经讨论过。这里,如果你想要让代码可读性更好,可以将 Post 模型中的关联关系调方法名修改为 author,这样,我们就需要手动指定更多的 belongsTo 方法传入参数了:

public function author()
{
    return $this->belongsTo(User::class, 'user_id', 'id', 'author');
}

相应的访问方式也需要做调整:

$author = $post->author;

返回结果完全一样。

渴求式加载

前面我们演示的关联关系查询都是通过动态属性的方式,这种加载方式叫做「懒惰式加载」,因为都是用到的时候才回去查询,这就意味着要多次对数据库的进行查询才能返回需要的结果。如果是单条记录获取关联关系,就需要两次查询;如果是多条记录获取关联关系,比如文章列表页获取作者信息,因为每篇文章的作者通过动态属性获取都有一次查询,所以对N条记录来说,需要「N+1」次查询才能返回需要的结果,从数据库查询优化的角度来说,显然是不合理的。能不能一次就返回所有的关联查询结果呢?

可以,Eloquent 为我们提供了 with 方法,我们将需要查询的关联关系动态属性(关联方法名)传入该方法,并将其链接到 Eloquent 模型原有的查询中,就可以一次完成关联查询,加上模型自身查询,总共查询两次。我们将这种加载方式叫做「渴求式加载」,即根据所需预先查询所有数据。

以文章列表为例,我们可以通过这种方式获取文章及对应作者信息:

$posts = Post::with('author')
    ->where('views', '>', 0)
    ->offset(1)->limit(10)
    ->get();

对应的底层 SQL 执行语句是:

select * from `posts` where `views` > 0 and `posts`.`deleted_at` is null limit 10 offset 0;
select * from `users` where `users`.`id` in (?, ?, ?, ?, ?, ?) and `email_verified_at` is not null

这样,就可以在返回的列表中看到关联的作者信息了,在遍历的时候可以通过 $post->author 获取,而无需每次加载,从而提高数据库查询性能:

多对多

建立关联关系

多对多关联也很常见,还是以博客系统为例,我们会为每篇文章设置标签,一篇文章往往有多个标签,反过来,一个标签可能会归属于多篇文章,这时,我们说文章和标签之间是多对多的关联关系。

多对多关联比一对一和一对多关联复杂一些,需要借助一张中间表才能建立关联关系。以文章标签为例,文章表已经存在了,还需要创建一张 tags 表和中间表 post_tags。首先创建 Tags 模型类及其对应数据表 tags 迁移文件:

php artisan make:model Tag -m

编写 create_tags_table 迁移文件对应类的 up 方法如下:

public function up()
{
    Schema::create('tags', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name', 100)->unique()->comment('标签名');
        $table->timestamps();
    });
}

然后创建 post_tags 数据表迁移文件:

php artisan make:migration create_post_tags_table --create=post_tags

编写其对应迁移类的 up 方法如下:

public function up()
{
    Schema::create('post_tags', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('post_id')->unsigned()->default(0);
        $table->integer('tag_id')->unsigned()->default(0);
        $table->unique(['post_id', 'tag_id']);
        $table->timestamps();
    });
}

运行 php artisan migrate 让迁移生效。

接下来,我们在 Post 模型类中定义其与 Tags 模型类的关联关系,通过 Eloquent 提供的 belongsToMany 方法来实现:

public function tags()
{
    return $this->belongsToMany(Tag::class, 'post_tags');
}

通过数据库填充器填充一些数据到 tags 表和 post_tags 表,这样我们就可以通过关联查询查询指定 Post 模型上的标签信息了:

$post = Post::findOrFail(1);
$tags = $post->tags;

返回的是个模型集合:

当然,你也可以通过前面提到的渴求式加载方式获取标签数据:

$post = Post::with('tags')->find(1);
$tags = $post->tags;

返回结果和上面一样,但减少了对数据库的查询次数。

Eloquent 底层约定

可以看到我们在定义多对多关联的时候,也没有指定通过哪些字段进行关联,这同样是遵循 Eloquent 底层默认约定的功劳,belongsToMany 方法签名如下:

public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null, $relation = null)

除了第一个参数之外,其它参数都可以为空。第一个参数是关联模型的类名,这里是 Tag

第二个参数 $table 是建立多对多关联的中间表名,该表名默认拼接规则如下:

$segments = [
    $instance ? $instance->joiningTableSegment()
                  : Str::snake(class_basename($related)),
    $this->joiningTableSegment(),
];

sort($segments);

return strtolower(implode('_', $segments));

其中 $this->joiningTableSegment() 将当前模型类名转化为小写字母+下划线格式(注意不是复数格式,所以并不是对应默认表名),$instance 对应关联模型类实例,如果为空的话返回Str::snake(class_basename($related)),也会将关联类名转化为小写字母+下划线格式(也不是表名),然后对转化后的字符片段按字母表排序。所以本例中如果不指定中间表名,按照默认约定该值是 post_tag。但是为了遵循 Laravel 数据表名都是复数,所以我这里自定义了一回。

第三个参数是 $foreignPivotKey 指的是中间表中当前模型类的外键,默认拼接规则和前面一对一、一对多一样,所以在本例中是 posts 表的 post_id 字段。我在建表的时候遵循了这个约定,所以不需要额外指定。

第四个参数 $relatedPivotKey 是中间表中当前关联模型类的外键,拼接规则和 $foreignPivotKey 一样,只不过作用于关联模型类,所以在本例中是 tags 表的 tag_id 字段。同样,我在建表的时候也遵循了这一约定,不需要额外指定。

第五个参数 $parentKey 表示对应当前模型的哪个字段(即 $foreignPivotKey 映射到当前模型所属表的哪个字段),默认是主键 ID,即 posts 表的 id 字段,所以这里不需要额外指定。

第六个参数 $relatedKey 表示对应关联模型的哪个字段(即 $relatedPivotKey 映射到关联模型所属表的哪个字段),默认是关联模型的主键 ID,即 tags 表的 id 字段,所以这里也不需要额外指定。

最后一个参数 $relation 表示关联关系名称,用于设置查询结果中的关联属性,默认是关联方法名。

如果你没有遵循上述约定,需要手动指定自己的参数字段,不过还是建议遵循这些默认的约定,不然写着写着容易把自己绕晕。

建立相对的关联关系

与之前的关联关系一样,多对多关联也支持建立相对的关联关系,而且由于多对多的双方是平等的,不存在谁归属谁的问题,所以建立相对关联的方法都是一样的,我们可以在 Tag 模型中通过 belongsToMany 方法建立其与 Post 模型的关联关系:

public function posts()
{
    return $this->belongsToMany(Post::class, 'post_tags');
}

比如博客的标签页,通过指定标签查看归属该标签下的所有文章,就可以用到类似的关联查询,相应的实现代码如下:

$tag = Tag::with('posts')->where('name', 'ab')->first();
$posts = $tag->posts;

对应的查询结果如下:

获取中间表字段

Eloquent 还提供了方法允许你获取中间表的字段,你仔细看查询结果字段,会发现 relations 字段中有一个 pivot 属性,中间表字段就存放在这个属性对象上:

我们在遍历返回结果的时候可以在循环中通过 $post->pivot->tag_id 获取中间表字段值。不过中间表默认只返回关联模型的主键字段,如果要返回额外字段,需要在定义关联关系的时候手动指定,比如如果想要返回时间戳信息,可以这么定义:

public function tags()
{
    return $this->belongsToMany(Tag::class, 'post_tags')->withTimestamps();
}

这样就可以返回文章标签创建时间和更新时间了:

如果除此之外,你还在中间表中定义了额外的字段信息,比如 user_id,可以通过 with 方法传入字段然后将其返回:

public function tags()
{
    return $this->belongsToMany(Tag::class, 'post_tags')->withPivot('user_id')->withTimestamps();
}

自定义中间表模型类

你还可以通过自定义中间表对应模型类实现更多自定义操作,中间表模型类继承自 Illuminate\Database\Eloquent\Relations\PivotPivot 也是 Eloquent Model 类的子类,只不过为中间表操作定义了很多方法和属性,比如我们创建一个自定义的中间表模型类 PostTag

namespace App;

use Illuminate\Database\Eloquent\Relations\Pivot;

class PostTag extends Pivot
{
    protected $table = 'post_tags';
}

这样,我们在定义多对多关联关系的时候指定自定义的模型类了:

public function tags()
{
    return $this->belongsToMany(Tag::class, 'post_tags')->using(PostTag::class);
}

更多中间表操作

此外,如果你觉得 pivot 可读性不好,你还可以自定义中间表实例属性名称:

$this->belongsToMany(Tag::class, 'post_tags')->as('taggable')->withTimestamps();

这样,就可以通过 $tag->taggable->created_at 访问中间表字段值了。

还可以通过中间表字段值过滤关联数据(支持 wherein 查询):

return $this->belongsToMany(Tag::class, 'post_tags')->wherePivot('user_id', 1);
return $this->belongsToMany(Tag::class, 'post_tags')->wherePivotIn('user_id', [1, 2]);

好了,今天先到这,明天我们继续探讨更高级的关联关系。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 进阶篇(六):Eloquent 模型事件和监听方式大全

>> 下一篇: 进阶篇(八):Eloquent 模型关联关系(中)