_CrtSetAllocHookでメモリアロケーション検知

VC++にはメモリアロケーションをフックするために、_CrtSetAllocHookという関数が用意されています。

今回はこれを利用してみたいと思います。

ちなみに_DEBUGマクロが定義されていないと動作しません。

Hook関数

アローケーション時にprintfする関数を定義してみます。

blockTypeが_CRT_BLOCKの時はCランタイムライブラリによって呼び出されたものなので無視します。(そうしないとprintfでクラッシュします。)

allocTypeが_HOOK_ALLOCの時にprintfします。

返り値をTRUEにすることで、既定のアロケーションを呼び出すようにします。(FALSEだとアロケーションが行われません。)

int Hook(
    int allocType,
    void *userData,
    size_t size,
    int blockType,
    long requestNumber,
    const unsigned char *filename,
    int lineNumber
) {
    if (blockType == _CRT_BLOCK) {
        return TRUE;
    }
    if (allocType == _HOOK_ALLOC) {
        printf("alloc\n");
    }
    return TRUE;
}


動作確認

下記の様に記述すると、newの行でallocと出力されます。

_CrtSetAllocHook(Hook);
int* p = new int(10); // alloc

ちなみに、std::functionにラムダを代入する際はキャプチャの大きさによってアロケーションが行われたり行われなかったりするのですが、それを確認できたりします。

_CrtSetAllocHook(Hook);

int x1;
std::function<void()> f1 = [x1] {};

int x2[10];
std::function<void()> f2 = [x2] {}; // alloc

Hookを解除する場合はNULLを設定します。

_CrtSetAllocHook(NULL);






PHP --- DateTimeを利用してカレンダー作成

PHPには標準でカレンダーを作成する機能はないので、実装の一例を紹介します。

二次元配列生成

年月を入力すると、日曜始まりのカレンダー二次元配列が生成されます。(日付が存在しない場所は0が入っています。)

DateTimeのformatに'w'を渡すと曜日(日:0~土:6)が取得でき、't'を渡すとその月の日数が取得できます。

<?php
function getCalendar(int $year, int $month) : array {
    $firstDate = DateTime::createFromFormat('Y-m-d', "{$year}-{$month}-1");
    assert($firstDate);
    $firstDayOfWeek = (int)$firstDate->format('w');
    $lastDay = (int)$firstDate->format('t');
    $weeks = (int)ceil(($firstDayOfWeek + $lastDay) / 7);

    $calender = [];
    for ($i = 0; $i < $weeks * 7; $i++) {
        $day = $i + 1 - $firstDayOfWeek;
        if ($day <= 0 || $lastDay < $day) {
            $day = 0;
        }
        $calender[intdiv($i, 7)][$i % 7] = $day;
    }
    return $calender;
}


Table生成

<table>
    <thead>
        <tr>
            <th></th>
            <th></th>
            <th></th>
            <th></th>
            <th></th>
            <th></th>
            <th></th>
        </tr>
    </thead>
    <tbody>
<?php foreach (getCalendar(2018, 6) as $week) { ?>
        <tr>
<?php foreach ($week as $day) { ?>
            <td>
                <?php if (0 < $day) echo $day ?>
            </td>
<?php } ?>
        </tr>
<?php } ?>
    </tbody>
</table>

f:id:any-programming:20180628182608p:plain





PHP --- POSTで送られてきた画像をリサイズして保存

GDを利用するため、有効にしておいてください。

php.ini

extension=php_gd2.dll


<?php
$originalImage = imagecreatefromjpeg($_FILES['image']['tmp_name']);
assert($originalImage);

$originalWidth  = imagesx($originalImage);
$originalHeight = imagesy($originalImage);

$resizedWidth  = 100;
$resizedHeight = (int)($originalHeight * $resizedWidth / $originalWidth);

$resizedImage = imagecreatetruecolor($resizedWidth, $resizedHeight);
assert($resizedImage);

$result = imagecopyresampled(
    $resizedImage,
    $originalImage,
    0,
    0,
    0,
    0,
    $resizedWidth,
    $resizedHeight,
    $originalWidth,
    $originalHeight
);
assert($result);

$dstPath = '...';
$result = imagejpeg($resizedImage, $dstPath);
assert($result);

imagedestroy($originalImage);
imagedestroy($resizedImage);






designModeを利用して、ブラウザ上でリッチテキストエディタ

ブラウザ上で高機能なテキストエディタを実現したい時、ブラウザに元々備わっているdesignModeを利用することができます。

iframeを用意

ページにテキストエディタとなるiframeを配置します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>デザインモード</title>
</head>
<body>
<iframe id="design-area" width="500" height="500"></iframe>
</body>
</html>


designModeをON

下記のようにdesignModeをONにするだけでテキストが入力できるようになります。

var designDoc = document.getElementById("design-area").contentDocument;
designDoc.designMode = "On";

f:id:any-programming:20180627131735p:plain

execCommand

designModeをONにしただけではテキスト入力できるだけです。(貼り付けでHTMLを貼り付けることは可能です。)

例えば太字にしたい、色を付けたい、などを実現するにはexecCommandを利用します。

下記は太字化の機能を加えたものとなります。

<button onclick="bold()">bold</button>
function bold() {
    designDoc.execCommand("bold");
}

f:id:any-programming:20180627133259p:plain

貼り付け時にテキストのみ貼り付ける

デフォルトでは貼り付けで、コピーした色々なHTMLを貼り付けることができます。

テキストだけを貼り付けたい時は、下記のようにonpaste関数を置き換えます。

改行文字は削除していますが、brに置き換えてもいいと思います。

bodyDoc.body.onpaste = function(ev) {
    ev.preventDefault();
    var text = ev.clipboardData.getData("text/plain");
    text = text.replace(/\r?\n|\r/g, "");
    bodyDoc.execCommand("insertHTML", false, text);
};


画像の挿入

一度画像をアップロードしてから、execCommandでURLを貼り付ける手順となります。

FormDataにデータを詰めて、XMLHttpRequestでサーバーにリクエストを飛ばします。

今回はサーバー側にupload-imageのpostが準備してあり、成功なら画像のURLが返ってくる状況を想定しています。

inputのonchangeでvalueをクリアして、おなじファイルを選んでもイベントが実行されるようにしています。

<input type="file" accept="image/jpeg" onchange="uploadImage(this.files); this.value = ''">
function uploadImage(files) {
    if (files.length !== 1) {
        return;
    }
    var request = new XMLHttpRequest();
    request.open("POST", "/upload-image");
    var data = new FormData();
    data.append("image", files[0]);

    request.onreadystatechange = function () {
        if (request.readyState === 4) {
            if (request.status === 200) {
                var imageUrl = request.responseText;
                designDoc.execCommand("insertImage", false, imageUrl);
            } else {
                alert("アップロードに失敗しました。");
            }
        }
    };

    request.send(data);
}


デフォルトの段落要素を変更

エディタ上で改行を行うと、Chromeならdivで一文が囲まれます。

一方IEだとpで囲まれます。

これを統一するのには下記を実行します。

designDoc.execCommand("DefaultParagraphSeparator", false, "div");

しかしChromeだと最初の一文がdivで囲まれなかったり、完全統一とはいかない様子です。

内容の取得

実際のHTMLテキストは下記でアクセスできます。

designDoc.body.innerHTML






PHP --- フレームワークを利用しないでToDoアプリ作成(4)

前回までで、基本的なToDoアプリの実装を行いました。
PHP --- フレームワークを利用しないでToDoアプリ作成(3) - 何でもプログラミング

今回はCSRF対策とログイン機能を盛り込んでみたいと思います。

両方ともセッションを利用しますので、$_SESSIONにアクセスする前までに下記を呼んでおいてください。

session_start();


CSRF対策

iframe内で他ページを開くなどして、勝手に送出された悪意あるPOSTをはじくために、各フォームにtokenを埋め込んでおきます。

フォーム表示ごとにtokenを作り直してもいいのですが、今回はセッションIDと同じ寿命にして実装しました。


tokenを管理するために、下記のような関数群を定義しました。

tokenの生成のアルゴリズムや格納キーは適宜変更しても問題ありません。

<?php
function setNewCsrfToken(string $seed) : void {
    $token = sha1($seed . session_id() . microtime());
    $_SESSION['csrfToken'] = $token;
}
function isCsrfTokenSet() : bool {
    return isset($_SESSION['csrfToken']);
}
function getCsrfToken() : string {
    if (isset($_SESSION['csrfToken']) === false) {
        throw new Exception403();
    }
    return $_SESSION['csrfToken'];
}
function assertCsrfToken(string $token) : void {
    if (isset($_SESSION['csrfToken']) === false || $_SESSION['csrfToken'] !== $token) {
        throw new Exception403();
    }
}

アプリケーションの初めに、tokenが準備されていなかったら生成するコードを追加します。

setNewCsrfTokenに渡すseedは適宜変更してください。

<?php
if (isCsrfTokenSet() === false) {
    setNewCsrfToken('todo');
}

今まで作成したすべてのフォームにtokenを埋め込みます。

<input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">

POSTを処理する関数の各々でtokenをチェックしてもいいのですが、今回はrespond関数を書き換えて一括でチェックします。

<?php
function respond(string $pathInfo, array $routesGET, array $routesPOST, callable $onError) : void {
    try {
        if ($_SERVER['REQUEST_METHOD'] === 'GET') {
            $response = getResponse($pathInfo, $routesGET);
        } else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            assert403(isset($_POST['token']));
            assertCsrfToken($_POST['token']);
            $response = getResponse($pathInfo, $routesPOST);
        }
        if (isset($response)) {
            $response->send();
        } else {
            throw new Exception404();
        }
    } catch (Exception $e) {
        $onError($e)->send();
    }
}


ログイン機能

ToDoの編集を、ログインした人のみできるよう実装してみたいと思います。

ログイン状態を管理するよう、下記の関数群を定義します。

<?php
function setAuthenticated(bool $authenticated) : void {
    $_SESSION['authenticated'] = $authenticated;
}
function isAuthenticated() : bool {
    return $_SESSION['authenticated'] ?? false;
}
function assertAuthenticated() : void {
    if (isAuthenticated() === false) {
        throw new Exception403();
    }
}

ログイン画面を下記のように定義します。

login.php

<?php ob_start(); ob_implicit_flush(0); ?>

<?php if ($showError) { ?>
<p>ログインできませんでした。</p>
<?php } ?>

<form action="/login" method="post">
    <input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
    <ul>
        <li>
            <label>
                ユーザー
                <input type="text" name="user">
            </label>
        </li>
        <li>
            <label>
                パスワード
                <input type="password" name="password">
            </label>
        </li>
        <li>
            <button type="submit">送信</button>
        </li>
    </ul>
</form>

<?php
$title = 'ログイン';
$body  = ob_get_clean();
require 'layout.php';
?>

f:id:any-programming:20180627113837p:plain

layout.phpにログアウトボタンを追加します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title><?php safeEcho($title) ?></title>
</head>
<body>
<header>
    <p>ToDo</p>
<?php if (isAuthenticated()) { ?>
    <form action="/logout" method="post">
        <input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
        <button type="submit">ログアウト</button>
    </form>
<?php } ?>
</header>
<main>
    <?php echo $body ?>
</main>
</body>
</html>

todoList.phpを、権限によって表示するものを変えるようにします。

<?php ob_start(); ob_implicit_flush(0); ?>

<?php if (isAuthenticated()) { ?>
<a href="/add">追加</a>
<?php } ?>

<table>
<?php foreach ($todos as $todo) { ?>
    <tr>
        <td>
            <?php safeEcho($todo->date->format('Y/m/d')) ?>
        </td>
        <td>
            <?php safeEcho($todo->text) ?>
        </td>
<?php if (isAuthenticated()) { ?>
        <td>
            <form action="/delete" method="post">
                <input type="hidden" name="token" value="<?php safeEcho(getCsrfToken()) ?>">
                <input type="hidden" name="id" value="<?php safeEcho($todo->id) ?>">
                <button type="submit">削除</button>
            </form>
        </td>
<?php } ?>
    </tr>
<?php } ?>
</table>

<?php
$title = 'ToDo一覧';
$body  = ob_get_clean();
require 'layout.php';
?>

index.phpを下記のように書き換えています。

ユーザーとパスワードは現状固定で判定しています。

セキュリティ向上のため、ログイン時にセッションIDとtokenの再生成を行っています。

<?php
function todoList() : Response {
    // 変更なし
}

function todoAdd() : Response {
    assertAuthenticated();
    // 変更なし
}

function login() : Response {
    $showError = false;
    $content = render('login.php', compact('showError'));
    return Response::ok($content);
}

function post_login() : Response {
    $user     = assertPOST('user');
    $password = assertPOST('password');
    if ($user === 'user' && $password === 'pass') {
        setAuthenticated(true);
        session_regenerate_id(true);
        setNewCsrfToken('todo');
        return Response::redirect('/');
    } else {
        $showError = true;
        $content = render('login.php', compact('showError'));
        return Response::badRequest($content);
    }
}

function post_logout() : Response {
    setAuthenticated(false);
    return Response::redirect('/');
}

function post_todoAdd() : Response {
    assertAuthenticated();
    // 変更なし
}

function post_todoDelete() : Response {
    assertAuthenticated();
    // 変更なし
}

function onError(Exception $e) : Response {
    if ($e instanceof Exception400) {
        return Response::badRequest('400 Bad Request');
    } else if ($e instanceof Exception403) {
        return Response::forbidden('403 Forbidden');
    } else if ($e instanceof Exception404) {
        return Response::notFound('404 Not Found');
    } else {
        return Response::internalServerError('500 Internal Server Error');
    }
}

$routesGET = [
    '/'      => 'todoList',
    '/add'   => 'todoAdd',
    '/login' => 'login'
];
$routesPOST = [
    '/add'    => 'post_todoAdd',
    '/delete' => 'post_todoDelete',
    '/login'  => 'post_login',
    '/logout' => 'post_logout'
];

session_start();
if (isCsrfTokenSet() === false) {
    setNewCsrfToken('todo');
}
respond(getPathInfo(), $routesGET, $routesPOST, 'onError');






PHP --- フレームワークを利用しないでToDoアプリ作成(3)

前回は、ルーティングの機能まで実装しました。
PHP --- フレームワークを利用しないでToDoアプリ作成(2) - 何でもプログラミング

今回はデータベースと連携して、実際のToDoアプリを構築してみたいと思います。

データベースはSQLiteを利用しており、CSRF対策はまだ入れていません。

今回の作成物

下図のように、ToDoの一覧ページと、追加ページからなります。

簡単のため、編集の機能は搭載してありません。

f:id:any-programming:20180626170455p:plain f:id:any-programming:20180626170559p:plain

View部分は、layout.php、todoList.php、todoAdd.phpの3ファイルからなります。

データベースは、database.sqliteという名前のファイルで、下記のtodoテーブルが定義してあります。

id INTEGER PRIMARY KEY AUTOINCREMENT
date TEXT
text TEXT


layout.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title><?php safeEcho($title) ?></title>
</head>
<body>
<header>
    <p>ToDo</p>
</header>
<main>
    <?php echo $body ?>
</main>
</body>
</html>

todoList.php

<?php ob_start(); ob_implicit_flush(0); ?>

<a href="/add">追加</a>

<table>
<?php foreach ($todos as $todo) { ?>
    <tr>
        <td>
            <?php safeEcho($todo->date->format('Y/m/d')) ?>
        </td>
        <td>
            <?php safeEcho($todo->text) ?>
        </td>
        <td>
            <form action="/delete" method="post">
                <input type="hidden" name="id" value="<?php safeEcho($todo->id) ?>">
                <button type="submit">削除</button>
            </form>
        </td>
    </tr>
<?php } ?>
</table>

<?php
$title = 'ToDo一覧';
$body  = ob_get_clean();
require 'layout.php';
?>

todoAdd.php

<?php ob_start(); ob_implicit_flush(0); ?>

<?php if (empty($errors) === false) { ?>
<ul>
<?php foreach ($errors as $error) { ?>
    <li>
        <?php safeEcho($error) ?>
    </li>
<?php } ?>
</ul>
<?php } ?>

<form action="/add" method="post">
    <ul>
        <li>
            <label>
                日付
                <input type="text" name="date" value="<?php safeEcho($date) ?>">
            </label>
        </li>
        <li>
            <label>
                テキスト
                <input type="text" name="text" value="<?php safeEcho($text) ?>">
            </label>
        </li>
        <li>
            <button type="submit">送信</button>
        </li>
    </ul>
</form>

<?php
$title = 'ToDo追加';
$body  = ob_get_clean();
require 'layout.php';
?>

index.php

<?php
function connectDB() : PDO {
    return connectSQLite('database.sqlite');
}

class ToDo {
    const DATE_FORMAT = 'Y/m/d';
    public $id;
    public $date;
    public $text;
    public static function insert(PDO $db, DateTime $date, string $text) : void {
        $db->prepare('INSERT INTO todo(date, text) VALUES (?, ?)')
            ->execute([ $date->format(self::DATE_FORMAT), $text ]);
    }
    public static function delete(PDO $db, int $id) : void {
        $db->prepare('DELETE FROM todo WHERE id = ?')
            ->execute([ $id ]);
    }
    public static function exists(PDO $db, int $id) : bool {
        $statement = $db->prepare('SELECT * FROM todo WHERE id = ?');
        $statement->execute([ $id ]);
        return empty($statement->fetchAll()) === false;
    }
    public static function fetchAll(PDO $db) : array {
        $statement = $db->prepare('SELECT * FROM todo ORDER BY date DESC');
        $statement->execute();
        return array_map(function($x) {
            $todo = new ToDo();
            $todo->id   = $x['id'];
            $todo->date = DateTime::createFromFormat(self::DATE_FORMAT, $x['date']);
            $todo->text = $x['text'];
            return $todo;
        }, $statement->fetchAll());
    }
}

function todoList() : Response {
    $db = connectDB();
    $todos = ToDo::fetchAll($db);
    $content = render('todoList.php', compact('todos'));
    return Response::ok($content);
}

function todoAdd() : Response {
    $errors = [];
    $date = (new DateTime())->format('Y/m/d');
    $text = '';
    $content = render('todoAdd.php', compact('errors', 'date', 'text'));
    return Response::ok($content);
}

function post_todoAdd() : Response {
    $date = assertPOST('date');
    $text = assertPOST('text');

    $validate = new Validate();
    $validate->isTrue(empty($text) === false,  'テキストが空です。');
    $validate->isTrue(mb_strlen($text) <= 100, 'テキストは100文字までです。');

    $dateObj = DateTime::createFromFormat('Y/m/d', $date);
    $validate->isTrue($dateObj !== false, '日付が****/**/**の形式ではありません。');

    $errors = $validate->messages;
    if (empty($errors)) {
        $db = connectDB();
        ToDo::insert($db, $dateObj, $text);
        return Response::redirect('/');
    } else {
        $content = render('todoAdd.php', compact('errors', 'date', 'text'));
        return Response::badRequest($content);
    }
}

function post_todoDelete() : Response {
    $id = assertPOST('id');
    $db = connectDB();
    sqlTransaction($db, function($db) use ($id) {
        assert400(ToDo::exists($db, $id));
        ToDo::delete($db, $id);
    });
    return Response::redirect('/');
}

function onError(Exception $e) : Response {
    if ($e instanceof Exception400) {
        return Response::badRequest('400 Bad Request');
    } else if ($e instanceof Exception404) {
        return Response::notFound('404 Not Found');
    } else {
        return Response::internalServerError('500 Internal Server Error');
    }
}

$routesGET = [
    '/'    => 'todoList',
    '/add' => 'todoAdd',
];
$routesPOST = [
    '/add'    => 'post_todoAdd',
    '/delete' => 'post_todoDelete'
];

respond(getPathInfo(), $routesGET, $routesPOST, 'onError');


connectSQLite

SQLiteに接続する関数になります。

エラーを例外で送出、連想配列でデータを取得、静的プレースホルダーを利用するようにしています。

なお、データベースファイルは絶対パスで指定する必要があります。

<?php
function connectSQLite(string $name) : PDO {
    $db = new PDO("sqlite:{$name}");
    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
    return $db;
}


sqlTransaction

トランザクションを毎回記述するのを回避するため、callableを受け取る関数を定義しました。

<?php
function sqlTransaction(PDO $db, callable $f) : void {
    try {
        $db->beginTransaction();
        $f($db);
        $db->commit();
    } catch (Exception $e) {
        $db->rollBack();
        throw $e;
    }
}


Validate

POSTパラメータの検証の記述量削減のため、下記のようなクラスを準備してみました。

<?php
class Validate {
    public $messages = [];
    public function isTrue(bool $condition, string $message) : void {
        if ($condition === false) {
            array_push($this->messages, $message);
        }
    }
}

ついでにException400を送出するassert関数も定義してみました。

<?php
function assert400(bool $condition) {
    if ($condition === false) {
        throw new Exception400();
    }
}
function assertPOST(string $key) {
    if (isset($_POST[$key])) {
        return $_POST[$key];
    } else {
        throw new Exception400();
    }
}






PHP --- フレームワークを利用しないでToDoアプリ作成(2)

前回の記事にて、Viewをレンダリングして、レスポンスを生成して返すところまで実装しました。
PHP --- フレームワークを利用しないでToDoアプリ作成(1) - 何でもプログラミング

今回はURLに応じて表示する内容を変更するよう実装してみたいと思います。

また、ページ毎に同じ内容が表示できるよう、レイアウトの対応もしてみたいと思います。

今回の作成物

ページ共有部分を定義するlayout.phpと、適当な2つのページpage1.php、page2.php、コントロール部分のindex.phpからなります。

index.phpでは、リクエストのURL毎にレスポンスを生成する関数を定義してあり、URLと関数の対応を連想配列で定義してあります。

連想配列のキーでは、コロン( : )で始まる部分はそのまま関数の引数として渡されます。(今回は名前に意味はなく、出現順で引数に渡されます。)

また例外が生じた際のレスポンスを決める関数も定義してあります。

レイアウトの適用は、前記事でも利用したob_start、ob_get_cleanの仕組みを利用して、各ページで行っています。

またcompact関数は、変数を連想配列に変換してくれる関数です。


layout.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title><?php safeEcho($title) ?></title>
</head>
<body>
<header>
    <p>Site header</p>
</header>
<main>
<?php echo $body ?>
</main>
</body>
</html>

page1.php

<?php ob_start(); ob_implicit_flush(0); ?>

<h1>Page1</h1>
<p><?php safeEcho($text) ?></p>

<?php
$title = 'page1';
$body  = ob_get_clean();
require 'layout.php';
?>

page2.php

<?php ob_start(); ob_implicit_flush(0); ?>

<h1>Page2</h1>
<p><?php safeEcho($text) ?></p>

<?php
$title = 'page2';
$body  = ob_get_clean();
require 'layout.php';
?>

index.php

<?php
function page1(string $text) : Response {
    $content = render('page1.php', compact('text'));
    return Response::ok($content);
}

function page2(string $text) : Response {
    $content = render('page2.php', compact('text'));
    return Response::ok($content);
}

function onError(Exception $e) : Response {
    if ($e instanceof Exception404) {
        return Response::notFound('404 Not Found');
    } else {
        return Response::internalServerError('500 Internal Server Error');
    }
}

$routesGET = [
    '/page1/:text' => 'page1',
    '/page2/:text' => 'page2'
];

respond($_SERVER['PATH_INFO'], $routesGET, [], 'onError');

結果は下記のようになります。

f:id:any-programming:20180622154507p:plain f:id:any-programming:20180622154419p:plain f:id:any-programming:20180622154627p:plain


respond関数

入力のPathInfoに対し、適合するルートを探してコールバックを呼び、レスポンスを送出する関数になります。

今回はGETとPOSTのみ想定しており、別々の連想配列を受け取るようにしてあります。

<?php
function respond(string $pathInfo, array $routesGET, array $routesPOST, callable $onError) : void {
    try {
        if ($_SERVER['REQUEST_METHOD'] === 'GET') {
            $response = getResponse($pathInfo, $routesGET);
        } else if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $response = getResponse($pathInfo, $routesPOST);
        }
        if (isset($response)) {
            $response->send();
        } else {
            throw new Exception404();
        }
    } catch (Exception $e) {
        $onError($e)->send();
    }
}

class Exception404 extends Exception {};

getResponseは下記のように定義してあります。

適合するルートがあれば、call_user_func_arrayでコールバックを呼び出しています。

<?php
function getResponse(string $pathInfo, array $callbackMap) : ?Response {
    foreach ($callbackMap as $route => $f) {
        $matches = matchRoute($route, $pathInfo);
        if (isset($matches)) {
            return call_user_func_array($f, $matches);
        }
    }
    return null;
}

matchRouteは下記のように定義してあります。

コロンで始まる場所を、正規表現の変数取り出しの形式に変更してからpreg_matchを呼び出しています。

matchesの最初の要素にはマッチした全体が格納されているので、取り除いています。

<?php
function matchRoute(string $route, string $pathInfo) : ?array {
    $pattern = preg_replace('#:[^/]+#', '([^/]+)', $route);
    $matches = [];
    if (preg_match("#^{$pattern}$#", $pathInfo, $matches) === 1) {
        array_shift($matches);
        return $matches;
    } else {
        return null;
    }
}


モバイル判定

HTTP_USER_AGENTを利用することにより、リクエストがモバイル端末からかどうかを判定することができます。

PCとモバイルで表示を変える際に利用することができます。

<?php
function isMoble() : bool {
    $userAgent = $_SERVER['HTTP_USER_AGENT'];
    // Android
    if (str_contains($userAgent, 'Android') && str_contains($userAgent, 'Mobile')){
        return true;
    }
    // others
    foreach ([ 'iPhone', 'iPod', 'Windows Phone', 'BlackBerry' ] as $name) {
        if (str_contains($userAgent, $name)) {
            return true;
        }
    }
    return false;
}
function str_contains(string $str, string $word) : bool {
    return strpos($str, $word) !== false;
}

.htaccessを利用している場合は、下記を追記するとデバイスで表示が変わることを明記できます。

Header set Vary User-Agent


PathInfo

今回は$_SERVERのPATH_INFOを利用しましたが、環境によっては利用できないことがあります。

回避の一例として、URLのパラメータとして渡した場合を紹介してみたいと思います。

<?php
function getPathInfo() : string {
    $pathInfo = '/';
    if (isset($_SERVER['PATH_INFO'])) {
        $pathInfo = $_SERVER['PATH_INFO'];
    } else if (isset($_GET['path-info'])) {
        $pathInfo = $_GET['path-info'];
    }
    return $pathInfo;
}

.htaccess

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^(.+)$ index.php?path-info=/$1 [L]
</IfModule>