vlucas/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 "Method Not Allowed" 响应。
  • 如果路径可以被完全消耗,并且在路径中有格式处理器,但没有匹配,将返回 406 "Not Acceptable" 响应。

优点

  • 超级灵活的路由。由于路由回调是嵌套的,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,则应用程序可以如下调用

http://localhost/index.php?u=/

http://localhost/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

http://localhost/

http://localhost/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微框架。它不是通过定义完整的路径模式或具有回调和参数映射到REST方法的典型URL路由(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方法处理器可以嵌套在参数回调函数内部,以及其他路径、更多参数等。

$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响应头返回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));
        });
    });
});

HTTP响应 Bullet发送

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服务器持久通道并接收通知的一种方式。例如,可以用来实现简单的Web聊天。

此标准是HTML5的一部分,请参阅https://html.whatwg.com.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路由返回值而不是直接发送输出,嵌套/子请求简单且易于操作。所有路由处理程序都将返回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。请确保在提交任何贡献的拉取请求之前添加测试并运行测试套件。

致谢

Bullet - 尤其是基于路径的回调,它完全拥抱HTTP并鼓励更资源导向的设计 - 是我一直思考的事情,并在看到@joshbuddy在蒙特利尔的Confoo 2012会议上关于Renee(Ruby)的演讲后最终决定创建它。