Laravel Database——Eloquent Model 原始碼分析(上)

leoyang發表於2017-10-09

前言

本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/lar...

前面幾個部落格向大家介紹了查詢構造器的原理與原始碼,然而查詢構造器更多是為 Eloquent Model 服務的,我們對資料庫操作更加方便的是使用 Eloquent Model。 本篇文章將會大家介紹 Model 的一些特性原理。

 

Eloquent Model 修改器

當我們在 Eloquent 模型例項中設定某些屬性值的時候,修改器允許對 Eloquent 屬性值進行格式化。如果對修改器不熟悉,請參考官方文件:Eloquent: 修改器

下面先看看修改器的原理:

public function offsetSet($offset, $value)
{
    $this->setAttribute($offset, $value);
}

public function setAttribute($key, $value)
{
    if ($this->hasSetMutator($key)) {
        $method = 'set'.Str::studly($key).'Attribute';

        return $this->{$method}($value);
    }

    elseif ($value && $this->isDateAttribute($key)) {
        $value = $this->fromDateTime($value);
    }

    if ($this->isJsonCastable($key) && ! is_null($value)) {
        $value = $this->castAttributeAsJson($key, $value);
    }

    if (Str::contains($key, '->')) {
        return $this->fillJsonAttribute($key, $value);
    }

    $this->attributes[$key] = $value;

    return $this;
}

自定義修改器

當我們為 model 的成員變數賦值的時候,就會呼叫 offsetSet 函式,進而執行 setAttribute 函式,在這個函式中第一個檢查的就是是否存在預處理函式:

public function hasSetMutator($key)
{
    return method_exists($this, 'set'.Str::studly($key).'Attribute');
}

如果存在該函式,就會直接呼叫自定義修改器。

日期轉換器

接著如果沒有自定義修改器的話,還會檢查當前更新的成員變數是否是日期屬性:

protected function isDateAttribute($key)
{
    return in_array($key, $this->getDates()) ||
                                $this->isDateCastable($key);
}

public function getDates()
{
    $defaults = [static::CREATED_AT, static::UPDATED_AT];

    return $this->usesTimestamps()
                ? array_unique(array_merge($this->dates, $defaults))
                : $this->dates;
}

protected function isDateCastable($key)
{
    return $this->hasCast($key, ['date', 'datetime']);
}

欄位的時間屬性有兩種設定方法,一種是設定 $dates 屬性:

protected $dates = ['date_attr'];

還有一種方法是設定 cast 陣列:

protected $casts = ['date_attr' => 'date'];

只要是時間屬性的欄位,無論是什麼型別的值,laravel 都會自動將其轉化為資料庫的時間格式。資料庫的時間格式設定是 dateFormat 成員變數,不設定的時候,預設的時間格式為 `Y-m-d H:i:s':

protected $dateFormat = ['U'];

protected $dateFormat = ['Y-m-d H:i:s'];

當資料庫對應的欄位是時間型別時,為其賦值就可以非常靈活。我們可以賦值 Carbon 型別、DateTime 型別、數字型別、字串等等:

public function fromDateTime($value)
{
    return is_null($value) ? $value : $this->asDateTime($value)->format(
        $this->getDateFormat()
    );
}

protected function asDateTime($value)
{
    if ($value instanceof Carbon) {
        return $value;
    }

    if ($value instanceof DateTimeInterface) {
        return new Carbon(
            $value->format('Y-m-d H:i:s.u'), $value->getTimezone()
        );
    }

    if (is_numeric($value)) {
        return Carbon::createFromTimestamp($value);
    }

    if ($this->isStandardDateFormat($value)) {
        return Carbon::createFromFormat('Y-m-d', $value)->startOfDay();
    }

    return Carbon::createFromFormat(
        $this->getDateFormat(), $value
    );
}

json 轉換器

接下來,如果該變數被設定為 arrayjson 等屬性,那麼其將會轉化為 json 型別。

protected function isJsonCastable($key)
{
    return $this->hasCast($key, ['array', 'json', 'object', 'collection']);
}

protected function asJson($value)
{
    return json_encode($value);
}

 

Eloquent Model 訪問器

相比較修改器來說,訪問器的適用情景會更加多。例如,我們經常把一些關於型別的欄位設定為 123 等等,例如使用者資料表中使用者性別欄位,1 代表男,2 代表女,很多時候我們取出這些值之後必然要經過轉換,然後再顯示出來。這時候就需要定義訪問器。

訪問器的原始碼:

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);
}

可以看到,當我們訪問資料庫物件的成員變數的時候,大致可以分為兩類:屬性值與關係物件。關係物件我們以後再詳細來說,本文中先說關於屬性的訪問。

public function getAttributeValue($key)
{
    $value = $this->getAttributeFromArray($key);

    if ($this->hasGetMutator($key)) {
        return $this->mutateAttribute($key, $value);
    }

    if ($this->hasCast($key)) {
        return $this->castAttribute($key, $value);
    }

    if (in_array($key, $this->getDates()) &&
        ! is_null($value)) {
        return $this->asDateTime($value);
    }

    return $value;
}

與修改器類似,訪問器也由三部分構成:自定義訪問器、日期訪問器、型別訪問器。

獲取原始值

訪問器的第一步就是從成員變數 attributes 中獲取原始的欄位值,一般指的是存在資料庫的值。有的時候,我們要取的屬性並不在 attributes 中,這時候就會返回 null

protected function getAttributeFromArray($key)
{
    if (isset($this->attributes[$key])) {
        return $this->attributes[$key];
    }
}

自定義訪問器

如果定義了訪問器,那麼就會呼叫訪問器,獲取返回值:

public function hasGetMutator($key)
{
    return method_exists($this, 'get'.Str::studly($key).'Attribute');
}

protected function mutateAttribute($key, $value)
{
    return $this->{'get'.Str::studly($key).'Attribute'}($value);
}

型別轉換

若我們在成員變數 $casts 陣列中為屬性定義了型別轉換,那麼就要進行型別轉換:

public function hasCast($key, $types = null)
{
    if (array_key_exists($key, $this->getCasts())) {
        return $types ? in_array($this->getCastType($key), (array) $types, true) : true;
    }

    return false;
}

protected function castAttribute($key, $value)
{
    if (is_null($value)) {
        return $value;
    }

    switch ($this->getCastType($key)) {
        case 'int':
        case 'integer':
            return (int) $value;
        case 'real':
        case 'float':
        case 'double':
            return (float) $value;
        case 'string':
            return (string) $value;
        case 'bool':
        case 'boolean':
            return (bool) $value;
        case 'object':
            return $this->fromJson($value, true);
        case 'array':
        case 'json':
            return $this->fromJson($value);
        case 'collection':
            return new BaseCollection($this->fromJson($value));
        case 'date':
            return $this->asDate($value);
        case 'datetime':
            return $this->asDateTime($value);
        case 'timestamp':
            return $this->asTimestamp($value);
        default:
            return $value;
    }
}

日期轉換

若當前屬性是 CREATED_ATUPDATED_AT 或者被存入成員變數 dates 中,那麼就要進行日期轉換。日期轉換函式 asDateTime 可以檢視上一節中的內容。
 

Eloquent Model 陣列轉化

在使用資料庫物件中,我們經常使用 toArray 函式,它可以將從資料庫中取出的所有屬性和關係模型轉化為陣列:

public function toArray()
{
    return array_merge($this->attributesToArray(), $this->relationsToArray());
}

本文中只介紹屬性轉化為陣列的部分:

public function attributesToArray()
{
    $attributes = $this->addDateAttributesToArray(
        $attributes = $this->getArrayableAttributes()
    );

    $attributes = $this->addMutatedAttributesToArray(
        $attributes, $mutatedAttributes = $this->getMutatedAttributes()
    );

    $attributes = $this->addCastAttributesToArray(
        $attributes, $mutatedAttributes
    );

    foreach ($this->getArrayableAppends() as $key) {
        $attributes[$key] = $this->mutateAttributeForArray($key, null);
    }

    return $attributes;
}

與訪問器與修改器類似,需要轉為陣列的元素有日期型別、自定義訪問器、型別轉換,我們接下來一個個看:

getArrayableAttributes 原始值獲取

首先我們要從成員變數 attributes 陣列中獲取原始值:

protected function getArrayableAttributes()
{
    return $this->getArrayableItems($this->attributes);
}

protected function getArrayableItems(array $values)
{
    if (count($this->getVisible()) > 0) {
        $values = array_intersect_key($values, array_flip($this->getVisible()));
    }

    if (count($this->getHidden()) > 0) {
        $values = array_diff_key($values, array_flip($this->getHidden()));
    }

    return $values;
}

我們還可以為資料庫物件設定可見元素 $visible 與隱藏元素 $hidden,這兩個變數會控制 toArray 可轉化的元素屬性。

日期轉換

protected function addDateAttributesToArray(array $attributes)
{
    foreach ($this->getDates() as $key) {
        if (! isset($attributes[$key])) {
            continue;
        }

        $attributes[$key] = $this->serializeDate(
            $this->asDateTime($attributes[$key])
        );
    }

    return $attributes;
}

protected function serializeDate(DateTimeInterface $date)
{
    return $date->format($this->getDateFormat());
}

自定義訪問器轉換

定義了自定義訪問器的屬性,會呼叫訪問器函式來覆蓋原有的屬性值,首先我們需要獲取所有的自定義訪問器變數:

public function getMutatedAttributes()
{
    $class = static::class;

    if (! isset(static::$mutatorCache[$class])) {
        static::cacheMutatedAttributes($class);
    }

    return static::$mutatorCache[$class];
}

public static function cacheMutatedAttributes($class)
{
    static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) {
        return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match);
    })->all();
}

protected static function getMutatorMethods($class)
{
    preg_match_all('/(?<=^|;)get([^;]+?)Attribute(;|$)/', implode(';', get_class_methods($class)), $matches);

    return $matches[1];
}

可以看到,函式用 get_class_methods 獲取類內所有的函式,並篩選出符合 get...Attribute 的函式,獲得自定義的訪問器變數,並快取到 mutatorCache 中。

接著將會利用自定義訪問器變數替換原始值:

protected function addMutatedAttributesToArray(array $attributes, array $mutatedAttributes)
{
    foreach ($mutatedAttributes as $key) {
        if (! array_key_exists($key, $attributes)) {
            continue;
        }

        $attributes[$key] = $this->mutateAttributeForArray(
            $key, $attributes[$key]
        );
    }

    return $attributes;
}

protected function mutateAttributeForArray($key, $value)
{
    $value = $this->mutateAttribute($key, $value);

    return $value instanceof Arrayable ? $value->toArray() : $value;
}

cast 型別轉換

被定義在 cast 陣列中的變數也要進行陣列轉換,呼叫的方法和訪問器相同,也是 castAttribute,如果是時間型別,還要按照時間格式來轉換:

protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes)
{
    foreach ($this->getCasts() as $key => $value) {
        if (! array_key_exists($key, $attributes) || in_array($key, $mutatedAttributes)) {
            continue;
        }

        $attributes[$key] = $this->castAttribute(
            $key, $attributes[$key]
        );

        if ($attributes[$key] &&
            ($value === 'date' || $value === 'datetime')) {
            $attributes[$key] = $this->serializeDate($attributes[$key]);
        }
    }

    return $attributes;
}

appends 額外屬性新增

toArray() 還會將我們定義在 appends 變數中的屬性一起進行陣列轉換,但是注意被放入 appends 成員變數陣列中的屬性需要有自定義訪問器函式:

protected function getArrayableAppends()
{
    if (! count($this->appends)) {
        return [];
    }

    return $this->getArrayableItems(
        array_combine($this->appends, $this->appends)
    );
}

 

查詢作用域

查詢作用域分為全域性作用域與本地作用域。全域性作用域不需要手動呼叫,由程式在每次的查詢中自動載入,本地作用域需要在查詢的時候進行手動呼叫。官方文件:查詢作用域

全域性作用域

一般全域性作用域需要定義一個實現 Illuminate\Database\Eloquent\Scope 介面的類,該介面要求你實現一個方法:apply。需要的話可以在 apply 方法中新增 where 條件到查詢。

要將全域性作用域分配給模型,需要重寫給定模型的 boot 方法並使用 addGlobalScope 方法。

另外,我們還可以向 addGlobalScope 中新增匿名函式實現匿名全域性作用域。

我們先看看原始碼:

public static function addGlobalScope($scope, Closure $implementation = null)
{
    if (is_string($scope) && ! is_null($implementation)) {
        return static::$globalScopes[static::class][$scope] = $implementation;
    } elseif ($scope instanceof Closure) {
        return static::$globalScopes[static::class][spl_object_hash($scope)] = $scope;
    } elseif ($scope instanceof Scope) {
        return static::$globalScopes[static::class][get_class($scope)] = $scope;
    }

    throw new InvalidArgumentException('Global scope must be an instance of Closure or Scope.');
}

可以看到,全域性作用域使用的是全域性的靜態變數 globalScopes,該變數儲存著所有資料庫物件的全域性作用域。

Eloquent\Model 類並不負責查詢功能,相關功能由 Eloquent\Builder 負責,因此每次查詢都會間接呼叫 Eloquent\Builder 類。

public function __call($method, $parameters)
{
    if (in_array($method, ['increment', 'decrement'])) {
        return $this->$method(...$parameters);
    }

    try {
        return $this->newQuery()->$method(...$parameters);
    } catch (BadMethodCallException $e) {
        throw new BadMethodCallException(
            sprintf('Call to undefined method %s::%s()', get_class($this), $method)
        );
    }
}

建立新的 Eloquent\Builder 類需要 newQuery 函式:

public function newQuery()
{
    $builder = $this->newQueryWithoutScopes();

    foreach ($this->getGlobalScopes() as $identifier => $scope) {
        $builder->withGlobalScope($identifier, $scope);
    }

    return $builder;
}

public function getGlobalScopes()
{
    return Arr::get(static::$globalScopes, static::class, []);
}

public function withGlobalScope($identifier, $scope)
{
    $this->scopes[$identifier] = $scope;

    if (method_exists($scope, 'extend')) {
        $scope->extend($this);
    }

    return $this;
}

newQuery 函式為 Eloquent\builder 載入全域性作用域,這樣靜態變數 globalScopes 的值就會被賦到 Eloquent\builderscopes 成員變數中。

當我們使用 get() 函式獲取資料庫資料的時候,也需要藉助魔術方法呼叫 Illuminate\Database\Eloquent\Builder 類的 get 函式:

public function get($columns = ['*'])
{
    $builder = $this->applyScopes();

    if (count($models = $builder->getModels($columns)) > 0) {
        $models = $builder->eagerLoadRelations($models);
    }

    return $builder->getModel()->newCollection($models);
}

呼叫 applyScopes 函式載入所有的全域性作用域:

public function applyScopes()
{
    if (! $this->scopes) {
        return $this;
    }

    $builder = clone $this;

    foreach ($this->scopes as $identifier => $scope) {
        if (! isset($builder->scopes[$identifier])) {
            continue;
        }

        $builder->callScope(function (Builder $builder) use ($scope) {
            if ($scope instanceof Closure) {
                $scope($builder);
            }

            if ($scope instanceof Scope) {
                $scope->apply($builder, $this->getModel());
            }
        });
    }

    return $builder;
}

可以看到,builder 查詢類會通過 callScope 載入全域性作用域的查詢條件。

protected function callScope(callable $scope, $parameters = [])
{
    array_unshift($parameters, $this);

    $query = $this->getQuery();

    $originalWhereCount = is_null($query->wheres)
                ? 0 : count($query->wheres);

    $result = $scope(...array_values($parameters)) ?? $this;

    if (count((array) $query->wheres) > $originalWhereCount) {
        $this->addNewWheresWithinGroup($query, $originalWhereCount);
    }

    return $result;
}

callScope 函式首先會獲取更加底層的 Query\builder,更新 query\bulidwhere 條件。

addNewWheresWithinGroup 這個函式很重要,它為 Query\builder 提供 nest 型別的 where 條件:

protected function addNewWheresWithinGroup(QueryBuilder $query, $originalWhereCount)
{
    $allWheres = $query->wheres;

    $query->wheres = [];

    $this->groupWhereSliceForScope(
        $query, array_slice($allWheres, 0, $originalWhereCount)
    );

    $this->groupWhereSliceForScope(
        $query, array_slice($allWheres, $originalWhereCount)
    );
}

protected function groupWhereSliceForScope(QueryBuilder $query, $whereSlice)
{
    $whereBooleans = collect($whereSlice)->pluck('boolean');

    if ($whereBooleans->contains('or')) {
        $query->wheres[] = $this->createNestedWhere(
            $whereSlice, $whereBooleans->first()
        );
    } else {
        $query->wheres = array_merge($query->wheres, $whereSlice);
    }
}

protected function createNestedWhere($whereSlice, $boolean = 'and')
{
    $whereGroup = $this->getQuery()->forNestedWhere();

    $whereGroup->wheres = $whereSlice;

    return ['type' => 'Nested', 'query' => $whereGroup, 'boolean' => $boolean];
}

當我們在查詢作用域中,所有的查詢條件連線符都是 and 的時候,可以直接合併到 where 中。

如果我們在查詢作用域中或者原查詢條件寫下了 orWhereorWhereColumn 等等連線符為 or 的查詢條件,那麼就會利用 createNestedWhere 函式建立 nest 型別的 where 條件。這個 where 條件會包含查詢作用域的所有查詢條件,或者原查詢的所有查詢條件。

本地作用域

全域性作用域會自定載入到所有的查詢條件當中,laravel 中還有本地作用域,只有在查詢時呼叫才會生效。

本地作用域是由魔術方法 __call 實現的:

public function __call($method, $parameters)
{
    ...

    if (method_exists($this->model, $scope = 'scope'.ucfirst($method))) {
        return $this->callScope([$this->model, $scope], $parameters);
    }

    if (in_array($method, $this->passthru)) {
        return $this->toBase()->{$method}(...$parameters);
    }

    $this->query->{$method}(...$parameters);

    return $this;
}

批量呼叫本地作用域

laravel 還提供一個方法可以一次性呼叫多個本地作用域:

$scopes = [
    'published',
    'category' => 'Laravel',
    'framework' => ['Laravel', '5.3'],
];

(new EloquentModelStub)->scopes($scopes);

上面的寫法會呼叫三個本地作用域,它們的引數是 $scopes 的值。

public function scopes(array $scopes)
{
    $builder = $this;

    foreach ($scopes as $scope => $parameters) {
        if (is_int($scope)) {
            list($scope, $parameters) = [$parameters, []];
        }

        $builder = $builder->callScope(
            [$this->model, 'scope'.ucfirst($scope)],
            (array) $parameters
        );
    }

    return $builder;
}

 

fill 批量賦值

Eloquent Model 預設只能一個一個的設定資料庫物件的屬性,這是為了保護資料庫。但是有的時候,欄位過多會造成程式碼很繁瑣。因此,laravel 提供屬性批量賦值的功能,fill 函式,相關的官方文件:批量賦值

fill 函式

public function fill(array $attributes)
{
    $totallyGuarded = $this->totallyGuarded();

    foreach ($this->fillableFromArray($attributes) as $key => $value) {
        $key = $this->removeTableFromKey($key);

        if ($this->isFillable($key)) {
            $this->setAttribute($key, $value);
        } elseif ($totallyGuarded) {
            throw new MassAssignmentException($key);
        }
    }

    return $this;
}

fill 函式會從引數 attributes 中選取可以批量賦值的屬性。所謂的可以批量賦值的屬性,是指被 fillableguarded 成員變數設定的引數。被放入 fillable 的屬性允許批量賦值的屬性,被放入 guarded 的屬性禁止批量賦值。

獲取可批量賦值的屬性:

protected function fillableFromArray(array $attributes)
{
    if (count($this->getFillable()) > 0 && ! static::$unguarded) {
        return array_intersect_key($attributes, array_flip($this->getFillable()));
    }

    return $attributes;
}

public function getFillable()
{
    return $this->fillable;
}

可以看到,若想要實現批量賦值,需要將屬性設定在 fillable 成員陣列中。

laravel 中,有一種資料庫物件關係是 morph,也就是 多型 關係,這種關係也會呼叫 fill 函式,這個時候傳入的引數 attributes 會帶有資料庫字首。接下來,就要呼叫 removeTableFromKey 函式來去除資料庫字首:

protected function removeTableFromKey($key)
{
    return Str::contains($key, '.') ? last(explode('.', $key)) : $key;
}

下一步,還要進一步驗證屬性的 fillable

public function isFillable($key)
{
    if (static::$unguarded) {
        return true;
    }

    if (in_array($key, $this->getFillable())) {
        return true;
    }

    if ($this->isGuarded($key)) {
        return false;
    }

    return empty($this->getFillable()) &&
        ! Str::startsWith($key, '_');
}

如果當前 unguarded 開啟,也就是不會保護任何屬性,那麼直接返回 true。如果當前屬性在 fillable 中,也會返回 true。如果當前屬性在 guarded 中,返回 false。最後,如果 fillable 是空陣列,也會返回 true

forceFill

如果不想受 fillable 或者 guarded 等的影響,還可以使用 forceFill 強制來批量賦值。

public function forceFill(array $attributes)
{
    return static::unguarded(function () use ($attributes) {
        return $this->fill($attributes);
    });
}

public static function unguarded(callable $callback)
{
    if (static::$unguarded) {
        return $callback();
    }

    static::unguard();

    try {
        return $callback();
    } finally {
        static::reguard();
    }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章