前言

在 Nginx 中 HTTP 数据是一边接收一边进行解析的,如果解析过程中发现收到的数据有问题就会停止解析,并且停止接收数据。

而 Workerman 将解析协议这一步进行后置,当程序需要用到 HTTP 协议携带的信息时才会解析相应的数据,并把解析结果缓存起来,下次获取信息时就直接从缓存中读取即可,避免多次解析。

两种方式各有自己的优点,前者的优点就是可以及时的检查数据是否有问题,后者的优点是在接收数据的逻辑相对要简单一点。

解析上传文件的逻辑在 Request::parseUploadFiles 方法中,下面是它的调用栈。

1
2
3
4
// source: Protocols\Http\Request.php
post()/file()
-> parsePost()
-> parseUploadFiles()

解析 POST 数据

当调用 post 或 file 方法获取 POST 参数或上传的文件时,程序会检查是否已经解析过了,如果已经解析过了则直接返回对应的结果,否则就会调用 parsePost 方法解析数据。

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
protected function parsePost()
{
// rawBody() 返回的就是请求体的内容
$body_buffer = $this->rawBody();
// 初始化用于保存解析数据的缓存
$this->_data['post'] = $this->_data['files'] = array();
if ($body_buffer === '') {
return;
}
// 尝试从缓存中读取
$cacheable = static::$_enableCache && !isset($body_buffer[1024]);
if ($cacheable && isset(static::$_postCache[$body_buffer])) {
$this->_data['post'] = static::$_postCache[$body_buffer];
return;
}
// 读取请求头中的 content-type 字段,如果请求头没有被解析过,那么也会进行解析并缓存
$content_type = $this->header('content-type', '');
// 尝试解析 boundary 字段,如果获取到了说明是 multipart/form-data 类型的,可能会上传文件
if (\preg_match('/boundary="?(\S+)"?/', $content_type, $match)) {
// $match[1] 中就是我们上面说的边界值
// 加上 -- 得到请求体中的边界值
$http_post_boundary = '--' . $match[1];
$this->parseUploadFiles($http_post_boundary);
return;
}
// 如果从 content-type 中匹配到了 json,比如 application/json
// 说明请求体的数据是 JSON 格式的
if (\preg_match('/\bjson\b/i', $content_type)) {
$this->_data['post'] = (array) json_decode($body_buffer, true);
} else {
// 否则就是 application/x-www-form-urlencoded
\parse_str($body_buffer, $this->_data['post']);
}
// 如果开启了缓存,就把解析结果缓存起来
if ($cacheable) {
static::$_postCache[$body_buffer] = $this->_data['post'];
if (\count(static::$_postCache) > 256) {
unset(static::$_postCache[key(static::$_postCache)]);
}
}
}

解析请求体的内容

接下来就是 parseUploadFiles 方法的内容了。

首先处理请求体,删掉末尾的结束边界值,然后通过边界值得到数据块数组。

1
2
3
4
5
6
7
8
9
10
// 先获取请求体的内容
$http_body = $this->rawBody();
//删除末尾的结束边界值
$http_body = \substr($http_body, 0, \strlen($http_body) - (\strlen($http_post_boundary) + 4))

// 通过边界值 + \r\n 分割请求体,得到数据块数组
$boundary_data_array = \explode($http_post_boundary . "\r\n", $http_body);
if ($boundary_data_array[0] === '') {
unset($boundary_data_array[0]);
};

为什么计算 substr 结束位置最后要 +4 呢?

因为 结束边界值 = 边界值 + -- + \r\n

接下来用两个 foreach 和一个 switch case 来解析请求体的内容。

遍历所有的数据块

通过 \r\n\r\n 分割数据块,得到数据块的头部信息数据块的值,然后去除数据块的值末尾的 \r\n

1
2
3
4
5
6
foreach ($boundary_data_array as $boundary_data_buffer) {
list($boundary_header_buffer, $boundary_value) = \explode("\r\n\r\n", $boundary_data_buffer, 2);
// 去除 $boundary_value 末尾的 \r\n
$boundary_value = \substr($boundary_value, 0, -2);
$key++;
}

解析数据块的头部信息

数据块的头部信息可能存在多行,所以需要通过 \r\n 分割头部信息字符串得到头部信息的数组。

然后遍历该数组,在循环中通过 : 分割每行的头部信息,得到字段名 $header_key 和字段值 $header_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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
foreach (\explode("\r\n", $boundary_header_buffer) as $item) {
list($header_key, $header_value) = \explode(": ", $item);
$header_key = \strtolower($header_key);

switch ($header_key) {
case "content-disposition":
// 匹配到了 filename 说明是文件数据
if (\preg_match('/name="(.*?)"; filename="(.*?)"/i', $header_value, $match)) {
$error = 0;
$tmp_file = '';
// 获取文件大小
$size = \strlen($boundary_value);
// 获取上传临时目录
$tmp_upload_dir = HTTP::uploadTmpDir();
if (!$tmp_upload_dir) {
$error = UPLOAD_ERR_NO_TMP_DIR;
} else {
// 使用 tempnam 函数在临时目录下创建一个唯一文件名的临时文件
$tmp_file = \tempnam($tmp_upload_dir, 'workerman.upload.');
// 文件创建成功后,将数据块的值写入到文件中
if ($tmp_file === false || false == \file_put_contents($tmp_file, $boundary_value)) {
$error = UPLOAD_ERR_CANT_WRITE;
}
}
// 格式化上传的文件信息
$files[$key] = array(
'key' => $match[1], // 表单中的字段名
'name' => $match[2], // 文件名称
'tmp_name' => $tmp_file, // 临时文件的完整路径
'size' => $size, // 文件大小
'error' => $error // 错误
);
break;
} else {
// 未匹配到 filename 说明是 POST 字段,需要解析 $_POST.
if (\preg_match('/name="(.*?)"$/', $header_value, $match)) {
$this->_data['post'][$match[1]] = $boundary_value;
}
}
break;
case "content-type":
// 添加文件类型
$files[$key]['type'] = \trim($header_value);
break;
}
}

switch 中的逻辑就是判断 $header_key 的值,然后执行相应的操作。

  • content-disposition: 通过正则判断是否为文件数据,如果是文件就将数据块的值写入临时文件中,否则将字段和值保存到存放 POST 数据的数组中。
  • content-type: 记录文件的类型。