PHP—— Laravel 访问器,你真的用好了吗?

重新学习 Laravel Eloquent:访问器

啥?重新学习 Laravel Eloquent:访问器?为什么要重新学习这玩意?

最近有反应说客户列表页面反应较慢,我测试了一下,使用体验确实很差,特别慢。后来查日志才知道,是在循环体中使用了一个定义好的一个访问器,这个访问器访问了数据库,但是相关数据库是做了关联预查询的,所以这种情况的发生是异常的。

那么问题来了,为什么呢?

带着这样的疑问,我决定忘记所有,从一个小白的态度,重新学习一下 Laravel Eloquent:访问器。

开始学习

开始实验之前,对这一块的使用方法还不了解的建议先看文档——Eloquent: 访问器了解大概

这里先描述本次实验的大致情况

  • version: Laravel5.5
  • Model:
    • customer -> customer_tags 一对多
    • customer_tags -> tags 一对一
  • 需求:
    • 将每个客户所有的 tag 转成字符串用/隔开返回

编写代码

我们需要一个在控制器中定义一个 function 处理请求返回数据。定义一个访问器来实现需求返回给前端

准备工作

脱敏,去除无关字段。

辅助方法

  • responseSuccess() 返回给前端前对数据进行格式处理

  • iteratorGet() 从一个数组或者对象中获取一个元素,如果没有就返回 null

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 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 转成字符串用/隔开返回

1
2
3
4
5
6
7
8
9
10
// App/Models/Customer
public function getTestTagAttribute()
{
$customerTags = iteratorGet($this, 'customerTags', []);
$tags = [];
foreach ($customerTags as $customerTag) {
$tags[] = iteratorGet($customerTag->tag, 'name');
}
return implode('/', $tags);
}

控制器方法

处理请求返回数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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());
}
}

第一次请求

返回的字段中并没有我们想要的数据

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"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 打印的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
>>> $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() 的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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() 是将字符串转化成大写字母开头的驼峰风格字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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稍作修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
"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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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, 结果还是返回了相关数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
"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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//对返回给前端前对数据进行格式处理
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() 查看返回的对象
1
2
3
Psy Shell v0.9.9 (PHP 7.1.25 — cli) by Justin Hileman
>>> response()
=> Illuminate\Routing\ResponseFactory {#3470}
  1. 查看 Illuminate\Routing\ResponseFactory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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() 的执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* {@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

1
2
3
4
5
6
>>> $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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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()

1
2
3
4
public function toArray()
{
return array_merge($this->attributesToArray(), $this->relationsToArray());
}
  1. 查看 $this->attributesToArray()

$this->relationsToArray() 是处理关联关系的,本质上还是对 CollectionModel 中的 toArray() 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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];
}
}

看的有点迷?

跑个代码看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
>>> $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. 回过头看一看我们写的代码
1
2
3
4
5
6
7
8
9
10
//App\Models\Customer
public function getTestTagAttribute()
{
$customerTags = iteratorGet($this, 'customerTags', []);
$tags = [];
foreach ($customerTags as $customerTag) {
$tags[] = iteratorGet($customerTag->tag, 'name');
}
return implode('/', $tags);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
//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 大法好,没想到跳来跳去都会跳到同一个坑里吧?

解决方案

我们已经了解了访问器的坑是怎么产生的,那么针对性的解决方案其实并不难

方案一 换个毫不相干的属性名

换个毫不相干的属性名,懒人专属,不过不适合老项目,毕竟返回的字段名不是说改就能改的

修改代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"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 中不存在,那么该访问器不会被调用。

我们对传进访问器的值加以判断

修改代码

test_tag_info 改为 test_tag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 = $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());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"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)

功能和特性十分丰富,对开发效率带来的提升确实不是一点半点,但是很多功能和特性,仅靠官方文档并不能真正了解怎么去用,怎么避开可能的坑。作为框架的使用者,我们不可能要求框架为我们而改变,我们能做的就是深入了解它,真正的驾驭它(吹牛皮的感觉真爽)

文章不错,你都不请我喝杯茶,就是说你呀!
0%
upyun