← Code-Übersicht

JsonInvoiceStateAdapter.php

Pfad: src/Infrastructure/Rechnung/JsonInvoiceStateAdapter.php

Ext: php

Größe: 3639 Bytes

Geändert: 2026-07-16 16:54:17+02

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Rechnung;

use Demo\Domain\Rechnung\InvoiceState;
use Demo\Domain\Rechnung\InvoiceStatePort;
use JsonException;
use RuntimeException;

/**
 * Atomarer JSON-State mit exklusivem Prozess-Lock.
 */
final class JsonInvoiceStateAdapter implements InvoiceStatePort
{
    /** @var resource|null Aktuell gehaltenes Lock. */
    private mixed $lock = null;

    /** Bindet den Adapter an eine State-Datei. */
    public function __construct(private readonly string $statePath)
    {
    }

    /** Laedt und validiert den State fail-closed. */
    public function load(): InvoiceState
    {
        if (!file_exists($this->statePath)) {
            return InvoiceState::empty();
        }
        $raw = file_get_contents($this->statePath);
        if ($raw === false || trim($raw) === '') {
            throw new RuntimeException('Rechnungs-State ist nicht lesbar');
        }
        try {
            $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException $error) {
            throw new RuntimeException('Rechnungs-State ist korrupt', 0, $error);
        }
        if (!is_array($data)) {
            throw new RuntimeException('Rechnungs-State ist kein Objekt');
        }
        return InvoiceState::fromArray($data);
    }

    /** Speichert ueber eindeutige Temp-Datei und atomaren Rename. */
    public function save(InvoiceState $state): void
    {
        if ($this->lock === null) {
            $this->withExclusiveLock(function () use ($state): void { $this->write($state); });
            return;
        }
        $this->write($state);
    }

    /** Fuehrt einen Ablauf unter einem exklusiven Dateilock aus. */
    public function withExclusiveLock(callable $operation): mixed
    {
        if ($this->lock !== null) {
            return $operation();
        }
        $directory = dirname($this->statePath);
        if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
            throw new RuntimeException('State-Verzeichnis kann nicht erstellt werden');
        }
        $handle = fopen($this->statePath . '.lock', 'c');
        if ($handle === false || !flock($handle, LOCK_EX)) {
            throw new RuntimeException('State-Lock kann nicht gesetzt werden');
        }
        $this->lock = $handle;
        try {
            return $operation();
        } finally {
            flock($handle, LOCK_UN);
            fclose($handle);
            $this->lock = null;
        }
    }

    /** Schreibt den State atomar auf dasselbe Dateisystem. */
    private function write(InvoiceState $state): void
    {
        $json = json_encode($state->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
        $temporary = tempnam(dirname($this->statePath), '.invoice-state-');
        if ($temporary === false) {
            throw new RuntimeException('Temporaerer State kann nicht erstellt werden');
        }
        $handle = fopen($temporary, 'wb');
        if ($handle === false || fwrite($handle, $json) !== strlen($json) || !fflush($handle)) {
            if (is_resource($handle)) {
                fclose($handle);
            }
            @unlink($temporary);
            throw new RuntimeException('Rechnungs-State kann nicht geschrieben werden');
        }
        if (function_exists('fsync')) {
            fsync($handle);
        }
        fclose($handle);
        if (!rename($temporary, $this->statePath)) {
            @unlink($temporary);
            throw new RuntimeException('Rechnungs-State kann nicht atomar ersetzt werden');
        }
    }
}

Frühere Versionen

Version Zeitpunkt Operation
26 2026-07-16 19:01:40.81492+02 UPDATE