Laravel 访问器,你真的用好了吗?(大坑实践)
原文地址
Laravel 访问器,你真的用好了吗?(大坑实践)
啥?重新学习 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 协议》,转载必须注明作者和本文链接
推荐文章: