Laravel 訪問器,你真的用好了嗎?(大坑實踐)

一冉再發表於2019-03-08

啥?重新學習 Laravel Eloquent:訪問器?為什麼要重新學習這玩意?

最近有反應說客戶列表頁面反應較慢,我測試了一下,使用體驗確實很差,特別慢。後來查日誌才知道,是在迴圈體中使用了一個定義好的一個訪問器,這個訪問器訪問了資料庫,但是相關資料庫是做了關聯預查詢的,所以這種情況的發生是異常的。

那麼問題來了,為什麼呢?

帶著這樣的疑問,我決定忘記所有,從一個小白的態度,重新學習一下 Laravel Eloquent:訪問器。

開始實驗之前,對這一塊的使用方法還不瞭解的建議先看文件——Eloquent: 訪問器瞭解大概

這裡先描述本次實驗的大致情況

  • version: Laravel5.5
  • Model:
    • customer -> customer_tags 一對多
    • customer_tags -> tags 一對一
  • 需求:
    • 將每個客戶所有的 tag 轉成字串用/隔開返回

編寫程式碼

我們需要一個在控制器中定義一個 function 處理請求返回資料。定義一個訪問器來實現需求返回給前端

準備工作

脫敏,去除無關欄位。

輔助方法

  • responseSuccess() 返回給前端前對資料進行格式處理

  • iteratorGet() 從一個陣列或者物件中獲取一個元素,如果沒有就返回 null
// helpers.php

//對返回給前端前對資料進行格式處理
function responseSuccess($data = [], $message = '操作成功')
{
    $res = [
        'msg'  => $message,
        'code' => 200,
        'data' => $data
    ];
    //分頁特殊處理
    if ($data instanceof Paginator) {
        $data = $data->toArray();
        $page = [
            'current_page' => $data['current_page'],
            'last_page'    => $data['last_page'],
            'per_page'     => $data['per_page'],
            'total'        => $data['total']
        ];

        $res['data']  = $data['data'];
        $res['pages'] = $page;
    }
    return response()->json($res)->setStatusCode(200);
}
// 從一個陣列或者物件中獲取一個元素,如果沒有就返回null
function iteratorGet($iterator, $key, $default = null)
{
    //程式碼省略,見諒
    ...
}

定義訪問器

定義訪問器,將當前客戶所有的 tag 轉成字串用/隔開返回

// App/Models/Customer
public function getTestTagAttribute()
{
    $customerTags = iteratorGet($this, 'customerTags', []);
    $tags         = [];
    foreach ($customerTags as $customerTag) {
        $tags[] = iteratorGet($customerTag->tag, 'name');
    }
    return implode('/', $tags);
}

控制器方法

處理請求返回資料

// CustomerController
public function testCustomer()
    try {
        $beginTime = microtime(true);
        /** @var Collection $customers */
        $customers = Customer::with('customerTags.tag')->select(['id'])->limit(15)->get();
        $endTime = microtime(true);
        \Log::info($endTime - $beginTime);
        return responseSuccess($customers);
    } catch (\Exception $e) {
        errorLog($e);
        return responseFailed($e->getMessage());
    }
}

第一次請求

返回的欄位中並沒有我們想要的資料

結果

{
    "msg": "操作成功",
    "code": 200,
    "data": [
        {
            "id": 92424,
            "customer_tags": [
                {
                    "id": 1586,
                    "customer_id": 92424,
                    "tag_id": 1,
                    "tag": {
                        "id": 1,
                        "name": "年齡太小",
                    }
                },
                {
                    "id": 1588,
                    "customer_id": 92424,
                    "tag_id": 2,
                    "tag": {
                        "id": 2,
                        "name": "零基礎",
                    }
                },
                {
                    "id": 1587,
                    "customer_id": 92424,
                    "tag_id": 10,
                    "tag": {
                        "id": 10,
                        "name": "年齡過大",
                    }
                }
            ]
        },
        {
            "id": 16,
            "customer_tags": []
        },
        ...
    ]
}

分析

為什麼會沒有呢?難道定義的訪問器並不能訪問資料?還是說沒有被呼叫呢?
我們在 tinker 中查詢一個 Customer,看一下 Customer 列印的結果

>>> $c = Customer::find(92424);
=> App\Models\Customer {#3493
     id: 92424,
     category: 0,
     name: "sadas",
   }
>>> $c->test_tag
=> "年齡太小/零基礎/年齡過大"
>>> $c
=> App\Models\Customer {#3493
     id: 92424,
     category: 0,
     name: "sadas",
     customerTags: Illuminate\Database\Eloquent\Collection {#3487
       all: [
         App\Models\CustomerTag {#3498
           id: 1586,
           customer_id: 92424,
           tag_id: 1,
           tag: App\Models\Tag {#3504
             id: 1,
             name: "年齡太小",
           },
         },
         App\Models\CustomerTag {#3499
           id: 1588,
           customer_id: 92424,
           tag_id: 2,
           tag: App\Models\Tag {#211
             id: 2,
             name: "零基礎",
           },
         },
         App\Models\CustomerTag {#3500
           id: 1587,
           customer_id: 92424,
           tag_id: 10,
           tag: App\Models\Tag {#3475
             id: 10,
             name: "年齡過大",
           },
         },
       ],
     },
   }

Customer 中並沒有與 test_tags 屬性,也沒有相關資訊。為什麼我們執行 $c->test_tag 是可以執行我們定義的訪問器呢?

不要著急,慢慢回顧一下 phpoop ,我們都知道 php 有很多的魔術方法。

魔術方法

__construct()__destruct()__call()__callStatic()__get()__set()__isset()__unset()__sleep()__wakeup()__toString()__invoke()__set_state()__clone()__debugInfo() 等方法在 PHP 中被稱為魔術方法(Magic methods)。在命名自己的類方法時不能使用這些方法名,除非是想使用其魔術功能。

Caution PHP 將所有以 (兩個下劃線)開頭的類方法保留為魔術方法。所以在定義類方法時,除了上述魔術方法,建議不要以 為字首。

讀取不可訪問屬性的值時,__get() 會被呼叫。

所以這裡我們檢視 laravel 的原始碼,看一下 Cusomer 所繼承的 Model 物件中,對 __get() 的定義

namespace Illuminate\Database\Eloquent;

abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable
{
    use Concerns\HasAttributes,
        Concerns\HasEvents,
        Concerns\HasGlobalScopes,
        Concerns\HasRelationships,
        Concerns\HasTimestamps,
        Concerns\HidesAttributes,
        Concerns\GuardsAttributes;

    /**
     * Dynamically retrieve attributes on the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function __get($key)
    {
        return $this->getAttribute($key);
    }

    /**
     * Dynamically set attributes on the model.
     *
     * @param  string  $key
     * @param  mixed  $value
     * @return void
     */
    public function __set($key, $value)
    {
        $this->setAttribute($key, $value);
    }
}

getAttribute() 不在 Model 物件中定義,在\Illuminate\Database\Eloquent\Concerns\HasAttributes 中定義,我們看一下。

Str::studly() 是將字串轉化成大寫字母開頭的駝峰風格字串

namespace Illuminate\Database\Eloquent\Concerns;

trait HasAttributes
{
     /**
     * The model's attributes.
     * 模型的屬性
     *
     * @var array
     */
    protected $attributes = [];

    /**
     * Get an attribute from the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        if (! $key) {
            return;
        }

        // If the attribute exists in the attribute array or has a "get" mutator we will
        // get the attribute's value. Otherwise, we will proceed as if the developers
        // are asking for a relationship's value. This covers both types of values.
        // 檢測key是模型的屬性之一或者key有對應定義的訪問器,滿足條件獲取key對應的值
        if (array_key_exists($key, $this->attributes) ||
            $this->hasGetMutator($key)) {
            return $this->getAttributeValue($key);
        }

        // Here we will determine if the model base class itself contains this given key
        // since we don't want to treat any of those methods as relationships because
        // they are all intended as helper methods and none of these are relations.
        if (method_exists(self::class, $key)) {
            return;
        }
        //獲取[關聯關係relation]的值
        return $this->getRelationValue($key);
    }

    /**
     * Determine if a get mutator exists for an attribute.
     *  檢查一個key是否存在對應定義的訪問器
     * @param  string  $key
     * @return bool
     */
    public function hasGetMutator($key)
    {
        return method_exists($this, 'get'.Str::studly($key).'Attribute');
    }

    /**
     * Get a plain attribute (not a relationship).
     * 獲取key對應的值
     * @param  string  $key
     * @return mixed
     */
    public function getAttributeValue($key)
    {
        //從已有元素中獲取一個key對應的值
        $value = $this->getAttributeFromArray($key);

        // If the attribute has a get mutator, we will call that then return what
        // it returns as the value, which is useful for transforming values on
        // retrieval from the model to a form that is more useful for usage.
        //檢查一個key是否存在對應定義的訪問器,滿足條件就返回對應訪問器方法返回的值
        // (注意這裡會傳一個引數給對應的方法,引數的值為從已有元素中獲取一個key對應的值)
        if ($this->hasGetMutator($key)) {
            return $this->mutateAttribute($key, $value);
        }

        // If the attribute exists within the cast array, we will convert it to
        // an appropriate native PHP type dependant upon the associated value
        // given with the key in the pair. Dayle made this comment line up.
        if ($this->hasCast($key)) {
            return $this->castAttribute($key, $value);
        }

        // If the attribute is listed as a date, we will convert it to a DateTime
        // instance on retrieval, which makes it quite convenient to work with
        // date fields without having to create a mutator for each property.
        if (in_array($key, $this->getDates()) &&
            ! is_null($value)) {
            return $this->asDateTime($value);
        }

        return $value;
    }

    /**
     * Get an attribute from the $attributes array.
     * 從已有元素中獲取一個key對應的值
     * @param  string  $key
     * @return mixed
     */
    protected function getAttributeFromArray($key)
    {
        if (isset($this->attributes[$key])) {
            return $this->attributes[$key];
        }
    }

    /**
     * Get the value of an attribute using its mutator.
     * 返回對應訪問器方法返回的值(注意這裡會傳一個引數給對應的方法)
     * @param  string  $key
     * @param  mixed  $value
     * @return mixed
     */
    protected function mutateAttribute($key, $value)
    {
        return $this->{'get'.Str::studly($key).'Attribute'}($value);
    }

    /**
     * Set a given attribute on the model.
     * 給物件設定一個屬性
     * @param  string  $key
     * @param  mixed  $value
     * @return $this
     */
    public function setAttribute($key, $value)
    {
        // First we will check for the presence of a mutator for the set operation
        // which simply lets the developers tweak the attribute as it is set on
        // the model, such as "json_encoding" an listing of data for storage.
        // 先檢查有沒有定義修改器
        if ($this->hasSetMutator($key)) {
            $method = 'set'.Str::studly($key).'Attribute';

            return $this->{$method}($value);
        }

        // If an attribute is listed as a "date", we'll convert it from a DateTime
        // instance into a form proper for storage on the database tables using
        // the connection grammar's date format. We will auto set the values.
        elseif ($value && $this->isDateAttribute($key)) {
            $value = $this->fromDateTime($value);
        }

        if ($this->isJsonCastable($key) && ! is_null($value)) {
            $value = $this->castAttributeAsJson($key, $value);
        }

        // If this attribute contains a JSON ->, we'll set the proper value in the
        // attribute's underlying array. This takes care of properly nesting an
        // attribute in the array's value in the case of deeply nested items.
        if (Str::contains($key, '->')) {
            return $this->fillJsonAttribute($key, $value);
        }

        $this->attributes[$key] = $value;

        return $this;
    }
}

看了原始碼以後我們就很清楚了

定義的訪問器是通過魔術方法來實現的,並不是真的會註冊一個屬性。

明白了以後,我們繼續

第二次請求

調整程式碼

我們將controller function稍作修改

// CustomerController
public function testCustomer()
    try {
        $beginTime = microtime(true);
        /** @var Collection $customers */
        $customers = Customer::with('customerTags.tag')->select(['id'])->limit(15)->get();
        $customers->transform(function ($customer) {
            /** @var Customer $customer */
            $customer->test_tag = $customer->test_tag;
            return $customer;
        });
        $endTime = microtime(true);
        \Log::info($endTime - $beginTime);
        return responseSuccess($customers);
    } catch (\Exception $e) {
        errorLog($e);
        return responseFailed($e->getMessage());
    }
}

結果

日誌中記錄的時間為 local.INFO: 0.01134991645813

{
    "msg": "操作成功",
    "code": 200,
    "data": [
        {
            "id": 92424,
            "test_tag": "年齡太小/零基礎/年齡過大",
            "customer_tags": [
                {
                    "id": 1586,
                    "customer_id": 92424,
                    "tag_id": 1,
                    "tag": {
                        "id": 1,
                        "name": "年齡太小",
                    }
                },
                {
                    "id": 1588,
                    "customer_id": 92424,
                    "tag_id": 2,
                    "tag": {
                        "id": 2,
                        "name": "零基礎",
                    }
                },
                {
                    "id": 1587,
                    "customer_id": 92424,
                    "tag_id": 10,
                    "tag": {
                        "id": 10,
                        "name": "年齡過大",
                    }
                }
            ]
        },
        {
            "id": 16,
            "customer_tags": []
        },
        ...
    ]
}

OK,非常好,到這裡我們已經實現了我們的需求。但是多餘的 customer_tags 是前端不需要的,所以我們繼續略改程式碼,將它移除掉。

第三次請求

調整程式碼

我們將controller function稍作修改,執行完訪問器以後,刪除掉 customer_tags

// CustomerController
public function testCustomer()
    try {
        $beginTime = microtime(true);
        /** @var Collection $customers */
        $customers = Customer::with('customerTags.tag')->select(['id'])->limit(15)->get();
        $customers->transform(function ($customer) {
            /** @var Customer $customer */
            $customer->test_tag = $customer->test_tag;
            unset($customer->customerTags);
            return $customer;
        });
        $endTime = microtime(true);
        \Log::info($endTime - $beginTime);
        return responseSuccess($customers);
    } catch (\Exception $e) {
        errorLog($e);
        return responseFailed($e->getMessage());
    }
}

結果

很奇怪,這裡我們明明 unset() 移除了 $customer->customerTags, 結果還是返回了相關資料。

{
    "msg": "操作成功",
    "code": 200,
    "data": [
        {
            "id": 92424,
            "test_tag": "年齡太小/零基礎/年齡過大",
            "customer_tags": [
                {
                    "id": 1586,
                    "customer_id": 92424,
                    "tag_id": 1,
                    "tag": {
                        "id": 1,
                        "name": "年齡太小",
                    }
                },
                {
                    "id": 1588,
                    "customer_id": 92424,
                    "tag_id": 2,
                    "tag": {
                        "id": 2,
                        "name": "零基礎",
                    }
                },
                {
                    "id": 1587,
                    "customer_id": 92424,
                    "tag_id": 10,
                    "tag": {
                        "id": 10,
                        "name": "年齡過大",
                    }
                }
            ]
        },
        {
            "id": 16,
            "customer_tags": []
        },
        ...
    ]
}

很奇怪,這裡我們明明 unset() 移除了 $customer->customerTags, 結果還是返回了相關資料。為什麼呢?

這裡我開啟了 sql 日誌以後,再次執行,依然還是之前的結果。沒關係,我們不慌,來檢視日誌。

可以看出,在輸出執行時間之後,又多出來了許多 sql ,而這些 sql 正是用來查詢客戶的tags相關資訊的。執行時間輸出以後就執行了 responseSuccess(),難道這個方法有問題?

讓我們修改一下 responseSuccess(),新增一條 log

//對返回給前端前對資料進行格式處理
function responseSuccess($data = [], $message = '操作成功')
{
    $res = [
        'msg'  => $message,
        'code' => 200,
        'data' => $data
    ];
    //分頁特殊處理
    if ($data instanceof Paginator) {
        $data = $data->toArray();
        $page = [
            'current_page' => $data['current_page'],
            'last_page'    => $data['last_page'],
            'per_page'     => $data['per_page'],
            'total'        => $data['total']
        ];

        $res['data']  = $data['data'];
        $res['pages'] = $page;
    }
    \Log::info('------------華麗的分割線-------------');
    return response()->json($res)->setStatusCode(200);
}

WTF ? 這是怎麼回事?“華麗的分割線”之後就是框架提供的返回 json 資料的方法,難道框架本身出了什麼問題?

追查 json() 方法

  1. tinker 中執行 response() 檢視返回的物件
Psy Shell v0.9.9 (PHP 7.1.25 — cli) by Justin Hileman
>>> response()
=> Illuminate\Routing\ResponseFactory {#3470}
  1. 檢視 Illuminate\Routing\ResponseFactory
namespace Illuminate\Routing;

use Illuminate\Http\JsonResponse;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Routing\ResponseFactory as FactoryContract;

class ResponseFactory implements FactoryContract
{
    use Macroable;

        /**
     * Return a new JSON response from the application.
     *
     * @param  mixed  $data
     * @param  int  $status
     * @param  array  $headers
     * @param  int  $options
     * @return \Illuminate\Http\JsonResponse
     */
    public function json($data = [], $status = 200, array $headers = [], $options = 0)
    {
        return new JsonResponse($data, $status, $headers, $options);
    }
}
  1. 檢視 Illuminate\Http\JsonResponse
namespace Illuminate\Http;

use JsonSerializable;
use InvalidArgumentException;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Contracts\Support\Arrayable;
use Symfony\Component\HttpFoundation\JsonResponse as BaseJsonResponse;

class JsonResponse extends BaseJsonResponse
{
    use ResponseTrait, Macroable {
        Macroable::__call as macroCall;
    }

    /**
     * Constructor.
     *
     * @param  mixed  $data
     * @param  int    $status
     * @param  array  $headers
     * @param  int    $options
     * @return void
     */
    public function __construct($data = null, $status = 200, $headers = [], $options = 0)
    {
        $this->encodingOptions = $options;

        parent::__construct($data, $status, $headers);
    }

    /**
     * {@inheritdoc}
     */
    public function setData($data = [])
    {
        $this->original = $data;

        if ($data instanceof Jsonable) {
            $this->data = $data->toJson($this->encodingOptions);
        } elseif ($data instanceof JsonSerializable) {
            $this->data = json_encode($data->jsonSerialize(), $this->encodingOptions);
        } elseif ($data instanceof Arrayable) {
            $this->data = json_encode($data->toArray(), $this->encodingOptions);
        } else {
            $this->data = json_encode($data, $this->encodingOptions);
        }

        if (! $this->hasValidJson(json_last_error())) {
            throw new InvalidArgumentException(json_last_error_msg());
        }

        return $this->update();
    }

        /**
     * Sets a raw string containing a JSON document to be sent.
     *
     * @param string $json
     *
     * @return $this
     *
     * @throws \InvalidArgumentException
     */
    public function setJson($json)
    {
        $this->data = $json;

        return $this->update();
    }
}
  1. 檢視 Symfony\Component\HttpFoundation\JsonResponse 中的構造方法

在當前流程中,第四個引數一定是 false (呼叫的時候壓根就沒傳第四個引數),所以就是呼叫了Illuminate\Http\JsonResponse::setData()

namespace Symfony\Component\HttpFoundation;

class JsonResponse extends Response
{
    /**
     * @param mixed $data    The response data
     * @param int   $status  The response status code
     * @param array $headers An array of response headers
     * @param bool  $json    If the data is already a JSON string
     */
    public function __construct($data = null, $status = 200, $headers = array(), $json = false)
    {
        parent::__construct('', $status, $headers);

        if (null === $data) {
            $data = new \ArrayObject();
        }

        $json ? $this->setJson($data) : $this->setData($data);
    }
}
  1. 分析 Illuminate\Http\JsonResponse::setData() 的執行
/**
* {@inheritdoc}
*/
public function setData($data = [])
{
    $this->original = $data;

    if ($data instanceof Jsonable) {
        $this->data = $data->toJson($this->encodingOptions);
    } elseif ($data instanceof JsonSerializable) {
        $this->data = json_encode($data->jsonSerialize(), $this->encodingOptions);
    } elseif ($data instanceof Arrayable) {
        $this->data = json_encode($data->toArray(), $this->encodingOptions);
    } else {
        $this->data = json_encode($data, $this->encodingOptions);
    }

    if (! $this->hasValidJson(json_last_error())) {
        throw new InvalidArgumentException(json_last_error_msg());
    }

    return $this->update();
}

通過看程式碼, 這麼分支,那麼是執行了哪個分支呢?所以我們要先弄清楚$data 的型別,$data 是什麼呢?對 於$data ,一路傳遞過來,其實不難想明白,它就是我們一開始在responseSuccess() 中拼接的 $res ,然後 $res['data'] 是我們一開始查詢得出的 $customers ,那我們都知道ORM 模型的結果集是Illuminate\Database\Eloquent\Collection。 所以這裡 $data 作為一個陣列,他會進入 setData() 中的第四個分支

$this->data = json_encode($data, \$this->encodingOptions);

詳情請看這裡 json_encode()如何轉化一個物件?

json_encode() 是一個向下遞迴的遍歷每一個可遍歷的元素,如果遇到不可遍歷元素是一個物件,則會判斷物件是否實現了 JsonSerializable ,如果實現了 JsonSerializable ,則要看該物件的 jsonSerialize(),否則只會編碼物件的公開非靜態屬性。

那我們看一下 $customers or Illuminate\Database\Eloquent\Collection 是否實現了 JsonSerializable

>>> $test = new \Illuminate\Database\Eloquent\Collection();
=> Illuminate\Database\Eloquent\Collection {#3518
     all: [],
   }
>>> $test instanceof JsonSerializable
=> true

Illuminate\Database\Eloquent\Collection 確實實現了 JsonSerializable,所以這裡關於 $customers 被編碼的情況,應該是要找到 Illuminate\Database\Eloquent\Collection 中的 jsonSerialize()

  1. 我們看一下 Illuminate\Database\Eloquent\Collection
namespace Illuminate\Database\Eloquent;

use LogicException;
use Illuminate\Support\Arr;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Queue\QueueableCollection;
use Illuminate\Support\Collection as BaseCollection;

class Collection extends BaseCollection implements QueueableCollection
{
        /**
     * Get the collection of items as a plain array.
     *
     * @return array
     */
    public function toArray()
    {
        return array_map(function ($value) {
            return $value instanceof Arrayable ? $value->toArray() : $value;
        }, $this->items);
    }

    /**
     * Convert the object into something JSON serializable.
     *
     * @return array
     */
    public function jsonSerialize()
    {
        return array_map(function ($value) {
            if ($value instanceof JsonSerializable) {
                return $value->jsonSerialize();
            } elseif ($value instanceof Jsonable) {
                return json_decode($value->toJson(), true);
            } elseif ($value instanceof Arrayable) {
                return $value->toArray();
            }

            return $value;
        }, $this->items);
    }

    /**
     * Get the collection of items as JSON.
     *
     * @param  int  $options
     * @return string
     */
    public function toJson($options = 0)
    {
        return json_encode($this->jsonSerialize(), $options);
    }
}

Illuminate\Database\Eloquent\Collection 又繼承了Illuminate\Support\Collection

  1. 我們看一下 Illuminate\Support\Collection
namespace Illuminate\Support;

use stdClass;
use Countable;
use Exception;
use ArrayAccess;
use Traversable;
use ArrayIterator;
use CachingIterator;
use JsonSerializable;
use IteratorAggregate;
use Illuminate\Support\Debug\Dumper;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Contracts\Support\Arrayable;

class Collection implements ArrayAccess, Arrayable, Countable, IteratorAggregate, Jsonable, JsonSerializable
{
    /**
     * The items contained in the collection.
     *
     * @var array
     */
    protected $items = [];
     /**
     * Create a new collection.
     *
     * @param  mixed  $items
     * @return void
     */
    public function __construct($items = [])
    {
        $this->items = $this->getArrayableItems($items);
    }
    /**
     * Results array of items from Collection or Arrayable.
     *
     * @param  mixed  $items
     * @return array
     */
    protected function getArrayableItems($items)
    {
        if (is_array($items)) {
            return $items;
        } elseif ($items instanceof self) {
            return $items->all();
        } elseif ($items instanceof Arrayable) {
            return $items->toArray();
        } elseif ($items instanceof Jsonable) {
            return json_decode($items->toJson(), true);
        } elseif ($items instanceof JsonSerializable) {
            return $items->jsonSerialize();
        } elseif ($items instanceof Traversable) {
            return iterator_to_array($items);
        }

        return (array) $items;
    }
}

可以看得出,Illuminate\Database\Eloquent\Collection 的父級 Illuminate\Support\Collection 實現了 JsonSerializable

看到這裡,我們就已經很明白了。

$customers 會進入第一個分支,呼叫 $customers->jsonSerialize()

array_map() 中的回撥函式會處理 $customers->items

array_map() 中的回撥函式也有許多分支,依賴元素的型別來選擇進入的分支

那麼$customers->items 中的元素是什麼呢?是 App\Models\Customer ,它繼承了Illuminate\Database\Eloquent\Model

  1. 來看一下 Illuminate\Database\Eloquent\Model
namespace Illuminate\Database\Eloquent;

use Exception;
use ArrayAccess;
use JsonSerializable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Contracts\Queue\QueueableEntity;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\ConnectionResolverInterface as Resolver;

abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable
{
    use Concerns\HasAttributes,
        Concerns\HasEvents,
        Concerns\HasGlobalScopes,
        Concerns\HasRelationships,
        Concerns\HasTimestamps,
        Concerns\HidesAttributes,
        Concerns\GuardsAttributes;
        /**
     * Convert the model instance to an array.
     *
     * @return array
     */
    public function toArray()
    {
        return array_merge($this->attributesToArray(), $this->relationsToArray());
    }

    /**
     * Convert the model instance to JSON.
     *
     * @param  int  $options
     * @return string
     *
     * @throws \Illuminate\Database\Eloquent\JsonEncodingException
     */
    public function toJson($options = 0)
    {
        $json = json_encode($this->jsonSerialize(), $options);

        if (JSON_ERROR_NONE !== json_last_error()) {
            throw JsonEncodingException::forModel($this, json_last_error_msg());
        }

        return $json;
    }

    /**
     * Convert the object into something JSON serializable.
     *
     * @return array
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }
}

看到這裡,我們就明白了。

  • $res 是一個陣列,一路傳遞到 Illuminate\Http\JsonResponse::setData(),然後陣列會被 json_encode() ,而 json_endode() 的本質就是遍歷每一個元素進行編碼。

  • $res['data'] 是一個 Illuminate\Database\Eloquent\Collection ,它實現了 JsonSerializable ,所以當遍歷到它的時候,會呼叫 Illuminate\Database\Eloquent\Collection::jsonSerialize()

  • Illuminate\Database\Eloquent\Collection::jsonSerialize() 會遍歷集合的屬性 $items,而 $items 中的每一個元素又是一個 App\Models\Customer

  • App\Models\Customer 繼承了Illuminate\Database\Eloquent\ModelIlluminate\Database\Eloquent\Model 也實現了 JsonSerializable ,所以會呼叫 Illuminate\Database\Eloquent\Model::jsonSerialize()
  • 通過檢視 Illuminate\Database\Eloquent\Model 原始碼,我們發現,Illuminate\Database\Eloquent\Model 中的 toJson()jsonSerialize() 都是先呼叫了 toArray()

看來問題的關鍵就是 Illuminate\Database\Eloquent\Model::toArray()

public function toArray()
{
    return array_merge($this->attributesToArray(), $this->relationsToArray());
}
  1. 檢視 $this->attributesToArray()

$this->relationsToArray() 是處理關聯關係的,本質上還是對 CollectionModel 中的 toArray() 呼叫

namespace Illuminate\Database\Eloquent\Concerns;

use LogicException;
use DateTimeInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Database\Eloquent\JsonEncodingException;

trait HasAttributes
{
    /**
     * The model's attributes.
     *
     * @var array
     */
    protected $attributes = [];

    /**
     * The cache of the mutated attributes for each class.
     *
     * @var array
     */
    protected static $mutatorCache = [];

    /**
     * Convert the model's attributes to an array.
     *
     * @return array
     */
    public function attributesToArray()
    {
        // If an attribute is a date, we will cast it to a string after converting it
        // to a DateTime / Carbon instance. This is so we will get some consistent
        // formatting while accessing attributes vs. arraying / JSONing a model.
        // 日期處理相關
        $attributes = $this->addDateAttributesToArray(
            $attributes = $this->getArrayableAttributes()
        );

        //處理突變的方法 就是定義的訪問器  $this->getMutatedAttributes() 就是正則獲取定義的訪問器名稱
        $attributes = $this->addMutatedAttributesToArray(
            $attributes, $mutatedAttributes = $this->getMutatedAttributes()
        );

        // Next we will handle any casts that have been setup for this model and cast
        // the values to their appropriate type. If the attribute has a mutator we
        // will not perform the cast on those attributes to avoid any confusion.
        // 這個可以忽略,我們沒有定義 $this->casts
        $attributes = $this->addCastAttributesToArray(
            $attributes, $mutatedAttributes
        );

        // Here we will grab all of the appended, calculated attributes to this model
        // as these attributes are not really in the attributes array, but are run
        // when we need to array or JSON the model for convenience to the coder.
        // 這個可以忽略,我們沒有定義 $this->appends
        foreach ($this->getArrayableAppends() as $key) {
            $attributes[$key] = $this->mutateAttributeForArray($key, null);
        }

        return $attributes;
    }

    /**
     * Add the mutated attributes to the attributes array.
     * // 將一個突變的key及對應的值 新增到$attributes 如果key已經存在於$attributes,呼叫其對應的訪問器
     *
     * @param  array  $attributes
     * @param  array  $mutatedAttributes
     * @return array
     */
    protected function addMutatedAttributesToArray(array $attributes, array $mutatedAttributes)
    {
        foreach ($mutatedAttributes as $key) {
            // We want to spin through all the mutated attributes for this model and call
            // the mutator for the attribute. We cache off every mutated attributes so
            // we don't have to constantly check on attributes that actually change.
            // 如果key不存在於$attributes,跳過
            if (!array_key_exists($key, $attributes)) {
                continue;
            }

            // Next, we will call the mutator for this attribute so that we can get these
            // mutated attribute's actual values. After we finish mutating each of the
            // attributes we will return this final array of the mutated attributes.
            // 如果key存在於$attributes,就會呼叫這裡的方法,注意傳了一個值進去
            $attributes[$key] = $this->mutateAttributeForArray(
                $key, $attributes[$key]
            );
        }

        return $attributes;
    }

    /**
     * Get the value of an attribute using its mutator.
     * 呼叫訪問器
     *
     * @param  string  $key
     * @param  mixed  $value
     * @return mixed
     */
    protected function mutateAttribute($key, $value)
    {
        //呼叫訪問器
        return $this->{'get'.Str::studly($key).'Attribute'}($value);
    }

    /**
     * Get the value of an attribute using its mutator for array conversion.
     *
     * @param  string  $key
     * @param  mixed  $value
     * @return mixed
     */
    protected function mutateAttributeForArray($key, $value)
    {
        //你沒看錯,$key已經存在於model的屬性了,還是要繼續呼叫了相應的訪問器來執行一遍程式碼
        $value = $this->mutateAttribute($key, $value);
        //如果訪問器返回的值實現了Arrayable,繼續toArray()  (包含集合和模型)
        return $value instanceof Arrayable ? $value->toArray() : $value;
    }

    /**
     * Get the mutated attributes for a given instance.
     * 快取訪問器對應的key
     *
     * @return array
     */
    public function getMutatedAttributes()
    {
        $class = static::class;

        if (! isset(static::$mutatorCache[$class])) {
            static::cacheMutatedAttributes($class);
        }

        return static::$mutatorCache[$class];
    }

    /**
     * Extract and cache all the mutated attributes of a class.
     * 獲取快取訪問器對應的key 轉化成了下劃線風格
     *
     * @param  string  $class
     * @return void
     */
    public static function cacheMutatedAttributes($class)
    {
        static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) {
            return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match);
        })->all();
    }

    /**
     * Get all of the attribute mutator methods.
     * 正則獲取定義的訪問器名稱
     *
     * @param  mixed  $class
     * @return array
     */
    protected static function getMutatorMethods($class)
    {
        preg_match_all('/(?<=^|;)get([^;]+?)Attribute(;|$)/', implode(';', get_class_methods($class)), $matches);

        return $matches[1];
    }
}

看的有點迷?

跑個程式碼看一下

>>> $c = Customer::query()->where('id',92424)->select(['id'])->first(92424);
=> App\Models\Customer {#3479
     id: 92424,
   }
>>> $c->test_tag = $c->test_tag;
=> "年齡太小/零基礎/年齡過大"
>>> $c
=> App\Models\Customer {#3479
     id: 92424,
     test_tag: "年齡太小/零基礎/年齡過大",
     customerTags: Illuminate\Database\Eloquent\Collection {#3487
       all: [
         App\Models\CustomerTag {#3498
           id: 1586,
           customer_id: 92424,
           tag_id: 1,
           tag: App\Models\Tag {#3504
             id: 1,
             name: "年齡太小",
           },
         },
         App\Models\CustomerTag {#3499
           id: 1588,
           customer_id: 92424,
           tag_id: 2,
           tag: App\Models\Tag {#211
             id: 2,
             name: "零基礎",
           },
         },
         App\Models\CustomerTag {#3500
           id: 1587,
           customer_id: 92424,
           tag_id: 10,
           tag: App\Models\Tag {#3475
             id: 10,
             name: "年齡過大",
           },
         },
       ],
     },
   }
>>> $c->getMutatedAttributes()
=> [
     "test_tag",
   ]

可以看到,在執行 $c->test_tag = $c->test_tag; 以後 ,$c 中已經有了 test_tag 屬性,test_tag 又是我們定義的訪問器對應的,所以在 $this->addMutatedAttributesToArray() 中,對於已經存在於 Model 中的訪問器屬性,還是要繼續呼叫相應的訪問器來執行一遍程式碼。

  1. 回過頭看一看我們寫的程式碼
//App\Models\Customer
public function getTestTagAttribute()
{
    $customerTags = iteratorGet($this, 'customerTags', []);
    $tags         = [];
    foreach ($customerTags as $customerTag) {
        $tags[] = iteratorGet($customerTag->tag, 'name');
    }
    return implode('/', $tags);
}
// CustomerController
public function testCustomer()
    try {
        $beginTime = microtime(true);
        /** @var Collection $customers */
        $customers = Customer::with('customerTags.tag')->select(['id'])->limit(15)->get();
        $customers->transform(function ($customer) {
            /** @var Customer $customer */
            $customer->test_tag = $customer->test_tag;
            unset($customer->customerTags);
            return $customer;
        });
        $endTime = microtime(true);
        \Log::info($endTime - $beginTime);
        return responseSuccess($customers);
    } catch (\Exception $e) {
        errorLog($e);
        return responseFailed($e->getMessage());
    }
}

分析

由於我們為集合中的每一個模型都設定了 test_tag 屬性,然後又刪除了不想返回給前端的 relation 資料,那麼根據上邊對 laravel 原始碼的分析, 由於 test_tag 是我們定義的訪問器對應的 key,並且 test_tag 被我們設定成了模型的屬性,所以在將資料編碼成為 json 的時候,訪問器是一定會被觸發的。然後關聯關係會被重新查詢出來,並且產生 sql

怎麼樣?驚喜不驚喜,意外不意外?

laravel 大法好,沒想到還有這樣的深坑等著我們吧?

有人會說,我看你的 responseSuccess() 有判斷傳進去的資料是否實現了分頁器(Illuminate\Pagination\LengthAwarePaginator

分析分頁器

分頁器方法返回的結果集物件是 Illuminate\Pagination\LengthAwarePaginator

//Illuminate\Pagination\LengthAwarePaginator
<?php

namespace Illuminate\Pagination;

use Countable;
use ArrayAccess;
use JsonSerializable;
use IteratorAggregate;
use Illuminate\Support\Collection;
use Illuminate\Support\HtmlString;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;

class LengthAwarePaginator extends AbstractPaginator implements Arrayable, ArrayAccess, Countable, IteratorAggregate, JsonSerializable, Jsonable, LengthAwarePaginatorContract
{
    /**
     * The total number of items before slicing.
     *
     * @var int
     */
    protected $total;

    /**
     * The last available page.
     *
     * @var int
     */
    protected $lastPage;

    /**
     * Create a new paginator instance.
     *
     * @param  mixed  $items
     * @param  int  $total
     * @param  int  $perPage
     * @param  int|null  $currentPage
     * @param  array  $options (path, query, fragment, pageName)
     * @return void
     */
    public function __construct($items, $total, $perPage, $currentPage = null, array $options = [])
    {
        foreach ($options as $key => $value) {
            $this->{$key} = $value;
        }

        $this->total = $total;
        $this->perPage = $perPage;
        $this->lastPage = max((int) ceil($total / $perPage), 1);
        $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path;
        $this->currentPage = $this->setCurrentPage($currentPage, $this->pageName);
        $this->items = $items instanceof Collection ? $items : Collection::make($items);
    }

    /**
     * Get the instance as an array.
     *
     * @return array
     */
    public function toArray()
    {
        return [
            'current_page' => $this->currentPage(),
            'data' => $this->items->toArray(),
            'first_page_url' => $this->url(1),
            'from' => $this->firstItem(),
            'last_page' => $this->lastPage(),
            'last_page_url' => $this->url($this->lastPage()),
            'next_page_url' => $this->nextPageUrl(),
            'path' => $this->path,
            'per_page' => $this->perPage(),
            'prev_page_url' => $this->previousPageUrl(),
            'to' => $this->lastItem(),
            'total' => $this->total(),
        ];
    }

    /**
     * Convert the object into something JSON serializable.
     *
     * @return array
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }

    /**
     * Convert the object to its JSON representation.
     *
     * @param  int  $options
     * @return string
     */
    public function toJson($options = 0)
    {
        return json_encode($this->jsonSerialize(), $options);
    }
}

程式碼很容易理解,無論是 toJson(),還是 jsonSerialize() ,都是呼叫 toArray()

然後看構造方法可以明白 $this->items 就是集合(不是集合也轉成集合了)

然後你一定特別明白'data' => $this->items->toArray(), 這一句,沒錯,呼叫了集合的 toArray()

所以分頁器編碼資料的最終方案還是會呼叫集合的 toArray() 來編碼資料

怎麼樣?驚喜不驚喜,意外不意外?

laravel 大法好,沒想到跳來跳去都會跳到同一個坑裡吧?

解決方案

我們已經瞭解了訪問器的坑是怎麼產生的,那麼針對性的解決方案其實並不難

方案一 換個毫不相干的屬性名

換個毫不相干的屬性名,懶人專屬,不過不適合老專案,畢竟返回的欄位名不是說改就能改的

修改程式碼

public function testCustomer()
{
    try {
        $beginTime = microtime(true);
        /** @var Collection $customers */
        $customers = Customer::query()->with('customerTags.tag')->orderByDesc('expired_at')->select(['id'])->paginate(5);
        $customers->transform(function ($customer) {
            /** @var Customer $customer */
            $customer->test_tag_info = $customer->test_tag;
            unset($customer->customerTags);
            return $customer;
        });
        $endTime = microtime(true);
        \Log::info($endTime - $beginTime);
        return responseSuccess($customers);
    } catch (\Exception $e) {
        errorLog($e);
        return responseFailed($e->getMessage());
    }
}

test_tag 改為 test_tag_info

結果

{
  "msg": "操作成功",
  "code": 200,
  "data": [
    {
      "id": 92424,
      "test_tag_info": "年齡太小/零基礎/年齡過大"
    },
    {
      "id": 93863,
      "test_tag_info": "年齡太小"
    },
    {
      "id": 93855,
      "test_tag_info": "零基礎"
    },
    {
      "id": 93852,
      "test_tag_info": "年齡太小"
    },
    {
      "id": 93797,
      "test_tag_info": ""
    }
  ]
}

可以看出並沒有多餘的資料返回

日誌

可以看出並沒有多餘的 sql 產生

方案二 修改訪問器

修改訪問器?訪問器還能怎麼修改?

回想一下前邊扒原始碼的時候,我有說過,在 $model 執行訪問器的時候,有傳一個值給到訪問器,這個值就是訪問器對應的 key$model->attributes 對應的值。

在呼叫訪問器對應的 key 時,如果 key$model->attributes 中不存在,那麼 $value 是一個 null

在編碼轉化 $model 時,如果 key$model->attributes 中不存在,那麼該訪問器不會被呼叫。

我們對傳進訪問器的值加以判斷

修改程式碼

// App\Models\Customer
public function getTestTagAttribute($value)
{
    if ($value !== null) {
        return $value;
    }
    $customerTags = iteratorGet($this, 'customerTags', []);
    $tags         = [];
    foreach ($customerTags as $customerTag) {
        $tags[] = iteratorGet($customerTag->tag, 'name');
    }
    return implode('/', $tags);
}

在這裡,我判斷 $value 不為 null ,就返回 $value

結果

{
  "msg": "操作成功",
  "code": 200,
  "data": [
    {
      "id": 92424,
      "test_tag": "年齡太小/零基礎/年齡過大"
    },
    {
      "id": 93863,
      "test_tag": "年齡太小"
    },
    {
      "id": 93855,
      "test_tag": "零基礎"
    },
    {
      "id": 93852,
      "test_tag": "年齡太小"
    },
    {
      "id": 93797,
      "test_tag": ""
    }
  ]
}

可以看出並沒有多餘的資料返回

日誌

可以看出並沒有多餘的 sql 產生,別說我拿上邊的圖,看下時間戳。

laravel 確實是被大家認可的優秀的 php 框架

功能和特性十分豐富,對開發效率帶來的提升確實不是一點半點,但是很多功能和特性,僅靠官方文件並不能真正瞭解怎麼去用,怎麼避開可能的坑。作為框架的使用者,我們不可能要求框架為我們而改變,我們能做的就是深入瞭解它,真正的駕馭它(吹牛皮的感覺真爽)

相關文章