我為你們準備了一個富有挑戰性的事情。接下來你們將以 無 框架的方式開啟一個項目之旅。
首先聲明, 這篇并非又臭又長的反框架裹腳布文章。也不是推銷 非原創 思想 。畢竟, 我們還將在接下來的開發之旅中使用其他框架開發者編寫的輔助包。我對這個領域的創新也是持無可非議的態度。
這無關他人,而是關乎己身。作為一名開發者,它將有機會讓你成長。
也許無框架開發令你受益匪淺的地方就是,可以從底層運作的層面中汲取豐富的知識。拋卻依賴神奇的,幫你處理無法調試和無法真正理解的東西的框架,你將清楚的看到這一切是如何發生的。
立即學習“PHP免費學習筆記(深入)”;
很有可能下一份工作中,你并不能隨心所以地選擇框架開拓新項目。現實就是,在很多高價值,關鍵業務的 php 工作中均使用現有應用。 并且該應用程序是否構建在當前令人舒爽的 laravel 或 symfony 等流行框架中,亦或是陳舊過時的 CodeIgniter 或者 FuelPHP 中,更有甚者它可能廣泛出現在令人沮喪的 “面向包含體系結構” 的傳統的 PHP 應用 之中,所以無框架開發會在將來你所面臨的任何 PHP 項目中助你一臂之力。
上古時代, 因為 某些系統 不得不解釋分發 http 請求,發送 HTTP 響應,管理依賴關系,無框架開發就是痛苦的鏖戰。缺乏行業標準必然意味著,框架中的這些組件高度耦合 。如果你從無框架開始,你終將難逃自建框架的命運。
時至今日,幸虧有 PHP-FIG 完成所有的自動加載和交互工作,無框架開發并非讓你白手起家。各色供應商都有這么多優秀的可交互的軟件包。把他們組合起來容易得超乎你的想象!
PHP 是如何工作的?
在做其他事之前,搞清楚 PHP 如何與外界溝通是非常重要的。
PHP 以請求 / 響應為周期運行服務端應用程序。與你的應用程序的每一次交互——無論是來自瀏覽器,命令行還是 REST API ——都是作為請求進入應用程序的。 當接收到請求以后:
-
程序開始啟動;
-
開始處理請求;
-
產生響應;
-
接著,響應返回給產生請求的相應客戶端;
-
最后程序關閉。
每一個 請求都在重復以上的交互。
前端控制器
用這些知識把自己武裝起來以后,就可以先從我們的前端控制器開始編寫程序了。前端控制器是一個 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":?"class="lazy" data-src/" ????????} ????} }
現在為此項目安裝 composer,它引入了依賴(如果有的話),并為我們創建好了自動加載器:
composer?install
更新 public/index.php 文件來引入自動加載器。在理想情況下,這將是你在程序當中使用的少數『包含』語句之一。
<?php declare(strict_types=1); require_once?dirname(__DIR__)?.?'/vendor/autoload.php'; echo?'Hello,?world!';
此時如果你刷新瀏覽器,你將不會看到任何變化。因為自動加載器沒有修改或者輸出任何數據,所以我們看到的是同樣的內容。讓我們把 Hello, world! 這個例子移動到一個已經自動加載的類里面看看它是如何運作的。
在項目根目錄創建一個名為 class=”lazy” data-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?ExampleAppHelloWorld(); $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?DIContainerBuilder(); $containerBuilder->useAutowiring(false); $containerBuilder->useAnnotations(false); $containerBuilder->addDefinitions([ ????ExampleAppHelloWorld::class?=>?DIcreate(ExampleAppHelloWorld::class) ]); $container?=?$containerBuilder->build(); $helloWorld?=?$container->get(ExampleAppHelloWorld::class); $helloWorld->announce();
沒啥大不了的。它仍是一個單文件的簡單示例,你很容易能看清它是怎么運行的。
迄今為止, 我們只是在 配置容器 ,所以我們必須 顯式地聲明依賴關系 (而不是使用 自動裝配 或 注解),并且從容器中檢索 HelloWorld 對象。
小貼士:自動裝配在你開始構建應用程序的時候是一個很不錯的特性,但是它隱藏了依賴關系,難以維護。 很有可能在接下里的歲月里, 另一個開發者在不知情的狀況下引入了一個新庫,然后就造就了多個庫實現一個單接口的局面,這將會破壞自動裝配,導致一系列讓接手者很容易忽視的的不可見的問題。
盡量 porting.php”>引入命名空間,可以增加代碼的可讀性。
<?php declare(strict_types=1); use?DIContainerBuilder; use?ExampleAppHelloWorld; use?function?DIcreate; 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 消息, 我們使用 ramework.github.io/zend-diactoros/”>Zend Diactoros 作為 PSR-7 的實現。
composer?require?zendframework/zend-diactoros
我們用 Relay 去接收中間件。
//?... use?DIContainerBuilder; use?ExampleAppHelloWorld; use?RelayRelay; use?ZendDiactorosServerRequestFactory; use?function?DIcreate; //?... $container?=?$containerBuilder->build(); $middlewareQueue?=?[]; $requestHandler?=?new?Relay($middlewareQueue); $requestHandler->handle(ServerRequestFactory::fromGlobals());
我們在第 16 行使用 ServerRequestFactory::fromGlobals() 把 ramework.github.io/zend-diactoros/usage/#marshaling-an-incoming-request”>創建新請求的必要信息合并起來 然后把它傳給 Relay。這正是 Request 進入我們中間件堆棧的起點。
現在我們繼續添加 FastRoute 和請求處理器中間件。(FastRoute 確定請求是否合法,究竟能否被應用程序處理,然后請求處理器發送 Request 到路由配置表中已注冊過的相應處理程序中)
composer?require?middlewares/fast-route?middlewares/request-handler
然后我們給 Hello, world! 處理類定義一個路由。我們在此使用 /hello 路由來展示基本 URI 之外的路由。
//?... use?DIContainerBuilder; use?ExampleAppHelloWorld; use?FastRouteRouteCollector; use?MiddlewaresFastRoute; use?MiddlewaresRequestHandler; use?RelayRelay; use?ZendDiactorosServerRequestFactory; use?function?DIcreate; use?function?FastRoutesimpleDispatcher; //?... $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?ZendDiactorosServerRequestFactory; use?function?DIcreate; use?function?DIget; use?function?FastRoutesimpleDispatcher; //?... $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?PsrHttpMessageResponseInterface; 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("Hello,?{$this->foo}?world!"); ????????return?$response; ????} }
然后修改容器給 HelloWorld 提供一個新的 Response 對象。
//?... use?MiddlewaresRequestHandler; use?RelayRelay; use?ZendDiactorosResponse; use?ZendDiactorosServerRequestFactory; use?function?DIcreate; //?... $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 響應的發射器。
值得注意的是,為了舉例,我們只是對發射器的使用小試牛刀。雖然它們可能會更復雜點,真正的應用應該配置成自動化的流式發射器用來應對大量下載的情況, ramework.zend.com/blog/2017-09-14-diactoros-emitters.html”>Zend 博客展示了如何實現它。
修改 public/index.php ,用來從調度器那里接收 Response ,然后傳給發射器。
//?... use?RelayRelay; use?ZendDiactorosResponse; use?ZendDiactorosResponseSapiEmitter; use?ZendDiactorosServerRequestFactory; use?function?DIcreate; //?... $requestHandler?=?new?Relay($middlewareQueue); $response?=?$requestHandler->handle(ServerRequestFactory::fromGlobals()); $emitter?=?new?SapiEmitter(); return?$emitter->emit($response);
刷新瀏覽器,業務恢復了!這次我們用了一種更健壯的方式來處理響應。
以上代碼 return 那一行是我們應用中請求/響應周期結束的地方,同時也是 web 服務器接管的地方。
總結
現在你已經獲得了現代化的 PHP 代碼。 僅僅 44 行代碼,在幾個被廣泛使用,經過全面測試和擁有可靠互操作性的組件的幫助下,我們就完成了一個現代化 PHP 程序的引導。它兼容 PSR-4, PSR-7,PSR-11 以及 PSR-15,這意味著你可以使用自己選擇的其他任一供應商對這些標準的實現,來構建自己的 HTTP 消息, DI 容器,中間件,還有中間件調度器。
我們深入理解了我們決策背后使用的技術和原理,但我更希望你能明白,在沒有框架的情況下,引導一個新的程序是多么簡單的一件事。或許更重要的是,我希望在有必要的時候你能更好的把這些技術運用到已有的項目中去。
你可以在 ramework”>這個例子的 GitHub 倉庫 上免費 fork 和下載它。
如果你正在尋找更高質量的解耦軟件包資源,我衷心推薦你看看 Aura, 了不起的軟件包聯盟, Symfony 組件, ramework.github.io/”>Zend framework 組件,Paragon 計劃的聚焦安全的庫, 還有這個 關于 PSR-15 中間件的清單.
如果你想把這個例子的代碼用到生產環境中, 你可能需要把路由和 容器定義 分離到它們各自的文件里面,以便將來項目復雜度提升的時候更好維護。我也建議 ramework.zend.com/blog/2017-09-14-diactoros-emitters.html”>實現 EmitterStack 來更好的處理文件下載以及其他的大量響應。