前言
现今相当多的团队都会使用框架开发,原因不外乎是因为框架提供了很多好用的工具可以加速开发,也能确保团队可以在同一个规範下共同协作。当然还有许多其他的优点。
本文旨在介绍自製简易框架的基本逻辑。虽然实务上,基本上不太可能会使用自製的框架来开发。然而,对于新手开发者而言,时常只是使用框架预先包装好的工具开发,却不明白背后的运作逻辑,要做深入的客製化更是难以执行。故本文透过自製简易框架,来理解框架的基本运作逻辑。
本文介绍的自製框架採取 MVC 的架构,谈及的观念与程式码绝大部分来自 Udemy 上的课程 Object Oriented PHP & MVC。本文通篇以解释观念为主,所以对部分程式码进行简化,甚至有些地方只有写注解没有撰写实际上可以运行的程式。
资料夹结构
让我们从一个空的资料夹开始!我们首先建立以下的资料夹和档案。
自製MVC框架/├── app/│ ├── config/│ │ └── config.php│ ├── libraries/│ │ ├── Core.php│ │ ├── Database.php│ │ └── Controller.php│ ├── models/│ ├── views/│ │ ├── inc/│ │ │ ├── header.php│ │ │ └── footer.php│ │ └── pages/│ │ └── index.php│ ├── controllers/│ │ └── Pages.php(预设的 Controller)│ └── bootstrap.php│ └── public/ ├── index.php ├── css/ │ └── style.css(空档案) ├── js/ │ └── main.js(空档案) └── img/
网站入口点
当使用者需要输入的网址为:127.0.0.1/public 时,网站预设执行的位置是 public/index.php。它的程式码只有短短两行,只做两件事,引入 bootstrap.php,然后将 Core 物件实例化(下一节会介绍到 Core)。
public/index.php
<?phprequire_once '../app/bootstrap.php';$init = new Core();
bootstrap.php 被引用进来之后,也只做两件事,第一是引入 config.php 好让程式可以取用里面的全域变数,里面包含一些关于资料库环境变数或资源引用的路径变数等等;第二是引用所有 libraries 里面的档案(下一节会介绍到 libraries)。
app/bootstrap.php
<?phprequire_once 'config/config.php';spl_autoload_register(function($className){ require_once 'libraries/' . $className . '.php';});
app/config/config.php
<?php// Database 的参数,以下为範例define('DB_HOST', '127.0.0.1');define('DB_NAME', 'default');define('DB_USER', 'default');define('DB_PASS', 'secret');// App 根目录,这是引入 app 资料夹里的资源用的define('APPROOT', dirname(dirname(__FILE__)) . '/');// URL 根目录,这是引入 public 资料夹里的资源,或是页面跳转时用的define('URLROOT', 'http://localhost:8000/public/');// 网站名称define('SITENAME', '自製 MVC 框架');
libraries
libraries 是整个框架的核心,共有三支档案:Core.php, Database.php, Controller.php。
Core.php 会在所有 request 发生的时候被实例化,然后进行路由处理。
Core 物件被实例化的过程中,简单来说做了以下几件事:
物件含有三个属性,分别代表「Controller」、「方法」、「参数」,预设的值为「'Pages'」、「'index'」、「空阵列」。对于网址做处理。比如当使用者输入 127.0.0.1/public?url=posts/show/1 时,会解析它然后得到一个 $url 阵列,它的值为 ['posts', 'show', 1]。若使用者输入 127.0.0.1/public,则得到 []。对于 $url 的元素做匹配。阵列中第一个值对应的是 Controller,第二个对应的是该 Controller 里的方法,第三个(或有更多)则对应的是该 Controller 里的该方法要传入的参数。执行!以 ['posts', 'show', 1] 为例,若 Controller Posts.php 存在,且含有一个名为 show 的方法,则会带入参数 1 并执行它。以 [] 为例,则执行预设的 Controller Pages 物件中预设的 index 方法,并且不带参数。app/libraries/Core.php
<?phpclass Core{ // 预设 Controller 为 Pages protected $currentController = 'Pages'; // 预设方法为 index protected $currentMethod = 'index'; // 预设参数为空 protected $params = []; public function __construct() { // 呼叫 getUrl() 取得 $url 阵列 // 将 $url[0] 视为 Controller 的名称 // 检查 $url[0] 是否有对应的 Controller ,即是否存在 $url[0].php 的档案 if(存在) $currentController = $url[0]; // 引入 Controller // 实例化 Controller // $url[1] 视为 Controller 中的方法 // 所以先要检查是否有值,若有,检查该值是否有对应的方法 if(isset($url[1])) if(method_exists($this->currentController, $url[1])) $this->currentMethod = $url[1]; // $url 阵列中的第三个值开始,视为带入方法中的参数 // 用 $params 阵列储存所有剩下的值 // 最后透过呼叫 callback 来执行方法 call_user_func_array([$this->currentController, $this->currentMethod], $this->params); } public function getUrl() { // 从 public?url= 后开始,将 $url 按 / 切分,转换成阵列并回传 // 例如: 使用者输入 127.0.0.1/public?url=posts/show/1 // 则回传 $url 的值为 ['posts', 'show', 1] // 它将在 __construct() 中依序被解析成 Controller, 方法, 参数 }}
Database.php 比较单纯。简述如下:
Database 物件被实例化时,会启动建构子,透过 PDO 确定成功连线并将之实例化。提供一些方法供 Model 使用,包含:query() 可以下查询语法,作为 prepare statementbind() 绑定变数execute() 执行该 prepare statementgetAll() 取得全部资料getSingle() 取得单笔资料getRowCount() 取得笔数app/libraries/Database.php
<?phpclass Database{ // 设定资料库的常数来自于 config/config.php private $host = DB_HOST; private $dbname = DB_NAME; private $user = DB_USER; private $pass = DB_PASS; // 定义一些操作 Database 的变数,例如: private $dbh; private $stmt; private $error; public function __construct() { // 透过 PDO 建立资料库连线 // 实例化 PDO } // Prepare statement with query public function query($query){...} // Bind values public function bind($param, $value, $type = null){...} // 执行 prepared statement public function execute(){...} // 以下是 Model 可以操作资料库的几个预设方法 // 可以自行定义更多需要的或常用的 // 取得资料表的所有资料 public function getAll(){...} // 取得资料表的单一笔资料 public function getSingle(){...} // 取得资料表中资料的笔数 public function getRowCount(){...}}
最后是 Controller.php。它只提供两个方法,分别用来载入 Model 和载入 View。
所有其他的 Controller 都要继承 Controller.php。这让我们在自定义的 Controller 中可以轻鬆的建立 Model 物件操作对应的资料库,并且将回传的值(如果有的话)包进阵列塞到 view 里面呈现在网页上。
app/libraries/Controller.php
<?phpclass Controller{ // 载入 model public function model($model) { require_once '../app/models/' . $model . '.php'; return new $model(); } // 载入 view // 其中 view 可能有需要从 Controller 带过去的资料,故多了 $data 阵列作为第二个参数 public function view($view, array $data = []) { // 如果档案存在就引入它 if(file_exists('../app/views/' . $view . '.php')){ require_once '../app/views/' . $view . '.php'; } else { die('View does not exist'); } }}
views
我们在 views 里面建立一个 inc 的资料夹(include 的缩写),并且在里面建立 header.php 和 footer.php 两支档案,来定义好基本 html 架构和引用 css, js 资源。所有其他的 view 都将引入这两支档案,来减少撰写重複的程式码。
app/views/inc/header.php
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="<?php echo URLROOT; ?>/css/style.css"> <title><?php echo SITENAME; ?></title></head><body>
app/views/inc/footer.php
<script src="<?php echo URLROOT; ?>/js/main.js"></script></body></html>
预设的 Controller 及 view
还记得我们在 app/libraries/Core.php 中定义预设 Controller 为 Pages,且预设方法为 index 吗?所以我们要预先定义好 app/controllers/Pages.php 这支档案,还有 app/views/pages/index.php 页面。
app/controllers/Pages.php
<?phpClass Pages extends Controller{ public function __construct() { // 当 Controller 需要操作资料库时,这里可以实例化该 Model。 // 不过这里我们只是要单纯引入 view,所以 __construct 里不需要撰写任何程式码。 } public function index() { // 这里可以引入页面,我们即将在 views 资料夹底下建立一个 pages/index.php 的档案,故可以先写好以下的程式码: $this->view('pages/index'); }}
在预设的页面里,我们单纯引入上一节的 header.php 跟 footer.php,然后在画面上印出 HELLO WORLD!。
app/views/pages/index.php
<?php require APPROOT . 'views/inc/header.php'; ?><h1>HELLO WORLD!</h1><?php require APPROOT . 'views/inc/footer.php'; ?>
运作流程
以 127.0.0.1/public?url=pages/index 为例,运作流程如下:
使用者输入 127.0.0.1/public?url=pages/index。网站入口点 index.php 被触发, bootstrap.php 被载入,并将 Core 物件实例化。在 Core 物件被实例化时,建构子会先呼叫 getUrl(),pages/index 被解析成 [pages, index],并赋值给 $url。$url[0] 会被当作 Controller 的名称,这里对应到的是 Pages.php,将会实例化 Pages 物件。$url[1] 则是对应到 Pages 物件的 index 方法。所以 Controller Pages 的 index 方法会被执行。在 index 方法中,会呼叫从 Controller.php 继承来的 view 方法,第一个参数填入的是欲呈现的 view 名称,在这边是 pages/index,因为没有需传入的资料,所以不需要第二个参数。View pages/index.php 被引用进来,呈现 HELLO WORLD! 的画面在浏览器上。基于框架上开发
到这边,我们已经完成框架了。接下来说明如何基于这个我们自製的框架进行开发。
假设我们要开发一个可以发文的相关功能,包含查看全部、新增、查看一笔、修改、删除等五个功能。
这个框架符合 MVC 的架构,而且有发文的相关需求肯定需要和资料库沟通,所以我们可以确定需要建立的档案将会包含 Model、View、Controller。
我们先来谈谈 Model。这边我们建立一个 Post.php,当 Post 物件被实例化时,会透过建构子将 Database 物件实例化,也就是说,它将自动连线完毕并且能够使用我们刚刚在 Database.php 里面定义的那些可以操作资料库的基本方法!
接着我们在 Post 物件里继续撰写需要用到的方法,包含「取得所有文章」、「发布新文章」、「取得特定一则文章」、「更新文章」、「删除文章」。这些方法都是根据基本方法作延伸的。
app/models/Post.php
<?phpclass Post{ private $db; // 在建构子将 Database 物件实例化 public function __construct() { $this->db = new Database; } // 取得所有文章 public function getPosts() { $query = 'SELECT ...'; $this->db->query($query); $results = $this->db->getAll(); return $results; } // 发布新文章 public function storePost($data) { $query = 'INSERT ...'; $this->db->query($query); $this->db->bind('title', $data['title']); $this->db->bind('body', $data['body']); if($this->db->execute()){ return true; } else{ return false; } } // 取得特定一则文章 public function getPostById($id) { $query = 'SELECT ...'; $this->db->query($query); $this->db->bind('id', $id); $result = $this->db->getSingle(); return $result; } // 更新文章 public function updatePost($data) { $query = 'UPDATE ...'; $this->db->query($query); $this->db->bind('id', $data['id']); $this->db->bind('title', $data['title']); $this->db->bind('body', $data['body']); if($this->db->execute()){ return true; } else{ return false; } } // 删除文章 public function deletePost($id){ $query = 'DELETE ...'; $this->db->query($query); $this->db->bind('id', $id); if($this->db->execute()){ return true; } else{ return false; } }}
接着是 Controller。这次我们需要操作资料库,所以在建构子中呼叫 model('Post') 并赋值给 postModel。这个方法来自于它继承的 Controller.php,用意是将 Model Post 实例化。又因为上面我们知道 Post 被实例化时会将 Database 实例化,且拥有五个操作文章的方法。所以接下来我们就可以使用 postModel 执行这些方法。
再来,我们再增添五个方法:「index」、「create」、「show」、「edit」、「delete」。在这些方法中透过 postModel 对资料库做操作,操作完毕后呼叫 view() 来呈现画面。若有回传值则将回传值塞进 view() 的第二个参数,若无则仅需一个参数。稍微不同的是,由于 delete() 的目的是要删除某一笔文章,所以没有对应的 view,而是重新导向回 posts/index。又因为我们在 Core.php 里定义预设的方法为 index,所以仅需重新导向回 posts 即可。
app/controllers/Posts.php
<?phpclass Posts extends Controller{ // 在建构子中将 Post 物件(Model)实例化 public function __construct() { $this->postModel = $this->model('Post'); } // 取得所有文章 public function index(){ $posts = $this->postModel->getPosts(); $data = [ 'posts' => $posts ]; $this->view('posts/index', $data); } // 发布新文章 public function create(){ // 注:基于输入值得验证及安全性,需要对使用者的 post 资料做处理。 // 但是这里省略上述步骤,以观念解释为主。 $data = [ 'title' => $_POST['title'], 'body' => $_POST['body'], ]; $this->view('posts/create', $data); } // 取得特定一则文章 public function show($id) { $post = $this->postModel->getPostById($id); $data = [ 'post' => $post, 'user' => $user ]; $this->view('posts/show', $data); } // 更新文章 public function edit($id){ // 注:这里跟新增文章相当类似 // 一样省略验证与消毒,以观念解释为主。 $data = [ 'title' => trim($_POST['title']), 'body' => trim($_POST['body']), ]; $this->view('posts/edit', $data); } // 删除文章 public function delete($id) { // 注:这里一样省略验证与消毒,以观念解释为主。 $this->postModel->deletePost($id) // 这边预先写了一个全域函式,可以重新导向 redirect('posts'); }}
最后是在 views 要加入几个对应的档案。因为删除并没有对应的页面,所以只需要新增前面四个页面。
app/views/posts/index.php
app/views/posts/create.php
app/views/posts/show.php
app/views/posts/edit.php
以上,我们完成发文相关功能的实作。我们最后再举一个例子帮助大家複习整个运作流程。
使用者输入 127.0.0.1/public?url=posts。网站入口点 index.php 被触发, bootstrap.php 被载入,并将 Core 物件实例化。Core 物件将会解析路由,取得阵列 ['posts']。因为 posts 存在,所以会载入 Controller Posts.php。Posts 物件被实例化时触发建构子,呼叫从 Controller.php 继承来的 model 方法,取得可以操作资料库的 postModel。由于 阵列 ['posts'] 没有提供方法和参数,所以使用预设的 index 方法和空阵列作为参数。Controller Posts 的 index 方法会被执行。在 index 方法中,postModel 将所有资料取出包进阵列,然后呼叫从 Controller.php 继承来的 view 方法,第一个参数填入的是欲呈现的 view 名称,在这边是 posts/index,第二个参数则是方才取出的资料阵列。View posts/index.php 将资料阵列适当的填入相应的 html 位置,呈现予使用者浏览。参考资源
本文介绍的观念与程式码绝大部分来自 Udemy 上的课程。以下附上课程连结:
Object Oriented PHP & MVC