Spiral 詳細上手指南之請求和響應

Spiral 詳細上手指南系列文章的程式碼託管在 gitee 倉庫,每一篇文章結束時的程式碼都會在一個單獨的分支下,本篇文章對應的程式碼分支是 step-3.

在上一篇《Spiral 詳細上手指南之路由規則》中,相信大家對於 Spiral 框架中的路由配置已經完全掌握了。不過在文章結束的時候,我們創建的控制器針對各種請求只是簡單地響應一串文字證明路由生效了。這次我們就來實現其中的文章列表和文章詳情。

HTTP 請求

PSR-7 標準消息介面

由於 Spiral 是完全遵循 PSR-7 消息介面規範的,因此我們在控制器方法(重要提示:不允許在控制器構造函數中注入 ServerRequestInterface)中藉助自動依賴注入,獲得 PsrHttpMessageServerRequestInterface 介面,就可以用它獲取客戶端的響應數據:

use PsrHttpMessageServerRequestInterface;    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 提供了一個可以常駐記憶體的,提供更多便捷方法和屬性的 SpiralHttpRequestInputManager 對象以供使用。

InputManager

InputManager 可以當作一個請求處理服務來用,它可以而且應該以單例模式實現,我們可以在控制器方法中或者控制器構造函數中依賴注入它:

use SpiralHttpRequestInputManager;    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 介面的對象
  • 實現 PsrHttpMessageResponseInterface 介面的對象

Spiral 根據控制器方法返回的數據類型會自動在響應頭資訊加上正確的 Content-Type. 比如返回的是 string, 那麼響應內容類型就是 text/html;如果返回的是 array 或者實現了 JsonSerializable 的對象,那麼響應內容類型就是 application/json;如果返回的是 ResponseInterface,那麼就需要自行指定 Content-Type 了,系統默認是 text/plain.

當然,與處理用戶請求一樣,Spiral 提供了一個便捷的對象 SpiralHttpResponseWrapper 來簡化響應操作。另外,如果使用要使用視圖模板,會用到 SpiralViewsViewsInterface 介面來渲染模板並返回 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 SpiralHttpResponseWrapper;    class MyController  {      public function myAction(ResponseWrapper $r)      {        $r->json(['id' => 1, 'name' => '李四'], 200);      }  }

視圖模板

Spiral 提供了自己的模板引擎 Stempler Template,這個以後再介紹,除此之外, Spiral 默認支援原生 PHP 模板,官方提供了 Twig 的集成。

要使用視圖模板,可以依賴 SpiralViewsViewsInterface 介面:

use SpiralViewsViewsInterface;    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 AppService;    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 AppService;    use SpiralPrototypeTraitsPrototypeTrait;    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 的原型開發輔助工具 SpiralPrototypeTraitsPrototypeTrait, 這個 trait 可以用在任意的控制器、服務類中,能夠提供訪問 Spiral 組件的便利方法。在這裡引入它的目的是為了使用 $this->files 這個屬性。另外還用到了 directory(string $alias) 這個全局輔助方法,它是在 spiral/boot 組件中提供的,基本上只要使用 Spiral,就可以使用。

如果想了解更多關於原型開發輔助的資訊,可以閱讀原型開發輔助的官方文檔

綁定介面與實現類

現在有了介面和它的具體實現類,我們想要實現的效果是這樣:

use AppServicePostService;    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 AppBootloader;    use AppServiceMemoryPostService;  use AppServicePostService;  use SpiralBootBootloaderBootloader;  use SpiralCoreContainer;    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 = [           BootloaderLocaleSelectorBootloader::class,           BootloaderRoutesBootloader::class,           BootloaderLoggingBootloader::class,             // fast code prototyping           PrototypePrototypeBootloader::class,  +        BootloaderAppBootloader::class,       ];   }

這樣就完成了我們定義的介面和實現類的自動依賴注入關係。

如果想要進一步了解 Spiral 的容器知識,可以閱讀 Spiral 依賴注入的官方文檔

控制器方法

接下來更新 PostController 控制器的程式碼,如下:

use AppServicePostService;  use SpiralPrototypeTraitsPrototypeTrait;    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 中的用戶輸入驗證,並實現文章的創建和編輯功能。