教你在不使用框架的狀況下也能寫出現代化 PHP 代碼

file

我爲大家準備了一個富有挑戰性的事情。接下來大家將以 框架的方式開啓一個項目之旅。php

首先聲明, 這篇並不是又臭又長的反框架裹腳布文章。也不是推銷 非原創 思想 。畢竟, 咱們還將在接下來的開發之旅中使用其餘框架開發者編寫的輔助包。我對這個領域的創新也是持無可非議的態度。html

這無關他人,而是關乎己身。做爲一名開發者,它將有機會讓你成長。前端

也許無框架開發令你受益不淺的地方就是,能夠從底層運做的層面中汲取豐富的知識。拋卻依賴神奇的,幫你處理沒法調試和沒法真正理解的東西的框架,你將清楚的看到這一切是如何發生的。nginx

頗有可能下一份工做中,你並不能隨心因此地選擇框架開拓新項目。現實就是,在不少高價值,關鍵業務的 PHP 工做中均使用現有應用。 而且該應用程序是否構建在當前使人舒爽的 Laravel 或 Symfony 等流行框架中,亦或是陳舊過期的 CodeIgniter 或者 FuelPHP 中,更有甚者它可能普遍出如今使人沮喪的 「面向包含體系結構」 的傳統的 PHP 應用 之中,因此框架開發會在未來你所面臨的任何 PHP 項目中助你一臂之力。laravel

上古時代, 由於 某些系統 不得不解釋分發 HTTP 請求,發送 HTTP 響應,管理依賴關係,無框架開發就是痛苦的鏖戰。缺少行業標準必然意味着,框架中的這些組件高度耦合 。若是你從無框架開始,你終將難逃自建框架的命運。git

時至今日,幸好有 PHP-FIG 完成全部的自動加載和交互工做,無框架開發並不是讓你白手起家。各色供應商都有這麼多優秀的可交互的軟件包。把他們組合起來容易得超乎你的想象!github

PHP 是如何工做的?

在作其餘事以前,搞清楚 PHP 如何與外界溝通是很是重要的。web

PHP 以請求 / 響應爲週期運行服務端應用程序。與你的應用程序的每一次交互——不管是來自瀏覽器,命令行仍是 REST API ——都是做爲請求進入應用程序的。 當接收到請求之後:數據庫

  1. 程序開始啓動;
  2. 開始處理請求;
  3. 產生響應;
  4. 接着,響應返回給產生請求的相應客戶端;
  5. 最後程序關閉。

每個 請求都在重複以上的交互。編程

前端控制器

用這些知識把本身武裝起來之後,就能夠先從咱們的前端控制器開始編寫程序了。前端控制器是一個 PHP 文件,它處理程序的每個請求。控制器是請求進入程序後遇到的第一個 PHP 文件,而且(本質上)也是響應走出你應用程序所通過的最後一個文件。

咱們使用經典的 Hello, world! 做爲例子來確保全部東西都正確鏈接上,這個例子由 PHP 的內置服務器  驅動。在你開始這樣作以前,請確保你已經安裝了 PHP7.1 或者更高版本。

建立一個含有 public 目錄的項目,而後在該目錄裏面建立一個index.php 文件,文件裏面寫入以下代碼:

<?php
declare(strict_types=1);

echo 'Hello, world!';

注意,這裏咱們聲明瞭使用嚴格模式 —— 做爲最佳實踐,你應該在應用程序的 每一個 PHP 文件的開頭 都這樣作。由於對從你後面來的開發者來講類型提示對 調試和清晰的交流意圖很重要 。

使用命令行(好比 macOS 的終端)切換到你的項目目錄並啓動 PHP 的內置服務器。

php -S localhost:8080 -t public/

如今,在瀏覽器中打開 http://localhost:8080/ 。是否是成功地看到了 "Hello, world!" 輸出?

很好。接下來咱們能夠開始進入正題了!

自動加載與第三方包

當你第一次使用 PHP 時,你可能會在你的程序中使用 includes 或 requires 語句來從其餘 PHP 文件導入功能和配置。 一般,咱們會避免這麼幹,由於這會使得其餘人更難以遵循你的代碼路徑和理解依賴在哪裏。這讓調試成爲了一個 真正的 噩夢。

解決辦法是使用自動加載(autoloading)。 自動加載的意思是:當你的程序須要使用一個類, PHP 在調用該類的時候知道去哪裏找到並加載它。雖然從 PHP 5 開始就可使用這個特性了, 可是得益於 PSR-0 ( 自動加載標準,後來被 PSR-4 取代),其使用率纔開始有真正的提高。

咱們能夠編寫本身的自動加載器來完成任務,可是因爲咱們將要使用的管理第三方依賴的  Composer 已經包含了一個完美的可用的自動加載器,那咱們用它就好了。

確保你已經在你的系統上 安裝 了 Composer。而後爲此項目初始化 Composer:

composer init

這條命令經過交互式引導你建立 composer.json 配置文件。 一旦文件建立好了,咱們就能夠在編輯器中打開它而後向裏面寫入 autoload 字段,使他看起來像這個樣子(這確保了自動加載器知道從哪裏找到咱們項目中的類):

{
    "name": "kevinsmith/no-framework",
    "description": "An example of a modern PHP application bootstrapped without a framework.",
    "type": "project",
    "require": {},
    "autoload": {
        "psr-4": {
            "ExampleApp\\": "src/"
        }
    }
}

如今爲此項目安裝 composer,它引入了依賴(若是有的話),併爲咱們建立好了自動加載器:

composer install

更新 public/index.php 文件來引入自動加載器。在理想狀況下,這將是你在程序當中使用的少數『包含』語句之一。

<?php
declare(strict_types=1);

require_once dirname(__DIR__) . '/vendor/autoload.php';

echo 'Hello, world!';

此時若是你刷新瀏覽器,你將不會看到任何變化。由於自動加載器沒有修改或者輸出任何數據,因此咱們看到的是一樣的內容。讓咱們把 Hello, world! 這個例子移動到一個已經自動加載的類裏面看看它是如何運做的。

在項目根目錄建立一個名爲 src 的目錄,而後在裏面添加一個叫 HelloWorld.php 的文件,寫入以下代碼:

<?php
declare(strict_types=1);

namespace ExampleApp;

class HelloWorld
{
    public function announce(): void
    {
        echo 'Hello, autoloaded world!';
    }
}

如今到 public/index.php 裏面用  HelloWorld 類的 announce 方法替換掉 echo 語句。

// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$helloWorld = new \ExampleApp\HelloWorld();
$helloWorld->announce();

刷新瀏覽器查看新的信息!

什麼是依賴注入?

依賴注入是一種編程技術,每一個依賴項都供給它須要的對象,而不是在對象外得到所需的信息或功能。

舉個例子,假設應用中的類方法須要從數據庫中讀取。爲此,你須要一個數據庫鏈接。經常使用的技術就是建立一個全局可見的新鏈接。

class AwesomeClass
{
    public function doSomethingAwesome()
    {
        $dbConnection = return new \PDO(
            "{$_ENV['type']}:host={$_ENV['host']};dbname={$_ENV['name']}",
            $_ENV['user'],
            $_ENV['pass']
        );

        // Make magic happen with $dbConnection
    }
}

可是這樣作顯得很亂,它把一個並不是屬於這裏的職責置於此地---建立一個* 數據庫鏈接對象  檢查憑證 , 還有 處理一些鏈接失敗的問題---它會致使應用中出現 大量*  重複代碼。若是你嘗試對這個類進行單元測試,會發現根本不可行。這個類和應用環境以及數據庫高度耦合。

相反,爲什麼不一開始就搞清楚你的類須要什麼?咱們只須要首先將 「PDO」 對象注入該類便可。

class AwesomeClass
{
    private $dbConnection;

    public function __construct(\PDO $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }

    public function doSomethingAwesome()
    {
        // Make magic happen with $this->dbConnection
    }
}

這樣更簡潔清晰易懂,且更不易產生 Bug。經過類型提示和依賴注入,該方法能夠清楚準確地聲明它要作的事情,而無需依賴外部調用去獲取。在作單元測試的時候,咱們能夠很好地模擬數據庫鏈接,並將其傳入使用。

依賴注入容器 是一個工具,你能夠圍繞整個應用程序來處理建立和注入這些依賴關係。容器並不須要可以使用依賴注入技術,但隨着應用程序的增加並變得更加複雜,它將大有裨益。

咱們將使用 PHP 中最受歡迎的 DI 容器之一:名副其實的 PHP-DI(值得推薦的是它文檔中的 依賴注入另解 可能會對讀者有所幫助)

依賴注入容器

如今咱們已經安裝了 Composer ,那麼安裝 PHP-DI 就垂手可得了,咱們繼續回到命令行來搞定它。

composer require php-di/php-di

修改 public/index.php 用來配置和構建容器。

// ...

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new \DI\ContainerBuilder();
$containerBuilder->useAutowiring(false);
$containerBuilder->useAnnotations(false);
$containerBuilder->addDefinitions([
    \ExampleApp\HelloWorld::class => \DI\create(\ExampleApp\HelloWorld::class)
]);

$container = $containerBuilder->build();

$helloWorld = $container->get(\ExampleApp\HelloWorld::class);
$helloWorld->announce();

沒啥大不了的。它還是一個單文件的簡單示例,你很容易能看清它是怎麼運行的。

迄今爲止, 咱們只是在 配置容器 ,因此咱們必須 顯式地聲明依賴關係 (而不是使用 自動裝配 或 註解),而且從容器中檢索 HelloWorld 對象。

小貼士:自動裝配在你開始構建應用程序的時候是一個很不錯的特性,可是它隱藏了依賴關係,難以維護。 頗有可能在接下里的歲月裏, 另外一個開發者在不知情的情況下引入了一個新庫,而後就造就了多個庫實現一個單接口的局面,這將會破壞自動裝配,致使一系列讓接手者很容易忽視的的不可見的問題。

儘可能 引入命名空間,能夠增長代碼的可讀性。

<?php
declare(strict_types=1);

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use function DI\create;

require_once dirname(__DIR__) . '/vendor/autoload.php';

$containerBuilder = new ContainerBuilder();
$containerBuilder->useAutowiring(false);
$containerBuilder->useAnnotations(false);
$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
]);

$container = $containerBuilder->build();

$helloWorld = $container->get(HelloWorld::class);
$helloWorld->announce();

如今看來,咱們好像是把之前已經作過的事情再拿出來小題大作。

毋需煩心,當咱們添加其餘工具來幫助咱們引導請求時,容器就有用武之地了。它會在適當的時機下按需加載正確的類。

中間件

若是把你的應用想象成一個洋蔥,請求從外部進入,到達洋蔥中心,最後變成響應返回出去。那麼中間件就是洋蔥的每一層。它接收請求而且能夠處理請求。要麼把請求傳遞到更裏層,要麼向更外層返回一個響應(若是中間件正在檢查請求不知足的特定條件,好比請求一個不存在的路由,則可能發生這種狀況)。

若是請求經過了全部的層,那麼程序就會開始處理它並把它轉換爲響應,中間件接收到響應的順序與接收到請求的順序相反,而且也能對響應作修改,而後再把它傳遞給下一個中間件。

下面是一些中間件用例的閃光點:

  • 在開發環境中調試問題
  • 在生產環境中優雅的處理異常
  • 對傳入的請求進行頻率限制
  • 對請求傳入的不支持資源類型作出響應
  • 處理跨域資源共享(CORS)
  • 將請求路由到正確的處理類

那麼中間件是實現這些功能的惟一方式嗎?固然不是。可是中間件的實現使得你對請求 / 響應這個生命週期的理解更清晰。這也意味着你調試起來更簡單,開發起來更快速。

咱們將從上面列出的最後一條用例,也就是路由,當中獲益。

路由

路由依靠傳入的請求信息來肯定應當由哪一個類來處理它。(例如 URI  /products/purple-dress/medium 應該被  ProductDetails::class類接收處理,同時 purple-dress 和 medium 做爲參數傳入)

在範例應用中,咱們將使用流行的 FastRoute 路由,基於 PSR-15兼容的中間件實現

中間件調度器

爲了讓咱們的應用能夠和 FastRoute 中間件---以及咱們安裝的其餘中間件協同工做---咱們須要一箇中間件調度器。

PSR-15是爲中間件和調度器定義接口的中間件標準(在規範中又稱「請求處理器」),它容許各式各樣的中間件和調度器互相交互。咱們只需選擇兼容 PSR-15 的調度器,這樣就能夠確保它能和任何兼容 PSR-15 的中間件協同工做。

咱們先安裝一個 Relay 做爲調度器。

composer require relay/relay:2.x@dev

並且根據 PSR-15 的中間件標準要求實現可傳遞 兼容 PSR-7 的 HTTP 消息, 咱們使用 Zend Diactoros 做爲 PSR-7 的實現。

composer require zendframework/zend-diactoros

咱們用 Relay 去接收中間件。

// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use Relay\Relay;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$container = $containerBuilder->build();

$middlewareQueue = [];

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

咱們在第 16 行使用 ServerRequestFactory::fromGlobals()  把 建立新請求的必要信息合併起來 而後把它傳給 Relay。 這正是 Request 進入咱們中間件堆棧的起點。

如今咱們繼續添加 FastRoute 和請求處理器中間件。 ( FastRoute 肯定請求是否合法,究竟可否被應用程序處理,而後請求處理器發送 Request 到路由配置表中已註冊過的相應處理程序中)

composer require middlewares/fast-route middlewares/request-handler

而後咱們給 Hello, world! 處理類定義一個路由。咱們在此使用 /hello 路由來展現基本 URI 以外的路由。

// ...

use DI\ContainerBuilder;
use ExampleApp\HelloWorld;
use FastRoute\RouteCollector;
use Middlewares\FastRoute;
use Middlewares\RequestHandler;
use Relay\Relay;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;
use function FastRoute\simpleDispatcher;

// ...

$container = $containerBuilder->build();

$routes = simpleDispatcher(function (RouteCollector $r) {
    $r->get('/hello', HelloWorld::class);
});

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler();

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

爲了能運行,你還須要修改 HelloWorld 使其成爲一個可調用的類, 也就是說 這裏類能夠像函數同樣被隨意調用.

// ...

class HelloWorld
{
    public function __invoke(): void
    {
        echo 'Hello, autoloaded world!';
        exit;
    }
}

(注意在魔術方法 __invoke() 中加入exit;。 咱們只需1秒鐘就能搞定--只是不想讓你遺漏這個事)

如今打開 http://localhost:8080/hello ,開香檳吧!

萬能膠水

睿智的讀者可能很快看出,雖然咱們仍舊囿於配置和構建 DI 容器的藩籬之中,容器如今實際上對咱們毫無用處。調度器和中間件在沒有它的狀況下也同樣運做。

那它什麼時候才能發揮威力?

嗯,若是---在實際應用程序中老是如此---HelloWorld類具備依賴關係呢?

咱們來說解一個簡單的依賴關係,看看究竟發生了什麼。

// ...

class HelloWorld
{
    private $foo;

    public function __construct(string $foo)
    {
        $this->foo = $foo;
    }

    public function __invoke(): void
    {
        echo "Hello, {$this->foo} world!";
        exit;
    }
}

刷新瀏覽器..

WOW!

看下這個 ArgumentCountError.

發生這種狀況是由於 HelloWorld 類在構造的時候須要注入一個字符串才能運行,在此以前它只能等着。  正是容器要幫你解決的痛點。
咱們在容器中定義該依賴關係,而後將容器傳給 RequestHandler 去 解決這個問題.

// ...

use Zend\Diactoros\ServerRequestFactory;
use function DI\create;
use function DI\get;
use function FastRoute\simpleDispatcher;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo')),
    'Foo' => 'bar'
]);

$container = $containerBuilder->build();

// ...

$middlewareQueue[] = new FastRoute($routes);
$middlewareQueue[] = new RequestHandler($container);

$requestHandler = new Relay($middlewareQueue);
$requestHandler->handle(ServerRequestFactory::fromGlobals());

嗟夫!當刷新瀏覽器的時候, "Hello, bar world!"將映入你的眼簾!

正確地發送響應

是否還記得我以前提到過的位於 HelloWorld 類中的 exit 語句?

當咱們構建代碼時,它可讓咱們簡單粗暴的得到響應,可是它絕非輸出到瀏覽器的最佳選擇。這種粗暴的作法給 HelloWorld 附加了額外的響應工做---其實應該由其餘類負責的---它會過於複雜的發送正確的頭部信息和 狀態碼,而後馬上退出了應用,使得 HelloWorld 以後 的中間件也無機會運行了。

記住,每一箇中間件都有機會在 Request 進入咱們應用時修改它,而後 (以相反的順序) 在響應輸出時修改響應。 除了 Request 的通用接口, PSR-7 一樣也定義了另一種 HTTP 消息結構,以輔助咱們在應用運行週期的後半部分之用:Response。(若是你想真正瞭解這些細節,請閱讀HTTP 消息以及什麼讓 PSR-7 請求和響應標準如此之好。)

修改 HelloWorld 返回一個 Response

// ...

namespace ExampleApp;

use Psr\Http\Message\ResponseInterface;

class HelloWorld
{
    private $foo;

    private $response;

    public function __construct(
        string $foo,
        ResponseInterface $response
    ) {
        $this->foo = $foo;
        $this->response = $response;
    }

    public function __invoke(): ResponseInterface
    {
        $response = $this->response->withHeader('Content-Type', 'text/html');
        $response->getBody()
            ->write("<html><head></head><body>Hello, {$this->foo} world!</body></html>");

        return $response;
    }
}

而後修改容器給 HelloWorld 提供一個新的 Response 對象。

// ...

use Middlewares\RequestHandler;
use Relay\Relay;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$containerBuilder->addDefinitions([
    HelloWorld::class => create(HelloWorld::class)
        ->constructor(get('Foo'), get('Response')),
    'Foo' => 'bar',
    'Response' => function() {
        return new Response();
    },
]);

$container = $containerBuilder->build();

// ...

若是你如今刷新頁面,會發現一片空白。咱們的應用正在從中間件調度器返回正確的 Response 對象,可是... 腫麼回事?

它啥都沒幹,就這樣。

咱們還須要一件東西來包裝下:發射器。發射器位於應用程序和 Web 服務器(Apache,nginx等)之間,將響應發送給發起請求的客戶端。它實際上拿到了 Response 對象並將其轉化爲 服務端 API 可理解的信息。

好消息! 咱們已經用來封裝請求的 Zend Diactoros 包一樣也內置了發送 PSR-7 響應的發射器。

值得注意的是,爲了舉例,咱們只是對發射器的使用小試牛刀。雖然它們可能會更復雜點,真正的應用應該配置成自動化的流式發射器用來應對大量下載的狀況, Zend 博客展現瞭如何實現它

修改 public/index.php ,用來從調度器那裏接收 Response ,而後傳給發射器。

// ...

use Relay\Relay;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\SapiEmitter;
use Zend\Diactoros\ServerRequestFactory;
use function DI\create;

// ...

$requestHandler = new Relay($middlewareQueue);
$response = $requestHandler->handle(ServerRequestFactory::fromGlobals());

$emitter = new SapiEmitter();
return $emitter->emit($response);

刷新瀏覽器,業務恢復了!此次咱們用了一種更健壯的方式來處理響應。

以上代碼的第 15 行是咱們應用中請求/響應週期結束的地方,同時也是 web 服務器接管的地方。

總結

如今你已經得到了現代化的 PHP 代碼。 僅僅 44 行代碼,在幾個被普遍使用,通過全面測試和擁有可靠互操做性的組件的幫助下,咱們就完成了一個現代化 PHP 程序的引導。它兼容 PSR-4, PSR-7PSR-11 以及 PSR-15,這意味着你可使用本身選擇的其餘任一供應商對這些標準的實現,來構建本身的 HTTP 消息, DI 容器,中間件,還有中間件調度器。

咱們深刻理解了咱們決策背後使用的技術和原理,但我更但願你能明白,在沒有框架的狀況下,引導一個新的程序是多麼簡單的一件事。或許更重要的是,我但願在有必要的時候你能更好的把這些技術運用到已有的項目中去。

你能夠在 這個例子的 GitHub 倉庫 上免費 fork 和下載它。

若是你正在尋找更高質量的解耦軟件包資源,我衷心推薦你看看 Aura, 了不得的軟件包聯盟, Symfony 組件, Zend Framework 組件Paragon 計劃的聚焦安全的庫, 還有這個 關於 PSR-15 中間件的清單.

若是你想把這個例子的代碼用到生產環境中, 你可能須要把路由和 容器定義 分離到它們各自的文件裏面,以便未來項目複雜度提高的時候更好維護。我也建議 實現 EmitterStack 來更好的處理文件下載以及其餘的大量響應。

若是有任何問題,疑惑或者建議,請 給我留言

更多現代化 PHP 知識,請前往 Laravel / PHP 知識社區

相關文章
相關標籤/搜索