🐡

PHPとGoを組み合わせたまったく新しいフレームワーク Spiral を試す

2021/07/01に公開

皆さんこんにちは。

異種の技術や技を組み合わせることで、新しくて強力なものに昇華させるというのは古来からあるものです。
空手道とブーメランを組み合わせたやつを思い浮かべていただければこれはもう自明の理です。

そんな一定年齢層の人にしか刺さらないような前置きをしつつ、PHPとGoを組み合わせたというフレームワークSpiralを、awesomeで見かけちゃったので、試してみますね。

Spiral

特徴

とりあえず、公式サイトから丸パクリしてきた特徴を見てみましょう。適当翻訳を下に置いてます。

  • High-performance HTTP, HTTP/2 server based on RoadRunner
    • RoadRunnerをもとに作られた高速HTTP, HTTP/2 サーバ
  • Console commands via Symfony/Console
    • Symfony/Consoleを使ったコンソールコマンド
  • Queue support for AMQP, Beanstalk, Amazon SQS, in-Memory
    • キューについてもAMQP, Beanstalk, Amazon SQS, in-Memoryに対応
  • Stempler template engine
    • テンプレートエンジンStemplerを採用
  • Translation support by Symfony/Translation
    • Symfony Translationを使った多言語対応
  • Security, validation, filter models
    • セキュリティ、バリデーション、フィルター機能
  • PSR-7 HTTP pipeline, session, encrypted cookies
    • PSR-7 のHTTPパイプライン、セッション、暗号化クッキー
  • DBAL and migrations support
    • DBALとマイグレーションのサポート
  • Monolog, Dotenv
  • Prometheus metrics
    • Prometheus(監視機構?)向けのメトリクス
  • Cycle DataMapper ORM

なんだか盛りだくさんです。
一番上のRoadRunnerとやらに、興味をそそられますね。

はじめる

さっそく始めてみましょう。
作業環境ですが、いつも使ってる感じのやつでいいでしょう。

Dockerfile

FROM php:8

RUN apt-get update && apt-get install -y git unzip libonig-dev libzip-dev && \
docker-php-ext-install mbstring pdo pdo_mysql zip && \
pecl install xdebug && docker-php-ext-enable xdebug

COPY --from=composer /usr/bin/composer /usr/bin/composer

docker-compose.yml

version: "3"

services:
    workspace:
        build: workspace
        command: sleep infinity
        volumes:
            - ../:/var/www/
        ports: 
            - 8080:8080

    db:
        image: mysql
        environment:
            - MYSQL_ROOT_PASSWORD=secret
            - MYSQL_USER=niisan
            - MYSQL_DATABASE=niisan
            - MYSQL_PASSWORD=secret

インストールはcomposerでいいらしい。

composer create-project spiral/app

なんかいろいろ実行してます。

ログにspiral-2.7.0-linux-amd64.tar.gzと出てくるので、ここにRoadRunnerが入っていると思われます。

マニュアルだとphp ./app.php configureこれを打って!って言ってるけど、create-projectの時点で実行していました。

で、./spiral serve -v -dでサーバを立ち上げることができて、http://localhost:8080 でアクセス可能です。

spiral初期画面

とりあえず、データベースの設定とかはまだ何もしていないけど、トップページ開くくらいならできるようです。

リクエスト

なかなか面白い機構のリクエストライフサイクルなので、引用しておきます。

https://spiral.dev/docs/http-lifecycle
spiralのライフサイクル

まず、リクエストをRoadRunnerが受け付け、Goで書かれたミドルウェアを通って、いったんプールされます。
プールされたものを順にPHPプロセスに食わせ、そこでPHPのミドルウェアを通って、コントローラにたどり着く、といった感じでしょうか。

アクセス流量とか制限とかをGoのミドルウェアで書いて、ドメイン側のロジックをPHPミドルウェアで書くって感じですかね。

ミニマムスタート

とりあえず、動くもの作ってみて、操作感を確認してみましょう。

課題設定

TODOを作って一覧表示するクソアプリを作ります。

データベースの設定

デフォルトではSQLite使ってます。いったんSQLiteで動かして、あとでMySQLで動くかも確認します。

とりあえず、データベースを作ります。

# php app.php migrate:init
Migrations table were successfully created

そうすると、マイグレーション用のテーブルが作られる

# php app.php db:list
+------------+---------------------+---------+---------+-----------+------------+----------------+
| Name (ID): | Database:           | Driver: | Prefix: | Status:   | Tables:    | Count Records: |
+------------+---------------------+---------+---------+-----------+------------+----------------+
| default    | /var/www/app/app.db | SQLite  | ---     | connected | migrations | 0              |
+------------+---------------------+---------+---------+-----------+------------+----------------+

次にアプリで使うデータベースを作りますが、Cycle ORM を使ってデータのエンティティを定義すると、それに従ってマイグレーション作ってくれるようなので、TODOのエンティティを先に作っちゃいます。

エンティティの作成

TODOアプリということで、適当なエンティティを作りましょう。

php app.php create:entity task -f id:primary -f title:string -f content:text -e

これをやると、以下の二つのファイルが作成されます。

app/src/Database/Task.php

<?php
/**
 * {project-name}
 * 
 * @author {author-name}
 */
declare(strict_types=1);

namespace App\Database;

use Cycle\Annotated\Annotation as Cycle;

/**
 * @Cycle\Entity(repository="\App\Repository\TaskRepository")
 */
class Task
{
    /**
     * @Cycle\Column(type = "primary")
     */
    public $id;

    /**
     * @Cycle\Column(type = "string")
     */
    public $title;

    /**
     * @Cycle\Column(type = "text")
     */
    public $content;
}

アノテーションでデータ型を指定している模様です。

リポジトリも作られてます。

app/src/Repository/TaskRepository.php

<?php
/**
 * {project-name}
 * 
 * @author {author-name}
 */
declare(strict_types=1);

namespace App\Repository;

use Cycle\ORM\Select\Repository;

class TaskRepository extends Repository
{
}

ここで作られたモデルをもとにmigrationを作れます。

# php app.php cycle:migrate -v
Detecting schema changes:
• default.tasks
    - create table
    - add column id
    - add column title
    - add column content

マイグレーションファイルはこんな感じ

app/migrations/20210xxx.132725_0_0_default_create_tasks.php

<?php

namespace Migration;

use Spiral\Migrations\Migration;

class OrmDefault7a08c2026c62bd05b5f1f6f49b61c6df extends Migration
{
    protected const DATABASE = 'default';

    public function up(): void
    {
        $this->table('tasks')
            ->addColumn('id', 'primary', [
                'nullable' => false,
                'default'  => null
            ])
            ->addColumn('title', 'string', [
                'nullable' => false,
                'default'  => null
            ])
            ->addColumn('content', 'text', [
                'nullable' => false,
                'default'  => null
            ])
            ->setPrimaryKeys(["id"])
            ->create();
    }

    public function down(): void
    {
        $this->table('tasks')->drop();
    }
}

あとはマイグレーションを流して準備完了です。

php app.php migrate -vv

これで準備完了です。

コントローラ作ってみる

コントローラを作ってみましょう。

php app.php create:controller task -a index

これでTaskControllerができます。

app/src/Controller/TaskController.php

<?php
/**
 * {project-name}
 * 
 * @author {author-name}
 */
declare(strict_types=1);

namespace App\Controller;

class TaskController
{
    public function index()
    {
    }
}

適当なTODOをいくつか作るコマンドを作ります。

php app.php create:command seed/task seed:task

こんなものができます。

app/src/Command/Seed/TaskCommand.php

<?php
/**
 * {project-name}
 * 
 * @author {author-name}
 */
declare(strict_types=1);

namespace App\Command\Seed;

use Spiral\Console\Command;

class TaskCommand extends Command
{
    protected const NAME = 'seed:task';

    protected const DESCRIPTION = '';

    protected const ARGUMENTS = [];

    protected const OPTIONS = [];

    /**
     * Perform command
     */
    protected function perform(): void
    {
    }
}

performメソッドにロジックを入れて動かします。

    protected function perform(TransactionInterface $tr): void
    {
        for ($num = 0; $num < 5; $num++) {
            $task = new Task;
            $task->title = mt_rand(100, 1000);
            $task->content = mt_rand(10000, 99999);
            $tr->persist($task);
        }
        $tr->run();
    }

超適当です。作ったTaskオブジェクトをトランザクションを通して永続化することで、データが作られます。

php app.php seed/task

これでデータが作られます。

あらためてサーバを立ち上げます。

./spiral serve -v -d

今作ったコントローラにはhttp://localhost:8080/taskでアクセスできます。

Todo Lists
842: 38801
163: 23742
189: 87655
700: 26324
829: 99549

こんな感じのが出ます。

POSTを作る

最後にデータを入れてみましょう。

app/src/Controller/TaskController.php

    public function create()
    {
        return $this->views->render('tasks/create.dark.php');
    }
<extends:layout.base title="[[Create TODO]]"/>

<stack:push name="styles">
    <link rel="stylesheet" href="/styles/welcome.css"/>
</stack:push>

<define:body>
    <div class="wrapper">
        <div class="placeholder">
            <h2>[[Todo Create]]</h2>
            <form action="/task/store" method="POST">
                <p><label>タイトル</label><input type="text" name="title"/></p>
                <p><label>内容</label><input  type="text" name="content"/></p>
                <button type="submit">作成</button>
            </form>
            
        </div>
    </div>
</define:body>

こんな感じで適当なフォームを作ります。

入力データを簡単にバリデーション通すようにします。

php app.php create:filter task -e "App\Database\Task"

なぜかマニュアルと名前が違うが。。。
適当に編集してこんな感じに。

app/src/Request/TaskRequest.php

<?php
/**
 * {project-name}
 * 
 * @author {author-name}
 */
declare(strict_types=1);

namespace App\Request;

use Spiral\Filters\Filter;

class TaskRequest extends Filter
{
    protected const SCHEMA = [
        'title'   => 'data:title',
        'content' => 'data:content'
    ];

    protected const VALIDATES = [
        'title'   => [
            'notEmpty',
            'string'
        ],
        'content' => [
            'notEmpty',
            'string'
        ]
    ];

    protected const SETTERS = [];

    public function __get($name) {
        $data = parent::__get($name);
        if ($data) {
            return $data;
        }

        if (isset(self::SCHEMA[$name])) {
            return $this->$name;
        }
    }
}

最後にコントローラにstoreアクションを定義します。

app/src/Controller/TaskController.php

    public function store(TaskRequest $request)
    {
        $task = new Task;
        $task->title = $request->title;
        $task->content = $request->content;
        $this->tr->persist($task);
        $this->tr->run();

        return $this->response->redirect('/task');
    }

これで、'localhost:8080/task/create'でデータを作成し、リストに追加することができるようになりました。

テスト

フレームワークを選ぶ基準の一つに、テストのしやすさを私としては要求しておきたいところです。

しかしながら、テストに関する記述は見当たりません。
ありませんが、一応用意はしてあるようです。
なお、SQLiteを使っていると、なぜかデッドロックが発生するのでMySQL使います。

database.php

   'drivers'   => [
        'sqlite' => [
            'driver'     => \Spiral\Database\Driver\SQLite\SQLiteDriver::class,
            'connection' => 'sqlite:' . directory('root') . 'test.db',
            'profiling'  => true,
        ],
        'mysql' => [
            'driver'     => \Spiral\Database\Driver\MySQL\MySQLDriver::class,
            'options'    => [
                'connection' => 'mysql:host=db;dbname=' . env('DB_NAME'),
                'username'   => env('DB_USERNAME'),
                'password'   => env('DB_PASSWORD'),
            ]
        ]

さらっとenvを使っているのだけど、マニュアルを見回しても解説が見当たらないのです。

テストを書きます。
tests/Feature/TaskTest.php

<?php

declare(strict_types=1);

use App\Database\Task;
use Cycle\ORM\ORM;
use Cycle\ORM\TransactionInterface;
use Spiral\Console\Console;
use Tests\TestCase;

class TaskTest extends TestCase
{

    public function testIndex()
    {
        $tr = $this->app->get(TransactionInterface::class);
        $task = new Task;
        $task->title = mt_rand(1000, 10000);
        $task->content = mt_rand(10000, 100000);
        $tr->persist($task);
        $tr->run();

        $got = (string)$this->get('/task')->getBody();

        $this->assertStringContainsString((string)$task->title, $got);
        $this->assertStringContainsString((string)$task->content, $got);
    }

    public function testStoreTask()
    {
        $title = mt_rand(1000, 10000);
        $content = mt_rand(10000, 100000);

        $this->post('/task/store', ['title' => $title, 'content' => $content]);

        $orm = $this->app->get(ORM::class);
        $task = $orm->getRepository(Task::class)
            ->select()
            ->where('title', $title)
            ->where('content', $content)
            ->fetchOne();

        $this->assertEquals($title, $task->title);
        $this->assertEquals($content, $task->content);
    }
}

で、実行します。

# ./vendor/bin/phpunit tests/Feature/TaskTest.php 
PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

..                                                                  2 / 2 (100%)

Time: 00:10.632, Memory: 22.00 MB

OK (2 tests, 4 assertions)

まあ、動くように作ったんでそりゃ動きますよって感じですね。

まとめ

よかった点

Cycle ORM

Cycle ORMはエンティティとリポジトリを分けて定義しているので、クラス上にカラム定義とクエリが同居してカオス化する懸念はなくなるかなっていうイメージでした。
また、エンティティからデータベースのマイグレーションを差分作成できるところはなかなか良かったと感じます。

RoadRunner

nginx + fpm のようにコンテナで考えたときに複数のサーバを経由しなくても、RoadRunner起動させるだけでオッケーなのはいいですね。
Laravelでもoctance突っ込めば使えるということで、独立させて使いたい気持ちもありますね。

微妙な点

テストの書きやすさ

テスト環境はあまりそろっているとは言えないので、こちらで環境を整える必要があったりします。この辺はLaravelやっててぬるま湯につかってしまった結果なのかなと思ったり。
特にデータベース周りの世話とかenv, configのテスト用環境の適用とかは自分でやり方を作るしかないので、開発前に自分なりのカスタマイズを確立しておかないとつらいですね。

マニュアル

マニュアルは、PHPのフレームワークであるということもあって、とても充実しているのですが、どこ見たら自分の欲しい情報がわかるのかがわかりにくかったりしました。
上から順番に眺めて行っても、一部は外部に飛んだりしているので、サイト内で完全に完結しているわけではないのもちょい引っかかる点。

面白かった点

プロトタイプ

PrototypeTraituseすると、明示的にDIしていなくともあらかじめ登録された名前のプロパティで依存先にアクセスできるという感じのやつです。
合わせてクラスのアノテーションでプロパティ名をつけて依存先登録する方法もあります。

感想

フレームワークなら、もう少しテストについて書いてもらってもいいんじゃないかなって思いますな。まあ、見りゃわかんだろってことなのかもしれんので、致命的とまでは言いませんが。
というより、Laravelのテスト周りが妙に手厚すぎるのかもしれませんな。

今回はそんなところです。

Discussion