Laravel 原始碼閱讀 - Eloquent

slpi1發表於2019-08-02

在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 屬性上。

就是在啟動SoftDeletestraits的時候,給模型新增了一組查詢作用域,來新增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

備註:

  1. 由於兩個類名一致,我們約定當提到Builder時,我們指的是Illuminate\Database\Query\Builder;當提到EloquentBuilder時,我們指的是Illuminate\Database\Eloquent\Builder。
  2. 在程式碼中,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 and (Grammar::removeLeadingBoolean) 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查詢的實現;

上述物件的關係如圖所示

laravel原始碼閱讀 - Eloquent

當然,Eloquent ORM還有其他跟多的特性,比如資料遷移、資料填充、查詢作用域、存取器等,可以留給讀者自行去了解與熟悉。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章