slpi1

slpi1


  • 首页

  • 归档

word操作与pdf转码

发表于 2019-11-08

Index

  • word文档中动态插入内容
  • 检查预定义标记是否存在
    • 一般情况
    • 审阅模式
  • word转pdf
  • 其他问题
    • mac上编辑过后,标记检查失败
    • pdf转码时中文出现乱码

最近在开发供应商入库系统时,涉及到部分操作 office 文档的过程,在这里简单记录一下。主要有下面这几个过程

  • 在 word 文档指定位置插入内容
  • 检查 word 文档中预定内容是否存在
  • word 文档转 pdf

word文档中动态插入内容

在项目中会按供应商商生成许多合同文档,附件等,需要以供应商公司名称等预定内容填充至文档中。这里可以使用 phpoffice/phpword 扩展直接来完成:

1
2
3
4
5
6
7
8
9
10
11
12
use PhpOffice\PhpWord\TemplateProcessor;

$template = '/template.docx';
$option = [
'company' => '游族',
'user' => '雷行'
];
$target = '/target.docx';

$templateProcessor = new TemplateProcessor($template);
$templateProcessor->setValue(array_keys($option), array_values($option));
$templateProcessor->saveAs($target);

其中 $template 表示模板文档路径,$option 是待填充内容。 $target 是生成的文件保存路径。待填充部分的内容,以规定的格式将键值填入文档,即可完成替换。如模板文档中字符 ${company} 会被替换为 游族, ${user} 会被替换为 雷行。

检查预定义标记是否存在

由于业务流程问题,在入库过程中,需要在文档中保留一部分的标记,供后续流程进行替换。在此之前,供应商会下载文档,进行编辑,然后重新上传,在上传时,服务端需要检查文档中标记是否被编辑过,是则提示供应商错误信息,以免后续流程执行错误。

一般情况

通过 TemplateProcessor::getVariableCount() 方法可以获取文档中存在的标记和数量,返回数据格式如下:

1
2
3
4
5
6
7
8
9
10
$target = '/target.docx';

$templateProcessor = new TemplateProcessor($target);
$result = $templateProcessor->getVariableCount();

// $result
// [
// 'company' => 1,
// 'user' => 1
// ]

因此只需要检查返回结果中是否存在相应的键值即可。

审阅模式

在审阅模式下,直接通过上述方法,无法获得正确的结果,其原因在于:审阅模式下,编辑 word 文档,即使删除了标记,但在 word 文档的数据源中,仍然存在标记,只不过通过删除线,在文档中变成了不可见内容。所以即使用户编辑了标记,上述方法也无法检测到标记的丢失。

将 word 文档重命名为 zip 的文档后解压,得到大致如下结构的目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/
|---_rels/
|---.rels
|---docProps/
|---app.xml
|---core.xml
|---custom.xml
|---word/
|---_rels/
|---media/
|---theme/
|---document.xml
|---endnotes.xml
|---fontTable.xml
|---footer1.xml
|---footnotes.xml
|---header1.xml
|---numbering.xml
|---people.xml
|---settings.xml
|---styles.xml
|---webSettings.xml
|---[Content_Types].xml

操作 word 文档,实际就是对压缩文件内的子文件的操作。文档的内容基本都在 /word/document.xml 这个文件当中。我们查看审阅模式下的 document.xml 找到被编辑过的标记:

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
<!--- 这是审阅模式下被编辑过的标记的数据源 -->
<w:ins w:id="29" w:author="宋正平(雷行)" w:date="2019-10-08T13:58:00Z">
<w:del w:id="30" w:author="宋正平(雷行) [2]" w:date="2019-12-24T10:51:00Z">
<w:r w:rsidR="001737A4" w:rsidRPr="001737A4" w:rsidDel="00212F81">
<w:rPr>
<w:rFonts w:ascii="微软雅黑" w:eastAsia="微软雅黑" w:hAnsi="微软雅黑"/>
<w:szCs w:val="21"/>
<w:u w:val="single"/>
</w:rPr>
<w:delText>${sY}</w:delText>
</w:r>
</w:del>
</w:ins>


<!--- 这是审阅模式下没有编辑过的标记的数据源 -->
<w:ins w:id="32" w:author="宋正平(雷行)" w:date="2019-10-08T13:58:00Z">
<w:r w:rsidR="001737A4" w:rsidRPr="001737A4">
<w:rPr>
<w:rFonts w:ascii="微软雅黑" w:eastAsia="微软雅黑" w:hAnsi="微软雅黑"/>
<w:szCs w:val="21"/>
<w:u w:val="single"/>
</w:rPr>
<w:t>${sM}</w:t>
</w:r>
</w:ins>

通过对比发现,审阅模式下被编辑过的内容,依然存在文档数据源中,不过被添加了一对 <w:delText></w:delText> 的标记。所以我们只需要在执行 getVariableCount() 方法时,过滤掉这部分的标记即可。

为了方便操作,我们新建一个类,继承自 PhpOffice\PhpWord\TemplateProcessor 类,然后添加相应操作:

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
<?php

namespace App\Services;

use App\Exceptions\ErrorLogicException;
use PhpOffice\PhpWord\TemplateProcessor;

class WordTagDeleteCheck extends TemplateProcessor
{
/**
* 过滤审阅模式下已删除的标记
*
* @method getVariableCountWithoutDel
* @author 雷行 songzhp@yoozoo.com 2019-10-30T12:01:16+0800
* @return array
*/
public function getVariableCountWithoutDel()
{

// 通过原方法获取标记列表
$vars = $this->getVariableCount();

// 构建替换数组,将带有删除线的标记替换为空字符
$option = [];
foreach ($vars as $key => $value) {
$option['<w:delText>${' . $key . '}</w:delText>'] = '';
}

$search = array_keys($option);
$replace = array_values($option);

// 对文档页头执行替换
$this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, self::MAXIMUM_REPLACEMENTS_DEFAULT);

// 对文档内容执行替换
$this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, self::MAXIMUM_REPLACEMENTS_DEFAULT);

// 对文档页脚执行替换
$this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, self::MAXIMUM_REPLACEMENTS_DEFAULT);

// 重新获取文档标记
return $this->getVariableCount();
}

/**
* 检查合同时间标记是否缺少
*
* @method checkDateTagDelete
* @author 雷行 songzhp@yoozoo.com 2019-10-30T12:01:40+0800
* @return boolean
*/
public function checkDateTagDelete()
{

$vars = $this->getVariableCountWithoutDel();
if (!isset($vars['sY']) ||
!isset($vars['sM']) ||
!isset($vars['sD']) ||
!isset($vars['eY']) ||
!isset($vars['eM']) ||
!isset($vars['eD'])
) {
throw new ErrorLogicException('file.doc.contract');
}
return true;
}
}

word转pdf

要完成word转pdf,需要现在服务器上安装软件 libreoffice, 然后就可以通过命令来完成:

1
export HOME=/output && soffice  --headless --convert-to pdf:writer_pdf_Export  --outdir /output /target.docx

其中,target.docx 表示文档路径,output 表示转码后 pdf 文档存放的目录。

一般来说,执行 docx 文档的生成、pdf 的转码,都需要放到队列中异步执行,然而 libreoffice 提供的命令不支持并发操作。所以在启动队列时,执行 pdf 文件转码的队列只允许有一个,否则出现并发,会导致进程卡死,PHP 执行 exec 的进程挂起,队列中的任务会无限超时,pdf 生成失败,千万要注意。

其他问题

mac上编辑过后,标记检查失败

mac上编辑 word 文档时,可能因 mac 上不具备原文档所需要的字体,自动转化为其他字体,此时会改变标记的 xml 数据,导致标记检查失效。解决方法是在保存模板时嵌入字体

pdf转码时中文出现乱码

  • 检查服务器上是否有安装中文字体,如果没有可能会导致中文全部乱码
  • 原 word 文档是否有嵌入字体,如果服务端已安装中文字体,word 文档嵌入字体,可能会导致部分中文乱码

错误与异常的处理

发表于 2019-11-01

Index

  • 概念
    • 什么是异常
      • 异常的捕获
      • 异常未捕获的情况
    • 什么是错误
      • 错误级别
      • 错误的捕获
      • 错误未捕获的情况
  • 模拟错误的产生
    • E_NOTICE
    • E_WARNING
    • E_ERROR
  • 异常的运用
    • 前提
    • 使用

概念

首先要注意区分错误与异常的概念。

什么是异常

所有的异常都由一个基类:Exception。异常是指在代码中由程序猿通过 throw new Exception 语法主动抛出的。

异常的捕获

异常可以通过两种方式进行捕获:

  • try/catch 语句块进行捕获
  • set_exception_handler 异常处理函数

Exception 异常可以被第一个匹配的 try / catch 块所捕获。如果没有匹配的 catch 块,则调用异常处理函数(事先通过 set_exception_handler() 注册)进行处理。

异常未捕获的情况

如果没有对异常进行捕获,就会产生一个致命错误。

如果尚未注册异常处理函数,则按照传统方式处理:被报告为一个致命错误(Fatal Error)。

什么是错误

错误是代码在运行过程中产生的,一般不由程序猿主动抛出。当出现错误时,说明代码中有bug,需要进行修复。

错误级别

错误有级别之分。经常遇到的错误级别有下列几种:

值 常量 说明
1 E_ERROR 致命的运行时错误。这类错误一般是不可恢复的情况,例如内存分配导致的问题。后果是导致脚本终止不再继续运行。
2 E_WARNING 运行时警告 (非致命错误)。仅给出提示信息,但是脚本不会终止运行。
8 E_NOTICE 运行时通知。表示脚本遇到可能会表现为错误的情况,但是在可以正常运行的脚本里面也可能会有类似的通知。

其他的错误类型在日常开发中可能遇到的不是很多,就不进行一一列举。其中, E_ERROR 类型的错误分为可捕获错误与致命错误。

错误的捕获

错误也是可进行捕获的。在PHP7中,由于改变了大多数错误的报告方式,大多数错误被作为 Error 异常抛出,也可以通过 try / catch 块进行捕获。

  • try/catch 语句块进行捕获
  • set_exception_handler 异常处理函数
  • set_error_handler 错误处理函数
  • register_shutdown_function => error_get_last

set_error_handler 错误处理函数所能捕获的错误有限。

以下级别的错误不能由用户定义的函数来处理: E_ERROR、 E_PARSE、 E_CORE_ERROR、 E_CORE_WARNING、 E_COMPILE_ERROR、 E_COMPILE_WARNING,和在 调用 set_error_handler() 函数所在文件中产生的大多数 E_STRICT。

register_shutdown_function 会由以下情况触发:

  • 脚本正常退出时
  • 在脚本运行(run-time not parse-time)出错退出时
  • 用户调用exit方法退出时

也就是说 register_shutdown_function 被执行时,并不能捕获到错误,需要在函数体内,由error_get_last来捕获最后产生的错误。

按错误级别由低到高来分的话,上述捕获手段可以分别捕获到如下表所示的错误:

错误级别 备注 捕获手段
8/E_NOTICE set_error_handler/register_shutdown_function
2/E_WARNING set_error_handler/register_shutdown_function
1/E_ERROR 可捕获错误 try/set_exception_handler/register_shutdown_function
1/E_ERROR 致命错误 register_shutdown_function

错误未捕获的情况

程序bug。

综上所述,如果在抛出一个异常之后:

  • 没有 try / catch , set_exception_handler 进行捕获,会报告为一个错误
  • 没有 set_error_handler 进行错误的捕获(其实 set_error_handler 也无法捕获上述错误)
  • 没有 register_shutdown_function、error_get_last 进行捕获
    则程序会中断运行。

模拟错误的产生

错误的模拟主要是为了方便验证上述捕获手段,并不会在实际中进行运用,关于错误模拟可以参考文件夹error部分的代码

E_NOTICE

1
2
//$test未定义,会报一个notice级别的错误
return $a;

E_WARNING

1
2
3
4
$array = [1];

// in_array函数需要传入两个参数,会报一个warning级别的错误
in_array($array);

E_ERROR

可捕获错误:

1
2
3
4
5
function sum(Array $array){

}
// sum指定传入一个数组参数,会报一个TypeError的可捕获错误
sum('a');

致命错误:

1
2
3
4
5
6
7
8

// 如果这里test的定义不放在if中,会在编译阶段报告语法错误,而不会进入到运行时。
if (true) {
function test()
{}
}
function test()
{}

异常的运用

前提

在运用异常之前,务必先了解框架的异常处理机制,或者自行设计异常的处理机制。laravel框架的异常处理逻辑主要在 Illuminate\Foundation\Bootstrap\HandleExceptions::class 这个类中。

使用

异常的使用,是指通过异常捕获机制,根据捕获到的不同类型的异常,来决定作出什么样的处理。我在项目中比较常用的技巧,是通过异常来决定接口的响应,在异常捕获的逻辑中有以下两个逻辑分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if ($exception instanceof ErrorLogicException) {
// 逻辑错误,需要反馈给用户阅读,并翻译为用户当前语系
return response()->json([
'code' => $exception->getCode(),
'data' => '',
'msg' => $exception->getMessage(),
]);
} elseif ($exception instanceof ErrorDebugException) {
// debug 错误,给用户返回一个统一的信息
// 日志记录错误信息,debug模式下直接返回错误信息,用于调试
$info = $exception->getMessage();
Log::debug($info);
return response()->json([
'code' => $exception->getCode(),
'data' => '',
'msg' => config('app.debug') ? $info : __('common.failed'),
]);
}

先定义两种异常类型:

  • ErrorLogicException: 流程逻辑错误。指没有按照规定使用,产生的异常类型。这个时候会返回明确的错误提示,或者指导用户什么是正确的操作。
  • ErrorDebugException: 非正常错误,泛指不可预知的错误,但是会影响流程的正确性。这个时候并不需要告知用户发生了什么错误,但是需要记录错误发生的相关信息,交由开发者进一步分析问题产生的原因。比如:数据保存失败等小概率事件。

然后在需要的地方抛出对应的异常即可。

对于ErrorLogicException异常的情形,并非一定要按上述逻辑来处理,因为在抛出ErrorLogicException异常的情形下,都是确定出错了的情况,也可以通过 return false 来返回函数的调用栈,如果代码的层级比较深,可能要经过Model -> Service -> Controller 或更多的层级来return到Controller,这个时候用异常就会有“短路”的效果,避免过多层级的return。

可配置化

发表于 2019-10-25

可配置化包含的内容非常广泛,这里我们仅仅讨论几个目前可能对我们有用的几个方面。

配置项

一个项目在测试环境与正式环境的访问入口是不同的,像这样的变量我们都会准备一个专门的配置文件来保存,比如laravel中的.env文件。通过配置文件,来达到修改变量,而不是修改代码的目的。

曾经流行的PHP系统中,往往会提供在后台管理配置项的功能,将配置项保存在数据库中,现今已不推荐使用这种方式。一来之前的系统很多是面向非程序员的站长的,所以需要配置可视化,但我们目前的系统都是有编程基础的人员进行管理的,直接修改配置文件并不存在门槛;其次,如果通过后台保存在数据库中,需要先连接数据库,然后才能读取配置项,在运行机制上会有一个先后问题,所以并非任意的配置项都能保存到数据库。

一般来说,配置项会满足下列特征中的一个或几个:

  • 可调整的值。
  • 环境变量。 系统入口、环境名称、debug等。
  • 服务间耦合信息。 数据库信息、reids信息等。

配置对象

配置对象,系统管理后台的所有功能,都可以抽象成对配置对象的管理。拿用户管理功能举例来说:

  • 新增用户:为系统新增一个用户对象。
  • 删除用户:删除系统中的某个用户对象。
  • 修改用户密码:修改系统中存在的用户对象的密码属性。

配置对象是如何抽象出来的呢?

1
分析系统需求 -> 构建系统逻辑 -> 抽象系统所需对象 -> 明确对象的属性 -> 对象对应数据库中的表,对象属性对应数据表的字段

编码习惯

发表于 2019-10-25
  • 单引号与双引号的使用:纯字符串使用单引号包括,而不是使用双引号。
  • 字符串与变量的拼接:

    • 通过点号(.)连接字符串与变量。
    • 双引号的写法,变量使用中括号包括。
      1
      $str = "Hello World. I'm {$name}.";
  • 同一个方法体内不要用相同的变量表示不同的含义。

  • foreach循环数组时,as后面避免使用引用。

    1
    2
    3
    foreach ($array as $key => & $value) {

    }
  • 编码过程中进行逻辑运算时,尽量避免出现无意义的罗马数值,通过使用常量来代替。此外,其他地方为了表意明确,都推荐做如上处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //错误示范
    if ($user->type == 1) {
    $isAdmin = true;
    }

    // 正确示范
    Class User{
    const TYPE_ADMIN = 1;
    }
    if ($user->type == User::TYPE_ADMIN) {
    $isAdmin = true;
    }
  • 对类型明确的函数参数,进行类型声明。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //错误示范
    function getUserName($user){
    return $user->name;
    }

    // 正确示范
    function getUserName(User $user){
    return $user->name;
    }

你不知道strtotime有多强大

发表于 2019-10-17

关于这个函数,PHP 手册译本是这样描述的:

strtotime

(PHP 4, PHP 5, PHP 7)
strtotime — 将任何字符串的日期时间描述解析为 Unix 时间戳

说明

int strtotime ( string $time [, int $now = time() ] )
本函数预期接受一个包含美国英语日期格式的字符串并尝试将其解析为 Unix 时间戳(自 January 1 1970 00:00:00 GMT 起的秒数),其值相对于 now 参数给出的时间,如果没有提供此参数则用系统当前时间。

原文:

strtotime

(PHP 4, PHP 5, PHP 7)
strtotime — Parse about any English textual datetime description into a Unix timestamp

Description

int strtotime ( string $time [, int $now = time() ] )
The function expects to be given a string containing an English date format and will try to parse that format into a Unix timestamp (the number of seconds since January 1 1970 00:00:00 UTC), relative to the timestamp given in now, or the current time if now is not supplied.

先按下不表。

关于PHP 中时间格处理,几乎是随处都会用到。在项目中就遇到过:

1
2
3
4
5
//判断是否为时间格式
function isTimeFormat($str)
{
return strtotime($str) !== false;
}

一开始并未深究其中逻辑,因为他工作挺正常的。直到最近发现多处日期数据有误,追踪到上述一段代码,才发现并没有这么简单。这段代码是如何引入的呢,通过搜索后发现,网上有很多文章都提到过这一黑科技:案例、案例、案例。所以我猜想应该也是在网上查询后加进来的。但是网上找到的案例或多或少都会提到该方法不是特别严格,对于某些特别的情况判断会出错。

阅读全文 »

PHP坑爹函数系列

发表于 2019-10-16

Index

  • 前言
  • strtotime
  • trim
  • getimagesize
  • realpath
  • pathinfo
  • opendir

前言

本系列中指出的问题,绝大部分都是因为对php文档内容不熟悉,或函数说明理解不到位导致的,所以有空多翻翻php文档。

strtotime

本函数预期接受一个包含美国英语日期格式的字符串并尝试将其解析为 Unix 时间戳(自 January 1 1970 00:00:00 GMT 起的秒数),其值相对于 now 参数给出的时间,如果没有提供此参数则用系统当前时间。

禁止给strtotime传入变量作为第一个参数。 因为你不知道这个变量代表的字符会是什么。千万要注意传入的参数,不可以是任意的字符串,否则可能会导致不确定的输出。之前网上流传的一段代码,通过strtotime来判断日期格式是否正确就是很好的错误示范:

1
2
3
4
5
6
7
8
$data='2014-11-11';//这里可以任意格式,因为strtotime函数很强大
$is_date=strtotime($data)?strtotime($data):false;

if($is_date===false){
exit('日期格式非法');
}else{
echo date('Y-m-d',$is_date);//只要提交的是合法的日期,这里都统一成2014-11-11格式
}

trim

trim/ltrim/rtrim 这三个函数,原本是用于去除字符串首尾的空白。但这个函数也支持传入第二个参数来指定要过滤的范围。之前的项目中有出现这样的用法:

1
2
3
$path = '/Resource/video/video.mp4';

$path = ltrim($path, '/Resource');

本意是想去除字符串的/Resource前缀,但是没想到结果会是 video/video.mp4,更没想到会误伤类似 /Resource/start/video.mp4 等字符串。因为如果第二个参数是一个字符列表的话,会逐个匹配去除,凡是指定的列表中出现的字符,如果在首位,都会被去除,而不是将/Resource作为一个整体去除。

getimagesize

用于检查指定图片文件的大小,除了可以检查本地文件系统中的文件,还能用于检查网络地址中的文件,这种情况下,就需要考虑因网络延迟导致的性能的问题。之前的项目中遇到过,在某个接口中检查一组网络图片的大小,导致接口返回异常缓慢。

realpath

realpath用于将目录转化成绝对路径。

realpath() 扩展所有的符号连接并且处理输入的 path 中的 ‘/./‘, ‘/../‘ 以及多余的 ‘/‘ 并返回规范化后的绝对路径名。返回的路径中没有符号连接,’/./‘ 或 ‘/../‘ 成分。

注意如果指定目录或文件不存在,函数会返回false

pathinfo

pathinfo有可能出现返回值不正确的情况。这时请注意检查文件名是否包含中文,这可能导致basename为空,解决办法是:

1
setlocale(LC_ALL, 'en_US.UTF-8');

opendir

在windows下读取映射目录时,有权限的问题。解决方案:

1
2
3
4
5
$location = "\\\\ip\web";
$user = "root";
$pass = "123456youzu";
$letter = "Z";
system("net use " . $letter . ": \"" . $location . "\" " . $pass . " /user:" . $user . " /persistent:no>nul 2>&1");

PHP代码规范

发表于 2019-10-16

1. 文件夹

  • 文件夹名称必须符合 CamelCase 式的大写字母开头驼峰命名规范。

2. 文件

  • PHP 代码文件必须以不带 BOM 的 UTF-8 编码。
  • 纯 PHP 代码文件必须省略最后的 ?> 结束标签。

3. 行

  • 行的长度一定限制在140个字符以内。
  • 非空行后一定不能有多余的空格符。
  • 每行一定不能存在多于一条语句。
  • 适当空行可以使得阅读代码更加方便以及有助于代码的分块(但注意不能滥用空行)。

4. 缩进

  • 代码必须使用4个空格符的缩进,一定不能用 tab 键 。

5. 关键字以及 true/false/null

  • PHP 所有关键字必须全部小写。
  • 常量 true、false 和 null 必须全部小写。

6. namespace 以及 use 声明

  • namespace 声明后必须插入一个空白行。
  • 所有 use 必须在 namespace 后声明。
  • 每条 use 声明语句必须只有一个 use 关键词。
  • use 声明语句块后必须要有一个空白行。
1
2
3
4
5
6
7
8
9
<?php 

namespace VendorgiPackage;

use FooClass;
use BarClass as Bar;
use OtherVendorgiOtherPackage\BazClass;

// ... additional PHP code ...

7. 类 class,属性 properties 和方法 methods

这里的类是广义的类,它包含所有的类 classes ,接口 interface 和 traits。

7.1 类 class

  • 类的命名必须遵循大写字母开头的驼峰式命名规范。
1
2
3
4
5
6
7
8
<?php 

namespace VendorgiPackage;

class ClassName
{
// constants, properties, methods
}

7.2 extends 和 implements

  • 关键词 extends 和 implements 必须写在类名称的同一行。
  • 类的开始花括号必须独占一行,结束花括号也必须在类主体后独占一行。
1
2
3
4
5
6
7
8
9
10
11
12
<?php 

namespace VendorgiPackage;

use FooClass;
use BarClass as Bar;
use OtherVendorgiOtherPackage\BazClass;

class ClassName extends ParentClass implements giArrayAccess, \Countable
{
// constants, properties, methods
}
  • implements 的继承列表如果超出140个字符也可以分成多行,这样的话,每个继承接口名称都必须分开独立成行,包括第一个。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php 

namespace VendorgiPackage;

use FooClass;
use BarClass as Bar;
use OtherVendorgiOtherPackage\BazClass;

class ClassName extends ParentClass implements
giArrayAccess,
giCountable,
giSerializable
{
// constants, properties, methods
}

7.3 常量 const

  • 类的常量中所有字母都必须大写,词间以下划线分隔。
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php 

namespace VendorgiPackage;

use FooClass;
use BarClass as Bar;
use OtherVendorgiOtherPackage\BazClass;

class ClassName extends ParentClass implements giArrayAccess, \Countable
{
const VSESION = '1.0';
const SITE_URL = 'http://www.xxx.com ';
}

7.4 属性 properties

  • 类的属性命名必须遵循小写字母开头的驼峰式命名规范 $camelCase。
  • 必须对所有属性设置访问控制(如,public,protect,private)。
  • 一定不可使用关键字 var 声明一个属性。
  • 每条语句一定不可定义超过一个属性。
  • 不要使用下划线作为前缀,来区分属性是 protected 或 private。
  • 定义属性时先常量属性再变量属性,先 public 然后 protected,最后 private。

以下是属性声明的一个范例:

1
2
3
4
5
6
7
8
9
10
11
<?php 

namespace VendorgiPackage;

class ClassName
{
const VSESION = '1.0';
public $foo = null;
protected $sex;
private $name;
}

7.5 方法 methods

  • 方法名称必须符合 camelCase() 式的小写字母开头驼峰命名规范。
  • 所有方法都必须设置访问控制(如,public,protect,private)。
  • 不要使用下划线作为前缀,来区分方法是 protected 或 private。
  • 方法名称后一定不能有空格符,其开始花括号独占一行,结束花括号必须在方法主体后单独成一行。参数左括号后和右括号前一定不能有空格。
  • 一个标准的方法声明可参照以下范例,留意其括号、逗号、空格以及花括号的位置。
1
2
3
4
5
6
7
8
9
10
11
<?php 

namespace VendorgiPackage;

class ClassName
{
public function fooBarBaz($storeName, $storeId, array $info = [])
{
// method body
}
}

7.6 方法的参数 method arguments

  • 方法参数名称必须符合 camelCase 式的小写字母开头驼峰命名规范
  • 参数列表中,每个参数后面必须要有一个空格,而前面一定不能有空格。
  • 有默认值的参数,必须放到参数列表的末尾。
  • 如果参数类型为对像必须指定参数类型为具体的类名,如下的 $bazObj 参数。
  • 如果参数类型为 array 必须指定参数类型为 array 。如下 $info。
1
2
3
4
5
6
7
8
9
10
11
<?php 

namespace VendorgiPackage;

class ClassName
{
public function foo($storeName, $storeId, BazClass $bazObj, array $info = [])
{
// method body
}
}
  • 参数列表超过140个字符可以分列成多行,这样,包括第一个参数在内的每个参数都必须单独成行。
  • 拆分成多行的参数列表后,结束括号以及最后一个参数必须写在同一行,其开始花括号可以写在同一行,也可以独占一行;结束花括号必须在方法主体后单独成一行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php 

namespace VendorgiPackage;

class ClassName
{

public function aVeryLongMethodName(
ClassTypeHint $arg1,
&$arg2,
array $arg3 = []) {
// method body
}
}

7.7 abstract 、 final 、 以及 static

  • 需要添加 abstract 或 final 声明时, 必须写在访问修饰符前,而 static 则必须写在其后。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace VendorgiPackage;

abstract class ClassName
{
protected static $foo;

abstract protected function zim();

final public static function bar()
{
// method body
}
}

7.8 方法及方法调用

  • 方法及方法调用时,方法名与参数左括号之间一定不能有空格,参数右括号前也一定不能有空格。每个参数前一定不能有空格,但其后必须有一个空格。
1
2
3
4
5
<?php

bar();
$foo->bar($arg1);
Foo::bar($arg2, $arg3);
  • 参数列表超过140个字符可以分列成多行,此时包括第一个参数在内的每个参数都必须单独成行。
1
2
3
4
5
6
<?php

$foo->bar(
$longArgument,
$longerArgument,
$muchLongerArgument);

8. 控制结构 control structures

控制结构的基本规范如下:

  • 控制结构关键词后必须有一个空格。
  • 左括号 ( 后一定不能有空格。
  • 右括号 ) 前也一定不能有空格。
  • 右括号 ) 与开始花括号 { 间一定有一个空格。
  • 结构体主体一定要有一次缩进。
  • 结束花括号 } 一定在结构体主体后单独成行。
  • 每个结构体的主体都必须被包含在成对的花括号之中, 这能让结构体更加标准,以及减少加入新行时,引入出错的可能性。

8.1 if 、 elseif 和 else

  • 标准的 if 结构如下代码所示,留意 括号、空格以及花括号的位置, 注意 else 和 elseif 都与前面的结束花括号在同一行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php 

if ($expr1) {
// if body
} elseif ($expr2) {
// elseif body
} else {
// else body;
}

// 单个if也必须带有花括号
if ($expr1) {
// if body
}

应该使用关键词 elseif 代替所有 else if ,以使得所有的控制关键字都像是单独的一个词。

8.2 switch 和 case

标准的 switch 结构如下代码所示,留意括号、空格以及花括号的位置。 case 语句必须相对 switch 进行一次缩进,而 break 语句以及 case 内的其它语句都 必须 相对 case 进行一次缩进。 如果存在非空的 case 直穿语句,主体里必须有类似 // no break 的注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php 

switch ($expr) {
case 0:
echo 'First case, with a break';
break;

case 1:
echo 'Second case, which falls through';
// no break

case 2:
case 3:
case 4:
echo 'Third case, return instead of break';
return;

default:
echo 'Default case';
break;
}

8.3 while 和 do while

一个规范的 while 语句应该如下所示,注意其 括号、空格以及花括号的位置。

1
2
3
4
5
<?php

while ($expr) {
// structure body
}

标准的 do while 语句如下所示,同样的,注意其 括号、空格以及花括号的位置。

1
2
3
4
5
<?php 

do {
// structure body;
} while ($expr);

8.4 for

标准的 for 语句如下所示,注意其 括号、空格以及花括号的位置。

1
2
3
4
5
<?php 

for ($i = 0; $i < 10; $i++) {
// for body
}

8.5 foreach

标准的 foreach 语句如下所示,注意其 括号、空格以及花括号的位置。

1
2
3
4
5
<?php 

foreach ($iterable as $key => $value) {
// foreach body
}

8.6 try, catch

标准的 try catch 语句如下所示,注意其 括号、空格以及花括号的位置。

1
2
3
4
5
6
7
8
9
<?php 

try {
// try body
} catch (FirstExceptionType $e) {
// catch body
} catch (OtherExceptionType $e) {
// catch body
}

9 闭包

  • 闭包声明时,关键词 function 后以及关键词 use 的前后都必须要有一个空格。
  • 开始花括号必须写在声明的下一行,结束花括号必须紧跟主体结束的下一行。
  • 参数列表和变量列表的左括号后以及右括号前,必须不能有空格。
  • 参数和变量列表中,逗号前必须不能有空格,而逗号后必须要有空格。
  • 闭包中有默认值的参数必须放到列表的后面。
  • 标准的闭包声明语句如下所示,注意其 括号、逗号、空格以及花括号的位置。
1
2
3
4
5
6
7
8
9
10
11
<?php

$closureWithArgs = function ($arg1, $arg2)
{
// body
};

$closureWithArgsAndVars = function ($arg1, $arg2) use ($var1, $var2)
{
// body
};
  • 参数列表以及变量列表可以分成多行,这样,包括第一个在内的每个参数或变量都必须单独成行。

以下几个例子,包含了参数和变量列表被分成多行的多情况。

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
<?php

$longArgsNoVars = function (
$longArgument,
$longerArgument,
$muchLongerArgument) {
// body
};

$noArgsLongVars = function () use (
$longVar1,
$longerVar2,
$muchLongerVar3) {
// body
};

$longArgsLongVars = function (
$longArgument,
$longerArgument,
$muchLongerArgument) use (
$longVar1,
$longerVar2,
$muchLongerVar3) {
// body
};

$longArgsShortVars = function (
$longArgument,
$longerArgument,
$muchLongerArgument) use ($var1) {
// body
};

$shortArgsLongVars = function ($arg) use (
$longVar1,
$longerVar2,
$muchLongerVar3) {
// body
};

注意,闭包被直接用作函数或方法调用的参数时,以上规则仍然适用。

1
2
3
4
5
6
7
<?php

$foo->bar(
$arg1,
function ($arg2) use ($var1) {
// body
}, $arg3);

10 注释

10.1 文件注释

  • 注释开始应该使用 /*, 不可以使用 /**;结束应该使用 */; 不可以使用 **/。
  • 第二行php版本信息,版本信息后一空行。
  • 注解内容对齐,注解之间不可有空行。
  • 星号和注释内容中间必须是一个空格。
  • 保持注解顺序一致 @copyright 然后 @link 再 @license。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* PHP version 5.5
*
* @copyright Copyright (c) 2005-2015 XXXX. (http://www.xxx.com)
* @link http://www.xxx.com
* @license xxx公司版权所有
*/

namespace VendorgiPackage;

class ClassName
{
public function aVeryLongMethodName(
ClassTypeHint $arg1,
&$arg2,
array $arg3 = []) {
// method body
}
}

10.2 类注释

  • 注释开始应该使用 /**, 不可以使用 /*;结束应该使用 */, 不可以使用 **/。
  • 第二行开始描述,描述后一空行。
  • 注解内容对齐,注解之间不可有空行。
  • 星号和注释内容中间必须是一个空格。
  • 保持注解顺序一致 @author 然后 @since 再 @version。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace VendorgiPackage;

/**
* 我是类描述信息哦!
*
* @author Author
* @since 2015年1月12日
* @version 1.0
*/
class ClassName
{
public function aVeryLongMethodName(
ClassTypeHint $arg1,
&$arg2,
array $arg3 = []) {
// method body
}
}

10.3 属性注释

  • 注释开始应该使用 /**, 不可以使用 /*,结束应该使用 */, 不可以使用 **/。
  • 星号和注释内容中间必须是一个空格。
  • 使用 var 注解并注明类型。
  • 注解基本类型包括 int、sting、array、boolea、具体类名称。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

class Cache
{
/**
* 缓存的键值
* @var string
*/
public static $cacheKey = '';

/**
* 缓存的键值
* @var string
*/
public static $cacheTime = 60;

/**
* 缓存的对象
* @var \CacheServer
*/
public static $cacheObj = null;
}

10.4 方法注释

  • 注释开始应该使用 /**, 不可以使用 /*;结束应该使用 */, 不可以使用 **/。
  • 第二行开始方法描述,方法描述后一空行。
  • 注解内容对齐,注解之间不可有空行。
  • 星号和注释内容中间必须是一个空格。
  • 注解顺序为 @param,@return,@author 和 @since,参数的顺序必须与方法参数顺序一致。
  • 参数和返回值注解包括基本类型(int/sting/array/boolean/unknown)和对象,如果多个可能类型使用 | 分割。
  • 如果参数类型为对像必须指定参数类型为具体的类名,如下的 $arg1 参数。
  • 如果参数类型为 array 必须指定参数类型为 array 。如下 $arg2。
  • 需要作者和日期注解,日期为最后修改日期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 我是方法描述信息
*
* @param ClassName $arg1 参数1描述 我是具体的对象类型哦
* @param array $arg2 参数2描述 我是数据类型哦
* @param int $arg3 参数3描述 我是基本数据类型哦
* @return boolean
* @author Author
* @since 2015年1月12日
*/
public function methodName(ClassName $arg1, array $arg2, $arg3)
{
// method body
return true;
}

10.5 其他注释

  • 代码注释尽量使用 //
  • 注释内容开始前必须一个空格
  • 代码行尾注释 // 前面必须一个空格
  • 代码注释与下面的代码对齐
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php

class ClassName
{

public function methodName(ClassName $arg1, array $arg2, $arg3)
{
// 这里是注释哦 注释内容前是有一个空格的哦
for ($i = 0; $i < 10; $i++) {
// for body 注释和下面的代码是对齐的哦
$a++; // 代码行尾注释‘//’前面必须一个空格
}

return true;
}

}
  • 多行注释时使用 /* * ......*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ClassName
{
public function methodName(ClassName $arg1, array $arg2, $arg3)
{
/**
* 这是多行注释哦
* 这是多行注释哦
*/
for ($i = 0; $i < 10; $i++) {
// for body
}
}

}

laravel ORM源码分析

发表于 2019-08-02

在web应用中,与数据库的交互可以说是最常用且最重要的操作。作为当前最流行的php框架之一,laravel对数据库操作的封装,可以说是非常优秀了。在官方文档当中,数据库的使用说明文档占据了两个大章节,分别是【数据库】与【Eloquent ORM】,为什么针对同一功能,官方要出两个文档呢?是因为它重要?复杂?对此我无从猜测,不过可以从源码中窥知一二。

一、 Eloquent的生命周期

在laravel应用的生命周期里,数据库部分出现在第二阶段,容器启动阶段。更精确的说,是容器启动阶段的服务提供者注册/启动阶段。数据库服务的入口,是数据库的服务提供者,即Illuminate\Database\DatabaseServiceProvider。

DatabaseServiceProvider的注册方法如代码所示:

1
2
3
4
5
6
7
8
9
10
public function register()
{
Model::clearBootedModels();

$this->registerConnectionServices();

$this->registerEloquentFactory();

$this->registerQueueableEntityResolver();
}

其中,registerConnectionServices()方法注册了三个别名服务,分别是db.factor/db/db.connection。db用于管理数据库连接;db.factor用于创建数据库连接;而db.connection绑定了一个可用的连接对象。值得一提的是,db.connection是通过bind方法绑定闭包到容器当中,所以在注册阶段并未实例化,而是在真正 需要进行数据连接时实例化连接对象,然后替换原来的闭包。

registerEloquentFactory()方法注册了数据填充功能中的数据工厂,用于生成模拟数据。registerQueueableEntityResolver()方法注册了队列的数据库实现。

接着,在DatabaseServiceProvider的启动方法中:

1
2
3
4
5
6
public function boot()
{
Model::setConnectionResolver($this->app['db']);

Model::setEventDispatcher($this->app['events']);
}

分别调用了Model的两个静态方法setConnectionResolver()/setEventDispatcher(),加上注册方法中的clearBootedModels(),完成了Eloquent ORM的Model类的全局设置。

1
2
3
Model::clearBootedModels();
Model::setConnectionResolver($this->app['db']);
Model::setEventDispatcher($this->app['events']);

二、 楔子 - Eloquent ORM的使用

我们先回顾一下官方文档中,关于ORM的用法:

1
2
3
4
5
6
7
8
9
10
// 1. 静态调用
User::all();
User::find(1);
User::where();

// 2. 对象调用
$flight = App\Flight::find(1);
$flight->name = 'New Flight Name';
$flight->save();
$filght->delete();

Eloquent ORM既可以通过静态调用执行方法,也可以先获取到模型对象,然后执行方法。但他们实质是一样的。在Model中定义的静态方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected static function boot()
protected static function bootTraits()
public static function clearBootedModels()
public static function on($connection = null)
public static function onWriteConnection()
public static function all($columns = ['*'])
public static function with($relations)
public static function destroy($ids)
public static function query()
public static function resolveConnection($connection = null)
public static function getConnectionResolver()
public static function setConnectionResolver(Resolver $resolver)
public static function unsetConnectionResolver()
public static function __callStatic($method, $parameters)

可以看到,形如User::find(1)/User::where()的静态调用方法,本身不在类中有定义,而是转发到__callStatic魔术方法:

1
2
3
4
public static function __callStatic($method, $parameters)
{
return (new static)->$method(...$parameters);
}

也就是先实例化自身,然后在对象上执行调用。所以,在使用Eloquent的过程中,模型基本上都会有实例化的过程,然后再对象的基础上进行方法的调用。那么我们看看Model的构造方法中,都做了哪些动作:

1
2
3
4
5
6
7
8
public function __construct(array $attributes = [])
{
$this->bootIfNotBooted();

$this->syncOriginal();

$this->fill($attributes);
}

bootIfNotBooted()是模型的启动方法,标记模型被启动,并且触发模型启动的前置与后置事件。在启动过程中,会查询模型使用的trait中是否包含boot{Name}形式的方法,有的话就执行,这个步骤可以为模型扩展一些功能,比如文档中的软删除:

要在模型上启动软删除,则必须在模型上使用 Illuminate\Database\Eloquent\SoftDeletes trait 并添加 deleted_at 字段到你的 $dates 属性上。

就是在启动SoftDeletestraits的时候,给模型添加了一组查询作用域,来新增Restore()/WithTrashed()/WithoutTrashed()/OnlyTrashed()四个方法,同时改写delete方法的逻辑,从而定义了软删除的相关行为。

syncOriginal()方法的作用在于保存原始对象数据,当更新对象的属性时,可以进行脏检查。

fill($attributes)就是初始化模型的属性。

在实际运用中可能会注意到,我们很少会用new的方法、通过构造函数来实例化模型对象,但在后续我们要说道的查询方法中,会有一个装载对象的过程,有这样的用法。为什么我们很少会new一个Model,其实原因两个方面:首先从逻辑上说,是先有一条数据库记录,然后才有基于该记录的数据模型,所以在new之前必然要有查询数据库的动作;其次是因为直接new出来的Model,它的状态有可能并不正确,需要手动进行设置,可以查阅Model的newInstance()/newFromBuilder()两个方法来理解“状态不正确”的含义。

三、 深入 - Eloquent ORM的查询过程

我们以User::all()的查询过程来作为本小节的开始,Model的all()方法代码如下:

1
2
3
4
5
6
public static function all($columns = ['*'])
{
return (new static)->newQuery()->get(
is_array($columns) ? $columns : func_get_args()
);
}

这个查询过程,可以分成三个步骤来执行:

  • new static: 模型实例化,得到模型对象。
  • $model->newQuery(): 根据模型对象,获取查询构造器$query。
  • $query->get($columns): 根据查询构造器,取得模型数据集合。

Eloquent ORM的查询过程,就可以归纳成这三个过程:

1
[模型对象]=>[查询构造器]=>[数据集合]

数据集合也是模型对象的集合,即使是做first()查询,也是先获取到只有一个对象的数据集合,然后取出第一个对象。但数据集合中的模型对象,与第一步中的模型对象不是同一个对象,作用也不一样。第一步实例化得到的模型对象,是一个空对象,其作用是为了获取第二步的查询构造器,第三步中的模型对象,是经过数据库查询,获取到数据后,对数据进行封装后的对象,是一个有数据的对象,从查询数据到模型对象的过程,我称之为装载对象,装载对象,正是使用的上文提及的newFromBuilder()方法。

newQuery()的调用过程很长,概括如下:

1
2
3
4
5
newQuery()
-->newQueryWithoutScopes() // 添加查询作用域
-->newModelQuery() // 添加对关系模型的加载
-->newEloquentBuilder(newBaseQueryBuilder()) // 获取查询构造器
--> return new Builder(new QueryBuilder()) // 查询构造器的再封装

它引出了Eloquent ORM中的一个重要概念,叫做$query,查询构造器,虽然官方文档中,有大篇幅关于Model的使用说明,但其实很多方法都会转发给$query去执行。从最后的一次调用可以看出,有两个查询构造器,分别是:

  • 数据库查询构造器:Illuminate\Database\Query\Builder
  • Eloquent ORM查询构造器:Illuminate\Database\Eloquent\Builder

备注:

  1. 由于两个类名一致,我们约定当提到Builder时,我们指的是Illuminate\Database\Query\Builder;当提到EloquentBuilder时,我们指的是Illuminate\Database\Eloquent\Builder。
  2. 在代码中,Builder或EloquentBuilder的实例一般用变量$query来表示

这两个查询构造器的存在,解释了本文开头提到的问题:为什么关于数据库的文档说明,会分为两个章节?因为一章是对Illuminate\Database\Query\Builder的说明,另一章是对Illuminate\Database\Eloquent\Builder的说明(直观的理解为对Model的说明)。

数据库查询构造器Builder定义了一组通用的,人性化的操作接口,来描述将要执行的SQL语句(见官方文档【数据库 - 查询构造器】一章。)在这一层提供的接口更接近SQL原生的使用方法,比如:where/join/select/insert/delete/update等,都很容易在数据库的体系内找到相应的操作或指令;EloquentBuilder是对Builder的再封装,EloquentBuilder在Builder的基础之上,定义了一些更复杂,但更便捷的描述接口(见官方文档【Eloquent ORM - 快速入门】一章。),比如:first/firstOrCreate/paginator等。

3.1 EloquentBuilder

EloquentBuilder是Eloquent ORM查询构造器,是比较高级的能与数据库交互的对象。一般在Model层面的与数据库交互的方法,都会转发到Model的EloquentBuilder对象上去执行,通过下列方法可以获取到一个Eloquent对象:

1
2
$query = User::query();
$query = User::select();

每个EloquentBuilder对象都会有一个Builder成员对象。

3.2 Builder

Builder是数据库查询构造器,在Builder层面已经可以与数据库进行交互了,如何获取到一个Builder对象呢?下面展示两种方法:

1
2
3
4
5
6
// 获取Builder对象
$query = DB::table('user');
$query = User::query()->getQuery();

// Builder对象与数据库交互
$query->select('name')->where('status', 1)->orderBy('id')->get();

Builder有三个成员对象:

  • ConnectionInterface
  • Grammar
  • Processor

ConnectionInterface

ConnectionInterface对象是执行SQL语句、对读写分离连接进行管理的对象,也就是数据库连接对象。是最初级的、能与数据交互的对象:

1
2
DB::select('select * from users where active = ?', [1]);
DB::insert('insert into users (id, name) values (?, ?)', [1, 'Dayle']);

虽然DB门面指向的是Illuminate\Database\DatabaseManager的实例,但是对数据库交互上的操作,都会转发到connection上去执行。

回头看本文中 Eloquent的生命周期 关于DatabaseServiceProvider的启动方法的描述,DatabaseServiceProvider的启动方法中执行的代码 Model::setConnectionResolver($this->app['db']); ,这个步骤就是为了后续获取Builder的第一个成员对象ConnectionInterface,数据库连接对象。前文提到过,数据库的连接并不是在服务提供者启动时进行的,是在做出查询动作时才会连接数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Illuminate\Database\Eloquent\Model::class
protected function newBaseQueryBuilder()
{
// 获取数据库连接
$connection = $this->getConnection();

// Builder实例化时传入的三个对象,ConnectionInterface、Grammar、Processor
return new QueryBuilder(
$connection, $connection->getQueryGrammar(), $connection->getPostProcessor()
);
}

public function getConnection()
{
return static::resolveConnection($this->getConnectionName());
}

public static function resolveConnection($connection = null)
{
// 使用通过Model::setConnectionResolver($this->app['db'])注入的resolver进行数据库的连接
return static::$resolver->connection($connection);
}

Grammar

Grammar对象是SQL语法解析对象,我们在Builder对象中调用的方法,会以Builder属性的形式将调用参数管理起来,然后在调用SQL执行方法时,先通过Grammar对象对这些数据进行解析,解析出将要执行的SQL语句,然后交给ConnectionInterface执行,获取到数据。

Processor

Processor对象的作用比较简单,将查询结果数据返回给Builder,包括查询的行数据,插入后的自增ID值。

3.3 SELECT语句的描述

在Builder对象中,关于数据库查询语句的描述,被分成12个部分:

  • aggregate: 聚合查询列描述,该部分与columns互斥
  • columns: 查询列描述
  • from: 查询表描述
  • joins: 聚合表描述
  • wheres: 查询条件描述
  • groups: 分组描述
  • havings: 分组条件描述
  • orders: 排序条件描述
  • limit: 限制条数描述
  • offset: 便宜了描述
  • unions: 组合查询描述
  • lock: 锁描述

其中,关于wherers的描述提供了相当丰富的操作接口,在实现这部分的接口时,在查询构造器Builder中将where操作分成了以下类型: Basic/Column/In/NotIn/NotInSub/InSub/NotNull/Null/between/Nested/Sub/NotExists/Exists/Raw。wheres条件的组装在 Illuminate\Database\Query\Grammars\Grammar::compileWheres() 方法中完成,每种类型都由两个部分组成:逻辑符号 + 条件表达式,逻辑符号包含and/or。多个where条件直接连接后,通过Grammar::removeLeadingBoolean去掉头部的逻辑符号,组装成最终的条件部分。如果有 Nested 的wheres描述,对Nested的部分单独执行compileWheres后,用括号包装起来形成一个复合的 条件表达式。

wheresTable:

type boolean condition
Basic and (Grammar::removeLeadingBoolean) id = 1
Column and table1.column1 = table2.column2
Nested and (wheresTable)

最终组合成的Sql语句就是 id = 1 and table1.column1 = table2.column2 and (...)。

where用法的一些注意事项:

  • where的第一个参数是数组或闭包时,条件被描述为Nested类型,也就是参数分组。
  • where的第二个参数,比较符号是等于号时,可以省略。
  • where的第三个参数是闭包时,表示一个子查询

3.4 join语句的描述

每次对Builder执行join操作时,都会新建一个JoinClause对象,在文档中关于高级 Join 语法的说明中,有非常类似于where参数分组的用法,就是由闭包导入查询条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// join高级用法
DB::table('users')
->join('contacts', function ($join) {
$join->on('users.id', '=', 'contacts.user_id')->orOn(...);
})
->get();

// where参数分组
DB::table('users')
->where('name', '=', 'John')
->orWhere(function ($query) {
$query->where('votes', '>', 100)
->where('title', '<>', 'Admin');
})
->get();

实际上JoinClause继承自Builder,所以上述代码中的闭包参数$join,后面也是可以链式调用where系列函数的。与Builder对象的区别在于扩展了一个on方法,on方法类似于whereColumn,条件的两边是对表字段的描述。

Builder调用join方法时传入的条件,会以Nested的类型添加到JoinClause对象当中,然后将JoinClause对象加入到Builder的joins部分。join结构的组装与wheres类似,会单独对JoinClause对象进行一次compileWheres,然后组装到整体SQL语句中:"{$join->type} join {$table} {$this->compileWheres($join)}"。

四、 高级 - 读写分离的实现

读写分离的问题在connection的范畴。当模型实例化Builder的时候,会先去获取一个connection,如果有配置读写分离,先获取一个writeConnection,然后获取一个readConnection,并绑定到writeConnection上去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Illuminate\Database\Connectors\ConnectionFactory
public function make(array $config, $name = null)
{
$config = $this->parseConfig($config, $name);

if (isset($config['read'])) {
return $this->createReadWriteConnection($config);
}

return $this->createSingleConnection($config);
}

protected function createReadWriteConnection(array $config)
{
$connection = $this->createSingleConnection($this->getWriteConfig($config));

return $connection->setReadPdo($this->createReadPdo($config));
}

注意此时的writeConnection与readConnection并不会真正的连接数据库,而是一个闭包,保存了获取连接的方法,当第一次需要连接数据时,执行闭包获取到连接,并将该连接替换掉闭包,后续执行SQL语句时直接使用该连接即可。在实际使用过程中,可能读写连接的使用并不能简单的按照定义而来,有时需要主动设置要使用的连接。

4.1 读连接的使用判定

在配置读写分离后,默认查询会使用readConnection,以下情况会使用writeConnection:

  • 对select操作指定为write:
1
2
3
4
5
6
7
8
// connection 级别指定
Illuminate\Database\Connection::select($query, $bindings = [], $useReadPdo = true);

// Builder 级别指定
DB::table('user')->useWritePdo()->get();

// Model 级别指定
Model::onWriteConnection()->get()
  • 查询时启用锁
  • 启用事务
  • 启用sticky配置且前文有写操作
  • 在队列执行时,读取SerializesModels的模型数据时

关于其判定逻辑的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Illuminate\Database\Connection::getReadPdo():
public function getReadPdo()
{
if ($this->transactions > 0) {
return $this->getPdo();
}

if ($this->getConfig('sticky') && $this->recordsModified) {
return $this->getPdo();
}

if ($this->readPdo instanceof Closure) {
return $this->readPdo = call_user_func($this->readPdo);
}

return $this->readPdo ?: $this->getPdo();
}

五、 进阶 - 关系模型

关于关系模型的定义,其操作接口全部定义在Illuminate\Database\Eloquent\Concerns\HasRelationships::trait中。每个关系定义方法,都是对一个关系对象的定义。

5.1 关系对象

关系对象全部继承自 Illuminate\Database\Eloquent\Relations\Relation::abstract 虚拟类。关系对象由一个查询构造器组成,用来保存由关系定义所决定的关系查询条件,和加载关系时的额外条件。比如一对一(多)的关系定义中:

1
2
3
4
5
6
7
8
public function addConstraints()
{
if (static::$constraints) {
$this->query->where($this->foreignKey, '=', $this->getParentKey());

$this->query->whereNotNull($this->foreignKey);
}
}

每当需要获取关系数据时,都会实例化关系对象,实例化的过程中调用addConstraints方法。与此同时,在加载关系数据时,可以传入额外的查询条件:

1
2
3
$users = App\User::with(['posts' => function ($query) {
$query->where('title', 'like', '%first%');
}])->get();

这些条件最终都会保存在关系对象的查询构造器中,在获取关系数据时,起到筛选作用。

在使用关系模型时,有两种模式:一种是即时加载模式,一种是预加载模式。

5.2 即时加载

即时加载关系对象,是基于当前模型对象来获取关系数据。当以$user->post的形式获取Model关系属性时,通过__get方法触发对关系模型的获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function getAttribute($key)
{
if (! $key) {
return;
}

// 访问对象属性或存取器
if (array_key_exists($key, $this->attributes) ||
$this->hasGetMutator($key)) {
return $this->getAttributeValue($key);
}

// 判断同名方法是否存在
if (method_exists(self::class, $key)) {
return;
}

// 获取关系对象
return $this->getRelationValue($key);
}

获取关系模型并实例化,得到关系模型对象,执行关系模型对象的addConstraints方法,将模型对象,转化为关系模型对象的查询条件:

  • 已知模型对象
  • 关系定义绑定对象的模型名称
  • 关系定义外键,已知模型对象的主键,及主键的值
    通过上述三个条件,可以生成关系查询,并获取到结果,这个过程是即时加载关系数据的。

即时加载在只有单个模型对象时比较适用,如果我们拥有的是一个模型集合,并且需要用到关系数据时,通过即时加载的模式,会有N+1的问题。针对每个模型去获取关系数据,都要进行一次数据库查询,这种情况下,就需要使用预加载的模式。

5.3 预加载

对于预加载关系的情况,Model::with(‘relation’)标记关系为预加载,在Model::get()获取数据时,检查到对关系的预加载标记,会对关系进行实例化,这个实例化的过程,会通过Relation::noConstraints屏蔽对关系数据的直接加载,在后续过程中,由通过Model::get()获取的模型列表数据,得到模型的ID列表,关系利用这个ID列表,统一查询关系模型数据。查询完成之后匹配到对应的模型中去,其过程如下:

  • EloquentBuilder::get():
    • Builder::get() 获取到模型数据列表
    • EloquentBuilder::eagerLoadRelations(): 获取所有模型关系
      • foreach relations EloquentBuilder::eagerLoadRelation() 针对每个关系获取关系数据
    • Collection: 转化为集合

其中:eagerLoadRelation()的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Illuminate\Database\Eloquent\Builder::eagerLoadRelation()
protected function eagerLoadRelation(array $models, $name, Closure $constraints)
{
// 获取关系对象,这里获取关系对象时会通过Relation::noConstraints屏蔽即时加载
$relation = $this->getRelation($name);

// 在这里将模型id列表注入到关系对象中,作为关系模型查询的条件
$relation->addEagerConstraints($models);

// 这里可以注入Model::with(['relation' => function($query){}])时定义的关系额外条件
$constraints($relation);

// 匹配每个关系对象数据到模型对象中去
return $relation->match(
$relation->initRelation($models, $name),
$relation->getEager(), $name
);
}

六、 总结

理解laravel的Eloquent ORM模型,可以先建立下列对象的概念:

  • Model,模型对象,编码中比较容易接触与使用的对象,是框架开放给用户的最直观的操作接口;
  • EloquentBuilder,Eloquent查询构造器;
  • Builder,数据库查询构造器,是EloquentBuilder的组成部分;
  • connection,数据库连接对象,与数据库进行交互,执行查询构造器描述的SQL语句;
  • Grammar,语法解析器,将查询构造器的描述解释为规范的SQL语句;
  • Processor,转发查询进程的结果数据;
  • Relation,关系对象,描述两个模型之间的关系,关键是关系之间的查询条件;
  • JoinClause,连接查询对象,多表join查询的实现;

上述对象的关系如图所示 relateion

当然,Eloquent ORM还有其他跟多的特性,比如数据迁移、数据填充、查询作用域、存取器等,可以留给读者自行去了解与熟悉。

laravel 队列部分源码阅读

发表于 2019-08-01

一、 依赖的服务

Illuminate\Queue\QueueServiceProvider

队列服务由服务提供者QueueServiceProvider注册。

  • registerManager() 注册队列管理器,同时添加 Null/Sync/Database/Redis/Beanstalkd/Sqs 连接驱动
    • Null:不启动队列,生产者产生的任务被丢弃
    • Sync:同步队列,生产者产生的任务直接执行
    • Database:数据库队列驱动,生产者产生的任务放入数据库
    • Redis:Redis队列驱动,生产者产生的任务放入Redis
    • Beanstalkd:略过
    • Sqs:略过
  • registerConnection() 注册队列连接获取闭包,当需要用到队列驱动连接时,实例化连接
  • registerWorker() 注册队列消费者
  • registerListener() Listen模式注册队列消费者
  • registerFailedJobServices() 注册失败任务服务
注册方法 对象 别名
QueueServiceProvider::registerManager() \Illuminate\Queue\QueueManager::class queue
QueueServiceProvider::registerConnection() \Illuminate\Queue\Queue::class queue.connection
QueueServiceProvider::registerWorker() \Illuminate\Queue\Worker::class queue.worker
QueueServiceProvider::registerListener() \Illuminate\Queue\Listener::class queue.listener
QueueServiceProvider::registerFailedJobServices() \Illuminate\Queue\Failed\FailedJobProviderInterface::class queue.failer

Illuminate\Bus\BusServiceProvider

这个服务提供者注册了Dispatcher这个服务,可以将具体的任务派发到队列。

二、 任务机制

一个可放入队列的任务类:

1
2
3
4
5
6
7
8
9
10
11
12
<?php

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class Job implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
}

任务在队列中需要经过两个过程:一个是任务入队,就是将要执行的任务,放到队列中去的过程,所有会发生这一过程的业务、对象、调用等等,可以统称为生产者;与之对应的,将任务从队列中取出,并执行的过程,叫做任务出队,所有会发生这一过程的业务、对象、调用等等,可以统称为消费者。

2.1 任务的要素

2.1.1 Illuminate\Foundation\Bus\Dispatchable

这个trait给任务添加了两个静态方法dispatch/withChain,赋予了任务派发的接口。

  1. dispatch

dispatch方法触发任务指派动作。当执行 Job::dispatch()时,会实例化一个Illuminate\Foundation\Bus\PendingDispatch对象PendingDispatch,并且将任务调用类实例化后的对象job当作构造函数的参数:

1
2
3
4
5
6
// Illuminate\Foundation\Bus\Dispatchable::trait
public static function dispatch()
{
// 这里的static转发到实际执行dispath的类 Job::dispatch,也就是Job类
return new PendingDispatch(new static(...func_get_args()));
}

PendingDispatch对象接下来可以通过链式调用来指定队列相关信息 onConnection/onQueue/allOnConnection/allOnQueue/delay/chain,然后在析构函数中,做实际的派发动作:

1
2
3
4
5
6
7
// Illuminate\Foundation\Bus\PendingDispatch::class
public function __destruct()
{
// Illuminate\Contracts\Bus\Dispatcher
// 这个服务由Illuminate\Bus\BusServiceProvider注册
app(Dispatcher::class)->dispatch($this->job);
}

PendingDispath这个中间指派者的作用,就是引出这里从容器中解析出来的服务Dispatcher::class,真正的任务指派者Dispatcher。

  1. withChain

withChain用于指定应该按顺序运行的队列列表。它只是一个语法糖,实际上的效果等同于:

1
2
3
4
5
6
7
8
9
10
11
// 1. withChain的用法
Job::withChain([
new OptimizePodcast,
new ReleasePodcast
])->dispatch();

// 2. 等同于dispatch的用法
Job::dispatch()->chain([
new OptimizePodcast,
new ReleasePodcast
])

2.1.2 Illuminate\Bus\Queueable

上文提到的PendingDispath,可以指定队列信息的方法,都是转发到任务对应的方法进行调用,Queueable就是实现了这部分的功能。这部分包括以下接口:

方法名 描述
onConnection 指定连接名
onQueue 指定队列名
allOnConnection 指定工作链的连接名
allOnQueue 指定工作链的队列名
delay 设置延迟执行时间
chain 指定工作链

以及最后一个方法dispatchNextJobInChain。上述方法都是在任务执行前调用,设置任务相关参数。dispatchNextJobInChain是在任务执行期间,如果检查到任务定义了工作链,就会派发工作链上面的任务到队列中。

2.1.3 Illuminate\Queue\SerializesModels

这个trait的作用是字符串化任务信息,方便将任务信息保存到数据库或Redis等存储器中,然后在队列的消费端取出任务信息,并据此重新实例化为任务对象,便于执行任务。

2.1.4 Illuminate\Queue\InteractsWithQueue

这个trait赋予了任务与队列进行数据交互的能力。InteractsWithQueue是任务的必要组成,如果一个任务只能被执行,而不能与队列进行交互,那么这个任务在队列中的状态就是未知的,必然会造成混乱。InteractsWithQueue与队列的交互能力来源于$job属性,它是一个QueueJob实例,需要与任务的概念进行区别:任务是泛指可执行的对象,而这个$job,是在任务出队以后,解析出来的QueueJob对象。

即时一个任务类实现了InteractsWithQueue,它在实例化的时候并没有$job这个属性。需要等到出队后的执行过程中,这个$job才被手动设置给任务。

2.1.5 Illuminate\Contracts\Queue\ShouldQueue

ShouldQueue也是是任务的必要实现的接口。只有实现了ShouldQueue接口的任务,才可以被放入队列。上文所提到的真正的任务指派者Dispatcher,它在PendingDispath销毁时所执行的dispatch方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
// Illuminate\Bus\Dispatcher::class
public function dispatch($command)
{
// 检查任务指派者是否注入了队列服务
// 并且当前任务需要方法队列
if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
return $this->dispatchToQueue($command);
}

return $this->dispatchNow($command);
}

commandShouldBeQueued方法的作用就是检查任务对象是否实现了ShouldQueue接口。如果是,就是执行dispatchToQueue方法,将任务放入队列之中;否则执行dispatchNow,放入队列执行栈(pipeline),进行同步执行。

2.2 任务入队

任务是如何被放入队列中的呢?这就引出了payload这个概念。当Dispatcher这个服务通过dispatch方法派发任务时,会通过队列服务,将任务push到队列中,在push的过程中会执行createPayloadArray方法:

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
// Illuminate\Queue\Queue

protected function createPayloadArray($job, $data = '')
{
// 如果我们文中所提到的“任务”,是一个对象的话,会调用createObjectPayload方法
// 进行一次封装,将封装后的数据Payload存入队列,如果不是一个对象的话,调用
// createStringPayload进行一次封装,然后存入队列
return is_object($job)
? $this->createObjectPayload($job)
: $this->createStringPayload($job, $data);
}

protected function createObjectPayload($job)
{
return [
'displayName' => $this->getDisplayName($job),
'job' => 'Illuminate\Queue\CallQueuedHandler@call',
'maxTries' => $job->tries ?? null,
'timeout' => $job->timeout ?? null,
'timeoutAt' => $this->getJobExpiration($job),
'data' => [
'commandName' => get_class($job),
'command' => serialize(clone $job),
],
];
}

protected function createStringPayload($job, $data)
{
return [
'displayName' => is_string($job) ? explode('@', $job)[0] : null,
'job' => $job, 'maxTries' => null,
'timeout' => null, 'data' => $data,
];
}

createObjectPayload/createStringPayload 这两个方法return的数组就是payload,是便于存储的一种格式。请特别注意 Illuminate\Queue\CallQueuedHandler@call 这部分出现的CallQueuedHandler这个对象,他是任务机制中的重要一环。

2.3 任务出队

任务出队是建立在消费者开始工作的基础之上的。在laravel的应用中,一类消费者就是Worker,队列处理器。通过命令行php artisan queue:work来启动一个Worker。Worker在daemon模式下,会不断的尝试从队列中取出任务并执行,这一过程有以下执行环节:

  • 第一步:检查是否要暂停队列,是则暂停一段时间,否则经行下一步
  • 第二步:取出当前要执行的任务,并给任务设置一个超时进程,
  • 第三步:执行任务,如果当前没有任务,暂停一段时间
  • 第四步:检查是否要停止队列

遇到三种情况会停止队列:

  • Worker进程收到SIGTERM信号
  • 使用内存超过限制
  • 收到重启命令

第二步就是任务出队的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 该方法表示,从连接$connection中,名称为$queue的队列中取出下一个任务。
protected function getNextJob($connection, $queue)
{
try {
foreach (explode(',', $queue) as $queue) {
if (! is_null($job = $connection->pop($queue))) {
return $job;
}
}
} catch (Exception $e) {
$this->exceptions->report($e);

$this->stopWorkerIfLostConnection($e);
} catch (Throwable $e) {
$this->exceptions->report($e = new FatalThrowableError($e));

$this->stopWorkerIfLostConnection($e);
}
}

如果队列驱动使用的时Database,那么$connection指的就是Illuminate\Queue\DatabaseQueue的实例,如果队列驱动使用的时Redis,那么$connection指的就是Illuminate\Queue\RedisQueue的实例。

$connection的pop方法会从队列存储中取出下一个payload,经过队列驱动的转化,得到不同的QueueJob实例,也就是上文提到的$job对象,调用$job的fire方法,任务就开始执行。

2.4 任务执行 - CallQueuedHandler

CallQueuedHandler就像是队列这个轨道上的一辆车,是任务机制中的重要环节。

我们可以把入队与出队称为队列的“内部操作”,他们是属于Queue这个概念之内的问题。而CallQueuedHandler可以看成Queue与外部任务对接的“标准接口”,如果把所有要执行的任务称为“可执行对象”,那么,只需要用CallQueueHandle这个对象来装载“可执行对象”,就可以让这个“可执行对象”利用队列的机制来执行。

在入队时,CallQueueHandle与“可执行对象”组合成为payload。在出队时,payload重放成为QueueJob,QueueJob调用fire的下一个环节,就是CallQueueHandle。

2.4.1 CallQueuedHandler的作用

在入队与出队的过程中,CallQueuedHandler并不发生任何作用,他只是随payload在队列的存储中流转进出。当任务被取出执行时,CallQueuedHandler就开始发挥作用。CallQueuedHandler的作用可以归纳为两点:

  • 继承QueueJob调用的fire方法,转发到CallQueuedHandler的handle方法,然后启动任务的执行方法。
  • 处理任务执行结果与队列中数据(payload)的去留关系

2.4.2 任务执行的调用栈

1
QueueJob::fire() ->  CallQueuedHandler::handle() ->  [任务或其他可执行对象调用]
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
// worker->process()  消费者执行一个任务
public function process($connectionName, $job, WorkerOptions $options)
{
try {
$this->raiseBeforeJobEvent($connectionName, $job);

$this->markJobAsFailedIfAlreadyExceedsMaxAttempts(
$connectionName, $job, (int) $options->maxTries
);

$job->fire();

$this->raiseAfterJobEvent($connectionName, $job);
} catch (Exception $e) {
$this->handleJobException($connectionName, $job, $options, $e);
} catch (Throwable $e) {
$this->handleJobException(
$connectionName, $job, $options, new FatalThrowableError($e)
);
}
}

// Job->fire()
public function fire()
{
$payload = $this->payload();

// 解析队列任务信息,查看上文中的createPayload方法: $payload = ['job' => 'Illuminate\Queue\CallQueuedHandler@call']
// 所以这里$class = Illuminate\Queue\CallQueuedHandler, $method = call
list($class, $method) = JobName::parse($payload['job']);

// 如果payload是通过CallQueuedHandler进行包装的,那么此时instance就是CallQueuedHandler的实例,method就是call方法
// 如果payload是通过字符串进行包装的,那么此时的instance就是制定的任务对象,method就是制定的调用方法
($this->instance = $this->resolve($class))->{$method}($this, $payload['data']);
}

2.5 小结

laravel提供的队里机制的执行调用栈,就是上述过程。当我们指队列的任务机制时,包含的内容有以下两点:

  • 队列底层提供的入队与出队机制
  • 任务出队后的执行调用栈

三、 事件机制

在充分理解任务机制的前提下,事件机制就很好理解了。事件监听器的原理是,通过Illuminate\Events\CallQueuedListener 来作为一个特殊的“任务”,将事件绑定与监听信息保存到这个“任务”中,当事件被触发时,通过事件解析出与之对应的“任务”,然后对这个“任务”进行派发,执行这个“任务”时,再去执行事件监听器。所以,这个环节的重点其实是,事件、监听器、CallQueuedListener三者之间是如何进行关联的,也就是事件监听机制。所以我们后面在分析laravel事件机制相关源码时,遇到CallQueuedListener这个对象时就知道,这是要开始与队列进行对接了。

1
2
3
4
5
6
7
// 1. 触发一个事件
event(new Event);

// 2. 从触发事件到进入队列的过程形容如下
$eventCommand = new \Illuminate\Events\CallQueuedListener(new Event);

new PendingDispatch($eventCommand);

四、 消息机制 Notification

消息机制的实现与事件机制类似。通过Illuminate\Notifications\SendQueuedNotifications 来作为一个特殊的“任务”,与消息相关信息进行关联,通过SendQueuedNotifications对象来完成入队与出队相关过程,然后在执行“任务”SendQueuedNotifications的时候解析出关联的notifiables和notification,然后据此执行消息相关逻辑。

五、 手动入队

上面提到的都是系统提供的队列机制,除此之外,你还可以手动推送任务到队列,即通过Queue Facade来指派任务。

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
// MyTask
class MyTask implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function handle($job, $args)
{
echo "MyTask";
return true;
}
}

// 推送至队列
Queue::push('MyTask@handle', $args, $queueName);

// MyAnotherTask
class MyAnotherTask implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function handle($args)
{
echo "MyTask";
return true;
}
}

// 推送至队列
Queue::push(new MyAnotherTask, $args, $queueName);

5.1 入队对象是一个实例

通过实例的方式,将任务推送到队列中,在createPayload的环节,执行的是createObjectPayload方法,这时可以利用系统提供的队列机制,实例只需要有一个handle方法作为执行方法,来承接CallQueuedHandler::handle()传递的调用栈。此时,handle方法只需要业务本身涉及到的数据作为参数。

5.2 入队对象是一个字符串

通过字符串的方式,将任务推送到队列中,在createPayload的环节,执行的是createStringPayload方法,这时无法利用系统体统的队列机制中的第二层内容:执行调用栈,在QueueJob::fire()之后会调用字符串指定的对象及方法。此时,方法除了需要业务本身涉及的数据作为参数外,还需要任务重放得到的QueueJob对象,作为第一个参数,所以上面代码中两个自定义任务的函数签名是不同的。

如果仅仅通过上述代码来执行的话,MyAnotherTask这个任务可能会一直执行下去,原因是缺少对执行任务后的处理:如果任务执行成功,因该从队列中删除掉;如果执行失败,也要有对应的处理措施。也就是CallQueuedHandler的第二个作用。我们不妨来看看CallQueuedHandler,是如何来处理这个问题的。

无论是系统的任务机制,或是事件机制,消费端从队列中取出任务信息后,还原出一个Job对象(RedisJob/DatabaseJob),然后执行这个Job的fire方法时,都会借助CallQueuedHandler这个对象来执行任务的具体内容:

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
// CallQueuedHandler->call()
public function call(Job $job, array $data)
{
// 预处理任务信息
try {
$command = $this->setJobInstanceIfNecessary(
$job, unserialize($data['command'])
);
} catch (ModelNotFoundException $e) {
return $this->handleModelNotFound($job, $e);
}

// 通过dispatcher同步执行任务
$this->dispatcher->dispatchNow(
$command, $this->resolveHandler($job, $command)
);

// 如果任务未失败,且未释放,确保工作链上的任务都已派发
if (!$job->hasFailed() && !$job->isReleased()) {
$this->ensureNextJobInChainIsDispatched($command);
}

// 如果任务未删除或未释放,删除任务
if (!$job->isDeletedOrReleased()) {
$job->delete();
}
}

也就是说,如果通过字符串的方式,手动派发任务到队列,需要自己手动进行像CallQueuedHandler::call()方法中那样的收尾工作,使任务执行完毕后,清除任务存储在队列中的信息,避免任务被重新执行。

六、 payload的存储

常用的数据存储驱动是Database与Redis,我们以Redis作为例子来做说明。

6.1 Redis

假设我们现在设置有一个名叫queue的队列,那么,在队列执行的过程中,会有下列几个key被redis用到:

  • queue 任务信息默认存储的key
  • queue:reserved 任务执行过程中,临时存储的key
  • queue:delayed 任务执行失败,被重新发布到的key,或者延迟执行的任务被发布到的key

6.1.1 入队

任务信息被推入队列时,调用RedisQueue的push方法,将任务信息的载体payload,rpush到键名为queue的lists中:

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
/**
* Push a new job onto the queue.
*
* @param object|string $job
* @param mixed $data
* @param string $queue
* @return mixed
*/
public function push($job, $data = '', $queue = null)
{
return $this->pushRaw($this->createPayload($job, $data), $queue);
}

/**
* Push a raw payload onto the queue.
*
* @param string $payload
* @param string $queue
* @param array $options
* @return mixed
*/
public function pushRaw($payload, $queue = null, array $options = [])
{
$this->getConnection()->rpush($this->getQueue($queue), $payload);

return json_decode($payload, true)['id'] ?? null;
}

如果是将一个任务推入队列中延迟执行,调用的是RedisQueue的later方法,zadd到键名为queue:delayed的zset中,延迟时长作为排序的依据:

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

/**
* Push a new job onto the queue after a delay.
*
* @param \DateTimeInterface|\DateInterval|int $delay
* @param object|string $job
* @param mixed $data
* @param string $queue
* @return mixed
*/
public function later($delay, $job, $data = '', $queue = null)
{
return $this->laterRaw($delay, $this->createPayload($job, $data), $queue);
}

/**
* Push a raw job onto the queue after a delay.
*
* @param \DateTimeInterface|\DateInterval|int $delay
* @param string $payload
* @param string $queue
* @return mixed
*/
protected function laterRaw($delay, $payload, $queue = null)
{
$this->getConnection()->zadd(
$this->getQueue($queue).':delayed', $this->availableAt($delay), $payload
);

return json_decode($payload, true)['id'] ?? null;
}

6.1.2 出队

出队的方法只有一个,就是RedisQueue的pop方法。

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
/**
* Pop the next job off of the queue.
*
* @param string $queue
* @return \Illuminate\Contracts\Queue\Job|null
*/
public function pop($queue = null)
{
$this->migrate($prefixed = $this->getQueue($queue));

list($job, $reserved) = $this->retrieveNextJob($prefixed);

if ($reserved) {
return new RedisJob(
$this->container, $this, $job,
$reserved, $this->connectionName, $queue ?: $this->default
);
}
}

/**
* Migrate any delayed or expired jobs onto the primary queue.
*
* @param string $queue
* @return void
*/
protected function migrate($queue)
{
$this->migrateExpiredJobs($queue.':delayed', $queue);

if (! is_null($this->retryAfter)) {
$this->migrateExpiredJobs($queue.':reserved', $queue);
}
}

/**
* Migrate the delayed jobs that are ready to the regular queue.
*
* @param string $from
* @param string $to
* @return array
*/
public function migrateExpiredJobs($from, $to)
{
return $this->getConnection()->eval(
LuaScripts::migrateExpiredJobs(), 2, $from, $to, $this->currentTime()
);
}

在出队之前,先检查queue:delayed上是否有到期的任务,有的话,先将这部分任务的信息转移到queue上,如果设置有超时时间,还会检查queue:reserved上是否有到期的任务,将这部分的任务信息也转移到queue上。

接着通过retrieveNextJob方法获取下一个要执行的任务信息:从queue中取出第一个任务,将他的attempt值加一后放入到queue:reserved中。

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
    /**
* Retrieve the next job from the queue.
*
* @param string $queue
* @return array
*/
protected function retrieveNextJob($queue)
{
return $this->getConnection()->eval(
LuaScripts::pop(), 2, $queue, $queue.':reserved',
$this->availableAt($this->retryAfter)
);
}


// LuaScripts::pop
/**
* Get the Lua script for popping the next job off of the queue.
*
* KEYS[1] - The queue to pop jobs from, for example: queues:foo
* KEYS[2] - The queue to place reserved jobs on, for example: queues:foo:reserved
* ARGV[1] - The time at which the reserved job will expire
*
* @return string
*/
public static function pop()
{
return <<<'LUA'
-- Pop the first job off of the queue...
local job = redis.call('lpop', KEYS[1])
local reserved = false

if(job ~= false) then
-- Increment the attempt count and place job on the reserved queue...
reserved = cjson.decode(job)
reserved['attempts'] = reserved['attempts'] + 1
reserved = cjson.encode(reserved)
redis.call('zadd', KEYS[2], ARGV[1], reserved)
end

return {job, reserved}
LUA;
}

6.1.3 执行结果

在任务执行成功时,检查任务是否被删除或Release,如果没有的话,就从queue:reserved中删除任务信息;如果执行失败的话,检查是否超过最大执行次数,超过则删除任务信息,否则标记为已删除,从queue:reserved中删除任务信息,并重新发布任务到queue:delayed中。

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

/**
* Delete a reserved job from the reserved queue and release it.
*
* @param string $queue
* @param \Illuminate\Queue\Jobs\RedisJob $job
* @param int $delay
* @return void
*/
public function deleteAndRelease($queue, $job, $delay)
{
$queue = $this->getQueue($queue);

$this->getConnection()->eval(
LuaScripts::release(), 2, $queue.':delayed', $queue.':reserved',
$job->getReservedJob(), $this->availableAt($delay)
);
}


// LuaScripts::release()
/**
* Get the Lua script for releasing reserved jobs.
*
* KEYS[1] - The "delayed" queue we release jobs onto, for example: queues:foo:delayed
* KEYS[2] - The queue the jobs are currently on, for example: queues:foo:reserved
* ARGV[1] - The raw payload of the job to add to the "delayed" queue
* ARGV[2] - The UNIX timestamp at which the job should become available
*
* @return string
*/
public static function release()
{
return <<<'LUA'
-- Remove the job from the current queue...
redis.call('zrem', KEYS[2], ARGV[1])

-- Add the job onto the "delayed" queue...
redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])

return true
LUA;
}

6.2 Database

如果队列驱动是数据库,这个过程也基本一致,不过只需要用一张数据表来保存任务信息,用reserved_at,available_at两个字段来表示不同的任务状态,在取出任务及任务失败等复杂情况下,通过事务来保证任务执行的结果与数据的一致性。:

  • 给reserved_at字段赋值,对应Redis中push到queue:reserved
  • 给available_at字段赋值,对应Redis中push到queue:delayed
  • Redis中LuaScripts脚本部分的执行,对应数据库中的事务

七、 总结

关于laravel的队列就是这些了,具体的细节部分,有大家去针对性的查看对应源码。这里对整体的逻辑做一个总结,如图:
queue
具体payload在Redis中的流转过程如图:
queue-redis

laravel Auth源码分析

发表于 2019-07-01

Index

  • Auth模块
    • AuthManager
    • Guard
      • SessionGuard与TokenGuard
    • UserProvider
  • Auth与框架的关系
    • AuthServiceProvider
    • 路由解析
    • 路由中间件

Auth 模块用于处理用户认证。在源码中,关于 Auth 模块,有两处命名空间:

  • Illuminate\Auth: Auth 模块核心代码。这部分的代码都是关于 Auth 模块的实现原理及逻辑。
  • Illuminate\Foundation\Auth: Auth 模块应用功能。这部分是 Auth 模块在应用层的一些功能的实现。

Auth模块

三大组成部分

  • AuthManager: 认证管理器
  • Guard: 认证器 or 看守器
  • UserProvider: 用户提供者

AuthManager

AuthManager 是用户认证模块功能的入口,是 Auth 类指代的实例。 AuthManager 的职责在于管理及扩展 Guard 与 UserProvider,这是他的“本职工作”,如果在使用过程中,我们不涉及对认证功能的扩展,一般不会用到这部分;AuthManager 的另一个职责,在于充当模块功能的入口,转发应用中对于 Auth 类的调用到 Guard,比如:

1
2
3
4
5
6
7
8
Auth::user();
Auth::check();
Auth::login($user);

// 等同于
Auth::guard('web')->user();
Auth::guard('web')->check();
Auth::guard('web')->login($user);

Guard

Guard 用于实现认证功能,在 AuthManager 实例化 Guard 时,会绑定一个 UserProvider 给 Guard ,用于后续提供用户实例。框架实现了 SessionGuard 及 TokenGuard ,分别用于使用 session,token 做用户认证的场景。Guard 的认证逻辑可以概括为:Guard 从上下文中获取登陆凭证,将登陆凭证传递给 UserProvider,查询出登陆用户的实例返回给 Guard。

SessionGuard与TokenGuard

Session 是非常常用的认证手段,框架实现的 SessionGuard 除了拥有认证功能外,还赋予了登陆与退出的功能。这里简单描述一下认证,登陆与退出的概念:

  • 登陆:客户端提交认证资料,经服务端验证成功后,生成登陆凭证,保存到相应位置,完成登陆。
  • 认证:服务端检查登陆凭证是否存在及有效,有则完成认证,请求放行。
  • 退出:服务端销毁登陆凭证,完成退出。

只有认证才是 Guard 的职责,其他两个并不是 Guard 的职责。基于不同的认证实现,登陆与退出功能可能会交给其他模块完成。比如基于 JWT 的 Token 认证方式,其登陆凭证是保存在客户端的,服务端不保存,所以服务端无法主动销毁登陆凭证,也就没有退出功能。然而基于 Session 的认证方式,登陆凭证是保存在服务端的,所以基于 Session 认证的方式,可以提供退出功能。

SessionGuard 实现的登陆功能,也就是文档中所指的“手动认证用户”部分。SessionGuard 提供 attempt 接口,用于用户登陆,同时提供了 logout 接口,实现了退出功能。TokenGuard 并没有这两个功能。

UserProvider

用户提供者接收由Guard传递的用户标识,查询出用户实例并返回。框架实现了 EloquentUserProvider 与 DatabaseUserProvider ,分别需要在 Auth 配置中指定用户模型与用户表。大多数情况都是使用 EloquentUserProvider。

Auth与框架的关系

要完整的了解 Auth 模块的认证过程,需要结合框架的其他模块及细节来解读。

AuthServiceProvider

和其他模块一样,Auth 模块也是由服务提供者注册,在 Laravel 应用生命周期中,处于第二阶段(容器启动)的结束阶段,在这里第一次与 Request 产生互动:

1
2
3
4
5
6
7
8
9
10
// Illuminate\Auth\AuthServiceProvider

protected function registerRequestRebindHandler()
{
$this->app->rebinding('request', function ($app, $request) {
$request->setUserResolver(function ($guard = null) use ($app) {
return call_user_func($app['auth']->userResolver(), $guard);
});
});
}

Auth服务注册时,给 request 绑定了一个“重绑定”事件,该事件的目的何在?

首先需要知道,request 对象的实例化,是在容器启动之前,是一个比较早的阶段,可以说,在 request 对象第一次被实例化时,容器中基本还没有其他对象的存在,那么,如果在代码后续执行的过程中,需要丰富 request 对象,该怎么办呢?答案就是重绑定,在合适的时机,更新 request 对象之后,重新将 request 对象绑定到容器中。

Auth 服务注册时,给 request 对象重绑定了一个事件,用于给 request 添加“用户解析”功能,当使用 request 的“用户解析”功能时,实际上会去找 Guard 要用户。然而,在服务注册阶段,Guard 表示我也还没实例化,你不能立刻来找我要用户,而是要“推迟”找我要用户的时间,所以,最终在这里绑定的是一个闭包,保存的是 request 解析用户的途径,在合适的时机,通过这一途径,即可找 Guard 要到用户,但这时机究竟是什么时候呢?这个时机,必须满足两个条件:

  • request 发生了重绑定
  • Guard 认证用户结束

路由解析

路由解析处于 Laravel 应用生命周期的的第三阶段(请求处理)。在第二阶段结束,第三阶段开始时,request 进行了重绑定:

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
// Illuminate\Foundation\Http\Kernel

// request 重绑定的发生过程

// HTTP kernel 捕获request,开始处理
public function handle($request)
{
try {
$request->enableHttpMethodParameterOverride();

$response = $this->sendRequestThroughRouter($request);
} catch (Exception $e) {
$this->reportException($e);

$response = $this->renderException($request, $e);
} catch (Throwable $e) {
$this->reportException($e = new FatalThrowableError($e));

$response = $this->renderException($request, $e);
}

$this->app['events']->dispatch(
new Events\RequestHandled($request, $response)
);

return $response;
}

// 2. HTTP kernel 发送request通过路由
protected function sendRequestThroughRouter($request)
{
// 这里第一次对request经行绑定,但不会触发重绑定事件
$this->app->instance('request', $request);
// 紧接着立刻清除已绑定的request对象
Facade::clearResolvedInstance('request');

$this->bootstrap();

return (new Pipeline($this->app))
->send($request)
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
}

// 3. HTTP kernel 准备解析路由
protected function dispatchToRouter()
{
return function ($request) {
// request 在这里重新被绑定,触发重绑定事件
$this->app->instance('request', $request);

return $this->router->dispatch($request);
};
}

框架选择在此处对 request 进行重绑定,是因为,刚刚结束的第二阶段,已经完成了所有服务提供者的注册与启动,此时容器中已经存在所有的服务对象,通过服务对象来丰富 request 对象成为可能。

在 request 重新绑定之后,执行了这么一段代码:

1
2
3
// Illuminate\Auth\AuthServiceProvider

return $this->router->dispatch($request);

这段代码的后文比较长,我简单概况一下:

1
匹配并命中路由 -> 通过路由解析并实例化控制器对象 -> 收集路由与控制器中定义的中间件 -> 执行路由中间件 -> 执行控制器方法

路由中间件

认证的行为,在中间件中触发。触发认证行为的中间件是\Illuminate\Auth\Middleware\Authenticate::class,在 request 通过该中间件时,Guard 检查 request 是否已通过认证,通过则放行,否则抛出AuthenticationException未认证异常。在通过认证之后,用户实例会保存在 Guard 对象中,后续所有找 Guard 要用户的行为,都可以得到相同的用户实例。至此,认证完成。

综上所述,用户的认证,发生在执行路由中间的过程中,在此之前,是无法通过 Auth 来获取认证用户的,需要特别注意的是,控制器的实例化过程,发生在路由中间件执行之前,所以无法在控制器的构造函数中获取用户的登陆状态。

1234
slpi1

slpi1

PHP,Laravel,thinkPHP,javascript,css

37 日志
6 标签
© 2021 slpi1
由 Hexo 强力驱动
主题 - NexT.Mist