爲何咱們要去構建一個本身的 PHP 框架?可能絕大多數的人都會說「市面上已經那麼多的框架了,還造什麼輪子?」。個人觀點「造輪子不是目的,造輪子的過程當中汲取到知識才是目的」。php
那怎樣才能構建一個本身的 PHP 框架呢?大體流程以下:css
入口文件 ----> 註冊自加載函數 ----> 註冊錯誤(和異常)處理函數 ----> 加載配置文件 ----> 請求 ----> 路由 ---->(控制器 <----> 數據模型) ----> 響應 ----> json ----> 視圖渲染數據
除此以外咱們還須要單元測試、nosql 支持、接口文檔支持、一些輔助腳本等。最終個人框架目錄以下:html
app [PHP 應用目錄] ├── demo [模塊目錄] │ ├── controllers [控制器目錄] │ │ └── Index.php [默認控制器文件,輸出 json 數據] │ ├── logics [邏輯層,主要寫業務邏輯的地方] │ │ ├── exceptions [異常目錄] │ │ ├── gateway [一個邏輯層實現的 gateway 演示] │ │ ├── tools [工具類目錄] │ │ └── UserDefinedCase.php [註冊框架加載到路由前的處理用例] │ └── models [數據模型目錄] │ └── TestTable.php [演示模型文件,定義一一對應的數據模型] ├── config [配置目錄] │ ├── demo [模塊配置目錄] │ │ ├── config.php [模塊自定義配置] │ │ └── route.php [模塊自定義路由] │ ├── common.php [公共配置] │ ├── database.php [數據庫配置] │ └── nosql.php [nosql 配置] docs [接口文檔目錄] ├── apib [Api Blueprint] │ └── demo.apib [接口文檔示例文件] ├── swagger [swagger] framework [Easy PHP 核心框架目錄] ├── exceptions [異常目錄] │ ├── CoreHttpException.php[核心 http 異常] ├── handles [框架運行時掛載處理機制類目錄] │ ├── Handle.php [處理機制接口] │ ├── ErrorHandle.php [錯誤處理機制類] │ ├── ExceptionHandle.php [未捕獲異常處理機制類] │ ├── ConfigHandle.php [配置文件處理機制類] │ ├── NosqlHandle.php [nosql 處理機制類] │ ├── LogHandle.php [log 機制類] │ ├── UserDefinedHandle.php[用戶自定義處理機制類] │ └── RouterHandle.php [路由處理機制類] ├── orm [對象關係模型] │ ├── Interpreter.php [sql 解析器] │ ├── DB.php [數據庫操做類] │ ├── Model.php [數據模型基類] │ └── db [數據庫類目錄] │ └── Mysql.php [mysql 實體類] ├── nosql [nosql 類目錄] │ ├── Memcahed.php [Memcahed 類文件] │ ├── MongoDB.php [MongoDB 類文件] │ └── Redis.php [Redis 類文件] ├── App.php [框架類] ├── Container.php [服務容器] ├── Helper.php [框架助手類] ├── Load.php [自加載類] ├── Request.php [請求類] ├── Response.php [響應類] ├── run.php [框架應用啓用腳本] frontend [前端源碼和資源目錄] ├── src [資源目錄] │ ├── components [vue 組件目錄] │ ├── views [vue 視圖目錄] │ ├── images [圖片] │ ├── ... ├── app.js [根 js] ├── app.vue [根組件] ├── index.template.html [前端入口文件模板] ├── store.js [vuex store 文件] public [公共資源目錄,暴露到萬維網] ├── dist [前端 build 以後的資源目錄,build 生成的目錄,不是發佈分支忽略該目錄] │ └── ... ├── index.html [前端入口文件,build 生成的文件,不是發佈分支忽略該文件] ├── index.php [後端入口文件] runtime [臨時目錄] ├── logs [日誌目錄] ├── build [php 打包生成 phar 文件目錄] tests [單元測試目錄] ├── demo [模塊名稱] │ └── DemoTest.php [測試演示] ├── TestCase.php [測試用例] vendor [composer 目錄] .git-hooks [git 鉤子目錄] ├── pre-commit [git pre-commit 預 commit 鉤子示例文件] ├── commit-msg [git commit-msg 示例文件] .babelrc [babel 配置文件] .env [環境變量文件] .gitignore [git 忽略文件配置] build [php 打包腳本] cli [框架 cli 模式運行腳本] LICENSE [lincese 文件] logo.png [框架 logo 圖片] composer.json [composer 配置文件] composer.lock [composer lock 文件] package.json [前端依賴配置文件] phpunit.xml [phpunit 配置文件] README-CN.md [中文版 readme 文件] README.md [readme 文件] webpack.config.js [webpack 配置文件] yarn.lock [yarn lock 文件]
定義一個統一的入口文件,對外提供統一的訪問文件。對外隱藏了內部的複雜性,相似企業服務總線的思想。前端
// 載入框架運行文件 require('../framework/run.php');
使用 spl_autoload_register 函數註冊自加載函數到__autoload 隊列中,配合使用命名空間,當使用一個類的時候能夠自動載入(require)類文件。註冊完成自加載邏輯後,咱們就可使用 use 和配合命名空間申明對某個類文件的依賴。mysql
[file: framework/Load.php]webpack
腳本運行期間:laravel
經過函數 set_error_handler 註冊用戶自定義錯誤處理方法,可是 set_error_handler 不能處理如下級別錯誤,E_ERROR、E_PARSE、E_CORE_ERROR、E_CORE_WARNING、E_COMPILE_ERROR、E_COMPILE_WARNING,和在 調用 set_error_handler() 函數所在文件中產生的大多數 E_STRICT。因此咱們須要使用 register_shutdown_function 配合 error_get_last 獲取腳本終止執行的最後錯誤,目的是對於不一樣錯誤級別和致命錯誤進行自定義處理,例如返回友好的提示的錯誤信息。git
[file: framework/hanles/ErrorHandle.php]github
經過函數 set_exception_handler 註冊未捕獲異常處理方法,目的捕獲未捕獲的異常,例如返回友好的提示和異常信息。
[file: framework/hanles/ExceptionHandle.php]
加載框架自定義和用戶自定義的配置文件。
[file: framework/hanles/ConfigHandle.php]
框架中全部的異常輸出和控制器輸出都是 json 格式,由於我認爲在先後端徹底分離的今天,這是很友善的,目前咱們不須要再去考慮別的東西。
[file: framework/Response.php]
經過用戶訪問的 url 信息,經過路由規則執行目標控制器類的的成員方法。我在這裏把路由大體分紅了四類:
傳統路由
domain/index.php?module=Demo&contoller=Index&action=test&username=test
pathinfo 路由
domain/demo/index/modelExample
用戶自定義路由
// 定義在 config/moduleName/route.php 文件中,這個的 this 指向 RouterHandle 實例 $this->get('v1/user/info', function (Framework\App $app) { return 'Hello Get Router'; });
微單體路由
我在這裏詳細說下這裏所謂的微單體路由,面向 SOA 和微服務架構大行其道的今天,有不少的團隊都在向服務化邁進,可是服務化過程當中不少問題的複雜度都是指數級的增加,例如分佈式的事務,服務部署,跨服務問題追蹤等等。這致使對於小的團隊從單體架構走向服務架構不免困難重重,因此有人提出來了微單體架構,按照個人理解就是在一個單體架構的 SOA 過程,咱們把微服務中的的各個服務仍是以模塊的方式放在同一個單體中,好比:
app ├── UserService [用戶服務模塊] ├── ContentService [內容服務模塊] ├── OrderService [訂單服務模塊] ├── CartService [購物車服務模塊] ├── PayService [支付服務模塊] ├── GoodsService [商品服務模塊] └── CustomService [客服服務模塊]
如上,咱們簡單的在一個單體裏構建了各個服務模塊,可是這些模塊怎麼通訊呢?以下:
App::$app->get('demo/index/hello', [ 'user' => 'TIGERB' ]);
經過上面的方式咱們就能夠鬆耦合的方式進行單體下各個模塊的通訊和依賴了。與此同時,業務的發展是難以預估的,將來當咱們向 SOA 的架構遷移時,很簡單,咱們只須要把以往的模塊獨立成各個項目,而後把 App 實例 get 方法的實現轉變爲 RPC 或者 REST 的策略便可,咱們能夠經過配置文件去調整對應的策略或者把本身的,第三方的實現註冊進去便可。
[file: framework/hanles/RouterHandle.php]
傳統的 MVC 模式包含 model-view-controller 層,絕大多時候咱們會把業務邏輯寫到 controller 層或 model 層,可是慢慢的咱們會發現代碼難以閱讀、維護、擴展,因此我在這裏強制增長了一個 logics 層。至於,邏輯層裏怎麼寫代碼怎麼,徹底由你本身定義,你能夠在裏面實現一個工具類,你也能夠在裏面再新建子文件夾並在裏面構建你的業務邏輯代碼,你甚至能夠實現一個基於責任連模式的網關(我會提供具體的示例)。這樣看來,咱們的最終結構是這樣的:
logics 邏輯層
邏輯層實現網關示例:
咱們在 logics 層目錄下增長了一個 gateway 目錄,而後咱們就能夠靈活的在這個目錄下編寫邏輯了。gateway 的結構以下:
gateway [Logics 層目錄下 gateway 邏輯目錄] ├── Check.php [接口] ├── CheckAppkey.php [檢驗 app key] ├── CheckArguments.php [校驗必傳參數] ├── CheckAuthority.php [校驗訪問權限] ├── CheckFrequent.php [校驗訪問頻率] ├── CheckRouter.php [網關路由] ├── CheckSign.php [校驗簽名] └── Entrance.php [網關入口文件]
網關入口類主要負責網關的初始化,代碼以下:
// 初始化一個:必傳參數校驗的 check $checkArguments = new CheckArguments(); // 初始化一個:app key check $checkAppkey = new CheckAppkey(); // 初始化一個:訪問頻次校驗的 check $checkFrequent = new CheckFrequent(); // 初始化一個:簽名校驗的 check $checkSign = new CheckSign(); // 初始化一個:訪問權限校驗的 check $checkAuthority = new CheckAuthority(); // 初始化一個:網關路由規則 $checkRouter = new CheckRouter(); // 構成對象鏈 $checkArguments->setNext($checkAppkey) ->setNext($checkFrequent) ->setNext($checkSign) ->setNext($checkAuthority) ->setNext($checkRouter); // 啓動網關 $checkArguments->start( APP::$container->getSingle('request') );
實現完成這個 gateway 以後,咱們如何在框架中去使用呢?在 logic 層目錄中我提供了一個 user-defined 的實體類,咱們把 gateway 的入口類註冊到 UserDefinedCase 這個類中,示例以下:
/** * 註冊用戶自定義執行的類 * * @var array */ private $map = [ // 演示 加載自定義網關 'App\Demo\Logics\Gateway\Entrance' ];
這樣這個 gateway 就能夠工做了。接着說說這個 UserDefinedCase 類,UserDefinedCase 會在框架加載到路由機制以前被執行,這樣咱們就能夠靈活的實現一些自定義的處理了。這個 gateway 只是個演示,你徹底能夠天馬行空的組織你的邏輯~
視圖 View 去哪了?因爲選擇了徹底的先後端分離和 SPA(單頁應用), 因此傳統的視圖層也所以去掉了,詳細的介紹看下面。
源碼目錄
徹底的先後端分離,數據雙向綁定,模塊化等等的大勢所趨。這裏我把我本身開源的 vue 前端項目結構easy-vue移植到了這個項目裏,做爲視圖層。咱們把前端的源碼文件都放在 frontend 目錄裏,詳細以下,你也能夠本身定義:
frontend [前端源碼和資源目錄,這裏存放咱們整個前端的源碼文件] ├── src [資源目錄] │ ├── components [編寫咱們的前端組件] │ ├── views [組裝咱們的視圖] │ ├── images [圖片] │ ├── ... ├── app.js [根 js] ├── app.vue [根組件] ├── index.template.html [前端入口文件模板] └── store.js [狀態管理,這裏只是個演示,你能夠很靈活的編寫文件和目錄]
build 步驟
yarn install DOMAIN=http://你的域名 npm run dev
編譯後
build 成功以後會生成 dist 目錄和入口文件 index.html 在 public 目錄中。非發佈分支.gitignore 文件會忽略這些文件,發佈分支去除忽略便可。
public [公共資源目錄,暴露到萬維網] ├── dist [前端 build 以後的資源目錄,build 生成的目錄,不是發佈分支忽略該目錄] │ └── ... ├── index.html [前端入口文件,build 生成的文件,不是發佈分支忽略該文件]
數據庫對象關係映射 ORM(Object Relation Map)是什麼?按照我目前的理解:顧名思義是創建對象和抽象事物的關聯關係,在數據庫建模中 model 實體類其實就是具體的表,對錶的操做其實就是對 model 實例的操做。可能絕大多數的人都要問「爲何要這樣作,直接 sql 語句操做很差嗎?搞得這麼麻煩!」,個人答案:直接 sql 語句固然能夠,一切都是靈活的,可是從一個項目的可複用,可維護, 可擴展出發,採用 ORM 思想處理數據操做是理所固然的,想一想若是若干一段時間你看見代碼裏大段的難以閱讀且無從複用的 sql 語句,你是什麼樣的心情。
市面上對於 ORM 的具體實現有 thinkphp 系列框架的 Active Record,yii 系列框架的 Active Record,laravel 系列框架的 Eloquent(聽說是最優雅的),那咱們這裏言簡意賅就叫 ORM 了。接着爲 ORM 建模,首先是 ORM 客戶端實體 DB:經過配置文件初始化不一樣的 db 策略,並封裝了操做數據庫的全部行爲,最終咱們經過 DB 實體就能夠直接操做數據庫了,這裏的 db 策略目前我只實現了 mysql(負責創建鏈接和 db 的底層操做)。接着咱們把 DB 實體的 sql 解析功能獨立成一個可複用的 sql 解析器的 trait,具體做用:把對象的鏈式操做解析成具體的 sql 語句。最後,創建咱們的模型基類 model,model 直接繼承 DB 便可。最後的結構以下:
├── orm [對象關係模型] │ ├── Interpreter.php [sql 解析器] │ ├── DB.php [數據庫操做類] │ ├── Model.php [數據模型基類] │ └── db [數據庫類目錄] │ └── Mysql.php [mysql 實體類]
DB 類使用示例
/** * DB 操做示例 * * findAll * * @return void */ public function dbFindAllDemo() { $where = [ 'id' => ['>=', 2], ]; $instance = DB::table('user'); $res = $instance->where($where) ->orderBy('id asc') ->limit(5) ->findAll(['id','create_at']); $sql = $instance->sql; return $res; }
Model 類使用示例
// controller 代碼 /** * model example * * @return mixed */ public function modelExample() { try { DB::beginTransaction(); $testTableModel = new TestTable(); // find one data $testTableModel->modelFindOneDemo(); // find all data $testTableModel->modelFindAllDemo(); // save data $testTableModel->modelSaveDemo(); // delete data $testTableModel->modelDeleteDemo(); // update data $testTableModel->modelUpdateDemo([ 'nickname' => 'easy-php' ]); // count data $testTableModel->modelCountDemo(); DB::commit(); return 'success'; } catch (Exception $e) { DB::rollBack(); return 'fail'; } } //TestTable model /** * Model 操做示例 * * findAll * * @return void */ public function modelFindAllDemo() { $where = [ 'id' => ['>=', 2], ]; $res = $this->where($where) ->orderBy('id asc') ->limit(5) ->findAll(['id','create_at']); $sql = $this->sql; return $res; }
什麼是服務容器?
服務容器聽起來很浮,按個人理解簡單來講就是提供一個第三方的實體,咱們把業務邏輯須要使用的類或實例注入到這個第三方實體類中,當須要獲取類的實例時咱們直接經過這個第三方實體類獲取。
服務容器的意義?
用設計模式來說:其實無論設計模式仍是實際編程的經驗中,咱們都是強調「高內聚,鬆耦合」,咱們作到高內聚的結果就是每一個實體的做用都是極度專注,因此就產生了各個做用不一樣的實體類。在組織一個邏輯功能時,這些細化的實體之間就會不一樣程度的產生依賴關係,對於這些依賴咱們一般的作法以下:
class Demo { public function __construct() { // 類 demo 直接依賴 RelyClassName $instance = new RelyClassName(); } }
這樣的寫法沒有什麼邏輯上的問題,可是不符合設計模式的「最少知道原則」,由於之間產生了直接依賴,整個代碼結構不夠靈活是緊耦合的。因此咱們就提供了一個第三方的實體,把直接依賴轉變爲依賴於第三方,咱們獲取依賴的實例直接經過第三方去完成以達到鬆耦合的目的,這裏這個第三方充當的角色就相似系統架構中的「中間件」,都是協調依賴關係和去耦合的角色。最後,這裏的第三方就是所謂的服務容器。
在實現了一個服務容器以後,我把 Request,Config 等實例都以單例的方式注入到了服務容器中,當咱們須要使用的時候從容器中獲取便可,十分方便。使用以下:
// 注入單例 App::$container->setSingle('別名,方便獲取', '對象 /閉包 /類名'); // 例,注入 Request 實例 App::$container->setSingle('request', function () { // 匿名函數懶加載 return new Request(); }); // 獲取 Request 對象 App::$container->getSingle('request');
提供對 nosql 的支持,提供全局單例對象,藉助咱們的服務容器咱們在框架啓動的時候,經過配置文件的配置把須要的 nosql 實例注入到服務容器中。目前咱們支持 redis/memcahed/mongodb。
如何使用?以下,
// 獲取 redis 對象 App::$container->getSingle('redis'); // 獲取 memcahed 對象 App::$container->getSingle('memcahed'); // 獲取 mongodb 對象 App::$container->getSingle('mongodb');
一般咱們寫完一個接口後,接口文檔是一個問題,咱們這裏使用 Api Blueprint 協議完成對接口文檔的書寫和 mock(可用),同時咱們配合使用 Swagger 經過接口文檔實現對接口的實時訪問(目前未實現)。
Api Blueprint 接口描述協議選取的工具是 snowboard,具體使用說明以下:
接口文檔生成說明
cd docs/apib ./snowboard html -i demo.apib -o demo.html -s open the website, http://localhost:8088/
接口 mock 使用說明
cd docs/apib ./snowboard mock -i demo.apib open the website, http://localhost:8087/demo/index/hello
基於 phpunit 的單元測試,寫單元測試是個好的習慣。
如何使用?
tests 目錄下編寫測試文件,具體參考 tests/demo 目錄下的 DemoTest 文件,而後運行:
vendor/bin/phpunit
測試斷言示例:
/** * 演示測試 */ public function testDemo() { $this->assertEquals( 'Hello Easy PHP', // 執行 demo 模塊 index 控制器 hello 操做,斷言結果是否是等於 Hello Easy PHP App::$app->get('demo/index/hello') ); }
目的規範化咱們的項目代碼和 commit 記錄。
cli 腳本
以命令行的方式運行框架,具體見使用說明。
build 腳本
打包 PHP 項目腳本,打包整個項目到 runtime/build 目錄,例如:
runtime/build/App.20170505085503.phar
<?php // 入口文件引入包文件便可 require('runtime/build/App.20170505085503.phar');
執行:
網站服務模式:
步驟 1: yarn install 步驟 2: DOMAIN=http://localhost:666 npm run demo 步驟 3: cd public 步驟 4: php -S localhost:666 訪問網站: http://localhost:666/index.html 訪問接口: http://localhost:666/Demo/Index/hello demo 以下:
客戶端腳本模式:
php cli --method=<module.controller.action> --<arguments>=<value> ... 例如, php cli --method=demo.index.get --username=easy-php
獲取幫助:
使用命令 php cli 或者 php cli --help