1 小时内编写您自己的 PHP MVC 框架
从头开始编写我们自己的 MVC 框架。
引言
MVC 架构模式如今几乎无处不在,无论您是在处理 Java、C#、PHP 还是 iOS 项目。这可能不是100%准确,但PHP社区拥有数量最多的MVC框架。今天您可能在使用 Zend,明天在另一个项目上您可能需要切换到 Yii、Laravel 或 CakePHP。如果您是 MVC 框架的新手,并且刚刚从官方网站下载了一个,那么当您查看框架的源代码时,可能会感到不知所措,是的,它很复杂,因为这些流行的框架不是在一个月内编写完成的——它们经过发布、完善、反复测试,并且功能不断增加。因此,根据我的经验,了解 MVC 框架的核心设计方法至关重要,否则您可能会觉得当您在新项目中使用新框架时,不得不一次又一次地学习另一个框架。
理解 MVC 的最佳方式是自己从头开始编写一个 MVC 框架!在本系列文章中,我将向您展示如何编写一个,以便您可能理解为什么框架中某些事情会以这种方式发生。
MVC 架构模式
M: 模型 (Model)
V: 视图 (View)
C: 控制器 (Controller)
MVC 的核心概念是将业务逻辑与显示(视图部分)分离。首先让我解释一下 HTTP 请求和 HTTP 响应的整个工作流程。例如,我们有一个电子商务网站,我们想添加某个产品。一个非常简单的 URL 看起来像这样:
http://bestshop.com/index.php?p=admin&c=goods&a=add
http://bestshop.com 是域名或基本 URL;
p=admin 表示平台是管理员面板,或者是系统的后端站点。我们还有一个前端站点,它是对公众开放的(在这种情况下,它将是 p=public)
c=goods&a=add 表示此 URL 请求“goods”控制器(Controller)的 add 动作方法(action method)。
前端控制器设计模式 (Front Controller Design Pattern)
在上面的例子中,index.php 是什么?在 PHP 的 MVC 框架中,这个文件被称为“前端控制器(Front Controller)”。名称通常是 index.php,但您也可以将其命名为其他名称(很少有人这样做……)。这个 index.php 文件的其中一个功能是,它作为任何 HTTP 请求的单一入口点,也就是说,无论您在 URL 中请求什么资源,它都会首先进入这个 index.php 文件。但为什么呢?魔术是如何发生的?前端控制器设计模式在 PHP 中通过 Apache HTTP 服务器的分布式配置文件 .htaccess 实现。在这个文件中,我们可以通过其重写模块告诉 Apache HTTP 服务器将所有请求重定向到 index.php。您可以编写类似这样的代码:
<IfModule mod_rewrite.c>
Options +FollowSymLinks
RewriteEngine on
# Send request via index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php/$1 [L]
</IfModule>
这个配置文件非常强大,当你更改它时,你不需要重启 Apache。如果你更改 Apache 的其他配置文件,你需要重启它,原因是对于其他 Apache 配置文件,Apache 只会在启动时读取它们,所以任何更改都需要重启(顺便说一下,Apache 主要用 C 语言编写)。但是对于 .htaccess 分布式配置文件,任何更改都不需要重启。
简单来说,index.php 还会为框架做适当的初始化,并将请求路由到适当的控制器(Controller)(在上面的例子中是 goodsController)和该控制器类中的一个动作方法(action method)(成员函数)。
我们的 MVC 框架结构
那么,让我们创建我们的框架结构吧。
application 目录是网络应用程序的目录;
framework 目录是框架本身专用的;
public 目录用于存储所有公共静态资源,如 html、css 和 js 文件。
index.php 是“入口文件”,即前端控制器。
现在在 application 文件夹内,我们创建这些子文件夹:
config - 存储应用的配置文件
controllers - 用于所有应用的控制器类
model - 用于所有应用的模型类
view - 用于所有应用的视图类
现在在 application/controllers 文件夹中,我们将为前端和后端平台创建两个文件夹:
在 view 文件夹中,前端和后端也一样。
如您所见,在 application 文件夹中,我们在 controllers 和 views 文件夹中创建了 frontend 和 backend 子文件夹,因为我们的应用程序有一个前端站点和一个后端站点。但为什么我们没有在 models 文件夹中这样做呢?
嗯,这里的原因是,通常对于一个网络应用来说:
前端和后端可以是两个不同的“网站”,但它们对同一数据库进行增删改查 (CRUD),这就是为什么内部用户更新产品价格后,价格会立即显示在前端公共页面上——后端和前端共享相同的数据库/表。
所以这就是为什么后端和前端可以共享一组模型类,这也是我们没有在 models 文件夹中创建独立文件夹的原因。
现在让我们转到 framework 目录,有些框架会用框架的名称来命名这个文件夹,比如“symfony”。在这个文件夹中,我们首先快速创建这些子文件夹:
core - 它将存储框架的核心类
database - 数据库相关类,例如数据库驱动类
helpers - 辅助/助手函数
libraries - 用于类库
现在转到 public 文件夹,我们创建这些子文件夹:
css - 用于 css 文件
images - 用于图片文件
js - 用于 javascript 文件
uploads - 用于上传的文件,例如上传的图片
好的,到目前为止,这就是我们这个迷你 MVC 框架的结构。
框架的核心类
现在在 framework/core 文件夹下,我们将创建框架的第一个类——framework/core 文件夹中的 Framework.class.php
// framework/core/Framework.class.php
class Framework {
public static function run() {
echo "run()";
}
我们创建了一个名为 run() 的静态函数。现在在 index.php 中测试它:
<?php
require "framework/core/Framework.class.php";
Framework::run();
您可以在浏览器中看到结果(此处省略了如何配置虚拟主机)。通常这个静态函数被称为 run() 或 bootstrap()。在这个函数中,我们可以做三件主要的事情,如下面的代码所示:
class Framework {
public static function run() {
// echo "run()";
self::init();
self::autoload();
self::dispatch();
}
private static function init() {
}
private static function autoload() {
}
private static function dispatch() {
}
}
初始化
这是 init() 方法的代码:
// Initialization
private static function init() {
// Define path constants
define("DS", DIRECTORY_SEPARATOR);
define("ROOT", getcwd() . DS);
define("APP_PATH", ROOT . 'application' . DS);
define("FRAMEWORK_PATH", ROOT . "framework" . DS);
define("PUBLIC_PATH", ROOT . "public" . DS);
define("CONFIG_PATH", APP_PATH . "config" . DS);
define("CONTROLLER_PATH", APP_PATH . "controllers" . DS);
define("MODEL_PATH", APP_PATH . "models" . DS);
define("VIEW_PATH", APP_PATH . "views" . DS);
define("CORE_PATH", FRAMEWORK_PATH . "core" . DS);
define('DB_PATH', FRAMEWORK_PATH . "database" . DS);
define("LIB_PATH", FRAMEWORK_PATH . "libraries" . DS);
define("HELPER_PATH", FRAMEWORK_PATH . "helpers" . DS);
define("UPLOAD_PATH", PUBLIC_PATH . "uploads" . DS);
// Define platform, controller, action, for example:
// index.php?p=admin&c=Goods&a=add
define("PLATFORM", isset($_REQUEST['p']) ? $_REQUEST['p'] : 'home');
define("CONTROLLER", isset($_REQUEST['c']) ? $_REQUEST['c'] : 'Index');
define("ACTION", isset($_REQUEST['a']) ? $_REQUEST['a'] : 'index');
define("CURR_CONTROLLER_PATH", CONTROLLER_PATH . PLATFORM . DS);
define("CURR_VIEW_PATH", VIEW_PATH . PLATFORM . DS);
// Load core classes
require CORE_PATH . "Controller.class.php";
require CORE_PATH . "Loader.class.php";
require DB_PATH . "Mysql.class.php";
require CORE_PATH . "Model.class.php";
// Load configuration file
$GLOBALS['config'] = include CONFIG_PATH . "config.php";
// Start session
session_start();
}
从注释中您可以看到每个步骤的目的。
自动加载 (Autoloading)
我们不希望在项目中的每个脚本中,手动为我们需要的类文件编写 include 或 require,这就是 PHP MVC 框架具有自动加载功能的原因。例如,在 Symfony 中,如果将自己的类文件放在“lib”文件夹下,那么它将被自动加载。神奇吗?不,没有魔术。让我们在我们的迷你框架中实现自动加载功能。
这里我们需要使用一个 PHP 内置函数,名为 spl_autoload_register
// Autoloading
private static function autoload(){
spl_autoload_register(array(__CLASS__,'load'));
}
// Define a custom load method
private static function load($classname){
// Here simply autoload app’s controller and model classes
if (substr($classname, -10) == "Controller"){
// Controller
require_once CURR_CONTROLLER_PATH . "$classname.class.php";
} elseif (substr($classname, -5) == "Model"){
// Model
require_once MODEL_PATH . "$classname.class.php";
}
}
每个框架都有一个命名约定,我们的也不例外。对于控制器类,它应该是 xxxController.class.php;对于模型类,它应该是 xxxModel.class.php。为什么您遇到的新框架必须遵循其命名约定?自动加载是原因之一。
路由/分发 (Routing/Dispatching)
// Routing and dispatching
private static function dispatch(){
// Instantiate the controller class and call its action method
$controller_name = CONTROLLER . "Controller";
$action_name = ACTION . "Action";
$controller = new $controller_name;
$controller->$action_name();
}
在这一步中,index.php 会将请求分派给正确的 Controller::Action() 方法。这里为了示例,它非常简单。
基础控制器类 (Base Controller class)
框架的核心类中总是有一个(或几个)基础控制器类。例如,在 Symfony 中它被称为 sfActions;在 iOS 中它被称为 UIViewController。这里我们只将其命名为 Controller,文件名为 Controller.class.php
<?php
// Base Controller
class Controller{
// Base Controller has a property called $loader, it is an instance of Loader class(introduced later)
protected $loader;
public function __construct(){
$this->loader = new Loader();
}
public function redirect($url,$message,$wait = 0){
if ($wait == 0){
header("Location:$url");
} else {
include CURR_VIEW_PATH . "message.html";
}
exit;
}
}
基础控制器有一个名为 $loader 的属性,它是 Loader 类的一个实例(稍后介绍)。请注意“它是 Loader 类的一个实例”这句话——精确地说,$this->loader 是一个引用变量,它引用/指向 Load 类的一个实例。我们在这里不详细讨论,但这实际上是一个非常重要的概念。我遇到过一些 PHP 开发者,他们相信在执行这条语句后:
$this->loader = new Loader();
$this->loader 是一个对象。不,它是一个引用。这个术语始于 Java,在 Java 之前,它在 C++ 或 Objective C 中被称为指针。引用是一种封装的指针类型。例如,在 iOS (Objective-C) 中,我们使用以下方式创建对象:
UIButton *btn = [UIButton alloc] init];
加载器类 (Loader class)
在 framework.class.php 中,我们已经实现了应用程序控制器和模型类的自动加载。但是如何加载 framework 目录中的类呢?这里我们可以创建一个名为 Loader 的新类,它将用于加载框架的类和函数。当我们需要加载框架的类时,只需调用这个 Loader 类的方法即可。
class Loader{
// Load library classes
public function library($lib){
include LIB_PATH . "$lib.class.php";
}
// loader helper functions. Naming conversion is xxx_helper.php;
public function helper($helper){
include HELPER_PATH . "{$helper}_helper.php";
}
}
实现模型 (Implementing Model)
我们将以最简单的方式实现 Model,通过创建两个类文件:
Mysql.class.php - 这个类位于 framework/database 目录下,它用于封装数据库连接和一些基本的 SQL 查询方法。
Model.class.php - 这是基础模型类,它包含各种 CRUD 方法。
<?php
/**
*================================================================
*framework/database/Mysql.class.php
*Database operation class
*================================================================
*/
class Mysql{
protected $conn = false; //DB connection resources
protected $sql; //sql statement
/**
* Constructor, to connect to database, select database and set charset
* @param $config string configuration array
*/
public function __construct($config = array()){
$host = isset($config['host'])? $config['host'] : 'localhost';
$user = isset($config['user'])? $config['user'] : 'root';
$password = isset($config['password'])? $config['password'] : '';
$dbname = isset($config['dbname'])? $config['dbname'] : '';
$port = isset($config['port'])? $config['port'] : '3306';
$charset = isset($config['charset'])? $config['charset'] : '3306';
$this->conn = mysql_connect("$host:$port",$user,$password) or die('Database connection error');
mysql_select_db($dbname) or die('Database selection error');
$this->setChar($charset);
}
/**
* Set charset
* @access private
* @param $charset string charset
*/
private function setChar($charest){
$sql = 'set names '.$charest;
$this->query($sql);
}
/**
* Execute SQL statement
* @access public
* @param $sql string SQL query statement
* @return $result,if succeed, return resrouces; if fail return error message and exit
*/
public function query($sql){
$this->sql = $sql;
// Write SQL statement into log
$str = $sql . " [". date("Y-m-d H:i:s") ."]" . PHP_EOL;
file_put_contents("log.txt", $str,FILE_APPEND);
$result = mysql_query($this->sql,$this->conn);
if (! $result) {
die($this->errno().':'.$this->error().'<br />Error SQL statement is '.$this->sql.'<br />');
}
return $result;
}
/**
* Get the first column of the first record
* @access public
* @param $sql string SQL query statement
* @return return the value of this column
*/
public function getOne($sql){
$result = $this->query($sql);
$row = mysql_fetch_row($result);
if ($row) {
return $row[0];
} else {
return false;
}
}
/**
* Get one record
* @access public
* @param $sql SQL query statement
* @return array associative array
*/
public function getRow($sql){
if ($result = $this->query($sql)) {
$row = mysql_fetch_assoc($result);
return $row;
} else {
return false;
}
}
/**
* Get all records
* @access public
* @param $sql SQL query statement
* @return $list an 2D array containing all result records
*/
public function getAll($sql){
$result = $this->query($sql);
$list = array();
while ($row = mysql_fetch_assoc($result)){
$list[] = $row;
}
return $list;
}
/**
* Get the value of a column
* @access public
* @param $sql string SQL query statement
* @return $list array an array of the value of this column
*/
public function getCol($sql){
$result = $this->query($sql);
$list = array();
while ($row = mysql_fetch_row($result)) {
$list[] = $row[0];
}
return $list;
}
/**
* Get last insert id
*/
public function getInsertId(){
return mysql_insert_id($this->conn);
}
/**
* Get error number
* @access private
* @return error number
*/
public function errno(){
return mysql_errno($this->conn);
}
/**
* Get error message
* @access private
* @return error message
*/
public function error(){
return mysql_error($this->conn);
}
}
这是 Model.class.php:
<?php
// framework/core/Model.class.php
// Base Model Class
class Model{
protected $db; //database connection object
protected $table; //table name
protected $fields = array(); //fields list
public function __construct($table){
$dbconfig['host'] = $GLOBALS['config']['host'];
$dbconfig['user'] = $GLOBALS['config']['user'];
$dbconfig['password'] = $GLOBALS['config']['password'];
$dbconfig['dbname'] = $GLOBALS['config']['dbname'];
$dbconfig['port'] = $GLOBALS['config']['port'];
$dbconfig['charset'] = $GLOBALS['config']['charset'];
$this->db = new Mysql($dbconfig);
$this->table = $GLOBALS['config']['prefix'] . $table;
$this->getFields();
}
/**
* Get the list of table fields
*
*/
private function getFields(){
$sql = "DESC ". $this->table;
$result = $this->db->getAll($sql);
foreach ($result as $v) {
$this->fields[] = $v['Field'];
if ($v['Key'] == 'PRI') {
// If there is PK, save it in $pk
$pk = $v['Field'];
}
}
// If there is PK, add it into fields list
if (isset($pk)) {
$this->fields['pk'] = $pk;
}
}
/**
* Insert records
* @access public
* @param $list array associative array
* @return mixed If succeed return inserted record id, else return false
*/
public function insert($list){
$field_list = ''; //field list string
$value_list = ''; //value list string
foreach ($list as $k => $v) {
if (in_array($k, $this->fields)) {
$field_list .= "`".$k."`" . ',';
$value_list .= "'".$v."'" . ',';
}
}
// Trim the comma on the right
$field_list = rtrim($field_list,',');
$value_list = rtrim($value_list,',');
// Construct sql statement
$sql = "INSERT INTO `{$this->table}` ({$field_list}) VALUES ($value_list)";
if ($this->db->query($sql)) {
// Insert succeed, return the last record’s id
return $this->db->getInsertId();
//return true;
} else {
// Insert fail, return false
return false;
}
}
/**
* Update records
* @access public
* @param $list array associative array needs to be updated
* @return mixed If succeed return the count of affected rows, else return false
*/
public function update($list){
$uplist = ''; //update fields
$where = 0; //update condition, default is 0
foreach ($list as $k => $v) {
if (in_array($k, $this->fields)) {
if ($k == $this->fields['pk']) {
// If it’s PK, construct where condition
$where = "`$k`=$v";
} else {
// If not PK, construct update list
$uplist .= "`$k`='$v'".",";
}
}
}
// Trim comma on the right of update list
$uplist = rtrim($uplist,',');
// Construct SQL statement
$sql = "UPDATE `{$this->table}` SET {$uplist} WHERE {$where}";
if ($this->db->query($sql)) {
// If succeed, return the count of affected rows
if ($rows = mysql_affected_rows()) {
// Has count of affected rows
return $rows;
} else {
// No count of affected rows, hence no update operation
return false;
}
} else {
// If fail, return false
return false;
}
}
/**
* Delete records
* @access public
* @param $pk mixed could be an int or an array
* @return mixed If succeed, return the count of deleted records, if fail, return false
*/
public function delete($pk){
$where = 0; //condition string
//Check if $pk is a single value or array, and construct where condition accordingly
if (is_array($pk)) {
// array
$where = "`{$this->fields['pk']}` in (".implode(',', $pk).")";
} else {
// single value
$where = "`{$this->fields['pk']}`=$pk";
}
// Construct SQL statement
$sql = "DELETE FROM `{$this->table}` WHERE $where";
if ($this->db->query($sql)) {
// If succeed, return the count of affected rows
if ($rows = mysql_affected_rows()) {
// Has count of affected rows
return $rows;
} else {
// No count of affected rows, hence no delete operation
return false;
}
} else {
// If fail, return false
return false;
}
}
/**
* Get info based on PK
* @param $pk int Primary Key
* @return array an array of single record
*/
public function selectByPk($pk){
$sql = "select * from `{$this->table}` where `{$this->fields['pk']}`=$pk";
return $this->db->getRow($sql);
}
/**
* Get the count of all records
*
*/
public function total(){
$sql = "select count(*) from {$this->table}";
return $this->db->getOne($sql);
}
/**
* Get info of pagination
* @param $offset int offset value
* @param $limit int number of records of each fetch
* @param $where string where condition,default is empty
*/
public function pageRows($offset, $limit,$where = ''){
if (empty($where)){
$sql = "select * from {$this->table} limit $offset, $limit";
} else {
$sql = "select * from {$this->table} where $where limit $offset, $limit";
}
return $this->db->getAll($sql);
}
}
现在我们可以在 application 文件夹中创建一个 User 模型类,这是为了我们数据库中的 User 表。代码看起来会是这样:
<?php
// application/models/UserModel.class.php
class UserModel extends Model{
public function getUsers(){
$sql = "select * from $this->table";
$users = $this->db->getAll($sql);
return $users;
}
}
我们的应用程序后端 indexController 可能看起来像这样:
<?php
// application/controllers/admin/IndexController.class.php
class IndexController extends BaseController{
public function mainAction(){
include CURR_VIEW_PATH . "main.html";
// Load Captcha class
$this->loader->library("Captcha");
$captcha = new Captcha;
$captcha->hello();
$userModel = new UserModel("user");
$users = $userModel->getUsers();
}
public function indexAction(){
$userModel = new UserModel("user");
$users = $userModel->getUsers();
// Load View template
include CURR_VIEW_PATH . "index.html";
}
public function menuAction(){
include CURR_VIEW_PATH . "menu.html";
}
public function dragAction(){
include CURR_VIEW_PATH . "drag.html";
}
public function topAction(){
include CURR_VIEW_PATH . "top.html";
}
}
到目前为止,我们应用程序后端的 Index 控制器正在工作,它与 Model 进行通信并将结果变量传递给 View 模板,这样就可以在浏览器中渲染了。
这是对迷你 MVC 框架的非常简短的介绍,希望它能阐明 MVC 框架中的一些基本概念。
历史
2016年2月25日 - 初始版本