在web應用中,與資料庫的互動可以說是最常用且最重要的操作。作為當前最流行的php框架之一,laravel對資料庫操作的封裝,可以說是非常優秀了。在官方文件當中,資料庫的使用說明文件佔據了兩個大章節,分別是【資料庫】與【Eloquent ORM】,為什麼針對同一功能,官方要出兩個文件呢?是因為它重要?複雜?對此我無從猜測,不過可以從原始碼中窺知一二。
在laravel應用的生命週期裡,資料庫部分出現在第二階段,容器啟動階段。更精確的說,是容器啟動階段的服務提供者註冊/啟動階段。資料庫服務的入口,是資料庫的服務提供者,即Illuminate\Database\DatabaseServiceProvider
。
DatabaseServiceProvider的註冊方法如程式碼所示:
public function register()
{
Model::clearBootedModels();
$this->registerConnectionServices();
$this->registerEloquentFactory();
$this->registerQueueableEntityResolver();
}
其中,registerConnectionServices()
方法註冊了三個別名服務,分別是db.factor/db/db.connection
。db用於管理資料庫連線;db.factor用於建立資料庫連線;而db.connection繫結了一個可用的連線物件。值得一提的是,db.connection是通過bind方法繫結閉包到容器當中,所以在註冊階段並未例項化,而是在真正 需要進行資料連線時例項化連線物件,然後替換原來的閉包。
registerEloquentFactory()
方法註冊了資料填充功能中的資料工廠,用於生成模擬資料。registerQueueableEntityResolver()
方法註冊了佇列的資料庫實現。
接著,在DatabaseServiceProvider的啟動方法中:
public function boot()
{
Model::setConnectionResolver($this->app['db']);
Model::setEventDispatcher($this->app['events']);
}
分別呼叫了Model的兩個靜態方法setConnectionResolver()/setEventDispatcher()
,加上註冊方法中的clearBootedModels()
,完成了Eloquent ORM的Model類的全域性設定。
Model::clearBootedModels();
Model::setConnectionResolver($this->app['db']);
Model::setEventDispatcher($this->app['events']);
我們先回顧一下官方文件中,關於ORM的用法:
// 1. 靜態呼叫
User::all();
User::find(1);
User::where();
// 2. 物件呼叫
$flight = App\Flight::find(1);
$flight->name = 'New Flight Name';
$flight->save();
$filght->delete();
Eloquent ORM既可以通過靜態呼叫執行方法,也可以先獲取到模型物件,然後執行方法。但他們實質是一樣的。在Model中定義的靜態方法如下:
protected static function boot()
protected static function bootTraits()
public static function clearBootedModels()
public static function on($connection = null)
public static function onWriteConnection()
public static function all($columns = ['*'])
public static function with($relations)
public static function destroy($ids)
public static function query()
public static function resolveConnection($connection = null)
public static function getConnectionResolver()
public static function setConnectionResolver(Resolver $resolver)
public static function unsetConnectionResolver()
public static function __callStatic($method, $parameters)
可以看到,形如User::find(1)/User::where()
的靜態呼叫方法,本身不在類中有定義,而是轉發到__callStatic魔術方法:
public static function __callStatic($method, $parameters)
{
return (new static)->$method(...$parameters);
}
也就是先例項化自身,然後在物件上執行呼叫。所以,在使用Eloquent的過程中,模型基本上都會有例項化的過程,然後再物件的基礎上進行方法的呼叫。那麼我們看看Model的構造方法中,都做了哪些動作:
public function __construct(array $attributes = [])
{
$this->bootIfNotBooted();
$this->syncOriginal();
$this->fill($attributes);
}
bootIfNotBooted()
是模型的啟動方法,標記模型被啟動,並且觸發模型啟動的前置與後置事件。在啟動過程中,會查詢模型使用的trait中是否包含boot{Name}
形式的方法,有的話就執行,這個步驟可以為模型擴充套件一些功能,比如文件中的軟刪除:
要在模型上啟動軟刪除,則必須在模型上使用 Illuminate\Database\Eloquent\SoftDeletes trait 並新增 deleted_at 欄位到你的 $dates 屬性上。
就是在啟動SoftDeletes
traits的時候,給模型新增了一組查詢作用域,來新增Restore()/WithTrashed()/WithoutTrashed()/OnlyTrashed()
四個方法,同時改寫delete方法的邏輯,從而定義了軟刪除的相關行為。
syncOriginal()
方法的作用在於儲存原始物件資料,當更新物件的屬性時,可以進行髒檢查。
fill($attributes)
就是初始化模型的屬性。
在實際運用中可能會注意到,我們很少會用new的方法、通過建構函式來例項化模型物件,但在後續我們要說道的查詢方法中,會有一個裝載物件
的過程,有這樣的用法。為什麼我們很少會new一個Model,其實原因兩個方面:首先從邏輯上說,是先有一條資料庫記錄,然後才有基於該記錄的資料模型,所以在new之前必然要有查詢資料庫的動作;其次是因為直接new出來的Model,它的狀態有可能並不正確,需要手動進行設定,可以查閱Model的newInstance()/newFromBuilder()
兩個方法來理解“狀態不正確”的含義。
我們以User::all()
的查詢過程來作為本小節的開始,Model的all()方法程式碼如下:
public static function all($columns = ['*'])
{
return (new static)->newQuery()->get(
is_array($columns) ? $columns : func_get_args()
);
}
這個查詢過程,可以分成三個步驟來執行:
new static
: 模型例項化,得到模型物件。$model->newQuery()
: 根據模型物件,獲取查詢構造器$query。$query->get($columns)
: 根據查詢構造器,取得模型資料集合。
Eloquent ORM的查詢過程,就可以歸納成這三個過程:
[模型物件]=>[查詢構造器]=>[資料集合]
資料集合也是模型物件的集合,即使是做first()
查詢,也是先獲取到只有一個物件的資料集合,然後取出第一個物件。但資料集合中的模型物件,與第一步中的模型物件不是同一個物件,作用也不一樣。第一步例項化得到的模型物件,是一個空物件
,其作用是為了獲取第二步的查詢構造器,第三步中的模型物件,是經過資料庫查詢,獲取到資料後,對資料進行封裝後的物件,是一個有資料的物件,從查詢資料到模型物件的過程,我稱之為裝載物件
,裝載物件,正是使用的上文提及的newFromBuilder()
方法。
newQuery()
的呼叫過程很長,概括如下:
newQuery()
-->newQueryWithoutScopes() // 新增查詢作用域
-->newModelQuery() // 新增對關係模型的載入
-->newEloquentBuilder(newBaseQueryBuilder()) // 獲取查詢構造器
--> return new Builder(new QueryBuilder()) // 查詢構造器的再封裝
它引出了Eloquent ORM中的一個重要概念,叫做$query,查詢構造器,雖然官方文件中,有大篇幅關於Model的使用說明,但其實很多方法都會轉發給$query去執行。從最後的一次呼叫可以看出,有兩個查詢構造器,分別是:
- 資料庫查詢構造器:Illuminate\Database\Query\Builder
- Eloquent ORM查詢構造器:Illuminate\Database\Eloquent\Builder
備註:
- 由於兩個類名一致,我們約定當提到Builder時,我們指的是Illuminate\Database\Query\Builder;當提到EloquentBuilder時,我們指的是Illuminate\Database\Eloquent\Builder。
- 在程式碼中,Builder或EloquentBuilder的例項一般用變數$query來表示
這兩個查詢構造器的存在,解釋了本文開頭提到的問題:為什麼關於資料庫的文件說明,會分為兩個章節?因為一章是對Illuminate\Database\Query\Builder
的說明,另一章是對Illuminate\Database\Eloquent\Builder
的說明(直觀的理解為對Model的說明)。
資料庫查詢構造器Builder定義了一組通用的,人性化的操作介面,來描述將要執行的SQL語句(見官方文件【資料庫 - 查詢構造器】一章。)在這一層提供的介面更接近SQL原生的使用方法,比如:where/join/select/insert/delete/update
等,都很容易在資料庫的體系內找到相應的操作或指令;EloquentBuilder是對Builder的再封裝,EloquentBuilder在Builder的基礎之上,定義了一些更復雜,但更便捷的描述介面(見官方文件【Eloquent ORM - 快速入門】一章。),比如:first/firstOrCreate/paginator
等。
3.1 EloquentBuilder
EloquentBuilder是Eloquent ORM查詢構造器,是比較高階的能與資料庫互動的物件。一般在Model層面的與資料庫互動的方法,都會轉發到Model的EloquentBuilder物件上去執行,通過下列方法可以獲取到一個Eloquent物件:
$query = User::query();
$query = User::select();
每個EloquentBuilder物件都會有一個Builder成員物件。
3.2 Builder
Builder是資料庫查詢構造器,在Builder層面已經可以與資料庫進行互動了,如何獲取到一個Builder物件呢?下面展示兩種方法:
// 獲取Builder物件
$query = DB::table('user');
$query = User::query()->getQuery();
// Builder物件與資料庫互動
$query->select('name')->where('status', 1)->orderBy('id')->get();
Builder有三個成員物件:
- ConnectionInterface
- Grammar
- Processor
ConnectionInterface
ConnectionInterface物件是執行SQL語句、對讀寫分離連線進行管理的物件,也就是資料庫連線物件。是最初級的、能與資料互動的物件:
DB::select('select * from users where active = ?', [1]);
DB::insert('insert into users (id, name) values (?, ?)', [1, 'Dayle']);
雖然DB門面指向的是
Illuminate\Database\DatabaseManager
的例項,但是對資料庫互動上的操作,都會轉發到connection上去執行。
回頭看本文中 Eloquent的生命週期 關於DatabaseServiceProvider的啟動方法的描述,DatabaseServiceProvider的啟動方法中執行的程式碼 Model::setConnectionResolver($this->app['db']);
,這個步驟就是為了後續獲取Builder的第一個成員物件ConnectionInterface
,資料庫連線物件。前文提到過,資料庫的連線並不是在服務提供者啟動時進行的,是在做出查詢動作時才會連線資料庫:
// Illuminate\Database\Eloquent\Model::class
protected function newBaseQueryBuilder()
{
// 獲取資料庫連線
$connection = $this->getConnection();
// Builder例項化時傳入的三個物件,ConnectionInterface、Grammar、Processor
return new QueryBuilder(
$connection, $connection->getQueryGrammar(), $connection->getPostProcessor()
);
}
public function getConnection()
{
return static::resolveConnection($this->getConnectionName());
}
public static function resolveConnection($connection = null)
{
// 使用通過Model::setConnectionResolver($this->app['db'])注入的resolver進行資料庫的連線
return static::$resolver->connection($connection);
}
Grammar
Grammar物件是SQL語法解析物件,我們在Builder物件中呼叫的方法,會以Builder屬性的形式將呼叫引數管理起來,然後在呼叫SQL執行方法時,先通過Grammar物件對這些資料進行解析,解析出將要執行的SQL語句,然後交給ConnectionInterface執行,獲取到資料。
Processor
Processor物件的作用比較簡單,將查詢結果資料返回給Builder,包括查詢的行資料,插入後的自增ID值。
3.3 SELECT語句的描述
在Builder物件中,關於資料庫查詢語句的描述,被分成12個部分:
- aggregate: 聚合查詢列描述,該部分與columns互斥
- columns: 查詢列描述
- from: 查詢表描述
- joins: 聚合表描述
- wheres: 查詢條件描述
- groups: 分組描述
- havings: 分組條件描述
- orders: 排序條件描述
- limit: 限制條數描述
- offset: 偏移量描述
- unions: 組合查詢描述
- lock: 鎖描述
其中,關於wherers的描述提供了相當豐富的操作介面,在實現這部分的介面時,在查詢構造器Builder中將where操作分成了以下型別: Basic/Column/In/NotIn/NotInSub/InSub/NotNull/Null/between/Nested/Sub/NotExists/Exists/Raw
。wheres條件的組裝在 Illuminate\Database\Query\Grammars\Grammar::compileWheres()
方法中完成,每種型別都由兩個部分組成:邏輯符號 + 條件表示式
,邏輯符號包含and/or
。多個where條件直接連線後,通過Grammar::removeLeadingBoolean去掉頭部的邏輯符號,組裝成最終的條件部分。如果有 Nested
的wheres描述,對Nested的部分單獨執行compileWheres
後,用括號包裝起來形成一個複合的 條件表示式
。
wheresTable:
type | boolean | condition |
---|---|---|
Basic | id = 1 | |
Column | and | table1.column1 = table2.column2 |
Nested | and | (wheresTable) |
最終組合成的Sql語句就是 id = 1 and table1.column1 = table2.column2 and (...)
。
where用法的一些注意事項:
- where的第一個引數是陣列或閉包時,條件被描述為Nested型別,也就是引數分組。
- where的第二個引數,比較符號是等於號時,可以省略。
- where的第三個引數是閉包時,表示一個子查詢
3.4 join語句的描述
每次對Builder執行join操作時,都會新建一個JoinClause物件,在文件中關於高階 Join 語法的說明中,有非常類似於where引數分組的用法,就是由閉包匯入查詢條件:
// join高階用法
DB::table('users')
->join('contacts', function ($join) {
$join->on('users.id', '=', 'contacts.user_id')->orOn(...);
})
->get();
// where引數分組
DB::table('users')
->where('name', '=', 'John')
->orWhere(function ($query) {
$query->where('votes', '>', 100)
->where('title', '<>', 'Admin');
})
->get();
實際上JoinClause繼承自Builder,所以上述程式碼中的閉包引數$join,後面也是可以鏈式呼叫where系列函式的。與Builder物件的區別在於擴充套件了一個on方法,on方法類似於whereColumn,條件的兩邊是對錶欄位的描述。
Builder呼叫join方法時傳入的條件,會以Nested
的型別新增到JoinClause物件當中,然後將JoinClause物件加入到Builder的joins部分。join結構的組裝與wheres類似,會單獨對JoinClause物件進行一次compileWheres,然後組裝到整體SQL語句中:"{$join->type} join {$table} {$this->compileWheres($join)}"
。
讀寫分離的問題在connection的範疇。當模型例項化Builder的時候,會先去獲取一個connection,如果有配置讀寫分離,先獲取一個writeConnection,然後獲取一個readConnection,並繫結到writeConnection上去。
Illuminate\Database\Connectors\ConnectionFactory
public function make(array $config, $name = null)
{
$config = $this->parseConfig($config, $name);
if (isset($config['read'])) {
return $this->createReadWriteConnection($config);
}
return $this->createSingleConnection($config);
}
protected function createReadWriteConnection(array $config)
{
$connection = $this->createSingleConnection($this->getWriteConfig($config));
return $connection->setReadPdo($this->createReadPdo($config));
}
注意此時的writeConnection與readConnection並不會真正的連線資料庫,而是一個閉包,儲存了獲取連線的方法,當第一次需要連線資料時,執行閉包獲取到連線,並將該連線替換掉閉包,後續執行SQL語句時直接使用該連線即可。在實際使用過程中,可能讀寫連線的使用並不能簡單的按照定義而來,有時需要主動設定要使用的連線。
4.1 讀連線的使用判定
在配置讀寫分離後,預設查詢會使用readConnection,以下情況會使用writeConnection:
- 對select操作指定為write:
// connection 級別指定
Illuminate\Database\Connection::select($query, $bindings = [], $useReadPdo = true);
// Builder 級別指定
DB::table('user')->useWritePdo()->get();
// Model 級別指定
Model::onWriteConnection()->get()
- 查詢時啟用鎖
- 啟用事務
- 啟用sticky配置且前文有寫操作
- 在佇列執行時,讀取SerializesModels的模型資料時
關於其判定邏輯的程式碼如下:
// Illuminate\Database\Connection::getReadPdo():
public function getReadPdo()
{
if ($this->transactions > 0) {
return $this->getPdo();
}
if ($this->getConfig('sticky') && $this->recordsModified) {
return $this->getPdo();
}
if ($this->readPdo instanceof Closure) {
return $this->readPdo = call_user_func($this->readPdo);
}
return $this->readPdo ?: $this->getPdo();
}
關於關係模型的定義,其操作介面全部定義在Illuminate\Database\Eloquent\Concerns\HasRelationships::trait中。每個關係定義方法,都是對一個關係物件的定義。
5.1 關係物件
關係物件全部繼承自 Illuminate\Database\Eloquent\Relations\Relation::abstract
虛擬類。關係物件由一個查詢構造器組成,用來儲存由關係定義所決定的關係查詢條件,和載入關係時的額外條件。比如一對一(多)的關係定義中:
public function addConstraints()
{
if (static::$constraints) {
$this->query->where($this->foreignKey, '=', $this->getParentKey());
$this->query->whereNotNull($this->foreignKey);
}
}
每當需要獲取關係資料時,都會例項化關係物件,例項化的過程中呼叫addConstraints方法。與此同時,在載入關係資料時,可以傳入額外的查詢條件:
$users = App\User::with(['posts' => function ($query) {
$query->where('title', 'like', '%first%');
}])->get();
這些條件最終都會儲存在關係物件的查詢構造器中,在獲取關係資料時,起到篩選作用。
在使用關係模型時,有兩種模式:一種是即時載入模式,一種是預載入模式。
5.2 即時載入
即時載入關係物件,是基於當前模型物件來獲取關係資料。當以$user->post
的形式獲取Model關係屬性時,通過__get方法觸發對關係模型的獲取。
public function getAttribute($key)
{
if (! $key) {
return;
}
// 訪問物件屬性或存取器
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);
}
獲取關係模型並例項化,得到關係模型物件,執行關係模型物件的addConstraints方法,將模型物件,轉化為關係模型物件的查詢條件:
- 已知模型物件
- 關係定義繫結物件的模型名稱
- 關係定義外來鍵,已知模型物件的主鍵,及主鍵的值
通過上述三個條件,可以生成關係查詢,並獲取到結果,這個過程是即時載入關係資料的。
即時載入在只有單個模型物件時比較適用,如果我們擁有的是一個模型集合,並且需要用到關係資料時,通過即時載入的模式,會有N+1的問題。針對每個模型去獲取關係資料,都要進行一次資料庫查詢,這種情況下,就需要使用預載入的模式。
5.3 預載入
對於預載入關係的情況,Model::with('relation')標記關係為預載入,在Model::get()獲取資料時,檢查到對關係的預載入標記,會對關係進行例項化,這個例項化的過程,會通過Relation::noConstraints遮蔽對關係資料的直接載入,在後續過程中,由通過Model::get()獲取的模型列表資料,得到模型的ID列表,關係利用這個ID列表,統一查詢關係模型資料。查詢完成之後匹配到對應的模型中去,其過程如下:
- EloquentBuilder::get():
- Builder::get() 獲取到模型資料列表
- EloquentBuilder::eagerLoadRelations(): 獲取所有模型關係
- foreach relations EloquentBuilder::eagerLoadRelation() 針對每個關係獲取關係資料
- Collection: 轉化為集合
其中:eagerLoadRelation()的程式碼如下
Illuminate\Database\Eloquent\Builder::eagerLoadRelation()
protected function eagerLoadRelation(array $models, $name, Closure $constraints)
{
// 獲取關係物件,這裡獲取關係物件時會通過Relation::noConstraints遮蔽即時載入
$relation = $this->getRelation($name);
// 在這裡將模型id列表注入到關係物件中,作為關係模型查詢的條件
$relation->addEagerConstraints($models);
// 這裡可以注入Model::with(['relation' => function($query){}])時定義的關係額外條件
$constraints($relation);
// 匹配每個關係物件資料到模型物件中去
return $relation->match(
$relation->initRelation($models, $name),
$relation->getEager(), $name
);
}
理解laravel的Eloquent ORM模型,可以先建立下列物件的概念:
- Model,模型物件,編碼中比較容易接觸與使用的物件,是框架開放給使用者的最直觀的操作介面;
- EloquentBuilder,Eloquent查詢構造器;
- Builder,資料庫查詢構造器,是EloquentBuilder的組成部分;
- connection,資料庫連線物件,與資料庫進行互動,執行查詢構造器描述的SQL語句;
- Grammar,語法解析器,將查詢構造器的描述解釋為規範的SQL語句;
- Processor,轉發查詢程式的結果資料;
- Relation,關係物件,描述兩個模型之間的關係,關鍵是關係之間的查詢條件;
- JoinClause,連線查詢物件,多表join查詢的實現;
上述物件的關係如圖所示
當然,Eloquent ORM還有其他跟多的特性,比如資料遷移、資料填充、查詢作用域、存取器等,可以留給讀者自行去了解與熟悉。
本作品採用《CC 協議》,轉載必須註明作者和本文連結