侧边栏壁纸
  • 累计撰写 244 篇文章
  • 累计创建 16 个标签
  • 累计收到 0 条评论
隐藏侧边栏

PHP 实现简单的 MVC 框架

kaixindeken
2021-02-22 / 0 评论 / 0 点赞 / 81 阅读 / 29,090 字

使用 PHP 实现一个简单的 MVC 框架,包含模型、视图、控制器以及模板解析等部分。可以了解MVC框架的基本原理和运行流程,学习面向对象编程和MVC设计模式,并学习开发中的一些注意事项。对其他框架学习有很大的帮助作用。

一、框架搭建

开启内置服务器:

php -S localhost:8080

1、编码规范

本项目遵循 PSR-2 命名规范和 PSR-4 自动加载规范,并且注意如下规范:

目录和文件
  • 目录不强制规范,驼峰及小写+下划线模式均支持;
  • 类库、函数文件统一以.php为后缀;
  • 类的文件名均以命名空间定义,并且命名空间的路径和类库文件所在路径一致;
  • 类名和类文件名保持一致,统一采用驼峰法命名(首字母大写);
函数和类、属性命名
  • 函数的命名使用小写字母和下划线(小写字母开头)的方式,例如 get_client_ip
  • 方法的命名使用驼峰法,并且首字母小写,例如 getUserName
  • 属性的命名使用驼峰法,并且首字母小写,例如 tableNameinstance
常量和配置
  • 常量以大写字母和下划线命名,例如 APP_PATHCORE_PATH
  • 配置参数以小写字母和下划线命名,例如 url_route_onurl_convert
数据表和字段
  • 数据表和字段采用小写加下划线方式命名,并注意字段名不要以下划线开头,例如lab_user 表和 lab_name字段,不建议使用驼峰和中文作为数据表字段命名。

2、目录结构

在你的主目录下建立项目的目录 Labframe,我们开发的这个框架就叫 Labframe ,你也可以给你的框架取一个好听的名字。

接下来开始搭建框架的主要目录,目录结构如下图:

目录详解

  • app/:应用程序目录。用户在其中进行功能开发 > - home/:模块目录。一般分为前台(home)和后台模块(admin),这里只建立的前台模块 > - controller/:前台控制器目录,存放控制器文件。主要处理前台模块的操作 > ```
    • model/:前台模型目录,存放模型文件。处理前台模型的相关操作
    • view/:前台视图目录,存放视图文件。前台展示的模板文件。
  • config/:配置文件目录 > - config.php:框架的配置文件
  • runtime/:运行时目录,保存框架运行时产生的数据。 > - cache/:缓存目录。用于存放缓存的模板文件 > - complie/:编译目录。用于存放经过编译的模板文件
    • log/:日志文件。用于记录框架运行期间的行为
  • sys/:框架目录。用于存放框架文件 > - core/:框架核心目录。存放框架运行所需的核心文件 > - start.php:框架启动文件。
    • index.php:框架入口文件。所有请求都经过此文件处理

目录中有一点需要再讲一下:index.php。这是整个框架的入口文件,叫做单一入口文件。这里涉及到一个知识点:单一入口模式和多入口模式。

  • 单一入口模式:单一入口通常是指一个项目或者应用具有一个统一(但并不一定是唯一)的入口文件,也就是说项目的所有功能操作都是通过这个入口文件进行的,并且往往入口文件是第一步被执行的。
  • 多入口模式:多入口即是通过不同的入口文件访问后台。比如常用的多入口:index.php(前台入口),admin.php(后台入口)

我们的框架采用单入口机制。

3、全局配置

由于我们的框架规模比较小,所以我们可以只需要一个配置文件,不区分前后台,作为全局配置。

编辑 config.php ,基础配置如下(可根据情况自行修改):

<?php 

return [
      //数据库相关配置
    'db_host'     =>    '127.0.0.1',
    'db_user'     =>    'root',
    'db_pwd'     =>    '',
    'db_name'     =>    'labframe',
    'db_table_prefix'     =>    'lab_',    //数据表前缀
    'db_charset'     =>    'utf8',

    'default_module'    => 'home',    //默认模块
    'default_controller'     =>    'Index',    //默认控制器
    'default_action'     =>    'index',    //默认操作方法
    'url_type'          =>      2,    // RUL模式:【1:普通模式,采用传统的 url 参数模式】【2:PATHINFO 模式,也是默认模式】

    'cache_path'     =>    RUNTIME_PATH . 'cache' .DS,    //缓存存放路径
    'cache_prefix'     =>    'cache_',    //缓存文件前缀
    'cache_type'     =>    'file',        //缓存类型(只实现 file 类型)
    'compile_path'     =>    RUNTIME_PATH . 'compile' .DS,    //编译文件存放路径

    'view_path'    => APP_PATH .'home' . DS . 'view' . DS,    // 模板路径
    'view_suffix'  => '.php',    // 模板后缀

    'auto_cache'     => true,    //开启自动缓存
    'url_html_suffix'        => 'html',     // URL伪静态后缀

];

上面用到了一些暂时尙未定义常量,我们将会在后面的文件中定义:

RUNTIME_PATH:运行时目录路径
DS:目录分隔符。在win下为 '\',在 linux 下为 '/'
APP_PATH:应用程序目录路径

4、数据库准备

上面的配置文件中配置了一些连接数据库的相关参数,所以这里我们可以先建立好相关的数据库和数据表。

开启数据库服务:

sudo service mysql start

进入MySQL:

mysql -u root -p

输入密码进入后,键入以下 sql 语句新建数据库:

CREATE DATABASE IF NOT EXISTS labframe;

新建数据表(以 user 表作为示例):

USE labframe;
CREATE TABLE lab_user(
    `id` INT(10) NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `name` varchar(30) NOT NULL,
    `password` varchar(32) NOT NULL
);

注意,上面的字段名是用 `` 包裹的,而不是 '',执行的时候一定要注意。 向其中插入一条测试数据:

INSERT INTO lab_user (name,password) VALUES ('admin','shiyanlou');

数据库准备完毕。

5、入口文件

上面提到过的 index.php 就是框架的入口文件。接下来可以向里面写点东西了。

<?php 
//框架入口文件
define('DS', DIRECTORY_SEPARATOR);    //定义目录分隔符(上面用到过)
define('ROOT_PATH', __DIR__ . DS);    //定义框架根目录
require 'sys/start.php';    //引入框架启动文件
core\App::run();    //框架启动

就这么简单几句就行了

6、框架启动文件

编辑 start.php 文件:

<?php 
//框架启动文件
define('APP_PATH', ROOT_PATH . 'app' . DS);    //定义应用程序目录路径
define('RUNTIME_PATH', ROOT_PATH . 'runtime' . DS);    //定义框架运行时目录路径
define('CONF_PATH', ROOT_PATH . 'config' . DS);        //定义全局配置目录路径
define('CORE_PATH', ROOT_PATH . 'sys' .DS . 'core' . DS);    //定义框架核心目录路径

//引入自动加载文件
require CORE_PATH.'Loader.php';

//实例化自动加载类
$loader = new core\Loader();
$loader->addNamespace('core',ROOT_PATH . 'sys' .DS . 'core');        //添加命名空间对应base目录
$loader->addNamespace('home',APP_PATH . 'home');
$loader->register();    //注册命名空间

//加载全局配置
\core\Config::set(include CONF_PATH . 'config.php');

在上面的框架启动文件中,我们定义了一些必要的路径常量,引入了自动加载类文件,并且添加并注册了两个主要的命名空间。最后将我们的全局配置文件载入框架。

7、自动加载类

相信很多人都遇到过这个问题,当一个文件需要使用其他文件的其他类的时候,往往需要使用很多的 requireinclude 来引入这些类文件,当需要的类特别多的时候,这样做就显得很笨拙,不仅使文件变的混乱不堪,各种引入关系也相当复杂。所以在大型项目中,往往采用按需加载,自动加载的方式来实现类的加载。本框架自动加载的实现由 core\Loader 类库完成,自动加载规范符合PHP的 PSR-4

Labframe采用了命名空间的特性,因此只需要给类库正确定义所在的命名空间,而命名空间的路径与类库文件的目录一致,那么就可以实现类的自动加载。

core/ 是框架运行的核心目录,我们的自动加载类应该放在这里。在 core/ 下新建一个加载类文件:Loader.php。编辑如下:

<?php 
namespace core;

class Loader
{
    /**
     * An associative array where the key is a namespace prefix and the value
     * is an array of base directories for classes in that namespace.
     *
     * @var array
     */
    protected static $prefixes = [];

    /**
     * 在 SPL 自动加载器栈中注册加载器
     * 
     * @return void
     */
    public static function register()
    {
        spl_autoload_register('core\\Loader::loadClass');
    }

    /**
     * 添加命名空间前缀与文件base目录对
     *
     * @param string $prefix 命名空间前缀
     * @param string $base_dir 命名空间中类文件的基目录
     * @param bool $prepend 为 True 时,将基目录插到最前,这将让其作为第一个被搜索到,否则插到将最后。
     * @return void
     */
    public static function addNamespace($prefix, $base_dir, $prepend = false)
    {
        // 规范化命名空间前缀
        $prefix = trim($prefix, '\\') . '\\';

        // 规范化文件基目录
        $base_dir = rtrim($base_dir, '/') . DIRECTORY_SEPARATOR;
        $base_dir = rtrim($base_dir, DIRECTORY_SEPARATOR) . '/';

        // 初始化命名空间前缀数组
        if (isset(self::$prefixes[$prefix]) === false) {
            self::$prefixes[$prefix] = [];
        }

        // 将命名空间前缀与文件基目录对插入保存数组
        if ($prepend) {
            array_unshift(self::$prefixes[$prefix], $base_dir);
        } else {
            array_push(self::$prefixes[$prefix], $base_dir);
        }
    }

    /**
     * 由类名载入相应类文件
     *
     * @param string $class 完整的类名
     * @return mixed 成功载入则返回载入的文件名,否则返回布尔 false
     */
    public static function loadClass($class)
    {
        // 当前命名空间前缀
        $prefix = $class;

        // 从后面开始遍历完全合格类名中的命名空间名称, 来查找映射的文件名
        while (false !== $pos = strrpos($prefix, '\\')) {

            // 保留命名空间前缀中尾部的分隔符
            $prefix = substr($class, 0, $pos + 1);

            // 剩余的就是相对类名称
            $relative_class = substr($class, $pos + 1);

            // 利用命名空间前缀和相对类名来加载映射文件
            $mapped_file = self::loadMappedFile($prefix, $relative_class);
            if ($mapped_file) {
                return $mapped_file;
            }

            // 删除命名空间前缀尾部的分隔符,以便用于下一次strrpos()迭代
            $prefix = rtrim($prefix, '\\');   
        }

        // 找不到相应文件
        return false;
    }

    /**
     * 根据命名空间前缀和相对类来加载映射文件
     * 
     * @param string $prefix The namespace prefix.
     * @param string $relative_class The relative class name.
     * @return mixed Boolean false if no mapped file can be loaded, or the
     * name of the mapped file that was loaded.
     */
    protected static function loadMappedFile($prefix, $relative_class)
    {
        //命名空间前缀中有base目录吗??
        if (isset(self::$prefixes[$prefix]) === false) {
            return false;
        }

        // 遍历命名空间前缀的base目录
        foreach (self::$prefixes[$prefix] as $base_dir) {

            // 用base目录替代命名空间前缀, 
            // 在相对类名中用目录分隔符'/'来替换命名空间分隔符'\', 
            // 并在后面追加.php组成$file的绝对路径
            $file = $base_dir
                  . str_replace('\\', DIRECTORY_SEPARATOR, $relative_class)
                  . '.php';
            $file = $base_dir
                  . str_replace('\\', '/', $relative_class)
                  . '.php';

            // 当文件存在时,载入之
            if (self::requireFile($file)) {
                // 完成载入
                return $file;
            }
        }

        // 找不到相应文件
        return false;
    }

    /**
     * 当文件存在,则从文件系统载入之
     * 
     * @param string $file 需要载入的文件
     * @return bool 当文件存在则为 True,否则为 false
     */
    protected static function requireFile($file)
    {
        if (file_exists($file)) {
            require $file;
            return true;
        }
        return false;
    }
}

以上 Loader.php 便可实现类的按需自动加载。可能理解起来比较困难,有兴趣的同学可以多花点时间理解一下 Loader 类的实现方法。文档中也不太好详细解释,更多详情请参阅 PSR-4-autoloade。上面的代码主要掌握两个重点:命名空间自动加载。如果暂时理解起来比较困难或不感兴趣的同学可以先不用管这些,就仿照上面写,知道如何使用就行。

还记得 start.php 中的添加命名空间的方法么,我们添加了两个根命名空间以及其对应的base目录:core ->/sys/corehome->/app/home,以后当我们在两个base目录下编写类的时候,只要明确类文件与base目录的相对路径,赋予其正确的命名空间, Loader 便可以正确的加载他们。

二、框架核心类(一)

1、框架启动类

sys/core/ 下新建一个 App.php 文件。先编辑如下:

<?php 
namespace core;        //定义命名空间

use core\Config;    //使用配置类
use core\Router;    //使用路由类
/**
* 框架启动类
*/
class App
{
      //启动
      public static function run()
    {

    }

      //路由分发
      public static function dispatch($url_array = [])
    {

    }
}

启动类主要包含了两个核心的方法:run(启动),dispatch(路由分发)。

  • 启动

    执行框架的运行流程。首先需要分析路由,然后分发路由。

public static $router;    //定义一个静态路由实例

public static function run()
    {
        self::$router = new Router();    //实例化路由类
        self::$router->setUrlType(Config::get('url_type'));     //读取配置并设置路由类型
        $url_array = self::$router->getUrlArray();    //获取经过路由类处理生成的路由数组
        self::dispatch($url_array);        //根据路由数组分发路由
    }

举一个简单的例子,由于我们默认的路由配置模式为 2:pathinfo 模式,所以当你在地址栏输入:localhost:8080/home/index/index.html 时,经过路由类处理之后,就会得到一个路由数组,形如:['module'=>'home','controller'=>'index','action'=>'index'],然后执行路由分发操作,将会执行home模块下的index控制器下的index方法,这样就完成了路由的访问。

  • 路由分发

    将会根据路由数组分发到具体的模块、控制器和方法。

public static function dispatch($url_array = [])
    {
        $module = '';
        $controller = '';
        $action= '';
        if (isset($url_array['module'])) {    //若路由中存在 module,则设置当前模块
            $module = $url_array['module'];
        } else {
            $module = Config::get('default_module');    //不存在,则设置默认的模块(home)
        }
        if (isset($url_array['controller'])) {    //若路由中存在 controller,则设置当前控制器,首字母大写
            $controller = ucfirst($url_array['controller']);
        } else {
            $controller = ucfirst(Config::get('default_controller'));    //不存在,则设置默认的控制器(index),首字母大写
        }
          //拼接控制器文件路径
        $controller_file = APP_PATH . $module . DS . 'controller' .DS . $controller . 'Controller.php';        
        if (isset($url_array['action'])) {        //同上,设置操作方法
            $action = $url_array['action'];
        } else {
            $action = Config::get('default_action');
        }
          //判断控制器文件是否存在
        if (file_exists($controller_file)) {
            require $controller_file;        //引入该控制器
            $className  = 'module\controller\IndexController';        //命名空间字符串示例
            $className = str_replace('module',$module,$className);    //使用字符串替换功能,替换对应的模块名和控制器名
            $className = str_replace('IndexController',$controller.'Controller',$className);
            $controller = new $className;    //实例化具体的控制器
          //判断访问的方法是否存在
            if (method_exists($controller,$action)) {
              $controller->setTpl($action);    //设置方法对应的视图模板
                $controller->$action();        //执行该方法
            } else {
                die('The method does not exist');
            }
        } else {
            die('The controller does not exist');
        }
    }

因为我们的类的命名规范采用驼峰法,且首字母大写,所以在分发控制器的时候需要确保 url 中的控制器名首字母大写。同在在 Windows 下面是不需要区分大小写的,但是在Linux 环境下却要格外注意。

上面代码的主要流程:定位模块 --> 定位控制器 ---> 定位方法(同时设置对应的模板)。这样就完成了路由分发的功能。

2、配置类

Loader 中我们使用到了 Config 来读取各种配置值,所以我们马上来实现 Config 来完成对应的功能。 在 core/ 下新建一个 Config.php 文件,这就是配置类。主要结构如下:

<?php 
namespace core;

/**
* 配置类
*/
class Config
{
    private static $config = [];    //存放配置

      //读取配置
    public static function get($name = null)
    {

    }
    //动态设置配置
    public static function set($name,$value = null)
    {

    }
    //判断是否存在配置
    public static function has($name)
    {

    }
    //加载其他配置文件
    public static function load($file)
    {

    }

}

核心方法就只有上面四个

  • 读取配置值:
public static function get($name = null)
{
    if (empty($name)) {
        return self::$config;
    }
  //若存在配置项,则返回配置值。否则返回 null
    return isset(self::$config[strtolower($name)]) ?self::$config[strtolower($name)] : null;
    }

动态设置配置项:

public static function set($name,$value = null)
{
    if (is_string($name)) {            //字符串,直接设置
            self::$config[strtolower($name)] = $value;
    } elseif (is_array($name)) {    //数组,循环设置
        if (!empty($value)) {
            self::$config[$value] = isset(self::$config[$value]) ?array_merge(self::$config[$value],$name) : self::$config[$value] = $name;
        } else {
            return self::$config = array_merge(self::$config,array_change_key_case($name));
        }
    } else {        //配置方式错误,返回当前全部配置
        return self::$config;
       }
}

上面的代码可以根据传入的参数类型来对应不同的配置方法。

  • 只有 name(string):name => null;
  • name(string) 和 value:name => value;
  • name(array):array_merge
  • name(array)和 value:array_merge 或者 value => $name (二级配置)
  • 是否存在配置
    public static function has($name)
    {
        return isset(self::$config[strtolower($name)]);
    }
  • 加载其他配置文件
    public static function load($file)
    {
        if (is_file($file)) {
            $type = pathinfo($file,PATHINFO_EXTENSION);
            if ($type != 'php') {
                return self::$config;
            } else {
                return self::set(include $file);
            }
        } else {
            return self::$config;
        }
    }

我们默认只是用 config/config.php 作为唯一的全局配置文件。但是如果你想为某个模块做单独的配置,或者需要覆盖默认的配置项,那么你就可以使用这个方法来实现 。

这样,我们的配置类就完成了,可以任意的读取、设置、加载配置项。

3、路由类

主要负责处理路由信息,将路由地址处理为路由数组,供启动类分发。当然,你也可以把启动类里的路由分发功能放到路由类中实现。在core/ 下新建一个 Router.php 文件,作为路由类。主要结构如下:

<?php 
namespace core;

/**
* 路由类
*/
class Router
{
    public $url_query;    //URL 串
    public $url_type;    //UTL 模式
    public $route_url =[];    //URL数组

    function __construct()
    {

    }

      //设置 URL 模式
    public function setUrlType($url_type = 2)
    {

    }

    //获取URL数组
    public function getUrlArray()
    {

    }

      //处理 URL
    public function makeUrl()
    {

    }

      //将参数形式转为数组
    public function queryToArray()
    {

    }

      //将 pathinfo 转为数组
    public function pathinfoToArray()
    {

    }
  • 构造方法
function __construct()
{
    $this->url_query = parse_url($_SERVER['REQUEST_URI']);
}

使用 $_SERVER['REQUEST_URI']是取得当前URL的 路径地址。再使用 parse_url 解析 url:主要分为路径信息 [path] 和 参数信息 [query] 两部分。

  • 设置URL模式
    public function setUrlType($url_type = 2)
    {
        if ($url_type > 0 && $url_type < 3) {
            $this->url_type = $url_type;
        }else{
            exit('Specifies the URL does not exist!');
        }
    }

默认为 2 => pathinfo 模式。

  • 获取经过处理的 URL数组
public function getUrlArray()
{
  $this->makeUrl();
  return $this->route_url;
}
  • 处理 URL
public function makeUrl()
    {
        switch ($this->url_type) {
            case 1:
                $this->queryToArray();
                break;

            case 2:
                $this->pathinfoToArray();
                break;
        }
    }

根据 url 模式的不同选择不同的方式构造 url 数组。

  • 将参数形模式转为数组
// ?xx=xx&xx=xx
public function queryToArray()
    {
        $arr = !empty($this->url_query['query']) ? explode('&',$this->url_query['query']) : [];
        $array = $tmp = [];
        if (count($arr) > 0) {
            foreach ($arr as $item) {
                $tmp = explode('=',$item);
                $array[$tmp[0]] = $tmp[1];
            }
            if (isset($array['module'])) {
                $this->route_url['module'] = $array['module'];
                unset($array['module']);
            }
            if (isset($array['controller'])) {
                $this->route_url['controller'] = $array['controller'];
                unset($array['controller']);
            }
            if (isset($array['action'])) {
                $this->route_url['action'] = $array['action'];
                unset($array['action']);
            }
            if (isset($this->route_url['action']) && strpos($this->route_url['action'],'.')) {
                //判断url方法名后缀 形如 'index.html',前提必须要在地址中以 localhost:8080/index.php 开始
              if (explode('.',$this->route_url['action'])[1] != Config::get('url_html_suffix')) {
                    exit('suffix errror');
                } else {
                    $this->route_url['action'] = explode('.',$this->route_url['action'])[0];
                }
            }
        } else {
            $this->route_url = [];
        }
    }
  • 将 pathinfo 转为数组
// xxx/xxx/xx    
public function pathinfoToArray()
    {
        $arr = !empty($this->url_query['path']) ? explode('/',$this->url_query['path']) : [];
        if (count($arr) > 0) {
            if ($arr[1] == 'index.php') {   //以 'localhost:8080/index.php'开始
                if (isset($arr[2]) && !empty($arr[2])) {
                    $this->route_url['module'] = $arr[2];
                }
                if (isset($arr[3]) && !empty($arr[3])) {
                    $this->route_url['controller'] = $arr[3];
                }
                if (isset($arr[4]) && !empty($arr[4])) {
                    $this->route_url['action'] = $arr[4];
                }
                  //判断url后缀名
                if (isset($this->route_url['action']) && strpos($this->route_url['action'],'.')) {
                    if (explode('.',$this->route_url['action'])[1] != Config::get('url_html_suffix')) {
                        exit('Incorrect URL suffix');
                    } else {
                        $this->route_url['action'] = explode('.',$this->route_url['action'])[0];
                    }
                }
            } else {                        //直接以 'localhost:8080'开始
                if (isset($arr[1]) && !empty($arr[1])) {
                    $this->route_url['module'] = $arr[1];
                }
                if (isset($arr[2]) && !empty($arr[2])) {
                    $this->route_url['controller'] = $arr[2];
                }
                if (isset($arr[3]) && !empty($arr[3])) {
                    $this->route_url['action'] = $arr[3];
                }
            }

        } else {
            $this->route_url = [];
        }
    }

若服务器开启了rewrite 模块,可以隐藏 index.php。在本课程中,若要添加 url 后缀名,则必须以 'localhost:8080/index.php' 开头。若以 'localhost:8080' 开头,则末尾不能添加 '.html' 或 '.php' 等后缀名

三、框架核心类(二)

1、控制器类

也就是 MVC 中的 C 模块。主要负责处理一些具体的业务逻辑,并调用模型进行操作。不过我们现在即将编写的类属于控制器的基类,主要用于封装一些高层次的操作,让具体的控制器继承于它。所以我们不必要写过多的内容。

core/ 下新建一个 Controller.php 作为控制器基类。主要结构如下:

<?php 
namespace core;

use core\View;    //使用视图类
/**
* 控制器基类
*/
class Controller
{
    protected $vars = [];    //模板变量
    protected $tpl;        //视图模板

      //变量赋值
    final protected function assign($name,$value = '')
    {

    }
      //设置模板
    final public function setTpl($tpl='')
    {

    }
      //模板展示
    final protected function display()
    {

    }
}
  • 变量赋值

    主要用于在模板中展示变量的数据。平时,如果我们需要在html模板中打印一个变量,一般都会采用 <?php echo $var; ?> 这种方式输出,但是当需要输出的变量很多的时候,如果每次都这样去写的话,那将是非常繁琐且无意义的工作。正是有这样的需求,模板引擎应运而生,只需要写下类似 {$var} 的形式便可以完成刚才的功能。关于模板引擎的内容,待会儿再讲解。这里你需要知道的就是,你想要展示数据,先给它赋值。

//将其设置为 final,子类不能改写    
final protected function assign($name,$value = '')
    {
        if (is_array($name)) {
            $this->vars = array_merge($this->vars,$name);
            return $this;
        } else {
            $this->vars[$name] = $value;
        }
    }

assign 方法也同样延续了和配置参数一样的方式,支持单个变量赋值,也支持数组批量赋值。

  • 模板设置

    如果你还有印象的话,我应该在启动文件中的路由分发部分提到过关于模板设置的问题,那里也用到了这个方法。可以自动匹配当前方法对应的视图模板文件。

    //同样为 final 类型,子类不能改写    
    final public function setTpl($tpl='')
    {
      $this->tpl = $tpl;
    }
    
  • 视图展示

    调用视图类展示此方法的视图文件。

    //同上    
    final protected function display()
    {
      $view = new View($this->vars);    //调用视图类
      $view->display($this->tpl);    //视图类展示方法
    }
    

控制器基类到此编写完毕。

2、视图类

见名知意,这是用于展示和处理视图模板的类。供控制器调用。

core/下建立 View.php 作为视图类,结构如下:

<?php 
namespace core;

use core\Config;    //使用配置类
use core\Parser;    //使用模板解析类
/**
* 视图类
*/
class View
{
      //模板变量
    public $vars = [];

    function __construct($vars =[])
    {

    }
    //展示模板
    public function display($file)
    {
  • 构造方法
    function __construct($vars =[])
    {
        if (!is_dir(Config::get('cache_path')) || !is_dir(Config::get('compile_path')) || !is_dir(Config::get('view_path'))) {
            exit('The directory does not exist');
        }
        $this->vars = $vars;
    }

上面的构造方法中,第一步:做了几个目录存在性判断,缓存目录是否存在,编译目录是否存在,模板文件目录是否存在,其中任何一个目录不存在都会退出程序。第二步:接受从控制器传来的模板变量

  • 模板展示方法
public function display($file)
    {
          //模板文件
        $tpl_file = Config::get('view_path').$file.Config::get('view_suffix');
        if (!file_exists($tpl_file)) {
            exit('Template file does not exist');
        }
          //编译文件(文件名用 MD5 加密加上原始文件名)
        $parser_file = Config::get('compile_path').md5("$file").$file.'.php';
          //缓存文件(缓存前缀加原始文件名)
        $cache_file = Config::get("cache_path").Config::get("cache_prefix").$file.'.html';
        //是否开启了自动缓存
      if (Config::get('auto_cache')) {
            if (file_exists($cache_file) && file_exists($parser_file)) {
                if (filemtime($cache_file) >= filemtime($parser_file) && filemtime($parser_file) >= filemtime($tpl_file)) {
                    return include $cache_file;
                }
            }
        }
          //是否需要重新编译模板
        if (!file_exists($parser_file) || filemtime($parser_file) < filemtime($tpl_file)) {
            $parser = new Parser($tpl_file);
            $parser->compile($parser_file);
        }
        include $parser_file;    //引入编译文件
          //若开启了自动缓存则缓存模板
        if (Config::get('auto_cache')) {
            file_put_contents($cache_file,ob_get_contents());
            ob_end_clean();
        }
    }

上面的逻辑也挺简单。调用 display 方法需要传入一个模板文件名,然后根据传入的文件名到视图目录去寻找是否存在该模板,若不存在,退出程序。若存在,定义对应的编译文件和缓存文件。接下来判断在配置选项中是否开启了自动缓存:

  • 若开启了缓存,若对应的缓存文件存在且编译文件也存在,若缓存的文件的最后修改时间大于对应的编译文件且编译文件的最后修改时间大于模板文件的修改时间,则表明缓存的文件是最新的内容,直接可以引入缓存文件,函数返回。

  • 若不满足使用缓存文件的条件,则向下执行。若编译文件不存在或编译文件存在但是最后修改时间小于模板文件的修改时间,表明编译文件无效,需要重新编译模板文件。实例化一个编译类的对象,调用其编译方法(传入编译文件名)。

    做完上面的操作,就可以引入编译文件了。

    若开启了自动缓存,则生成缓存文件。这里用到了一个函数 ob_get_contents ,将本来输出在屏幕上的内容输入到缓冲区。再将缓冲区的内容写到缓存文件。这样就生成了缓存文件,下次就可以不用再经过编译的过程而直接展示。

3、模板解析类

这一部分内容也是模板引擎的工作核心,对模板文件进行解析,编译。我们的这个类也可以称作一个简单的模板引擎。模板引擎的出现,使得业务逻辑和视图展示得以分离,代码结构更加清晰,更多的关于模板引擎的内容,大家可以自行了解。

在 core/ 下建立 Parser.php 作为模板解析类。主要内容如下:

<?php 
namespace core;

/**
*  解析
*/
class Parser
{
    private $content;
    function __construct($file)
    {
        $this->content = file_get_contents($file);
        if (!$this->content) {
            exit('Template file read failed');
        }
    }
    //解析普通变量
    private function parVar()
    {
        $patter = '/\{\$([\w]+)\}/';
        $repVar = preg_match($patter,$this->content);
        if ($repVar) {
            $this->content = preg_replace($patter,"<?php echo \$this->vars['$1']; ?>",$this->content);
        }
    }

      private function parIf()
      //编译
    public function compile($parser_file){
        $this->parVar();
        file_put_contents($parser_file,$this->content);
    }
}

上面的内容给大家做了一个示例,只定义了解析普通变量的方法。这里使用了正则表达式来解析,首先获取模板文件的内容,然后使用正则表达式去寻找符合条件的内容,找到之后执行内容替换操作,就换成了我们所熟悉的编写方式。此方法的处理效果:将混在 html 代码中的形如 {$var} 的内容,替换为<?php echo $this->vars['var']; ?> ,这样就可以将模板变量在模板文件中展示出来,而不用每次都写很多重复的代码。

由于这个类只是负责解析模板中的特定语法,而不是真正渲染模板内容,所以不需要使用模板变量。真正的渲染过程将会在 View 中执行。我们这里默认约定的模板语法:普通模板变量,使用 {$var} 标识。当然,这不是固定的写法,你可以自行设计模板语法或直接在配置文件中设定,然后在解析的时候做一些匹配修改就行。一个完整的模板引擎所做的功能远远不止这一点,还包括了解析条件判断语法,循环语法,系统变量语法,函数使用方法等等,大家完全可以仿照上面解析普通变量的方法继续完善其他模板语法的解析:parIf()parWhile()parSys()parFunc()

4、模型类

这是 MVC 中的 M,也是最重要的一个模块。主要负责与数据库交互,我们需要在其中封装一些预设的方法,方便控制器调用以及方便的执行 CURD(增删查改)操作,并做一些日志的记录,方便我们查找失败的原因。

在 core/ 下新建一个 Model.php 作为模型基类,其他模型类都继承于它,主要结构:

<?php 
namespace core;

use core\Config;
use PDO;

class Model
{
    protected $db;
    protected $table;
    function __construct($table = '')
    {
        $this->db = new PDO('mysql:host='.Config::get('db_host').';dbname='.Config::get('db_name').';charset='.Config::get('db_charset'),Config::get('db_user'),Config::get('db_pwd'));        
        $this->table = Config::get('db_table_prefix').$table;    //补充完整数据表名
    }

      /*获取数据表字段*/
    public function getFields()
    {

    }
    /*获取数据库所有表*/
    public function getTables()
    {

    }
    /*释放连接*/
    protected function free()
    {
        $this->db = null;
    }

    /*获得客户端真实的IP地址*/
    protected function getip() 
    {

    }
    /*新增数据*/
    public function save($data = [])
    {

    }
    /*更新数据*/
    public function update($data = [],$wheres = [],$options = 'and')
    {

    }
    /*查找数据*/
    public function select($fields,$wheres = [],$options = 'and')
    {

    }
    /*删除数据*/
    public function delete($wheres = [],$options = 'and')
    {

    }
    /*错误日志记录*/
    protected function log_error($message = '',$sql = '')
    {

    }
}

以上就是在模型基类中主要的方法,包括增删查改,展示数据库和数据表,错误日志记录。基本上可以满足我们的使用,你也可以加一些其他的方法。

首先在构造方法中,我们需要接受一个表名,用来设置与哪个数据表交互。这会在具体的模型子类中执行。然后开始使用 PDO 方式连接数据库,你也可以使用 mysqli_ 系列函数来实现数据库的连接。

  • 获取数据表字段
    public function getFields()
    {
        $sql = 'SHOW COLUMNS FROM `' . $this->table . '`';    //拼接 SQL 语句
        $pdo = $this->db->query($sql);    //执行
        $result = $pdo->fetchAll(PDO::FETCH_ASSOC);    //转换为索引数组
        $info = [];
        if ($result) {
            foreach ($result as $key => $val) {
                $val = array_change_key_case($val);
                $info[$val['field']] = [
                    'name' => $val['field'],
                    'type' => $val['type'],
                    'notnull' => (bool)('' === $val['null']),
                    'default' => $val['default'],
                    'primary' => (strtolower($val['key']) == 'pri'),
                    'auto' => (strtolower($val['extra']) == 'auto_increment'),
                ];
            }
        return $info;
        }
    }
  • 获取数据库所有表
public function getTables()
    {
        $sql = 'SHOW TABLES';
        $pdo = $this->db->query($sql);
        $result = $pdo->fetchAll(PDO::FETCH_ASSOC);
        $info = [];
        foreach ($result as $key => $val) {
            $info['key'] = current($val);
        }
        return $info;
    }
  • 获得客户端真实的IP地址
function getip() {
        if (getenv("HTTP_CLIENT_IP") && strcasecmp(getenv("HTTP_CLIENT_IP"), "unknown")) {
            $ip = getenv("HTTP_CLIENT_IP");
        } else
            if (getenv("HTTP_X_FORWARDED_FOR") && strcasecmp(getenv("HTTP_X_FORWARDED_FOR"), "unknown")) {
                $ip = getenv("HTTP_X_FORWARDED_FOR");
            } else
                if (getenv("REMOTE_ADDR") && strcasecmp(getenv("REMOTE_ADDR"), "unknown")) {
                    $ip = getenv("REMOTE_ADDR");
                } else
                    if (isset ($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] && strcasecmp($_SERVER['REMOTE_ADDR'], "unknown")) {
                        $ip = $_SERVER['REMOTE_ADDR'];
                    } else {
                        $ip = "unknown";
                    }
        return ($ip);
    }

这个方法主要用来获取客户端的 ip 地址,将会用于错误日志记录。主要用到了一些 PHP 预定义的函数来实现,有兴趣的可以查一下这些函数的用法。

  • 新增数据
public function save($data = [])
    {
        $keys = '';
        $values = '';
        foreach ($data as $key => $value) {
            $keys .= "$key,";
            $values .= "'".$value."',";
        }
        $keys = substr($keys,0,strlen($keys)-1);
        $values = substr($values,0,strlen($values)-1);
        $sql = 'INSERT INTO `'.$this->table.'` ('.$keys.') VALUES ('.$values.')';
        $pdo = $this->db->query($sql);
        if ($pdo) {
            return true;
        }else{
            $this->log_error('save error',$sql);
            return false;
        }
    }

实现向数据表插入一条数据,需要传入一个包含字段和值的数组,形如 ['field'=>'value'],接下来将数组拆开,将其拼接成 SQL 语句并执行,执行成功返回 true,执行失败则进行错误日志的记录,返回 false。此方法也支持多字段插入。

  • 更新数据
 public function update($data = [],$wheres = [],$options = 'and')
    {
        $keys = '';
        $where = '';
        foreach ($data as $key => $value) {
            $keys .= $key." = '".$value."',";
        }
        if (count($wheres) > 1) {
            foreach ($wheres as $key => $value) {
                $where .= $key . " = '" . $value . "' " . $options . " ";
            }
            $where = substr($where,0,strlen($where)-strlen($options)-2);
        } else {
            foreach ($wheres as $key => $value) {
                $where .= $key . " = '" . $value ."'";
            }
        }
        $keys = substr($keys,0,strlen($keys)-1);
        $sql = 'UPDATE '.$this->table .' SET '.$keys .' WHERE '.$where;
        $pdo = $this->db->query($sql);
        if ($pdo) {
            return true;
        } else {
            $this->log_error('update error',$sql);
            return false;
        }
    }

这个方法与新增数据类似,只是多了几个参数。更新数据需要得到更新的字段和值,更新的条件,条件之间的关系。所以此方法至少需要这三个参数。$data 是由更新字段和更新值组成,$where 是更新的条件数组,形如 ['field'=>'value'],默认为等值关系,可支持多个条件。$options 是条件之间的逻辑关系。当更行条件不止一个的时候,它们之间就存在逻辑关系,例如 id > 1 and id < 5 这样的关系,默认使用 and 。如果更新条件只有一个,就不需要考虑这个参数。如果理解比较困难,可以多花点时间看一下。上面都是一些简单的字符串操作,这里就不详解了。

  • 查找数据
public function select($fields,$wheres = [],$options = 'and')
    {
        $field = '';
        if (is_string($fields)) {
            $field = $fields;
        } elseif (is_array($fields)) {
            foreach ($fields as $key => $value) {
                $field .= $value.",";
            }
            $field = substr($field,0,strlen($field)-1);
        }
        $where = '';
        foreach ($wheres as $key => $value) {
            $where .= $key . " = '" . $value . "' " . $options . " "
        }
        $where = substr($where, 0, strlen($where)-2);
        $sql = 'SELECT '.$field.' FROM '.$this->table.' WHERE '.$where;
        $pdo = $this->db->query($sql);
        if ($pdo) {
            $result = $pdo->fetchAll(PDO::FETCH_ASSOC);
            return $result;
        } else {
            $this->log_error('select error',$sql);
            return false;
        }
    }
  • 删除数据
public function delete($wheres = [],$options = 'and')
    {
        $where = '';
        foreach ($wheres as $key => $value) {
            $where .= $key.' '.$options." '$value',";
        }
        $where = substr($where,0,strlen($where)-1);
        $sql = 'DELETE FROM '.$this->table.' WHERE '.$where;
        $pdo = $this->db->query($sql);
        if ($pdo) {
            return true;
        } else {
            $this->log_error('delete error',$sql);
            return false;
        }
    }
  • 错误日志记录
 protected function log_error($message = '',$sql = '')
    {
        $ip = $this->getip();
        $time = date("Y-m-d H:i:s");
        $message = $message . "\r\n$sql" . "\r\n客户IP:$ip" . "\r\n时间 :$time" . "\r\n\r\n";
        $server_date = date("Y-m-d");
        $filename = $server_date . "_SQL.txt";
        $file_path = RUNTIME_PATH. 'log' . DS .$filename;
        $error_content = $message;
        $file = RUNTIME_PATH. 'log'; //设置文件保存目录
        //建立文件夹
        if (!file_exists($file)) {
            if (!mkdir($file, 0777)) {
                //默认的 mode 是 0777,意味着最大可能的访问权
                die("upload files directory does not exist and creation failed");
            }
        }
        //建立txt日志文件
        if (!file_exists($file_path)) {
            //echo "建立日志文件";
            fopen($file_path, "w+");
            //首先要确定文件存在并且可写
            if (is_writable($file_path)) {
                //使用添加模式打开$filename,文件指针将会在文件的开头
                if (!$handle = fopen($file_path, 'a')) {
                    echo "Cannot open $filename";
                    exit;
                }
                //将$somecontent写入到我们打开的文件中。
                if (!fwrite($handle, $error_content)) {
                    echo "Cannot write $filename";
                    exit;
                }
                //echo "文件 $filename 写入成功";
                echo "Error logging is saved!";
                //关闭文件
                fclose($handle);
            } else {
                echo "File $filename cannot write";
            }
        } else {
            //首先要确定文件存在并且可写
            if (is_writable($file_path)) {
                //使用添加模式打开$filename,文件指针将会在文件的开头
                if (!$handle = fopen($file_path, 'a')) {
                    echo "Cannot open $filename";
                    exit;
                }
                //将$somecontent写入到我们打开的文件中。
                if (!fwrite($handle, $error_content)) {
                    echo "Cannot write $filename";
                    exit;
                }
                //echo "文件 $filename 写入成功";
                echo "——Error logging is saved!!";
                //关闭文件
                fclose($handle);
            } else {
                echo "File $filename cannot write";
            }
        }
    }

这个方法主要用来做日志处理,如果 CURD 操作失败,都会调用这个方法,记录下详细的错误信息,方便我们查看。

模型基类到此就编写完毕了。

四、运行测试

1、测试控制器和视图

app/home/controller/ 下新建一个 IndexController.php 作为首页控制器,一定要注意命名规范。

编辑并写下如下内容:

<?php 
namespace home\controller;

use core\Controller;
/**
* index控制器
*/
class IndexController extends Controller
{
    public function index()
    {
        $this->assign('name','shiyanlou---Admin');    //模板变量赋值
        $this->display();    //模板展示
    }
}

相信大家都看得懂上面的代码,方法中用到了模板,所以我们需要在 view/下建立一个index.php的视图文件,对应于上面的 index 方法,得益于我们之前在启动文件中做的工作,当我们调用 display 方法时,不需要显式去指定该调用哪个模板,框架会默认到视图目录去寻找与该方法同名的模板文件。

index.php中写下如下内容:


<html>
    <head>
        <title>Lab Framwork</title>

        <link href="https://fonts.googleapis.com/css?family=Lato:100" rel="stylesheet" type="text/css">

        <style>
            html, body {
                height: 100%;
            }

            body {
                margin: 0;
                padding: 0;
                width: 100%;
                display: table;
                font-weight: 100;
                font-family: 'Lato';
            }

            .container {
                text-align: center;
                display: table-cell;
                vertical-align: middle;
            }

            .content {
                text-align: center;
                display: inline-block;
            }

            .title {
                font-size: 96px;
            }
            .line {
                color: black;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="content">
                <div class="title">Lab Framework</div>
                <span class="line"><b>{$name}</b></span>
            </div>
        </div>
    </body>
</html>

看似很多,其实没什么重要的东西,你也可以随点写点 html 代码。上面的代码中,我写了一句:{$name} ,如果你还记得,这是这是我们约定的模板语法,也是模板解析类主要解析的内容,待会儿这部分将会展示控制器中 index 方法中赋值的模板变量的值。

现在打开浏览器,输入 localhost:8080 ,应该就会出现上面展示的图片效果。而且在 /rumtime/compile/ 下也存在一个名字很长的文件。查看其中的内容,你会发现和模板的内容不同:

<div class="container">
        <div class="content">
            <div class="title">Lab Framework</div>
            <span class="line"><b><?php echo $this->vars['name']; ?></b></span>
        </div>
</div>

这就是经过模板解析类编译生成的编译文件,已将将模板变量解析为正确的输出方式。

现在到配置文件中把自动缓存:auto_cache 配置为 true。再次刷新页面:没有任何变化。不过现在你可以发现在 /runtime/cache/ 下多了一个名为 cache_index.html 的 html 文件,没错,这就是缓存的页面文件,打开并查看其中的内容,你会发现内容又变了:

<div class="container">
      <div class="content">
          <div class="title">Lab Framework</div>
          <span class="line"><b>shiyanlou---Admin</b></span>
      </div>
</div>

这就是最普通并且可以直接展示的 html 代码!当我们下次再访问这个页面地址时,如果开启了自动缓存,且此缓存文件存在,且模板文件和编译文件都存在并且都没有修改过,那么框架就会自动调用这个缓存文件,而不需要在经过编译、渲染的步骤,大大提高了网页的加载速度。

2、测试模型

在控制器文件中再建立一个用户控制器:UserController.php,在其中添加 index 方法,并写下:

<?php 
namespace home\controller;

use core\Controller;
use home\model\UserModel;
/**
* 用户控制器
*/
class UserController extends Controller
{
    public function index()
    {
        $model = new UserModel();
        if ($model->save(['name'=>'hello','password'=>'shiyanlou'])) {
          $model->free();    //释放连接
            echo "Success";
        } else {
          $model->free();    //释放连接
          echo 'Failed';
        }
    }
}

由于这是用户控制器,所以应该建立一个用户模型,上面的代码中也明确了要使用 home/model/ 目录下的UserModel,所以在 model/目录下新建一个用户模型:UserModel.php ,并且编辑如下:

<?php 
namespace home\model;

use core\Model;
/**
*     用户模型
*/
class UserModel extends Model
{
    function __construct()
    {
        parent::__construct('user');
    }
}

这里只是为了起到示范作用,所以只写了构造方法,大家也可以向其中添加更多的自定义方法。

在 UserModel 的构造方法中,我们需要显式向模型基类传递数据表名,明确此模型需要和哪一张表交互,Model 类会根据传入的表名自动添加表前缀,形成完整的表名。这里的 user 将会对应数据库里的frw_user 表。

在浏览器输入地址:localhost:8080/index.php/home/user/index.html,应该会显示 ‘Success’ 。其他模型操作方法,就留给你们自己去测试。

4、添加跳转方法

上面的控制器方法中,当模型操作失败的时候,应该给出提示并跳转页面,执行成功也应该执行跳转操作。所以为了实现这个功能,我们可以添加一个页面跳转的方法。框架的跳转功能应该作为核心且通用的功能来实现,所以我们应该将他放到核心类文件中。不过这里的实现方式要和普通类不同,我们可以使用 Trait 来实现。

Trait:是为类似 PHP 的单继承语言而准备的一种代码复用机制。Trait 为了减少单继承语言的限制,使开发人员能够自由地在不同层次结构内独立的类中复用 method。Trait 和 Class 组合的语义定义了一种减少复杂性的方式,避免传统多继承和 Mixin 类相关典型问题。

Trait 和 Class 相似,但 Trait 仅仅旨在用细粒度和一致的方式来组合功能。 无法通过 trait 自身来实例化。它为传统继承增加了水平特性的组合;也就是说,应用的几个 Class 之间不需要继承。

回到核心类文件目录,在 core/ 下新建一个 traits 的文件夹,主要用于存放类似跳转这种 trait 文件,在其下新建一个 Jump.php 。编辑如下:

<?php 
namespace core\traits;

trait Jump
{
    public static function success($msg = '',$url = '',$data = '')
    {
        $code = 1;
        if (is_numeric($msg)) {
            $code = $msg;
            $msg = '';
        }
        if (is_null($url) && isset($_SERVER["HTTP_REFERER"])) {
            $url = $_SERVER["HTTP_REFERER"];
        }
        $result = [
            'code'    => $code,    //状态码
            'msg'    => $msg,    //显示信息
            'data'     => $data,    //输出数据
            'url'     => $url,    //跳转url
        ];
        $output = 'code:'.$result['code'].'\n'.'msg:'.$result['msg'].'\n'.'data:'.$result['data'];
        echo "<script> alert('$output');location.href='".$result['url']."'</script>";
        exit();
    }

    public static function error($msg = '',$url = '',$data = '')
    {
        $code = 0;
        if (is_numeric($msg)) {
            $code = $msg;
            $msg = '';
        }
        if (is_null($url) && isset($_SERVER['HTTP_REFERER'])) {
            $url = $_SERVER['HTTP_REFERER'];
        }
        $result = [
            'code'     => $code,
            'msg'     => $msg,
            'data'     => $data,
            'url'     => $url,
        ];
        $output = 'code:'.$result['code'].'\n'.'msg:'.$result['msg'].'\n'.'data:'.$result['data'];
        echo "<script> alert('$output');location.href='".$result['url']."'</script>";
        exit();

    }
}

上面的代码实现了一个非常简单的跳转功能(虽然样式不美观)。成功的跳转操作可以指定跳转地址,如不指定,将会回退到前一页。失败的跳转操作也类似。两者都可以指定提示信息和必要的数据。

由于主要的逻辑操作在控制器中进行,所以跳转功能也主要在控制器中执行。我们直接在控制器基类中添加跳转功能,子类就可以直接使用。

修改 Controller.php :

use core\View;
use core\traits\Jump;    //新增语句

***

    use Jump;    //新增语句

    protected $vars = [];
    protected $tpl;

接下来就可以直接在子类控制器中调用 successerror方法。刚才的用户控制器的提示方式可以改为:

if ($model->save(['name'=>'hello','password'=>'shiyanlou'])) {
                $this->success('Success','/');    //执行成功,弹出信息,跳转至首页
        } else {
         $this->error('Error');    //操作失败,弹出错误消息,执行跳转操作,默认上一页,若没有上一页,当前页面将会一直报错
        }

上面的信息提示确实不太美观,不过我们的目的不在于多美观。只要能实现功能就行。你也可以自信设计跳转页面,比如类似 ThinkPHP 的跳转效果。

0

评论区