ピクシブ百科事典にAttributeを用いたルーティング機能を実装しました

2021-09-21

はじめに

8/31~9/16 の間で,PIXIV SUMMER BOOTCAMP2021 の技術基盤コースに参加してきましたので,その参加経緯からインターンで行った内容についてブログに書いていきます.

来年,このインターンに応募したいと思っている方たちが,このブログを読み,少しでもインターンの参考になればいいなと思っています.

参加までの経緯

昔からピクシブさんの提供しているサービスが好きで,よく使わせていただいたこともあり,以前からインターンに参加したいと思っておりました.

昨年の夏インターンや,今年の春インターンにも応募をしてきたのですが,自身の力不足で参加を見送られ,今年の夏インターンこそはという思いで応募をさせていただきました.

僕が応募した技術基盤コースは,オンライン百科事典「ピクシブ百科事典」の機能開発およびコア機能・Web アプリケーションフレームワークの改善を行うコースです。フレームワークの開発を通じて Web サービスが提供すべき基本機能の理解や開発者が利用しやすい設計について学ぶことが目的でした.

引用:PIXIV SUMMER BOOT CAMP 2021 募集ページ

また,応募の際に実績などを提出することに加え,テーマを与えられ,そのテーマに沿った自分の考えを課題として提出することで評価対象にしていただけるということでしたので,僕は以下のテーマについて,自分の考えをまとめ,提出しました.

  • Q.Web 開発時に不便に感じていて、技術的に解決したい問題があれば教えてください

    • A.ライブラリの依存関係を Composer などで管理することなどについて回答しました.
  • Q.普段利用している Web フレームワークのソースコードを簡単に読んでみて、勉強になった部分と、読んでみたがよく理解できなかった部分と挙げてください

    • A.オブジェクト指向について回答しました.
  • Q.Web フレームワークやライブラリがサポートを提供し、それを適切に利用することでほとんどのユースケースでは防止できると考えられる脆弱性の種類を二つ選んで挙げてください

    • A.Laravel を例にあげ,二種の脆弱性対策について回答しました.
  • Q.2021 年現在でもイベント駆動などによる並行処理を前提とせず、PHP のように単なる同期実行のみで動作するサーバーサイド Web アプリケーションが根強く残っている理由について簡単に考察してください

    • A.これまでの経験をもとに回答しました.

他にも,これまでの実績として php で作成した自作 MVC フレームワークについても実績として提出しました.

イチから php フレームワークを作ってみる

また,インターンに採用されてから参加するまでの事前課題として,PSR-HTTP についての理解を深めてきてほしいということでこちらの資料を提供していただき,読み込んだりしておきました.

PSR-HTTP シリーズを理解するための情報源

実装したルーティング機能について

さて,インターンで行った新機能の開発では,PHP8.0 で導入された Attribute を使用し,「ルーティング情報を走査してルーティングファイルとして出力する」という機能(以下,Attribute ベースルーティング)を課題として出されたので,そちらの実装を行いました.

ルーティングとは

ルーティングについて少し解説します.

ルーティングとは,例えばdic.pixiv.net/a/初音ミクなどのような,URL にアクセスした際に呼び出される処理を記述しているクラスのことで,ピクシブ百科事典では以下のようなルーティングが実装されています.

この例の場合だとArticleControllerが呼ばれ,そのArticleController内のviewメソッドが実行されることによって,ピクシブ百科事典のサイト上で初音ミクの解説ページを読むことが出来ます.

Router::addRoute('a/:articlename', [	// dic.pixiv.net/a/初音ミク
    'controller' => 'article',
    'action' => 'view',
]);

Attribute とは

Attribute とは,先述の通り,PHP8.0 で導入された機能で,クラスなどの宣言時にメソッドなどの追加情報を埋め込むことを可能にする機能です.

埋め込んだ情報は ReflectionAPI という機能を使用して内容を取得したり,変更したりすることも可能です.

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class MyAttribute
{
    // snip
}

// $reflection->getAttributes() などでAttributeの情報を取得できる

アトリビュートの概要

リフレクション API を使ってアトリビュートを読み取る

これは BEAR.Sunday という PHP フレームワークのドキュメントからの引用になりますが,従来の PHP では下記のコードのように,Annotation というものを利用して同じようなことをすることも可能でした.

// Annotation

/**
 * @Inject
 * @Named('admin')
 */
public function setLogger(LoggerInterface $logger)

これが Attribute を用いることでこのような書き方をすることができるようになりました.

// Attribute

#[Inject, Named('admin')]
public function setLogger(LoggerInterface $logger)

BEAR.Sunday アトリビュート

今回はこの Attribute を利用して PHP8.0 の標準機能に寄せるため,この機能を実装させていただくという流れでした.

また,現在のピクシブ百科事典の PHP のバージョンは 8.0 ではないので,Spiral Framework の Attributeを使用し,開発を進めました.

実装する機能の要件

今回実装する Attribute ベースルーティングは以下のような要件に沿って開発を進めていきます.

  • ピクシブ百科事典のルーティングを Attribute で表現すること

  • Attribute は実行時に読み込まれないようにすること

    • また,出力するルーティングファイルは既存のルーティングファイルの構造を参考にします.
  • 合理的な範囲で小さなモジュールとして構成する

    • アプリケーションに依存させずに,それぞれのモジュールごとにユニットテストが可能なものにします.

今回実装したモジュールは,Route クラスと,ファイルを走査してルーティング情報を収拾するクラス,ルーティングファイルを出力するクラスといった感じで,三つのモジュールに展開させて実装を進めました.

Route クラスの実装

新しく Route クラスを実装する前に,既存のルーティングについて少しだけ触れておくと,既存のルーティングであるRouter::addRoute()はいくつかのパラメータを持っています.

// RouteConfigPC.php

Router::addRoute('en', [					// path
    'action' => Dic\Http\Controller\Ja\EnNoSlashAction::class,
    'no_trailing_slash' => true,			// no_trailing_slash
]);

Router::addRoute('sitemap.xml', [
    'action' => Dic\Http\Controller\Sitemap\IndexAction::class,
    'cache' => true,					// cache
]);

上記は従来のルーティングファイルの構成ですが,ここにはpathというパラメータと,cacheno_trailing_slashというオプションで付与するパラメータがあります.

これらのパラメータは,それぞれ以下のような意味をもっており,これと同じようなパラメータを新しく作るRouteクラスにも同様に付与し,実装したものがその下のRouteクラスになります.

$path // URIに対応する文字列
$cache // キャッシュを有効化するかどうか
$no_trailing_slash // 末尾の / を許容するかどうか
declare(strict_types=1);

namespace Dic\Domain;

use Attribute;

/**
 * @property string $path
 * @property bool $cache
 * @property bool $no_trailing_slash
 */

#[Attribute]
#[\Spiral\Attributes\NamedArgumentConstructor]
final class Route
{
    /** @var string */
    private $path;

    /** @var bool */
    private $cache;

    /** @var bool */
    private $no_trailing_slash;

    public function __construct(string $path, bool $cache = false, bool $no_trailing_slash = false)
    {
        $this->path = $path;
        $this->cache = $cache;
        $this->no_trailing_slash = $no_trailing_slash;
    }

    public function __get(string $key)
    {
        return $this->$key;
    }
}

実際に,このRouteクラスに実装した Attribute が使用できるかというテストはこのような形で行うことができます.

declare(strict_types=1);

namespace Dic\Domain;

use Dic\TestCase;
use Spiral\Attributes\AttributeReader;

#[Route(path: 'a/:articlename')]
#[Route(path: 'ae/:article_name', cache: true)]
#[Route(path: 'ae/:article_name', no_trailing_slash: true)]
#[Route(path: 'ae/:article_name', cache: true, no_trailing_slash: true)]
class RouteTest extends TestCase
{
    /** @var AttributeReader */
    private $reader;

    public function setUp(): void
    {
        parent::setUp();

        $this->reader = new AttributeReader();
    }

    public function test_Class(): void
    {
        $ref_class = new \ReflectionClass($this);
        $actual = $this->reader->getClassMetadata($ref_class);

        $count = 0;

        foreach ($actual as $attr) {
            $count++;
            $this->assertInstanceOf(Route::class, $attr);
        }

        $this->assertSame(4, $count);
    }

    public function test_Method(): void
    {
        $ref_class = new \ReflectionClass($this);
        $ref_method = $ref_class->getMethod('subject');
        $actual = $this->reader->getFunctionMetadata($ref_method);

        $count = 0;

        foreach ($actual as $attr) {
            $count++;
            $this->assertInstanceOf(Route::class, $attr);
        }

        $this->assertSame(4, $count);
    }

    #[Route(path: 'a/:articlename')]
    #[Route(path: 'ae/:article_name', cache: true)]
    #[Route(path: 'ae/:article_name', no_trailing_slash: true)]
    #[Route(path: 'ae/:article_name', cache: true, no_trailing_slash: true)]
    public function subject(): void
    {
        // snip
    }
}

RouteTest::test_Class()では,Attribute を付与したクラスに対するテストを行っており,RouteTest::test_Method()では,Attribute を付与したメソッドに対するテストを行っています.

上で実装したRouteクラスは下記のように既存の Controller や Action に付与することで使用することが可能になります.

// example
class ArticleController
{
    /**
     * GET /a/:article_name 記事ページ
     */
    #[Route(path: 'a/:articlename')]
    public function view()
    {
        // snip
    }
}
// example
#[Route(path: 'a/:articlename')]
class SitemapAction
{
    // snip
}

ルーティング情報の走査

Routeクラスの実装は出来たので,次はルーティング情報を走査するクラスを作成します.

ルーティング情報の走査には DirectoryIterator などの機能を使用します.

また,走査したルーティング情報は,ルーティング情報を出力するためのクラスに使用するために配列で返してあげます.

declare(strict_types=1);

namespace Dic\Domain;

use ControllerBase;
use DirectoryIterator;
use Generator;
use Psr\Http\Server\RequestHandlerInterface;
use ReflectionClass;
use Spiral\Attributes\AttributeReader;

class RouteCollector
{
    /** @var list<string> */
    private $target_directories;

    /** @var AttributeReader */
    private $reader;

    /**
     * @param list<string> $target_directories
     */
    public function __construct(array $target_directories)
    {
        $this->reader = new AttributeReader();
        $this->target_directories = $target_directories;
    }

    public function getRoutes(): array
    {
        $result = [];
        foreach ($this->target_directories as $directory_name) {
            $dir = new DirectoryIterator($directory_name);
            foreach ($this->fetchRoute($dir) as $route) {
                $result[] = $route;
            }
        }
        return $result;
    }

    public function fetchRoute(DirectoryIterator $dir)
    {
        if ($dir->isFile()) {
            return;
        }

        foreach ($dir as $item) {
            /** @var DirectoryIterator $item */
            if ($item->isFile()) {
                $classname = $this->getClassWithNamespace($item);
                if ($classname === null) {
                    continue;
                }
                yield from $this->fetchRoutesFromClass($classname);
            } elseif ($item->isDir() && !$item->isDot()) {
                yield from $this->fetchRoute(new DirectoryIterator($item->getPathname()));
            }
        }
    }

    /**
     * @param class-string $classname
     * @return Generator
     */
    public function fetchRoutesFromClass(string $classname): Generator
    {
        $ref = new ReflectionClass($classname);
        if (is_a($classname, RequestHandlerInterface::class, true)) {
            $metadata = $this->reader->getClassMetadata($ref);
            foreach ($metadata as $attr) {
                yield [
                    'action' => $classname,
                    'route' => $attr,
                ];
            }
        } elseif (is_a($classname, ControllerBase::class, true)) {
            foreach ($ref->getMethods() as $ref_method) {
                $method_name = $ref_method->getName();
                $metadata = $this->reader->getFunctionMetadata($ref_method);
                foreach ($metadata as $attr) {
                    yield [
                        'controller' => $classname,
                        'action' => $method_name,
                        'route' => $attr,
                    ];
                }
            }
        }
    }

    /**
     * @return class-string
     */
    public function getClassWithNamespace(\DirectoryIterator $item): ?string
    {
        $namespace = null;
        $file = $item->openFile("r");
        foreach ($file as $line) {
            if (preg_match('/^namespace (?<namespace>.+);$/', $line, $m)) {
                $namespace = $m['namespace'];
                break;
            }
        }
        $classname = $item->getBasename(".php");
        $fqsen = "{$namespace}\\{$classname}";
        if (class_exists($fqsen)) {
            return $fqsen;
        }

        return null;
    }
}

ここで実装したRouteCollectorクラスでは,RouteCollector::getRoutes()という関数が指定したディレクトリ内にある,ルーティング情報が記載されたコントローラなどを走査して配列として返すような処理を行っています.

declare(strict_types=1);

namespace Dic\Domain;

use Dic\TestCase;
use Dic\Domain\RouteCollector\SampleAction;
use Dic\Domain\RouteCollector\SampleController;
use Dic\Domain\RouteCollector\Nested;

class RouteCollectorTest extends TestCase
{
    public function test(): void
    {
        $subject = new RouteCollector([
            __DIR__ . '/RouteCollector',
        ]);

        $expected = [
            ['action' => Nested\SampleAction2::class, 'route' => new Route("/Nested/SampleAction2")],
            ['action' => Nested\SampleAction::class, 'route' => new Route("/Nested/SampleAction")],
            ['controller' => SampleController::class, 'action' => 'index', 'route' => new Route('/sample')],
            ['controller' => SampleController::class, 'action' => 'cached', 'route' => new Route('/sample/cached', true)],
            ['controller' => SampleController::class, 'action' => 'robots', 'route' => new Route('/sample/robots.txt', false, true)],
            ['action' => SampleAction::class, 'route' => new Route("/SampleAction")],
        ];

        $this->assertEquals($expected, $subject->getRoutes());
    }
}
namespace Dic\Domain\RouteCollector\Nested;

use Dic\Domain\Route;
use LogicException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

#[Route("/Nested/SampleAction")]
class SampleAction implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        throw new LogicException("do not call me.");
    }
}
namespace Dic\Domain\RouteCollector;

use Dic\Domain\Route;
use LogicException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

#[Route("/SampleAction")]
class SampleAction implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        throw new LogicException("do not call me.");
    }
}
namespace Dic\Domain\RouteCollector;

use Dic\Domain\Route;

class SampleController extends \ControllerBase
{
    #[Route('/sample')]
    public function index()
    {
        // snip
    }

    #[Route('/sample/cached', cache: true)]
    public function cached()
    {
        // snip
    }

    #[Route('/sample/robots.txt', no_trailing_slash: true)]
    public function robots()
    {
        // snip
    }
}

RouteCollectorTestでは,ネストされたディレクトリ内にあるルーティング情報も取得できるかどうかのテストケースを書いていますが,実際にテストを行っていた際には,RouteCollector::fetchRoutesFromClass()でちょっとした無限ループが起きてしまいました.

その対策としてGeneratoryieldなどを活用したのですが,Generatorや,yieldなどの使い方について詳しくなかったので,これらに関して結構な頻度で戸惑っていました.

ルーティング情報の出力

先述の通り,配列として返ってきたルーティング情報は既存の形式に合わせてファイルに出力しなければなりません.

declare(strict_types=1);

namespace Dic\Domain;

use function Safe\fwrite;

class RoutingFileGenerator
{
    /** @var array<array{controller?:class-string, action:string|class-string, route:Route}> */
    private $routes;

    /**
     * @param array<array{controller?:class-string, action:string|class-string, route:Route}> $routes
     */
    public function __construct(array $routes)
    {
        $this->routes = $routes;
    }

    /**
     * @param resource $file
     */
    public function write($file): void
    {
        $code = "<?php return " . preg_replace('/\s+$/mu', '', var_export($this->build(), true)) . ";";
        fwrite($file, $code);
    }

    /**
     * @return array<array{0:string, array{controller?:class-string, action:string|class-string, cache?:bool, no_trailing_slash?:bool}}>
     */
    public function build(): array
    {
        $result = [];

        foreach ($this->routes as $route) {
            $attr = $route['route'];
            assert($attr instanceof Route);

            $config = [
                'controller' => $route["controller"] ?? null,
                'action' => $route["action"],
            ];

            if ($config["controller"] === null) {
                unset($config["controller"]);
            }

            if ($attr->cache) {
                $config['cache'] = true;
            }

            if ($attr->no_trailing_slash) {
                $config['no_trailing_slash'] = true;
            }

            $result[] = [
                $attr->path,
                $config,
            ];
        }

        return $result;
    }
}

PHPDoc を読んでいただければ分かると思ますが,既存のルーティング情報に配列の書き方を合わせて返すような処理をしています.

declare(strict_types=1);

namespace Dic\Domain;

use Dic\Domain\RouteCollector\SampleAction;
use Dic\Domain\RouteCollector\SampleController;
use Dic\Domain\RouteCollector\Nested;
use Dic\TestCase;
use function Safe\fopen;
use function Safe\rewind;
use function Safe\stream_get_contents;

class RoutingFileGeneratorTest extends TestCase
{
    public function test()
    {
        $routes = [
            ['action' => Nested\SampleAction2::class, 'route' => new Route("/Nested/SampleAction2")],
            ['action' => Nested\SampleAction::class, 'route' => new Route("/Nested/SampleAction")],
            ['controller' => SampleController::class, 'action' => 'index', 'route' => new Route('/sample')],
            ['controller' => SampleController::class, 'action' => 'cached', 'route' => new Route('/sample/cached', true)],
            ['controller' => SampleController::class, 'action' => 'robots', 'route' => new Route('/sample/robots.txt', false, true)],
            ['action' => SampleAction::class, 'route' => new Route("/SampleAction")],
        ];

        $file = fopen('php://memory', 'rw');

        $subject = new RoutingFileGenerator($routes);
        $subject->write($file);

        rewind($file);

        $actual_content = stream_get_contents($file);
        $this->assertSame(self::EXPECTED_CONTENT, $actual_content);
    }

    private const EXPECTED_CONTENT = <<<'PHP'
<?php return array (
  0 =>
  array (
    0 => '/Nested/SampleAction2',
    1 =>
    array (
      'action' => 'Dic\\Domain\\RouteCollector\\Nested\\SampleAction2',
    ),
  ),
  1 =>
  array (
    0 => '/Nested/SampleAction',
    1 =>
    array (
      'action' => 'Dic\\Domain\\RouteCollector\\Nested\\SampleAction',
    ),
  ),
  2 =>
  array (
    0 => '/sample',
    1 =>
    array (
      'controller' => 'Dic\\Domain\\RouteCollector\\SampleController',
      'action' => 'index',
    ),
  ),
  3 =>
  array (
    0 => '/sample/cached',
    1 =>
    array (
      'controller' => 'Dic\\Domain\\RouteCollector\\SampleController',
      'action' => 'cached',
      'cache' => true,
    ),
  ),
  4 =>
  array (
    0 => '/sample/robots.txt',
    1 =>
    array (
      'controller' => 'Dic\\Domain\\RouteCollector\\SampleController',
      'action' => 'robots',
      'no_trailing_slash' => true,
    ),
  ),
  5 =>
  array (
    0 => '/SampleAction',
    1 =>
    array (
      'action' => 'Dic\\Domain\\RouteCollector\\SampleAction',
    ),
  ),
);
PHP;

}

テストコードに書いてあるように,返ってきたルーティング情報はこのような配列になっています.

この配列からルーティング情報をファイルに出力していきます.

if (!file_exists( __DIR__ . '/route-config.php')) {
    return;
}

foreach ((require __DIR__ . '/route-config.php') as [$path, $config]) {
    Router::addRoute(ltrim($path, '/'), $config);
}

こうすることで配列から一つ一つのルーティング情報がファイルに追加されていきます.

おわりに

ピクシブ百科事典のルーティングについて Attribute ベースルーティングという新しい機能の各モジュールを実装するところまですることが出来たのですが,時間切れとなり,一つの機能にまとめ上げることが出来ませんでした.

しかし,Attribute ベースルーティングの各モジュールを実装するなかで,自分の知らなかった PHP の様々な機能を知ることが出来ましたし,テストをしっかりと書く経験が出来て,とても楽しくインターン期間を過ごすことが出来ました.

メンターの@tadsanさん含め,沢山の方にお世話になりました.

ありがとうございました.

Share