啥?重新學習 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
是可以執行我們定義的訪問器呢?
不要著急,慢慢回顧一下 php
的 oop
,我們都知道 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()
方法
tinker
中執行response()
檢視返回的物件
Psy Shell v0.9.9 (PHP 7.1.25 — cli) by Justin Hileman
>>> response()
=> Illuminate\Routing\ResponseFactory {#3470}
- 檢視
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);
}
}
- 檢視
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();
}
}
- 檢視
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);
}
}
- 分析
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()
- 我們看一下
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
- 我們看一下
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
。
- 來看一下
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\Model
,Illuminate\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());
}
- 檢視
$this->attributesToArray()
$this->relationsToArray()
是處理關聯關係的,本質上還是對Collection
和Model
中的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
中的訪問器屬性,還是要繼續呼叫相應的訪問器來執行一遍程式碼。
- 回過頭看一看我們寫的程式碼
//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
框架
功能和特性十分豐富,對開發效率帶來的提升確實不是一點半點,但是很多功能和特性,僅靠官方文件並不能真正瞭解怎麼去用,怎麼避開可能的坑。作為框架的使用者,我們不可能要求框架為我們而改變,我們能做的就是深入瞭解它,真正的駕馭它(吹牛皮的感覺真爽)
完
本作品採用《CC 協議》,轉載必須註明作者和本文連結