资讯专栏INFORMATION COLUMN

手把手教你开发现代PHP框架

raise_yang / 3394人阅读

摘要:本文将从零开始搭建一个现代化的框架,该框架会拥有现代框架的一切特征,如单入口,路由,依赖注入,类自动加载机制等等,如同时下最流行的框架一样。执行控制器文件中的逻辑代码,最终将数据通过对应的视图层显示出来。

本文将从零开始搭建一个现代化的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 文件即可:


RewriteEngine On
#如果文件存在就直接访问目录不进行RewriteRule
RewriteCond %{REQUEST_FILENAME} !-f
#如果目录存在就直接访问目录不进行RewriteRule
RewriteCond %{REQUEST_FILENAME} !-d
#将所有其他URL重写到 index.php/URL
RewriteRule ^(.*)$ index.php?action=$1 [PT,L]

编写路由类 Router

Router.php

 [],
        "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 路由文件

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 入口文件

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文件中查看生成的文件对应关系。

 $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框架是如何诞生的?

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/30590.html

相关文章

  • 把手教你搭APM之Skywalking搭建指南(支持Java/C#/Node.js)

    摘要:通过跟踪请求的处理过程,来对应用系统在前后端处理服务端调用的性能消耗进行跟踪,关于的介绍可以看这个链接,大规模分布式系统的跟踪系统作者刀把五链接来源知乎著作权归作者所有。 手把手教你搭APM之Skywalking 前言 什么是APM?全称:Application Performance Management 可以参考这里: 现代APM体系,基本都是参考Google的Dapper(大规模...

    ingood 评论0 收藏0
  • 把手教你5分钟用 PHP 搭建一个高性能服务化后端框架

    摘要:前言一直以来,因为标准应用方式是配合或使用,而被认为不适合做服务化后端。下面我就介绍如何用来搭建一个高性能的服务化后端框架,并且实现一个客户端调用例子。服务端我使用的框架叫,地址在这里。 前言 一直以来,PHP 因为标准应用方式是配合 php-fpm 或 apache mod 使用,而被认为不适合做服务化后端。但是随着 Workerman 和 Swoole 这些常驻进程模块的出现,PH...

    Charles 评论0 收藏0
  • 教你在不使用框架的情况下也能写出现代PHP 代码

    摘要:毕竟,我们还将在接下来的开发之旅中使用其他框架开发者编写的辅助包。缺乏行业标准必然意味着,框架中的这些组件高度耦合。如果你尝试对这个类进行单元测试,会发现根本不可行。在做单元测试的时候,我们可以很好地模拟数据库连接,并将其传入使用。 showImg(https://segmentfault.com/img/remote/1460000014180802); 我为你们准备了一个富有挑战性...

    trigkit4 评论0 收藏0
  • 把手教你基于WordPress搭建自己的个人博客

    摘要:一步一步教你基于搭建自己的个人博客,作为成熟的框架,美观,方便,插件多,更新频繁,非常适合个人博客与网站的搭建,适合新手,无需太多的代码基础。原文链接手把手教你搭建自己的网站购买购买云服务器为了搭建个人网站,首先肯定需要一个云服务器。 一步一步教你基于WordPress搭建自己的个人博客,WordPress作为成熟的CMS框架,美观,方便,插件多,更新频繁,非常适合个人博客与网站的搭建...

    vpants 评论0 收藏0
  • 后端API从入门到放弃指北

    摘要:菜鸟教程框架中文手册入门目标使用搭建通过对数据增删查改没了纯粹占行用的拜 后端API入门学习指北 了解一下一下概念. RESTful API标准] 所有的API都遵循[RESTful API标准]. 建议大家都简单了解一下HTTP协议和RESTful API相关资料. 阮一峰:理解RESTful架构 阮一峰:RESTful API 设计指南 RESTful API指南 依赖注入 D...

    Jeffrrey 评论0 收藏0

发表评论

0条评论

raise_yang

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<