手把手教你開發現代PHP框架

本文將從零開始搭建一個現代化的PHP框架,該框架會擁有現代框架的一切特徵,如單入口,路由,依賴注入,composer類自動加載機制等等,如同時下最流行的Laravel框架同樣。php

1、開發環境搭建

一、開發環境搭建

這裏咱們使用 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

2、初版-實現最基本的功能

如今,咱們先建立一個簡單的框架,實現MySQLPDO的鏈接,查詢,建立引導文件,建立項目的配置文件(包括鏈接數據庫的用戶名和密碼等)數據庫

初版本GitHub地址json

3、第二版本-單一入口和mvc架構

咱們對目錄進行重構,按照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.phphttp://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 對每一個域名都分配不一樣的配置

clipboard.png

咱們對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 來訪問了:

clipboard.png

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

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.

4、使用composer進行類自動加載

咱們如今的項目中使用了一堆的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',
);

clipboard.png

這裏的核心思想是使用了一個 spl_autoload_register() 函數,進行類按需加載,懶加載,即建立對象,而後再加載對象所須要的類文件,而不是以前那種將全部的類文件所有引入,具體請看 詳解spl_autoload_register()函數

若是新添加了類文件,咱們須要運行下面命令進行類自動從新加載:

composer dump-autoload

注意:以上方法只能將類文件自動加載,其餘文件不會進行引入的,如 function.php不會被引入,若是須要,則仍須要使用手動 require 引入。

5、實現依賴注入容器 DI Container

什麼是依賴注入容器 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文件。

6、重構控制器

1.新建控制器類

如今咱們的控制器中的代碼還都是一些麪條式的代碼, 並無使用面向對象的方式去開發,咱們來重構下,咱們須要編寫控制器類,而後讓路由指向到對應的控制器的方法,這樣在咱們之後的工做流中就會方便不少。

咱們在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都刪除了,將路由文件中的代碼改爲下面這樣:

2.更改路由文件

$router->get('', 'PagesController@home');
$router->get('about', 'PagesController@about');
$router->get('contact', 'PagesController@contact');

3.初次修改 direct() 方法

如今個人意圖是這樣的,以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');
}

4.實現私有方法 callAction()

下面考慮下 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();
}

5. ... 運算符和 explode() 函數用法

上面的 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 後新出的語法,能夠本身查閱文檔。

6.修改入口頁面的代碼

ok, 如今將入口頁面的這句代碼require Router::load('routes.php')->direct(Request::uri(), Request::method());require 去掉吧。再測試以前不要忘記了在命令行運行 composer dump-autoload 來從新加載文件。

7、全局函數 view()

下面更改下 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";
}

8、經過 composer 加載不是類的文件

下面本身把控制器中與view()相關的代碼都更改過來,而後運行composer dump-autoload,它仍是會提示找不到view()函數,緣由在於咱們的composer.json中的配置,咱們須要將配置改爲下面這樣:

{
    "autoload": {
        "classmap": [
            "./"
        ],
        "files": [
            "core/functions.php"
        ]
    }
}

上面的classmap只會加載類文件,要加載普通的文件須要使用 "files": [],好了,最後別忘記了composer dump-autoload.

9、控制器和路由的一些命名規範及命名空間

控制器和路由咱們能夠按照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框架是如何誕生的?

相關文章
相關標籤/搜索