マイクロフレームワークをつくろう - Pimpleの上に (フォームオブジェクトとドメインデータで投稿フォーム)
Pimpleを拡張して自分好みに使うために作成した小さなアプリケーションクラスを使って、マイクロフレームワークっぽいものを作る試みです。
記事にはしていないものの、コードの方は頻繁に更新しています。
フレームワーク全体に影響する部分としては以下のような変更を行いました。
- 例外処理用のクラスを Volcanus_Error に統合した
- テンプレートレンダラクラスの構成を変更し、volcanus-template-renderer として独立させた
- 設定値へのアクセス用クラスの内容を volcanus-configuration として独立させた
- データベース処理用クラスを作成し、volcanus-database として独立させた
- Twitter Bootstrapのバージョンを2系から3に切り替え、外部のCDNおよびFont Awesomeの利用を廃止した
汎用クラス DataObject の実装
ここのところ、マジックメソッド、ArrayAccessインタフェース、Traversableインタフェースを実装した配列風のクラスに関する記事を書きましたが
これらの試行錯誤を経て、汎用クラス DataObject の実装が固まりました。
<?php /** * Create my own framework on top of the Pimple * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme; /** * データオブジェクト * * @author k.holy74@gmail.com */ class DataObject implements \ArrayAccess, \IteratorAggregate { /** * @var array 属性値の配列 */ protected $attributes; /** * コンストラクタ * * @param array 属性の配列 */ public function __construct($attributes = array()) { if (!is_array($attributes) && !($attributes instanceof \Traversable)) { throw new \InvalidArgumentException( sprintf('The attributes is not Array and not Traversable. type:"%s"', (is_object($attributes)) ? get_class($attributes) : gettype($attributes) ) ); } $this->attributes = array(); foreach ($attributes as $name => $value) { $this->attributes[$name] = $value; } } /** * ArrayAccess::offsetGet() * * @param mixed * @return mixed */ public function offsetGet($name) { if (array_key_exists($name, $this->attributes)) { return $this->attributes[$name]; } return null; } /** * ArrayAccess::offsetSet() * * @param mixed * @param mixed */ public function offsetSet($name, $value) { $this->attributes[$name] = $value; } /** * ArrayAccess::offsetExists() * * @param mixed * @return bool */ public function offsetExists($name) { return array_key_exists($name, $this->attributes); } /** * ArrayAccess::offsetUnset() * * @param mixed */ public function offsetUnset($name) { if (array_key_exists($name, $this->attributes)) { $this->attributes[$name] = null; } } /** * magic getter * * @param string 属性名 */ public function __get($name) { return $this->offsetGet($name); } /** * magic setter * * @param string 属性名 * @param mixed 属性値 */ public function __set($name, $value) { $this->offsetSet($name, $value); } /** * magic isset * * @param string 属性名 * @return bool */ public function __isset($name) { return $this->offsetExists($name); } /** * magic unset * * @param string 属性名 */ public function __unset($name) { $this->offsetUnset($name); } /** * magic call method * * @param string * @param array */ public function __call($name, $args) { if (array_key_exists($name, $this->attributes) && $this->attributes[$name] instanceof \Closure) { return call_user_func_array($this->attributes[$name], $args); } throw new \BadMethodCallException( sprintf('Undefined Method "%s" called.', $name) ); } /** * __toString */ public function __toString() { return var_export($this->toArray(), true); } /** * IteratorAggregate::getIterator() * * @return \ArrayIterator */ public function getIterator() { return new \ArrayIterator($this->attributes); } /** * 配列に変換して返します。 * * @return array */ public function toArray() { $values = array(); foreach (array_keys($this->attributes) as $name) { $values[$name] = $this->offsetGet($name); } ksort($values); return $values; } }
フォームオブジェクトの仮実装
DataObjectの利用例として、今回はPHPTALテンプレートの記述を簡潔化できるよう、フォームオブジェクトの存在を想定し、 その実装の代わりとして、以下のようなオブジェクトを返すメソッドをアプリケーションに定義しました。
フォームオブジェクトといっても項目の値やエラーの状態を管理するだけのもので、HTMLの出力などは行いません。
app/app.php より一部抜粋
<?php /** * Create my own framework on top of the Pimple * * Web共通初期処理 * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ $app = include realpath(__DIR__ . '/../app/app.php'); use Acme\Application; use Acme\DataObject; // …中略… //----------------------------------------------------------------------------- // フォームを生成する //----------------------------------------------------------------------------- $app->createForm = $app->protect(function($attributes) use ($app) { $elements = []; foreach ($attributes as $id => $value) { $element = new DataObject(); $element->value = $value; $element->error = null; $element->isError = function() use ($element) { return !is_null($element->error); }; $elements[$id] = $element; } $form = new DataObject($elements); $form->hasError = function() use ($form) { foreach ($form as $element) { if (false === ($element instanceof DataObject)) { continue; } if ($element->isError()) { return true; } } return false; }; $form->getErrors = function() use ($form) { $errors = []; foreach ($form as $element) { if (false === ($element instanceof DataObject)) { continue; } if ($element->isError()) { $errors[] = $element->error; } } return $errors; }; return $form; });
このように定義することで、投稿フォームのテンプレートはこんな感じになりました。(Bootstrap2 → Bootstrap3への変更やフォームのサイズ変更も実施済み)
www/comment.html
<!DOCTYPE html> <html lang="ja"> <head metal:use-macro="__layout.html/head"> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css" /> <script src="/js/jquery-1.9.1.min.js"></script> <script src="/bootstrap/js/bootstrap.min.js"></script> <title>投稿フォーム</title> </head> <body metal:use-macro="__layout.html/body"> <div class="container"> <header class="header"> <h1>投稿フォーム@example.com</h1> </header> <div class="content" metal:fill-slot="content"> <div class="alert alert-danger" tal:condition="form/hasError"> <button class="close" data-dismiss="alert">×</button> <span class="glyphicon glyphicon-warning-sign"></span><strong>入力値にエラーがあります</strong> <ul> <li tal:repeat="error form/getErrors" tal:content="error">名前を入力してください。</li> </ul> </div> <form class="form-horizontal" role="form" method="post" tal:attributes="action server/REQUEST_URI"> <input type="hidden" name="${token/name}" value="${token/value}" tal:condition="exists:token" /> <fieldset> <legend><span class="glyphicon glyphicon -comment"></span>投稿フォーム</legend> <div class="form-group" tal:attributes="class php:form.author.isError() ? 'form-group has-error' : 'form-group'"> <label class="col-md-2 control-label">名前</label> <div class="col-md-5"> <input type="text" name="author" class="form-control" tal:attributes="value form/author/value" /> </div> <div class="col-md-5" tal:condition="form/author/isError"> <p class="help-block" tal:content="form/author/error">名前を入力してください。</p> </div> </div> <div class="form-group" tal:attributes="class php:form.comment.isError() ? 'form-group has-error' : 'form-group'"> <label class="col-md-2 control-label">コメント</label> <div class="col-md-5"> <textarea name="comment" rows="5" class="form-control" tal:content="form/comment/value">コメント内容....</textarea> </div> <div class="col-md-5" tal:condition="form/comment/isError"> <p class="help-block" tal:content="form/comment/error">コメントを入力してください。</p> </div> </div> <div class="form-group"> <div class="col-md-offset-2 col-md-5"> <input type="submit" value="送信" class="btn btn-primary btn-lg" /> </div> </div> </fieldset> </form> </div> <footer class="footer"> <p>Copyright © 2013 k-holy <k.holy74@gmail.com> Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p> </footer> </div> </body> </html>
エラーの情報をフォームオブジェクトを通じてアクセスするようになったので、たとえばフォーム上部のエラー表示部分はこう変わりました。
変更前
<div class="alert alert-error" tal:condition="php:count(errors) > 0"> <button class="close" data-dismiss="alert">×</button> <span class="glyphicon glyphicon-warning-sign"></span><strong>入力値にエラーがあります</strong> <ul> <li tal:repeat="error errors" tal:content="error">名前を入力してください。</li> </ul> </div>
変更後
<div class="alert alert-danger" tal:condition="form/hasError"> <button class="close" data-dismiss="alert">×</button> <span class="glyphicon glyphicon-warning-sign"></span><strong>入力値にエラーがあります</strong> <ul> <li tal:repeat="error form/getErrors" tal:content="error">名前を入力してください。</li> </ul> </div>
これまでエラー情報を格納していた配列 $errors がなくなった代わりに、フォームオブジェクトのメソッドを呼び出しています。
フォームの項目部分はこのように変わりました。
変更前
<dl class="form-group" tal:attributes="class php:isset(errors['author']) ? 'form-group has-error' : 'form-group'"> <dt class="col-lg-2 control-label">名前</dt> <dd class="col-lg-8"> <input type="text" name="author" class="form-control" tal:attributes="value form/author" /> </dd> </dl>
変更後
<div class="form-group" tal:attributes="class php:form.author.isError() ? 'form-group has-error' : 'form-group'"> <label class="col-md-2 control-label">名前</label> <div class="col-md-5"> <input type="text" name="author" class="form-control" tal:attributes="value form/author/value" /> </div> <div class="col-md-5" tal:condition="form/author/isError"> <p class="help-block" tal:content="form/author/error">名前を入力してください。</p> </div> </div>
フォームオブジェクトの導入によって、値の出力部分のパスは一つ階層が深くなりましたが、
php:
式を使うことなく tal:condition
属性による要素の表示条件、 tal:content
属性による要素の内容出力を定義できました。
また、タグをDLからDIVに変えたのと、エラーメッセージの項目別出力を追加しています。
DataTrait
データベースへのコメント保存機能を実装するにあたり、ドメインデータとデータアクセス層の分離を目標としました。
データベース取得した目的のデータをドメインデータに反映させる処理は必要になりますが、ドメインデータ側では属性値を受け取るのみで、間の処理には一切関与しないようにします。
ドメインデータの基底クラス的な扱いの DataTrait はこんな実装にしました。
src/Acme/Domain/Data/DataTrait.php
<?php /** * Create my own framework on top of the Pimple * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Data; /** * ドメインデータTrait * * @author k.holy74@gmail.com */ trait DataTrait { /** * 属性値を初期化します。 * * @param array 属性値 * @return self */ public function setAttributes($attributes = array()) { foreach ($attributes as $name => $value) { $this->offsetSet($name, $value); } return $this; } /** * ArrayAccess::offsetGet() * * @param mixed * @return mixed */ public function offsetGet($name) { if (method_exists($this, 'get_' . $name)) { return $this->{'get_' . $name}(); } $camelize = $this->camelize($name); if (method_exists($this, 'get' . $camelize)) { return $this->{'get' . $camelize}(); } if (array_key_exists($name, $this->attributes)) { return $this->attributes[$name]; } return null; } /** * ArrayAccess::offsetSet() * * @param mixed * @param mixed */ public function offsetSet($name, $value) { if (method_exists($this, 'set_' . $name)) { return $this->{'set_' . $name}($value); } $camelize = $this->camelize($name); if (method_exists($this, 'set' . $camelize)) { return $this->{'set' . $camelize}($value); } if (array_key_exists($name, $this->attributes)) { $this->attributes[$name] = $value; } } /** * ArrayAccess::offsetExists() * * @param mixed * @return bool */ public function offsetExists($name) { return array_key_exists($name, $this->attributes); } /** * ArrayAccess::offsetUnset() * * @param mixed */ public function offsetUnset($name) { if (array_key_exists($name, $this->attributes)) { $this->attributes[$name] = null; } } /** * magic getter * * @param string 属性名 */ public function __get($name) { return $this->offsetGet($name); } /** * magic setter * * @param string 属性名 * @param mixed 属性値 */ public function __set($name, $value) { $this->offsetSet($name, $value); } /** * magic isset * * @param string 属性名 * @return bool */ public function __isset($name) { return $this->offsetExists($name); } /** * magic unset * * @param string 属性名 */ public function __unset($name) { $this->offsetUnset($name); } /** * __toString */ public function __toString() { return var_export($this->toArray(), true); } /** * IteratorAggregate::getIterator() * * @return \ArrayIterator */ public function getIterator() { return new \ArrayIterator($this->attributes); } /** * 配列に変換して返します。 * * @return array */ public function toArray() { $values = array(); foreach (array_keys($this->attributes) as $name) { $values[$name] = $this->offsetGet($name); } return $values; } /** * @param string $string * @return string */ private function camelize($string) { return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); } }
getIterator() と toArray() の実装内容についてはまだ悩んでいるところですが、前者が属性値の ArrayIterator をそのまま返す、後者が全ての属性に対して offsetGet() で取得した値を配列で返すという違いがあります。
camelize() については別クラスに実装すべき内容かもしれませんが、この程度の処理のためだけに依存を作ってしまうのもどうかと思い、Traitに実装しました。(適切な名前空間やクラス名を考えるのも面倒ですし…)
なお PHP 5.3 では Trait が使えないため Abstract クラスとして実装することになりますが、その場合はアクセス修飾子を private ではなく protected に変更する必要があります。(やらかしました)
ドメインデータ
上記 DataTrait を利用して、ArrayAccess および IteratorAggregate を実装したドメインデータクラス、Commentクラスです。
src/Acme/Domain/Data/Comment.php
<?php /** * Create my own framework on top of the Pimple * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ namespace Acme\Domain\Data; use Acme\DateTime; /** * コメント * * @author k.holy74@gmail.com */ class Comment implements \ArrayAccess, \IteratorAggregate { use DataTrait; private $datetimeFormat; private $timezone; private $attributes = []; public function __construct($attributes = array(), $options = array()) { $this->initialize($attributes, $options); } /** * プロパティを初期化します。 * * @param array プロパティ * @return self */ public function initialize($attributes = array(), $options = array()) { if (!isset($options['timezone'])) { throw new \InvalidArgumentException('Required option "timezone" is not appointed.'); } $this->setTimezone($options['timezone']); $this->datetimeFormat = isset($options['datetimeFormat']) ? $options['datetimeFormat'] : 'Y-m-d H:i:s'; $this->attributes = [ 'author' => null, 'comment' => null, 'posted_at' => null, ]; $this->setAttributes($attributes); return $this; } /** * DateTimeZoneオブジェクトをセットします。 * * @param \DateTimeZone タイムゾーン */ public function setTimezone(\DateTimeZone $timezone) { $this->timezone = $timezone; } /** * setter for posted_at * * @param mixed */ public function set_posted_at($datetime) { if (false === ($datetime instanceof DateTime)) { $datetime = new DateTime($datetime, $this->datetimeFormat); } $datetime->setTimezone($this->timezone); $this->attributes['posted_at'] = $datetime->getTimestamp(); // 実体はUnixTimestampで保持 } /** * getter for posted_at * * @return \Acme\DateTime */ public function get_posted_at() { if (isset($this->attributes['posted_at'])) { $datetime = new DateTime($this->attributes['posted_at'], $this->datetimeFormat); // UnixTimestampで保持している値をDateTimeクラスで変換して出力 $datetime->setTimezone($this->timezone); return $datetime; } return null; } }
プロパティ posted_at への値セット時に DataTrait::offsetSet() 経由で呼ばれるのが set_posted_at() メソッドですが、日付文字列とタイムスタンプのどちらも受け付けるようにするために、Acme\DateTime および DateTimezone クラスに依存しています。
結果的に PSR-1 に違反するメソッド名が定義されてますが、これを気にすると実質的にプロパティ名にもアンダースコアを利用できなくなってしまうので、あえて無視しました。
また、「ドメインデータとデータアクセス層を分離」といいつつ、DateTimeの値を内部でint値に変換して保持している理由が、データの保存先が日付型をサポートしていない SQLite だからというのも微妙ですが、こういう使い方も想定できるという例示ということで。(苦しい)
また、このドメインデータを簡単に生成できるよう、アプリケーションオブジェクトに以下を定義しました。
app/app.php より一部抜粋
<?php /** * Create my own framework on top of the Pimple * * Web共通初期処理 * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ $app = include realpath(__DIR__ . '/../app/app.php'); // …中略… //----------------------------------------------------------------------------- // ドメインデータファクトリ //----------------------------------------------------------------------------- $app->createData = $app->protect(function($name, $attributes = array(), $options = array()) use ($app) { $class = '\\Acme\\Domain\\Data\\' . ucfirst($name); if (!class_exists($class, true)) { throw new \InvalidArgumentException( sprintf('The Domain Data "%s" is not found.', $name) ); } switch ($name) { case 'comment': if (!isset($attributes['posted_at'])) { $attributes['posted_at'] = $app->clock; } if (!isset($options['timezone'])) { $options['timezone'] = $app->timezone; } break; } return new $class($attributes, $options); });
switch文を使ってるというだけで怒られそうな昨今ですが、自分としては設定ファイルや謎規約よりもコードで示す方が好きなので…。
アプリケーションオブジェクトの利用側コードから、依存オブジェクトの生成を省略できるだけでも充分だと考えました。
データベース抽象化レイヤ
データベース抽象化レイヤとして PDO をベースに作成したのが volcanus-database ですが、 単にPDOをラッピングするのではなく、データベースとの接続とSQLの実行やメタデータを扱うドライバクラス、実行したSQLの結果を保持するイテレータとなるステートメントクラス、 トランザクション制御を行うトランザクションクラスに分けてインタフェースを定義しました。
データベースドライバとトランザクションの生成は、例のごとくアプリケーションオブジェクト経由で行います。
app/app.php より一部抜粋
<?php /** * Create my own framework on top of the Pimple * * Web共通初期処理 * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ $app = include realpath(__DIR__ . '/../app/app.php'); // …中略… use Volcanus\Database\Driver\Pdo\PdoDriver; use Volcanus\Database\Driver\Pdo\PdoTransaction; use Volcanus\Database\MetaDataProcessor\SqliteMetaDataProcessor; // …中略… //----------------------------------------------------------------------------- // PDO //----------------------------------------------------------------------------- $app->pdo = $app->share(function(Application $app) { try { $pdo = new \PDO($app->config->database->dsn); $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); } catch (\PDOException $e) { throw new \RuntimeException( sprintf('Invalid DSN: "%s"', $app->config->database->dsn) ); } return $pdo; }); //----------------------------------------------------------------------------- // データベースドライバ //----------------------------------------------------------------------------- $app->db = $app->share(function(Application $app) { return new PdoDriver($app->pdo, new SqliteMetaDataProcessor()); }); //----------------------------------------------------------------------------- // データベーストランザクション //----------------------------------------------------------------------------- $app->transaction = $app->share(function(Application $app) { return new PdoTransaction($app->pdo); });
メタデータの取得方法についてはDBMS依存となるため、PdoDriver のコンストラクタに MetaDataProcessorInterface を実装したオブジェクトを渡す仕様としています。
今回は SQLite を使いますので、SqliteMetaDataProcessor を生成しています。
なお、複数データベースへの対応については今のところ必要ないため考慮していません。
コメント投稿フォームの実装
これらの実装によって、コメント投稿フォームのページスクリプトはこのようになりました。
www/comment.php
<?php /** * Create my own framework on top of the Pimple * * 投稿フォーム * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ $app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php'; $app->on('GET|POST', function($app, $method) { $form = $app->createForm([ 'author' => $app->findVar('P', 'author'), 'comment' => $app->findVar('P', 'comment'), ]); if ($method === 'POST') { // CSRFトークンの検証 if (!$app->csrfVerify('P')) { $app->abort(403, 'リクエストは無効です。'); } // 投稿フォーム処理 if (strlen($form->author->value) === 0) { $form->author->error = '名前を入力してください。'; } elseif (mb_strlen($form->author->value) > 20) { $form->author->error = '名前は20文字以内で入力してください。'; } if (strlen($form->comment->value) === 0) { $form->comment->error = 'コメントを入力してください。'; } elseif (mb_strlen($form->comment->value) > 50) { $form->comment->error = 'コメントは50文字以内で入力してください。'; } if (!$form->hasError()) { $comment = $app->createData('comment', [ 'author' => $form->author->value, 'comment' => $form->comment->value, ]); $statement = $app->db->prepare(<<<'SQL' INSERT INTO comments ( author ,comment ,posted_at ) VALUES ( :author ,:comment ,:posted_at ) SQL ); $app->transaction->begin(); try { $statement->execute($comment); $app->transaction->commit(); } catch (\Exception $e) { $app->transaction->rollback(); throw $e; } $cols = []; foreach ($comment as $name => $value) { $cols[] = sprintf('%s = %s', $name, $value); } $app->flash->addSuccess(sprintf('投稿を受け付けました (%s)', implode(', ', $cols))); return $app->redirect('/'); } } return $app->render('comment.html', [ 'title' => '投稿フォーム', 'form' => $form, ]); }); $app->run();
こんな単純な内容なら別にSQL直書きでもいいんじゃね、という気がしてきます。
ただ、これが色々な場所からコメントが投稿されたり編集や削除されることになると、別途ビジネスロジックを扱うクラスを設ける必要があると思います。(まあ普通のアプリケーションはそうですよね)
コメント一覧の実装
コメント一覧も兼ねたトップページのスクリプトはこんな感じ。
www/index.php
<?php /** * Create my own framework on top of the Pimple * * トップページ * * @copyright 2013 k-holy <k.holy74@gmail.com> * @license The MIT License (MIT) */ $app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php'; use Volcanus\Database\Statement; $app->on('GET', function($app) { $statement = $app->db->prepare("SELECT author, comment, posted_at FROM comments LIMIT :limit OFFSET :offset"); $statement->execute(['limit' => 20, 'offset' => 0]); $statement->setFetchMode(Statement::FETCH_FUNC, function($author, $comment, $posted_at) use ($app) { return $app->createData('comment', [ 'author' => $author, 'comment' => $comment, 'posted_at' => $posted_at, ]); }); return $app->render('index.html', [ 'title' => 'トップページ', 'comments' => $statement, ]); }); $app->run();
これを表示するテンプレートはこうなりました。
www/comment.html
<!DOCTYPE html> <html lang="ja"> <head metal:use-macro="__layout.html/head"> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" /> <title>トップページ</title> </head> <body metal:use-macro="__layout.html/body"> <div class="container"> <header class="header"> <h1>トップページ@example.com</h1> </header> <div class="content" metal:fill-slot="content"> <h2>コメント一覧</h2> <table class="table table-condensed" tal:condition="exists:comments"> <thead> <tr> <th>名前</th> <th>コメント</th> <th>投稿日</th> </tr> </thead> <tbody> <tr tal:repeat="item comments"> <td tal:content="item/author"></td> <td tal:content="item/comment"></td> <td tal:content="item/posted_at"></td> </tr> </tbody> </table> </div> <footer class="footer"> <p>Copyright © 2013 k-holy <k.holy74@gmail.com> Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p> </footer> </div> </body> </html>
この部分でステートメントオブジェクトのイテレータが前述の Comment クラスのインスタンスを返しているわけですが…。
<tr tal:repeat="item comments"> <td tal:content="item/author"></td> <td tal:content="item/comment"></td> <td tal:content="item/posted_at"></td> </tr>
item/posted_at
で DataTrait経由で offsetGet() が呼ばれ、Comment::get_posted_at() が呼ばれます。
Comment::get_posted_at() は Acme\DateTime インスタンスを返して、さらに Acme\DateTime::__toString() が呼ばれた結果、 Commentクラスのデフォルトの dateTimeFormat 設定 'Y-m-d H:i:s' で書式化された文字列として出力されます。
要件にマッチした時の PHPTAL の威力がお分かりいただけるでしょうか。
最後にスクリーンショットを。
入力値エラー メッセージがタブってますが、サンプルなので…。
投稿完了 こんな感じで、タイムスタンプが日付文字列に変換されます。
ここまで長かったですが、ようやく準備が整ってきた感じです。