Predvoditelev.RU
Заметки

PHP класс для хранения вычисляемых значений

Бывают ситуации, когда процесс расчёта или получения неких значений для вывода пользователю, довольно долгий или трудоёмкий. Например, получение какого-то значения через внешнее API или какой-то «тяжёлый» расчёт на основе существующих данных. Как правило, такие значения где-то сохраняют, чтобы иметь к нему быстрый доступ.

Я столкнулся с такой ситуацией и решил написать универсальную обвязку для работы с такими значениями.

Начальные условия/ограничения

  • получение значения выполняется периодически и/или запускается вручную;
  • нужно хранить не только само значение, но и дату его получения;
  • значение хранится как строка;
  • на время получения значения нужно запоминать, что получение в процессе;
  • история значений не нужна;
  • в случае ошибки во время получения значения необходимо сохранять текст ошибки и не удалять предыдущие данные.

Структура записи

Исходя из условий можно выделить следующие поля, которые позволят хранить всю необходимую информацию для значения:

  • key — идентификатор значения;
  • status — статус значения (в процессе получения, успешно получено, ошибка);
  • status_date — дата сохранения статуса;
  • value_date (опционально) — дата получения значения;
  • value (опционально) — значение;
  • error (опционально) — текст ошибки.

Статус

Статус значения зададим перечислением:

enum Status: string
{
    case IN_PROCESS = 'in-process';
    case SUCCESS = 'success';
    case FAIL = 'fail';
}

Хранилище

Напишем интерфейс для хранилища:

interface StorageInterface
{
    /**
     * @param array{
     *   status: Status,
     *   status_date: DateTimeImmutable,
     *   value_date?: DateTimeImmutable|null,
     *   value?: string|null,
     *   error?: string|null,
     * } $data
     */
    public function save(string $key, array $data): void;

    /**
     * @return array{
     *   status: Status,
     *   status_date: DateTimeImmutable,
     *   value_date: DateTimeImmutable|null,
     *   value: string|null,
     *   error: string|null
     * }|null
     */
    public function get(string $key): ?array;
}

Реализация хранилища зависит от конкретной ситуации. Хранение может быть организовано в файлах, базе данных, кэше и так далее.

Основной класс

Теперь перейдём к основному классу, с которым будет взаимодействовать код приложения для получение/сохранения информации.

final readonly class CalculatedValues
{
    public function __construct(
        private StorageInterface $storage,
    ) {
    }

    /**
     * Запомнить, что начат расчёт значения:
     *  - меняем статус,
     *  - запоминаем время смены статуса,
     *  - сбрасываем текст ошибки,
     *  - предыдущее значение не трогаем.
     */
    public function start(string $key): void
    {
        $this->storage->save($key, [
            'status' => Status::IN_PROCESS,
            'status_date' => new DateTimeImmutable(),
            'error' => null,
        ]);
    }

    /**
     * Запомнить значение:
     *  - меняем статус,
     *  - запоминаем время смены статуса,
     *  - запоминаем значение и дату его получения,
     *  - сбрасываем текст ошибки.
     */
    public function success(string $key, string $value): void
    {
        $date = new DateTimeImmutable();
        $this->storage->save($key, [
            'status' => Status::SUCCESS,
            'status_date' => $date,
            'value_date' => $date,
            'value' => $value,
            'error' => null,
        ]);
    }

    /**
     * Запомнить, что расчёт значения завершился с ошибкой:
     *  - меняем статус,
     *  - запоминаем время смены статуса,
     *  - запоминаем текст ошибки,
     *  - предыдущее значение не трогаем.
     */
    public function fail(string $key, string $error): void
    {
        $this->storage->save($key, [
            'status' => Status::FAIL,
            'status_date' => new DateTimeImmutable(),
            'error' => $error,
        ]);
    }

    /**
     * Получить значение.
     */
    public function get(string $key): Result
    {
        $data = $this->storage->get($key);
        if ($data === null) {
            return Result::notExist();
        }
        return match ($data['status']) {
            Status::IN_PROCESS => Result::inProcess(
                $data['status_date'],
                $data['value'],
                $data['value_date'],
            ),
            Status::SUCCESS => ($data['value'] === null || $data['value_date'] === null)
                ? Result::notExist()
                : Result::success($data['value'], $data['value_date']),
            Status::FAIL => $data['error'] === null
                ? Result::notExist()
                : Result::fail($data['status_date'], $data['error'], $data['value'], $data['value_date']),
        };
    }
}

Результатом вызова метода get() будет объект Result, предоставляющий доступ как к самому значению, так и к дополнительной информации.

Если информации о значении нет в хранилище или запись в хранилище некорректная, то результатом будет "значения нет". Вообще, кодом обрабатывать некорректные значения считается плохой практикой, но в данном случае, на мой взгляд, такой подход оправдан. Некорректная записать в хранилище при следующем обновлении заменится на верную.

Код класса Result:

final readonly class Result
{
    private const int STATUS_NOT_EXIST = 0;
    private const int STATUS_IN_PROCESS = 1;
    private const int STATUS_SUCCESS = 2;
    private const int STATUS_FAIL = 3;

    /**
     * @param self::STATUS_* $status
     */
    private function __construct(
        private int $status,
        public ?string $value = null,
        public ?DateTimeImmutable $valueDate = null,
        private ?DateTimeImmutable $calculationStartDate = null,
        private ?DateTimeImmutable $errorDate = null,
        private ?string $error = null,
    ) {
    }

    public static function notExist(): self
    {
        return new self(self::STATUS_NOT_EXIST);
    }

    public static function inProcess(
        DateTimeImmutable $calculationStartDate,
        ?string $value,
        ?DateTimeImmutable $valueDate,
    ): self {
        return new self(self::STATUS_NOT_EXIST, $value, $valueDate, $calculationStartDate);
    }

    public static function success(
        string $value,
        DateTimeImmutable $date,
    ): self {
        return new self(self::STATUS_SUCCESS, $value, $date);
    }

    public static function fail(
        DateTimeImmutable $errorDate,
        string $error,
        ?string $value,
        ?DateTimeImmutable $valueDate,
    ): self {
        return new self(self::STATUS_FAIL, $value, $valueDate, errorDate: $errorDate, error: $error);
    }

    public function isNotExist(): bool
    {
        return $this->status === self::STATUS_NOT_EXIST;
    }

    public function isInProcess(): bool
    {
        return $this->status === self::STATUS_IN_PROCESS;
    }

    /**
     * @psalm-assert-if-true !null $this->value
     * @psalm-assert-if-true !null $this->valueDate
     */
    public function isSuccess(): bool
    {
        return $this->status === self::STATUS_SUCCESS;
    }

    public function isFail(): bool
    {
        return $this->status === self::STATUS_FAIL;
    }

    /**
     * @psalm-assert-if-true !null $this->value
     * @psalm-assert-if-true !null $this->valueDate
     */
    public function hasValue(): bool
    {
        return $this->value !== null && $this->valueDate !== null;
    }

    public function getCalculationStartDate(): DateTimeImmutable
    {
        if ($this->calculationStartDate === null) {
            throw new LogicException('Calculation start date available in "in process" status only.');
        }
        return $this->calculationStartDate;
    }

    public function getErrorDate(): DateTimeImmutable
    {
        if ($this->errorDate === null) {
            throw new LogicException('Error date available in "fail" status only.');
        }
        return $this->errorDate;
    }

    public function getError(): string
    {
        if ($this->error === null) {
            throw new LogicException('Error available in "fail" status only.');
        }
        return $this->error;
    }
}

Пример использования

В итоге, в коде мы будем работать с классом CalculatedValues. Выглядеть это будет примерно так:

/**
 * Код, запускаемый по крону
 */
$calculatedValues->start('my-data');
// ставим в очередь задачу на расчёт данных

/**
 * Очередь
 */
$calcluatedValues->success('my-data', 'xxxxxx');
// или 
$calcluatedValues->fail('my-data', 'API не доступно.');

/**
 * Чтение данных
 */
$result = $calculatedValues->get('my-data');

if ($result->hasValue()) {
  echo '<p>' . $result->value . ' (' . $result->valueDate->format('d.m.Y') . ')</p>';
}

if ($result->isNotExist()) {
  echo '<p>Нет данных</p>';
}

if ($result->isFail()) {
  echo '<p>Ошибка от ' . $result->getErrorDate()->format('d.m.Y') . ': ' . $result->getError() . '</p>';
}

if ($result->isInProcess()) {
  echo '<p>Идёт расчёт (' . $result->getCalculationStartDate()->format('d.m.Y') . '</p>';
}

Библиотека?

Не уверен, что этот код стоит выделять в отдельную библиотеку, так как в конкретном приложении возможны нюансы. Думаю будет лучше, если это будет частью пользовательского кода.

Спасибо подписчикам моего телеграм-канала и отдельно Алексндру Борисову за помощь в доработке статьи.

@sergei_predvoditelev — Авторский канал в Telegram: заметки о веб-разработке, PHP, открытом ПО, развитии и немного о жизни.