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

:grin: :grin::stuck_out_tongue:

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

本作品采用《CC 协议》,转载必须注明作者和本文链接
每天一点小知识,到那都是大佬,哈哈
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 2

appends这个问题有好的解决办法没

2年前 评论
raybon (楼主) 2年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!