以前、usePHPというライブラリを作っているという記事を書いた。
その後、仕事が忙しくてなかなか開発を進められなかったが、やっと最近時間ができてusePHPの開発を進めることができた。
usePHPはReactのようなUIに関するライブラリであって、それ単体だけで使うことはちょっと難しい。
一応Router機能があるので、自分でDIやDBまわりのライブラリを入れれば実践投入することは可能。
しかしやっぱり敷居は高い。
そこで、敷居を下げるために Relayer というusePHPを使ったWebフレームワークを作った。
Relayerとはなにか?
一言で言うと「usePHP の生の表現力に、Next.js Page Router風のルーティングとSymfony DIを足して、boot() 一行で全部繋がるようにしたもの」らしい。
いや、そのとおりだと思う。
usePHPだけだとデータベースのフェッチだったり、フォームのバリデーションだったり足りないところがいくつかある。
そこを補うためのフレームワークである。
PHPにはあまり馴染みのないコンポーネントベースのフレームワークで、対象としてはメディアサイトや、そんなにリッチなインタラクションがないWebシステムに最適だと思う。
名前の由来
Webアプリケーションがフロントエンドとバックエンドに分離してレイヤーが分かれてしまった昨今に、もう一度レイヤーを再定義したいという意味を込めた「Re Layer」と、YESのRelayerのアルバムを聴きながら開発していたという2つの意味からRelayerという名前にした。
特徴的な機能について
フレームワークとしてWebサイトやWebシステムを開発しやすいように様々な機能を入れているが、その中でも特徴的な機能を紹介する。
psx — JSXライクなテンプレート
psxとは、usePHPの機能なんだけど、jsx(tsx)ライクなテンプレートシンタックスを持っている。
Reactのようにクラスベース or 関数ベースのコンポーネントを定義することができる。
Next.jsのPageRouterっぽい感じのルーティング機能がある。
exampleを見てもらえればわかるけど、ディレクトリにpage.psxを置くタイプのルーティング
HTTP Cache
psxにはHTTP Cacheの設定もすることができる。
1
2
3
4
5
6
7
8
9
10
11
|
#[Cache(
maxAge: 3600,
sMaxAge: 86400,
public: true,
vary: ['Accept-Language'],
etag: 'home-v1',
)]
final class HomePage extends PageComponent
{
public function render(): Element { /* ... */ }
}
|
関数ベースだと以下のようになる
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
<?php
// src/Pages/feed/page.psx
declare(strict_types=1);
use Polidog\Relayer\Http\Cache;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\UsePhp\Runtime\Element;
return function (PageContext $ctx): Closure {
// Lightweight setup: declare cache, read params. NO DB queries here.
$ctx->cache(new Cache(maxAge: 60, public: true, etagKey: 'feed'));
return function () use ($ctx): Element {
// Heavy work goes here — only runs on cache miss.
// ... query DB, build the page
};
};
|
基本的にサーバサイドレンダリングのため、ここでHTTP Cacheの設定も可能になっている。
さらにマニアックなことに、ETagのいい感じのキャッシュ機構も用意している。詳しくはREADMEのDynamic ETag via EtagStoreを読んでいただきたい。
Deferレンダリング
これはどちらかというとusePHPの機能なんだけど紹介しておきたい。
RelayerやusePHPはサーバサイドでレンダリングされてしまうということで、CDNにキャッシュさせづらい状況が多々起こる。
よくある例としては、ログイン前、ログイン後にヘッダーの内容が変わるなど。
ここのためにJSで処理するのが一般的だとは思うが、Relayer/usePHPでは追加でJavaScriptを用意することなく解決できる。
まずは以下のようにコンポーネントを定義する
1
2
3
|
{/* UserHeader.psx — the actual content, reused inline elsewhere if needed */}
return fn(array $props) => <header>Hello {$_SESSION['user']['name'] ?? 'guest'}</header>;
|
その後ヘッダーコンポーネントをラップしたdeferコンポーネントを定義する
1
2
3
4
5
6
7
8
|
{/* UserHeaderDeferred.psx — the wrapper */}
use Polidog\UsePhp\Component\Defer;
use function Polidog\UsePhp\Runtime\fc;
return fc(
fn(array $props) => <UserHeader />,
defer: new Defer(name: 'user-header', cacheControl: 'private, no-store'),
);
|
こうすることによって、初期レンダリング時にこのコンポーネントはレンダリングされなくて、初期レンダリング後にクライアント側からレンダリングするようになる。
これで、CDNへのキャッシュを効率的に行うことができる。
詳しくはusePHPのDeferred rendering (CDN-friendly partial hydration)を見てもらいたい。
Next.jsのServerActionが好きなので同じような機能を用意した。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
<?php
// src/Pages/users/page.psx
declare(strict_types=1);
use App\Service\UserRepository;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\UsePhp\Runtime\Element;
return function (PageContext $ctx, UserRepository $users): Closure {
$save = $ctx->action('save', function (array $form) use ($users): void {
$users->create($form['name']);
\header('Location: /users', true, 303);
exit;
});
return function () use ($save, $users): Element {
return (
<main>
<ul>{...\array_map(fn($u) => <li>{$u->name}</li>, $users->all())}</ul>
<form action={$save}>
<input name="name" />
<button>save</button>
</form>
</main>
);
};
};
|
こんな形でFormの処理をすることが可能。
ちなみにクラスベースのコンポーネントでは以下のように実装する。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public function render(): Element
{
return (
<form method="post">
<input type="hidden" name="_usephp_action" value={$this->action([$this, 'save'])} />
<input name="title" />
</form>
);
}
public function save(array $form): void
{
// ... handle $form['title']
header('Location: /dashboard', true, 303); // PRG
exit;
}
|
Zodライクなバリデーション
バリデーションの機能をどうしようか悩んだけど、やはりZodライクなものが欲しいと思い作った。
Form Actionsと連携することで簡単にバリデーションすることが可能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
$schema = Validator::object([
'name' => Validator::string()->trim()->min(1, 'Name is required.'),
'email' => Validator::string()->trim()->email(),
'password' => Validator::string()->min(8, 'Password must be at least 8 characters.'),
]);
$signup = $ctx->action('signup', function (array $form) use ($schema, &$errors): void {
$result = $schema->safeParse($form);
if (!$result->success) {
$errors = $result->errors; // hand field errors to the view
return;
}
// $result->data is coerced
});
|
最後に
まだまだ安定版ではないけど、安定版としてリリースできるように開発を進めていきたい。