php做一个webserver
1. 目标
利用php实现一个不依靠nginx/apache的简易webserver,同时支持Router路由功能,实现如在命令行键入php server 8080
启动的功能
2. 流程
做一个webserver需要做的模块:
- 监听连接进来
- 客户端连接服务端
- 服务端接连接
- 服务端做出响应
3. 接收命令行参数
项目根目录创建一个server.php
文件,用于接收命令行参数,并进行项目初始化工作
先是获取命令行参数,有一个提前定义好的变量名argv
,我们直接打印测试一下
<?php
var_dump($argv);
运行php server a b c d
数据结果为:
array(5) {
[0]=>
string(10) "server.php"
[1]=>
string(1) "a"
[2]=>
string(1) "b"
[3]=>
string(1) "c"
[4]=>
string(1) "d"
}
我们发现第一个选项是文件名,这个并不是我们所需要的,所以使用array_shift
去除他,这样我们就能获取他的参数了,下面我们实现通过php server port
这条命令
<?php
array_shift($argv);
if (empty($argv)){
$port = 80;
var_dump("port is 80");
}else{
$port = $argv[0];
var_dump("port is ".$port);
}
5 实现自动加载
利用composer生成自动加载的文件:
生成composer.json
文件
composer init
下面是我的composer.json:
{
"name": "lx/webserver",
"description": "a toy webserver",
"type": "lib",
"license": "mit",
"minimum-stability": "dev",
"require": {},
"autoload": {
"psr-4": {
"webserver\\":[
"vendor/webserver/src/"
]
}
}
}
生成自动引入程序:
copmoser install
创建webserver目录和src目录:
结构如下:
├── composer.json
├── composer.lock
├── server.php
└── vendor
├── autoload.php
├── composer
│ ├── autoload_classmap.php
│ ├── autoload_namespaces.php
│ ├── autoload_psr4.php
│ ├── autoload_real.php
│ ├── autoload_static.php
│ ├── ClassLoader.php
│ ├── installed.json
│ ├── installed.php
│ ├── InstalledVersions.php
│ └── LICENSE
└── webserver
└── src
接下来进行验证是否可以 使用:
在src下新建Hello.php,内容如下:
<?php
namespace webserver;
class Hello{
public function say(){
return "hello.world";
}
}
根目录的server.php:
<?php
require __DIR__."/vendor/autoload.php";
array_shift($argv);
if (empty($argv)){
$port = 80;
}else{
$port = $argv[0];
}
$hello = new \webserver\Hello();
$a = $hello->say();
var_dump($a); //hello.world
如果正确打印,且不报错,说明到这里所有的步骤都是争取的.
下面开始我们的核心部分
6 .server服务
启用一个webserver需要一个服务去不停的去监听该端口是不是又请求进来,在src目录下,新建server.php
文件
创建监听,又可以查分成三部:
- 创建一个socket
- 绑定port到socket
- 通过一个while 循环去监听请求
第一步: 创建socket:
这里我们使用函数socket_create()
函数
该函数用法: socket_create ( int
$domain, int
$type, int
$protocol)`
需要注意的,这几个参数,的值都是int,所以需要查找手册,看一下他预定义的常量表示的含义
下面是创建socket
的代码:
<?php
namespace webserver;
class Server{
protected $host;
protected $port;
protected $socket;
protected function createSocket(){
$this->socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
}
}
第二步:绑定端口到socket
这里使用的函数socket_bind
用法:socket_bind ( Socket $socket, string $address , int $port):bool
手册地址https://www.php.net/manual/en/function.socket-bind.php
第一个参数是,上面我们定义好的socket的实例,
<?php
namespace webserver;
class Server{
protected $host;
protected $port;
protected $socket;
public function __construct($host,$port){
$this->createSocket();
$this->bind($host,$port);
}
protected function createSocket(){
$this->socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
}
protected function bind($host,$port){
$res = socket_bind($this->socket,$host,$port);
if (!$res){
throw new Exception("socket 绑定失败:".socket_strerror(socket_last_error()));
}
}
}
绑定socket到端口,然后将这两部都放进初始化函数中
第三步: 监听端口
前面我们第一步创建socket的一端,然后绑定到指定端口,接下来我们要告诉socket,开始监听了,使用socket_listen函数
用法:socket_listen ( Socket $socket , int $backlog = 0 ) : bool
第二个参数有默认值0, 返回值是布尔
开始监听以后使用socket_accept
用来接收请求参数,socket_accept 的返回值是一个含有请求信息的socket,然后在利用socket_read去读取这个socket里面的内容.
下面是具体的代码:
public function listen(){
$listen_res = socket_listen($this->socket);
if (!$listen_res){
var_dump(socket_strerror(socket_last_error()));
contine();
}
//socket_set_nonblock($this->socket);
while(true){
//判断接收请求是否存在异常,如果有异常则跳过该条请求
if(!$client=socket_accept($this->socket)){
socket_close($this->socket);
continue;
}
//进入到这里说明请求没有问题,接下进行解析请求参数
$data = socket_read($this->socket,1024);
var_dump($data);
}
}
修改一下根目录下的server.php
:
<?php
require __DIR__."/vendor/autoload.php";
array_shift($argv);
if (empty($argv)){
$port = 80;
}else{
$port = $argv[0];
}
use phpserver\Server;
$server = new Server("0.0.0.0",$port);
$server->listen();
启动webserver:
php server.php 9001
如果感觉server.php不好看的话,可以将文件名改成 server
那么命令就变成php server 9001
测试结果
"
string(730) "GET / HTTP/1.1
Host: 192.168.2.10:9001
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.128 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: experimentation_subject_id=eyJfcmFpbHMiOnsibWVzc2FnZSI6IklqSTNaak5tT0dJMUxUSmhNbUl0TkdGaVlpMWlZelUyTFRVeU5HRmpaVGMyT1dJeE15ST0iLCJleHAiOm51bGwsInB1ciI6ImNvb2tpZS5leHBlcmltZW50YXRpb25fc3ViamVjdF9pZCJ9fQ%3D%3D--98ff316f61bc94dfd47dc8cfdd41e6d568723001
"
7.解析请求
这一步我们将得到head头数据进行解析,获取到uri地址,以及数据,然后将请求与响应的路由做适配,最后将适配的结果返回给浏览器,大体是这样一个流程.
所以我们接下来做的就是解析header头信息:
首先我们获取请求类型,get/post 然后是他的路由地址
$data = explode("\n",$data);
//首先获取请求方法
list($method,$uri) = explode(" ",array_shift($data));
@list($uri,$param_str) = explode("?",$uri);// 因为可能没有参数,所以用错误抑制符
parse_str( $param_str, $params); //将路由参数,解析成数组
然后我们将其他header头参数进行解析:
$headers = [];
foreach($data as $headOpt){
$arr = explode(":",$headOpt);
if(count($arr)==2){
$headers[$arr[0]] = $arr[1];
}
}
现在的话,我们得到的数据有,请求参数,请求uri,各个header信息,接下来我们将得到的数据,放到全局中,便于调用,这些参数不能别修改,所以使用protected修饰,同时提供访问的方法
下面是一个汇总以后的方法:
protected $uri;
protected $method;
protected $params;
protected $headers;
//....
protected function getRequest($data){
$data = explode("\n",$data);
//首先获取请求方法
list($method,$uri) = explode(" ",array_shift($data));
@list($uri,$param_str) = explode("?",$uri);
parse_str( $param_str, $params);
$headers = [];
foreach($data as $headOpt){
$arr = explode(":",$headOpt);
if(count($arr)==2){
$headers[$arr[0]] = $arr[1];
}
}
$this->uri = $uri;
$this->method = strtoupper($method);
$this->params = $params;
$this->headers = $headers;
return $this;
}
public function method(){
return $this->method;
}
public function uri(){
return $this->uri;
}
public function params(){
return $this->params;
}
public function headers(){
return $this->headers;
}
我们将request请求抽离成一个单独的文件Request.php
:
<?php
/**
* Created by PhpStorm.
* User: lx
* Date: 2021/4/18
* Time: 23:03
*/
namespace webserver;
class Request
{
protected $uri;
protected $method;
protected $params;
protected $headers;
public function getRequest($data){
$data = explode("\n",$data);
//首先获取请求方法
list($method,$uri) = explode(" ",array_shift($data));
@list($uri,$param_str) = explode("?",$uri);
parse_str( $param_str, $params);
$headers = [];
foreach($data as $headOpt){
$arr = explode(":",$headOpt);
switch(count($arr)){
case 2:
$headers[$arr[0]] = $arr[1];
break;
case 3:
$headers[$arr[0]] = $arr[1].$arr[2];
break;
}
}
$this->uri = $uri;
$this->method = strtoupper($method);
$this->params = $params;
$this->headers = $headers;
return $this;
}
public function method(){
return $this->method;
}
public function uri(){
return $this->uri;
}
public function params(){
return $this->params;
}
public function headers(){
return $this->headers;
}
}
8.响应请求
根据不同的请求地址,访问到不同的控制器,所以这里我们需要首先实现一个路由功能
做一个路由:
首先我们在src/server.php
同级目录创建一个Router.php
文件,这个文件作为我们的处理逻辑与请求地址的映射关系.一般我们平时使用的框架,写一条路由会包含三部分,请求方法
,请求uri
,逻辑处理文件方法
,这里我们同样需要这样做:
实现的效果: Router::get();
这种形式
下面是Router中的方法:
<?php
/**
* Created by PhpStorm.
* User: lx
* Date: 2021/4/18
* Time: 22:45
*/
namespace webserver;
class Router
{
public static $GetRouter=[];
public static $PostRouter=[];
public static function get($uri,$reflect){
//首先将$method解析一下
@list($class,$method) = explode("@",$reflect);
self::$GetRouter[$uri] = [
"class" => $class,
"method"=>$method
];
}
public static function post($uri, $reflect){
//首先将$method解析一下
@list($class,$method) = explode("@",$reflect);
self::$PostRouter[$uri] = [
"class" => $class,
"method"=>$method
];
}
}
接下来,我们创建定义路由的文件,同样在同级目录,创建config.php
,用于注册路由:
<?php
namespace webserver;
Router::get("/","webserver\controller\index@index");
Router::get("/welcome","webserver\controller\index@welcome");
加载路由
路由我们做好了,接下来就是在程序初始的时候,将路由的映射加载进来:
下面我们在src/server.php
中增加init
方法
//初始化一些准备工作
protected function init(){
require_once __DIR__."/config.php";
}
并在构造函数中调用
public function __construct($host,$port){
$this->init();
//....
}
路由和控制器做绑定
我们得到了路由,通过Requst.php我们得到了请求信息,接下来我们来做映射关系:
创建Response.php
作为相应处理, 在这之前我们需要将src/server.php
做一些调整,我们让request获取的数据传递给response进行处理
//src/server.php
public function listen(){
$listen_res = socket_listen($this->socket);
if (!$listen_res){
var_dump(socket_strerror(socket_last_error()));
contine();
}
//socket_set_nonblock($this->socket);
$request = new Request();
$response = new Response();
while(true){
//判断接收请求是否存在异常,如果有异常则跳过该条请求
if(!$client=socket_accept($this->socket)){
socket_close($this->socket);
continue;
}
//进入到这里说明请求没有问题,接下进行解析请求参数
$data = socket_read($client,1024);
$requestObj =$request->getRequest($data);
$resCtx = $response->handle($requestObj);//交给response进行处理
}
}
下面是处理逻辑:
Response.php:
<?php
namespace webserver;
class Response
{
public function setHeader($code,$msg,$len){
/**
* HTTP/1.1 200 OK
Content-Length: 152
Content-Type: text/plain; charset=UTF-8
Date: Sun, 18 Apr 2021 15:22:23 GMT
*/
$lines = [];
$lines[] = "HTTP/1.1 ".$code." ".$msg;
$lines[] = "Content-Length: ".$len;
$lines[] = "Date: ".date( ‘D, d M Y H:i:s T‘ );
return implode( " \r\n", $lines )."\r\n\r\n";
}
public function handle(Request $request){
$method = $request->method();
switch($method){
case "GET":
$map = Router::$GetRouter;
break;
case "POST":
$map = Router::$PostRouter;
break;
}
if(isset($map[$request->uri()])){
$className = $map[$request->uri()]["class"];
$methodName = $map[$request->uri()]["method"];
$obj = new $className;
$content = (string)$obj->$methodName();
$header = $this->setHeader(200,"OK",strlen($content));
return $header. $content;
}else{
$header = $this->setHeader(404,"Not Found",0);
return $header;
}
}
}
上面的程序也很简单,需要注意的是,我们在返回给浏览器的时候要有响应头
将数据写入浏览器
在src/server.php
中
//进入到这里说明请求没有问题,接下进行解析请求参数
$data = socket_read($client,1024);
$requestObj =$request->getRequest($data);
$resCtx = $response->handle($requestObj);//交给response进行处理
//上面是之前的代码,只为了标识一下位置,
socket_write( $client, $resCtx, strlen($resCtx));
socket_close( $client );
9.完成
基本完成了,下面我们新建一个控制器做一个测试,创建一个controller/index.php
<?php
namespace webserver\controller;
class index
{
public function index(){
return "<h1>hello,world</h1>";
}
public function welcome(){
return json_encode([
"msg"=>"welcome"
]);
}
}
最终的目录结构为:
.
├── composer.json
├── composer.lock
├── server
├── src
│ ├── config.php # 路由文件
│ ├── controller
│ │ └── index.php #测试的控制器
│ ├── Request.php # 处理请求
│ ├── Response.php #处理响应
│ ├── Router.php #路由逻辑处理
│ └── Server.php #socket服务
└── vendor
├── autoload.php
└── composer
├── autoload_classmap.php
├── autoload_namespaces.php
├── autoload_psr4.php
├── autoload_real.php
├── autoload_static.php
├── ClassLoader.php
├── installed.json
├── installed.php
├── InstalledVersions.php
└── LICENSE
最后我们修改一下入口文件:
<?php
require __DIR__ . "/vendor/autoload.php";
array_shift($argv);
if (empty($argv)){
$port = 80;
}else{
$port = $argv[0];
}
$server = new \webserver\Server("0.0.0.0",$port);
$server->listen();
运行
php server 9001
打开浏览器访问: http://192.168.2.10:9001/
最后放一张效果图: