本文將從零開始搭建一個現代化的PHP框架,該框架會擁有現代框架的一切特徵,如單入口,路由,依賴注入,composer類自動加載機制等等,如同時下最流行的Laravel框架同樣。php
這裏咱們使用 Homestead 來做爲咱們的集成開發環境,裏邊集成了PHP、MySQL咱們須要的軟件環境,或者也能夠用Xampp集成環境來開發,只要你安裝PHP、MySQL便可,我這裏用Homestead作爲開發環境。html
homestead.yaml配置:nginx
atom ~/.homestead/Homestead.yaml
--- ip: "192.168.10.10" memory: 2048 cpus: 1 provider: virtualbox authorize: ~/.ssh/id_rsa.pub keys: - ~/.ssh/id_rsa folders: - map: ~/Code to: /home/vagrant/Code sites: - map: framework.app # <--- 這裏,第五個項目,框架學習開發 to: /home/vagrant/Code/php-framework # <--- 這裏 databases: - php-framework variables: - key: APP_ENV value: local # blackfire: # - id: foo # token: bar # client-id: foo # client-token: bar # ports: # - send: 50000 # to: 5000 # - send: 7777 # to: 777 # protocol: udp
重啓vagrant
修改完 Homestead.yaml 文件後,須要從新加載配置文件信息才能生效。git
➜ ~ cd Homestead ➜ Homestead git:(7924ab4) vagrant reload --provision
修改hosts配置文件
Hosts配置域名在mac的位置: /etc/hostsgithub
192.168.10.10 digtime.app
咱們能夠選擇 Sublime,Atom,PHPStorm 這些IDE。web
如今,咱們先建立一個簡單的框架,實現MySQLPDO的鏈接,查詢,建立引導文件,建立項目的配置文件(包括鏈接數據庫的用戶名和密碼等)數據庫
初版本GitHub地址json
咱們對目錄進行重構,按照MVC功能劃分:bootstrap
├── index.php ├── config.php ├── controllers ├── core │ ├── bootstrap.php │ └── database │ ├── Connection.php │ └── QueryBuilder.php ├── models │ └── Task.php └── views
如今咱們再來添加兩張頁面about.php和contact.php, 按照以前咱們說的邏輯層和視圖層分離的原則,咱們還須要創建about.view.php和contact.view.php, 並在about.php和contact.php中引入它們的視圖文件。而後咱們能夠經過http://framework.app/about.php 或 http://framework.app/contact.php 之類的 uri 來訪問這些頁面, 像這種方式咱們稱爲多入口方式
,這種方式對於小型項目還能管理,項目過大了,管理起來就會比較麻煩了。vim
如今的框架基本都是採用單一入口的模式,什麼是單一入口,其實就是整個站點只有 index.php 這一個入口,咱們訪問的任何 uri 都是先通過 index.php 頁面,而後在index.php中根據輸入的 uri 找到對應的文件或者代碼運行,而後返回數據
。
單一入口思路:
1.訪問http://framework.app/about.php這條路徑時,先進入到 index.php
中
2.而後在 index.php
中會經過一些方法去找到與這條路由對應須要執行的文件,通常咱們會把這些文件放到控制器中。
三、執行控制器文件中的邏輯代碼,最終將數據經過對應的視圖層顯示出來。
事實上,咱們訪問 http://framework.app/about.php 這個路由時,它真正的路由是 http://framework.app/index.ph...而後經過Apache或者是Nginx作路由跳轉,就能夠實現成類式 http://framework.app/about.php 這樣的路由了。
重寫Nginx服務器路由(Homestead 下重寫):
nginx配置url重寫
// Homestead 對每一個域名都分配不一樣的配置
咱們對framework.app的Nginx配置進行路由重寫:
cd /etc/nginx/sites-available vagrant@homestead:/etc/nginx/sites-available$ sudo vim framework.app
重寫:
server { listen 80; listen 443 ssl http2; server_name framework.app; root "/home/vagrant/Code/php-framework"; ## 重寫路由 rewrite ^(.*) /index.php?action=$1 last; index index.html index.htm index.php; charset utf-8; location / { try_files $uri $uri/ /index.php?$query_string; } location = /favicon.ico { access_log off; log_not_found off; } location = /robots.txt { access_log off; log_not_found off; } access_log off; error_log /var/log/nginx/framework.app-error.log error; sendfile off; client_max_body_size 100m; location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass unix:/var/run/php/php7.0-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_intercept_errors off; fastcgi_buffer_size 16k; fastcgi_buffers 4 16k; fastcgi_connect_timeout 300; fastcgi_send_timeout 300; fastcgi_read_timeout 300; } location ~ /\.ht { deny all; } ssl_certificate /etc/nginx/ssl/framework.app.crt; ssl_certificate_key /etc/nginx/ssl/framework.app.key; }
重啓服務器:
sudo service nginx restart;
重寫路由地址後,咱們能夠直接用 http://framework.app/about
來訪問了:
Nginx 服務器會將訪問的路徑http://framework.app/about
重寫爲:http://framework.app/index.php?action=about
若是你的服務器是Apache
,則能夠在根目錄下增長.htaccess 文件便可:
<IfModule mod_rewrite.c> RewriteEngine On #若是文件存在就直接訪問目錄不進行RewriteRule RewriteCond %{REQUEST_FILENAME} !-f #若是目錄存在就直接訪問目錄不進行RewriteRule RewriteCond %{REQUEST_FILENAME} !-d #將全部其餘URL重寫到 index.php/URL RewriteRule ^(.*)$ index.php?action=$1 [PT,L] </IfModule>
Router.php
<?php class Router { protected $routes = [ 'GET' => [], 'POST' => [] ]; public function get($uri, $controller) { $this->routes['GET'][$uri] = $controller; } // 當定義POST路由時候,把對應的$uri和$controller以健值對的形式保存在$this->routes['POST']數組中 public function post($uri, $controller) { $this->routes['POST'][$uri] = $controller; } /** * 賦值路由關聯數組 * @param $routes */ public function define($routes) { $this->routes = $routes; } /** * 分配控制器路徑 * 經過用戶輸入的 uri 返回對應的控制器類的路徑 * @param $uri * 這裏的 $requestType 是請求方式,GET 或者是 POST * 經過請求方式和 $uri 查詢對應請求方式的數組中是否認義了路由 * 若是定義了,則返回對應的值,沒有定義則拋出異常。 * @return mixed * @throws \Exception */ public function direct($uri, $requestType) { if(array_key_exists($uri, $this->routes[$requestType])) { return $this->routes[$requestType][$uri]; } // 不存在,拋出異常,之後關於異常的能夠本身定義一些,好比404異常,可使用NotFoundException throw new Exception('No route defined for this URI'); } public static function load($file) { $router = new static; // 調用 $router->define([]); require ROOT . DS . $file; // 注意這裏,靜態方法中沒有 $this 變量,不能 return $this; return $router; } }
routes.php 路由文件
<?php $router->get('', 'controllers/index.php'); $router->get('about', 'controllers/about.php'); $router->get('contact', 'controllers/contact.php'); $router->post('tasks', 'controllers/add-task.php');
index.php 入口文件
<?php // 定義分隔符常量 define('DS', DIRECTORY_SEPARATOR); // 定義根目錄常量 // D:\xampps\htdocs\web\Frame define('ROOT', dirname(__FILE__)); $query = require ROOT . DS . 'core/bootstrap.php'; // 建立路由對象 require Router::load('routes.php') ->direct(Request::uri(), Request::method());
咱們來看一下入口文件index.php
,先加載路由文件routes.php
,該文件是否是和咱們Laravel的同樣呢,根據請求類型進行控制器分配,先把全部請求的路徑根據類型劃分到不一樣的請求類型屬性(GET,POST)中,而後,再根據請求的路徑來加載對應的控制器。
加載過程詳解:http://framework.app/about
經過GET請求訪問頁面:
1:
Router::load('routes.php')
,加載全部路由
routes.php
$router->get('', 'controllers/index.php'); $router->get('about', 'controllers/about.php'); $router->get('contact', 'controllers/contact.php'); $router->post('tasks', 'controllers/add-task.php');
路由類Router.php
public static function load($file) { $router = new static; // 調用 $router->define([]); require ROOT . DS . $file; // 注意這裏,靜態方法中沒有 $this 變量,不能 return $this; return $router; } 此方法等價於: public static function load($file) { $router = new static; // 調用 $router->define([]); // require ROOT . DS . $file; // 這裏調用get,post方法進行$routes屬性賦值 $router->get('', 'controllers/index.php'); $router->get('about', 'controllers/about.php'); $router->get('contact', 'controllers/contact.php'); $router->post('tasks', 'controllers/add-task.php'); // 注意這裏,靜態方法中沒有 $this 變量,不能 return $this; return $router; }
加載路由文件routes.php以後Router.php的$routes屬性結果爲:
protected $routes = [ 'GET' => [ '' => 'controllers/index.php', 'about' => 'controllers/about.php', 'contact' => 'controllers/contact.php', ], 'POST' => ['tasks' => 'controllers/add-task.php'] ];
而後再根據 direct($uri, $requestType)
方法獲取對應路徑的控制器路徑,而後 require controllers/about.php
.
咱們如今的項目中使用了一堆的require
語句, 這樣的方式對項目管理並非很好,如今有人爲 php 開發了一個叫作 composer
的依賴包管理工具,很是好用,咱們將其集成進來,composer 官方地址 https://getcomposer.org/ 按照提示進行全局安裝便可。
咱們先將 bootstrap.php
中的下面4句類引入
代碼註銷
// require 'core/Router.php'; // require 'core/Request.php'; // require 'core/database/Connection.php'; // require 'core/database/QueryBuilder.php';
而後在根目錄下創建 coomposer.json
的配置文件,輸入如下內容:
{ "autoload": { "classmap": [ "./" ] } }
上面的意思是將根目錄下的全部的類文件都加載進來
, 在命令行執行 composer install
後,在根目錄會生成出一個vendor
的文件夾,咱們之後經過 composer
安裝的任何第三方代碼都會被生成在這裏。
下面在bootstrap.php
添加require 'vendor/autoload.php';
便可。咱們能夠在vendor/composer/autoload_classmap.php
文件中查看生成的文件對應關係。
<?php // autoload_classmap.php @generated by Composer $vendorDir = dirname(dirname(__FILE__)); $baseDir = dirname($vendorDir); return array( 'Connection' => $baseDir . '/core/database/Connection.php', 'QueryBuilder' => $baseDir . '/core/database/QueryBuilder.php', 'Request' => $baseDir . '/core/Request.php', 'Router' => $baseDir . '/core/Router.php', 'Task' => $baseDir . '/models/Task.php', );
這裏的核心思想是使用了一個 spl_autoload_register()
函數,進行類按需加載,懶加載,即建立對象,而後再加載對象所須要的類文件,而不是以前那種將全部的類文件所有引入,具體請看 詳解spl_autoload_register()函數。
若是新添加了類文件,咱們須要運行下面命令進行類自動從新加載:
composer dump-autoload
注意:以上方法只能將類文件自動加載,其餘文件不會進行引入的,如 function.php不會被引入,若是須要,則仍須要使用手動 require 引入。
什麼是依賴注入容器 DI Container
? 一個聽上去很是高大上的東西,先不要去糾結字面的意思,你能夠這麼想,把咱們的 APP 想象成一個很大的盒子,把咱們所寫的一些功能,好比說配置,數據庫操做等都扔到這個盒子裏,在扔進去的時候你要給它們貼一個標籤,之後能夠經過這個標籤把它們取出來用。大致就是這個意思
。
咱們來看bootstrap.php
中的代碼, 其實 $app
這個數組就能夠當作是一個容器
,咱們把配置文件扔到數組中,貼上config的標籤(也就是健),把QueryBuilder也扔進去了,貼上標籤database。以後咱們能夠經過$app['config']這樣拿出咱們須要的值。
咱們爲什麼不把$app數組作成一個對象呢! 這樣咱們之後能夠爲其添加不少的屬性和方法,會方便不少,須要對象就必需要有類,咱們立刻就能夠在core文件夾內創建一個 App.php 的文件,當中包含App類。
下面看看咱們須要哪些方法,先看 $app['config'] = require 'config.php';
這一句是把config.php放進到App的容器中,如今經常使用的說法是 註冊config 到App
, 或者是綁定config 到App
, 那咱們須要的方法多是這樣的。
$app->bind('config', require 'config.php'); // 或者 $app->register('config', require 'config.php'); // 或者 App::bind(config', require 'config.php'); // 或者 App::register('config', require 'config.php');
在咱們寫類的時候,可能不知道怎麼動手,能夠先嚐試着調用假定存在的方法,再回頭去完善類,以前咱們也都是這麼作的,這樣相對會容易些,上面的幾種方法我的感受App::bind(config', require 'config.php');
更好些,而後要取出config可使用 App::get('config')
方法,下面去實現這兩個方法。在core/App.php
中
class App { protected static $registries = []; public static function bind($key, $value) { static::$registries[$key] = $value; } public static function get($key) { if (! array_key_exists($key, static::$registries)) { throw new Exception("No {$key} is bound in the container."); } return static::$registries[$key]; } }
bootstrap.php 中目前代碼以下:
require 'vendor/autoload.php'; App::bind('config', require 'config.php'); App::bind('database', new QueryBuilder( Connection::make(App::get('config')['database']) ));
將全部使用到$app['config']和$app['database']的地方所有用App::get('config')
和App::get('database')
替換過來,毫無疑問的會提示「找不到APP的錯誤」,緣由是在咱們的autoload_classmap.php文件中並無導入App.php文件,咱們須要在命令行執行 composer dump-autoload
來從新生成autoload_classmap.php
文件。
如今咱們的控制器中的代碼還都是一些麪條式的代碼, 並無使用面向對象的方式去開發,咱們來重構下,咱們須要編寫控制器類,而後讓路由指向到對應的控制器的方法,這樣在咱們之後的工做流中就會方便不少。
咱們在controllers文件夾下創建 PagesController.php
的文件, 編寫如下的代碼,將以前控制器中的文件中的代碼都以方法的形式寫在這個類中
class PagesController { public function home() { $tasks = App::get('database')->selectAll('tasks', 'Task'); require 'views/index.view.php'; } public function about() { require 'views/about.view.php'; } public function contact() { require 'views/contact.view.php'; } }
如今能夠將controllers文件夾下的index.php, about.php, contact.php都刪除了,將路由文件中的代碼改爲下面這樣:
$router->get('', 'PagesController@home'); $router->get('about', 'PagesController@about'); $router->get('contact', 'PagesController@contact');
如今個人意圖是這樣的,以about路由舉例,當咱們訪問about, 就會調用PagesController類的about方法, 在about方法中直接運行邏輯代碼。因此咱們須要修改Router.php中的direct()方法。
目前direct()是根據相對路徑返回對應控制器類的路徑,而後在入口頁面將其引入進來執行,如今咱們只須要經過實例化控制器類,而後調用對應的方法便可。 那direct()的核心代碼應該是類式這樣的:(new PagesController)->about();
咱們暫且把這個功能命名爲 callAction() 方法,先將定已經有了這個方法, 咱們先去 direct()方法中調用它, 以下:
public function direct($uri, $requestType) { if (array_key_exists($uri, $this->routes[$requestType])) { return $this->callAction('這裏應該有參數'); } throw new Exception('No route defined for this URI'); }
下面考慮下 Router 類中的 callAction() 方法該怎麼實現,剛纔說了這個方法的核心是 (new Controller)->action();
很少考慮,咱們給這個方法兩個參數,$controller 和 $action, 代碼以下:
private function callAction($controller, $action) { $controllerObj = new $controller; if (! method_exists($controllerObj, $action)) { throw new Exception( "{$controller} does not respond to the {$action} action." ); } return $controllerObj->$action(); }
上面的 method_exists($obj, $action)
方法是判斷一個對象中是否某個方法,那在 direct() 中調用callAction()的參數咱們該如何獲取呢? 咱們如今的 $this->routes$requestType的值是類式於 PagesController@about 這樣的字符串,咱們只需將該值拆分爲 ['PagesController', 'about'] 這樣的數組,而後使用 php5.6 以後出現的 ...運算符,將其做爲參數傳遞,關於拆分字符串爲數組,php 也給咱們提供了一個這樣的函數,叫作 explode(), 咱們先看下這個函數的用法,
打開終端,輸入 php --interactive 進入命令行交互模式
好了,如今就能夠修改下direct() 這個方法了,以下:
public function direct($uri, $requestType) { if (array_key_exists($uri, $this->routes[$requestType])) { return $this->callAction( ...explode('@', $this->routes[$requestType][$uri]) ); } throw new Exception('No route defined for this URI'); }
關於...explode('@', $this->routes$requestType) 這裏的 ... 操做符, 它會把一維數組中的第一個元素做爲參數1, 第二個元素做爲參數2,以此類推,這是 php5.6 後新出的語法,能夠本身查閱文檔。
ok, 如今將入口頁面的這句代碼require Router::load('routes.php')->direct(Request::uri(), Request::method());
的 require
去掉吧。再測試以前不要忘記了在命令行運行 composer dump-autoload
來從新加載文件。
下面更改下 PagesController 的 require 'views/about.view.php';
這句代碼,咱們改爲 return view('about');
這樣,可讀性會好不少。同時在 psr標準中
也有這樣的規定,在聲明一個類的文件中是不能存在 require
代碼的。
咱們在core下建立一個functions.php
的文件,把全部的全局函數都放在這裏,準確來講幫助函數的文件不該該放在這裏,它並不屬於核心文件,可是爲了咱們這裏寫的幫助函數基本都是給咱們的框架使用的,不設計業務開發,因此暫時仍是先放這裏。view()函數很簡單,以下:
function view($name) { $name = trim($name, '/'); return require "views/{$name}.view.php"; }
在PagesController的home 方法當中有$tasks對象集合, 咱們怎麼傳遞它到view()函數中呢? 咱們須要給view()設置第二個數組形式的參數,調用view()的時候,將數據以數組的形式傳遞給view()便可,以下:
return view('index', ['tasks' => $tasks]);
如今在view()函數中會出現問題了,咱們傳入的數據是一個數組,而在index.view.php
中使用的是$tasks這樣的變量,怎麼轉化?使用PHP提供的extract()
函數能夠作到這點,它能夠將數組中的元素以變量的形式導入到當前的符號表,這句話很差懂,咱們來演示下就明白了,仍是進入 php 的命令行交互模式, 以下:
使用了extract()函數就會自動幫咱們定義好與數組 key 同名的變量,並將 key 對應的 value 賦值給了該變量,好了,下面咱們把view()方法完善下,以下:
function view($name, $data =[]) { extract($data); return require "views/{$name}.view.php"; }
下面本身把控制器中與view()相關的代碼都更改過來,而後運行composer dump-autoload
,它仍是會提示找不到view()函數,緣由在於咱們的composer.json
中的配置,咱們須要將配置改爲下面這樣:
{ "autoload": { "classmap": [ "./" ], "files": [ "core/functions.php" ] } }
上面的classmap只會加載類文件,要加載普通的文件須要使用 "files": [],好了,最後別忘記了composer dump-autoload
.
控制器和路由咱們能夠按照Laravel的風格:
// tasks 的列表頁 $router->get('tasks', 'TasksController@index'); // TasksController.php class TasksController { public function index() { $tasks = App::get('database')->selectAll('tasks', 'Task'); return view('index', compact('tasks')); } public function store() { App::get('database')->create('tasks', [ 'description' => $_POST['description'], 'completed' => 0 ]); return redirect('/'); } }
從 PHP5.3 開始就支持命名空間了,關於命名空間的介紹看官方文檔: http://php.net/manual/zh/lang... 。其實也很簡單,你把命名空間想象層文件夾就行
本項目Github地址:php-framework
參考文章:論PHP框架是如何誕生的?