自作 Php MVC Web Framework PART2
完成したレポジトリのリンクを張っておきます。
developブランチで確認してください。
レポジトリ
前回までのSTEPは
でしたね。
続きをやっていきたいと思います。
これからやるSTEPは以下になります
STEP7: DB
次はDBの実装を行っていきます。
DBMangerに各テーブルのレポジトリーを登録し、DBMangerで管理するという方法をとります。
このSTEPではDBを使用できるようにDBMangerを作成し、TaskAppでCRUDの動作確認を行っていきます。
- CRUD: Create, Read, Update, Delete
このSTEPで実装・編集するファイルは以下になります。
project.com
├─ Config/
│ └─ DBSettings.php
│
├─ Libs/
│ ├─ DB/
│ │ ├─ DBManager.php
│ │ ├─ Entity.php
│ │ └─ Repository.php
│ │
│ └─ Project.php
│
├─ TaskApp/
│ ├─ Controllers/
│ │ └─ TasksController.php
│ │
│ ├─ Entities/
│ │ └─ Task.php
│ │
│ ├─ migrations/
│ │ └─ init_tasks_table.php
│ │
│ ├─ Repositories/
│ │ └─ TasksRepository.php
│ │
│ └─ RoutingTable.php
│
└─ templates/
└─ task/
├─ create.tmp.php
├─ detail.tmp.php
├─ edit.tmp.php
└─ index.tmp.php
データベースの設定等
今回の記事の本質的な部分ではないのでコマンドなどの説明は省略します。
まずMysqlで下記を行います。
- アプリ専用ユーザーの登録
- アプリ専用データベースの登録
- アプリ専用ユーザーにアプリ専用データベースへの権限付与
Projectで扱うDBの設定値は以下です。
KEY | VALUE |
---|---|
USER | sample_app_user |
PASSWORD | Pasuwa-do123 |
DRIVER | mysql |
DB_NAME | sample_app_db |
HOST | localhost |
アプリ専用ユーザ・データベースの作成
では、rootユーザーでmysqlにログインして、以下を実行していきます。
use mysql;
# 今いるユーザーの一覧をみる、 ここに新しくユーザを追加します。
SELECT user, host FROM user;
# 下記のコマンドでユーザを作成する
# CREATE USER 'ユーザー名'@'ホスト名' IDENTIFIED BY 'パスワード';
CREATE USER 'sample_app_user'@'localhost' IDENTIFIED BY 'Pasuwa-do123';
# 次にサンプルアプリ用のデータベースを作りましょう。
CREATE DATABASE sample_app_db;
# 先ほど作ったユーザはまだ権限を全く持っていません。
# 先ほど作ったユーザーに新しく作成したデータベースの権限を付与します。
# * はすべてという意味です。
# なのでこの場合は sample_app_dbに対してすべての権限を持っています。
GRANT ALL ON sample_app_db.* to 'sample_app_user'@'localhost';
# 権限が付与されたか確認しましょう
SHOW GRANTS FOR 'sample_app_user'@'localhost';
# いったんLogoutします。
quit;
Taskテーブルの作成
アプリ専用ユーザーとデータベースが作成できたら、sqlを書いてTaskテーブルを作成してみましょう。
TaskApp/migrations/init_tasks_table.sqlを作成し、初期データも入れちゃいましょう。
create table sample_app_db.tasks
(
id int not null primary key auto_increment,
title char(30) not null,
status char(10) not null
);
desc sample_app_db.tasks;
INSERT INTO sample_app_db.tasks (title, status) VALUES
('title a', 'todo'),
('title b', 'doing'),
('title c', 'done');
SELECT * from sample_app_db.tasks;
では作成したsqlを実行します。
mysql -u sample_app_user -p < /var/www/project.com/TaskApp/migrations/init_tasks_table.sql
結果に下記が表示されていれば成功です。
- desc sample_app_db.tasks;の結果
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | int(11) | NO | PRI | NULL | auto_increment |
title | char(30) | NO | NULL | ||
status | char(10) | NO | NULL |
- select * from sample_app_db.tasks;の結果
id | title | status |
---|---|---|
1 | title a | todo |
2 | title b | doing |
3 | title c | done |
Config/DBSettings.php
DBの設定用ファイルを作成しましょう
<?php
namespace Config;
class DBSettings
{
public const USE_DB = true;
public const USER = 'sample_app_user';
public const PASSWORD = 'Pasuwa-do123';
public const DRIVER = 'mysql';
public const DB_NAME = "sample_app_db";
public const HOST = "localhost";
public const OPTIONS =
[
[\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION]
];
public const REPOSITORIES_TABLE =
[
];
}
DBManager
DBの設定が完了したので実際にDBMangerを作成していきます。
DBとの接続にはPODを使います。
DBMangerは以下の機能を持っています。
- DBとの接続
- レポジトリーの登録
- レポジトリーの取得
<?php
namespace Libs\DB;
use Config\DBSettings;
class DBManager
{
private static DBManager $instance;
private \PDO $connection;
private array $repository_table = array();
private function __construct()
{
$this->initialize();
}
public function __destruct()
{
foreach ($this->repository_table as $repository){
unset($repository);
}
unset($this->connection);
}
public static function instance()
{
if (empty(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
/**
* @param $repository_key
* @return Repository
*/
public function repository($repository_key)
{
return $this->repository_table[$repository_key];
}
public function registerRepositories(array $repository_table)
{
$this->repository_table = array_merge($this->repository_table, $repository_table);
}
private function initialize()
{
$dsn = $this->createDsn();
$this->connection = new \PDO(
$dsn,
DBSettings::USER,
DBSettings::PASSWORD
);
foreach(DBSettings::OPTIONS as $option){
$this->connection->setAttribute($option[0], $option[1]);
}
$repositories = array();
foreach(DBSettings::REPOSITORIES_TABLE as $repo_table){
$repo = new $repo_table['repository'](
$repo_table['table_name'],
$this->connection);
$repositories[$repo_table['key']] = $repo;
}
$this->registerRepositories($repositories);
}
private function createDsn(): string
{
$dsn = DBSettings::DRIVER . ":" .
"dbname=" . DBSettings::DB_NAME . ";" .
"host=" . DBSettings::HOST . ";";
return $dsn;
}
}
では初期化処理をProject.phpで行いましょう。
DBManger::instance() するだけで初期化を行えます
<?php
...
use Libs\DB\DBManager;
class Project
{
...
private function __construct()
{
$this->_request = Request::instance();
$this->_router = new Router(ProjectSettings::ROUTING_TABLE_CLASSES);
DBManager::instance();
}
...
}
http://project.com/tasks/john にアクセスし前回と結果に変化がなければ成功です。
ここで driver not foundのエラーが出た場合は mysql driver がphpにインストールされていない状態なので、インストールしましょう。
最初に説明した方法で環境構築を行っている場合は、下記のコマンドでインストールできます。
yum install -y --enablerepo=remi-php74 php-mysqlnd
Repository / Entity
では次にRepositoryとEntityを作成し、TaskAppで確認していきましょう。
まずベースとなるRepositoryクラスとEntityクラスを作成します。
まずEntityクラスを作成します。
Entityクラスはテーブルのコラムの一覧を返す機能を持っています。
<?php
namespace Libs\DB;
abstract class Entity
{
public string $id;
public abstract static function columns();
}
テーブルのidコラムがプライマリキーと限定したクラスになっています。
新しいテーブルを作る際にはidをプライマリキーとして作成してください。
次にRepositoryクラスを作成します。
Repositoryクラスは以下の機能を持っています。
- sqlを実行する機能
- sqlを実行した結果を取得する機能
- テーブルのデータ一覧を取得する機能
最初はこれだけですがこれから、以下の機能を追加しつつTaskAppを完成させていこうと思います。
- フィルター機能
- id指定でデータを取得する機能
- データを追加する機能
- データを更新する機能
- データを削除する機能
<?php
namespace Libs\DB;
class Repository
{
protected string $_table_name;
protected \PDO $_connection;
private string $_entity_class;
/**
* Override this method to return entity class of repository.
* @return null
*/
protected function entityClass()
{
return null;
}
public function __construct($table_name, \PDO $connection, $entity_class = null)
{
$this->_table_name = $table_name;
$this->_connection = $connection;
$this->_entity_class = is_null($entity_class) ? $this->entityClass() : $entity_class;
if (is_null($this->_entity_class)) {
throw new \InvalidArgumentException('$this->entity_class is required not string.');
}
}
public function all()
{
$sql = "select * from {$this->_table_name}";
return $this->fetchAll($sql);
}
public function execute($sql, $params = [])
{
$query = $this->_connection->prepare($sql);
$query->execute($params);
return $query;
}
public function fetchAll($sql, $params = [])
{
$query = $this->execute($sql, $params);
return $query->fetchAll(\PDO::FETCH_CLASS, $this->_entity_class);
}
}
TaskApp
では実際にDBを用いてTaskAppを完成させていきましょう。
TaskAppにCRUDの機能を搭載していきます。
Taskの全件表示
まずはTaskを全件表示させてみましょう。
レポジトリーとエンティティの登録
ベースとなるRepositoryクラスとEntityクラスを継承した、
TasksRepositoryクラスとTaskクラスを作成していきます。
<?php
namespace TaskApp\Entities;
use Libs\DB\Entity;
class Task extends Entity
{
public string $title;
public string $status;
public static function columns()
{
return ['title', 'status'];
}
}
<?php
namespace TaskApp\Repositories;
use Libs\DB\Repository;
use TaskApp\Entities\Task;
class TasksRepository extends Repository
{
protected function entityClass()
{
return Task::class;
}
}
作成したTasksRepositoryを登録しましょう。
...
public const REPOSITORIES_TABLE =
[
[
'key' => "tasks",
'table_name' => 'tasks',
'entity' => \TaskApp\Entities\Task::class,
"repository" => \TaskApp\Repositories\TasksRepository::class
],
];
...
全件出力処理
ではレポジトリとエンティティの登録が完了したので、
TasksControllerでTaskの全件データをTemplateに渡して表示していきましょう。
<?php
...
class TasksController extends Controller
{
private $_repository;
public function __construct()
{
parent::__construct();
$this->_repository = \Libs\DB\DBManager::instance()->repository('tasks');
}
public function index($params)
{
return $this->render('tasks/index', ['tasks' => $this->_repository->all()]);
}
...
}
TaskApp/RoutingTableを変更します。
ここでついでにDetailのルートも変更します。
<?php
namespace TaskApp;
use TaskApp\Controllers\TasksController;
class RoutingTable extends \Libs\Routing\RoutingTable
{
protected array $urlPatterns = [
['', 'GET', TasksController::class, 'index'],
['int:id', 'GET', TasksController::class, 'detail'],
];
}
あとはテンプレートを変更して動作確認を行いましょう。
...
<body>
<h1>Tasks</h1>
<ul>
<?php foreach ($tasks as $task){?>
<li>
<a href="/tasks/<?php $escape($task->id) ?>">
<?php $escape($task->title) ?> : <?php $escape($task->status) ?>
</a>
</li>
<?php } ?>
</ul>
</body>
...
では実際にブラウザで確認してみましょう。 http://project.com/tasks
下記の内容が表示されていれば成功です。
Tasks
・title a : todo
・title b : doing
・title c : done
Taskの詳細ページ
全件出力ができるようになったので次は一つ一つのTaskを表示できるようにしましょう。
Libs/DB/Repository.phpに以下の機能を追加します。
- フィルター機能
- id指定でデータを取得する機能
...
class Repository
{
...
public function get($id)
{
$result = $this->where('id', '=', $id);
return empty($result[0]) ? null : $result[0];
}
public function where($column, $operator, $value)
{
$sql = "select * from {$this->_table_name} where {$column} {$operator} :value";
return $this->fetchAll($sql, [':value' => $value]);
}
public function execute($sql, $params = [])
...
}
テンプレートの詳細ページにTaskの情報を渡すようにTasksControllerを変更しましょう。
...
class TasksController extends Controller
{
...
public function detail($params)
{
$task = $this->_repository->get($params['id']);
if(is_null($task))
return $this->render404();
return $this->render('tasks/detail', ['task' => $task]);
}
}
あとはテンプレートを変更して動作確認を行います。
...
<body>
<a href="/tasks/"> LIST PAGE</a>
<ul>
<li>
Title: <?php $escape($task->title) ?>
</li>
<li>
Status: <?php $escape($task->status) ?>
</li>
</ul>
</body>
...
では実際にブラウザで確認してみましょう。 http://project.com/tasks
一覧ページにアクセスしそれぞれのTaskのリンクから詳細ページに行き、
正しい情報が表示されるか確認しましょう。
Taskの作成ページ
Taskの一覧の表示と詳細表示ができるようになりましたね、
次は新しいTaskを作成できるようにしましょう。
Libs/DB/Repository.phpに以下の機能を追加します。
- データを追加する機能
...
class Repository
{
...
public function insert(Entity $entity)
{
$columns = implode(', ', $this->_columns());
$values_columns = implode(', ', $this->_to_params($this->_columns()));
$sql = "insert into {$this->_table_name} ({$columns}) values ({$values_columns})";
$params = [];
foreach ($this->_columns() as $key) {
$params[$this->_to_param($key)] = $entity->$key;
}
$this->execute($sql, $params);
}
public function execute($sql, $params = [])
...
private function _columns(): array
{
return $this->_entity_class::columns();
}
private function _to_param(string $key): string
{
return ':' . $key;
}
private function _to_params(array $keys): array
{
$result = [];
foreach ($keys as $key) {
$result[] = $this->_to_param($key);
}
return $result;
}
}
ではTaskApp側を実装していきます。
今回追加するアクションは以下の2個になります。
- create: Taskの追加フォームを表示するアクション
- store: Taskの追加フォームから送られてきたデータをもとにTaskを追加するアクション
まずTaskAppのRoutingTableで登録しましょう。
storeアクションがPOSTで登録されていることに注目してください。
<?php
namespace TaskApp;
use TaskApp\Controllers\TasksController;
class RoutingTable extends \Libs\Routing\RoutingTable
{
protected array $urlPatterns = [
['', 'GET', TasksController::class, 'index'],
['int:id', 'GET', TasksController::class, 'detail'],
['', 'POST', TasksController::class, 'store'],
['create', 'GET', TasksController::class, 'create'],
];
}
ではTasksControllerにstoreとcreateを追加します。
<?php
...
use TaskApp\Entities\Task;
class TasksController extends Controller
{
...
public function create($params)
{
return $this->render('tasks/create');
}
public function store($params)
{
$task = new Task();
$task->title = $this->_request->post('title');
$task->status = $this->_request->post('status');
$this->_repository->insert($task);
return $this->redirect('/tasks/');
}
}
では動作確認をしたいのでcreateテンプレートを作成しましょう。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form action="/tasks/" method="POST">
<ul>
<li>
<label>
Title:
<input type="text" name="title">
</label>
</li>
<li>
<label>
Status:
<input type="text" name="status">
</label>
</li>
</ul>
<input type="submit" value="Add">
</form>
</body>
</html>
ついでに一覧ページに作成ページへのリンクも張ってあげましょう
...
<a href="/tasks/create">Add</a>
</body>
...
では実際にブラウザで確認してみましょう。 http://project.com/tasks/create
実際に入力したデータが一覧ページに追加されるか確認してください。
Taskの更新・削除
新しいTaskの作成までできるようになりましたね、
あとは更新と削除だけですね。
Libs/DB/Repository.phpに以下の機能を追加します。
- データを更新する機能
- データを削除する機能
...
class Repository
{
...
public function update(Entity $entity)
{
$sql = "update {$this->_table_name} set ";
$params = [];
foreach ($this->_columns() as $column) {
$key = $this->_to_param($column);
$sql .= " {$column} = {$key},";
$params[$key] = $entity->$column;
}
$sql = rtrim($sql, ',');
$sql .= " where id = :id";
$params[':id'] = $entity->id;
$this->execute($sql, $params);
}
public function delete($id)
{
$sql = "delete from {$this->_table_name} where id = :id";
$this->execute($sql, [':id' => $id]);
}
public function execute($sql, $params = [])
...
}
ではTaskApp側を実装していきます。
今回追加するアクションは以下の3個になります。
- edit: Taskの編集フォームを表示するアクション
- update: 対象のTaskデータを変更するアクション
- delete: 対象のTaskを削除するアクション
まずTaskAppのRoutingTableで登録しましょう。
updateアクションがPUTで、deleteアクションがDELETEで登録されていることに注目してください。
<?php
namespace TaskApp;
use TaskApp\Controllers\TasksController;
class RoutingTable extends \Libs\Routing\RoutingTable
{
protected array $urlPatterns = [
['', 'GET', TasksController::class, 'index'],
['int:id', 'GET', TasksController::class, 'detail'],
['', 'POST', TasksController::class, 'store'],
['create', 'GET', TasksController::class, 'create'],
['int:id/edit', 'GET', TasksController::class, 'edit'],
['int:id', 'PUT', TasksController::class, 'update'],
['int:id', 'DELETE', TasksController::class, 'delete'],
];
}
ではTasksControllerにeditとupdateとdeleteを追加します。
<?php
...
class TasksController extends Controller
{
...
public function edit($params)
{
$task = $this->_repository->get($params['id']);
if(is_null($task))
return $this->render404();
return $this->render('tasks/edit', ['task' => $task]);
}
public function update($params){
$task = $this->_repository->get($params['id']);
if(is_null($task))
return $this->render404();
$task->title = $this->_request->post('title');
$task->status = $this->_request->post('status');
$this->_repository->update($task);
return $this->redirect('/tasks/' . $params['id']);
}
public function delete($params){
$this->_repository->delete($params['id']);
return $this->redirect('/tasks/');
}
}
では動作確認をしたいのでeditテンプレートを作成しましょう。
formのアクションはPOSTですが、form内のhiddenのinputでメソッドタイプをPUTに指定しています。
これはRequestクラスのmethodType関数でPOSTされたデータに_methodがある場合はそのメソッドタイプを使用するように設定されています。
HTMLフォームのメソッドタイプはGETかPOSTしか指定できないので _methodを利用して、メソッドタイプを変更しましょう。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<form action="/tasks/<?php $escape($task->id) ?>" method="POST">
<input type="hidden" name="_method" value="PUT">
<ul>
<li>
<label>
Title:
<input type="text" name="title" value="<?php $escape($task->title) ?>">
</label>
</li>
<li>
<label>
Status:
<input type="text" name="status" value="<?php $escape($task->status) ?>">
</label>
</li>
</ul>
<input type="submit" value="Update">
</form>
</body>
</html>
次にTaskの詳細ページに編集と削除のリンクを張ります。
削除リンクはフォームを使用していてDELETEのメソッドタイプを送信しています。
...
<a href="/tasks/<?php $escape($task->id) ?>/edit">EDIT</a>
<form action="/tasks/<?php $escape($task->id) ?>" method="POST">
<input type="hidden" name="_method" value="DELETE">
<input type="submit" value="DELETE">
</form>
</body>
では実際にブラウザで確認してみましょう。 http://project.com/tasks/
一覧ページから一つタスクを選んで編集や削除ができるか確認しましょう。
STEP8: セッションとユーザー認証
次にセッションの管理とセッションを用いたユーザー認証機能の実装を行いたいと思います。
このSTEPで実装・編集するファイルは以下になります。
project.com
├─ Config/
│ ├─ DBSettings.php
│ └─ ProjectSettings.php
│
├─ Libs/
│ └─ Apps
│ │ └─ Auth/
│ │ ├─ Controllers/
│ │ │ └─ UserController.php
│ │ │
│ │ ├─ Entities/
│ │ │ └─ User.php
│ │ │
│ │ ├─ migrations/
│ │ │ └─ create_users_table.sql
│ │ │
│ │ ├─ Repositories/
│ │ │ └─ UsersRepository.php
│ │ │
│ │ ├─ Services/
│ │ │ └─ UsersService.php
│ │ │
│ │ ├─ AuthApplication.php
│ │ └─ RoutingTable.php
│ │
│ └─ Https
│ └─ Session.php
│
└─ templates/
└─ auth/
├─ login.tmp.php
├─ my_page.tmp.php
└─ sign_up.tmp.php
セッション
PHPには標準の機能にセッションを扱う機能が提供されています。
今回はそれを用いてセッションの管理をしていきます。
セッションを扱いやすいようにSessionクラスを作成します。
<?php
namespace Libs\Https;
class Session
{
private static Session $_instance;
private static bool $is_session_started = false;
private static bool $is_session_id_regenerated = false;
private function __construct()
{
if (self::$is_session_started) return;
$this->startSession();
}
public static function instance()
{
if (empty(self::$_instance)) {
self::$_instance = new self();
}
return self::$_instance;
}
public function set($key, $value)
{
$_SESSION[$key] = $value;
}
public function get($key, $default = null)
{
return isset($_SESSION[$key]) ? $_SESSION[$key] : $default;
}
public function unSet($key)
{
unset($_SESSION[$key]);
}
public function clear()
{
$_SESSION = array();
}
public function regenerate($destroy = true)
{
if (self::$is_session_id_regenerated) return;
session_regenerate_id($destroy);
self::$is_session_id_regenerated = true;
}
private function startSession(): void
{
session_start();
self::$is_session_started = true;
}
}
動作確認をしたいのでTasksControllerで動作確認を行いましょう
...
public function detail($params)
{
echo \Libs\Https\Session::instance()->get('task_id') . '<br>';
$task = $this->_repository->get($params['id']);
\Libs\Https\Session::instance()->set('task_id', $params['id']);
if(is_null($task))
return $this->render404();
return $this->render('tasks/detail', ['task' => $task]);
}
...
では実際にアクセスして動作を確認しましょう
まずhttp://project.com/tasks/1にアクセスしましょう。
最後のIDを変更して再度アクセスしましょう。
変更するたびに前回のIDが出力されていれば成功です。
動作確認が完了したのでTasksControllerを変更前の状態に戻しておきましょう。
...
public function detail($params)
{
$task = $this->_repository->get($params['id']);
if(is_null($task))
return $this->render404();
return $this->render('tasks/detail', ['task' => $task]);
}
...
ユーザー認証
セッションの管理ができそうなので、次はユーザー認証機能を追加していきます。
新しくAuthアプリをLibs/Appsに追加していきます。
Authアプリは以下の機能を提供します。
- MyPageの表示
- SignUp機能
- Login機能
- Logout機能
とりあえず、DBにユーザテーブルを登録します。
create table sample_app_db.users
(
id int not null primary key auto_increment,
name varchar(255) not null unique,
password varchar(255) not null
)
sqlが作成できたので実行しましょう。
mysql -u sample_app_user -p < /var/www/project.com/Libs/Apps/Auth/migrations/create_users_table.sql
あたらしいAppを作るので
AuthApplicationを作成してプロジェクトに登録しましょう。
実装しましょう。
<?php
namespace Libs\Apps\Auth;
use Libs\Application;
class AuthApplication extends Application
{
}
ProjectSettingsに登録しましょう。
<?php
...
use Libs\Apps\Auth\AuthApplication;
class ProjectSettings
{
...
public const APPLICATIONS = [
ConfigApplication::class,
AuthApplication::class,
TaskApplication::class
];
...
}
SignUp機能
まずはSignUp機能の実装からやっていきましょう。
Userのレポジトリとエンティティ
まずはUserエンティティとレポジトリを作成しましょう。
<?php
namespace Libs\Apps\Auth\Entities;
use Libs\DB\Entity;
class User extends Entity
{
public string $name;
public string $password;
public static function columns()
{
return ['name', 'password'];
}
}
<?php
namespace Libs\Apps\Auth\Repositories;
use Libs\DB\Repository;
use Libs\Apps\Auth\Entities\User;
class UsersRepository extends Repository
{
protected function entityClass()
{
return User::class;
}
public function isUniqueName($name)
{
$users = $this->where('name', '=', $name);
return empty($users);
}
public function add($name, $password)
{
$user = new User();
$user->name = $name;
$user->password = password_hash($password, PASSWORD_BCRYPT);
$this->insert($user);
}
}
UsersRepositoryにisUniqueName関数とadd関数を追加しました。
isUniqueNameはユーザー名が重複するのを避けるために使用します。
add関数をわざわざ用意したのは機密情報(ここではユーザのパスワード)をDBで暗号化した状態で扱うためです。
password_hashというphpの関数を使用して、暗号化したパスワードをDBに保存しています。
ログイン機能の実装の際に暗号化したパスワードとユーザが入力したパスワードの比較方法を説明します。
ではUsersRepositoryができたのでDBSettingsに登録しましょう。
...
class DBSettings
{
...
public const REPOSITORIES_TABLE =
[
[
'key' => "users",
'table_name' => 'users',
"repository" => \Libs\Apps\Auth\Repositories\UsersRepository::class
],
[
'key' => "tasks",
'table_name' => 'tasks',
'entity' => \TaskApp\Entities\Task::class,
"repository" => \TaskApp\Repositories\TasksRepository::class
],
];
}
AuthService
認証の機能はいろんなところで使用されることが予想されるのでAuthServiceを作成しましょう。
この時点でのAuthServiceは新規ユーザを作成する機能を実装します。
<?php
namespace Libs\Apps\Auth\Services;
use Libs\DB\DBManager;
class AuthService
{
public static function addNewUser($name, $password)
{
$errors = [];
if (self::_repository()->isUniqueName($name) === false) {
$errors['name'] = "'{$name}'' is already exists.";
};
if (strlen($password) < 8) {
$errors['password'] = "Password required at least 8 letters.";
}
if (count($errors) === 0)
self::_repository()->add($name, $password);
return $errors;
}
protected static function _repository()
{
return DBManager::instance()->repository('users');
}
}
addNewUser関数はユーザーネームが重複してないこと、パスワードが8文字以上であることをValidationしてます。Validationが成功したら、新しいユーザを作成します。
ではUserControllerを作成してユーザの登録をしてきましょう。
<?php
namespace Libs\Apps\Auth\Controllers;
use Libs\Controllers\Controller;
use Libs\Apps\Auth\Services\AuthService;
class UserController extends Controller
{
public function myPage($params)
{
echo '<pre>';
var_dump(\Libs\DB\DBManager::instance()->repository('users')->all());
echo '</pre>';
return new \Libs\Https\Response('');
}
public function signUpForm($params)
{
return $this->render('auth/sign_up');
}
public function signUp($params)
{
$errors = AuthService::addNewUser($this->_request->post('name'), $this->_request->post('password'));
if (count($errors) === 0)
return $this->redirect('/auth');
return $this->render('auth/sign_up', ['errors' => $errors]);
}
}
myPageは動作確認するために登録したユーザー全件を出力してます。
新規Task作成と同様、ユーザ登録フォームの表示アクションと、ユーザ登録アクションの二つを実装しました。
ではRoutingTableを作成し登録しましょう。
<?php
namespace Libs\Apps\Auth;
use Libs\Apps\Auth\Controllers\UserController;
class RoutingTable extends \Libs\Routing\RoutingTable
{
protected array $urlPatterns = [
['', 'GET', UserController::class, 'myPage'],
['sign-up', 'GET', UserController::class, 'signUpForm'],
['sign-up', 'POST', UserController::class, 'signUp'],
];
}
...
class ProjectSettings
{
...
public const ROUTING_TABLE_CLASSES = [
['/^tasks(\/|)/', \TaskApp\RoutingTable::class],
['/^auth(\/|)/', \Libs\Apps\Auth\RoutingTable::class],
];
...
}
あとはテンプレートを作成して動作確認を行いましょう。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<?php if(empty($errors) === false){ ?>
<ul>
<?php foreach($errors as $key => $message){ ?>
<li> <?php $escape($message) ?></li>
<?php } ?>
</ul>
<?php } ?>
<form action="/auth/sign-up" method="POST">
<ul>
<li>
<label>
User Name:
<input type="text" name="name">
</label>
</li>
<li>
<label>
Password:
<input type="text" name="password">
</label>
</li>
</ul>
<input type="submit" value="SignUp">
</form>
<a href="/auth/login">Login</a>
</body>
</html>
では実際にブラウザで確認してみましょう。 http://project.com/auth/sign-up
新規のユーザーを登録し、Documentに新しいユーザが表示されていれば成功です。
Login・Logout機能
ユーザーを登録する機能ができたら次はログイン・ログアウトですよね。
ここからセッションを使用していきます。
AuthServiceに以下の機能を追加します。
- ログインする機能
- ログインしてるか確認する機能
- ログアウトする機能
- ログインしているユーザーを取得する機能
- ユーザー名からユーザを取得する機能
<?php
namespace Libs\Apps\Auth\Services;
use Libs\Apps\Auth\Entities\User;
use Libs\DB\DBManager;
use Libs\Https\Session;
class AuthService
{
const AUTHENTICATED_KEY = '_authenticated';
const AUTH_ID_KEY = '_auth_id';
public static function getLoginUser($is_secure = true)
{
$user = self::_repository()->get(self::_session()->get(self::AUTH_ID_KEY));
if (empty($user))
return $user;
if($is_secure === false)
return $user;
$user->password = '';
return $user;
}
public static function getUser($name)
{
$result = self::_repository()->where('name', '=', $name);
return empty($result[0]) ? null : $result[0];
}
public static function login(User $user, $password)
{
if (password_verify($password, $user->password) === false) {
return false;
}
self::_session()->set(self::AUTHENTICATED_KEY, true);
self::_session()->set(self::AUTH_ID_KEY, $user->id);
self::_session()->regenerate();
return true;
}
public static function logout()
{
self::_session()->set(self::AUTHENTICATED_KEY, false);
self::_session()->set(self::AUTH_ID_KEY, false);
self::_session()->regenerate();
}
public static function isAuthenticated()
{
return self::_session()->get(self::AUTHENTICATED_KEY) === true;
}
public static function addNewUser($name, $password)
{
$errors = [];
if (self::_repository()->isUniqueName($name) === false) {
$errors['name'] = "'{$name}'' is already exists.";
};
if (strlen($password) < 8) {
$errors['password'] = "Password required at least 8 letters.";
}
if (count($errors) === 0)
self::_repository()->add($name, $password);
return $errors;
}
protected static function _repository()
{
return DBManager::instance()->repository('users');
}
protected static function _session()
{
return Session::instance();
}
}
login関数を見てみましょう。
暗号化したパスワードを比較するにはpassword_verify関数を使用します。
これで実際に入力されたパスワードが暗号化されたパスワードとの比較を行います。
比較に成功した場合はセッションに以下の二つをセットします。
- 認証に成功しているよー状態
- 認証に成功したユーザーのID
ユーザを取得する関数はセッションにセットされているIDを使用して取得してきます。
logout関数に関してはそれぞれのセッションにfalseを入れてしまえばそれでlogoutになります。
では、UserControllerに以下のアクションを追加して、ルートを登録しましょう。
- ログインしているユーザーのマイページを表示(名前だけ)するアクション
- ログインフォームを表示するアクション
- ログインするアクション
- ログアウトするアクション
<?php
namespace Libs\Apps\Auth\Controllers;
use Libs\Controllers\Controller;
use Libs\Apps\Auth\Services\AuthService;
class UserController extends Controller
{
public function myPage($params)
{
if (AuthService::isAuthenticated() === false)
return $this->redirect('/auth/login');
return $this->render(
'auth/my_page',
['user' => AuthService::getLoginUser()]);
}
public function signUpForm($params)
{
return $this->render('auth/sign_up');
}
public function signUp($params)
{
$errors = AuthService::addNewUser($this->_request->post('name'), $this->_request->post('password'));
if (count($errors) === 0)
return $this->redirect('/auth/login');
return $this->render('auth/sign_up', ['errors' => $errors]);
}
public function loginForm($params)
{
return $this->render('auth/login');
}
public function login($params)
{
$failed_result = $this->render('auth/login', ['error' => 'Name or password is correct.']);
$user = AuthService::getUser($this->_request->post('name'));
if (is_null($user))
return $failed_result;
if (AuthService::login($user, $this->_request->post('password')))
return $this->redirect('/auth/');
return $failed_result;
}
public function logout($params)
{
AuthService::logout();
return $this->redirect('/auth/login');
}
}
<?php
namespace Libs\Apps\Auth;
use Libs\Apps\Auth\Controllers\UserController;
class RoutingTable extends \Libs\Routing\RoutingTable
{
protected array $urlPatterns = [
['', 'GET', UserController::class, 'myPage'],
['sign-up', 'GET', UserController::class, 'signUpForm'],
['sign-up', 'POST', UserController::class, 'signUp'],
['login', 'GET', UserController::class, 'loginForm'],
['login', 'POST', UserController::class, 'login'],
['logout', 'GET', UserController::class, 'logout'],
];
}
あとはテンプレート追加して動作確認をしましょう。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
User: <?php $escape($user->name)?>
<a href="/auth/logout">Logout</a>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<?php if(empty($error) === false){ $escape($error); }?>
<form action="/auth/login" method="POST">
<ul>
<li>
<label>
User Name:
<input type="text" name="name">
</label>
</li>
<li>
<label>
Password:
<input type="text" name="password">
</label>
</li>
</ul>
<input type="submit" value="Login">
</form>
<a href="/auth/sign-up">Sign Up</a>
</body>
</html>
では実際にブラウザで確認してみましょう。 http://project.com/auth
ログインしてない状態でマイページにアクセスするとログインページにリダイレクトします。
ログインページで先ほど作成したユーザでログインしてください。
ログインに成功するとマイページにリダイレクトしてユーザー名が表示されれば成功です。
ログアウトして再度http://project.com/authにアクセスしログインページにリダイレクトされればログアウト成功です。
STEP9: Middleware
最後にMiddleware機能の追加を行いましょう。
Middleware機能を追加して、ユーザ認証していなければアクセスできないようにするなどを実装していきたいと思います。
このSTEPで実装・編集するファイルは以下になります。
project.com
├─ Config/
│ ├─ ConfigApplication.php
│ └─ ProjectSettings.php
│
└─ Libs/
├─ Apps
│ └─ Auth/
│ ├─ Controllers/
| | └─ UserController.php
| |
│ └─ Middleware/
│ ├─ AuthMiddleware.php
| └─ RequiredAuthenticationMiddleware.php
|
├─ Https/
│ └─ Request.php
│
├─ Middleware/
│ ├─ BaseMiddleware.php
│ └─ MiddlewareManager.php
│
└─ Project.php
Middleware実装
ではMiddlewareの実装を行っていきます。
ここでのMiddlewareには以下の機能を提供します。
- コントローラのアクションを実行する前に、リクエストを受け取って何かしらの処理をする。
- HTTPレスポンスを返す前にレスポンスを受け取って何かしらの処理をする。
まずベースとなるMiddlewareを作成しましょう。
<?php
namespace Libs\Middleware;
use Libs\Https\Request;
use Libs\Https\Response;
class BaseMiddleware
{
protected bool $_have_to_return_response_immediately = false;
/**
* Every request pass here.
* You can edit request in here like set request->user.
*
* Return request if you want continue.
* Return response if you want return response immediately like 401 response.
*
* @param Request $request
* @return Request|Response
*/
public function processRequest(Request $request){
return $request;
}
/**
* Every responses pass here.
* You can edit response in here like set cookie header.
*
* Set true to $this->_have_to_return_response_immediately if you want return response immediately.
*
* @param Response $response
* @return Response
*/
public function processResponse(Response $response){
return $response;
}
public function haveToReturnResponse(){
return $this->_have_to_return_response_immediately;
}
}
Middlewareを登録する場合はこのクラスを継承したMiddlewareを登録します。
- processRequest関数:
- リクエスト受け取り何かしらの処理をする関数です。
- 何も問題がなければリクエストを返却してください。
- 問題が発生し(ログインしてないなど)すぐにレスポンスを返したい場合は、レスポンスを返却するという仕様です。
- processResponse関数:
- レスポンスを受け取り何かしらの処理をする関数です。
- 何も問題がなければレスポンスを返却してください。
- 何か問題が発生した場合は$_have_to_return_resonse_immediatelyをtrueにしてレスポンスを返却するという仕様です。
上記の仕様を踏まえて、MiddlewareManagerクラスを作成しましょう。
<?php
namespace Libs\Middleware;
use Libs\Https\Response;
class MiddlewareManager
{
private array $_middleware_list = [];
public function __construct($middleware_list)
{
foreach ($middleware_list as $middleware) {
$this->_middleware_list[] = new $middleware;
}
}
public function processRequest($request)
{
$result = $request;
foreach ($this->_middleware_list as $middleware) {
$result = $middleware->processRequest($result);
if ($result instanceof Response)
break;
}
return $result;
}
public function processResponse($response) : Response
{
$result = $response;
foreach (array_reverse($this->_middleware_list) as $middleware) {
$result = $middleware->processResponse($result);
if ($middleware->haveToReturnResponse())
break;
}
return $result;
}
}
では作成したMiddlewareManagerをProject.phpを編集して機能するようにします。
class ProjectSettings
{
...
public const MIDDLEWARE_CLASSES = [];
public const ROUTING_TABLE_CLASSES = [
...
<?php
namespace Libs;
use Config\ProjectSettings;
use Libs\Controllers\Controller;
use Libs\DB\DBManager;
use Libs\Https\Request;
use Libs\Https\Response;
use Libs\Middleware\MiddlewareManager;
use Libs\Routing\Router;
/**
* Class Project
* @package Libs
*/
class Project
{
private static Project $_instance;
private Request $_request;
private Router $_router;
private MiddlewareManager $middleware_manager;
private function __construct()
{
DBManager::instance();
$this->_request = Request::instance();
$this->_router = new Router(ProjectSettings::ROUTING_TABLE_CLASSES);
$this->middleware_manager = new MiddlewareManager(ProjectSettings::MIDDLEWARE_CLASSES);
}
public static function instance()
{
if (empty(self::$_instance)) {
self::$_instance = new Project();
}
return self::$_instance;
}
public function run()
{
$result = $this->middleware_manager->processRequest($this->_request);
if ($result instanceof Response) {
$result->send();
return;
}
list($controller, $action, $params) = $this->_selectController();
$response = $this->_actionController($controller, $action, $params);
$response = $this->middleware_manager->processResponse($response);
$response->send();
}
private function _selectController()
{
$result = $this->_router->resolve($this->_request);
if (is_null($result)) {
$controller = ProjectSettings::NOT_FOUND_CONTROLLER;
return [new $controller(), 'index', []];
}
return [new $result['class'], $result['action'], $result['params']];
}
private function _actionController(Controller $controller, string $action, array $params)
{
return $controller->run($action, $params);
}
}
run関数内部でコントローラーのアクションを選択する前にMiddlewareのリクエストプロセスを実行します。
アクション実行後Middlewareのレスポンスプロセスを実行します。
実際に動作確認をしてみましょう。
動作確認用のサンプルMiddlewareを作成します。
<?php
namespace Libs\Apps\Auth\Middleware;
use Libs\Https\Request;
use Libs\Https\Response;
use Libs\Middleware\BaseMiddleware;
class MiddlewareA extends BaseMiddleware
{
public function processRequest(Request $request){
echo "Request pass the Middleware A. <br><br>";
return $request;
}
public function processResponse(Response $response){
echo "Response pass the Middleware A. <br><br>";
return $response;
}
}
<?php
namespace Libs\Apps\Auth\Middleware;
use Libs\Https\Request;
use Libs\Https\Response;
use Libs\Middleware\BaseMiddleware;
class MiddlewareB extends BaseMiddleware
{
public function processRequest(Request $request){
echo "Request pass the Middleware B. <br><br>";
return $request;
}
public function processResponse(Response $response){
echo "Response pass the Middleware B. <br><br>";
return $response;
}
}
作製したMiddlewareを登録します。
class ProjectSettings
{
...
public const MIDDLEWARE_CLASSES = [
\Libs\Apps\Auth\Middleware\MiddlewareA::class,
\Libs\Apps\Auth\Middleware\MiddlewareB::class,
];
...
では、実際に動作確認をしましょう http://project.com/
以下の結果が表示されていれば成功です。
Request pass the Middleware A.
Request pass the Middleware B.
Response pass the Middleware B.
Response pass the Middleware A.
Page not found.
動作を見てもらえばわかる通り、リクエストは登録されている上からの順番で、レスポンスを下からの順番で実行されます。
動作確認が完了したらProjectSettings.phpのMIDDLEWARE_CLASSESを空配列に戻しておきましょう。
あとサンプルで作成したMiddlewareAとMiddlewareBを削除しておきましょう。
class ProjectSettings
{
...
public const MIDDLEWARE_CLASSES = [];
...
Requestにユーザを引っ付ける
DjangoやLaravelでユーザを取得する方法の一つに
$request->user()
や
request.user
があると思います。
これをできるようにMiddlewareでリクエストにuserを引っ付けちゃいましょう。
AuthMiddlewareを作成して登録します。
<?php
namespace Libs\Apps\Auth\Middleware;
use Libs\Https\Request;
use Libs\Middleware\BaseMiddleware;
use Libs\Apps\Auth\Services\AuthService;
class AuthMiddleware extends BaseMiddleware
{
public function processRequest(Request $request){
$request->user = AuthService::getLoginUser();
return $request;
}
}
...
public const MIDDLEWARE_CLASSES = [
\Libs\Apps\Auth\Middleware\AuthMiddleware::class,
];
...
では実際に動作確認をしましょう。
UserControllerのmyPage関数内でAuthService::getLoginUser関数を使用しているところをリクエストから取得するように変更します。
...
class UserController extends Controller
{
public function myPage($params)
{
if (AuthService::isAuthenticated() === false)
return $this->redirect('/auth/login');
return $this->render(
'auth/my_page',
['user' => $this->_request->user]);
}
...
ではログインして動作確認をしましょう。 http://project.com/auth/
以前と同じようにユーザー名が出力されれば成功です。
ユーザー認証制限
ユーザの認証がない状態でアクセスしたらログインページにリダイレクトするMiddlewareを実装していきます。
RequiredAuthenticationMiddlewareを作成し登録しましょう。
<?php
namespace Libs\Apps\Auth\Middleware;
use Libs\Https\Request;
use Libs\Https\Response;
use Libs\Middleware\BaseMiddleware;
use Libs\Apps\Auth\Services\AuthService;
class RequiredAuthenticationMiddleware extends BaseMiddleware
{
public static array $IGNORE_URL_PATTERNS = ['/^auth\/(login|sign-up)$/'];
private array $_ignore_url_patterns;
public function __construct()
{
$this->_ignore_url_patterns = self::$IGNORE_URL_PATTERNS;
}
public function processRequest(Request $request)
{
if (AuthService::isAuthenticated())
return $request;
foreach ($this->_ignore_url_patterns as $ignore_url_pattern) {
if (preg_match($ignore_url_pattern, $request->pathInfo()))
return $request;
}
return Response::redirect('/auth/login');
}
}
IGNORE_URL_PATTERNSにはユーザ認証なしでもアクセスできるパスの正規表現パターンを設定します。
初期設定ではログイン画面と登録画面のみ制限を解除しています。
では登録して、アクセスしてみましょう。
...
public const MIDDLEWARE_CLASSES = [
\Libs\Apps\Auth\Middleware\AuthMiddleware::class,
\Libs\Apps\Auth\Middleware\RequiredAuthenticationMiddleware::class,
];
...
では実際に動作確認を行います。まずログアウトしてください。 http://project.com/auth/logout
その後Taskの一覧ページにアクセスして、ログインページに飛ばされることを確認しましょう。 http://project.com/tasks/
次はログインしてもう一度Taskの一覧ページにアクセスして一覧が表示されれば成功です。
IGNORE_URL_PATTERNSの設定
IGNORE_URL_PATTERNSに登録するとそのページはログインなしでアクセスすることができるようになります。
Taskの一覧ページを見れるように、ConfigApplicationのready関数でIGNORE_URL_PATTERNSを拡張しましょう。
<?php
namespace Config;
use Libs\Application;
class ConfigApplication extends Application
{
public function ready()
{
\Libs\Apps\Auth\Middleware\RequiredAuthenticationMiddleware::$IGNORE_URL_PATTERNS[] = '/^tasks\/$/';
}
}
では実際に動作確認を行います。まずログアウトしてください。 http://project.com/auth/logout
その後Taskの一覧ページにアクセスして、一覧が表示されることを確認しましょう。 http://project.com/tasks/
Taskの詳細ページアクセスするとログインページに飛ばされることが確認できれば成功です。
まとめ
以上で自作 MVC WebFrameworkの完成です。
最後まで一緒に頑張ってくれた方ありがとうございます。
だんだん疲れてきて雑になっているかもしれませんがご容赦ください。
あくまでも最低限の機能を学ぶためのWebFrameworkですので、セキュリティー面などはほぼ考慮してないです。
このフレームワークを使って開発したものを公開するのはやめといたほうがいいと思います。自分ならしませんw。
ただ自分で作ったフレームワークを拡張してアプリを作るはとても楽しいです。
良ければ拡張しながら遊んでってもらえばなーと思います。
自分が拡張した例を紹介しますね。
- ViewにFormViewを追加した
- TemplateシステムにLayoutを追加した。Layoutをextendsして扱えるということです。
- DIを実装、これは昔C# でやってみたことがあってPHPでもやりたかったのでやりましたw
- ORマッパー的な奴、難しくてあまりうまいことできなかったです。
今度実装したDIパターンとかまとめてみようかなと思います。
完成したレポジトリのリンクを張っておきます。
developブランチで確認してください。
レポジトリ
長い記事でしたがお付き合いただきありがとうございました!!!