如何在坑中掌握模型屬性 $casts 和 $appends 的正確使用姿勢

raybon發表於2021-05-15

關於標題產生的兩個原因:一定來源於工作真實案列

  1. 第一種情況是有一個 mobile 新增入庫成功,編輯時獲取到的mobile 為空,編輯時資料修改了,吧之前的資料給覆蓋了,這種問題已經相當嚴重了 ?:rage: 【這種是 appends 影響】
  2. 第二種當我們編輯一條資料,發現傳值了,save() 之後卻發現 欄位還是初始值 未更新, 這種一般會發生在 jsonarrayobject 這兩種資料型別上 【這種是casts 影響】
  • 下面將從上面兩種情況介紹一下這個位置 我們要如何正確使用 $casts$appends| setAppends() ,使得我們能夠正確的拿到使用的姿勢。

  • 資料庫欄位(測試表[wecaht_users])

CREATE TABLE `wechat_users` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `nickname` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `mobile` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `avatar` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `custom` json DEFAULT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
  • 本次測試所用的模型 [WechatUser]
<?php

namespace App\Model;

use Illuminate\Database\Eloquent\Model;

class WechatUser extends Model
{
    use CommonTrait;
    //
    protected $fillable = [
        'nickname',
        'mobile',
        'avatar'
    ];

    public function getTestAttribute($value)
    {
        return $value;
    }
}
  • 模型引用的 Trait
<?php

namespace App\Model;

trait CommonTrait
{
    public function getMobileAttribute($value)
    {
        return $value;
    }

    public function setMobileAttribute($value)
    {
        $this->attributes['mobile'] = $value;
    }
}

產生問題的姿勢:(錯誤姿勢,禁止這樣子使用)

一、setAppends 觸發的系統bug和注意事項

  • 工作中的用法(模擬):我們在 公用 CommonTrait 內重寫了mobile 欄位的 gettersetter 方法, 實際工作中不一定是這個欄位,這個是舉例使用,為什麼會這麼做,因為專案中這個trait 是隻要你引入,只需要在主表 加上對應的欄位, 欄位內的邏輯用的是 trait 控制的,因為工作中沒有注意到 trait 中的操作,在使用者編輯資料時 有一段程式碼如下:
$user = WechatUser::query()->find(1);
    $user->setAppends([
        'mobile',
        'test'
    ]);

如上操作導致詳情獲取到 mobile 欄位為空,使用者編輯開啟什麼也沒操作,直接點選表單提交入庫, 這個時候資料庫發現 mobile 空了,產生這麼大的問題,開發能不慌嗎,就趕緊檢視這個問題,那麼你說為什麼會有人 做這個操作, 其實也是沒完全理解 setAppend() 這個函式做了什麼操作

  • 我的排查問題思路:

    • 因為實際資料庫在查詢位置我除錯還有資料

    • 一開始我以為是欄位額外操作了,看了下查詢的邏輯並沒有對欄位做處理,但是在最後看到一個操作

      • $user->setAppends([
        'mobile',
        'test'
        ]);
    • 我就直接定位這個地方的資料處理了,導致後續的問題

    • 下面是為什麼執行了 setAppend 之後空了

      • /**
        * 將模型的屬性轉成陣列結構(我們在查詢到結果時,這個地方都會執行一步操作)
        *
        * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) array
        */
        public function attributesToArray()
        {
        // 處理需要轉換成時間格式的屬性
        $attributes = $this->addDateAttributesToArray(
          $attributes = $this->getArrayableAttributes()
        );
        // 這一步就是將變異屬性轉成陣列
        $attributes = $this->addMutatedAttributesToArray(
          $attributes, $mutatedAttributes = $this->getMutatedAttributes()
        );
        
        // 將模型的屬性和變異屬性(重寫了get和set 操作)進行引數型別處理
        $attributes = $this->addCastAttributesToArray(
          $attributes, $mutatedAttributes
        );
        
        // 關鍵的一步,也正是我們出問題的地方,獲取到所有的appends 追加的欄位 這個地方包含 模型預設設定的 $appends 屬性的擴充欄位,這個位置是 key 是欄位 可以看到value 都是 null , 因為 我們所用的 mobile 是系統欄位, 所以這一步銷燬了我們的value ,導致了我們的後續問題,那麼這個地方應該怎麼用, 我們們去分析一下這個地方的呼叫
        foreach ($this->getArrayableAppends() as $key) {
          $attributes[$key] = $this->mutateAttributeForArray($key, null);
        }
        
        return $attributes;
        }
      • 下面具體分析一下 append 欄位該怎麼去用,以及下面這段實行了什麼

      • foreach ($this->getArrayableAppends() as $key) {
         $attributes[$key] = $this->mutateAttributeForArray($key, null);
        }
      • $this->mutateAttributeForArray($key, null) 這個其實將我們append 欄位的修改器返回的內容給轉成array 形式

      • /**
        * 使用其突變體進行陣列轉換,獲取屬性值。
        *
        * @param  string  $key
        * @param  mixed  $value
        * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) mixed
        */
        protected function mutateAttributeForArray($key, $value)
        {
        $value = $this->mutateAttribute($key, $value);
        
        return $value instanceof Arrayable ? $value->toArray() : $value;
        }
        // 這個是獲取我們自定義的變異屬性 預設是我們模型定義了這個 `getMobileAttribute($value)` 的修改器
        protected function mutateAttribute($key, $value)
        {
        return $this->{'get'.Str::studly($key).'Attribute'}($value);
        }
      • 相比到這裡都明白了,為什麼這個位置 mobile 會返回空了吧

    • laravel 其實這個位置是讓我們在模型上追加以外的欄位的,所以給我們預設傳的 null 這個,所以我們不能修改模型已有的屬性,這樣子會打亂我們的正常資料,也不能這麼使用,騷操作雖然好用,但是要慎用,使用不好就是坑

    • 模型屬性定義的 $append 原理一樣,我們一定不要再 appends 裡面寫資料庫欄位,一定不要寫,這個是給別人找麻煩

二、$casts 型別轉換引起的bug,常見問題出在 json 等欄位型別對映上

  • 這個問題引起也是因為 我們的 $casts 屬性轉換的型別和我們重寫的修改器之後返回的型別不一致導致的,如下我們模型內定義為 custom 入庫或者輸出時候 轉換成 json 型別:
protected $casts = [
        'custom' => 'json'
    ];

這樣子寫本身也沒問題,只要資料是陣列格式,自動轉成json 格式入庫,這個要個前端約定好,否則可能出現想不到的資料異常,假設我們現在沒有在模型重寫 customgetCustomAttributesetCustomAttribute 這兩個修改器方法, 這個位置在laravel 中預設處理的方式如下:

有資料入庫時會觸發模型的 save 方法 【laravel 原始碼如下】:

/**
     * Save the model to the database.
     *
     * @param  array  $options
     * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) bool
     */
    public function save(array $options = [])
    {
        $query = $this->newModelQuery();

        // If the "saving" event returns false we'll bail out of the save and return
        // false, indicating that the save failed. This provides a chance for any
        // listeners to cancel save operations if validations fail or whatever.
        if ($this->fireModelEvent('saving') === false) {
            return false;
        }

        // If the model already exists in the database we can just update our record
        // that is already in this database using the current IDs in this "where"
        // clause to only update this model. Otherwise, we'll just insert them.
        if ($this->exists) {
            $saved = $this->isDirty() ?
                        $this->performUpdate($query) : true;
        }

        // If the model is brand new, we'll insert it into our database and set the
        // ID attribute on the model to the value of the newly inserted row's ID
        // which is typically an auto-increment value managed by the database.
        else {
            $saved = $this->performInsert($query);

            if (! $this->getConnectionName() &&
                $connection = $query->getConnection()) {
                $this->setConnection($connection->getName());
            }
        }

        // If the model is successfully saved, we need to do a few more things once
        // that is done. We will call the "saved" method here to run any actions
        // we need to happen after a model gets successfully saved right here.
        if ($saved) {
            $this->finishSave($options);
        }

        return $saved;
    }

我們這裡只看 更新操作 有個核心函式: $this->isDirty() 檢測是否有需要更新的欄位,這個函式又處理了什麼操作呢:

/**
     * Determine if the model or any of the given attribute(s) have been modified.
     *
     * @param  array|string|null  $attributes
     * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) bool
     */
    public function isDirty($attributes = null)
    {
        return $this->hasChanges(
            $this->getDirty(), is_array($attributes) ? $attributes : func_get_args()
        );
    }

hasChanges 這個主要是判斷一下是否有變更,我們主要看 $this->getDirty() 這個裡面的操作,為什麼我們會深入到這裡去查這個問題,因為資料庫記錄能否更新和這個息息相關, getDirty() 方法內又是怎麼操作呢

/**
     * Get the attributes that have been changed since last sync.
     *
     * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) array
     */
    public function getDirty()
    {
        $dirty = [];

        foreach ($this->getAttributes() as $key => $value) {
            if (! $this->originalIsEquivalent($key, $value)) {
                $dirty[$key] = $value;
            }
        }

        return $dirty;
    }

// 接下來的處理是呼叫 $this->originalIsEquivalent($key, $value)
/**
     * Determine if the new and old values for a given key are equivalent.
     *
     * @param  string  $key
     * @param  mixed  $current
     * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) bool
     */
    public function originalIsEquivalent($key, $current)
    {
        if (! array_key_exists($key, $this->original)) {
            return false;
        }

        $original = $this->getOriginal($key);

        if ($current === $original) {
            return true;
        } elseif (is_null($current)) {
            return false;
        } elseif ($this->isDateAttribute($key)) {
            return $this->fromDateTime($current) ===
                   $this->fromDateTime($original);
        } elseif ($this->hasCast($key, ['object', 'collection'])) {
            return $this->castAttribute($key, $current) ==
                $this->castAttribute($key, $original);
        } elseif ($this->hasCast($key, ['real', 'float', 'double'])) {
            if (($current === null && $original !== null) || ($current !== null && $original === null)) {
                return false;
            }

            return abs($this->castAttribute($key, $current) - $this->castAttribute($key, $original)) < PHP_FLOAT_EPSILON * 4;
        } elseif ($this->hasCast($key)) {
            return $this->castAttribute($key, $current) ===
                   $this->castAttribute($key, $original);
        }

        return is_numeric($current) && is_numeric($original)
                && strcmp((string) $current, (string) $original) === 0;
    }

這個時候我們要排查我們 模型內定義的 casts 轉換的欄位預設會執行如下程式碼:

elseif ($this->hasCast($key)) {
            return $this->castAttribute($key, $current) ===
                   $this->castAttribute($key, $original);
        }

這個地方有個型別處理器 【castAttribute】:

/**
     * Cast an attribute to a native PHP type.
     *
     * @param  string  $key
     * @param  mixed  $value
     * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) mixed
     */
    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 $this->fromFloat($value);
            case 'decimal':
                return $this->asDecimal($value, explode(':', $this->getCasts()[$key], 2)[1]);
            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':
            case 'custom_datetime':
                return $this->asDateTime($value);
            case 'timestamp':
                return $this->asTimestamp($value);
            default:
                return $value;
        }
    }

到這個位置我們大概就知道我們所定義的 casts 型別到底在什麼時候幫我們執行資料轉換了, 入庫的前一步操作,而我們往往不注意開發的時候,問題也就出在這個地方

出問題原因:

  1. 我們定義了 custom => json 型別 ,本身我們要求前端傳過來的是一個陣列ID,後端轉成 逗號拼接入庫,這個時候由於開發沒有前後端統一,出現了更新不上的問題 ,但是這個時候因為我們這個模型繼承的父類模型 又是有個修改器,如 getCustomAttribute 返回是一個字串, 但是 我們最終在 $this->fromJson($value); 時候因為value 的非法,導致json_encode 失敗,返回了 false
/**
     * Decode the given JSON back into an array or object.
     *
     * @param  string  $value
     * @param  bool  $asObject
     * [[@return](https://learnku.com/users/31554)](https://learnku.com/users/31554) mixed
     */
    public function fromJson($value, $asObject = false)
    {
        return json_decode($value, ! $asObject);
    }

而模型內的 getCustomAttribute 裡面程式碼是如下格式:

public function setCustomAttribute($value)
    {
        if ($value) {
            $value = implode(',', $value);
        }

        $this->attributes['custom'] = $value;
    }

這個是否修改器內的值已經不是陣列了, 是一個字串,這個是否 執行 fromJson 就會返回 false
下面這個條件就會一直返回 true , 預設相等了 ,然後上面! $this->originalIsEquivalent($key, $value)的就會認為 這個欄位 新值和舊資料 相等,不需要更新

$this->castAttribute($key, $current) ===
                   $this->castAttribute($key, $original)

因為 save 這個位置是隻更新變更的資料欄位,沒有變更的預設捨棄,所以就出現我們專案中遇到的一個問題,一直不被更新,排查到這個問題,就趕緊更新了程式碼

  • 這個位置的注意事項我們們要記一下 【最好是根據自己的需要寫】
      1. 如果前端提交的引數 正好是我們想要的,我們直接定義 $casts 欄位型別,就不用後續處理轉換了。這個時候正常寫 custom => json 就行 【推薦】
      1. 如果針對前端傳過來的引數不滿意,需要特殊處理成我們想要的, 也就是我們現在所做的操作 重寫了 setCustomAttribute 修改器, 在這個位置直接處理成我們要入庫的資料型別和型別就行 【推薦】
      1. 模型已經定義了 $casts 針對 custom => json 型別的轉換 ,這個時候又在模型 重新定義了setCustomAttribute 修改器,也是當前我們專案中這麼做出現bug 的一個原因,不是不能這麼寫,而是 這個修改器的值型別必須和我們定義的 casts 需要轉換的型別保持一致,json 一定要求是物件或者陣列才能序列化,string 不能執行這個操作,出現前後不一致的型別,導致資料寫入失敗,這種方式我們需要儘量避免,要麼直接用 casts 型別轉換, 要麼直接定義 修改器修改格式, 兩者確實需要用了 一定要保持格式正確

正確姿勢:

  1. 如何正確掌握 $appendssetAppends($appends) 的使用姿勢

    • 如何正確使用
      • 非模型欄位
      • 一定要在模型內實現變異屬性修改器 如: getTestAttribute($value) , 這樣子我們就能在模型裡面動態追加了
      • 模型的$appends 會在全域性追加該屬性,只要有查詢模型的地方,返回之後都會帶上
      • setAppends 只會在呼叫的地方返回追加欄位,其他地方觸發不會主動返回該欄位
  2. 如何正確掌握 $casts 的使用姿勢

    • 如何正確使用
      • 非模型欄位, 這個處理只是展示資料有影響,不影響我們入庫資料
      • 如果合理,儘量不要重寫修改器, 前端傳入的引數直接就是我們所要的資料,限制嚴格一點沒有壞處, 這個時候我們 直接使用系統的型別轉換 ,節約開發時間
      • 第三種是我們如果有使用 修改器調整資料格式,那麼 $casts 位置就請刪除掉欄位型別轉換,因為多人合作,避免不掉型別會對不上,針對這種,建議自己寫修改器,不要新增欄位對應的轉換器,也是比較推薦的一種

:grin: :grin::stuck_out_tongue:

如果哪位在開發中也有類似的騷操作, 歡迎評論學習。
文中如果錯誤地方,還望各位大佬指正!:stuck_out_tongue:

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

相關文章