php做一个webserver

754 阅读3分钟

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": "root/webserver",
    "type": "lib",
    "license": "mit",
    "minimum-stability": "dev",
    "require": {},
    "autoload":{
        "psr-4":{
            "webserver\\":"src/"
        }
    }
}

生成自动引入程序:

copmoser install

创建webserver目录和src目录:

结构如下:

├── composer.json
├── composer.lock
├── server
├── src
└── 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

    

接下来进行验证是否可以 使用:

在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,intdomain` , 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

手册地址www.php.net/manual/en/f…

第一个参数是,上面我们定义好的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){
            var_dump("socket 绑定失败");
        }
    }
}

绑定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/

最后放一张效果图:

image-20210418235925679.png