运营研发团队 季伟滨
一、前言
前几天的工作中,需要通过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