如何使用 Zend Expressive 建立 NASA 圖片庫?

OneAPM官方技術部落格發表於2015-12-10

在本文中,我們將藉助 NASA 天文相簿 API,使用 Zend Expressive 建立圖片庫。最後的結果將顯示在 AstroSplash 網站,該網站是為了文字特意搭建的。本文系 OneAPM 工程師編譯整理。

Zend Expressive 是用於建立 PSR-7 中介軟體程式的全新微框架。微框架相較於全棧框架的好處在於更小、更快、更加靈活,適用於設計應用時無需多餘幫助、喜歡使用單獨元件靈活建立應用的開發老手。

中介軟體一詞將在本文中多次出現。其完善定義可在 Zend Expressive 文件 中找到:

中介軟體是位於請求與響應間的任意程式碼。通常,中介軟體負責分析請求以收集輸入資料,將資料分配給其他層進行處理,之後建立並返回響應。

從2013年開始,StackPHP 為 PHP 開發者提供了建立中介軟體的方法。然而,StackPHP 定義的中介軟體與本文將會提到的中介軟體有些不同。考慮到本文的意圖,兩者的相容性只在理論層面有效。

如果你仍感到困惑,無需擔心。所有的概念都會輔之以詳盡的例子,讓我們馬上動手建立應用吧。

應用簡介

我們即將建立的應用會用到 NASA 為其天文相簿網站提供的 API,該網站提供了許多美輪美奐的天文圖片,雖然現在看來有些過時。只要花一些功夫,我們就能用這個 API 創造一個方便瀏覽的圖片庫。

在閱讀本文時,你也可以參考 GitHub 中的 AstroSplash 公共資源庫。該庫包含本應用的完整原始碼,而應用的最終效果則在 astrosplash.com 呈現。

建立 Zend Expressive 專案

為了快速搭建開發環境,建議(但非必須)使用 Homestead Improved Vagrant 虛擬機器

Zend Expressive 提供了一個非常實用的專案框架安裝程式,可用於配置框架及所選的元件。使用下面的 composer 命令,開始建立應用:

```

composer create-project -s rc zendframework/zend-expressive-skeleton

```

此處,需要將 <project-directory> 替換為之後安裝 Zend Expressive 的目錄。在使用 Homestead Improved Vagrant 虛擬機器時,此處應為 Project,命令將在 Code 目錄下執行。如果安裝程式發現 Project 目錄已經存在,會刪除原有目錄,再重新執行該命令。

安裝程式會讓我們選擇框架支援的不同元件。大部分情況下,我們會選擇預設設定,使用 FastRoute、Zend ServiceManager 與 Whoops 錯誤處理器。模板引擎沒有預設選項,我們將使用 Plates。

現在,如果我們在瀏覽器中載入該應用,就能看到歡迎我們使用 Zend Expressive 的頁面了。 大概瀏覽一下自動建立的文件,特別是 config 目錄。該目錄包含了 Zend ServiceManager 建立容器所需的資料,而容器正是 Zend Expressive 應用的核心。

接著,我們得刪除所有不需要的示例程式碼。轉入專案目錄,執行以下命令: ```

rm public/favicon.ico

rm public/zf-logo.png

rm src/Action/*

rm test/Action/*

rm templates/app/*

rm templates/layout/*

```

配置容器

容器是應用的關鍵,它會包含路徑、中介軟體定義,服務以及應用的其餘配置。

很快,我們就得為應用的索引頁動作建立服務。在此之前,讓我們學習一下 Zend Expressive 文件中的服務命名策略

我們建議在選擇服務名時,儘量使用完整的類名。唯一的例外是:當某個服務實現了用於 typehints 的介面時,選用介面名。

基於這一策略,開啟 config/autoload/dependencies.global.php,用以下程式碼替換其內容:

```

return [

'dependencies' => [

    'factories' => [

        Zend\Expressive\Application::class => Zend\Expressive\Container\ApplicationFactory::class,

    ],

],

];

`` 此處,我們刪除了invokables` 鍵,因為在應用中無需定義此類服務。Invokable 服務無需建構函式引數即可例項化。

首先建立的服務是應用服務。如果你看一下前端控制器 (public/index.php),就會發現該控制器從容器中呼叫應用服務以執行應用。該服務包含依賴關係,我們必須在 factories 鍵下列出。這樣,相當於告訴 Zend ServiceManager 它必須使用指定的 factory 類來建立服務。Zend Expressive 還提供了許多 factories 用於建立核心服務。

接下來,開啟 config/autoload/routes.global.php,用以下程式碼替換其內容: ```

return [

'dependencies' => [

    'invokables' => [

        Zend\Expressive\Router\RouterInterface::class => Zend\Expressive\Router\FastRouteRouter::class,

    ],

    'factories' => [

        App\Action\IndexAction::class => App\Action\IndexFactory::class,

    ]

],



'routes' => [

    [

        'name' => 'index',

        'path' => '/',

        'middleware' => App\Action\IndexAction::class,

        'allowed_methods' => ['GET'],

    ],

],

];

```

dependencies 鍵下的第一個條目告訴框架,它會例項化 FastRoute adapter 類以建立 router 物件,無需傳入建構函式引數。factories 鍵下的條目用於索引操作服務。我們會在下一節為該服務及其 factory 填寫程式碼。

routes 鍵會由 Zend Expressive 載入 router,且需包含一組 route 描述符。在我們定義的單一 route 描述符中,path 鍵與索引 route 的條目相符,middleware 鍵會告訴框架將哪個服務作為處理程式, allowed_methods 鍵則會指定允許的 HTTP 方法。將 allowed_methods 設定為Zend\Expressive\Router\Route::HTTP_METHOD_ANY ,即為允許任意的 HTTP 方法。

Route 中介軟體

下面將建立在 routes 配置檔案中與索引 route 關聯的索引操作服務。操作類套用 Zend Expressive 中 route 中介軟體的形式,也即用於繫結至特定 routes 的中介軟體。

操作類將位於專案根目錄的 src/Action/IndexAction.php。其內容如下: ```

namespace App\Action;

use Psr\Http\Message\ServerRequestInterface;

use Psr\Http\Message\ResponseInterface;

use Zend\Expressive\Template\TemplateRendererInterface;

use Zend\Stratigility\MiddlewareInterface;

class IndexAction implements MiddlewareInterface

{

private $templateRenderer;

public function __construct(TemplateRendererInterface $templateRenderer)

{

    $this->templateRenderer = $templateRenderer;

}

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next = null)

{

    $html = $this->templateRenderer->render('app::index');

    $response->getBody()->write($html);

    return $response->withHeader('Content-Type', 'text/html');

}

}

```

此處,我們使用依賴注入獲取模板渲染器介面的實現。之後,我們需要為處理該依賴注入建立 factory 類。

__invoke 魔術方法的出現使該類變成可呼叫的。呼叫時,以 PSR-7 訊息為引數。由於所有的索引請求都由該中介軟體處理,我們無需呼叫鏈中其他的中介軟體,可以直接返回響應。此處用於標識可呼叫中介軟體的簽名非常常見:

```

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next = null);

```

用此模式建立的中介軟體,PSR-7 中介軟體排程器 Relay 也會支援。相應地,用於 Slim v3 框架——另一種 PSR-7 中介軟體框架的中介軟體也與 Zend Expressive 相容。Slim 現在提供的中介軟體可用於 CSRF 保護HTTP 快取

當操作被呼叫時,它會渲染 app::index 模板,將其寫入響應中,並以 text/html 內容型別返回該響應。由於 PSR-7 訊息是不可變的,每次給響應新增 header ,必須建立一個新的響應物件。原因在 PSR-7 規範 meta 文件中有說明。

接下來要寫容器賴以例項化索引操作類的 factory 類。factory 類將位於專案根目錄的 src/Action/IndexFactory.php。其內容如下:

```

use Interop\Container\ContainerInterface;

use Zend\Expressive\Template\TemplateRendererInterface;

class IndexFactory

{

public function __invoke(ContainerInterface $container)

{

    $templateRenderer = $container->get(TemplateRendererInterface::class);

    return new IndexAction($templateRenderer);

}

}

`` 再一次地,使用__invoke` 魔術方法將該類變成可呼叫的。容器會呼叫該類,傳入自身例項作為唯一引數。之後,可使用該容器獲得模板渲染器服務的實現,將之注入操作並返回。此處,可以仔細看看容器的配置,從而瞭解其中原理。

模板

現在,唯一缺少的元件就是模板了。在之前的索引操作中,我們向模板渲染器索取 app::index 模板,但是該模板還未建立。Zend Expressive 使用 namespace::template 註釋指代模板。在容器配置中,Plates 瞭解到 app 名稱空間中的所有模板都能在 templates/app 目錄下找到,且它該以 use .phtml 為模板副檔名。另外兩個配置過的名稱空間為 errorlayout

首先,我們要建立 layout 模板。該模板的名字為 layout::default,根據配置,其路徑為 templates/layout/default.phtml

```

<!DOCTYPE html>

<head>

    <meta charset="utf-8" />

    <title><?=$this->e($title);?></title>

</head>

<body>

    <?=$this->section('content')?>

</body>

```

接下來,建立 templates/app/index.phtml 中的 app::index 模板。我們會使之擴充套件之前建立的 layout::default 模板。error 名稱空間中的模板已經配置為擴充套件 layout::default 模板。

``` <?php $this->layout('layout::default', ['title' => 'Astronomy Picture of the Day']) ?>

Astronomy Picture of the Day App

Welcome to my Astronomy Picture of the Day App. It will use an API provided by NASA to deliver awesome astronomy pictures.

```

在瀏覽器中載入應用,你就能看到剛才建立的模板了。

Pipe 中介軟體

Zend Expressive 文件中關於 pipe 中介軟體的說明如下:

當你在應用中 pipe 中介軟體時,它會被新增到佇列中,當某個中介軟體返回響應例項時才會按順序從佇列中移除。如果沒有中介軟體返回響應例項,會由‘最終處理器’進行處理,後者會決定是否返回錯誤,若返回,則由其決定錯誤型別。

pipe 中介軟體可用於建立應用防火牆、認證層、分析程式等等。實際上,Zend Expressive 將 pipe 中介軟體用於路由。在本應用中,我們會使用 pipe 中介軟體建立應用層快取。

首先,需要獲取快取庫。

``` composer require doctrine/cache ^1.5

其次,在 `config/autoload/dependencies.global.php` 檔案新增以下程式碼:

return [

'dependencies' => [

    'factories' => [

        // ...

        Doctrine\Common\Cache\Cache::class => App\DoctrineCacheFactory::class,

    ],

],

'application' => [

    'cache_path' => 'data/doctrine-cache/',

],

];

``` 我們新增了一個 doctrine 快取服務,該服務所需的自定義 factory 類會在之後建立。使用檔案系統快取是使應用上線執行的最快方法,我們需要為此服務建立一個目錄。

``` mkdir data/doctrine-cache

```

配置檔案中的最後改動,是在路由開始之前將中介軟體服務報告給 Zend Expressive,並將其加入到中介軟體 pipe 中。開啟 config/autoload/middleware-pipeline.global.php 檔案,用以下程式碼替換其內容:

```

return [

'dependencies' => [

    'factories' => [

        App\Middleware\CacheMiddleware::class => App\Middleware\CacheFactory::class,

    ]

],

'middleware_pipeline' => [

    'pre_routing' => [

        [ 'middleware' => App\Middleware\CacheMiddleware::class ],

    ],

    'post_routing' => [

    ],

],

];

`` 用於 doctrine 快取的 factory 會儲存在src/DoctrineCacheFactory.php` 檔案中。如果需要改變應用使用的快取,我們只需改變該檔案(及其配置),使用另一個 doctrine 快取驅動程式即可。

```

namespace App;

use Doctrine\Common\Cache\FilesystemCache;

use Interop\Container\ContainerInterface;

use Zend\ServiceManager\Exception\ServiceNotCreatedException;

class DoctrineCacheFactory

{

public function __invoke(ContainerInterface $container)

{

    $config = $container->get('config');



    if (!isset($config['application']['cache_path'])) {

        throw new ServiceNotCreatedException('cache_path must be set in application configuration');

    }



    return new FilesystemCache($config['application']['cache_path']);

}

}

```

位於 src/Middleware/CacheFactory.php 的中介軟體 factory 會將快取服務注入中介軟體:

```

namespace App\Middleware;

use Doctrine\Common\Cache\Cache;

use Interop\Container\ContainerInterface;

class CacheFactory

{

public function __invoke(ContainerInterface $container)

{

    $cache = $container->get(Cache::class);

    return new CacheMiddleware($cache);

}

}

```

最後剩下中介軟體。建立 src/Middleware/CacheMiddleware.php,輸入以下程式碼:

```

namespace App\Middleware;

use Doctrine\Common\Cache\Cache;

use Psr\Http\Message\ResponseInterface;

use Psr\Http\Message\ServerRequestInterface;

use Zend\Stratigility\MiddlewareInterface;

class CacheMiddleware implements MiddlewareInterface

{

private $cache;

public function __construct(Cache $cache)

{

    $this->cache = $cache;

}

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next = null)

{

    $cachedResponse = $this->getCachedResponse($request, $response);

    if (null !== $cachedResponse) {

        return $cachedResponse;

    }

    $response = $next($request, $response);

    $this->cacheResponse($request, $response);

    return $response;

}

private function getCacheKey(ServerRequestInterface $request)

{

    return 'http-cache:'.$request->getUri()->getPath();

}

private function getCachedResponse(ServerRequestInterface $request, ResponseInterface $response)

{

    if ('GET' !== $request->getMethod()) {

        return null;

    }

    $item = $this->cache->fetch($this->getCacheKey($request));

    if (false === $item) {

        return null;

    }

    $response->getBody()->write($item['body']);

    foreach ($item['headers'] as $name => $value) {

        $response = $response->withHeader($name, $value);

    }

    return $response;

}

private function cacheResponse(ServerRequestInterface $request, ResponseInterface $response)

{

    if ('GET' !== $request->getMethod() || !$response->hasHeader('Cache-Control')) {

        return;

    }

    $cacheControl = $response->getHeader('Cache-Control');

    $abortTokens = array('private', 'no-cache', 'no-store');

    if (count(array_intersect($abortTokens, $cacheControl)) > 0) {

        return;

    }

    foreach ($cacheControl as $value) {

        $parts = explode('=', $value);

        if (count($parts) == 2 && 'max-age' === $parts[0]) {

            $this->cache->save($this->getCacheKey($request), [

                'body'    => (string) $response->getBody(),

                'headers' => $response->getHeaders(),

            ], intval($parts[1]));

            return;

        }

    }

}

}

```

中介軟體會首先嚐試從快取處獲取響應。如果快取中包含有效響應,則返回之,下一個中介軟體不會被呼叫。然而,如果快取中沒有有效響應,生成響應的任務就會由 pipe 中的下一個中介軟體負責。

在返回 pipe 中的最後一個響應之前,應用會快取該響應以備下次使用。因此,會簡單檢查該響應是否可以快取。

如果回到索引操作類,我們可以給響應物件新增一個快取控制 header,該 header 用來告訴剛剛建立的快取中介軟體,將此響應快取一個小時:

```

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next = null)

{

$html = $this->templateRenderer->render('app::index');

$response->getBody()->write($html);

return $response

    ->withHeader('Content-Type', 'text/html')

    ->withHeader('Cache-Control', ['public', 'max-age=3600']);

}

```

這是一個非常原始的快取,只有當 pipe 中之後的中介軟體返回的響應物件較為簡單時才有效。有一系列的 header 都能影響快取處理響應的方式。此處,作為 pipe 中介軟體利用應用層級設計的演示程式碼,已經夠用。

在建立應用的同時,我們可以禁用快取控制 header 以防止快取舊的響應。清除快取的指令如下:

``` rm -rf data/doctrine-cache/*

```

請注意,Cache-Control header 會啟用客戶端的快取。瀏覽器會記下其快取的響應,即便這些響應已經在服務端刪除。

整合 NASA API

儘管可以直接使用 NASA API,這種方法還是有些複雜之處。最主要的兩個問題是 NASA API 並未提供任何獲取結果集和縮圖的方法。我們的解決方案是使用一個本文專屬的 wrapper API。

在專案根目錄執行以下指令:

```

composer require andrewcarteruk/astronomy-picture-of-the-day ^0.1

```

config/autoload/dependencies.global.php 檔案新增以下程式碼:

```

return [

'dependencies' => [

    'factories' => [

        // ...

        AndrewCarterUK\APOD\APIInterface::class => App\APIFactory::class,

    ],

],

'application' => [

    // ...

    'results_per_page' => 24,

    'apod_api' => [

        'store_path' => 'public/apod',

        'base_url' => '/apod',

    ],

],

];

```

我們還需在 config/autoload/dependencies.local.php 建立本地依賴檔案:

```

return [

'application' => [

    'apod_api' => [

        'api_key' => 'DEMO_KEY',

        // DEMO_KEY might be good for a couple of requests

        // Get your own here: https://api.nasa.gov/index.html#live_example

    ],

],

];

```

並在 config/autoload/routes.global.php 檔案新增路由資訊:

```

return [

'dependencies' => [

    // ...

    'factories' => [

        // ...

        App\Action\PictureListAction::class => App\Action\PictureListFactory::class,

    ],

],

'routes' => [

    // ...

    [

        'name' => 'picture-list',

        'path' => '/picture-list[/{page:\d+}]',

        'middleware' => App\Action\PictureListAction::class,

        'allowed_methods' => ['GET'],

    ],

],

];

```

所以,以上配置修改會產生什麼效果呢?我們新增的路由可以從 NASA API 獲取近期的圖片列表。該路由會接收任意的整數型分頁屬性,我們可將之作為頁碼。我們還為 API wrapper 及此路由附屬的操作建立了服務。

我們需要建立在 apod_api 鍵中指定的儲存路徑,如果可行,將此路徑新增至 .gitignore 檔案。API wrapper 將在該路徑下儲存縮圖,因此它必須儲存在公共目錄下。否則就無法為縮圖建立公共 URL。

```

mkdir public/apod

```

此 API 的 factory 比較簡單。建立 src/APIFactory.php 檔案,填入以下程式碼:

```

namespace App;

use AndrewCarterUK\APOD\API;

use GuzzleHttp\Client;

use Interop\Container\ContainerInterface;

use Zend\ServiceManager\Exception\ServiceNotCreatedException;

class APIFactory

{

public function __invoke(ContainerInterface $container)

{

    $config = $container->get('config');

    if (!isset($config['application']['apod_api'])) {

        throw new ServiceNotCreatedException('apod_api must be set in application configuration');

    }

    return new API(new Client, $config['application']['apod_api']);

}

}

```

該 API wrapper 使用 Guzzle 向 API 終端提交 HTTP 請求。我們只需注入客戶端例項以及 config 服務中的配置即可。

處理路由的操作需要與 API 服務一起注入。操作 factory 位於 /src/Action/PictureListFactory.php 檔案,內容如下:

```

namespace App\Action;

use AndrewCarterUK\APOD\APIInterface;

use Interop\Container\ContainerInterface;

use Zend\ServiceManager\Exception\ServiceNotCreatedException;

class PictureListFactory

{

public function __invoke(ContainerInterface $container)

{

    $apodApi = $container->get(APIInterface::class);

    $config  = $container->get('config');

    if (!isset($config['application']['results_per_page'])) {

        throw new ServiceNotCreatedException('results_per_page must be set in application configuration');

    }

    return new PictureListAction($apodApi, $config['application']['results_per_page']);

}

}

```

現在只剩下操作了。建立 src/Action/PictureListAction.php 檔案,填入如下程式碼:

```

namespace App\Action;

use AndrewCarterUK\APOD\APIInterface;

use Psr\Http\Message\ServerRequestInterface;

use Psr\Http\Message\ResponseInterface;

use Zend\Stratigility\MiddlewareInterface;

class PictureListAction implements MiddlewareInterface

{

private $apodApi;

private $resultsPerPage;

public function __construct(APIInterface $apodApi, $resultsPerPage)

{

    $this->apodApi        = $apodApi;

    $this->resultsPerPage = $resultsPerPage;

}

public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $out = null)

{

    $page     = intval($request->getAttribute('page')) ?: 0;

    $pictures = $this->apodApi->getPage($page, $this->resultsPerPage);

    $response->getBody()->write(json_encode($pictures));

    return $response

        // ->withHeader('Cache-Control', ['public', 'max-age=3600'])

        ->withHeader('Content-Type', 'application/json');

}

}

```

該操作會從 API 獲取一個頁面的圖片,以 JSON 格式將之匯出。示例展示瞭如何為快取中介軟體的響應新增快取控制 header。然而,在開發時還是將這部分註釋掉比較穩妥。

現在,我們只需建立一個容納內容的工具。下面的文件可以在命令列執行。它包含了配置中的容器,會安裝一個訊號處理器,因此可以快速關閉程式,執行 API wrapper 中的 updateStore 方法。 建立 bin/update.php 檔案:

```

chdir(DIR.'/..');

include 'vendor/autoload.php';

$container = include 'config/container.php';

// Create a SIGINT handler that sets a shutdown flag

$shutdown = false;

declare(ticks = 1);

pcntl_signal(SIGINT, function () use (&$shutdown) {

$shutdown = true;    

});

$newPictureHandler = function (array $picture) use (&$shutdown) {

echo 'Added: ' . $picture['title'] . PHP_EOL;

// If the shutdown flag has been set, die

if ($shutdown) {

    die;

}

};

$errorHandler = function (Exception $exception) use (&$shutdown) {

echo (string) $exception . PHP_EOL;

// If the shutdown flag has been set, die

if ($shutdown) {

    die;

}

};

$container->get(AndrewCarterUK\APOD\APIInterface::class)->updateStore(20, $newPictureHandler, $errorHandler);

```

現在,我們可以執行該命令以更新內容,從 API 處獲取最近20天的圖片。這會需要一點時間,但更新完成後,我們可以在瀏覽器中監控 /picture-list 路由,並看到一組 JSON 圖片資料。在監控圖片流時,最好禁用響應中的快取 header,否則可能無法更新。

確保從 NASA 獲取專屬的 API 鍵,DEMO_KEY 很快就會達到請求上線,並返回 429 響應碼。

```

php bin/update.php

```

若想要應用自動更新,需要將命令設定為每日執行。此外,還需將 updateStore 方法的第一個引數設定為1,使其只下載當天的圖片。

至此,本應用的 Zend Expressive 部分就介紹完畢了。然後只需修改模板,用 AJAX 從新的路由載入圖片即可。AstroSplash 資源庫 展示了一種實現方法(templates/app/index.phtmltemplates/layout/default.phtml)。不過,這更應該我們發揮各人特色的地方。

最後需要做的就是不斷的對網站的效能進行優化了,如果是在本地通過壓測工具進行優化,那麼使用 JMeter+XHProf 就可以了,不過這個方法不能完全的重現真實環境的效能狀況,因此針對這種方式的結果進行優化,不一定是最優結果,這時候使用 OneAPM PHP 探針 就能解決這個問題。

使用 OneAPM 提供的 PHP 探針只需要直接在生產環境安裝好探針,進行一些簡單的配置,就能自動完成效能資料的收集和分析工作了,效能瓶頸準確度直達程式碼行,而且因為分析結果是基於真實資料,對於效能優化來說更具有參考價值,所以只需要經常按照慢事務堆疊圖對標紅的方法進行持續優化就可以很好的優化應用效能了。

總結

使用 Zend Expressive 這類以中介軟體為基礎的框架使我們在設計應用時以層級為基礎。依照最簡單的形式,我們可以使用 route 中介軟體模擬在其他框架中可能熟悉的控制器操作。然而,中介軟體的好處在於它能在應用的任何階段攔截並修改請求與響應。

Zend Expressive 是一種很好用的框架,因為它容易移植。之前所寫的全部程式碼都可以輕易地移植到不同的框架使用,甚至用在沒有框架的應用中,再配合 PHP 探針就能輕鬆搭建高效能的PHP應用程式了。

Zend Expressive 還支援許多意想不到的元件,使其很難不讓人喜愛。目前,該框架支援三種路由(FastRoute, Aura.Router, ZF2 Router),三種容器(Zend ServiceManager, Pimple, Aura.DI)以及三種模板引擎(Plates, Twig, Zend View)。

此外,Zend Expressive 文件提供了有關該框架與其支援元件的深入文件,還包含了快速上手的簡便指導教程。

原文地址:http://www.sitepoint.com/build-nasa-photo-gallery-zend-expressive/

相關文章