重新学习 Laravel Eloquent:访问器
啥?重新学习 Laravel Eloquent:访问器?为什么要重新学习这玩意?
最近有反应说客户列表页面反应较慢,我测试了一下,使用体验确实很差,特别慢。后来查日志才知道,是在循环体中使用了一个定义好的一个访问器,这个访问器访问了数据库,但是相关数据库是做了关联预查询的,所以这种情况的发生是异常的。
那么问题来了,为什么呢?
带着这样的疑问,我决定忘记所有,从一个小白的态度,重新学习一下 Laravel Eloquent:访问器。
开始学习
开始实验之前,对这一块的使用方法还不了解的建议先看文档——Eloquent: 访问器了解大概
这里先描述本次实验的大致情况
- version: Laravel5.5
- Model:
customer
->customer_tags
一对多customer_tags
->tags
一对一- 需求:
- 将每个客户所有的
tag
转成字符串用/
隔开返回
编写代码
我们需要一个在控制器中定义一个
function
处理请求返回数据。定义一个访问器来实现需求返回给前端
准备工作
脱敏,去除无关字段。
辅助方法
responseSuccess()
返回给前端前对数据进行格式处理
iteratorGet()
从一个数组或者对象中获取一个元素,如果没有就返回 null
1 | // helpers.php |
定义访问器
定义访问器,将当前客户所有的
tag
转成字符串用/
隔开返回
1 | // App/Models/Customer |
控制器方法
处理请求返回数据
1 | // CustomerController |
第一次请求
返回的字段中并没有我们想要的数据
结果
1 | { |
分析
为什么会没有呢?难道定义的访问器并不能访问数据?还是说没有被调用呢?
我们在 tinker
中查询一个 Customer
,看一下 Customer
打印的结果
1 | >>> $c = Customer::find(92424); |
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()
的定义
1 | namespace Illuminate\Database\Eloquent; |
getAttribute()
不在 Model
对象中定义,在\Illuminate\Database\Eloquent\Concerns\HasAttributes
中定义,我们看一下。
Str::studly()
是将字符串转化成大写字母开头的驼峰风格字符串
1 | namespace Illuminate\Database\Eloquent\Concerns; |
看了源码以后我们就很清楚了
定义的访问器是通过魔术方法来实现的,并不是真的会注册一个属性。
明白了以后,我们继续
第二次请求
调整代码
我们将
controller function
稍作修改
1 | // CustomerController |
结果
日志中记录的时间为
local.INFO: 0.01134991645813
1 | { |
OK,非常好,到这里我们已经实现了我们的需求。但是多余的 customer_tags
是前端不需要的,所以我们继续略改代码,将它移除掉。
第三次请求
调整代码
我们将
controller function
稍作修改,执行完访问器以后,删除掉customer_tags
1 | // CustomerController |
结果
很奇怪,这里我们明明
unset()
移除了$customer->customerTags
, 结果还是返回了相关数据。
1 | { |
很奇怪,这里我们明明 unset()
移除了 $customer->customerTags
, 结果还是返回了相关数据。为什么呢?
这里我开启了 sql
日志以后,再次执行,依然还是之前的结果。没关系,我们不慌,来查看日志。
可以看出,在输出执行时间之后,又多出来了许多 sql
,而这些 sql
正是用来查询客户的tags
相关信息的。执行时间输出以后就执行了 responseSuccess()
,难道这个方法有问题?
让我们修改一下 responseSuccess()
,添加一条 log
1 | //对返回给前端前对数据进行格式处理 |
WTF ? 这是怎么回事?“华丽的分割线”之后就是框架提供的返回 json
数据的方法,难道框架本身出了什么问题?
追查 json()
方法
tinker
中执行response()
查看返回的对象
1 | Psy Shell v0.9.9 (PHP 7.1.25 — cli) by Justin Hileman |
- 查看
Illuminate\Routing\ResponseFactory
1 | namespace Illuminate\Routing; |
- 查看
Illuminate\Http\JsonResponse
1 | namespace Illuminate\Http; |
- 查看
Symfony\Component\HttpFoundation\JsonResponse
中的构造方法
在当前流程中,第四个参数一定是
false
(调用的时候压根就没传第四个参数),所以就是调用了Illuminate\Http\JsonResponse::setData()
1 | namespace Symfony\Component\HttpFoundation; |
- 分析
Illuminate\Http\JsonResponse::setData()
的执行
1 | /** |
通过看代码, 这么分支,那么是执行了哪个分支呢?所以我们要先弄清楚$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 | >>> $test = new \Illuminate\Database\Eloquent\Collection(); |
Illuminate\Database\Eloquent\Collection
确实实现了 JsonSerializable
,所以这里关于 $customers
被编码的情况,应该是要找到 Illuminate\Database\Eloquent\Collection
中的 jsonSerialize()
- 我们看一下
Illuminate\Database\Eloquent\Collection
1 | namespace Illuminate\Database\Eloquent; |
Illuminate\Database\Eloquent\Collection
又继承了Illuminate\Support\Collection
- 我们看一下
Illuminate\Support\Collection
1 | namespace Illuminate\Support; |
可以看得出,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
1 | namespace Illuminate\Database\Eloquent; |
看到这里,我们就明白了。
$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()
1 | public function toArray() |
- 查看
$this->attributesToArray()
$this->relationsToArray()
是处理关联关系的,本质上还是对Collection
和Model
中的toArray()
调用
1 | namespace Illuminate\Database\Eloquent\Concerns; |
看的有点迷?
跑个代码看一下
1 | >>> $c = Customer::query()->where('id',92424)->select(['id'])->first(92424); |
可以看到,在执行 $c->test_tag = $c->test_tag;
以后 ,$c
中已经有了 test_tag
属性,test_tag
又是我们定义的访问器对应的,所以在 $this->addMutatedAttributesToArray()
中,对于已经存在于 Model
中的访问器属性,还是要继续调用相应的访问器来执行一遍代码。
- 回过头看一看我们写的代码
1 | //App\Models\Customer |
1 | // CustomerController |
分析
由于我们为集合中的每一个模型都设置了 test_tag
属性,然后又删除了不想返回给前端的 relation
数据,那么根据上边对 laravel
源码的分析, 由于 test_tag
是我们定义的访问器对应的 key
,并且 test_tag
被我们设置成了模型的属性,所以在将数据编码成为 json
的时候,访问器是一定会被触发的。然后关联关系会被重新查询出来,并且产生 sql
。
怎么样?惊喜不惊喜,意外不意外?
laravel
大法好,没想到还有这样的深坑等着我们吧?
有人会说,我看你的 responseSuccess()
有判断传进去的数据是否实现了分页器(Illuminate\Pagination\LengthAwarePaginator
)
分析分页器
分页器方法返回的结果集对象是
Illuminate\Pagination\LengthAwarePaginator
1 | //Illuminate\Pagination\LengthAwarePaginator |
代码很容易理解,无论是 toJson()
,还是 jsonSerialize()
,都是调用 toArray()
然后看构造方法可以明白 $this->items
就是集合(不是集合也转成集合了)
然后你一定特别明白'data' => $this->items->toArray(),
这一句,没错,调用了集合的 toArray()
所以分页器编码数据的最终方案还是会调用集合的 toArray()
来编码数据
怎么样?惊喜不惊喜,意外不意外?
laravel
大法好,没想到跳来跳去都会跳到同一个坑里吧?
解决方案
我们已经了解了访问器的坑是怎么产生的,那么针对性的解决方案其实并不难
方案一 换个毫不相干的属性名
换个毫不相干的属性名,懒人专属,不过不适合老项目,毕竟返回的字段名不是说改就能改的
修改代码
1 | public function testCustomer() |
将 test_tag
改为 test_tag_info
结果
1 | { |
可以看出并没有多余的数据返回
日志
可以看出并没有多余的 sql
产生
方案二 修改访问器
修改访问器?访问器还能怎么修改?
回想一下前边扒源码的时候,我有说过,在
$model
执行访问器的时候,有传一个值给到访问器,这个值就是访问器对应的key
在$model->attributes
对应的值。在调用访问器对应的
key
时,如果key
在$model->attributes
中不存在,那么$value
是一个null
。在编码转化
$model
时,如果key
在$model->attributes
中不存在,那么该访问器不会被调用。我们对传进访问器的值加以判断
修改代码
将 test_tag_info
改为 test_tag
1 | public function testCustomer() |
1 | // App\Models\Customer |
在这里,我判断 $value
不为 null
,就返回 $value
结果
1 | { |
可以看出并没有多余的数据返回
日志
可以看出并没有多余的 sql
产生,别说我拿上边的图,看下时间戳。
总结
laravel
确实是被大家认可的优秀的 php
框架(排除我,我喜欢接近原生的 CI)
功能和特性十分丰富,对开发效率带来的提升确实不是一点半点,但是很多功能和特性,仅靠官方文档并不能真正了解怎么去用,怎么避开可能的坑。作为框架的使用者,我们不可能要求框架为我们而改变,我们能做的就是深入了解它,真正的驾驭它(吹牛皮的感觉真爽)
完