Laravel 如何在坑中掌握模型属性 $casts 和 $appends 的正确使用姿势

关于标题产生的两个原因:一定来源于工作真实案列

原文链接

  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 位置就请删除掉字段类型转换,因为多人合作,避免不掉类型会对不上,针对这种,建议自己写修改器,不要添加字段对应的转换器,也是比较推荐的一种

如果哪位在开发中也有类似的骚操作, 欢迎评论学习。
文中如果错误地方,还望各位大佬指正!:stuck_out_tongue:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260