摘要:和进程的启动过程类似,启动过程有种进程角色启动进程进程和进程。直到请求到来,将连接赋值给对象的字段。注当进程执行完后会再次调用函数,准备监听新的请求。当读取到的时,会调用函数对进行解析,将中的以及存储到结构体中。
运营研发团队 季伟滨
一、前言前几天的工作中,需要通过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拿到请求参数。
在分析之前,有必要对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的结果: