模型關聯
上篇文章我們主要講了Eloquent Model關於基礎的CRUD方法的實現,Eloquent Model中除了基礎的CRUD外還有一個很重要的部分叫模型關聯,它通過物件導向的方式優雅地把資料表之間的關聯關係抽象到了Eloquent Model中讓應用依然能用Fluent Api的方式訪問和設定主體資料的關聯資料。使用模型關聯給應用開發帶來的收益我認為有以下幾點
- 主體資料和關聯資料之間的關係在程式碼表現上更明顯易懂讓人一眼就能明白資料間的關係。
- 模型關聯在底層幫我們解決好了資料關聯和匹配,應用程式中不需要再去寫join語句和子查詢,應用程式碼的可讀性和易維護性更高。
- 使用模型關聯預載入後,在效率上高於開發者自己寫join和子查詢,模型關聯底層是通過分別查詢主體和關聯資料再將它們關聯匹配到一起。
- 按照Laravel設定好的模式來寫關聯模型每個人都能寫出高效和優雅的程式碼 (這點我認為適用於所有的Laravel特性)。
說了這麼多下面我們就通過實際示例出發深入到底層看看模型關聯是如何解決資料關聯匹配和載入關聯資料的。
在開發中我們經常遇到的關聯大致有三種:一對一,一對多和多對多,其中一對一是一種特殊的一對多關聯。我們通過官方文件裡的例子來看一下Laravel是怎麼定義這兩種關聯的。
一對多
class Post extends Model
{
/**
* 獲得此部落格文章的評論。
*/
public function comments()
{
return $this->hasMany('App\Comment');
}
}
/**
* 定義一個一對多關聯關係,返回值是一個HasMany例項
*
* @param string $related
* @param string $foreignKey
* @param string $localKey
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function hasMany($related, $foreignKey = null, $localKey = null)
{
//建立一個關聯表模型的例項
$instance = $this->newRelatedInstance($related);
//關聯表的外來鍵名
$foreignKey = $foreignKey ?: $this->getForeignKey();
//主體表的主鍵名
$localKey = $localKey ?: $this->getKeyName();
return new HasMany(
$instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
);
}
/**
* 建立一個關聯表模型的例項
*/
protected function newRelatedInstance($class)
{
return tap(new $class, function ($instance) {
if (! $instance->getConnectionName()) {
$instance->setConnection($this->connection);
}
});
}
複製程式碼
在定義一對多關聯時返回了一個\Illuminate\Database\Eloquent\Relations\HasMany
類的例項,Eloquent封裝了一組類來處理各種關聯,其中HasMany
是繼承自HasOneOrMany
抽象類, 這也正印證了上面說的一對一是一種特殊的一對多關聯,Eloquent定義的所有這些關聯類又都是繼承自Relation
這個抽象類, Relation
裡定義裡一些模型關聯基礎的方法和一些必須讓子類實現的抽象方法,各種關聯根據自己的需求來實現這些抽象方法。
為了閱讀方便我們把這幾個有繼承關係類的構造方法放在一起,看看定義一對多關返回的HasMany例項時都做了什麼。
class HasMany extends HasOneOrMany
{
......
}
abstract class HasOneOrMany extends Relation
{
......
public function __construct(Builder $query, Model $parent, $foreignKey, $localKey)
{
$this->localKey = $localKey;
$this->foreignKey = $foreignKey;
parent::__construct($query, $parent);
}
//為關聯關係設定約束 子模型的foreign key等於父模型的 上面設定的$localKey欄位的值
public function addConstraints()
{
if (static::$constraints) {
$this->query->where($this->foreignKey, '=', $this->getParentKey());
$this->query->whereNotNull($this->foreignKey);
}
}
public function getParentKey()
{
return $this->parent->getAttribute($this->localKey);
}
......
}
abstract class Relation
{
public function __construct(Builder $query, Model $parent)
{
$this->query = $query;
$this->parent = $parent;
$this->related = $query->getModel();
//子類實現這個抽象方法
$this->addConstraints();
}
}
複製程式碼
通過上面程式碼看到建立HasMany例項時主要是做了一些配置相關的操作,設定了子模型、父模型、兩個模型的關聯欄位、和關聯的約束。
Eloquent裡把主體資料的Model稱為父模型,關聯資料的Model稱為子模型,為了方便下面所以下文我們用它們來指代主體和關聯模型。
定義完父模型到子模型的關聯後我們還需要定義子模型到父模型的反向關聯才算完整, 還是之前的例子我們在子模型裡通過belongsTo
方法定義子模型到父模型的反向關聯。
class Comment extends Model
{
/**
* 獲得此評論所屬的文章。
*/
public function post()
{
return $this->belongsTo('App\Post');
}
public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null)
{
//如果沒有指定$relation引數,這裡通過debug backtrace方法獲取呼叫者的方法名稱,在我們的例子裡是post
if (is_null($relation)) {
$relation = $this->guessBelongsToRelation();
}
$instance = $this->newRelatedInstance($related);
//如果沒有指定子模型的外來鍵名稱則使用呼叫者的方法名加主鍵名的snake命名方式來作為預設的外來鍵名(post_id)
if (is_null($foreignKey)) {
$foreignKey = Str::snake($relation).'_'.$instance->getKeyName();
}
// 設定父模型的主鍵欄位
$ownerKey = $ownerKey ?: $instance->getKeyName();
return new BelongsTo(
$instance->newQuery(), $this, $foreignKey, $ownerKey, $relation
);
}
protected function guessBelongsToRelation()
{
list($one, $two, $caller) = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
return $caller['function'];
}
}
class BelongsTo extends Relation
{
public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey, $relation)
{
$this->ownerKey = $ownerKey;
$this->relation = $relation;
$this->foreignKey = $foreignKey;
$this->child = $child;
parent::__construct($query, $child);
}
public function addConstraints()
{
if (static::$constraints) {
$table = $this->related->getTable();
//設定約束 父模型的主鍵值等於子模型的外來鍵值
$this->query->where($table.'.'.$this->ownerKey, '=', $this->child->{$this->foreignKey});
}
}
}
複製程式碼
定義一對多的反向關聯時也是一樣設定了父模型、子模型、兩個模型的關聯欄位和約束,此外還設定了關聯名稱,在Model的belongsTo
方法裡如果未提供後面的引數會通過debug_backtrace 獲取呼叫者的方法名作為關聯名稱進而猜測出子模型的外來鍵名稱的,按照約定Eloquent 預設使用父級模型名的「snake case」形式、加上 _id 字尾名作為外來鍵欄位。
多對多
多對多關聯不同於一對一和一對多關聯它需要一張中間表來記錄兩端資料的關聯關係,官方文件裡以使用者角色為例子闡述了多對多關聯的使用方法,我們也以這個例子來看一下底層是怎麼來定義多對多關聯的。
class User extends Model
{
/**
* 獲得此使用者的角色。
*/
public function roles()
{
return $this->belongsToMany('App\Role');
}
}
class Role extends Model
{
/**
* 獲得此角色下的使用者。
*/
public function users()
{
return $this->belongsToMany('App\User');
}
}
/**
* 定義一個多對多關聯, 返回一個BelongsToMany關聯關係例項
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null,
$parentKey = null, $relatedKey = null, $relation = null)
{
//沒有提供$relation引數 則通過debug_backtrace獲取呼叫者方法名作為relation name
if (is_null($relation)) {
$relation = $this->guessBelongsToManyRelation();
}
$instance = $this->newRelatedInstance($related);
$foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey();
$relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey();
//如果沒有提供中間表的名稱,則會按照字母順序合併兩個關聯模型的名稱作為中間表名
if (is_null($table)) {
$table = $this->joiningTable($related);
}
return new BelongsToMany(
$instance->newQuery(), $this, $table, $foreignPivotKey,
$relatedPivotKey, $parentKey ?: $this->getKeyName(),
$relatedKey ?: $instance->getKeyName(), $relation
);
}
/**
* 獲取多對多關聯中預設的中間表名
*/
public function joiningTable($related)
{
$models = [
Str::snake(class_basename($related)),
Str::snake(class_basename($this)),
];
sort($models);
return strtolower(implode('_', $models));
}
class BelongsToMany extends Relation
{
public function __construct(Builder $query, Model $parent, $table, $foreignPivotKey,
$relatedPivotKey, $parentKey, $relatedKey, $relationName = null)
{
$this->table = $table;//中間表名
$this->parentKey = $parentKey;//父模型User的主鍵
$this->relatedKey = $relatedKey;//關聯模型Role的主鍵
$this->relationName = $relationName;//關聯名稱
$this->relatedPivotKey = $relatedPivotKey;//關聯模型Role的主鍵在中間表中的外來鍵role_id
$this->foreignPivotKey = $foreignPivotKey;//父模型Role的主鍵在中間表中的外來鍵user_id
parent::__construct($query, $parent);
}
public function addConstraints()
{
$this->performJoin();
if (static::$constraints) {
$this->addWhereConstraints();
}
}
protected function performJoin($query = null)
{
$query = $query ?: $this->query;
$baseTable = $this->related->getTable();
$key = $baseTable.'.'.$this->relatedKey;
//$query->join('role_user', 'role.id', '=', 'role_user.role_id')
$query->join($this->table, $key, '=', $this->getQualifiedRelatedPivotKeyName());
return $this;
}
/**
* Set the where clause for the relation query.
*
* @return $this
*/
protected function addWhereConstraints()
{
//$this->query->where('role_user.user_id', '=', 1)
$this->query->where(
$this->getQualifiedForeignPivotKeyName(), '=', $this->parent->{$this->parentKey}
);
return $this;
}
}
複製程式碼
定義多對多關聯後返回一個\Illuminate\Database\Eloquent\Relations\BelongsToMany
類的例項,與定義一對多關聯時一樣,例項化BelongsToMany時定義裡與關聯相關的配置:中間表名、關聯的模型、父模型在中間表中的外來鍵名、關聯模型在中間表中的外來鍵名、父模型的主鍵、關聯模型的主鍵、關聯關係名稱。與此同時給關聯關係設定了join和where約束,以User類裡的多對多關聯舉例,performJoin
方法為其新增的join約束如下:
$query->join('role_user', 'roles.id', '=', 'role_user.role_id')
複製程式碼
然後addWhereConstraints
為其新增的where約束為:
//假設User物件的id是1
$query->where('role_user.user_id', '=', 1)
複製程式碼
這兩個的約束就是對應的SQL語句就是
SELECT * FROM roles INNER JOIN role_users ON roles.id = role_user.role_id WHERE role_user.user_id = 1
複製程式碼
遠層一對多
Laravel還提供了遠層一對多關聯,提供了方便、簡短的方式通過中間的關聯來獲得遠層的關聯。還是以官方文件的例子說起,一個 Country 模型可以通過中間的 User 模型獲得多個 Post 模型。在這個例子中,您可以輕易地收集給定國家的所有部落格文章。讓我們來看看定義這種關聯所需的資料表:
countries
id - integer
name - string
users
id - integer
country_id - integer
name - string
posts
id - integer
user_id - integer
title - string
複製程式碼
class Country extends Model
{
public function posts()
{
return $this->hasManyThrough(
'App\Post',
'App\User',
'country_id', // 使用者表外來鍵...
'user_id', // 文章表外來鍵...
'id', // 國家表本地鍵...
'id' // 使用者表本地鍵...
);
}
}
/**
* 定義一個遠層一對多關聯,返回HasManyThrough例項
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
public function hasManyThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null)
{
$through = new $through;
$firstKey = $firstKey ?: $this->getForeignKey();
$secondKey = $secondKey ?: $through->getForeignKey();
$localKey = $localKey ?: $this->getKeyName();
$secondLocalKey = $secondLocalKey ?: $through->getKeyName();
$instance = $this->newRelatedInstance($related);
return new HasManyThrough($instance->newQuery(), $this, $through, $firstKey, $secondKey, $localKey, $secondLocalKey);
}
class HasManyThrough extends Relation
{
public function __construct(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey)
{
$this->localKey = $localKey;//國家表本地鍵id
$this->firstKey = $firstKey;//使用者表中的外來鍵country_id
$this->secondKey = $secondKey;//文章表中的外來鍵user_id
$this->farParent = $farParent;//Country Model
$this->throughParent = $throughParent;//中間 User Model
$this->secondLocalKey = $secondLocalKey;//使用者表本地鍵id
parent::__construct($query, $throughParent);
}
public function addConstraints()
{
//country的id值
$localValue = $this->farParent[$this->localKey];
$this->performJoin();
if (static::$constraints) {
//$this->query->where('users.country_id', '=', 1) 假設country_id是1
$this->query->where($this->getQualifiedFirstKeyName(), '=', $localValue);
}
}
protected function performJoin(Builder $query = null)
{
$query = $query ?: $this->query;
$farKey = $this->getQualifiedFarKeyName();
//query->join('users', 'users.id', '=', 'posts.user_id')
$query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey);
if ($this->throughParentSoftDeletes()) {
$query->whereNull($this->throughParent->getQualifiedDeletedAtColumn());
}
}
}
複製程式碼
定義遠層一對多關聯會返回一個\Illuminate\Database\Eloquent\Relations\hasManyThrough
類的例項,例項化hasManyThrough
時的操作跟例項化BelongsToMany
時做的操作非常類似。
針對這個例子performJoin
為關聯新增的join約束為:
query->join('users', 'users.id', '=', 'posts.user_id')
複製程式碼
新增的where約束為:
$this->query->where('users.country_id', '=', 1) 假設country_id是1
複製程式碼
對應的SQL查詢是:
SELECT * FROM posts INNER JOIN users ON users.id = posts.user_id WHERE users.country_id = 1
複製程式碼
從SQL查詢我們也可以看到遠層一對多跟多對多生成的語句非常類似,唯一的區別就是它的中間表對應的是一個已定義的模型。
動態屬性載入關聯模型
上面我們定義了三種使用頻次比較高的模型關聯,下面我們再來看一下在使用它們時關聯模型時如何載入出來的。我們可以像訪問屬性一樣訪問定義好的關聯的模型,例如,我們剛剛的 User 和 Post 模型例子中,我們可以這樣訪問使用者的所有文章:
$user = App\User::find(1);
foreach ($user->posts as $post) {
//
}
複製程式碼
還記得我們上一篇文章裡講獲取模型的屬性時提到過的嗎? “如果模型的$attributes
屬性裡沒有這個欄位,那麼會嘗試獲取模型關聯的值”:
abstract class Model implements ...
{
public function __get($key)
{
return $this->getAttribute($key);
}
public function getAttribute($key)
{
if (! $key) {
return;
}
//如果attributes陣列的index裡有$key或者$key對應一個屬性訪問器`'get' . $key` 則從這裡取出$key對應的值
//否則就嘗試去獲取模型關聯的值
if (array_key_exists($key, $this->attributes) ||
$this->hasGetMutator($key)) {
return $this->getAttributeValue($key);
}
if (method_exists(self::class, $key)) {
return;
}
//獲取模型關聯的值
return $this->getRelationValue($key);
}
public function getRelationValue($key)
{
//取出已經載入的關聯中,避免重複獲取模型關聯資料
if ($this->relationLoaded($key)) {
return $this->relations[$key];
}
// 呼叫我們定義的模型關聯 $key 為posts
if (method_exists($this, $key)) {
return $this->getRelationshipFromMethod($key);
}
}
protected function getRelationshipFromMethod($method)
{
$relation = $this->$method();
if (! $relation instanceof Relation) {
throw new LogicException(get_class($this).'::'.$method.' must return a relationship instance.');
}
//通過getResults方法獲取資料,並快取到$relations陣列中去
return tap($relation->getResults(), function ($results) use ($method) {
$this->setRelation($method, $results);
});
}
}
複製程式碼
在通過動態屬性獲取模型關聯的值時,會呼叫與屬性名相同的關聯方法,拿到關聯例項後會去呼叫關聯例項的getResults
方法返回關聯的模型資料。 getResults
也是每個Relation子類需要實現的方法,這樣每種關聯都可以根據自己情況去執行查詢獲取關聯模型,現在這個例子用的是一對多關聯,在hasMany
類中我們可以看到這個方法的定義如下:
class HasMany extends HasOneOrMany
{
public function getResults()
{
return $this->query->get();
}
}
class BelongsToMany extends Relation
{
public function getResults()
{
return $this->get();
}
public function get($columns = ['*'])
{
$columns = $this->query->getQuery()->columns ? [] : $columns;
$builder = $this->query->applyScopes();
$models = $builder->addSelect(
$this->shouldSelect($columns)
)->getModels();
$this->hydratePivotRelation($models);
if (count($models) > 0) {
$models = $builder->eagerLoadRelations($models);
}
return $this->related->newCollection($models);
}
}
複製程式碼
關聯方法
出了用動態屬性載入關聯資料外還可以在定義關聯方法的基礎上再給關聯的子模型新增更多的where條件等的約束,比如:
$user->posts()->where('created_at', ">", "2018-01-01");
複製程式碼
Relation例項會將這些呼叫通過__call
轉發給子模型的Eloquent Builder去執行。
abstract class Relation
{
/**
* Handle dynamic method calls to the relationship.
*
* @param string $method
* @param array $parameters
* @return mixed
*/
public function __call($method, $parameters)
{
if (static::hasMacro($method)) {
return $this->macroCall($method, $parameters);
}
$result = $this->query->{$method}(...$parameters);
if ($result === $this->query) {
return $this;
}
return $result;
}
}
複製程式碼
預載入關聯模型
當作為屬性訪問 Eloquent 關聯時,關聯資料是「懶載入」的。意味著在你第一次訪問該屬性時,才會載入關聯資料。不過當查詢父模型時,Eloquent 可以「預載入」關聯資料。預載入避免了 N + 1 查詢問題。看一下文件裡給出的例子:
class Book extends Model
{
/**
* 獲得此書的作者。
*/
public function author()
{
return $this->belongsTo('App\Author');
}
}
//獲取所有的書和作者資訊
$books = App\Book::all();
foreach ($books as $book) {
echo $book->author->name;
}
複製程式碼
上面這樣使用關聯在訪問每本書的作者時都會執行查詢載入關聯資料,這樣顯然會影響應用的效能,那麼通過預載入能夠把查詢降低到兩次:
$books = App\Book::with('author')->get();
foreach ($books as $book) {
echo $book->author->name;
}
複製程式碼
我們來看一下底層時怎麼實現預載入關聯模型的
abstract class Model implements ArrayAccess, Arrayable,......
{
public static function with($relations)
{
return (new static)->newQuery()->with(
is_string($relations) ? func_get_args() : $relations
);
}
}
//Eloquent Builder
class Builder
{
public function with($relations)
{
$eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);
$this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);
return $this;
}
protected function parseWithRelations(array $relations)
{
$results = [];
foreach ($relations as $name => $constraints) {
//如果$name是數字索引,證明沒有為預載入關聯模型新增約束條件,為了統一把它的約束條件設定為一個空的閉包
if (is_numeric($name)) {
$name = $constraints;
list($name, $constraints) = Str::contains($name, ':')
? $this->createSelectWithConstraint($name)
: [$name, function () {
//
}];
}
//設定這種用Book::with('author.contacts')這種巢狀預載入的約束條件
$results = $this->addNestedWiths($name, $results);
$results[$name] = $constraints;
}
return $results;
}
public function get($columns = ['*'])
{
$builder = $this->applyScopes();
//獲取模型時會去載入要預載入的關聯模型
if (count($models = $builder->getModels($columns)) > 0) {
$models = $builder->eagerLoadRelations($models);
}
return $builder->getModel()->newCollection($models);
}
public function eagerLoadRelations(array $models)
{
foreach ($this->eagerLoad as $name => $constraints) {
if (strpos($name, '.') === false) {
$models = $this->eagerLoadRelation($models, $name, $constraints);
}
}
return $models;
}
protected function eagerLoadRelation(array $models, $name, Closure $constraints)
{
//獲取關聯例項
$relation = $this->getRelation($name);
$relation->addEagerConstraints($models);
$constraints($relation);
return $relation->match(
$relation->initRelation($models, $name),
$relation->getEager(), $name
);
}
}
複製程式碼
上面的程式碼可以看到with方法會把要預載入的關聯模型放到$eagarLoad
屬性裡,針對我們這個例子他的值類似下面這樣:
$eagarLoad = [
'author' => function() {}
];
//如果有約束則會是
$eagarLoad = [
'author' => function($query) {
$query->where(....)
}
];
複製程式碼
這樣在通過Model 的get
方法獲取模型時會預載入的關聯模型,在獲取關聯模型時給關係應用約束的addEagerConstraints
方法是在具體的關聯類中定義的,我們可以看下HasMany類的這個方法。
***注: 下面的程式碼為了閱讀方便我把一些在父類裡定義的方法拿到了HasMany中,自己閱讀時如果找不到請去父類中找一下。
class HasMany extends ...
{
// where book_id in (...)
public function addEagerConstraints(array $models)
{
$this->query->whereIn(
$this->foreignKey, $this->getKeys($models, $this->localKey)
);
}
}
複製程式碼
他給關聯應用了一個where book_id in (...)
的約束,接下來通過getEager
方法獲取所有的關聯模型組成的集合,再通過關聯類裡定義的match方法把外來鍵值等於父模型主鍵值的關聯模型組織成集合設定到父模型的$relations
屬性中接下來用到了這些預載入的關聯模型時都是從$relations
屬性中取出來的不會再去做資料庫查詢
class HasMany extends ...
{
//初始化model的relations屬性
public function initRelation(array $models, $relation)
{
foreach ($models as $model) {
$model->setRelation($relation, $this->related->newCollection());
}
return $models;
}
//預載入出關聯模型
public function getEager()
{
return $this->get();
}
public function get($columns = ['*'])
{
return $this->query->get($columns);
}
//在子類HasMany
public function match(array $models, Collection $results, $relation)
{
return $this->matchMany($models, $results, $relation);
}
protected function matchOneOrMany(array $models, Collection $results, $relation, $type)
{
//組成[父模型ID => [子模型1, ...]]的字典
$dictionary = $this->buildDictionary($results);
//將子模型設定到父模型的$relations屬性中去
foreach ($models as $model) {
if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) {
$model->setRelation(
$relation, $this->getRelationValue($dictionary, $key, $type)
);
}
}
return $models;
}
}
複製程式碼
預載入關聯模型後沒個Book Model的$relations
屬性裡都有了以關聯名author
為key的資料, 類似下面
$relations = [
'author' => Collection(Author)//Author Model組成的集合
];
複製程式碼
這樣再使用動態屬性引用已經預載入關聯模型時就會直接從這裡取出資料而不用再去做資料庫查詢了。
模型關聯常用的一些功能的底層實現到這裡梳理完了,Laravel把我們平常用的join, where in 和子查詢都隱藏在了底層實現中並且幫我們把相互關聯的資料做好了匹配。還有一些我認為使用場景沒那麼多的多型關聯、巢狀預載入那些我並沒有梳理,並且它們的底層實現都差不多,區別就是每個關聯型別有自己的關聯約束、匹配規則,有興趣的讀者自己去看一下吧。
模型關聯介紹完後,整個Laravel的 Illuminate/Database
就梳理完了,我們用四篇文章分別介紹了Laravel的Database基礎、查詢構建器QueryBuilder、模型CRUD和模型關聯,希望能給大家在學習Laravel Database的原始碼時提供一些幫助。
本文已經收錄在系列文章Laravel核心程式碼學習裡,歡迎訪問閱讀。