关于标题产生的两个原因:一定来源于工作真实案列
- 第一种情况是有一个 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
如果哪位在开发中也有类似的骚操作, 欢迎评论学习。
文中如果错误地方,还望各位大佬指正!