PHPでオレオレフレームワークを作ってみた

フレームワークを学習するには、フレームワークを自作するのが一番だとどこかで聞いたため、いっちょPHPでオレオレフレームワークを作ってみました。
拙いですけど…。
製作期間は大体12時間くらい。

ITの現場では負の遺産としてよく揶揄されるオレオレフレームワーク
F社とかが作った独自フレームワークとか、社内の凄腕?プログラマが作ったやつとか…。
まあ、私一人で使うならいいよね。

折角なのでコードを晒して解説して、初学者の糧になってもらえればと思います。


フレームワークの概要

今回作ったのはPHPのWebフレームワークで、MVC(Model View Controlle)を採用しています。

MVCとはなんぞやみたいな説明はWebの各所で語られています。
下手なこと言うとお前のMVCはMVCじゃないとか言われそうなので説明は放棄します笑

主だった機能は、

・フロント部分はjQuery、Bootstrapを使用
・composerを使ってライブラリ管理可能
・ルーティング機能
・テンプレートエンジン(smarty)の使用
・DBMSはSQLite、MySQLに対応

といったところでしょうか。

ディレクトリ階層

MyFramework
 ├─app
 │  │  app.ini
 │  │  
 │  ├─controller
 │  │      controller.php
 │  │      sample.php
 │  │      
 │  ├─model
 │  │  │  model.php
 │  │  │  mytable.php
 │  │  │  
 │  │  ├─ddl
 │  │  │      mytable.sql
 │  │  │      
 │  │  └─sqlite
 │  │          sample.db
 │  │          
 │  ├─utility
 │  │      utility.php
 │  │      
 │  └─view
 │      │  sample.tpl
 │      │  
 │      └─ template
 │             footer.tpl
 │             foot_tag.tpl
 │             header.tpl
 │             head_tag.tpl
 │             
 ├─logs
 ├─vendor
 └─webroot
     │  .htaccess
     │  index.php
     │  
     ├─css  
     └─js

主なディレクトリ・ファイルの役割

  • app/ … アプリケーションの本体。PHPは基本ここに作る
    • app.ini …アプリケーションの設定ファイル
    • controller/ … コントローラーのクラスを配置
    • model/ … モデルのクラスを配置
    • utility/ … よく使う関数群をまとめたクラス
    • view/ … ビューのテンプレートを配置
  • logs/ … アプリケーションログの出力場所
  • vendor/ … composerでインストールしたライブラリが入るところ
  • webroot/ … Web公開ディレクトリ

ルーティング

ルーティングはWebアプリケーションフレームワークの基本的な機能です。
URLとファイルを一対一で紐づけず、URLの文字列やクエリストリングの命名ルールで呼び出すディレクトリ、ファイルを決定するために利用します。

ルーティングを使うとURLを変えずに呼び出されるプログラムを変更することが容易になりますし、Webアプリが何の言語でできてるか隠すことができるのでセキュリティの向上にもつながります。

このフレームワークでは、どんなURLのアクセスも必ずwebroot/index.phpを通して振り分けることによってルーティングを実現します。
<?php
# index.php

if(empty($_SERVER['REQUEST_URI'])) {
    exit;
}

// URLをスラッシュで分解
$array_parse_uri = explode('/', $_SERVER['REQUEST_URI']);
$last_uri = end($array_parse_uri); // 最後の文字を取り出す
$call = substr($last_uri, 0, strcspn($last_uri,'?'));   // クエリ文字列を外す

// app/controller/配下に同名のPHPファイルがないか探す。
if(file_exists('../app/controller/' . $call . '.php')) {
    // 見つかったファイルをインクルードしてコントローラーをインスタンス化
    include('../app/controller/' . $call . '.php');
    $class = 'app\controller\\' . $call;
    $obj   = new $class();
    
    if($_SERVER["REQUEST_METHOD"] != "POST"){
        // GETならindexメソッドを呼び出す
        $response = $obj->index();
    }else{
        // POSTならpostメソッドを呼び出す
        $response = $obj->post();
    }

    // コントローラーから戻された内容をレスポンスとして戻す。
    echo $response;
    exit;
}else{
    // ファイルがなければ404エラー
    header("https/1.0 404 Not Found");
    exit;
}
URLに書かれた内容を無視してindex.phpにアクセスさせるには、ApacheのMod_Rewriteを使用します。
Mod_Rewriteの定義は「httpsd.conf」にも書けますが、対象としたいディレクトリに「.htaccess」を置くことでも実現できます。
#.htaccess
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_URI} !\.css$
    RewriteCond %{REQUEST_URI} !\.js$
    RewriteCond %{REQUEST_URI} !\.jpg$
    RewriteCond %{REQUEST_URI} !\.gif$
    RewriteCond %{REQUEST_URI} !\.png$
    RewriteRule ^.*$ index.php
</IfModule>
RewriteRule ^.*$ index.phpですべてのアクセスをindex.phpに集めています。
ただし、cssやjs、イメージファイルにはアクセスできて欲しいので、それを除外条件として、RewriteCondに書いてます。

コントローラー

ルーティングから最初に呼ばれる処理がコントローラーです。
ここでは、モデルの呼び出しとテンプレートの生成を行います。

コントローラーは機能の数だけ増えるので、基底クラスを作って共通部分を取り出すようにしています。
requireやuseを何度も書きたくないですしね。
<?php
namespace app\controller;

require_once dirname ( __FILE__ ) . '/../../vendor/autoload.php';
require_once dirname ( __FILE__ ) . '/../utility/utility.php';

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Smarty;
use app\utility\utility;

class controller
{
    private $name   = 'controller';

    protected function __construct(){
        // コントローラーを直接呼ばれてもnewできないように
    }

    // ログの出力
    public function logging($message, string $file_name = 'app.log'){
        $Logger = new Logger('logger');
        $Logger->pushHandler(new StreamHandler(__DIR__ . '/../../logs/' . $file_name, Logger::INFO));
        $Logger->addInfo($message);
    }

    // ビューの生成
    public function view(string $template, array $param): string{
        $Smarty = new Smarty();
        $Smarty->template_dir = __DIR__ . '/../view/';
        $Smarty->compile_dir  = __DIR__ . '/../view/view_c/';
        $Smarty->escape_html  = true;
        $Smarty->assign([
            'cssUnCache'    => Utility::cssUnCache()
        ]);
        $Smarty->assign($param);

        return $Smarty->fetch($template . '.tpl');
    }
}

機能ごとに作成するコントローラーでは、app\controllerクラスを継承して作成します。
<?php
namespace app\controller;

require_once 'controller.php';
require_once dirname ( __FILE__ ) . '/../model/mytable.php';

use app\model\mytable;

class sample extends controller
{
    public function __construct(){
    }

    public function index(){ // GETで呼ばれた
        $mytable = new mytable();
        // テンプレートにパラメータを渡し、HTMLを生成し返却
        return $this->view($this->name, [
            'title'     => $this->name,
            'message'   => 'Hello',
            'list'      => $mytable->getAll()
        ]);
    }

    public function post(){ // POSTで呼ばれた
        if(isset($_POST['add'])){ // registerボタンを押された
            $param = filter_input(INPUT_POST, 'name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);

            $this->logging($param);

            $mytable = new mytable();
            $mytable->insert([ // レコード挿入
                'name'  => $param
            ]);
        }

        if(isset($_POST['delete'])){ // deleteボタンを押された
            $item = filter_input(INPUT_POST, 'item', FILTER_SANITIZE_FULL_SPECIAL_CHARS, FILTER_REQUIRE_ARRAY);
            $this->logging(implode(',', $item));

            $mytable = new mytable();
            foreach($item as $id => $val){
                $mytable->delete([ // レコード削除
                    'id'  => $id
                ]);
            }
        }

        // テンプレートにパラメータを渡し、HTMLを生成し返却
        return $this->view($this->name, [
            'title'     => $this->name,
            'message'   => 'Hello',
            'list'      => $mytable->getAll()
        ]);
    }
}

モデル

モデルではDBMSへの接続とテーブルデータの取得・更新を制御します。
こちらも基底クラスを作っています。
<?php
namespace app\model;

require_once dirname ( __FILE__ ) . '/../../vendor/autoload.php';
require_once dirname ( __FILE__ ) . '/../utility/utility.php';

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use PDO;
use app\utility\utility;

class model
{
    private $name   = 'model';
    public $pdo;

    public function __construct($dbms){
        // PDOを使いDBMSに接続
        try{
            switch(strtoupper($dbms)){ // パラメータでDBMSごとに接続を切り替え
                case 'MYSQL':
                    $dsn       = 'mysql:dbname=' . Utility::getIniValue('MYSQL', 'DB_NAME') 
                               . ';host=' . Utility::getIniValue('MYSQL', 'HOST_NAME');
                    $user      = Utility::getIniValue('MYSQL', 'USER');
                    $password  = Utility::getIniValue('MYSQL', 'PASSWORD');
                    $this->pdo = new PDO($dsn, $user, $password);
                    break;

                case 'SQLITE':
                    $db_file   = 'sample.db';
                    $db_path   = __DIR__ . '/sqlite/' . $db_file;
                    $this->pdo = new PDO('sqlite:' . $db_path);
                    break;
            }
            
            $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        }catch(Exception $e){
            $this->logging($e->getMessage());
        }
    }

    public function logging(string $message, string $file_name = 'pdo.log'){
        $Logger = new Logger('logger');
        $Logger->pushHandler(new StreamHandler(__DIR__ . '/../../logs/' . $file_name, Logger::INFO));
        $Logger->addInfo($message);
    }
}
モデルはテーブルごとに作成します。
テーブルにアクセスするためのSQLをメソッドとして定義します。
今時はORM使うのが普通ですけど…。
<?php
namespace app\model;

require_once 'model.php';

use PDO;
class mytable extends model
{
    private $name   = 'mytable';

    public function __construct(){
        $dbms = 'sqlite';
        parent::__construct($dbms); // 基底クラスのコンストラクタを呼び出しDBMSに接続
    }

    public function getAll():array { // レコードの取得
        $sql  = <<< EOF
SELECT *
FROM {$this->name}
EOF;
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute();
            $rs = $stmt->fetchAll();
        }catch(Exception $e){
            $this->logging($e->getMessage());
        }
        return $rs;
    }

    public function insert(array $param) { // レコードの挿入
        $sql  = <<< EOF
INSERT INTO {$this->name}(
    name
)VALUES(
    :name
)
EOF;
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':name', $param['name'], PDO::PARAM_STR);
            $stmt->execute();
        }catch(Exception $e){
            $this->logging($e->getMessage());
        }
    }

    public function delete(array $param) { // レコードの削除
        $sql  = <<< EOF
DELETE FROM {$this->name}
WHERE id = :id
EOF;
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->bindParam(':id', $param['id'], PDO::PARAM_INT);
            $stmt->execute();
        }catch(Exception $e){
            $this->logging($e->getMessage());
        }
    }
}

ビュー

最後はビューです、コントローラーから渡されたパラメータをHTMLに展開するために使用します。
テンプレートエンジンとしてSmartyを使っています。
素のPHPでも書けますが、テンプレートエンジンを使うことでより記述を簡略化できます。

headタグの中身やヘッダー、フッターは各ページで共通になるので、外部テンプレートを読み込むようにしています。
<!DOCTYPE>
<html lang="ja">
<head>
{* headタグの中身を出力 *}
{include file='template/head_tag.tpl'}
</head>
{* ヘッダーを出力 *}
{include file='template/header.tpl' title=$title}
<body class="d-flex flex-column h-100">
    <main role="main" class="flex-shrink-0">
        <div class="container">
            <h1>Sample Page!</h1>
            <p>{$message}</p>
        </div>
        <form method="post" class="form-horizontal">
            <div class="form-group ml-5">
                <button name="delete" type="submit" class="btn btn-danger">delete</button>
            </div>
            <table class="table table-striped">
                <tr>
                    <th>delete</th>
                    <th>id</th>
                    <th>name</th>
                </tr>
                {foreach from=$list item=item}
                <tr>
                    <td>
                        <input type="checkbox" name="item[{$item['id']}]" />
                    </td>
                    <td>{$item['id']}</td>
                    <td>{$item['name']}</td>
                </tr>
                {/foreach}
            </table>
        </form>
        <form class="form-inline" method="post">
            <label> Name </label><input type="text" name="name" class="form-control" placeholder="input new name">
            <button name="add" type="submit" class="btn btn-info">register</button>
        </form>
    </main>
</body>
{* フッターを出力 *}
{include file='template/footer.tpl' copyright='danishi'}
{* scriptタグを出力 *}
{include file='template/foot_tag.tpl'}
</html>

https://www.apps.danishi.net/MyFramework/sample」のURLを叩くと、これらのモジュールを通り下の画面が出力されます。

処理フローは、

  1. index.phpがURLを解析し、sampleコントローラーをインスタンス化。
    GETリクエストなのでindex()メソッドを呼び出す。
  2. mytableモデルをインスタンス化、getAlll()メソッドを呼び出しmytableのレコードを連想配列で受け取る。
  3. 受け取った連想配列をパラメータとしてview()メソッドを呼び出し、そのままテンプレートに渡す。
  4. テンプレートでは受け取ったパラメータを割当てHTMLを生成する。
  5. 生成したHTMLをindex.phpに返し、クライアントに返却する。
こんな感じ。
まだまだ、バリデータやヘルパーメソッドを実装したり、セッション管理の方法とか、セキュリティとか、色々見直すところは沢山あるのでちょっとずつバージョンアップしていこうと思います。

MVCフレームワークを実際に作ってみることでCakePHPやLaravelなどの著名なフレームワークの動きもおぼろげに掴めるようになる気がします。

PHPのMVCフレームワークって色々ありますが、同じPHPな以上、結局その内部でやっていることってそう変わらないはずなので。

ではまた。