關於標題產生的兩個原因:一定來源於工作真實案列
- 第一種情況是有一個 mobile 新增入庫成功,編輯時獲取到的mobile 為空,編輯時資料修改了,吧之前的資料給覆蓋了,這種問題已經相當嚴重了 ? 【這種是 appends 影響】
- 第二種當我們編輯一條資料,發現傳值了,save() 之後卻發現 欄位還是初始值 未更新, 這種一般會發生在
json
、array
、object
這兩種資料型別上 【這種是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 欄位的getter
和setter
方法, 實際工作中不一定是這個欄位,這個是舉例使用,為什麼會這麼做,因為專案中這個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 格式入庫,這個要個前端約定好,否則可能出現想不到的資料異常,假設我們現在沒有在模型重寫 custom
的 getCustomAttribute
和setCustomAttribute
這兩個修改器方法, 這個位置在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 型別到底在什麼時候幫我們執行資料轉換了, 入庫的前一步操作,而我們往往不注意開發的時候,問題也就出在這個地方
出問題原因:
- 我們定義了
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 這個位置是隻更新變更的資料欄位,沒有變更的預設捨棄,所以就出現我們專案中遇到的一個問題,一直不被更新,排查到這個問題,就趕緊更新了程式碼
- 這個位置的注意事項我們們要記一下 【最好是根據自己的需要寫】
- 如果前端提交的引數 正好是我們想要的,我們直接定義
$casts
欄位型別,就不用後續處理轉換了。這個時候正常寫custom => json
就行 【推薦】
- 如果前端提交的引數 正好是我們想要的,我們直接定義
- 如果針對前端傳過來的引數不滿意,需要特殊處理成我們想要的, 也就是我們現在所做的操作 重寫了
setCustomAttribute
修改器, 在這個位置直接處理成我們要入庫的資料型別和型別就行 【推薦】
- 如果針對前端傳過來的引數不滿意,需要特殊處理成我們想要的, 也就是我們現在所做的操作 重寫了
- 模型已經定義了
$casts
針對custom => json
型別的轉換 ,這個時候又在模型 重新定義了setCustomAttribute
修改器,也是當前我們專案中這麼做出現bug 的一個原因,不是不能這麼寫,而是 這個修改器的值型別必須和我們定義的casts
需要轉換的型別保持一致,json
一定要求是物件或者陣列才能序列化,string
不能執行這個操作,出現前後不一致的型別,導致資料寫入失敗,這種方式我們需要儘量避免,要麼直接用casts
型別轉換, 要麼直接定義 修改器修改格式, 兩者確實需要用了 一定要保持格式正確
- 模型已經定義了
正確姿勢:
如何正確掌握
$appends
和setAppends($appends)
的使用姿勢- 如何正確使用
- 非模型欄位
- 一定要在模型內實現變異屬性修改器 如:
getTestAttribute($value)
, 這樣子我們就能在模型裡面動態追加了 - 模型的
$appends
會在全域性追加該屬性,只要有查詢模型的地方,返回之後都會帶上 setAppends
只會在呼叫的地方返回追加欄位,其他地方觸發不會主動返回該欄位
- 如何正確使用
如何正確掌握
$casts
的使用姿勢- 如何正確使用
- 非模型欄位, 這個處理只是展示資料有影響,不影響我們入庫資料
- 如果合理,儘量不要重寫修改器, 前端傳入的引數直接就是我們所要的資料,限制嚴格一點沒有壞處, 這個時候我們 直接使用系統的型別轉換 ,節約開發時間
- 第三種是我們如果有使用 修改器調整資料格式,那麼
$casts
位置就請刪除掉欄位型別轉換,因為多人合作,避免不掉型別會對不上,針對這種,建議自己寫修改器,不要新增欄位對應的轉換器,也是比較推薦的一種
- 如何正確使用
如果哪位在開發中也有類似的騷操作, 歡迎評論學習。
文中如果錯誤地方,還望各位大佬指正!
本作品採用《CC 協議》,轉載必須註明作者和本文連結