Spiral 官方超完整實戰教程

前言

我在寫「Spiral 詳細上手指南」的過程其實是一邊做官方文檔的翻譯,一邊驗證英文版教程中內容的正確性,一邊寫的。而且涉及到每一個模組和組件,除了在演示項目中簡單應用之外,一般還要順便把該組件做比較全面詳細地介紹。以致於進度就非常慢了。考慮到讀者「快速上手」的需求,正好官方文檔的「快速開始」文檔也完成了,乾脆我先把官方的快速開始文檔(一個簡單但完整的項目實戰)翻譯好發出來,以滿足讀者快速上手的需求。至於「Spiral 詳細上手指南」,還會隨著我的進度慢慢推進。

這篇教程很長,有四萬多字,建議讀者跟著教程一步一步地動手,到最終完成演示項目時,也就基本掌握了 Spiral 的開發流程。我在翻譯這篇文檔的時候,也是操作一步,翻譯一步,中間發現官方文檔的錯誤或者有遺漏的步驟時,都先提交 PR 修復了英文文檔。

文檔中有大量指向其它文檔的鏈接,所有已經完成翻譯的,我都指向了中文文檔的地址,還沒有完成翻譯的,只能先指向英文文檔。


Spiral 框架包含了大量的組件,這些組件各自承擔不同的職責,彼此之間相互協同,緊密合作,從而構建出各種複雜的應用。

在本文中,我會通過一個部落格應用示例,向大家演示 Rest API、ORM(對象關係映射)、migrations(數據遷移)、request validation(請求校驗)、custom annotations(可選的)以及 domain interceptors(域攔截器)的使用。

實例中對各個組件和實現方法的介紹都只停留在基礎層面,如果需要對任何一個部分進行更深入的理解,請自行閱讀文檔相應章節。

安裝

可以通過 composer 命令和官方提供的 spiral/app 包進行安裝(或者說創建項目),這個包已經為大家集成了 WEB 應用開發中可能用到的大量組件:

$ composer create-project spiral/app spiral-demo  $ cd spiral-demo

如果不需要創建完整的 WEB 應用,也可以考慮通過另外一個 spiral/app-cli 包來創建初始項目,這個包只繼承了最少的依賴項。

當項目創建完成,且所有依賴項都成功安裝之後,你可以通過下面的命令立刻啟動應用伺服器:

$ ./spiral serve -v -d

這個命令啟動了 Spiral 的應用伺服器。該伺服器同時可以用於開發和生產環境,因此開發者的開發環境與最終部署環境會非常接近。Spiral 的應用程式集成了 HTTP/2、GRPC、Queue(隊列)、WebSockets 等開發應用程式所需的工具,開箱即用,無需外部代理或其它配置。

默認情況下,通過上面的命令啟動伺服器之後,可以通過 http://localhost:8080 訪問 web 應用。初始化的項目包含了幾個預設的頁面,你可以直接使用它們,或者用它們作為參考。

訪問 http://localhost:8080/exception.html 可以看到默認的報錯頁面。在頁面右側可以看到默認項目已經集成的所有攔截器和中間件。根據你的實際情況,可以把不需要的關閉,讓應用運行時更小更省資源。

配置

Spiral 應用程式通過 app/config 目錄下的配置文件對項目進行配置。在配置文件中,你可以硬編碼配置值,當然也可以而且推薦通過 envdirectory 函數來獲得所需的敏感資訊。spiral/app 項目使用 DotEnv 擴展從項目根目錄下的 .env 文件中讀取環境變數。

.rr.yaml 文件中可以對應用伺服器及其插件的參數進行調整。

項目的依賴項定義在 composer.json 文件中,並在 app/src/App.php 文件中作為引導程式啟用。項目默認包含了大量預配置的組件。

開發模式

在開發階段,為了簡化開發調試,可以以開發模式啟動應用伺服器。在開發模式下,應用伺服器只是用一個工作進程,並在處理完每個請求之後重新載入程式碼。

$ ./spiral serve -v -d -o "http.workers.pool.maxJobs=1" -o "http.workers.pool.numWorkers=1"

當然你也可以把參數放到一個單獨的 .rr.dev.yaml 之類的文件中,並在是用 spiral 命令時通過 -c 參數指定該配置文件。

請參閱 這篇文檔 以了解更多有關工作進程和應用生命周期的資訊。

清理項目

在我們的演示應用中,不會用到 translation(多語言翻譯)、session、cookies、CSRF 和 encryption(加密)組件。所以接下來先從引導程式中移除他們。

打開 app/src/App.php 文件,找到並刪除下面列出來的程式碼(注釋是為了方便你定位程式碼,不必刪除相關的注釋行):

// Core Services  FrameworkI18nBootloader::class,    // Security and validation  FrameworkSecurityEncrypterBootloader::class,    // HTTP extensions  FrameworkHttpCookiesBootloader::class,  FrameworkHttpSessionBootloader::class,  FrameworkHttpCsrfBootloader::class,  FrameworkHttpPaginationBootloader::class,    // Views and view translation  FrameworkViewsTranslatedCacheBootloader::class,    // Application specific services and extensions  BootloaderLocaleSelectorBootloader::class,

刪除了相應的引導程式之後,如果你願意的話,也可以從 composer.json 中刪除相關的依賴項:

"spiral/cookies": "^1.0",  "spiral/csrf": "^1.0",  "spiral/session": "^1.1",  "spiral/translator": "^1.2",  "spiral/encrypter": "^1.1",

最後,還可以刪除掉默認項目自帶的以下文件或目錄:

  • app/locale
  • app/src/Bootloader/LocaleSelectorBootloader.php
  • app/src/Middleware.

提示, 現在應用程式不能工作了,因為我們剛剛刪除了渲染 app/views/home/dark.php 所需的依賴項(國際化相關的依賴)。

資料庫連接

部落格系統作為常見的資料庫驅動的應用,當然需要一個可操作的資料庫。資料庫的配置文件默認在 app/config/database.php 這個位置。新創建的項目已經配置好了一個 SQLite 資料庫,存放在 runtime/runtime.db 這個路徑下。

// app/config/database.php    use SpiralDatabaseDriver;    return [      'default'   => 'default',      'databases' => [          'default' => ['driver' => 'runtime'],      ],      'drivers'   => [          'runtime' => [              'driver'     => DriverSQLiteSQLiteDriver::class,              'options'    => [                  'connection' => 'sqlite:' . directory('runtime') . 'runtime.db',              ]          ],      ]  ];

接下來我們配置一個 MySQL 的連接(如果你沒有 MySQL 資料庫,可以跳過這部分),連接 MySQL 的資訊,最好不要直接硬編碼到配置文件中,可以存放到項目根目錄下的 .env 文件里(這個文件不要上傳到你的程式碼倉庫)。比如我們在 .env 文件中寫入以下環境變數:

DB_HOST=localhost  DB_NAME=name  DB_USER=username  DB_PASSWORD=password

請根據自己的情況修改對應的值

然後在 app/config/database.php 文件中,配置一個名為 mysql 的資料庫驅動(drivers),然後把 databases 項下面的 drivers 指向新增的 MySQL 驅動:

return [      'default'   => 'default',      'databases' => [          'default' => ['driver' => 'mysql'],      ],      'drivers'   => [           'runtime' => [              'driver'     => DriverSQLiteSQLiteDriver::class,              'options'    => [                  'connection' => 'sqlite:' . directory('runtime') . 'runtime.db',              ]          ],          'mysql' => [              'driver'     => DriverMySQLMySQLDriver::class,              'connection' => sprintf('mysql:host=%s;dbname=%s', env('DB_HOST'), env('DB_NAME')),              'username'   => env('DB_USER'),              'password'   => env('DB_PASSWORD'),          ],      ]  ];

請注意當前 default 資料庫指向了 default 連接,default 連接指向了 mysql 配置。在 Spiral 中,你可以同時配置多個資料庫驅動、同時啟用多個資料庫連接。具體請參閱資料庫相關章節的文檔。

然後通過以下命令,可以檢查資料庫連接是否配置正確:

$ php app.php db:list

如果連接配置正確,你會看到類似這樣的輸出(注意 Status 應該是 "connected"):

+------------+-----------+---------+---------+-----------+-----------+----------------+  | Name (ID): | Database: | Driver: | Prefix: | Status:   | Tables:   | Count Records: |  +------------+-----------+---------+---------+-----------+-----------+----------------+  | default    | spiral    | MySQL   | ---     | connected | no tables | no records     |  +------------+-----------+---------+---------+-----------+-----------+----------------+

有關資料庫連接的更多資訊,請查看資料庫配置文檔.

假數據

在開發階段,通常我們需要一個假數據。fzaninotto/faker 這個庫提供了強大的假數據生成功能。

首先在項目中安裝這個庫作為依賴項:

$ composer require fzaninotto/faker

為了生成數據,需要創建一個 FakerGenerator 實例,在 Spiral 中我們不必每次用到它的時候都去生成一次新的實例,可以在 app/src/Bootloader 目錄下創建一個引導程式,該引導程式在每次我們需要這個類的時候就提供一個單例對象。這裡使用的是設計模式中的工廠模式。

namespace AppBootloader;    use FakerFactory;  use FakerGenerator;  use SpiralBootBootloaderBootloader;    class FakerBootloader extends Bootloader  {      protected const SINGLETONS = [          Generator::class => [self::class, 'fakerGenerator']      ];        private function fakerGenerator(): Generator      {          return Factory::create(Factory::DEFAULT_LOCALE);      }  }

創建好引導程式之後,要把它加到 app/src/App.phpLOAD 或者 APP 常量數組中才能啟用該引導程式:

--- a/app/src/App.php  +++ b/app/src/App.php  @@ -85,5 +85,6 @@ class App extends Kernel             // fast code prototyping           PrototypePrototypeBootloader::class,  +        BootloaderFakerBootloader::class,       ];   }

fakerGenerator 這個方法上,你可以添加依賴項作為參數,Spiral 容器會自動注入依賴項。

然後我們修改一下 app/src/Controllers/HomeController.php 中的程式碼,通過 http://localhost:8080/ 來看一下假數據生成工具是否正常工作了:

namespace AppController;    use FakerGenerator;    class HomeController  {      public function index(Generator $generator)      {          return $generator->sentence(128);      }  }

一切正常的話,你會看到一大段隨機生成的文字。

要深入了解引導程式,請閱讀這篇文檔

路由

默認情況下,路由規則的定義在 app/src/Bootloader/RoutesBootloader.php 文件中。對於配置路由而言,你有很多選擇。可以把路由指向控制器、控制器方法、控制器組;可以指定默認的匹配參數……

作為實例,我們先創建一個簡單的路由,把所有 URL 都指向 AppControllerHomeController:

namespace AppBootloader;    use AppControllerHomeController;  use SpiralBootBootloaderBootloader;  use SpiralRouterRoute;  use SpiralRouterRouterInterface;  use SpiralRouterTargetController;    class RoutesBootloader extends Bootloader  {      public function boot(RouterInterface $router): void      {          $route = new Route('/[<action>[/<id>]]', new Controller(HomeController::class));          $route = $route->withDefaults(['action' => 'index']);            $router->setRoute('home', $route);      }  }

按照上面給出的配置,actionid 都是 URL 中的可選部分(使用了 []),默認的 action 值是 index。所以如果訪問 http://localhost:8080/ 或者 http://localhost:8080/index,都會執行 HomeController::index 方法。

在控制器方法中可以採用方法注入的方式,通過路由參數的名稱來訪問它們,比如在 HomeController 中增加下面的方法:

public function open(string $id)  {      dump($id);  }

然後可以通過 http://localhost:8080/open/123 來調用這個方法,id 參數會被自動注入。

有關路由配置的更多資訊,請參閱路由文檔

註解式路由

Spiral 框架默認沒有提供開箱即用的註解式路由配置。但是可以通過已有的組件進行簡單地配置來實現它

註解

我們首先要創建一個簡單的註解,稍後可以把它應用到公共控制器方法上:

namespace AppAnnotation;    use DoctrineCommonAnnotationsAnnotation;    /**   * @Annotation()   * @AnnotationTarget({"METHOD"})   * @AnnotationAttributes({   *      @AnnotationAttribute("action", type="string", required=true),   *      @AnnotationAttribute("verbs", type="array"),   * })   */  class Route  {      /** @var string */      public $action;        /** @var string[]|null */      public $verbs;  }

WEB 應用框架 apiral/app 默認已經引入並啟用了註解組件 spiral/annotations(作為 spiral/prototype 的依賴項)。

引導程式

修改 RoutesBootloader,讓它可以把註解轉換為路由。可以使用 SpiralAnnotationsAnnotationLocator 這個類來查找程式碼中可用的路由註解。

namespace AppBootloader;    use SpiralAnnotationsAnnotationLocator;  use SpiralBootBootloaderBootloader;  use SpiralRouterRoute;  use SpiralRouterRouterInterface;  use SpiralRouterTargetAction;    class RoutesBootloader extends Bootloader  {      public function boot(AnnotationLocator $annotationLocator, RouterInterface $router): void      {          $methods = $annotationLocator->findMethods(AppAnnotationRoute::class);            foreach ($methods as $method) {              $name = sprintf(                  "%s.%s",                  $method->getClass()->getShortName(),                  $method->getMethod()->getShortName()              );                $route = new Route(                  $method->getAnnotation()->action,                  new Action(                      $method->getClass()->getName(),                      $method->getMethod()->getName()                  )              );                $route = $route->withVerbs(...(array)$method->getAnnotation()->verbs);                $router->setRoute($name, $route);          }      }  }

控制器

創建好了註解,並在引導程式中實現了註解轉換為路由的邏輯之後,就可以在控制器中用註解來定義路由規則了:

namespace AppController;    use AppAnnotationRoute;    class HomeController  {      /**       * @Route(action="/", verbs={"GET"})       */      public function index()      {          return 'hello world';      }        /**       * @Route(action="/open/<id>", verbs={"GET"})       */      public function open(string $id)      {          dump($id);      }  }

可以在命令行下執行以下命令列出所有已經登記的路由:

$ php app.php route:list

上面示例中的控制器註解,執行這個命令後的輸出類似這樣:

+--------+------------+----------------------------------+  | Verbs: | Pattern:   | Target:                          |  +--------+------------+----------------------------------+  | GET    | /          | ControllerHomeController->index |  | GET    | /open/<id> | ControllerHomeController->open  |  +--------+------------+----------------------------------+

還可以使用更多的路由參數來配置中間件、通用前綴等。

在接下來的示例中,為了簡單起見,我們就一直使用註解路由來演示了。

如果你在調試過程中覺得日誌不夠詳細,可以在 App 中禁用 ErrorHandleRootLoader 來查看完整的錯誤日誌。

領域內核

連接自定義的控制器攔截器(領域內核)可以用附加功能來豐富應用的領域層。比如改變應用的默認行為、把路由參數自動解析為 Cycle 實體,進行請求參數的過濾和驗證,或者實現 @Guard 註解等。

首先創建一個引導程式 AppBootloader 來注入攔截器:

namespace AppBootloader;    use SpiralBootloaderDomainBootloader;  use SpiralCoreCoreInterface;  use SpiralDomain;    class AppBootloader extends DomainBootloader  {      protected const SINGLETONS = [          CoreInterface::class => [self::class, 'domainCore']      ];        protected const INTERCEPTORS = [          DomainFilterInterceptor::class,          DomainCycleInterceptor::class,          DomainGuardInterceptor::class,      ];  }

上面的程式碼通過 AppBootloader 引導程式在應用中啟用了一些攔截器。記得在 App.phpLOAD 或者 APP(推薦)中添加該引導程式。

    protected const APP = [          BootloaderRoutesBootloader::class,            // fast code prototyping          PrototypePrototypeBootloader::class,          BootloaderFakerBootloader::class,          BootloaderAppBootloader::class,      ];

要深入了解領域內核,可以查詢領域內核的詳細文檔

資料庫腳手架

Spiral 支援通過資料庫遷移文件來配置資料庫的結構。執行以下命令可以初始化資料庫遷移記錄表:

$ php app.php migrate:init

之後可以用以下命令來觀察資料庫遷移記錄表的的結構:

$ php app.php db:list  $ php app.php db:table migrations

你可以手工創建資料庫遷移文件,或者讓 Cycle ORM 幫你生成。

請參閱 資料庫遷移文檔,使用腳手架組件來輔助人工創建遷移文件。

定義 ORM 實體

我們的示例項目以及所有基於 spiral/app 創建的項目都自帶了 Cycle ORM 組件。默認配置下你直接就可以使用註解來配置關係對象實體。

我們先創建 Post, UserComment 三個實體以及它們之間的關係。這裡使用腳手架命令來創建:

$ php app.php create:entity post -f id:primary -f title:string -f content:text -e  $ php app.php create:entity user -f id:primary -f name:string -e  $ php app.php create:entity comment -f id:primary -f message:string

執行命令後請觀察項目下 app/src/Databaseapp/src/Repository 目錄,相關的類文件已經自動創建。

Post:

namespace AppDatabase;    use CycleAnnotatedAnnotation as Cycle;    /**   * @CycleEntity(repository = "AppRepositoryPostRepository")   */  class Post  {      /**       * @CycleColumn(type = "primary")       */      public $id;        /**       * @CycleColumn(type = "string")       */      public $title;        /**       * @CycleColumn(type = "text")       */      public $content;  }

User:

namespace AppDatabase;    use CycleAnnotatedAnnotation as Cycle;    /**   * @CycleEntity(repository = "AppRepositoryUserRepository")   */  class User  {      /**       * @CycleColumn(type = "primary")       */      public $id;        /**       * @CycleColumn(type = "string")       */      public $name;  }

Comment:

namespace AppDatabase;    use CycleAnnotatedAnnotation as Cycle;    /**   * @CycleEntity()   */  class Comment  {      /**       * @CycleColumn(type = "primary")       */      public $id;        /**       * @CycleColumn(type = "string")       */      public $message;  }

如果你不喜歡默認配置下的目錄映射關係以及自動生成的文件的頭部注釋等,可以通過 腳手架配置來調整。

關於 Cycle 的更多資訊,請參考 Cycle 文檔。使用自定義映射可以配置自動時間戳。

生成資料庫遷移文件

通過 cycle 的腳手架命令,可以自動把實體的最新修改自動生成為資料庫遷移文件:

$ php app.php cycle:migrate -v

生成的遷移文件存放在 app/migrations/ 目錄下,可以通過命令來執行資料庫遷移文件,更新已連接的資料庫結構:

$ php app.php migrate -vv

然後通過 db:list 命令就可以看到新生成的數據表。

創建實體關係

通過 關係註解 來定義實體之間的關係。配置 Post 和 Comment 屬於 User、Post 擁有多個 Comment。

Post:

namespace AppDatabase;    use CycleAnnotatedAnnotation as Cycle;  use DoctrineCommonCollectionsArrayCollection;    /**   * @CycleEntity(repository = "AppRepositoryPostRepository")   */  class Post  {      /**       * @CycleColumn(type = "primary")       */      public $id;        /**       * @CycleColumn(type = "string")       */      public $title;        /**       * @CycleColumn(type = "text")       */      public $content;        /**       * @CycleRelationBelongsTo(target = "User", nullable = false)       * @var User       */      public $author;        /**       * @CycleRelationHasMany(target = "Comment")       * @var ArrayCollection|Comment[]       */      public $comments;        public function __construct()      {          $this->comments = new ArrayCollection();      }  }

Comment:

namespace AppDatabase;    use CycleAnnotatedAnnotation as Cycle;    /**   * @CycleEntity()   */  class Comment  {      /**       * @CycleColumn(type = "primary")       */      public $id;        /**       * @CycleColumn(type = "string")       */      public $message;        /**       * @CycleRelationBelongsTo(target = "User", nullable = false)       * @var User       */      public $author;        /**       * @CycleRelationBelongsTo(target = "Post", nullable = false)       * @var Post       */      public $post;  }

實體發生變更之後,再次執行 cycle:migrate 生成遷移文件,migrate 執行遷移:

$ php app.php cycle:migrate -v  $ php app.php migrate -vv

提示:你可以通過一條命令生成遷移文件並執行文件:php app.php cycle:migrate -r.

我們可以通過 db:table 命令來看一下自動生成的外鍵:

$ php app.php db:table comments

切記:修改了實體之後,一定要記得執行 php app.php cycle:migrate

服務

我們把業務邏輯單獨封裝到服務層,與數據層隔離開。先在 app/src/Service 目錄下創建 PostService 服務類。在服務類中,需要用到實現 CycleORMTransactionInterface 介面的實例來進行 post 的數據持久化。

namespace AppService;    use AppDatabasePost;  use AppDatabaseUser;  use CycleORMTransactionInterface;    class PostService  {      private $tr;        public function __construct(TransactionInterface $tr)      {          $this->tr = $tr;      }        public function createPost(User $user, string $title, string $content): Post      {          $post = new Post();          $post->author = $user;          $post->title = $title;          $post->content = $content;            $this->tr->persist($post);          $this->tr->run();            return $post;      }  }

你可以在 run 方法後重用事務。

原型開發輔助

Spiral 框架強大功能的其中一項就是它的 原型開發輔助。給 PostService 類加一個原型註解,指定 postService 指向 PostService:

namespace AppService;    use AppDatabasePost;  use AppDatabaseUser;  use CycleORMTransactionInterface;  use SpiralPrototypeAnnotationPrototyped;    /**   * @Prototyped(property="postService")   */  class PostService  {      // ...  }

同樣的給 app/src/Repository/UserRepository.php 中的 UserRepository 也加一個原型註解,指定 users 指向 UserRepository:


更新了原型開發輔助相關的配置後,要執行配置命令來收集所有可用的原型類:

$ php app.php configure

要獲得 IDE 智慧提示,需要使用支援的 IDE,比如 Jetbrains PHPStorm.

經過上述配置和操作,現在通過引入 PrototypeTrait,就可以在類中直接訪問到 PostService 對象了(見下面的示例)。

控制台命令

經過上面的步驟,我們已經創建了數據實體、實體關係、數據訪問倉庫、服務等,但是還沒有可用的假數據呢。所以,接下來我們先創建三個命令,用來生成假數據:

$ php app.php create:command seed/user seed:user  $ php app.php create:command seed/post seed:post  $ php app.php create:command seed/comment seed:comment

生成的命令類,文件都在 app/src/Command/Seed 目錄下。

UserCommand

在生成的 "UserCommand" 類中,可以在 perform 方法上使用方法注入需要的對象,比如 FakerGenerator,然後用它來生成假數據:

// app/src/Command/Seed/UserCommand.php  namespace AppCommandSeed;    use AppDatabaseUser;  use CycleORMTransactionInterface;  use FakerGenerator;  use SpiralConsoleCommand;    class UserCommand extends Command  {      protected const NAME = 'seed:user';        protected function perform(TransactionInterface $tr, Generator $faker): void      {          for ($i = 0; $i < 100; $i++) {              $user = new User();              $user->name = $faker->name;                $tr->persist($user);          }            $tr->run();      }  }

然後執行創建好的命令:

$ php app.php seed:user

PostCommand

同樣地,使用 PostCommand 來生成假的文章數據,不過,在這個命令里,我們會使用原型開發輔助提供的屬性來代替方法注入。通過原型開發輔助,可以大大提升我們的開發效率。請注意下面的示例程式碼中,我們直接通過類屬性來訪問 postServiceusers(對應 UserRepository)。

如果你的 IDE 沒有智慧提示倉庫類或者其它服務,請執行 php app.php configure.

// app/src/Command/Seed/PostCommand.php  namespace AppCommandSeed;    use FakerGenerator;  use SpiralConsoleCommand;  use SpiralPrototypeTraitsPrototypeTrait;    class PostCommand extends Command  {      use PrototypeTrait;        protected const NAME = 'seed:post';        protected function perform(Generator $faker): void      {          $users = $this->users->findAll();            for ($i = 0; $i < 1000; $i++) {              $user = $users[array_rand($users)];                $post = $this->postService->createPost(                  $user,                  $faker->sentence(12),                  $faker->text(900)              );                $this->sprintf("New post: <info>%s</info>n", $post->title);          }      }  }

然後在控制台執行 seed:post 命令,加上 -vv 參數可以觀察到 SQL 查詢:

$ php app.php seed:post -vv

開發完成以後,通過腳手架命令可以自動從源程式碼中移除原型開發輔助:

$ php app.php prototype:inject -r

這條命令會自動修改使用了 PrototypeTrait 的類,修改後的程式碼如下:

namespace AppCommandSeed;    use AppRepositoryUserRepository;  use AppServicePostService;  use FakerGenerator;  use SpiralConsoleCommand;    class PostCommand extends Command  {      protected const NAME = 'seed:post';        /** @var UserRepository */      private $users;        /** @var PostService */      private $postService;        /**       * @param UserRepository $users2       * @param PostService    $postService       * @param string|null    $name       */      public function __construct(UserRepository $users2, PostService $postService, ?string $name = null)      {          parent::__construct($name);          $this->postService = $postService;          $this->users = $users2;      }        protected function perform(Generator $faker): void      {          $users = $this->users->findAll();            for ($i = 0; $i < 1000; $i++) {              $user = $users[array_rand($users)];                $post = $this->postService->createPost(                  $user,                  $faker->sentence(12),                  $faker->text(900)              );                $this->sprintf("New post: <info>%s</info>n", $post->title);          }      }  }

在開發階段,你可以在任意程式碼中使用原型開發輔助,然後在最終上線前通過命令批量移除開發輔助,還可以從項目中刪除掉整個擴展組件。

CommentCommand

生成 comment 假數據時,需要隨機指定 user 和 post 來生成關聯的評論。可以通過方法注入來獲得需要的實例對象。

namespace AppCommandSeed;    use AppDatabaseComment;  use AppRepositoryPostRepository;  use AppRepositoryUserRepository;  use CycleORMTransactionInterface;  use FakerGenerator;  use SpiralConsoleCommand;    class CommentCommand extends Command  {      protected const NAME = 'seed:comment';        protected function perform(          Generator $faker,          TransactionInterface $tr,          UserRepository $userRepository,          PostRepository $postRepository      ): void {          $users = $userRepository->findAll();          $posts = $postRepository->findAll();            for ($i = 0; $i < 1000; $i++) {              $user = $users[array_rand($users)];              $post = $posts[array_rand($posts)];                $comment = new Comment();              $comment->author = $user;              $comment->post = $post;              $comment->message = $faker->sentence(12);                $tr->persist($comment);              $tr->run();          }      }  }

同樣地,執行 seed:comment 命令生成評論數據:

$ php app.php seed:comment -vv

控制器

作為 Restful API 應用,我們需要創建一系列 REST 端點來提供訪問數據的 API。首先創建一個簡單的控制器,AppControllerPostController, 可以通過腳手架命令來快速創建:

$ php .app.php create:controller post -a test -a get -p 

提示: -a 選項可以預創建控制器方法,-p 選項可以預載入原型開發輔助擴展。

生成的程式碼如下:

namespace AppController;    use SpiralPrototypeTraitsPrototypeTrait;    class PostController  {      use PrototypeTrait;        public function test()      {      }        public function get()      {      }  }

測試方法

在 Spiral 的控制器方法中,你可以返回不同類型的數據。以下這些類型的數據都是有效的:

  • string(字元串)
  • PSR-7 Response(PSR-7 規範定義的 Response 對象)
  • array(數組會自動作為 JSON 響應給用戶)
  • JsonSerializable(可以序列化為 JSON 字元串的對象)

除了上述默認支援的類型外,還可以通過自定義的領域內核執行自己定義的響應格式轉換。在使用了原型開發輔助之後,也可以藉由 $this->response 輔助屬性把數據寫入到標準的 PSR-7 響應對象。

為了演示返回數組的實現,我們在返回數據中加了一個 status 鍵,代表響應狀態。

/**   * @Route(action="/api/test/<id>", verbs={"GET"})   * @param string $id   * @return array   */  public function test(string $id)  {      return [          'status' => 200,          'data'   => [              'id' => $id          ]      ];  }

在瀏覽器中打開 http://localhost:8080/api/test/123 可以看到輸出的 JSON 數據。

上面這種方式,我們無法控制 HTTP 響應的狀態碼,響應的數據里的 status 只是 JSON 數據里的響應狀態,而 HTTP 響應狀態碼始終是 200. 因此我們還有另外的方法,比如使用 ResponseWrapper 輔助類:

use PsrHttpMessageResponseInterface;    // ...    /**   * @Route(action="/api/test/<id>", verbs={"GET"})   * @param string $id   * @return ResponseInterface   */  public function test(string $id): ResponseInterface  {      return $this->response->json(          [              'data' => [                  'id' => $id              ]          ],          200      );  }

這裡向 json 函數傳入的第二個參數值 200 就指定了本次響應的狀態碼。

這個 test 方法僅僅作為這裡的演示,後續不再需要。

獲取文章數據

要從資料庫里查詢 post 數據,需要 PostRepository,可以在控制器的構造函數、get 方法中通過方法注入來獲得它的實例,也可以通過原型開發輔助提供的 posts 縮寫(前文有相關介紹)。而需要用戶提供的文章 id,可以通過路由參數訪問到:

namespace AppController;    use AppAnnotationRoute;  use AppDatabasePost;  use SpiralHttpExceptionClientExceptionNotFoundException;  use SpiralPrototypeTraitsPrototypeTrait;    class PostController  {      use PrototypeTrait;        /**       * @Route(action="/api/post/<id:d+>", verbs={"GET"})       * @param string $id       * @return array       */      public function get(string $id)      {          /** @var Post $post */          $post = $this->posts->findByPK($id);          if ($post === null) {              throw new NotFoundException("post not found");          }            return [              'post' => [                  'id'      => $post->id,                  'author'  => [                      'id'   => $post->author->id,                      'name' => $post->author->name                  ],                  'title'   => $post->title,                  'content' => $post->content,              ]          ];      }  }

由於前面我們有配置領域內核,啟用了 CycleInterceptor 攔截器,因此上面的方法也可以進一步簡化,直接在方法注入中依賴 PostCycleInterceptor 會用 id 進行查詢並將對應的 post 注入到我們的方法中:

namespace AppController;    use AppAnnotationRoute;  use AppDatabasePost;  use SpiralPrototypeTraitsPrototypeTrait;    class PostController  {      use PrototypeTrait;        /**       * @Route(action="/api/post/<post:d+>", verbs={"GET"})       * @param Post $post       * @return array       */      public function get(Post $post)      {          return [              'post' => [                  'id'      => $post->id,                  'author'  => [                      'id'   => $post->author->id,                      'name' => $post->author->name                  ],                  'title'   => $post->title,                  'content' => $post->content,              ]          ];      }  }

實際開發中可以考慮使用視圖對象把響應的數據映射到 JsonSerializable 形式。

Post 視圖映射

要把數據對象映射到 JSON 格式,可以使用已有的解決方案(例如 jms/serializer),或者編寫自己的映射實現。下面我們演示一下如何創建一個視圖對象把 post 數據轉換為 JSON 格式,別忘了之前的知識:通過注釋中的 Prototyped 註解可以簡化我們的開發。

namespace AppView;    use AppDatabasePost;  use PsrHttpMessageResponseInterface;  use SpiralCoreContainerSingletonInterface;  use SpiralPrototypeAnnotationPrototyped;  use SpiralPrototypeTraitsPrototypeTrait;    /**   * @Prototyped(property="postView")   */  class PostView implements SingletonInterface  {      use PrototypeTrait;        public function map(Post $post): array      {          return [              'post' => [                  'id'      => $post->id,                  'author'  => [                      'id'   => $post->author->id,                      'name' => $post->author->name                  ],                  'title'   => $post->title,                  'content' => $post->content,              ]          ];      }        public function json(Post $post): ResponseInterface      {          return $this->response->json($this->map($post), 200);      }  }

執行 php app.php configure 命令註冊原型開發輔助類,並生成 IDE 程式碼提示。

然後修改控制器的程式碼使用剛才創建的 PostView:

namespace AppController;    use AppAnnotationRoute;  use AppDatabasePost;  use PsrHttpMessageResponseInterface;  use SpiralPrototypeTraitsPrototypeTrait;    class PostController  {      use PrototypeTrait;        /**       * @Route(action="/api/post/<post:d+>", verbs={"GET"})       * @param Post $post       * @return ResponseInterface       */      public function get(Post $post): ResponseInterface      {          return $this->postView->json($post);      }  }

你可以通過瀏覽器來驗證,這裡對程式碼的重構並沒有改變 API 的行為。

獲取文章列表

我們可以直接通過實體倉庫(repository)來載入多個文章。在載入多個文章時,可以考慮同時載入可用的文章(post)和它們的作者(post)以減少查詢數量。

先在 PostRepository 中創建一個 findAllWithAuthors 方法:

namespace AppRepository;    use CycleORMSelect;  use CycleORMSelectRepository;    class PostRepository extends Repository  {      public function findAllWithAuthor(): Select      {          return $this->select()->load('author');      }  }

然後在 PostController 中創建一個 list 方法來調用它:

/**       * @Route(action="/api/post", verbs={"GET"})       * @return array       */      public function list(): array      {          $posts = $this->posts->findAllWithAuthor();            return [              'posts' => array_map([$this->postView, 'map'], $posts->fetchAll())          ];      }

你可以訪問 http://localhost:8080/api/post 查看包含所有文章的 JSON 數據。

數據網格(Data Grid)

上面的實現方案有個很大的問題,因為你必須手動對結果進行分頁、篩選和排序操作。

不過不用擔心,Spiral 提供了數據網格組件(Data Grid Component)來提供數據格式化的操作:

$ composer require spiral/data-grid-bridge

在應用中激活 SpiralDataGridBootloaderGridBootloader 引導程式(參見前文介紹)。

要使用數據網格,首先要指定數據格式,創建 AppViewPostGrid 類:

namespace AppView;    use SpiralDataGridGridSchema;  use SpiralDataGridSpecificationFilterEquals;  use SpiralDataGridSpecificationPaginationPagePaginator;  use SpiralDataGridSpecificationSorterSorter;  use SpiralDataGridSpecificationValueIntValue;  use SpiralPrototypeAnnotationPrototyped;    /**   * @Prototyped(property="postGrid")   */  class PostGrid extends GridSchema  {      public function __construct()      {          $this->addFilter('author', new Equals('author.id', new IntValue()));            $this->addSorter('id', new Sorter('id'));          $this->addSorter('author', new Sorter('author.id'));            // default limit, available limits          $this->setPaginator(new PagePaginator(10, [10, 20, 50]));      }  }

在上面的示例中,我們為數據網格定義了一個篩選欄位、兩個排序欄位,也指定了分頁的實現和分頁限制。

然後通過 SpiralDataGridGridFactory 把引導程式與方法連接起來:

/**   * @Route(action="/api/post", verbs={"GET"})   * @param GridFactory $grids   * @return array   */  public function list(GridFactory $grids): array  {      $grid = $grids->create($this->posts->findAllWithAuthor(), $this->postGrid);        return [          'posts' => array_map(              [$this->postView, 'map'],              iterator_to_array($grid->getIterator())          )      ];  }

如果剛才還沒有做的話,別忘了每次添加 prototyped 類之後都要執行 php app.php configure

數據網格是一個擴展性極強的組件,有大量可以個訂製化的選項。默認配置下,數據網格組件從用戶請求的查詢字元串和請求數據中讀取需要的參數值。

網址

說明

http://localhost:8080/api/post?paginate[page]=2

返回第二頁數據,每頁顯示 10 條(上面指定的默認值)

http://localhost:8080/api/post?paginate[page]=2&paginate[limit]=20

返回第二頁數據,每頁顯示 20 條

http://localhost:8080/api/post?sort[id]=desc

按照 post->id 倒序排列

http://localhost:8080/api/post?sort[author]=asc

按照 post->author->id 正序排列

http://localhost:8080/api/post?filter[author]=1

只返回指定 author->id 相關的 post

在同一個請求 URL 中可以同時使用排序、篩選和分頁,也可以一次應用多個篩選條件。

校驗請求

在互動式程式開發中,永遠不要忘記 校驗/驗證 用戶輸入的數據。在 Spiral 中你可以使用更底層的 ValidationInterface 或者 請求過濾(Request Filters) 來對用戶輸入進行過濾、驗證和映射。

我們的示例應用允許用戶提交評論,通過腳手架命令創建一個 CommentFilter 類:

$ php app.php create:filter comment

按照下面的示例來配置過濾類:

namespace AppFilter;    use SpiralFiltersFilter;    class CommentFilter extends Filter  {      protected const SCHEMA = [          'message' => 'data:message'      ];        protected const VALIDATES = [          'message' => ['notEmpty']      ];        protected const SETTERS = [          'message' => 'strval'      ];        public function getMessage(): string      {          return $this->message;      }  }

評論服務類

創建 AppServiceCommentService:

namespace AppService;    use AppDatabaseComment;  use AppDatabasePost;  use AppDatabaseUser;  use CycleORMTransactionInterface;  use SpiralPrototypeAnnotationPrototyped;    /**   * @Prototyped(property="commentService")   */  class CommentService  {      private $tr;        public function __construct(TransactionInterface $tr)      {          $this->tr = $tr;      }        public function comment(Post $post, User $user, string $message): Comment      {          $comment = new Comment();          $comment->post = $post;          $comment->author = $user;          $comment->message = $message;            $this->tr->persist($comment);          $this->tr->run();            return $comment;      }  }

控制器方法

PostController 控制器中定義發表評論的 comment 方法,通過方法注入獲得請求過濾類的實例。由於之前我們在領域內核中配置了 FilterInterceptor 攔截器,Spiral 框架會保證過濾器的有效性。

參考下面的程式碼來實現 comment 端點,可以對指定的文章發表評論:

/**   * @Route(action="/api/post/<post:d+>/comment", verbs={"POST"})   * @param Post          $post   * @param CommentFilter $commentFilter   * @return array   */  public function comment(Post $post, CommentFilter $commentFilter)  {      $this->commentService->comment(          $post,          $this->users->findOne(), // todo: use current user          $commentFilter->getMessage()      );        return ['status' => 201];  }

如果需要實現 HTML 格式的 AJAX 原生表單,可以使用 spiral/toolkit 擴展。

執行和驗證

我們直接通過 curl 命令來檢查我們的 API 功能是否正確:

$ curl -X POST -H 'content-type: application/json' --data '{}' http://localhost:8080/api/post/1/comment

響應內容:

{"status":400,"errors":{"message":"This value is required."}}

或者當指定 ID 的文章不存在時響應 404:

$ curl -X POST -H 'content-type: application/json' --data '{"message":"some comment"}' http://localhost:8080/api/post/9999/comment

要獲取 JSON 格式的錯誤響應,別忘了在請求頭資訊加上 accept: application/json.

發表一個有效的評論:

$ curl -X POST -H 'content-type: application/json' --data '{"message": "first comment"}' http://localhost:8080/api/post/1/comment

關於請求過濾的更多資訊,請閱讀相關文檔. 也可以通過改變腳手架的配置來修改生成的請求處理邏輯或者默認命名空間。

渲染模板

雖然我們的演示程式是設計 Restful API 應用,不過作為教程,還是向大家介紹一下視圖模板渲染的方法。

在 Spiral 中,可以使用 視圖組件 和 Stempler 模板引擎來生成 HTML 頁面。在渲染列表頁時可以直接把數據網格對象傳遞給模板。

先創建一個控制器方法來處理 http://localhost:8080/posts 的請求:

/**   * @Route(action="/posts", verbs={"GET"})   * @param GridFactory $grids   * @return string   */  public function all(GridFactory $grids): string  {      $grid = $grids->create($this->posts->findAllWithAuthor(), $this->postGrid);        return $this->views->render('posts', ['posts' => $grid]);  }

創建布局

創建或者編輯位於 app/views/layout/base.dark.php 的布局文件:

<!DOCTYPE html>  <html>  <head>      <title>${title}</title>      <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">  </head>  <body>  <div class="container">  <block:body/>  </div>  </body>  </html>

文章列表頁面

然後在 app/views/posts.dark.php 創建一個視圖模板,繼承剛才的布局。

<extends:layout.base title="Posts"/>    <define:body>      @foreach($posts as $post)          <div class="post">              <div class="title">{{$post->title}}</div>              <div class="author">{{$post->author->name}}</div>          </div>      @endforeach  </define:body>

現在打開 http://localhost:8080/posts 就能看到文章列表,可以參考之前講數據網格時介紹的URL查詢參數來控制數據的篩選、排序、分頁(比如 http://localhost:8080/posts?paginate[page]=2

文章詳情頁

要實現查看某篇文章和它的評論,在 PostController 控制器中創建一個新的控制器方法,通過數據倉庫類手動載入文章並預載入它的作者和評論資訊。

use SpiralHttpExceptionClientExceptionNotFoundException;  // ...    /**   * @Route(action="/post/<id:d+>", verbs={"GET"})   * @param string $id   * @return string   */  public function view(string $id): string  {      $post = $this->posts->findOneWithComments($id);      if ($post === null) {          throw new NotFoundException();      }        return $this->views->render('post', ['post' => $post]);  }

調用的倉庫方法 findOneWithComments($id) 程式碼如下:

public function findOneWithComments(string $id): ?Post  {      return $this          ->select()          ->wherePK($id)          ->load('author')          ->load(              'comments.author',              [                  'load' => function (SelectQueryBuilder $q) {                      // last comments at top                      $q->orderBy('id', 'DESC');                  }              ]          )          ->fetchOne();  }

類似地,創建 app/views/post.dark.php 模板:

<extends:layout.base title="Posts"/>    <define:body>      <div class="post">          <div class="title">{{$post->title}}</div>          <div class="author">{{$post->author->name}}</div>      </div>      <div class="comments">          @foreach($post->comments as $comment)              <div class="comment">                  <div class="message">{{$comment->message}}</div>                  <div class="author">{{$comment->author->name}}</div>              </div>          @endforeach      </div>  </define:body>

打開地址 http://localhost:8080/post/1 查看效果。

網頁的樣式以及評論的時間顯示就留給讀者自己完成了。

路由

在之前有關註解式路由的部分,我們在路由引導程式 RoutesBootloader 中為每個註解式路由都按照 ControllerName.methodName 的格式做了命名。所以在模板中要生成指向 PostControllerview 方法的鏈接時,對應的路由名稱為 PostController.view:

<extends:layout.base title="Posts"/>    <define:body>     @foreach($posts as $post)         <div class="post">             <div class="title">                 <a href="@route('PostController.view', ['id' => $post->id])">{{$post->title}}</a>             </div>             <div class="author">{{$post->author->name}}</div>         </div>     @endforeach  </define:body>

有關 Stempler 模板引擎的指令,請閱讀 Stempler 的文檔

後續步驟

Spiral 框架為你提供了大量預先構建好的強大功能。請參考以下文檔以了解更多:

教程源碼

本教程的示例項目的最終源碼:https://github.com/spiral/demo

程式碼下載到本地之後,別忘記先執行以下命令:

$ composer install  $ vendor/bin/spiral get  $ php app.php configure