📝

Symfony ExpressionLanguage #3 使用例 データの変換としきい値

2022/10/08に公開

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

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

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

今回は ExpressionLanguage を使用してデータの変換としきい値を定義するシステムを実装していきます。

前提

僕が以前に実装したシステムは、ユーザーに機器を貸し出し、その機器の使用状況をユーザーが Web 上で確認できるというものでした。

ただ、あまり細かく書くことができないので、今回は以下のような前提で実装します。

  • バケツに貯まる水量を計測し、その状況を伝えるシステムです。
  • バケツの水量を計測器からデータとして取得できます。
  • データはミリリットル単位で送られますが、ユーザーにはバケツの総量に対する割合 (%) で出力します。
  • バケツの容量は契約プランによって決まります。
    • Aプラン: 5リットル
    • Bプラン: 10リットル
  • 水量に応じて、メッセージを表示します。
    • 0%以上 50%未満: 正常です。
    • 50%以上 75%未満: 注意してください。
    • 75%以上 100%未満: 危険です!
    • 100%以上: あふれています! (実際にはバケツは契約の 2 倍の容量があり、100% を超えられるようになっている)

構成ファイルを考える

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

この手法を使って、構成ファイルを使ってみたいと思います。

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

config/bucket.yaml
bucket:
  capacities:
    - plan: A
      capacity: 5
    - plan: B
      capacity: 10
  display_value: "(value / (capacity * 1000)) * 100"
  display_value_unit: "%"
  thresholds:
    - condition: 0 <= displayValue and displayValue < 50
      message: 正常です。
    - condition: 50 <= displayValue and displayValue < 75
      message: 注意してください。
    - condition: 75 <= displayValue and displayValue < 100
      message: 危険です!
    - condition: 100 <= displayValue
      message: あふれてます!

まず、契約プランに応じた容量を capacities で定義します。

display_valuedisplay_value_unit はユーザーに表示する値の計算式とその単位です。

表示する値を式言語で表現することで割合から実際の容量に変更することが容易になります。

水量に応じて表示するメッセージは条件を示す式言語とメッセージの組み合わせで定義します。

こうすることで割合だけでなく、実際の水量に応じてメッセージを表示することも可能になります。

準備

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

mkdir el-data-convert
cd el-data-convert

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

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

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

実装

YAML をロードして ExpressionLanguage を使用してデータを変換する処理を Bucket クラスで行うことにします。

src/bucket.php
<?php

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

class Bucket
{
    private array $bucket;

    public function __construct(private string $plan, private int $value)
    {
        $config = Yaml::parseFile(__DIR__ . '/../config/bucket.yaml');
        $this->bucket = $config['bucket'];
    }

    public function getDisplayValue(bool $withUnit = true): string
    {
        $capacity = $this->getCapacity();

        $expressionLanguage = new ExpressionLanguage;
        $displayValue = $expressionLanguage->evaluate(
            $this->bucket['display_value'],
            [
                'value' => $this->value,
                'capacity' => $capacity
            ]
        );

        return $displayValue . ($withUnit ? $this->bucket['display_value_unit'] : '');
    }

    public function getThresholdMessage(): string
    {
        $displayValue = $this->getDisplayValue(false);

        $expressionLanguage = new ExpressionLanguage;
        foreach ($this->bucket['thresholds'] as $threshold) {
            $condition = $expressionLanguage->evaluate(
                $threshold['condition'],
                [
                    'value' => $this->value,
                    'displayValue' => $displayValue
                ]
            );

            if ($condition) {
                return $threshold['message'];
            }
        }

        return '';
    }

    private function getCapacity()
    {
        foreach ($this->bucket['capacities'] as $value) {
            if ($value['plan'] != $this->plan) continue;

            return $value['capacity'];
        }
        return null;
    }
}

コンストラクター (10-14 行目) で契約プランと値を引数として受け取り、YAML をロードします。

Bucket クラスには2つのパブリックメソッドがあります。

getDisplayValue() メソッドは表示するための値を返します。

getThresholdMessage() メソッドは取得している値を使用して条件を評価し、該当するメッセージを返します。

getCapacity() メソッドはプランからバケツの容量を返します。

このメソッドはプライベートですが、外部から呼び出されることになれば、パブリックにしても良いと思います。

getDisplayValue()getThresholdMessage() では ExpressionLanguage を使用してデータを変換したり、条件に合致するかを評価したりしています。

Bucket クラスは YAML から ExpressionLanguage取り出し評価しているだけで、どういった形式でデータを変換するかや、しきい値の条件といったことの詳細には関与していないことがわかります。

次に Bucket クラスを使う側を見てみましょう。

src/console.php
<?php

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/bucket.php';

do {
    print "契約プランを入力してください (A, B, q: 終了): ";
    $plan = trim(fgets(STDIN));
} while (!in_array($plan, ['A', 'B']) && $plan != 'q');

if ($plan == 'q') exit;

while (true) {
    print "水量を入力してください (ミリリットル) (q: 終了): ";
    $value = trim(fgets(STDIN));

    if ($value == 'q') break;

    if (!is_numeric($value)) continue;

    $bucket = new Bucket($plan, (int) $value);
    print $bucket->getDisplayValue() . ': ' . $bucket->getThresholdMessage() . "\n";
}

Web あp類ケーションであれば送られてくる水量のデータを取得しながら更新することになりますが、今回はサンプルなので CLI で手動で入力しています。

まず契約プランを入力し、次に水量をミリリットル単位で入力します。

契約プランと水量で使って Bucket クラスのインスタンスを生成し、変換した水量を示すデータ (今回は割合) と該当するメッセージを表示しています。

このコードを見ると Bucket クラスを使う側は ExpressionLanguage を使っていることを認識しておらず、実装が隠蔽されているのがわかります。

実は Bucket クラスはコンストラクターで毎回 YAML をロードしているので、あまり効率が良いコードとは言えません。

実装が隠蔽されているので、クライアント (console.php) に手を加えることなく、最初に一度だけ YAML をロードするような実装に変更することも可能です。

では、実行してみましょう。

うまく動いているようです🌈

まとめ

システムの挙動を構成ファイルによって変更するというのはよくある手法です。

そこに ExpressionLanguage を使うことで本来 PHP で実装しなければならい内容を構成ファイルに移動することができます。

こうすることで実装の抽象度を押し上げることができ、実装をシンプルにまとめることができます。

Discussion