Spiral 详细上手指南之请求和响应

Spiral 详细上手指南系列文章的代码托管在 gitee 仓库,每一篇文章结束时的代码都会在一个单独的分支下,本篇文章对应的代码分支是 step-3.

在上一篇《Spiral 详细上手指南之路由规则》中,相信大家对于 Spiral 框架中的路由配置已经完全掌握了。不过在文章结束的时候,我们创建的控制器针对各种请求只是简单地响应一串文字证明路由生效了。这次我们就来实现其中的文章列表和文章详情。

HTTP 请求

PSR-7 标准消息接口

由于 Spiral 是完全遵循 PSR-7 消息接口规范的,因此我们在控制器方法(重要提示:不允许在控制器构造函数中注入 ServerRequestInterface)中借助自动依赖注入,获得 Psr\Http\Message\ServerRequestInterface 接口,就可以用它获取客户端的响应数据:

use Psr\Http\Message\ServerRequestInterface;

class FooController
{
    public function index(ServerRequestInterface $request)
    {
        // 头信息
        $headers = $request->getHeaders();
        $host = $request->getHeader('user-agent');
        // 查询参数,比如 ?page=1&size=10
        $querys = $request->getQueryParams();
        // POST|PUT 等请求的请求体
        $body = $request->getParsedBody();
        // ...
    }
}

更多的方法请参阅 PSR-7 消息接口规范 。要特别说明的是,由于 Spiral 将核心模块和单例模式的对象都常驻内存中,因此不可将用户相关的 Request 对象作为控制器构造函数的依赖注入。

当然,实际使用中,也没必要用到这个接口,因为 Spiral 提供了一个可以常驻内存的,提供更多便捷方法和属性的 Spiral\Http\Request\InputManager 对象以供使用。

InputManager

InputManager 可以当作一个请求处理服务来用,它可以而且应该以单例模式实现,我们可以在控制器方法中或者控制器构造函数中依赖注入它:

use Spiral\Http\Request\InputManager;

class MyController
{
    private $input;

    public function __constructor(InputManager $input)
    {
        $this->input = $input;
    }

    public function myAction()
    {
        $headers = $this->input->headers->all();
        $host = $this->input-headers->get('host');
        $querys = $this->input->query->all();
        $page = $this->input->query('page', 1);
        $postData = $this->input->post->all();
        $method = $this->input->method();
        $isAjax = $this->input->isAjax();
        $path = $this->input->path();
        // ...
    }
}

InputManager 的属性和方法非常多,使用起来也非常方便。这里就不一一列举了。可以参阅 Spiral 官方文档,不过我个人更喜欢在 PHPStorm 中直接跳到源代码看注释。由于 Spiral 框架不使用 __get(), __call() 这样的魔术方法,因此它的源代码就是最好的文档。

在后续随着演示 APP 的开发进展,我们会进一步介绍 Spiral 中如何获取用户请求信息,包括输入验证。但目前我们先来考虑一下响应输出的问题。

HTTP 响应

响应内容类型

在 Spiral 的控制器中,你可以返回以下类型的数据:

  • string
  • array 或者实现 JsonSerializable 接口的对象
  • 实现 Psr\Http\Message\ResponseInterface 接口的对象

Spiral 根据控制器方法返回的数据类型会自动在响应头信息加上正确的 Content-Type. 比如返回的是 string, 那么响应内容类型就是 text/html;如果返回的是 array 或者实现了 JsonSerializable 的对象,那么响应内容类型就是 application/json;如果返回的是 ResponseInterface,那么就需要自行指定 Content-Type 了,系统默认是 text/plain.

当然,与处理用户请求一样,Spiral 提供了一个便捷的对象 Spiral\Http\ResponseWrapper 来简化响应操作。另外,如果使用要使用视图模板,会用到 Spiral\Views\ViewsInterface 接口来渲染模板并返回 HTML.

ResponseWrapper

ResponseWrapper 可以在控制器构造函数或者控制器方法中自动依赖注入。它提供以下便捷方法:

  • create(int $code = 200): 创建一个 ResponseInterface 对象
  • redirect($uri, int $code = 302): 重定向
  • json($data, int $code = 200): 输出 JSON
  • attachment($filename, string $name, string $mime = 'application/octet-stream'): 下载附件
  • html(string $html, $int code = 200, string $contentType = 'text/html; charset=utf-8'): 输出 HTML

五种响应基本上满足了 WEB 应用开发的所有类型。除此之外,如果不是开发 API,一般都会需要使用到模板引擎。示例:

use Spiral\Http\ResponseWrapper;

class MyController
{
    public function myAction(ResponseWrapper $r)
    {
      $r->json(['id' => 1, 'name' => '李四'], 200);
    }
}

视图模板

Spiral 提供了自己的模板引擎 Stempler Template,这个以后再介绍,除此之外, Spiral 默认支持原生 PHP 模板,官方提供了 Twig 的集成。

要使用视图模板,可以依赖 Spiral\Views\ViewsInterface 接口:

use Spiral\Views\ViewsInterface;

class HomeController
{
    public function index(ViewsInterface $view)
    {
        // 渲染 <project>/app/views/index.{ext} 模板
        return $view->render('index');
    }

    public function list(ViewsInterface $view)
    {
        // 传递数据给模板引擎并渲染和输出
        return $view->render('list', ['articles' => $articles]);
    }
}

ViewsInterface 提供了以下方法:

  • get(string $path): 用指定路径的模板文件创建一个 ViewInterface 对象
  • compile(string $path): 为指定路径下的模板创建模板缓存
  • reset(string $path): 重置指定路径下模板的缓存
  • render(string $path, array $data = []): 渲染指定路径下的模板

通过以上介绍,即使还没有深入到请求验证、CSRF 防御、模板引擎语法等信息,但是可以发现,Spiral 的请求和响应处理功能还是非常完整和强大的。

需要了解有关 Spiral 对 HTTP 的请求和响应的处理,可以阅读请求与响应的官方文档

实现博客的列表和文章页面

上一篇文章中我们已经实现了博客的路由,也创建好了控制器。今天文章的第二部分我们要来实现博客的列表页和详情页。

模拟数据

为了在没有数据库之前模拟博客文章的查询,我们用一个 JSON 文件来提供初始数据。用到的 JSON 文件在这里

我会把这个 JSON 文件放在项目根目录下的 runtime/data.json 这个位置。下面要用到。

考虑到未来(使用真实数据库)的扩展性,以及方便测试,我决定把数据的操作封装到一个服务类里,并且把方法签名用一个接口来进行规范。这样的话控制器里只要依赖指定的接口,就可以借助 Spiral 的自动依赖注入获得真实的服务类。

这部分会涉及 Spiral 的容器和依赖注入的相关知识,暂时不做解释,先照做即可。

PostService 接口

创建一个接口声明文件 <project>/app/src/service/PostService.php:

<?php
declare(strict_types=1);

namespace App\Service;

interface PostService
{
    /**
     * 返回文章列表
     * @param int $page
     * @param int $size
     * @return array
     */
    public function getPosts(int $page = 1, int $size = 10): array;

    /**
     * 返回指定 ID 对应的文章
     * @param int $id
     * @return array|null
     */
    public function getPost(int $id): ?array;
}

在接口类中定义了两个方法,分别用来返回文章列表和具体的文章。

MemoryPostService 服务类

然后在 <project>/app/src/service/MemoryPostService.php 这个位置创建基于内存的服务类:

<?php
declare(strict_types=1);

namespace App\Service;

use Spiral\Prototype\Traits\PrototypeTrait;

class MemoryPostService implements PostService
{
    use PrototypeTrait;

    /**
     * @var array $data 文章数据
     */
    protected $data = [];

    public function __construct()
    {
        $this->data = $this->initPosts();
    }

    /**
     * @inheritDoc
     */
    public function getPosts(int $page = 1, int $size = 10): array
    {
        $offset = $size * ($page - 1);
        $offset = $offset < 0 ? 0 : $offset;
        $size = $size < 1 ? 1 : $size;
        $pages = floor(count($this->data) / $size) + 1;
        return [
            'pages' => $pages,
            'posts' => array_merge(array_slice($this->data, $offset, $size))
        ];
    }

    /**
     * @inheritDoc
     */
    public function getPost(int $id): ?array
    {
        $post = null;
        foreach ($this->data as $p) {
            if ( $p['id'] === $id) {
                $post = $p;
            }
        }
        return $post;
    }

    /**
     * 从 json 中读取初始文章数据
     * @return array
     */
    protected function initPosts(): array
    {
        $jsonData = $this->files->read(directory('runtime') . '/data.json');
        return json_decode($jsonData, true);
    }
}

这个实现类里用到了 Spiral 的原型开发辅助工具 Spiral\Prototype\Traits\PrototypeTrait, 这个 trait 可以用在任意的控制器、服务类中,能够提供访问 Spiral 组件的便利方法。在这里引入它的目的是为了使用 $this->files 这个属性。另外还用到了 directory(string $alias) 这个全局辅助方法,它是在 spiral/boot 组件中提供的,基本上只要使用 Spiral,就可以使用。

如果想了解更多关于原型开发辅助的信息,可以阅读原型开发辅助的官方文档

绑定接口与实现类

现在有了接口和它的具体实现类,我们想要实现的效果是这样:

use App\Service\PostService;

class BlogController
{
    private $service;

    public function __constructor(PostService $service)
    {
        $this->service = $service;
    }
}

也就是让 Spiral 在遇到我们依赖 PostService 的地方,就自动为我们提供一个 MemoryPostService 的实例,而且由于服务类中不存储与单次请求相关的数据以及用户数据,所以可以以单例模式常驻内存。但是 Spiral 现在还不知道要用 MemoryPostService 这个实现来满足对 PostService 这个接口的依赖。我们还需要在容器中注册他们之间的关系(这些部分如果暂时不理解,可以不用在意,只要照做即可):

首先创建一个引导程序类 <project>/app/src/Bootloader/AppBootloader.php(可以在命令行执行 php app.php create:bootloader app 让脚手架自动创建)。引导程序的代码如下:

<?php

declare(strict_types=1);

namespace App\Bootloader;

use App\Service\MemoryPostService;
use App\Service\PostService;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Core\Container;

class AppBootloader extends Bootloader
{
    public function boot(Container $container): void
    {
        $container->bindSingleton(PostService::class, MemoryPostService::class);
    }
}

然后要在 <project>/app/src/App.php 中登记我们的引导程序:

--- a/app/src/App.php
+++ b/app/src/App.php
@@ -90,5 +90,6 @@ class App extends Kernel
     protected const APP = [
         Bootloader\LocaleSelectorBootloader::class,
         Bootloader\RoutesBootloader::class,
         Bootloader\LoggingBootloader::class,

         // fast code prototyping
         Prototype\PrototypeBootloader::class,
+        Bootloader\AppBootloader::class,
     ];
 }

这样就完成了我们定义的接口和实现类的自动依赖注入关系。

如果想要进一步了解 Spiral 的容器知识,可以阅读 Spiral 依赖注入的官方文档

控制器方法

接下来更新 PostController 控制器的代码,如下:

use App\Service\PostService;
use Spiral\Prototype\Traits\PrototypeTrait;

class PostController
{
    use PrototypeTrait;

    private $service;

    public function __construct(PostService $service)
    {
        $this->service = $service;
    }

    public function getPost(int $id = null)
    {
        if (is_int($id)) { // 传入 $id 的是查看文章详情
            $post = $this->service->getPost($id);
            if (!$post) {
                // 文章不存在,返回 404
                return $this->response->html('Page not found', 404);
            } else {
                return $this->views->render('posts/single', [ 'post' => $post]);
            }
        } else { // 否则是查看文章列表
            $page = (int) $this->input->query('page', 1);
            $size = (int) $this->input->query('size', 5);
            $page = $page > 0 ? $page :  1;
            $size = $size > 0 ? $size : 10;

            // $data 包含 'posts' 和 'pages' 两个 key
            $data = $this->service->getPosts($page, $size);
            return $this->views->render('posts/list', [
                'posts' => $data['posts'],
                'pages' => $data['pages'],
                'page' => $page,
                'size' => $size
            ]);
        }
    }

    // 其它代码略...
}

这里要注意到构造函数 __construct(PostService $service), 控制器是由 Spiral 框架来进行实例化的,它遇到了参数 $service,自动根据容器中注册的绑定关系,创建一个 MemoryPostService 的实例,作为参数传入。

另外在控制器类中再次使用了 PrototypeTrait 这个 trait, 这样就可以直接使用 $this->response, $this->input, $this->views 这些属性了。当然你如果想自己按照前面所讲的,基于依赖注入获得这些对象也是可以的。

另外注意到这里通过调用 $this->views->render(string $path, array $data) 方法,分别为文章列表页和详情页渲染了模板,分别是 posts/listposts/single. 在传递模板路径时,不需要传入文件名后缀,路径是相对于 <project>/app/views/ 的相对路径。

<project/app/views/> 下的 pages 目录中,Spiral 会自动查找以下后缀的文件:

  • .php: PHP 原生模板
  • .dark.php: Stempler 模板
  • .twig: Twig 模板

在本节中,我暂时用 PHP 原生模板,所以对应的模板文件是 pages/list.phppages/single.php. 在之后的文章中,会介绍到 Stempler 模板引擎,到时候再换成对应的后缀。

渲染文章列表页时,传递了四个变量 $posts, $pages, $page, $size, 分别是包含文章的数组、总页数、当前页码、每页显示数。而传递给文章详情页的变量只有一个,就是 $post, 也是一个数组,包含一篇文章的内容。示例数据中每个文章结构如下:

$post = [
  'id' => 1, // 文章 ID
  'title' => 'xxxxx', // 文章标题
  'summary' => 'xxxxx', // 文章摘要
  'content' => '<p>...</p><p>...</p>', // 文章正文
  'tags' => 'word,word,word', // 文章标签
  'created_at' => '2006-01-02 03:15:00', // 创建时间
  'updated_at' => '2006-01-02 03:15:00' // 更新时间
]

模板实现

为了简单起见,我直接使用了 bootstrap 4.4 的博客模板。简单看一下相关的代码:

列表页模板

主要的代码如下:

<!-- 其它代码省略 -->
<div class="col-md-8 blog-main">
  <?php if ($posts): ?>
    <?php foreach ($posts as $post): ?>
      <div class="blog-post">
        <h2 class="blog-post-title"><a class="text-dark" href="/posts/<?= $post['id'] ?>"><?= $post['title'] ?></a></h2>
        <p class="blog-post-meta">更新时间:<?= $post['updated_at'] ?></p>
        <div class="blog-post-content"><p><?= $post['summary'] ?></p></div>
      </div><!-- /.blog-post -->
    <?php endforeach; ?>
    <nav class="blog-pagination">
      <?php if ($page > 1): ?>
        <a class="btn btn-outline-primary" href="/posts?page=<?= $page-1 ?>&size=<?= $size ?>">上一页</a>
      <?php else: ?>
        <a class="btn btn-outline-secondary disabled" href="#" tabindex="-1" aria-disabled="true">上一页</a>
      <?php endif; ?>
      <?php if ($page < $pages): ?>
        <a class="btn btn-outline-primary" href="/posts?page=<?= $page+1 ?>&size=<?= $size ?>">下一页</a>
      <?php else: ?>
        <a class="btn btn-outline-secondary disabled" href="#" tabindex="-1" aria-disabled="true">下一页</a>
      <?php endif; ?>
    </nav>
  <?php else: ?>
    <div class="blog-post">
      <p>暂时没有文章</p>
    </div>
  <?php endif; ?>
</div><!-- /.blog-main -->
<!-- 其它代码省略 -->

实现效果:

列表页

详情页模板

主要的代码如下:

<div class="col-md-8 blog-main">
  <?php if ($post): ?>
  <div class="blog-post">
    <h2 class="blog-post-title"><?= $post['title'] ?></h2>
    <p class="blog-post-meta">更新时间:<?= $post['updated_at'] ?></p>
    <div class="blog-post-content">
      <p><?= $post['content'] ?></p>
    </div>
  </div>
  <!-- /.blog-post -->
  <?php else: ?>
  <div class="blog-post">
    <p>文章不见了 :-(</p>
  </div>
  <?php endif; ?>
  <nav class="blog-pagination">
    <a class="btn btn-outline-primary" href="/posts">返回列表</a>
  </nav>
</div>

实现效果:

详情页

这部分就不展开说明了。详细的代码可以在 gitee 上的 spiral-demo 仓库中找到。

至此,我们简单实现了博客列表页和详情页,数据是存储在一个 JSON 文件中,在服务器启动时自动载入到内存。

在下一篇文章中,会继续介绍 Spiral 中的用户输入验证,并实现文章的创建和编辑功能。

本文首发于云加社区我的个人专栏。受云加社区协议限制,未经许可不得转载。关于 Spiral 框架的这个系列文章,都会首发云加社区,之后过一段时间才会同步到个人博客。