chuchiy/phpex

轻量级、灵活、开发者友好的微型Web框架

0.0.1 2016-06-21 01:01 UTC

This package is not auto-updated.

Last update: 2024-09-24 19:39:21 UTC


README

Build Status Coverage Status License

PHPex 是一个用 PHP 编写的轻量级、灵活、开发者友好的微型 Web 框架。

特性

  • 基于高阶函数的强大插件(中间件)和灵活的处理链
  • 基于类方法注释和函数回调的 URL 路由,支持参数和模式匹配
  • 统一处理 PSR-7 请求、响应、依赖注入等。
  • 请求处理器可以在插件的帮助下返回字符串、流、可迭代对象或任何你想要的内容
  • 复杂的网页组合的懒加载模板渲染

安装

使用 Composer 安装 PHPex。

$ composer require chuchiy/phpex

入门

单文件示例,请勿在生产环境中使用

<?php
require 'vendor/autoload.php';
$pex = new \Pex\Pex; 
//install plugin for result jsonize and inject a console logger
$pex->install(function($run){
    return function($cycle) use ($run) {
        $cycle->register('log', function($c) {
            return function($msg) { error_log($msg, 4); };
        });
        $r = $run($cycle);
        return ($r instanceof stdClass)?json_encode($r):$r;
    };
});

//routing with anonymous function
$pex->attach('/console')->get('/send/<msg>', function($cycle){
    call_user_func($cycle->log, "recv msg: {$cycle['msg']}");
    return (object)['ok'=> True];
});

//handler class use method annotation routing
class PexHandler {

    /** 
     * @get('/hello/<name>')
     */
    public function hello($cycle) {
        return 'hello ' . $cycle['name'];
    }

    /**
     * @route('/get')
     */
    public function getId($cycle) {
        return (object)['id'=>$cycle->want('id'), 'name'=>$cycle->get('name', 'foo')];
    }
};
$pex->attach('/', 'PexHandler');

$pex->serve();

将上述内容保存到 index.php 并使用内置的 PHP 服务器进行测试

$ php -S localhost:8080

访问 https://:8080/hello/world 将显示 hello world

访问 https://:8080/get?id=123 将获取 json 化的结果 {"name": "foo", 'id': 123}

访问 https://:8080/console/send/foobar 将将文本 foobar 记录到服务器控制台

配置

nginx + php-fpm

PHPex 利用 php-fpm 的特殊参数 SCRIPT_FILENAME 来设置前端控制器。将 SCRIPT_FILENAME 设置为入口点文件,php-fpm 将将所有 HTTP 流量传递到该文件。

    ...
    location / {
            fastcgi_param SCRIPT_FILENAME /var/www/index.php;
            fastcgi_pass ...;
            ...
            ...
    }
    ...

路由与处理器

路由机制

PHPex 为您提供了两种不同的路由机制和两级路由分发,以提高性能。

对于小型项目,您可以为路由设置函数处理器

$pex->get('/path/to/a', function($c){return 'a'});
$pex->get('/path/to/b', function($c){return 'b'});

如果您希望将处理器组织成组以在特定路径上挂载并共享插件

$pex->attach('/path-at/')->get('/a', function($c){
    return 'a';
})->post('/b', function($c){
    return 'b';
})->with(function($run){
    return funcion($c) use ($run) {
        $r = $run($c);
        return (is_string($r))?strtoupper($r):$r;
    };
});

$pex->attach($path) 将将方法链中的所有路由设置挂载到附加路径。在方法链中使用 ->with($plugin) 将为该方法链中的所有路由安装插件。

因此,https://:8080/path-at/a 将显示 A(在 strtoupper 插件的帮助下),而 https://:8080/a 将返回 404 未找到的 HTTP 状态码。

在实际项目中,我们通常使用不同的类来处理应用程序逻辑。将所有路由和处理器内容放入一个文件会使整个项目变得混乱。PHPex 提供了添加路由和设置处理器的类级功能。

在前端控制器中,只需设置如下:

$pex->attach('/api/', '\NS\Api');
$pex->attach('/site/', '\NS\Site');

所有以 /api/ 开头的请求将流向 \NS\Api,所有以 /site/ 开头的请求将流向 \NS\Site

下面是这个类的样子

class Api {

    public function __invoke($route) {
        $route->post('delete', [$this, 'delete']);
    }

    public function delete($cycle) {
        return ['ok'=>True];
    }

    /**
     * @post('/create')
     */
    public function create($cycle) {
        return ['id'=>uniqid()];
    }

    /**
     * @get('/list')
     */
    public function list($cycle) {
        return ['list'=>range(0, 10)];
    }

}

PHPex 提供了两种添加类路由的机制

  1. 您可以使用注解命令(如 @get/@post/@route)添加路由(此方法也将用于方法插件安装)。
  2. 您可以在 __invoke 魔法方法中添加路由(您还可以在这里安装类级插件)。

对于大型项目,使用不同的专用类来处理逻辑相关的请求并将它们组合起来以实现更复杂的企业逻辑会更方便。使用 PHPex,您只需在前端控制器中设置一级挂载点分发映射,并在不同的类中编写实际的路由和处理代码。

请求处理器

PHPex中的请求处理器是一个用于处理HTTP请求的可调用对象。请求处理器接收一个周期对象作为参数,并本地返回字符串、可调用对象、可遍历对象、生成器、流或无(如果你直接写入响应体)。在大多数情况下,请求处理器被某些插件包装,然后成为处理链。

插件可以轻松地更改请求处理器的参数和返回值。在实际开发中,请求处理器最广泛使用的返回类型是关联数组。插件将关联数组在不同场景和插件配置下转换为JSON字符串或HTML页面。

插件(中间件)

插件(中间件)是PHPex的关键概念。插件是一个高阶函数,它接收一个内部请求处理器($run)并返回一个新的请求处理器,该处理器包装了内部请求处理器。插件和请求处理器构建了处理链。

//demonstration proces chain build
$callable = $plugin1($plugin2($plugin3($handler)));
$r = $callable($cycle);

以下插件检查内部可调用返回值的类型。如果返回值是stdClass,则进行json_encode。

function jsonize($run) {
    return function($cycle) use ($run) {
        //run the inner callable
        $r = $run($cycle);
        return ($r instanceof stdClass)?json_encode($r):$r;
    };
}

插件也可以用类实现。只需使用__invoke魔法方法接收可调用对象并返回新的处理器。PHPex提供了一个抽象类\Pex\Plugin\BasePlugin,用于方便地进行类式插件实现。

插件有4个有效的作用域

全局插件

全局插件通过$pex->install安装,这些插件将应用于所有HTTP请求。

$pex = new \Pex\Pex;
$pex->install('global_plugin');
$pex->install('global_plugin2');

组插件

组插件通过路由方法链安装,它将应用于方法链中的处理器。

$pex->post('/foo', 'foo_handler')->get('/bar', 'bar_handler')->with('awesome_plugin');

类插件

类插件在类的__invoke方法中安装,它将应用于类中的所有方法处理器。

class Foo {

    public function __invoke($route) {
        $route->install('foo_plugin')
    }

    ...
    ..
}

方法插件

方法插件通过方法注释安装,应调用$pex->bindAnnotation$route->bindAnnotation来绑定注释命令到一个高阶插件。高阶插件是一个可调用对象,它接收注释命令名称和传递给注释命令的参数,返回一个有效的插件,因此你可以利用传递给注释命令的参数。

class Foo {

    public function __invoke($route) {
        $route->bindAnnotation('view', new \Pex\Plugin\Templater(__DIR__));
    }

    /**
     * @get('/bar')
     * @view('bar.tpl')
     * @custhdr('x-author', 'pex')
     * @custhdr('x-site', 'test')
     */
    public function bar($cycle) {
        return [];
    }

}
$pex->attach('/', 'Foo');
//bind a high-order function to annotation command custhdr
//$name = 'custhdr', $args = ['x-...', '....']
$pex->bindAnnotation('custhdr', function($name, $args){
    //return a plugin
    return function($run) use ($name, $args) {
        return function($cycle) use ($run, $name, $args) {
            $r = $run($cycle);
            $cycle->reply()[$args[0]] = $args[1];
            return $r;
        };
    };
});

周期对象

请求处理器只有一个参数:一个全能的$cycle对象。周期对象可用于

  1. 从路径/cookie/post/get获取输入参数
  2. 依赖注入与服务容器
  3. PSR-7兼容的HTTP请求/响应的包装和请求/响应处理的便捷方式

参数

你可以使用$cycle->want('arg')$cycle->get('arg')来获取参数。类似于ArrayAccess的$cycle["args"]isset($cycle["args"])也是可用的。周期将按以下顺序获取参数:请求路径中的参数、cookie、post和get。

$cycle->want($name)如果找不到参数名称,将抛出InvalidArgumentException。$cycle->get($name, $default=null)如果找不到参数名称,则返回$default

依赖注入与服务容器

插件/请求处理器使用$cycle来注入和获取服务。你可以使用inject/register方法将可调用对象注入为服务。

注入和注册之间的区别

如果你使用inject,每次你使用$cycle->srvname,相关的可调用对象将被调用,并且你将获得新的返回值。

如果你使用register,可调用对象只会在第一次调用时被调用,结果将被缓存,你将在后续的$cycle->srvname中始终获得相同的结果。

使用可调用对象进行注入给你提供了延迟初始化的能力,服务仅在真正使用时创建。

$cnt = 0;
$cycle->inject('counter', function($cycle) use (&$cnt) {
    return ++$cnt;
});

$cycle->register('counter2', function($cycle) use (&$cnt) {
    return ++$cnt;
});

var_dump($cycle->counter); //will output 1
var_dump($cycle->counter); //will output 2
var_dump($cycle->counter2); //will output 1
var_dump($cycle->counter2); //will output 1

请求和响应

你可以使用$cycle->request()$cycle->response()获取PSR-7兼容的HTTP请求/响应。

周期还提供了$cycle->client()来获取一个\Pex\Client实例,该实例具有一些便捷的方法来检索常见的HTTP头部。

如果你想向响应添加头部,请使用$cycle->reply()->setHeader()来添加头部,ArrayAccess操作符也是可用的,添加的头部将在处理链结束时输出到客户端。

周期对象也可以被调用。就像Python WSGI的start_response,立即调用$cycle($status, $headers)会将状态码和头部信息发送到客户端,并返回一个可调用的写入器,可以用来向客户端发送内容。$cycle->response()只有在调用$cycle之后才会返回创建的HTTP响应。

$request = $cycle->request();  //return a PSR-7 http request

$ua = $cycle->client()->userAgent();

$cycle->reply()->setHeader('content-type', 'text/plain')
$cycle->reply()['x-author'] = 'Pex';

$writer = $cycle(200, ['x-site'=>'test']);
$writer('body');
//return a PSR-7 http response. you can only get response after $cycle($status, $headers) is called.
$response = $cycle->response(); 

$cycle->interrupt($status, $headers=[], $body=null)将抛出\Pex\Exception\HttpException异常。

在全局范围内安装\Pex\Plugin\CatchHttpException插件后,您可以使用$cycle->interrupt来中断当前进程,而CatchHttpException将捕获异常并将给定的$status$headers$body发送到客户端。

$pex->install(new \Pex\Plugin\CatchHttpException);
    ...
    ...
$pex->get('/redir', function($cycle){
    //http page redirect
    $cycle->interrupt(302, ['Location'=>$cycle->want('cont')]);
})

有用的内置插件

模板 & Twig插件

PHPex内置了\Pex\Plugin\Templater用于普通PHP模板渲染和\Pex\Plugin\TwigPlugin用于twig模板渲染。模板插件将使用请求处理器的返回数据渲染每个通过注解命令传递的模板参数。

$pex->bindAnnotation('view', new \Pex\Plugin\Templater(__DIR__));
$pex->bindAnnotation('twig', new \Pex\Plugin\TwigPlugin(__DIR__, sys_get_temp_dir().'/twig/'));

class Page {

    public function __invoke($route) {
        $route->install(function($run) {
            return function($cycle) use ($run) {
                $r = $cycle($run);
                $r['menu'] = ['a', 'b', 'c']
                $r['user'] = 'pex';
                return $r;
            };
        });
    }

    /**
     * @view('header.php', 'main.php', 'footer.php')
     * @get('/test')
     */
    public function test($cycle) {
        return ['name'=>'test'];
    }

    /**
     * @twig('hello.html')
     * @get('/hello')
     */
    public function hello($cycle) {
        return ['name'=>'hello']
    }
}

内置模板插件最重要的特性是延迟渲染

尽管模板插件是方法插件,应该在全局/分组插件之前渲染请求处理器的返回值。模板插件的返回值不是实际的渲染结果,而是返回\Pex\ViewRender,它是ArrayObject的可调用子类。

外部插件可以像数组一样操作返回的ViewRender。因此,在模板文件中,我们可以使用处理链插件插入到视图渲染中的所有变量。

在处理链执行完成后,因为ViewRender是可调用的,框架将尝试调用结果并获取实际的渲染结果。

模板插件还会将模板实例注入到带有注解名称的$cycle中。在上面的例子中,请求处理器可以使用$cycle->view->render($context, $templateFile)直接渲染模板。

Jsonize & CatchHttpException

Jsonize插件将所有关联数组返回值进行json_encode并绕过其他类型的结果。因此,在全局级别安装Jsonize插件是安全的。

CatchHttpException需要作为整个处理链中的第一个插件安装。此插件将捕获\Pex\Exception\HttpException并正确地向客户端发送响应。

代码食谱

处理时间计数插件

<?php
require 'vendor/autoload.php';
$pex = new \Pex\Pex; 
//inject timer
$pex->install(function($run){
    return function($cycle) use ($run) {
        $start = microtime(true);
        $r = $run($cycle);
        //set reply header
        $cycle->reply()['x-proc-msec'] = round((microtime(true)-$start)*1000, 3);
        return $r;
    }
});

注入数据库实例

$pex->install(function($run){
    return function($cycle) use ($run) {
        $cycle->register('db', function($c) {
            return new \PDO('sqlite::memory:');
        });
        return $run($cycle);
    }
});

请求POST体JSON解析插件

function jsonInput($run) {
    return function($cycle) use ($run) {
        //$c is the latest cycle object when $cycle->input is first called
        $cycle->register('input', function($c) {
            return json_decode((string)$c->request()->getBody(), true);
        });
        return $run($cycle);
    };
}

客户端工作人员

$cycle->client()->userAgent(); //get request user-agent
$cycle->client()->contentType(); //get request content-type
$cycle->client()->isAjax(); //whether the request is ajax request or not
$cycle->client()->publicIp(); //get the most likely public ip of client
$cycle->client()->redirect($url); //redirect client to $url
$cycle->client()->referer(); //get request referer

类式插件

class Jsonize extends \Pex\Plugin\BasePlugin 
{
    protected function apply($cycle, $run)
    {
        $r = $run($cycle);
        if((is_array($r) and array_keys($r) !== range(0, count($r) - 1)) or (is_object($r) and $r instanceof \stdClass)) {
            $cycle->reply()['Content-Type'] = 'application/json; charset=utf-8';
            $r = json_encode($r, JSON_UNESCAPED_UNICODE);
        }
        return $r;
    }    
}

高阶类式插件

class AddHeader extends \Pex\Plugin\HighorderPlugin
{
    protected function apply($cycle, $run, $name, $args)
    {
        $r = $run($cycle);
        $cycle->reply()[$args[0]] = $args[1];
        return $r;        
    }
}

$pex->bindAnnotation('custhdr', new AddHeader);

class A {
    /**
     * @get('/test')
     * @custhdr('x-extra', 'foobar')
     */
    public function($cycle) {
        return 'abc';
    }
}

无参数的模板插件

$pex->bindAnnotation('view', new \Pex\Plugin\Templater(__DIR__));

class Page {
    
    /**
     * use view command without parameters will only inject template instance
     * @get('/test')
     * @view
     */
    public function render($cycle) {
        return $cycle->view->render(['name'=>'foobar'], 'test.php');
    }
}

使用挂载点信息进行适当的重定向

function go2login($cycle) {
    $cycle->client()->redirect($cycle->mountpoint() . 'login'); //will redirect to /app/login
}
$pex->attach('/app/')->get('/auth', 'go2login');

请求参数检索

$val = $cycle->want('foo'); //throw InvalidArgumentException if parameter not found
$val = $cycle->get('foo'); //return null if parameter not found
$val = $cycle->get('foo', 'dftval');
$val = $cycle['foo'];
isset($cycle['foo']);

检索文件内容

//return file stream
function fileStream($cycle) {
    return fopen('foo.bar', 'r');
}
//generator
function fileStream($cycle) {
    $fp = fopen('foo.bar', 'r');
    while (!feof($fp)) {
        yield fread($fp, 8192);
    }
}
//write to response body
function fileWriter($cycle) {
    $writer = $cycle();
    $fp = fopen('foo.bar', 'r');
    while (!feof($fp)) {
        $writer(fread($fp, 8192));
    }    
}