上篇文章咱們主要講了Eloquent Model關於基礎的CRUD方法的實現,Eloquent Model中除了基礎的CRUD外還有一個很重要的部分叫模型關聯,它經過面向對象的方式優雅地把數據表之間的關聯關係抽象到了Eloquent Model中讓應用依然能用Fluent Api的方式訪問和設置主體數據的關聯數據。使用模型關聯給應用開發帶來的收益我認爲有如下幾點php
說了這麼多下面咱們就經過實際示例出發深刻到底層看看模型關聯是如何解決數據關聯匹配和加載關聯數據的。git
在開發中咱們常常遇到的關聯大體有三種:一對一,一對多和多對多,其中一對一是一種特殊的一對多關聯。咱們經過官方文檔裏的例子來看一下Laravel是怎麼定義這兩種關聯的。github
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
方法定義子模型到父模型的反向關聯。app
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 後綴名做爲外鍵字段。post
多對多關聯不一樣於一對一和一對多關聯它須要一張中間表來記錄兩端數據的關聯關係,官方文檔裏以用戶角色爲例子闡述了多對多關聯的使用方法,咱們也以這個例子來看一下底層是怎麼來定義多對多關聯的。性能
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源碼學習裏,歡迎訪問閱讀。