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

在本文中,咱們將藉助 NASA 天文圖庫 API,使用 Zend Expressive 創建圖片庫。最後的結果將顯示在 AstroSplash 網站,該網站是爲了文本特地搭建的。本文系 OneAPM 工程師編譯整理。php

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

中間件一詞將在本文中屢次出現。其完善定義可在 Zend Expressive 文檔 中找到:前端

中間件是位於請求與響應間的任意代碼。一般,中間件負責分析請求以收集輸入數據,將數據分配給其餘層進行處理,以後建立並返回響應。git

從2013年開始,StackPHP 爲 PHP 開發者提供了建立中間件的方法。然而,StackPHP 定義的中間件與本文將會提到的中間件有些不一樣。考慮到本文的意圖,二者的兼容性只在理論層面有效。github

若是你仍感到困惑,無需擔憂。全部的概念都會輔之以詳盡的例子,讓咱們立刻動手建立應用吧。express

應用簡介

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

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

建立 Zend Expressive 項目

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

Zend Expressive 提供了一個很是實用的項目框架安裝程序,可用於配置框架及所選的組件。使用下面的 composer 命令,開始建立應用:緩存

composer create-project -s rc zendframework/zend-expressive-skeleton <project-directory>

此處,須要將 <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,用如下代碼替換其內容:

<?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,用如下代碼替換其內容:

<?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。其內容以下:

<?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。其內容以下:

<?php
namespace App\Action;

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>

<html lang="en">

    <head>

        <meta charset="utf-8" />

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

    </head>

    <body>

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

    </body>

</html>

接下來,建立 templates/app/index.phtml 中的 app::index 模板。咱們會使之擴展以前建立的 layout::default 模板。error 命名空間中的模板已經配置爲擴展 layout::default 模板。

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

<h1>Astronomy Picture of the Day App</h1>

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

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

Pipe 中間件

Zend Expressive 文檔中關於 pipe 中間件的說明以下:

當你在應用中 pipe 中間件時,它會被添加到隊列中,當某個中間件返回響應實例時纔會按順序從隊列中移除。若是沒有中間件返回響應實例,會由‘最終處理器’進行處理,後者會決定是否返回錯誤,若返回,則由其決定錯誤類型。

pipe 中間件可用於建立應用防火牆、認證層、分析程序等等。實際上,Zend Expressive 將 pipe 中間件用於路由。在本應用中,咱們會使用 pipe 中間件建立應用層緩存。

首先,須要獲取緩存庫。

composer require doctrine/cache ^1.5

其次,在 config/autoload/dependencies.global.php 文件添加如下代碼:

<?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 文件,用如下代碼替換其內容:

<?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 緩存驅動程序便可。

<?php

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 會將緩存服務注入中間件:

<?php

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,輸入如下代碼:

<?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 文件添加如下代碼:

<?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 建立本地依賴文件:

<?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 文件添加路由信息:

<?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 文件,填入如下代碼:

<?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 文件,內容以下:

<?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 文件,填入以下代碼:

<?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 文件:

<?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/

相關文章
相關標籤/搜索