📝

Symfony ExpressionLanguage #4 使用例 機能の有効・無効の切り替え

2022/10/08に公開

PHP のフレームワークとして有名な Symfony には ExpressionLanguage というコンポート (ライブラリ) があります。

このコンポーネントの概要は Symfony ExpressionLanguage #1 概要と使い方 に書いていますので、そちらもご覧ください。

とても面白く使い勝手のあるコンポーネントなのですが、あまり使用例が見当たらないので、僕が実際の案件で実装したものをアレンジして紹介したいと思います。

今回は ExpressionLanguage を使用して使用して機能の有効・無効を切りけるシステムを実装していきます。

前提

ある機能を、条件を満たすユーザーにのみ公開したり、期限が来たら公開したりする要望はよくあります。

今回は以下を前提とします。

  • EC サイトを想定しています。
  • ユーザーは管理者か一般ユーザーかのいずれかの権限を持ちます。
  • 一般ユーザーは A, B, C の 3 つのランクのいずれかに分類されます。
  • 機能はユーザーの権限とランク、実行日時で有効か無効かを定義できるようにします。

構成ファイルを考える

僕は YAML で構成ファイルを作り、その中で ExpressionLanguage を使用するという手法をよく使います。

今回もこの手法を使って、構成ファイルを作りたいとを思います。

上記の前提を元に構成ファイルを以下のように組んでみました。

features:
  - name: 商品の注文
    condition: true
  - name: 一般商品の参照
    condition: true
  - name: 特別商品の参照
    condition: user.isAdmin() or (user.isNormal() and user.rank.name in ['A', 'B'])
  - name: 一括購入商品の参照
    condition: user.isAdmin() or (user.isNormal() and user.rank.name in ['A'])
  - name: お気に入り
    condition: user.isAdmin() or now > 20220925000000

各機能の使用条件を ExpressionLanguage を使って記述できるようにします。

「商品の注文」や「一般商品の参照」は誰でも行えます。

「特別商品の参照」や「一括購入商品の参照」はランクが限定されています。

(今回はランクを PHP の enum で表現するので、name プロパティを参照しています。)

「お気に入り」はユーザーが商品をブックマークする機能で 2022 年 9 月 25 日の 0 時から使える想定です。但し、管理者はテストのためにすでに使えるようになっています。

準備

最初にプロジェクトのためのディレクトリを作成します。

mkdir el-feature
cd el-feature

次に こちら を参考に Composer をダウンロードしてください。
(既に Composer が使用できる場合は不要です。)

php composer.phar require symfony/yaml symfony/expression-language

YAML と ExpressionLanguage のコンポーネントをインストールします。

実装

まずは機能のコアとなる部分から実装してみましょう。

今回は関数で実装してみます。

src/feature.php
<?php

use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\Yaml\Yaml;

function isEnableFeature(string $featureName, User $user, \DateTime $now = new \DateTime): bool
{
    $config = Yaml::parseFile(__DIR__ . '/../config/feature.yaml');
    foreach ($config['features'] ?? [] as $feature) {
        if ($feature['name'] != $featureName) continue;

        $expressionLanguage = new ExpressionLanguage;
        return $expressionLanguage->evaluate(
            $feature['condition'],
            [
                'user' => $user,
                'now' => $now->format('YmdHis')
            ]
        );
    }

    throw new \Exception('該当する機能がありません: ' . $featureName);
}

シンプルなコードですが、ポイントは現在の日時を引数で受け取り、デフォルトを設定していることです。

今回使用するのは現在の日時なので関数内でも取得することは可能です。

しかし、それだとこの関数のテストを行いづらくなります。

たとえば今回のように設定した日時の前後で正しく結果が変わるかをテストしたいような場合です。

とはいえ、使う側から見ると、「どうして現在の日時なのにこちらから渡さないといけないの?」と不思議に思います。

そこで現在の日時を引数に追加し、外部から指定できるようにしつつ、デフォルト値を設定することで使用時は意識しないようにします。

このテクニックは現在の日時を扱う場合にはよく使われます。

次に User クラスです。

src/user.php
<?php

class User
{
    public function __construct(public Role $role, public Rank $rank)
    {}

    public static function admin(): User
    {
        return new self(Role::Admin, Rank::None);
    }

    public static function normal(Rank $rank): User
    {
        return new self(Role::Normal, $rank);
    }

    public function isAdmin(): bool
    {
        return $this->role == Role::Admin;
    }

    public function isNormal(): bool
    {
        return $this->role == Role::Normal;
    }
}

enum Role {
    case Admin;
    case Normal;
}

enum Rank {
    case A;
    case B;
    case C;
    case None;
}

User クラスには admin メソッドと normal メソッドがあります。

これらはスタティックメソッドで自身のインスタンスを返すいわゆるファクトリメソッドになっています。

ファクトリメソッドを使うことでクライアントはインスタンスの生成をシンプルに行うことができます。

ほかは PHP 8.1 から使えるようになった列挙型 (enum) を使っているぐらいです。

最後にクライアント (使う側) です。

src/console.php
<?php

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/user.php';
require __DIR__ . '/feature.php';

showMenu(User::normal(Rank::A));
showMenu(User::normal(Rank::C));
showMenu(User::normal(Rank::B), new \DateTime('2022-09-25 0:00:01'));
showMenu(User::admin());

function showMenu(User $user, \DateTime $now = new \DateTime)
{
    print '権限: ' . ($user->isAdmin() ? '管理者' : '一般ユーザー') . "\n";
    if ($user->isNormal()) {
        print 'ランク: ' . $user->rank->name . "\n";
    }

    print "現在の日時: " . $now->format('YmdHis') . "\n";

    $status = function($featureName) use($user, $now) {
        return (isEnableFeature($featureName, $user, $now) ? '使用可' : '使用不可');
    };

    print "機能の使用状況:\n";
    print "  商品の注文: " . $status('商品の注文') . "\n";
    print "  一般商品の参照: " . $status('一般商品の参照') . "\n";
    print "  特別商品の参照: " . $status('特別商品の参照') . "\n";
    print "  一括購入商品の参照: " . $status('一括購入商品の参照') . "\n";
    print "  お気に入り: " . $status('お気に入り') . "\n";

    print "\n";
}

User クラスのインスタンスを生成して、各機能の使用状況を表示しています。

それでは実行してみましょう。

正しく評価されているようです🌈

まとめ

ExpressionLanguage を使って、システムで使用できる機能を判定できるようにしました。

この機能をフレームワークが持つ保護機能に組み込めば、機能の使用制限をより柔軟に調整することができるようになります。

式言語はノンプログラマーでもなんとなく意味がわかるのは大きなメリットです。

今回の構成ファイルを見れば、システムの各機能の稼働状況を把握でき、しかもノンプログラマーが調整することができます。

もちろん実際の環境ではいろいろと注意する点があります。

たとえば機能の名前や ExpressionLanguage に不備があった場合、システムダウンにつながるため、反映前のバリデーションは必須になるでしょう。

いろいろなユーザーの権限とランクでテストする仕組みが必要になるかもしれません。

とはいえ、システムの制御を使用者が行うための大きな一歩だと僕は思います。

Discussion