iT邦幫忙

7

手作:用純 PHP 打造 MVC 框架!?

前言

現今相當多的團隊都會使用框架開發,原因不外乎是因為框架提供了很多好用的工具可以加速開發,也能確保團隊可以在同一個規範下共同協作。當然還有許多其他的優點

本文旨在介紹自製簡易框架的基本邏輯。雖然實務上,基本上不太可能會使用自製的框架來開發。然而,對於新手開發者而言,時常只是使用框架預先包裝好的工具開發,卻不明白背後的運作邏輯,要做深入的客製化更是難以執行。故本文透過自製簡易框架,來理解框架的基本運作邏輯。

本文介紹的自製框架採取 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

<?php

require_once '../app/bootstrap.php';
$init = new Core();

bootstrap.php 被引用進來之後,也只做兩件事,第一是引入 config.php 好讓程式可以取用裡面的全域變數,裡面包含一些關於資料庫環境變數或資源引用的路徑變數等等;第二是引用所有 libraries 裡面的檔案(下一節會介紹到 libraries)。

app/bootstrap.php

<?php

require_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 物件被實例化的過程中,簡單來說做了以下幾件事:

  1. 物件含有三個屬性,分別代表「Controller」、「方法」、「參數」,預設的值為「'Pages'」、「'index'」、「空陣列」。
  2. 對於網址做處理。比如當使用者輸入 127.0.0.1/public?url=posts/show/1 時,會解析它然後得到一個 $url 陣列,它的值為 ['posts', 'show', 1]。若使用者輸入 127.0.0.1/public,則得到 []。
  3. 對於 $url 的元素做匹配。陣列中第一個值對應的是 Controller,第二個對應的是該 Controller 裡的方法,第三個(或有更多)則對應的是該 Controller 裡的該方法要傳入的參數。
  4. 執行!以 ['posts', 'show', 1] 為例,若 Controller Posts.php 存在,且含有一個名為 show 的方法,則會帶入參數 1 並執行它。以 [] 為例,則執行預設的 Controller Pages 物件中預設的 index 方法,並且不帶參數。

app/libraries/Core.php

<?php

class 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 比較單純。簡述如下:

  1. Database 物件被實例化時,會啟動建構子,透過 PDO 確定成功連線並將之實例化。
  2. 提供一些方法供 Model 使用,包含:
    1. query() 可以下查詢語法,作為 prepare statement
    2. bind() 綁定變數
    3. execute() 執行該 prepare statement
    4. getAll() 取得全部資料
    5. getSingle() 取得單筆資料
    6. getRowCount() 取得筆數

app/libraries/Database.php

<?php

class 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

<?php

class 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

<?php

Class 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 為例,運作流程如下:

  1. 使用者輸入 127.0.0.1/public?url=pages/index。
  2. 網站入口點 index.php 被觸發, bootstrap.php 被載入,並將 Core 物件實例化。
  3. 在 Core 物件被實例化時,建構子會先呼叫 getUrl(),pages/index 被解析成 [pages, index],並賦值給 $url。
  4. $url[0] 會被當作 Controller 的名稱,這裡對應到的是 Pages.php,將會實例化 Pages 物件。$url[1] 則是對應到 Pages 物件的 index 方法。所以 Controller Pages 的 index 方法會被執行。
  5. 在 index 方法中,會呼叫從 Controller.php 繼承來的 view 方法,第一個參數填入的是欲呈現的 view 名稱,在這邊是 pages/index,因為沒有需傳入的資料,所以不需要第二個參數。
  6. View pages/index.php 被引用進來,呈現 HELLO WORLD! 的畫面在瀏覽器上。

基於框架上開發

到這邊,我們已經完成框架了。接下來說明如何基於這個我們自製的框架進行開發。

假設我們要開發一個可以發文的相關功能,包含查看全部、新增、查看一筆、修改、刪除等五個功能。

這個框架符合 MVC 的架構,而且有發文的相關需求肯定需要和資料庫溝通,所以我們可以確定需要建立的檔案將會包含 Model、View、Controller。

我們先來談談 Model。這邊我們建立一個 Post.php,當 Post 物件被實例化時,會透過建構子將 Database 物件實例化,也就是說,它將自動連線完畢並且能夠使用我們剛剛在 Database.php 裡面定義的那些可以操作資料庫的基本方法!

接著我們在 Post 物件裡繼續撰寫需要用到的方法,包含「取得所有文章」、「發佈新文章」、「取得特定一則文章」、「更新文章」、「刪除文章」。這些方法都是根據基本方法作延伸的。

app/models/Post.php

<?php

class 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

<?php

class 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

以上,我們完成發文相關功能的實作。我們最後再舉一個例子幫助大家複習整個運作流程。

  1. 使用者輸入 127.0.0.1/public?url=posts。
  2. 網站入口點 index.php 被觸發, bootstrap.php 被載入,並將 Core 物件實例化。
  3. Core 物件將會解析路由,取得陣列 ['posts']。因為 posts 存在,所以會載入 Controller Posts.php。
  4. Posts 物件被實例化時觸發建構子,呼叫從 Controller.php 繼承來的 model 方法,取得可以操作資料庫的 postModel。
  5. 由於 陣列 ['posts'] 沒有提供方法和參數,所以使用預設的 index 方法和空陣列作為參數。Controller Posts 的 index 方法會被執行。
  6. 在 index 方法中,postModel 將所有資料取出包進陣列,然後呼叫從 Controller.php 繼承來的 view 方法,第一個參數填入的是欲呈現的 view 名稱,在這邊是 posts/index,第二個參數則是方才取出的資料陣列。
  7. View posts/index.php 將資料陣列適當的填入相應的 html 位置,呈現予使用者瀏覽。

參考資源

本文介紹的觀念與程式碼絕大部分來自 Udemy 上的課程。以下附上課程連結:
Object Oriented PHP & MVC


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言