[HTTP] PHP 实现 HTTP Server 原理
单进程服务器简陋版:
<?php /** * Single http server. * * Access http://127.0.0.1:8081 * * @license Apache-2.0 * @author farwish <www.farwish.com> */ $s_socket_uri = 'tcp://0.0.0.0:8081'; $s_socket = stream_socket_server($s_socket_uri, $errno, $errstr) OR trigger_error("Failed to create socket: $s_socket_uri, Err($errno) $errstr", E_USER_ERROR); while(1) { while($connection = @stream_socket_accept($s_socket, 60, $peer)) { echo "Connected with $peer. Request info...\n"; $client_request = ""; // Read until double \r while( !preg_match('/\r?\n\r?\n/', $client_request) ) { $client_request .= fread($connection, 1024); } if (!$client_request) { trigger_error("Client request is empty!"); } $headers = "HTTP/1.1 200 OK\r\n" ."Server: nginx\r\n" ."Content-Type: text/html; charset=utf-8\r\n" ."\r\n"; $body = "<h1>hello world</h1><br><br>"; if ((int) fwrite($connection, $headers . $body) < 1) { trigger_error("Write to socket failed!"); } fclose($connection); } }
HTTP 底层基于 TCP,所以 socket 地址指定为 tcp 协议没有任何问题;stream_socket_server 功能相当于执行了 socket => bind => listen,stream_socket_accept 阻塞等待 client 连接,并设置了超时时间,默认的 timeout 时间使用在 php.ini 中设置。
注意这里的错误抑制符@,抑制 accept 超时情况产生的 PHP Warning,如果用到 stream_select 也需要加错误抑制符 @ 来避免超时产生的 PHP Warning。
HTTP 响应报文格式包含 响应状态码、响应首部、响应主体(如果有的话),响应首部每行以 \r\n 结尾,响应头部结束单独一行 \r\n 结尾,后面就是响应主体了,响应头部加响应主体以 fwrite 写入 socket 连接,fclose 关闭连接。
注意:上面的简陋版既没有设置 socket 上下文选项,也没有使用 I/O 复用,更不是多进程的,只是作为请求响应的演示。
较为严谨的HTTP协议处理版:
$method = ''; $url = ''; $protocol_version = ''; $request_header = []; $content_type = 'text/html; charset=utf-8'; $content_length = 0; $request_body = ''; $end_of_header = false; // @see http://php.net/manual/en/function.fread.php $buffer = fread($connection, 8192); if (false !== $buffer) { // Http request format check. if (false !== strstr($buffer, "\r\n")) { $list = explode("\r\n", $buffer); } if ($list) { foreach ($list as $line) { if ($end_of_header) { if (strlen($line) === $content_length) { $request_body = $line; } else { throw new \Exception("Content-Length {$content_length} not match request body length " . strlen($line) . "\n"); } break; } if ( empty($line) ) { $end_of_header = true; } else { // Header. // if (false === strstr($line, ': ')) { $array = explode(' ', $line); // Request line. if (count($array) === 3) { $method = $array[0]; $url = $array[1]; $protocol_version = $array[2]; } } else { $array = explode(': ', $line); // Request header. list ($key, $value) = $array; $request_header[$key] = $value; if ( strtolower($key) === strtolower('Content-type') ) { $content_type = $value; } // Have request body. if ($key === 'Content-Length') { $content_length = $value; } } } } } } // No request body, show buffer from read. $response_body = $request_body ?: $buffer; $response_header = "HTTP/1.1 200 OK\r\n"; $response_header .= "Content-type: {$content_type}\r\n"; if (empty($content_length) && (strlen($response_body) > 0)) { $response_header .= "Content-Length: " . strlen($response_body) . "\r\n"; } foreach ($request_header as $k => $v) { $response_header .= "{$k}: {$v}\r\n"; } $response_header .= "\r\n"; fwrite($connection, $response_header . $response_body); fclose($connection);
以上程序属于 accept 之后的处理步骤,外层逻辑这里已省略,也适用在多进程服务器中子进程的处理部分。
这里还是用 fread 统一读取数据,设置读取的长度 8192(fread 的默认也是8192),$buffer 含有头部信息和数据,按 \r\n 分解成数组元素再处理,处理方式按照 HTTP 请求报文格式。
首先是请求行,如 GET /index HTTP/1.1 三部分以 “空格” 隔开,行尾以 \r\n 结束。
其次是报文首部,如 Content-Type: text/html 键值中间以 “冒号” 加 “空格” 隔开,行尾以 \r\n 结束。
请求报文头部以一行 \r\n 结束。
最后是请求主体(如果有的话),如果报文首部中有 Content-Length 值,就说明有请求主体。
响应数据按照 响应头部 加 响应主体,写入 socket 连接,最后关闭连接。
小结:
无重逻辑(字符输出)的场景中,ab 100并发1000次访问压测,对比传统调优后的 Nginx + PHP,PHP 实现的多进程非阻塞 I/O HTTP Server 的 QPS 性能稍高,有一个理由可以解释:它不需要 Nginx 来转发请求。
PHP实现的 HTTP Server 有很多细节需要自身处理,不同因素会对处理请求的性能产生直接影响以及面临某些场景下才能产生的BUG,所以稳定性上需要经受更多考验。