この記事はレコチョクAdventCalendar2022の15日目の記事となります。
はじめに
こんにちは、株式会社レコチョク新卒エンジニアの祖父江です。
駆け出しサーバーサイドエンジニアとしてmurketというサービスに携わっています。
好きなアーティストはOasis、毛皮のマリーズ、GLIM SPANKYです!よろしくおねがいします!
私のプログラミングに関する略歴は以下になります。
- 文系学部出身で、入社前までほぼプログラミング未経験
- 入社後、レコチョクのエンジニア研修でPHPを学ぶ
- 今年10月に現在の部署に配属後、PHPのフレームワークであるCakePHPを勉強し始めたところ
本記事では、簡単な掲示板システムをPHPで作成した後、CakePHPでの作成方法を書き方の違いに触れながら紹介したいと思います。
「現在PHPを使っていてCakePHPにも興味があり、雰囲気を知りたい」方や「フレームワークを触ってみたい」といった方に向けて、少しでも参考になれば幸いです。
注意
本記事では素のPHPとCakePHPの違いに触れることが目的のため、最小限で実装しています。
一部丁寧さに欠ける処理があります。ご注意ください。
そもそもCakePHPとは?
CakePHPはPHPのためのオープンソースフレームワークです。
フレームワークとは、アプリケーション開発においてよく利用される機能をあらかじめ備えた枠組みのことです。
フレームワークは共通する処理部分のプログラムを自動的に作成してくれるため、作りたいシステムに必要な固有処理を追記するだけでアプリケーションを構築することができます。
PHPのフレームワークにはLaravel, Symfonyなど様々存在しますが、レコチョクではCakePHPを採用しています。
余談ですが、CakePHPという名前は、「ケーキを焼くように簡単に開発できる」という意味で名付けられたそうです。
今回作りたいもの
DBを用いたシンプルな掲示板システムを作ります。
要件
- 名前とコメントを入力し、発言できる
- 全ての利用者の過去発言を一覧で見ることができ、「名前、コメント、投稿日時」がそれぞれ表示されている
- バリデーション
- 名前は20文字以内、コメントは100文字以内
- 空白・半角スペースのみの入力は許容しない
- 入力文字の間に空白があれば、そのまま出力する
- 入力文字に特殊文字があれば、そのまま出力する
前提
- EC2を使用しています
- OSイメージ:Amazon Linux 2 Kernel 5.10
- MariaDBのバージョン:10.5.10
- nginxのバージョン: 1.20.0
- php-fpmのバージョン: 7.4.30
- PHPのバージョン: 7.4.30
- CakePHPのバージョン: 4.2
準備
データベースを作成します。
CREATE TABLE `logs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`comment` varchar(255) DEFAULT NULL,
`created` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
素のPHPで作った場合
まずは素のPHPで作ってみましょう!
<?php
const COMMENT_MAX = 100;
const NAME_MAX = 20;
const DB_HOST = 'ホスト名';
const DB_USER = 'ユーザー名';
const DB_PASSWORD = 'パスワード';
const DB_NAME = 'データベース名';
$post_data = [];
$error_txt_list = [];
/**
* DBに接続する
*
* @return object|false 成功:PDOクラスのインスタンス 失敗:false
*/
function get_db_connect() {
try {
$link = new PDO('mysql:dbname=' . DB_NAME . ';host=' . DB_HOST . ';charset=UTF8', DB_USER, DB_PASSWORD);
$link->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
return false;
}
return $link;
}
/**
* 過去の投稿一覧を取得
*
* @param object $link PDOクラスのインスタンス
* @return array|false 成功:過去の投稿が入った配列、該当するデータがない場合は空配列 失敗:false
*/
function fetch_data($link) {
try {
$sql = $link->prepare('SELECT name, comment, created FROM logs ORDER BY created DESC');
if ($sql->execute()) {
return $sql->fetchAll(PDO::FETCH_ASSOC);
}
} catch (PDOException $e) {
return false;
}
}
/**
* 投稿する
*
* @param object $link PDOクラスのインスタンス
* @param string $name 入力された名前
* @param string $comment 入力されたコメント
* @return boolean 成功:true 失敗:false
*/
function insert_new_post($link, $name, $comment) {
try {
$sql = $link->prepare('INSERT INTO logs (name, comment, created)
VALUES (:name, :comment, :created)');
$now = date('Y-m-d H:i:s');
$sql->bindParam(':name', $name, PDO::PARAM_STR);
$sql->bindParam(':comment', $comment, PDO::PARAM_STR);
$sql->bindParam(':created', $now, PDO::PARAM_STR);
$sql->execute();
} catch (PDOException $e) {
return false;
}
return true;
}
// 過去の投稿を表示
$link = get_db_connect();
if ($link !== false) {
$post_data = fetch_data($link);
$link = null;
if ($post_data === false) {
$error_message_list[] = '投稿一覧の表示に失敗しました';
}
} else {
$error_message_list[] = 'データベース接続に失敗しました';
}
// 新しく投稿する
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = '';
$comment = '';
if (isset($_POST['name'], $_POST['comment'], $_POST['submit']) !== false) {
$name = $_POST['name'];
$comment = $_POST['comment'];
if (strlen($name) === 0) {
$error_txt_list[] = '名前が未入力です';
} elseif (preg_match('/\A[ ]+\z/', $name)) {
$error_txt_list[] = '名前に半角スペースのみ入力されています。半角スペースのみの投稿はできません';
} elseif (mb_strlen($name) > NAME_MAX) {
$error_txt_list[] = '名前は' . NAME_MAX . '文字以内にしてください';
}
if (strlen($comment) === 0) {
$error_txt_list[] = 'コメントが未入力です';
} elseif (preg_match('/\A[ ]+\z/', $comment)) {
$error_txt_list[] = 'コメントに半角スペースのみ入力されています。半角スペースのみの投稿はできません';
} elseif (mb_strlen($comment) > COMMENT_MAX) {
$error_txt_list[] = 'コメントは' . COMMENT_MAX . '文字以内にしてください';
}
if (count($error_txt_list) === 0) {
$link = get_db_connect();
if ($link === false) {
$error_message_list[] = 'db error occured';
}
if (insert_new_post($link, $name, $comment) === false) {
$link = null;
$error_message_list[] = 'db error occured';
}
// 投稿に成功した場合はリダイレクトする
$link = null;
header('Location: http://52.192.153.161/bbs.php');
exit;
}
} else {
$error_txt_list[] = '送信に失敗しました';
}
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ひとこと掲示板</title>
</head>
<body>
<h1>ひとこと掲示板</h1>
<?php foreach ($error_txt_list as $error_txt_list) { ?>
<p><?php print htmlspecialchars($error_txt_list, ENT_QUOTES, 'UTF-8'); ?></p>
<?php } ?>
<form method="post">
<p>名前: <input type="text" name="name"></p>
<p>発言: <input type="text" name="comment"></p>
<input type="submit" name="submit" value="送信">
</form>
<p>発言一覧</p>
<ul>
<?php if (count($post_data) === 0) { ?>
<p>まだコメントが投稿されていません</p>
<?php } ?>
<?php foreach ($post_data as $read) { ?>
<li>
<a>名前: <?php print htmlspecialchars($read['name'], ENT_QUOTES, 'UTF-8'); ?></a>
<a>コメント: <?php print htmlspecialchars($read['comment'], ENT_QUOTES, 'UTF-8'); ?></a>
<a>投稿日時: <?php print htmlspecialchars($read['created'], ENT_QUOTES, 'UTF-8'); ?></a>
</li>
<?php } ?>
</ul>
</body>
</html>
完成した画面がこちらです。
CakePHPで作ってみる
プロジェクトの作成・DB設定・ファイルの生成
今度はCakePHPを使用して作成してみましょう!
CakePHPのインストールにはComposerを使用します。
まずは現在の作業ディレクトリでComposerコマンドが打てるか確認します。
Composerのインストールがまだの場合、CakePHP公式ドキュメントを参考にインストールしてください。
$ composer
出力
______
/ ____/___ ____ ___ ____ ____ ________ _____
/ / / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__ ) __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
/_/
Composer version 2.4.2 2022-09-14 16:11:15
後略
Composerのマークが出れば大丈夫です!
インストールを行います。
$ composer create-project --prefer-dist cakephp/app:"4.2.*" ['プロジェクト名']
インストールが完了すると現在のディレクトリ配下にプロジェクト名のディレクトリが出来ているはずです。
$ ls
['プロジェクト名'] // ディレクトリが作成されていることを確認
ディレクトリの中身を見てみましょう!
$ ls ['プロジェクト名']
README.md bin composer.json composer.lock config index.php logs phpcs.xml phpstan.neon phpunit.xml.dist plugins resources src templates tests tmp vendor webroot
なんだか色々なディレクトリやファイルが入っています。。。
先程素のPHPで掲示板システムを作成した際には一枚のファイルにすべてプログラムを記述しました。
CakePHPでは、ひとつのWebアプリケーションは複数のファイルから構成され、機能ごとに各ディレクトリに格納されています。
プロジェクトを作成した時点で各ディレクトリが作られ、ディレクトリ構造はMVCモデルに沿ったものになっています。
各ディレクトリにどんなファイルが置かれるのか、主要なディレクトリについて簡単にまとめました。
CakePHPのディレクトリ構造
.
├── README.md
├── bin // 実行コマンド
├── config // DB設定やルートの設定など構成設定ファイル
├──logs // ログ出力場所
├── plugins //プラグイン保存先
├── resources
├── src //開発拠点
│ ├── Command
│ ├── Console
│ ├── Controller // コントローラーファイルが置かれる
│ ├── Model // モデルファイルが置かれる
│ └── View
├── templates // ビューファイルが置かれる
├── tests // テストケース設置場所
├── tmp //一時的なデータを格納する場所(キャッシュやセッション情報など)
├── vendor // ライブラリ格納場所(CakePHP固有のメソッドはここに入っている)
├── webroot // Web公開ディレクトリ(cssファイルもここに置かれる)
├── index.php
├── phpcs.xml
├── phpstan.neon
├── phpunit.xml.dist
├── composer.json
└── composer.lock
プロジェクト作成後はディレクトリ構成に沿ってプログラムを配置していくことになります。
様々なディレクトリがありますが、今回はconfig, src/Contorller, src/Model, templatesにそれぞれプログラムを記述します!
プロジェクトが作成できたので、ドキュメントルートを設定し、サーバーにアクセスした際にCakePHPのデフォルトページが表示できるか確認してみましょう。
/etc/nginx/nginx.confのルートをプロジェクトの絶対パス/webroot
に変更にします。
$ sudo vim /etc/nginx/nginx.conf
rootを変更する
server {
listen 80;
listen [::]:80;
server_name _;
root /プロジェクトの絶対パス/webroot; // ここを変更
index index.php index.html;
省略
}
nginxを再起動し、ブラウザからhttp://IPアドレス
にアクセスします。
CakePHPのデフォルトページが表示されればOKです!
DB設定
PHPで書いた際にはPDOを用い、DB操作の度に毎回DB接続をしていました。
CakePHPの場合はconfig/app_local.phpにDB情報を記述することで、DBに接続することができます。
config/app_local.phpを開き、Datasorces
の中のhost
, username
, password
, database
をそれぞれ自分のものに書き換えます。
/*
* Connection information used by the ORM to connect
* to your application's datastores.
*
* See app.php for more configuration options.
*/
'Datasources' => [
'default' => [
'host' => 'ホスト名',
/*
* CakePHP will use the default DB port based on the driver selected
* MySQL on MAMP uses port 8889, MAMP users will want to uncomment
* the following line and set the port accordingly
*/
//'port' => 'non_standard_port_number',
'username' => 'ユーザー名',
'password' => 'パスワード',
'database' => 'データベース名',
/*
* If not using the default 'public' schema with the PostgreSQL driver
* set it here.
*/
//'schema' => 'myapp',
/*
* You can use a DSN string to set the entire configuration
*/
'url' => env('DATABASE_URL', null),
],
またデフォルトだとデバックモードがONになっているので切っておきます。
/*
* Debug Level:
*
* Production Mode:
* false: No error messages, errors, or warnings shown.
*
* Development Mode:
* true: Errors and warnings shown.
*/
'debug' => filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN), // デフォルトではtrueになっているのでfalseに変更する
ファイルの生成
DB設定が作成・確認が終わったら次はファイルを作成します。
先程素のPHPで掲示板システムを作成した際には手動でファイル(必要であればディレクトリも)を作成しました。
CakePHPにはファイルの生成のためにbakeという便利機能があります!
bakeとはCakePHPが持つ機能のひとつであり、bakeコマンドを使えばディレクトリ内にプログラムの雛形となるファイルを自動生成することができます。
bakeはMVCモデルに基づき、データベースのテーブルに合わせてファイルを自動生成し、そのテーブルへのCRUD機能を持つWebアプリケーションを作成します。
CRUD機能とはCreate(生成), Read(読み取り), Update(更新), Delete(削除)機能のことです。
bakeを行う際にはテーブル名を指定して使用します。
今回はModel, Controller, Templatesのファイルを作りたいので以下のコマンドを実行します。
$ ./bin/cake bake model logs --no-test
生成されるファイル
- src/Model/Table/LogsTable.php
- src/Model/Entity/Log.php
$ ./bin/cake bake controller logs --no-test
生成されるファイル
- src/Controller/LogsController.php
$ ./bin/cake bake template logs
生成されるファイル
- templates/Logs/index.php
- templates/Logs/view.php
- templates/Logs/add.php
- templates/Logs/edit.php
ブラウザでhttp://IPアドレス/logs
にアクセスするとテーブルのレコード一覧が表示されています。
またlogsテーブルに対し、データの詳細表示・追加・編集・削除も行うこともできます。
たった3行コマンドを打ち込むだけで、DB操作ができるWebアプリケーションが作成できました!
bakeすごい。。。
ファイルの生成方法はbakeを使う以外にも手動で対応するディレクトリ内に作成することもできます。
その場合はCakePHPの規約に沿って命名を行う必要があります。
投稿一覧の表示
bakeを使ってWebアプリケーションの雛形が作れたので、次は生成されたファイルをそれぞれ編集し、今回の要件を満たすよう機能を実装していきます。
まずはどのリクエストに対してどのメソッドが呼び出されるのか設定していきます。
今回は一画面上で投稿の一覧表示&新規投稿ができるようにしたいので、GETで/logsにアクセスされたときにはLogsController.phpのindex()メソッドが、POSTでアクセスされたときにはadd()メソッドが実行されるようにconfig/routes.php
に設定します。
$routes->scope('/', function (RouteBuilder $builder) {
略
$builder->get('/logs', ['controller' => 'Logs', 'action' => 'index']); // この行を追加
$builder->post('/logs', ['controller' => 'Logs', 'action' => 'add']); // この行を追加
}
次に投稿の一覧表示していきます。
Controller
Controllerを編集します。
素のPHPの場合、SQL文を記述→実行→結果セットを配列に変換し取得というプロセスが必要でしたが、CakePHPの場合はSQL文を直接記入することなくDBの操作を行うことができます。
<?php
declare(strict_types=1);
namespace App\Controller;
/**
* Logs Controller
*
* @property \App\Model\Table\LogsTable $Logs
* @method \App\Model\Entity\Log[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
*/
class LogsController extends AppController
{
/**
* Index method
*
* @return \Cake\Http\Response|null|void Renders view
*/
public function index()
{
// 降順で全件取得したLogsテーブルのデータを変数logsに代入する
$logs = $this->Logs->find('all', array('order' => array('Logs.id DESC')));
// 変数logsをviewに渡す
$this->set(compact('logs'));
}
}
CakePHPはオブジェクト指向を用いるため、オブジェクト演算子(アロー演算子)を使って、LogsControllerインスタンスからLogsTableを呼び出し、find()メソッドを実行することで、SQLのSELECT文と同じ動作をすることができます。
変数logsに代入されたデータはset()メソッドを使ってViewに渡されます。
$logs = $this->Logs->find('all', array('order' => array('Logs.id DESC')));
$this->set(compact('logs'));
templates
MVCのViewにあたるtemplates/Logs/index.htmlを編集します。
Controllerからset()メソッドを使って渡された変数logsの値を表示します。
<h1>ひとこと掲示板</h1>
<table class="table">
<tr>
<th>名前</th>
<th>コメント</th>
<th>投稿日時</th>
</tr>
<tr>
<?php foreach ($logs as $log): ?>
<td>
<?= h($log->name) ?>
</td>
<td>
<?= h($log->comment) ?>
</td>
<td>
<?= $log->created->format(DATE_RFC850) ?>
</td>
</tr>
<?php endforeach; ?>
</table>
ブラウザからhttp://IPアドレス/logs
にアクセスし、確認します。
投稿の一覧が新しい投稿順にソートされ、テーブル形式で並んでいます!
新規投稿
名前とコメントを入力し、新たに投稿を追加できるようにします。
templates
先程編集したtemplates/Logs/index.phpにフォームを追加します。
フォームを作成したいときは、CakePHPのビューの機能であるFormHelperを使用します。
<h1>ひとこと掲示板</h1>
<?php
// フォームを生成
echo $this->Form->create($log);
// フォームの中身を指定
echo $this->Form->control('name',['label' => '名前', 'error' => false]);
echo $this->Form->control('comment', ['rows' => '3', 'label' => 'コメント', 'error' => false]);
echo $this->Form->button('投稿する', ['type' => 'submit']);
// フォームを終了
echo $this->Form->end();
?>
<table class="table">
<tr>
中略
Model
フォームに入力された値に対してバリデーションチェックを行います。
Modelにバリデーションを記述することに対して、違和感を覚える方もいるのではないでしょうか?(私はめちゃめちゃ感じました)
CakePHPのModelはTableとEntityという2つのオブジェクトで構成されています。
Tableはテーブルへのアクセスを行い、Entityはテーブルの中の1レコードに対して操作することができます。
validationDefault()メソッドを使い、バリデーションの検証ルールを定義することができます。
定義された検証ルールの実行タイミングはEntityが保存される前に自動で実行される仕様になっています。(参考)
明示的に記述しなくてもCakePHPが自動で実行してくれるメソッドがあることも、素のPHPで書く時との違いのひとつです。
また、今回はCakePHPのチュートリアルに沿い、Model/Tableにバリデーションルールを記述しましたが、実際の業務では別ファイルにValidatorクラスを作成し、Controller内で呼び出す方法を使うことが多いです。(参考)
<?php
declare(strict_types=1);
namespace App\Model\Table;
use Cake\ORM\Table;
use Cake\Validation\Validator;
class LogsTable extends Table
{
const NAME_MAX_LENGTH = 20;
const COMMENT_MAX_LENGTH = 100;
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('logs');
$this->setDisplayField('name');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
}
public function validationDefault(Validator $validator): Validator
{
$validator
->integer('id')
->allowEmptyString('id', null, 'create');
$validator
// キーが存在するかどうか
->requirePresence('name')
//テキスト型
->scalar('name')
//空白を許容しない
->notEmptyString('name', '名前を入力してください')
//最大文字数
->maxLength('name', self::NAME_MAX_LENGTH, '20文字以内で入力してください')
//空白文字だけの入力を制御
->add('name', 'notBlank', [
'rule' => [$this, 'check_space'],
'message' => ERROR_SPACE_ONLY_ENTRY
]);
$validator
->requirePresence('comment')
->scalar('comment')
->notEmptyString('comment', 'コメントを入力してください')
->maxLength('comment', self::COMMENT_MAX_LENGTH, '100文字以内で入力してください')
->add('comment', 'notBlank', [
'rule' => [$this, 'check_space'],
'message' => '半角スペースのみの入力はできません'
]);
return $validator;
}
public static function check_space($str)
{
return !(bool) preg_match('/\A[ ]+\z/', $str);
}
}
Controller
最後にControllerのadd()メソッドを編集しましょう!
最初にnewEmptyEntity()メソッドを使用して空のレコードを作成し、getData()メソッドで取得した値(入力された名前とコメント)を格納し、レコードを更新しています。
また、投稿に失敗したときor成功したときに、ユーザーに通知を行うために、Flashコンポーネントを使用しブラウザにメッセージ通知を表示しています。
<?php
declare(strict_types=1);
namespace App\Controller;
class LogsController extends AppController
{
略
/**
* Add method
*
* @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise.
*/
public function add()
{
//空のEntityを作成
$log = $this->Logs->newEmptyEntity();
if ($this->request->is('post')) {
//Entityにデータを格納し更新する
$log = $this->Logs->patchEntity($log, $this->request->getData());
$errors = $log->getErrors();
if (isset($errors['name'])) {
foreach($errors['name'] as $error){
$this->Flash->error('【名前】' . $error);
}
}
if (isset($errors['comment'])) {
foreach($errors['comment'] as $error){
$this->Flash->error('【コメント】'. $error);
}
}
if ($this->Logs->save($log)) {
$this->Flash->success(__('投稿完了'));
return $this->redirect(['action' => 'index']);
}
}
$this->set('log', $log);
}
}
ブラウザで確認します!
フォームが生成されていることが確認できました。
記事の投稿も問題なくできています。
バリデーションも動いています。
これで完成です!
素のPHPとCakePHPの違い
シンプルな掲示板システムを作成しただけでも、以下のような違いがあることがわかりました!
オブジェクト指向 | ファイル作成 | DBへの接続 | テーブル操作 | バリデーション | |
---|---|---|---|---|---|
素のPHP | 必須ではない | 手動 | PDOもしくは手続き型で毎回接続する | SQL文を実行する | if文を使って明示的に検証する |
CakePHP | 必須 | bake + 手動 |
app_local.php に設定するだけ |
クラスのメソッドを使う | Tableに検証ルールを記述すると自動で実行される |
まとめ
素のPHPとCakePHPでは書き方がだいぶ違うことが伝わったでしょうか。
約2ヶ月CakePHPを使ってみた所感としては、記述量が少なくてすむ点など便利さを感じる一方で、オブジェクト指向への理解不足や、CakePHPがよしなにやってくれているが故にエラーが発生した際に詰まってしまうことなど、知識不足に悩まされることもあります。
CakePHPは公式ドキュメントが充実しているので(分かりやすいとは言っていない)、今後もドキュメントやソースコードなどを読みつつ、知識を深めていきたいと思います。
コードやCakePHPについて気になる点があれば、ぜひ教えて下さい。
この記事がCakePHPに興味がある方の参考になれば幸いです。
明日のレコチョク Advent Calendarは16日目 VSCode上でCakePHPコーディング規約チェックの自動化
です。お楽しみに!
参考
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。