[源碼分析系列] Laravel 訪問器,你真的用好了嗎?(大坑實踐)

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

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

啥?從新學習 Laravel Eloquent:訪問器?爲何要從新學習這玩意?php

最近有反應說客戶列表頁面反應較慢,我測試了一下,使用體驗確實不好,特別慢。後來查日誌才知道,是在循環體中使用了一個定義好的一個訪問器,這個訪問器訪問了數據庫,可是相關數據庫是作了關聯預查詢的,因此這種狀況的發生是異常的。前端

那麼問題來了,爲何呢?laravel

帶着這樣的疑問,我決定忘記全部,從一個小白的態度,從新學習一下 Laravel Eloquent:訪問器。sql

開始學習

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

這裏先描述本次實驗的大體狀況json

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

編寫代碼

咱們須要一個在控制器中定義一個 function 處理請求返回數據。定義一個訪問器來實現需求返回給前端數組

準備工做

脫敏,去除無關字段。緩存

輔助方法

  • responseSuccess() 返回給前端前對數據進行格式處理bash

  • iteratorGet() 從一個數組或者對象中獲取一個元素,若是沒有就返回 nullapp

// 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 框架(排除我,我喜歡接近原生的 CI)

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

相關文章
相關標籤/搜索