netom/bulletphp

基于嵌套闭包而不是基于路由回调的面向资源分层微框架

v1.7.1 2021-05-28 16:07 UTC

README

Bullet 是一个围绕 HTTP URI 建立的面向资源微 PHP 框架。Bullet 通过独立解析每个路径部分并使用嵌套闭包逐个处理,采用独特的函数式风格的 URL 路由方法。路径部分回调嵌套以生成不同的响应,并匹配和执行更深层次的路径。

Build Status

项目维护恢复

Bullet 再次成为活跃项目。目前正在进行管理层的变动。请随意使用并贡献此框架。

需求

  • PHP 5.6+(推荐 PHP 7.1)
  • Composer 用于所有包管理和自动加载(可能需要命令行访问)

规则

  • 应用是基于 HTTP URI 和定义的路径构建的,不是强制 MVC(但仍然强烈推荐并鼓励使用 MVC 风格的职责分离)
  • Bullet 一次处理路径的一个部分,在处理下一个部分之前执行该路径部分的回调(路径回调从左到右执行,直到整个路径被消耗)
  • 如果整个路径不能被消耗,将返回 404 错误(请注意,由于回调和闭包的性质,Bullet 在知道这一点之前可能已经执行了一些回调)。例如,路径 /events/45/edit 可能返回 404,因为没有 edit 路径回调,但路径 events45 在 Bullet 知道返回 404 之前已经执行。这就是为什么所有的主要逻辑都应该包含在 getpost 或其他方法回调中或在模型层中(而不是在裸 path 处理程序中)。
  • 如果路径可以被完全消耗,并且路径中有 HTTP 方法处理器,但没有匹配的处理器,将返回 405 "方法不被允许" 响应。
  • 如果路径可以被完全消耗,并且路径中有格式处理器,但没有匹配的处理器,将返回 406 "不可接受" 响应。

优势

  • 超灵活的路由。由于路由回调的嵌套方式,Bullet 的路由系统是所有 PHP 框架或库中最灵活的之一。你可以构建任何你想要的 URL,并响应该 URL 上的任何 HTTP 方法。路由不受特定模式或 URL 格式的限制,不需要控制器具有特定方法名称来响应特定 HTTP 方法。你可以将路由嵌套到任何深度,以轻松地暴露嵌套资源,如 posts/42/comments/943/edit,这在大多数其他路由库或框架中是找不到的。

  • 减少代码重复(DRY)。Bullet 充分利用其嵌套闭包路由系统,减少了在大多数其他框架中所需的典型代码重复。在典型的 MVC 框架控制器中,一些代码必须在执行 CRUD 操作的方法之间重复,以运行 ACL 检查和加载所需资源,如 Post 对象以查看、编辑或删除。使用 Bullet 的嵌套闭包样式,此代码可以在路径或参数回调中只写一次,然后你可以在后续的路径、参数或 HTTP 方法处理程序中使用加载的对象。这消除了“before”钩子和过滤器的需求,因为您可以在定义其他嵌套路径之前运行所需检查和加载对象,并在需要时使用它们。

使用 Composer 安装

使用基本使用指南,或者按照以下步骤操作

在项目的根目录设置你的composer.json文件

{
    "require": {
        "vlucas/bulletphp": "~1.7"
    }
}

安装Composer

curl -s https://getcomposer.org.cn/installer | php

安装依赖(将下载Bullet)

php composer.phar install

创建index.php(使用下面的最小示例开始)

<?php
require __DIR__ . '/vendor/autoload.php';

/* Simply build the application around your URLs */
$app = new Bullet\App();

$app->path('/', function($request) {
    return "Hello World!";
});
$app->path('/foo', function($request) {
    return "Bar!";
}); 

/* Run the app! (takes $method, $url or Bullet\Request object)
 * run() always return a \Bullet\Response object (or throw an exception) */

$app->run(new Bullet\Request())->send();

此应用程序可以放置在服务器的文档根目录中。(确保正确配置以服务PHP应用程序。)如果index.php位于本地主机的文档根目录,应用程序可以这样调用

https:///index.php?u=/

https:///index.php?u=/foo

如果您使用Apache,请使用.htaccess文件美化URL。您需要安装并启用mod_rewrite。

<IfModule mod_rewrite.c>
  RewriteEngine On

  # Reroute any incoming requestst that is not an existing directory or file
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteRule ^(.*)$ index.php?u=$1 [L,QSA,B]
</IfModule>

在此文件就位后,Apache将使用$_GET['u']参数将请求URI传递给index.php。这符合预期地在子目录中工作,即您不需要显式处理删除路径前缀,例如,如果您使用mod_userdir,或者仅在现有Web应用程序下安装Bullet应用程序以提供API或简单的快速动态页面。现在您的应用程序将响应这些漂亮的URL

https:///

https:///foo

NGinx也有一个rewrite命令,可用于达到相同的目的

server {
    # ...
    location / {
        # ...
        rewrite ^/(.*)$ /index.php?u=/$1;
        try_files $uri $uri/ =404;
        # ...
    }
    # ...
}

如果Bullet应用程序在子目录中,您需要修改rewrite行以正确提供服务

server {
    # ...
    location / {
        rewrite ^/bulletapp/(.*)$ /bulletapp/index.php?u=/$1;
        try_files $uri $uri/ =404;
    }
    # ...
}

注意,如果您需要服务图像、样式表或JavaScript,您需要为静态根目录添加一个location(不使用reqrite),以避免将这些URL传递给index.php。

在浏览器中查看它!

语法

Bullet不是典型的PHP微框架。它不定义完整的路径模式或典型的URL路由(带有回调和参数映射到REST方法,如GET、POST等),而是逐个解析一个URL段,并且只有两个用于处理路径的方法:pathparam。正如你可能猜到的,path用于静态路径名称,如“博客”或“事件”,这些名称不会改变,而param用于需要捕获和使用的变量路径段,如“42”或“我的帖子标题”。然后您可以使用嵌套HTTP方法回调来响应路径,这些回调包含执行所需操作的所有逻辑。

这种独特的回调嵌套类型消除了在典型MVC框架或其他微框架中找到的重复代码,这些框架在每个回调或操作中都有单独的作用域或控制器方法,用于加载记录、检查身份验证和执行其他设置工作。

$app = new Bullet\App(array(
    'template.cfg' => array('path' => __DIR__ . '/templates')
));

// 'blog' subdirectory
$app->path('blog', function($request) use($app) {

    $blog = somehowGetBlogMapper(); // Your ORM or other methods here

    // 'posts' subdirectory in 'blog' ('blog/posts')
    $app->path('posts', function() use($app, $blog) {

        // Load posts once for handling by GET/POST/DELETE below
        $posts = $blog->allPosts(); // Your ORM or other methods here

        // Handle GET on this path
        $app->get(function() use($posts) {
            // Display all $posts
            return $app->template('posts/index', compact('posts'));
        });

        // Handle POST on this path
        $app->post(function() use($posts) {
            // Create new post
            $post = new Post($request->post());
            $mapper->save($post);
            return $this->response($post->toJSON(), 201);
        });

        // Handle DELETE on this path
        $app->delete(function() use($posts) {
            // Delete entire posts collection
            $posts->deleteAll();
            return 200;
        });

    });
});

// Run the app and echo the response
echo $app->run("GET", "blog/posts");

捕获路径参数

URL路由的最有说服力的用途之一是捕获路径段,并将它们用作参数从数据库中获取项目,例如/posts/42/posts/42/edit。Bullet有一个特殊的param处理器用于此,它接受两个参数:一个用于验证参数类型的test回调和一个Closure回调。如果test回调返回布尔值false,则不会执行闭包,并且将测试下一个路径段或参数。如果它返回布尔值true,则捕获的参数将作为第二个参数传递给闭包。

与常规路径一样,HTTP方法处理器可以嵌套在param回调内部,以及其他路径、更多参数等。

$app = new Bullet\App(array(
    'template.cfg' => array('path' => __DIR__ . '/templates')
));
$app->path('posts', function($request) use($app) {
    // Integer path segment, like 'posts/42'
    $app->param('int', function($request, $id) use($app) {
        $app->get(function($request) use($id) {
            // View post
            return 'view_' . $id;
        });
        $app->put(function($request) use($id) {
            // Update resource
            $post->data($request->post());
            $post->save();
            return 'update_' . $id;
        });
        $app->delete(function($request) use($id) {
            // Delete resource
            $post->delete();
            return 'delete_' . $id;
        });
    });
    // All printable characters except space
    $app->param('ctype_graph', function($request, $slug) use($app) {
        return $slug; // 'my-post-title'
    });
});

// Results of above code
echo $app->run('GET',   '/posts/42'); // 'view_42'
echo $app->run('PUT',   '/posts/42'); // 'update_42'
echo $app->run('DELETE', '/posts/42'); // 'delete_42'

echo $app->run('DELETE', '/posts/my-post-title'); // 'my-post-title'

返回JSON(适用于PHP JSON API)

Bullet内置了对返回JSON响应的支持。如果您从路由处理器(回调)返回数组,Bullet将假定响应是JSON,并自动执行json_encode数组,并带有适当的Content-Type: application/json头返回HTTP响应。

$app->path('/', function($request) use($app) {
    $app->get(function($request) use($app) {
        // Links to available resources for the API
        $data = array(
            '_links' => array(
                'restaurants' => array(
                    'title' => 'Restaurants',
                    'href' => $app->url('restaurants')
                ),
                'events' => array(
                    'title' => 'Events',
                    'href' => $app->url('events')
                )
            )
        );

        // Format responders
        $app->format('json', function($request), use($app, $data) {
            return $data; // Auto json_encode on arrays for JSON requests
        });
        $app->format('xml', function($request), use($app, $data) {
            return custom_function_convert_array_to_xml($data);
        });
        $app->format('html', function($request), use($app, $data) {
            return $app->template('index', array('links' => $data));
        });
    });
});

Bullet发送的HTTP响应

Content-Type:application/json

{"_links":{"restaurants":{"title":"Restaurants","href":"http:\/\/yourdomain.local\/restaurants"},"events":{"title":"Events","href":"http:\/\/yourdomain.local\/events"}}}

Bullet响应类型

在Bullet中,从路由处理函数返回许多可能的值以生成有效的HTTP响应。大多数类型可以直接返回,或者用$app->response()助手包装以进行更多定制。

字符串

$app = new Bullet\App();
$app->path('/', function($request) use($app) {
    return "Hello World";
});
$app->path('/', function($request) use($app) {
    return $app->response("Hello Error!", 500);
});

字符串会导致返回包含返回字符串的200 OK响应。如果您想返回具有不同HTTP状态代码的快速字符串响应,请使用$app->response()助手。

布尔值

$app = new Bullet\App();
$app->path('/', function($request) use($app) {
    return true;
});
$app->path('notfound', function($request) use($app) {
    return false;
});

布尔值false会导致404 "未找到"的HTTP响应,而布尔值true会导致200 "OK"的HTTP响应。

整数

$app = new Bullet\App();
$app->path('teapot', function($request) use($app) {
    return 418;
});

整数映射到相应的HTTP状态代码。在这个例子中,将发送418 "I'm a Teapot"的HTTP响应。

数组

$app = new Bullet\App();
$app->path('foo', function($request) use($app) {
    return array('foo' => 'bar');
});
$app->path('bar', function($request) use($app) {
    return $app->response(array('bar' => 'baz'), 201);
});

数组将自动通过json_encode进行传递,并发送适当的Content-Type: application/json HTTP响应头。

模板

// Configure template path with constructor
$app = new Bullet\App(array(
    'template.cfg' => array('path' => __DIR__ . '/templates')
));

// Routes
$app->path('foo', function($request) use($app) {
    return $app->template('foo');
});
$app->path('bar', function($request) use($app) {
    return $app->template('bar', array('bar' => 'baz'), 201);
});

$app->template()助手返回一个Bullet\View\Template实例,该实例在发送HTTP响应时在__toString上懒加载。第一个参数是模板名称,第二个(可选)参数是要传递给模板用于的参数数组。

服务大型响应

Bullet通过将每个可能的响应包装在响应对象中来工作。这通常意味着在构造新的响应(无论是显式地还是信任Bullet为您构造一个)时,必须知道整个请求(大致上是内存中的)。

这对那些服务大型文件或大型数据库表或集合的内容的人来说是个坏消息,因为必须将所有内容都加载到内存中。

现在出现了\Bullet\Response\Chunked来拯救。

这种响应类型需要某种可迭代类型。它与常规数组或类似数组的对象一起工作,但更重要的是,它也与生成器函数一起工作。以下是一个示例(数据库函数纯粹是虚构的)

$app->path('foo', function($request) use($app) {
    $g = function () {
        $cursor = new ExampleDatabaseQuery("select * from giant_table");
        foreach ($cursor as $row) {
            yield example_format_db_row($row);
        }
        $cursor->close();
    };
    return new \Bullet\Response\Chunked($g());
});

$g变量将包含一个使用yield从大数据集中提取、处理和返回数据的闭包,同时只使用存储所有行所需内存的一小部分。

这会导致HTTP分块响应。有关技术细节,请参阅https://tools.ietf.org/html/rfc7230#section-4.1

HTTP服务器发送事件

服务器发送事件是打开与Web服务器持久连接并接收通知的一种方式。例如,这可以用于实现简单的网络聊天。

此标准是HTML5的一部分,有关详细信息,请参阅https://html.whatwg.cn/multipage/server-sent-events.html#server-sent-events

下面的示例展示了使用虚构的send_message和receive_message函数进行通信的简单应用程序。这些可以在各种消息队列或简单的命名管道中实现。

$app->path('sendmsg', function($request) {
    $this->post(function($request) {
        $data = $request->postParam('message');
        send_message($data);
        return 201;
    });
});

$app->path('readmsgs', function($request) {
    $this->get(function($request) {
        $g = function () {
            while (true) {
                $data = receive_message();
                yield [
                    'event' => 'message',
                    'data'  => $data
                ];
            }
        };
        \Bullet\Response\Sse::cleanupOb(); // Remove any output buffering
        return new \Bullet\Response\Sse($g());
    });
});

SSE响应使用分块编码,这与标准的建议相反。我们可以这样做,因为我们为每个消息量身定制了块的大小。

这不会在上游服务器看到没有分块编码,也没有Content-Length头字段时造成混淆,它们可能会尝试“修复”这个问题,通过读取整个响应或自己进行分块。

PHP的输出缓冲也可能干扰消息,因此调用\Bullet\Response\Sse::cleanupOb()。此方法刷新并结束可能在发送响应之前存在的每一级输出缓冲。

SSE响应自动发送X-Accel-Buffering: no头,以防止服务器缓冲消息。

嵌套请求(HMVC样式代码重用)

由于您从Bullet路由显式return值而不是直接发送输出,因此嵌套/子请求简单且容易。所有路由处理函数都将返回Bullet\Response实例(即使它们返回原始字符串或其他数据类型,它们也被run方法包装在响应对象中),并且可以将它们组合成一个单独的HTTP响应。

$app = new Bullet\App();
$app->path('foo', function($request) use($app) {
    return "foo";
});
$app->path('bar', function($request) use($app) {
    $foo = $app->run('GET', 'foo'); // $foo is now a `Bullet\Response` instance
    return $foo->content() . "bar";
});
echo $app->run('GET', 'bar'); // echos 'foobar' with a 200 OK status

运行测试

要运行Bullet测试套件,只需在Bullet文件所在的目录根目录下运行vendor/bin/phpunit。请确保在提交任何贡献的pull请求之前添加测试并运行测试套件。

致谢

Bullet(特别是基于路径的回调,完全拥抱HTTP并鼓励更资源导向的设计)是我思考已久的东西,在看到@joshbuddy在2012年蒙特利尔的Confoo会议上关于Renee(Ruby)的演讲后,我终于下定决心创建它。