共计 27996 个字符,预计需要花费 70 分钟才能阅读完成。
运营研发团队 季伟滨
一、前言
前几天的工作中,需要通过 curl 做一次接口测试。让我意外的是,通过 $_POST 竟然无法获取到 Content-Type 是 application/json 的 http 请求的 body 参数。查了下 php 官网(http://php.net/manual/zh/rese…)对 $_POST 的描述,的确是这样。后来通过 file_get_contents(“php://input”) 获取到了原始的 http 请求 body,然后对参数进行 json_decode 解决了接口测试的问题。事后,脑子里面冒出了挺多问题:
php-fpm 是怎么读取并解析 FastCGI 协议的?http 请求的 header 和 body 分别都存储在哪里?
对于 Content-Type 是 application/x-www-form-urlencoded 的请求,为什么通过 $_POST 可以拿到解析后的参数数组?
对于 Content-Type 是 application/json 的请求,为什么通过 $_POST 拿不到解析后的参数数组?
基于这几个问题,对 php 代码进行了一次新的学习,有一定的收获,在这里记录一下。最后,编写了一个叫 postjson 的 php 扩展,它在源代码层面实现了 feature:对于 Content-Type 是 application/json 的请求,可以通过 $_POST 拿到请求参数。
二、fpm 整体流程
在分析之前,有必要对 php-fpm 整体流程有所了解。包括你可能想知道的 fpm 进程启动过程、ini 配置文件何时读取,扩展在哪里被加载,请求数据在哪里被读取等等,这里都会稍微提及一下,这样看后面的时候,我们会比较清楚,某一个函数调用发生在整个流程的哪一个环节,做到可识庐山真面目,哪怕身在此山中。
和 Nginx 进程的启动过程类似,fpm 启动过程有 3 种进程角色:启动 shell 进程、fpm master 进程和 fpm worker 进程。上图列出了各个进程在生命周期中执行的主要函数,其中标有颜色的表示和上面的问题答案有关联的函数。下面概况的说明一下:
启动 shell 进程
1.sapi_startup:SAPI 启动。将传入的 cgi_sapi_module 的地址赋值给全局变量 sapi_module,初始化全局变量 SG,最后执行 php_setup_sapi_content_types 函数。【这个函数后面会详细说明】
2.php_module_startup:模块初始化。php.ini 文件的解析,php 动态扩展.so 的加载、php 扩展、zend 扩展的启动都是在这里完成的。
zend_startup:启动 zend 引擎,设置编译器、执行器的函数指针,初始化相关 HashTable 结构的符号表 CG(function_table)、CG(class_table) 以及 CG(auto_globals),注册 Zend 核心扩展 zend_builtin_module(该过程会注册 Zend 引擎提供的函数:func_get_args、strlen、class_exists 等),注册标准常量如 E_ALL、TRUE、FALSE 等。
php_init_config:读取 php.ini 配置文件并解析,将解析的 key-value 对存储到 configuration_hash 这个 hashtable 中,并且将所有的 php 扩展(extension=xx.so) 的扩展名称保存到 extension_lists.functions 结构中,将所有的 zend 扩展(zend_extension=xx.so) 的扩展名称保存到 extension_lists.engine 结构中。
php_startup_auto_globals:向 CG(auth_globals) 中注册_GET、_POST、_COOKIE、_SERVER 等超全局变量钩子,在后面合适的时机(实际上是 php_hash_environment)会回调相应的 handler。
php_startup_sapi_content_types:设置 sapi_module 的 default_post_reader 和 treat_data。【这 2 个函数后面会详细说明】
php_ini_register_extensions:遍历 extension_lists.functions,使用 dlopen 函数打开 xx.so 扩展文件,将所有的 php 扩展注册到全局变量 module_registry 中,同时如果 php 扩展有实现函数的话,将实现的函数注册到 CG(function_table)。遍历 extension_lists.engine,使用 dlopen 函数打开 xx.so 扩展文件,将所有的 zend 扩展注册到全局变量 zend_extensions 中。
zend_startup_modules:遍历 module_registry,调用所有 php 扩展的 MINIT 函数。
zend_startup_extensions:遍历 zend_extensions,调用所有 zend 扩展的 startup 函数。
3.fpm_init:fpm 进程相关初始化。这个函数也比较重要。解析 php-fpm.conf、fork master 进程、安装信号处理器、打开监听 socket(默认 9000 端口)都是在这里完成的。启动 shell 进程在 fork 之后不久就退出了。而 master 进程则通过 setsid 调用脱离了原来启动 shell 的终端所在会话,成为了 daemon 进程。限于篇幅,这里不再展开。
master 进程
fpm_run:根据 php-fpm.conf 的配置 fork worker 进程(一个监听端口对应一个 worker pool 即进程池,worker 进程从属于 worker pool,只处理该监听端口的请求)。然后进入 fpm_event_loop 函数,无限等待事件的到来。
fpm_event_loop:事件循环。一直等待着信号事件或者定时器事件的发生。区别于 Nginx 的 master 进程使用 suspend 系统调用挂起进程,fpm master 通过循环的调用 epoll_wait(timeout 为 1s)来等待事件。
worker 进程
fpm_init_request:初始化 request 对象。设置 request 的 listen_socket 为从父进程复制过来的相应 worker pool 对应的监听 socket。
fcgi_accept_request:监听请求连接,读取请求的头信息。
1.accept 系统调用:如果没有请求到来,worker 进程会阻塞在这里。直到请求到来,将连接 fd 赋值给 request 对象的 fd 字段。
2.select/poll 系统调用:循环的调用 select 或者 poll(timeout 为 5s),等待着连接 fd 上有可读事件。如果连接 fd 一直不可读,worker 进程将一直在这里阻塞着。
3.fcgi_read_request:一旦连接 fd 上有可读事件之后,会调用该函数对 FastCGI 协议进行解析,解析出 http 请求 header 以及 fastcgi_param 变量存储到 request 的 env 字段中。
php_request_startup:请求初始化
1.zend_activate:重置垃圾回收器,初始化编译器、执行器、词法扫描器。
2.sapi_activate:激活 SAPI,读取 http 请求 body 数据。
3.php_hash_environment:回调在 php_startup_auto_globals 函数中注册的_GET,_POST,_COOKIE 等超全局变量的钩子,完成超全局变量的生成。
4.zend_activate_modules:调用所有 php 扩展的 RINIT 函数。
php_execute_script:使用 Zend VM 对 php 脚本文件进行编译(词法分析 + 语法分析)生成虚拟机可识别的 opcodes,然后执行这些指令。这块很复杂,也是 php 语言的精华所在,限于篇幅这里不展开。
php_request_shutdown:请求关闭。调用注册的 register_shutdown_function 回调,调用__destruct 析构函数,调用所有 php 扩展的 RSHUTDOWN 函数,flush 输出内容,发送 http 响应 header,清理全局变量,关闭编译器、执行器,关闭连接 fd 等。
注:当 worker 进程执行完 php_request_shutdown 后会再次调用 fcgi_accept_request 函数,准备监听新的请求。这里可以看到一个 worker 进程只能顺序的处理请求,在处理当前请求的过程中,该 worker 进程不会接受新的请求连接,这和 Nginx worker 进程的事件处理机制是不一样的。
三、FastCGI 协议的处理
言归正传,让我们回到本文的主题,一步步接开 $_POST 的面纱。
大家都知道 $_POST 存储的是对 http 请求 body 数据解析后的数组,但 php-fpm 并不是一个 web server,它并不支持 http 协议,一般它通过 FastCGI 协议来和 web server 如 Apache、Nginx 进行数据通信。关于这个协议,已经有其他同学写的好几篇很棒的文章来讲述,如果对 FastCGI 不了解的,可以先读一下这些文章。
一个 FastCGI 请求由三部分的数据包组成:FCGI_BEGIN_REQUEST 数据包、FCGI_PARAMS 数据包、FCGI_STDIN 数据包。
FCGI_BEGIN_REQUEST 表示请求的开始,它包括:
header
data:数据部分,承载着 web server 期望 fpm 扮演的角色 role 字段
FCGI_PARAMS 主要用来传输 http 请求的 header 以及 fastcgi_param 变量数据,它包括:
首 header:表示 FCGI_PARAMS 的开始
data:承载着 http 请求 header 和 fastcgi_params 信息的 key-value 对组成的字符串
padding:填充字段
尾 header:表示 FCGI_PARAMS 的结束
FCGI_STDIN 用来传输 http 请求的 body 数据,它包括:
首 header:表示 FCGI_STDIN 的开始
data:承载着原始的 http 请求 body 数据
padding:填充字段
尾 header:表示 FCGI_STDIN 的结束
php 对 FastCGI 协议本身的处理上,可以分为了 3 个阶段:头信息读取、body 信息读取、数据后置处理。下面一一介绍各个阶段都做了些什么。
头信息读取
头信息读取阶段只读取 FCGI_BEGIN_REQUEST 和 FCGI_PARAMS 数据包。因此在这个阶段只能拿到 http 请求的 header 以及 fastcgi_param 变量。在 main/fastcgi.c 中 fcgi_read_request 负责完成这个阶段的读取工作。从第二节可以看到,它在 worker 进程发现请求连接 fd 可读之后被调用。
static int fcgi_read_request(fcgi_request *req)
{
fcgi_header hdr;
int len, padding;
unsigned char buf[FCGI_MAX_LENGTH+8];
…
// 读取到了 FCGI_BEGIN_REQUEST 的 header
if (hdr.type == FCGI_BEGIN_REQUEST && len == sizeof(fcgi_begin_request)) {
// 读取 FCGI_BEGIN_REQUEST 的 data,存储到 buf 里
if (safe_read(req, buf, len+padding) != len+padding) {
return 0;
}
…
// 分析 buf 里 FCGI_BEGIN_REQUEST 的 data 中 FCGI_ROLE,一般是 RESPONDER
switch ((((fcgi_begin_request*)buf)->roleB1 << 8) + ((fcgi_begin_request*)buf)->roleB0) {
case FCGI_RESPONDER:
fcgi_hash_set(&req->env, FCGI_HASH_FUNC(“FCGI_ROLE”, sizeof(“FCGI_ROLE”)-1), “FCGI_ROLE”, sizeof(“FCGI_ROLE”)-1, “RESPONDER”, sizeof(“RESPONDER”)-1);
break;
case FCGI_AUTHORIZER:
fcgi_hash_set(&req->env, FCGI_HASH_FUNC(“FCGI_ROLE”, sizeof(“FCGI_ROLE”)-1), “FCGI_ROLE”, sizeof(“FCGI_ROLE”)-1, “AUTHORIZER”, sizeof(“AUTHORIZER”)-1);
break;
case FCGI_FILTER:
fcgi_hash_set(&req->env, FCGI_HASH_FUNC(“FCGI_ROLE”, sizeof(“FCGI_ROLE”)-1), “FCGI_ROLE”, sizeof(“FCGI_ROLE”)-1, “FILTER”, sizeof(“FILTER”)-1);
break;
default:
return 0;
}
// 继续读下一个 header
if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) ||
hdr.version < FCGI_VERSION_1) {
return 0;
}
len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
padding = hdr.paddingLength;
while (hdr.type == FCGI_PARAMS && len > 0) {
// 读取到了 FCGI_PARAMS 的首 header(header 中 len 大于 0,表示 FCGI_PARAMS 数据包的开始)
if (len + padding > FCGI_MAX_LENGTH) {
return 0;
}
// 读取 FCGI_PARAMS 的 data
if (safe_read(req, buf, len+padding) != len+padding) {
req->keep = 0;
return 0;
}
// 解析 FCGI_PARAMS 的 data,将 key-value 对存储到 req.env 中
if (!fcgi_get_params(req, buf, buf+len)) {
req->keep = 0;
return 0;
}
// 继续读取下一个 header,下一个 header 有可能仍然是 FCGI_PARAMS 的首 header,也有可能是 FCGI_PARAMS 的尾 header
if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) ||
hdr.version < FCGI_VERSION_1) {
req->keep = 0;
return 0;
}
len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
padding = hdr.paddingLength;
}
}
…
return 1;
}
上面的代码可以和 FastCGI 协议对照着去看,这会加深我们对 FastCGI 协议的理解。
总的来讲,对于 FastCGI 协议,总是需要先读取 header,根据 header 中带的类型以及长度继续做不同的处理。
当读取到 FCGI_PARAMS 的 data 时,会调用 fcgi_get_params 函数对 data 进行解析,将 data 中的 http header 以及 fastcgi_params 存储到 req.env 结构体中。FCGI_PARAMS 的 data 格式是什么样的呢?它是由一个个的 key-value 对组成的字符串,对于 key-value 对,通过 keyLength+valueLength+key+value 的形式来描述,因此 FCGI_PARAMS 的 data 的格式一般是这样:
这里有一个细节需要注意,为了节省空间,在 Length 字段长度制定上,采取了长短 2 种表示法。如果 key 或者 value 的 Length 不超过 127,那么相应的 Length 字段用一个 char 来表示。最高位是 0,如果相应的 Length 字段大于 127,那么相应的 Length 字段用 4 个 char 来表示,第一个 char 的最高位是 1。大部分 http 中的 header 以及 fastcgi_params 变量的 key-value 的长度其实都是不超过 127 的。
举个栗子,在我的 vm 环境下,执行如下 curl 命令:curl -H “Content-Type: application/json” -d ‘{“a”:1}’ http://10.179.195.72:8585/test/jiweibin,下面是我 gdb 时 FCGI_PARAMS 的 data 的结果:
\017?SCRIPT_FILENAME/home/xiaoju/webroot/mis_deploy/mis/src/index.php/test/jiweibin\f\000QUERY_STRING\016\004REQUEST_METHODPOST\f\020CONTENT_TYPEapplication/json\016\001CO
NTENT_LENGTH7\v SCRIPT_NAME/mis/src/index.php/test/ji…
可以看到第一个 key-value 对是 ”017?SCRIPT_FILENAME/home/xiaoju/webroot/mis_deploy/mis/src/index.php/test/jiweibin”,keyLength 是 ’017’,它是 8 进制,转成十进制是 15,valueLength 是字符 ’?’,字符 ’?’ 对应的数值是 63,也就是 valueLength 是 63,因此按 keyLength 往后读取 15 个长度的字符,取到了 key 是:”SCRIPT_FILENAME”,继续前移读取 63 个字符,取到 value 是:”/home/xiaoju/webroot/mis_deploy/mis/src/index.php/test/jiweibin”。以此类推,我们解析出一个个 key-value 对,可以看到 CONTENT_TYPE=application/json 也在其中。
在 fcgi_get_params 里面解析了某一个 key-value 对之后,会调用 fcgi_hash_set 函数将 key-value 存储到 req.env 结构体中。req.env 结构体的类型是 fcgi_hash:
typedef struct _fcgi_hash {
fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE]; //hashtable,共 128 个 slot,每一个 slot 存储着指向 bucket 的指针
fcgi_hash_bucket *list; // 按插入顺序的逆序排列的 bucket 链表头指针
fcgi_hash_buckets *buckets; // 存储 bucket 的物理内存
fcgi_data_seg *data; // 存储字符串的堆内存首地址
} ;
这个 hashtable 的实现采用了普遍采用的链地址法思路,不过 bucket 的内存分配(malloc)并不是每次都需要进行的,而是在 hash 初始化的时候,一次性预分配一个大小为 128 的连续的数组。上面的 buckets 指针指向这段内存。同时 hashtable 还维护了一个按照元素插入顺序逆序排列的全局单链表,list 指向了这个链表的头元素。每一个 bucket 元素包括对 key 进行 hash 之后的 hash_value、key 的 length、key 的字符指针、value 的 length、value 的字符指针、相同 slot 中下个 bucket 元素指针,全局单链表的下一个 bucket 元素指针。bucket 中 key 和 value 并不直接存储字符数组(因为长度未知),而只是存储字符指针,真正的字符数组存储在 hashtable 的 data 指向的内存中。
下图展示了当我解析出 FCGI_ROLE(通过解析 FCGI_BEGIN_REQUEST)以及第一个 key-value 对(SCRIPT_FILENAME=”/home/xiaoju..”)时,内存的示意图:
body 信息读取
该阶段负责处理 FCGI_STDIN 数据包,这个数据包承载着原始 http post 请求的 body 数据。
也许你会想,为什么在头信息读取的时候,不同时将 body 数据读取出来呢?答案是为了适配多种 Content-Type 不同的行为。
感兴趣的同学可以做下实验,针对 Content-Type 为 multipart/form-data 类型的请求,从 $_POST 可以拿到 body 数据,但却不能通过 php://input 获取到原始的 body 数据流。而对于 Content-Type 为 x -www-form-urlencoded 的请求,这 2 者是都可以获取到的。
下表总结了 3 种不同的 Content-Type 的行为差异,本节我们说明 php://input 的行为差异原因之所在,而 $_POST 的差异则要在下一节进行讲解。
在 body 信息读取阶段,对不同的 Content-Type 差异化处理的关键节点发生在 sapi_read_post_data 函数,见下图,展示了差异化处理的整体流程:
下面我们基于上图,结合着代码进行详细分析。(代码可能会稍微多一点,这块代码比较核心,不是很好通过图的方式去画)
fpm 在接收到请求连接并且读取并解析完头信息之后,会调用 php_request_startup 执行请求初始化。它又调用 sapi_activate 函数,该函数会判断如果当前请求是 POST 请求,那么会调用 sapi_read_post_data 函数对 body 数据进行读取。
SAPI_API void sapi_activate(void)
{
…
/* Handle request method */
if (SG(server_context)) {
if (PG(enable_post_data_reading)
&& SG(request_info).content_type
&& SG(request_info).request_method
&& !strcmp(SG(request_info).request_method, “POST”)) {
/* HTTP POST may contain form data to be processed into variables
* depending on given content type */
sapi_read_post_data(); // 根据不同的 Content-Type 进行 post 数据的读取
} else {
SG(request_info).content_type_dup = NULL;
}
…
}
…
}
而在 sapi_read_post_data 中,会首先从 SG(known_post_content_types) 这个 hashtable 中查询是否有对应的钩子,如果有则调用,如果没有,则使用默认的处理方式。
static void sapi_read_post_data(void)
{
…
/* now try to find an appropriate POST content handler */
if ((post_entry = zend_hash_str_find_ptr(&SG(known_post_content_types), content_type,
content_type_length)) != NULL) {//content_type 已注册钩子
/* found one, register it for use */
SG(request_info).post_entry = post_entry; // 将钩子保存到 SG
post_reader_func = post_entry->post_reader; // 钩子中的 post_reader 函数指针赋值给 post_reader_func
} else {
/* fallback */
SG(request_info).post_entry = NULL;
if (!sapi_module.default_post_reader) {
/* no default reader ? */
SG(request_info).content_type_dup = NULL;
sapi_module.sapi_error(E_WARNING, “Unsupported content type: ‘%s'”, content_type);
return;
}
}
…
if(post_reader_func) {// 如果 post_reader_func 不为空,执行 post_reader_func
post_reader_func();
}
// 否则,执行默认的处理逻辑(之所以 post_reader_func 和 sapi_module.default_post_reader 互斥,关键的逻辑在 sapi_module.default_post_reader 里面实现)
if(sapi_module.default_post_reader) {
sapi_module.default_post_reader();
}
}
SG(known_post_content_types) 中为哪些 Content-Type 安装了钩子呢?答案是只有 2 种:application/x-www-form-urlencoded 和 multipart/form-data。在第二节曾经提到,在 SAPI 启动阶段,会执行一个神秘函数 php_setup_sapi_content_types,它会遍历 php_post_entries 数组,将上面 2 个 Content-Type 对应的钩子注册到 SG 的 known_post_content_types 这个 hashtable 中。
#define DEFAULT_POST_CONTENT_TYPE “application/x-www-form-urlencoded”
#define MULTIPART_CONTENT_TYPE “multipart/form-data”
int php_setup_sapi_content_types(void)
{
sapi_register_post_entries(php_post_entries); // 安装内置的 Content_Type 处理钩子
return SUCCESS;
}
static sapi_post_entry php_post_entries[] = {
{DEFAULT_POST_CONTENT_TYPE, sizeof(DEFAULT_POST_CONTENT_TYPE)-1, sapi_read_standard_form_data, php_std_post_handler },
{MULTIPART_CONTENT_TYPE, sizeof(MULTIPART_CONTENT_TYPE)-1, NULL, rfc1867_post_handler },
{NULL, 0, NULL, NULL}
};
struct _sapi_post_entry {
char *content_type;
uint content_type_len;
void (*post_reader)(void); //post 数据读取函数指针
void (*post_handler)(char *content_type_dup, void *arg); //post 数据后置处理函数指针,见下一小节
};
typedef struct _sapi_post_entry sapi_post_entry;
钩子包含了 2 个函数指针,post_reader 在本阶段会被调用,而 post_handler 会在数据后置处理阶段被调用。从上面代码可以看到,php 为 application/x-www-form-urlencoded 安装的钩子的 post_reader 函数指针指向 sapi_read_standard_form_data,而 multipart/form-data 虽然钩子已安装,但是 post_reader 函数指针为 NULL,所以在本阶段不进行任何处理。
让我们继续跟踪 sapi_read_standard_form_data 都干了些什么,它的整体流程可以参考下图:
首先,它会创建一个 phpstream,并将 SG(request_info).request_body 指向这个 phpstream(phpstream 是 php 对 io 的封装,比较复杂,这里不展开)。然后调用 sapi_read_post_block 函数读取 htt ppost 请求的 body 数据,内部它会调用 sapi_cgi_read_post 函数,这个函数会判断头信息里是否存在 REQUEST_BODY_FILE 字段(REQUEST_BODY_FILE 用来在 nginx 和 fpm 传递 size 特别大的 body 时或者传递上传的文件时只传递文件名,这里不展开),如果有则直接读 REQUEST_BODY_FILE 对应的文件,如果没有则调用 fcgi_read 函数解析 FCGI_STDIN 数据包。最后将读取的数据写入到之前创建的 phpstream 中。
php://input 其实就是基于这个 stream 做的读取包装。对于 multipart/form-data,由于安装的钩子中 post_reader 是 NULL,在本阶段并未做任何事儿,因此无法通过 php://input 获取到原始的 post body 数据流。
下面对照着上面的流程,跟踪下代码:
SAPI_API SAPI_POST_READER_FUNC(sapi_read_standard_form_data)
{
// 创建 phpstream
SG(request_info).request_body = php_stream_temp_create_ex(TEMP_STREAM_DEFAULT, SAPI_POST_BLOCK_SIZE, PG(upload_tmp_dir));
if (sapi_module.read_post) {
size_t read_bytes;
for (;;) {
char buffer[SAPI_POST_BLOCK_SIZE];
// 调用 sapi_module.read_post 读取 FCGI_STDIN 数据包
read_bytes = sapi_read_post_block(buffer, SAPI_POST_BLOCK_SIZE);
if (read_bytes > 0) {
// 将 body 数据写到 SG(request_info).request_body 这个 phpstream
if (php_stream_write(SG(request_info).request_body, buffer, read_bytes) != read_bytes) {
…
}
}
…
if (read_bytes < SAPI_POST_BLOCK_SIZE) {
/* done */
break;
}
}
php_stream_rewind(SG(request_info).request_body);
}
}
sapi_read_post_block 内部会调用 sapi_module.read_post 函数指针,而对于 php-fpm 而言,sapi_module.read_post 指向 sapi_cgi_read_post 函数,该函数内部会调用 fcgi_read 读取 FCGI_STDIN 数据流。
static sapi_module_struct cgi_sapi_module = {
“fpm-fcgi”, /* name */
…
sapi_cgi_read_post, /* read POST data */
sapi_cgi_read_cookies, /* read Cookies */
…
STANDARD_SAPI_MODULE_PROPERTIES
};
static size_t sapi_cgi_read_post(char *buffer, size_t count_bytes)
{
…
while (read_bytes < count_bytes) {
…
if (request_body_fd == -1) {
// 检查是否有 REQUEST_BODY_FILE 头
char *request_body_filename = FCGI_GETENV(request, “REQUEST_BODY_FILE”);
if (request_body_filename && *request_body_filename) {
request_body_fd = open(request_body_filename, O_RDONLY);
…
}
}
/* If REQUEST_BODY_FILE variable not available – read post body from fastcgi stream */
if (request_body_fd < 0) {
// 如果没有 REQUEST_BODY_FILE 头,继续按照 FastCGI 协议读取 FCGI_STDIN 数据包
tmp_read_bytes = fcgi_read(request, buffer + read_bytes, count_bytes – read_bytes);
} else {
// 如果有 REQUEST_BODY_FILE 头,从文件读取 body 数据
tmp_read_bytes = read(request_body_fd, buffer + read_bytes, count_bytes – read_bytes);
}
…
read_bytes += tmp_read_bytes;
}
return read_bytes;
}
int fcgi_read(fcgi_request *req, char *str, int len)
{
int ret, n, rest;
fcgi_header hdr;
unsigned char buf[255];
n = 0;
rest = len;
while (rest > 0) {
if (req->in_len == 0) {// 第一次循环,读取 header
if (safe_read(req, &hdr, sizeof(fcgi_header)) != sizeof(fcgi_header) ||
hdr.version < FCGI_VERSION_1 ||
hdr.type != FCGI_STDIN) {// 如果 header 不是 STDIN,异常退出
req->keep = 0;
return 0;
}
req->in_len = (hdr.contentLengthB1 << 8) | hdr.contentLengthB0;
req->in_pad = hdr.paddingLength;
if (req->in_len == 0) {
return n;
}
}
// 读取 FCGI_STDIN 的 data
if (req->in_len >= rest) {
ret = (int)safe_read(req, str, rest);
} else {
ret = (int)safe_read(req, str, req->in_len);
}
…
}
return n;
}
至此,我们跟踪完成了 application/x-www-form-urlencoded 的整个 body 读取过程。
再回过头来看下 application/json,由于并没有为它安装钩子,在 sapi_read_post_data 时,使用默认的处理方式。这里的默认行为会执行 sapi_module.default_post_reader 函数指针指向的函数。而这个函数指针指向哪个函数呢?
在第二节讲到的 php_module_startup 函数中有一个 php_startup_sapi_content_types 函数,它会指定 sapi_module.default_post_reader 是 php_default_post_reader。
int php_startup_sapi_content_types(void)
{
sapi_register_default_post_reader(php_default_post_reader); // 设置 default_post_reader
sapi_register_treat_data(php_default_treat_data);
sapi_register_input_filter(php_default_input_filter, NULL);
return SUCCESS;
}
SAPI_API SAPI_POST_READER_FUNC(php_default_post_reader)
{
if (!strcmp(SG(request_info).request_method, “POST”)) {// 如果是 POST 请求
if (NULL == SG(request_info).post_entry) {// 如果 Content-Type 没有对应的钩子
/* no post handler registered, so we just swallow the data */
sapi_read_standard_form_data(); // 和 application/x-www-form-urlencoded 一样的处理逻辑
}
}
}
在 php_default_post_reader 中,我们看到,其实它执行的仍然是 sapi_read_standard_form_data 函数,也就是在 body 信息读取阶段,尽管 application/json 没有注册钩子,但是它和 application/x-www-form-urlencoded 仍然保持这一致的处理逻辑。这也解释了,为什么 application/json 可以通过 php://input 拿到原始 post 数据。
到现在,php://input 的行为差异已经是可以解释的清了,而 $_POST 我们需要继续跟踪下去。
数据后置处理
数据后置处理阶段是用来对原始的 body 数据做后置处理的,$_POST 就是在这个阶段产生。下图展示了在数据后置处理阶段,php 执行的函数流程。
第二节讲到,在 php_module_startup 函数中,会调用 php_startup_auto_globals 向 CG(auto_globals) 这个 hashtable 注册超全局变量_GET、_POST、_COOKIE、_SERVER 的钩子,然后在合适的时机回调。
void php_startup_auto_globals(void)
{
zend_register_auto_global(zend_string_init(“_GET”, sizeof(“_GET”)-1, 1), 0, php_auto_globals_create_get);
zend_register_auto_global(zend_string_init(“_POST”, sizeof(“_POST”)-1, 1), 0, php_auto_globals_create_post);
zend_register_auto_global(zend_string_init(“_COOKIE”, sizeof(“_COOKIE”)-1, 1), 0, php_auto_globals_create_cookie);
zend_register_auto_global(zend_string_init(“_SERVER”, sizeof(“_SERVER”)-1, 1), PG(auto_globals_jit), php_auto_globals_create_server);
zend_register_auto_global(zend_string_init(“_ENV”, sizeof(“_ENV”)-1, 1), PG(auto_globals_jit), php_auto_globals_create_env);
zend_register_auto_global(zend_string_init(“_REQUEST”, sizeof(“_REQUEST”)-1, 1), PG(auto_globals_jit), php_auto_globals_create_request);
zend_register_auto_global(zend_string_init(“_FILES”, sizeof(“_FILES”)-1, 1), 0, php_auto_globals_create_files);
}
而这个合适的时机就是 php_request_startup 中在 sapi_activate 之后执行的 php_hash_environment 函数。该函数内部会调用 zend_activate_auto_globals 函数,这个函数遍历所有注册的 auto global,回调相应的钩子。而 $_POST 对应的钩子是 php_auto_globals_create_post。
PHPAPI int php_hash_environment(void)
{
memset(PG(http_globals), 0, sizeof(PG(http_globals)));
zend_activate_auto_globals(); // 激活超全局变量,回调 startup 时注册的钩子
if (PG(register_argc_argv)) {
php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
}
return SUCCESS;
}
ZEND_API void zend_activate_auto_globals(void) /* {{{*/
{
zend_auto_global *auto_global;
ZEND_HASH_FOREACH_PTR(CG(auto_globals), auto_global) {// 遍历所有的超全局变量
if (auto_global->jit) {
auto_global->armed = 1;
} else if (auto_global->auto_global_callback) {
auto_global->armed = auto_global->auto_global_callback(auto_global->name); // 回调钩子函数
} else {
auto_global->armed = 0;
}
} ZEND_HASH_FOREACH_END();
}
php_auto_globals_create_post 做了什么操作呢?下图展示了它的整体流程。
在 PG 里有一个 http_globals 字段,它是包含 6 个 zval 的数组。这 6 个 zval 分别用来临时存储 _POST、_GET、_COOKIE、_SERVER、_ENV 和_FILES 数据。
struct _php_core_globals {
…
zval http_globals[6]; //0-$_POST 1-$_GET 2-$_COOKIE 3-$_SERVER 4-$_ENV 5-$_FILES
…
};
对于一个简单的 post 请求:curl -d “a=1” http://10.179.195.72:8585/test/jiweibin,Content-Type 是 application/x-www-form-urlencoded,php_auto_globals_create_post 所做的操作可以分这么几步:
读取上一阶段写入到 SG(request_info).request_body 这个 phpstream 中的 body 数据到内存 buf。这里 body 数据是 ”a=1″ 这个字符串。
解析 post body 数据(按 & 分割 key-value 对,按 = 分割 key 和 value),并将解析后的数据通过调用 add_post_vars 函数,写入到 PG(http_globals)[0] 这个 zval 中,zval 的类型是数组类型。
最后,为了让 Zend 引擎可以通过_POST 这个字符串索引到上一步解析的 zval,我们需要以 ”_POST” 为 key,刚刚 zval 为 value 注册到 php Zend 引擎的全局变量符号表 EG(symbol_table) 中。
在 php_auto_globals_create_post 函数中,当发现当前的请求是 POST 请求时,会调 sapi_module.treat_data 函数指针。在 php_module_startup 阶段,php 会设置 sapi_module.treat_data 函数指针指向 php_default_treat_data 函数。该函数会最终完成 body 数据解析并存储到 PG(http_globals)[0] 这个 zval 中。在调用完 php_default_treat_data 之后,会将 ”_POST” 和 PG(http_globals)[0] 注册到符号表 EG(symbol_table)。代码如下:
static zend_bool php_auto_globals_create_post(zend_string *name)
{
if (PG(variables_order) &&
(strchr(PG(variables_order),’P’) || strchr(PG(variables_order),’p’)) &&
!SG(headers_sent) &&
SG(request_info).request_method &&
!strcasecmp(SG(request_info).request_method, “POST”)) {
sapi_module.treat_data(PARSE_POST, NULL, NULL); // 从 stream 中读取并解析 body 数据,存储到 PG(http_globals)[0]
} else {
zval_ptr_dtor(&PG(http_globals)[TRACK_VARS_POST]);
array_init(&PG(http_globals)[TRACK_VARS_POST]);
}
zend_hash_update(&EG(symbol_table), name, &PG(http_globals)[TRACK_VARS_POST]); // 将 ’_POST’ 和 PG(http_globals)[0] 注册到 EG(symbol_table)
Z_ADDREF(PG(http_globals)[TRACK_VARS_POST]);
return 0; /* don’t rearm */
}
在 php_default_treat_data 中,对于 POST 请求,会重新初始化 PG(http_globals)[0](TRACK_VARS_POST 是一个宏,在编译阶段会被替换为 0),然后调用 sapi_handle_post 函数,该函数会回调在 SAPI 启动阶段为 Content-Type 安装的钩子中的 post_handler 函数指针。
SAPI_API SAPI_TREAT_DATA_FUNC(php_default_treat_data)
{
…
zval array;
…
ZVAL_UNDEF(&array);
switch (arg) {
case PARSE_POST:
case PARSE_GET:
case PARSE_COOKIE:
array_init(&array);
switch (arg) {
case PARSE_POST:
zval_ptr_dtor(&PG(http_globals)[TRACK_VARS_POST]); // 析构 zval,释放上一次请求的旧数组内存
ZVAL_COPY_VALUE(&PG(http_globals)[TRACK_VARS_POST], &array); // 重新初始化 zval, 指向新的空数组内存
break;
…
}
break;
default:
ZVAL_COPY_VALUE(&array, destArray);
break;
}
if (arg == PARSE_POST) {
sapi_handle_post(&array); // 回调 Content-Type 钩子
return;
}
…
}
SAPI_API void sapi_handle_post(void *arg)
{
// 如果 Content-Type 已经安装钩子
if (SG(request_info).post_entry && SG(request_info).content_type_dup) {
SG(request_info).post_entry->post_handler(SG(request_info).content_type_dup, arg); // 调用相应钩子的 post_handler 函数指针
efree(SG(request_info).content_type_dup);
SG(request_info).content_type_dup = NULL;
}
对于 application/x-www-form-urlencoded,post_handler 是 php_std_post_handler。
SAPI_API SAPI_POST_HANDLER_FUNC(php_std_post_handler)
{
zval *arr = (zval *) arg; //arg 指向 PG(http_globals)[0]
php_stream *s = SG(request_info).request_body;
post_var_data_t post_data;
if (s && SUCCESS == php_stream_rewind(s)) {
memset(&post_data, 0, sizeof(post_data));
while (!php_stream_eof(s)) {
char buf[SAPI_POST_HANDLER_BUFSIZ] = {0};
// 读取上一阶段被写入的 phpstream
size_t len = php_stream_read(s, buf, SAPI_POST_HANDLER_BUFSIZ);
if (len && len != (size_t) -1) {
smart_str_appendl(&post_data.str, buf, len);
// 解析并插入到 arr 中,arr 指向 PG(http_globals)[0]
if (SUCCESS != add_post_vars(arr, &post_data, 0)) {
smart_str_free(&post_data.str);
return;
}
}
if (len != SAPI_POST_HANDLER_BUFSIZ){// 读到最后了
break;
}
}
if (post_data.str.s) {
// 解析并插入到 arr 中,arr 指向 PG(http_globals)[0]
add_post_vars(arr, &post_data, 1);
smart_str_free(&post_data.str);
}
}
}
对于 multipart/form-data,post_handler 是 rfc1867_post_handler。由于它的代码过长,这里不再贴代码了。由于在 body 信息读取阶段,钩子的 post_reader 是空,所以 rfc1867_post_handler 会一边做 FCGI_STDIN 数据包的读取,一边做解析存储工作,最终将数据包中的 key-value 对存储到 PG(http_globals)[0] 中。另外,该函数还会对上传的文件进行处理,有兴趣的同学可以读下这个函数。
对于 application/json,由于未安装任何钩子,所以在这里不会做任何事情,PG(http_globals)[0] 是空数组。因此如果 Content-Type 是 application/json,是无法获取到 $_POST 变量的。
php_auto_globals_create_post 执行的最后,需要进行全局变量符号表的注册操作,这是为什么呢?其实这和 Zend 引擎的代码执行有关系了。Zend 引擎的编译器碰到 $_POST 时,opcode 会是 ZEND_FETCH_R 或者 ZEND_FETCH_W(其中操作数是 ’_POST’,fetch_type 是 global),在执行阶段执行器会去 EG(symbol_table) 中根据 key=’_POST’ 去找到对应的 zval。因此这里的注册操作是有必要的。
让我们用一个例子来验证下 opcode,写一个简单的 php 脚本 test.php:
<?php
var_dump($_POST);
安装 vld 扩展之后,执行 php -dvld.active=1 test.php,可以看到 opcode 是 FETCH_R,正如我们预期。它会先从全局符号表中查找 ’_POST’ 对应的 zval,然后赋值给 $0(主函数栈的第一个变量,该变量是阴式声明)。
四、postjson 扩展
到这里,我们已经对 $_POST 的整体流程以及细节有所了解。让我们做点什么吧,写一个扩展,来让 application/json 的请求也可以享受到 $_POST 这个超全局变量带来的便利。(这个扩展的生产环境的意义不大,完全可以在 php 层通过 php://input 拿到请求 body,更多的是学以致用的学习意义)
如何来实现我们的扩展呢?上面我们知道,之所以拿不到是因为没有为 application/json 安装钩子,导致在数据后置处理阶段并没有做 post body 的解析,所以这里我们需要安装一个钩子,钩子的 post_reader 可以是 NULL(这样会走默认逻辑),也可以和 application/x-www-form-urlencoded 保持一致:sapi_read_standard_form_data。而 post_handler 则需要我们编写了,post_handler 我们取名:php_json_post_handler。
下图展示了 postjson 扩展整体的执行流程:
它在模块初始化时,zend_startup_modules 执行之后,会调用该扩展的 MINIT 函数,MINIT 函数里面会进行 ini entry 注册,并获取到关心的 ini 配置的值(这里我们会注册一个开关配置 postjson.parse 表示是否开启扩展 ),如果扩展开启,我们会向 SG(known_post_content_types) 注册 application/json 的钩子。
然后在请求初始化时,FastCGI 协议处理的数据后置处理阶段,回调我们的钩子函数 php_json_post_handler,完成 json 格式的 post body 的解析以及将解析后的 key-value 存储到 PG(http_globals)[0] 的操作。
后续 php 的框架代码 php_auto_globals_create_post 会完成后续的符号表注册操作。
关于 php_json_post_handler,对 json 的解析是一个复杂的过程,我们可以使用现有的轮子,看下 php 的 json 扩展是如何实现的:
static PHP_FUNCTION(json_decode)
{
char *str;
size_t str_len;
zend_bool assoc = 0; /* return JS objects as PHP objects by default */
zend_long depth = PHP_JSON_PARSER_DEFAULT_DEPTH;
zend_long options = 0;
if (zend_parse_parameters(ZEND_NUM_ARGS(), “s|bll”, &str, &str_len, &assoc, &depth, &options) == FAILURE) {
return;
}
JSON_G(error_code) = 0;
if (!str_len) {
JSON_G(error_code) = PHP_JSON_ERROR_SYNTAX;
RETURN_NULL();
}
/* For BC reasons, the bool $assoc overrides the long $options bit for PHP_JSON_OBJECT_AS_ARRAY */
if (assoc) {
options |= PHP_JSON_OBJECT_AS_ARRAY;
} else {
options &= ~PHP_JSON_OBJECT_AS_ARRAY;
}
php_json_decode_ex(return_value, str, str_len, options, depth); // 解析 str, 存储到 return_value 这个 zval 中
}
我们可以使用 php_json_decode_ex(它内部使用 yacc 完成语法解析)这个函数来做 json 解析,将 return_value 替换为 &PG(http_globals)[0]。而 str 则从 SG(request_info).request_body 这个 phpstream 中去读取。所以,整体的思路已经通了,下面我们来操作一下。
生成扩展骨架
进入到源码目前的 ext 目录:cd /home/xiaoju/offcial_code/php/7.0.6/php-7.0.6/ext,执行 ./ext_skel –extname=postjson,这时在代码目录下可以看到 postjson.c 和 php_postjson.h 等文件。
编辑 php_postjson.h 文件
我们的扩展可以在 php.ini 中开关,开的方式是 postjson.parse=On, 关的方式是 postjson.parse=Off,所以这里我们需要定义一个存储这个开关的结构体,parse 字段表示这个开关。定义了 2 个常量:JSON_CONTENT_TYPE 和 CHUNK_SIZE,分别用来表示 application/json 的 Content-Type 和读取 phpstream 时的 buffer 大小。
#ifndef PHP_POSTJSON_H
#define PHP_POSTJSON_H
#include “SAPI.h”
#include “ext/json/php_json.h”
#include “php_globals.h”
extern zend_module_entry postjson_module_entry;
#define phpext_postjson_ptr &postjson_module_entry
#define PHP_POSTJSON_VERSION “0.1.0” /* Replace with version number for your extension */
#ifdef PHP_WIN32
# define PHP_POSTJSON_API __declspec(dllexport)
#elif defined(__GNUC__) && __GNUC__ >= 4
# define PHP_POSTJSON_API __attribute__ ((visibility(“default”)))
#else
# define PHP_POSTJSON_API
#endif
#ifdef ZTS
#include “TSRM.h”
#endif
ZEND_BEGIN_MODULE_GLOBALS(postjson)
zend_long parse; // 存储配置的结构体
ZEND_END_MODULE_GLOBALS(postjson)
SAPI_POST_HANDLER_FUNC(php_json_post_handler);
#define JSON_CONTENT_TYPE “application/json”
#define CHUNK_SIZE 8192
/* Always refer to the globals in your function as POSTJSON_G(variable).
You are encouraged to rename these macros something shorter, see
examples in any other php module directory.
*/
#define POSTJSON_G(v) ZEND_MODULE_GLOBALS_ACCESSOR(postjson, v)
#if defined(ZTS) && defined(COMPILE_DL_POSTJSON)
ZEND_TSRMLS_CACHE_EXTERN()
#endif
#endif /* PHP_POSTJSON_H */
编辑 postjson.c 文件
这里定义 ini 配置,钩子数组 post_entries,实现 php_json_post_handler,并改写 MINIT 函数,判断 ini 中开关 postjson.parse 是否开启,如果开启,则注册钩子。
在 php_json_post_handler 中分配一个 8k 的 zend_string,读取 SG(request_info).request_body 这个 phpstream 到一个 8k 的 buffer,如果一次读取不完,分多次读取,zend_string 不断扩容,最终包含整个 json 字符串。最后调用 php_json_decode_ex 函数完成 json 串解析并存储到 PG(http_globlas)[0] 中。
#ifdef HAVE_CONFIG_H
#include “config.h”
#endif
#include “php.h”
#include “php_ini.h”
#include “ext/standard/info.h”
#include “php_postjson.h”
ZEND_DECLARE_MODULE_GLOBALS(postjson)
/* True global resources – no need for thread safety here */
static int le_postjson;
//postjson 扩展使用到的 ini
PHP_INI_BEGIN()
STD_PHP_INI_BOOLEAN(“postjson.parse”, “0”, PHP_INI_ALL, OnUpdateLong, parse, zend_postjson_globals, postjson_globals)
PHP_INI_END()
static sapi_post_entry post_entries[] = { // 定义 Content-Type 钩子
{JSON_CONTENT_TYPE, sizeof(JSON_CONTENT_TYPE)-1, sapi_read_standard_form_data, php_json_post_handler },
{NULL, 0, NULL, NULL}
};
SAPI_POST_HANDLER_FUNC(php_json_post_handler){//post handler
size_t ret = 0;
char *ptr;
size_t len = 0, max_len;
int step = CHUNK_SIZE;
int min_room = CHUNK_SIZE / 4;
int persistent = 0;
zend_string *result;
php_stream *s = SG(request_info).request_body;
if (s && SUCCESS == php_stream_rewind(s)) {
max_len = step;
result = zend_string_alloc(max_len, persistent);
ptr = ZSTR_VAL(result);
while ((ret = php_stream_read(s, ptr, max_len – len))) {// 读取 SG(request_info).request_body 这个 phpstream
len += ret;
if (len + min_room >= max_len) {
result = zend_string_extend(result, max_len + step, persistent);
max_len += step;
ptr = ZSTR_VAL(result) + len;
} else {
ptr += ret;
}
}
if (len) {
result = zend_string_truncate(result, len, persistent);
ZSTR_VAL(result)[len] = ‘\0’;
// 解析 json,并存储到 PG(http_globals)[0]
php_json_decode_ex(&PG(http_globals)[TRACK_VARS_POST], ZSTR_VAL(result), ZSTR_LEN(result), PHP_JSON_OBJECT_AS_ARRAY, PHP_JSON_PARSER_DEFAULT_DEPTH);
} else {
zend_string_free(result);
result = NULL;
}
}
}
static void php_postjson_init_globals(zend_postjson_globals *postjson_globals)
{
postjson_globals->parse = 0;
}
PHP_MINIT_FUNCTION(postjson)
{
ZEND_INIT_MODULE_GLOBALS(postjson, php_postjson_init_globals, NULL);
REGISTER_INI_ENTRIES();
int parse = (int)POSTJSON_G(parse);
if(parse == 1){// 如果 ini 中 postjson.parse 开启,那么将 application/json 的钩子注册到 SG(known_post_content_types) 中
sapi_register_post_entries(post_entries);
}
return SUCCESS;
}
const zend_function_entry postjson_functions[] = { // 这里我们不注册任何 php 函数
PHP_FE_END /* Must be the last line in postjson_functions[] */
};
static zend_module_dep module_deps[] = { // 本扩展依赖 php 的 json 扩展
ZEND_MOD_REQUIRED(“json”)
ZEND_MOD_END
};
zend_module_entry postjson_module_entry = {
STANDARD_MODULE_HEADER_EX,NULL,
module_deps,
“postjson”,
postjson_functions,
PHP_MINIT(postjson),
PHP_MSHUTDOWN(postjson),
PHP_RINIT(postjson), /* Replace with NULL if there’s nothing to do at request start */
PHP_RSHUTDOWN(postjson), /* Replace with NULL if there’s nothing to do at request end */
PHP_MINFO(postjson),
PHP_POSTJSON_VERSION,
STANDARD_MODULE_PROPERTIES
};
…
编译安装
phpize
configure –with-php-config=../php-config
make
make install
配置 php.ini
增加 post 配置:
[postjson]
extension=”postjson.so”
postjson.parse=On
验证是否安装成功:php -m|grep postjson
测试
重启 php-fpm,kill -USR2 cat /home/xiaoju/php7/var/run/php-fpm.pid
编写测试脚本:
<?php
namespace xxx\Test;
class Jiweibin{
function index() {
var_dump($_POST);
var_dump(file_get_contents(“php://input”));
}
}
执行 curl 命令,curl -H “Content-Type: application/json” -d ‘{“a”:1}’ http://10.179.195.72:8585/test/jiweibin,执行结果如下,我们看到通过 $_POST 可以拿到解析后的 post 数据了,搞定。
五、总结
本篇 wiki,从源码角度分析了 php 中_POST 的原理,展现了 FastCGI 协议的整体处理流程,以及针对不同 Content-Type 的处理差异化,并为 application/json 动手编写了 php 扩展,实现了_POST 的解析,希望大家有所收获。但本篇 wiki 并不是终点,通过编写这篇 wiki,对 json 解析(yacc)、Zend 引擎原理有了比较浓厚的兴趣和探知欲,有时间的话,希望能分享给大家,另外感谢我的同事朱栋同学,一起跟代码的感觉还是很赞的。