手把手教你开发现代PHP框架
本文将从零开始搭建一个现代化的PHP框架,该框架会拥有现代框架的一切特征,如单入口,路由,依赖注入,composer类自动加载机制等等,如同时下最流行的Laravel框架一样。
一、开发环境搭建
1、开发环境搭建
这里我们使用 Homestead 来作为我们的集成开发环境,里边集成了PHP、MySQL我们需要的软件环境,或者也可以用Xampp集成环境来开发,只要你安装PHP、MySQL即可,我这里用Homestead做为开发环境。
homestead.yaml配置:
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 文件后,需要重新加载配置文件信息才能生效。
➜ ~ cd Homestead ➜ Homestead git:(7924ab4) vagrant reload --provision
修改hosts配置文件
Hosts配置域名在mac的位置: /etc/hosts
192.168.10.10 digtime.app
2、开发工具
我们可以选择 Sublime,Atom,PHPStorm 这些IDE。
二、第一版-实现最基本的功能
现在,我们先创建一个简单的框架,实现MySQLPDO的连接,查询,创建引导文件,创建项目的配置文件(包括连接数据库的用户名和密码等)
第一版本GitHub地址
三、第二版本-单一入口和mvc架构
我们对目录进行重构,按照MVC功能划分:
├── 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 来访问这些页面, 像这种方式我们称为多入口方式
,这种方式对于小型项目还能管理,项目过大了,管理起来就会比较麻烦了。
现在的框架基本都是采用单一入口的模式,什么是单一入口,其实就是整个站点只有 index.php 这一个入口,我们访问的任何 uri 都是先经过 index.php 页面,然后在index.php中根据输入的 uri 找到对应的文件或者代码运行,然后返回数据
。
单一入口思路:
1.访问http://framework.app/about.php这条路径时,先进入到 index.php
中
2.然后在 index.php
中会通过一些方法去找到与这条路由对应需要执行的文件,一般我们会把这些文件放到控制器中。
3、执行控制器文件中的逻辑代码,最终将数据通过对应的视图层显示出来。
事实上,我们访问 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
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
.
四、使用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', );
这里的核心思想是使用了一个 spl_autoload_register()
函数,进行类按需加载,懒加载,即创建对象,然后再加载对象所需要的类文件,而不是之前那种将所有的类文件全部引入,具体请看 详解spl_autoload_register()函数。
如果新添加了类文件,我们需要运行下面命令进行类自动重新加载:
composer dump-autoload
注意:以上方法只能将类文件自动加载,其他文件不会进行引入的,如 function.php不会被引入,如果需要,则仍需要使用手动 require 引入。
五、实现依赖注入容器 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
文件。
六、重构控制器
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
来重新加载文件。
七、全局函数 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"; }
八、通过 composer 加载不是类的文件
下面自己把控制器中与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框架是如何诞生的?