【PHP】ファイル削除用のざっくりしたトランザクション処理を作る

  • 2021年3月5日
  • 2021年3月5日
  • PHP

 次の通りのファイルをまとめて削除する素朴な処理があるとします。

        $files = [
            '/deleteTgt1.txt',
            '/cantDelete/deleteTgt2.txt',
            '/deleteDir/deleteTgt3.txt',
        ];
        foreach($files as $file){
            unlink($file);
        }

 もし deleteTgt1.txt を削除した後 /cantDelete/deleteTgt2.txt が権限の問題で削除できないことが判明して処理が中断された場合(PHP のエラーを例外として投げる様にしているとままあります)、削除対象のうち半端に削除された状態でファイルらが残ってしまいます。この削除対象のファイルらが一揃いある状態を前提としているシステムを組んでいた場合、この半端に削除された状態はデータの整合性が破壊された状態であり、ファイル削除以外でもシステムが異常な動作を起こす原因になります。これを防ぐためにデータを一塊で削除する方法――トランザクションとして削除する方法が欲しくなります。
 大雑把なファイルのトランザクション処理は次のフローで実現できます。MySQL などのより堅牢なトランザクションに比べると貧弱ですが対策がないよりはずっとましです。
 
 図中の一時退避ファイル置き場は Linux ならば /tmp 以下か /var/tmp 以下の何かしらのディレクトリにすると Linux がそのうち自動削除してくれてディスク容量に優しいです。
 これは次のコードで作れます。

<?php

namespace AnyWhere;

/** ファイル削除トランザクション管理クラス */
class TransactionDeleteFiles
{
    // ファイルシステムの区切り文字のエイリアス。素では長いため用意
    protected const DS = DIRECTORY_SEPARATOR;
    /** @var string[] 削除対象のファイルらのフルパスリスト */
    protected array $deleteTargetFiles = [];
    /** @var string[] 一時退避中のファイルらのフルパスリスト */
    protected array $temporaryFiles = [];

    /** @var bool トランザクション削除処理が実行中かの状態を持っておくフラグ */
    protected bool $running = false;

    /**
     * TransactionDeleteFiles constructor.
     * @param string[] $deleteTargetFiles 削除対象のファイルらのフルパスリスト
     */
    public function __construct(array $deleteTargetFiles)
    {
        $this->deleteTargetFiles = $deleteTargetFiles;

        // 一時退避先のディレクトリを用意
        $tmpDirRoot = $this->tmpDirRoot();
        if (! is_dir($tmpDirRoot) && ! mkdir($tmpDirRoot, 0777, true) && ! is_dir($tmpDirRoot)) {
            throw new \RuntimeException(sprintf('ディレクトリ "%s" は作成できませんでした', $tmpDirRoot));
        }
    }

    public function __destruct()
    {
        if ($this->running) {
            // トランザクションが未完了で投げ出されたらとりあえずロールバックしておく
            $this->rollback();
        }
    }

    /**
     * 一時退避ディレクトリのルート
     * @return string
     */
    public function tmpDirRoot(): string
    {
        return '/tmp/my-app';
    }

    /**
     * 一時ディレクトリに退避
     */
    public function begin(): void
    {
        $this->running = true;
        $tmpDirRoot    = $this->tmpDirRoot();
        try{
            foreach ($this->deleteTargetFiles as $file) {
                // 与えられた削除対象ファイルが既に削除済みか確認。ここはユースケースに応じて FileNotFound な例外を投げる様にするか考えどころ
                $file = $this->cleanFilePath($file);
                if (! is_file($file)) {
                    continue;
                }
                // 一時退避状態のファイルパスを生成 & 保持
                $tmpPath = rtrim($tmpDirRoot, self::DS).self::DS.ltrim($file, self::DS);
                $this->temporaryFiles[] = $tmpPath;

                // ファイルの一時退避先ディレクトリを用意
                $tmpDir = $this->filePathToDirPath($tmpPath);
                if (! is_dir($tmpDir) && ! mkdir($tmpDir, 0777, true) && ! is_dir($tmpDir)) {
                    throw new \RuntimeException(sprintf('ディレクトリ "%s" は作成できませんでした', $tmpDir));
                }
                // 一時退避
                rename($file, $tmpPath);
            }
        }catch(\Throwable $e){
            // この一時退避中に何かやらかしたら一時退避を巻き戻し
            $this->rollback();
            throw $e;
        }
    }

    /**
     * 一時退避中のファイルを完全に削除
     */
    public function commit(): void
    {
        $this->running = false;
        foreach ($this->temporaryFiles as $tmpPath) {
            unlink($tmpPath);
        }
    }

    /**
     * 一時退避中のファイルを元の場所に復元
     * この処理でこけた場合、致命傷
     */
    public function rollback(): void
    {
        $this->running = false;
        $tmpDirRoot    = $this->tmpDirRoot();
        foreach ($this->temporaryFiles as $tmpPath) {
            if (! is_file($tmpPath)){ 
                // できうる限り巻き戻しを続行
                continue;
            }
            rename($tmpPath, substr($tmpPath, strlen($tmpDirRoot)));
        }
    }

    /**
     * ファイルパスの冗長な区切り文字を正規化
     * @param  string $path
     * @return string
     */
    protected function cleanFilePath(string $path): string
    {
        return preg_replace('#'.self::DS.'+#', self::DS, $path);
    }

    /**
     * ファイルパスからそのファイルの置いてあるディレクトリのパスを取得
     * @param  string $path
     * @return string
     */
    protected function filePathToDirPath(string $path): string
    {
        $normalized = preg_replace('#'.self::DS.'+#', self::DS, $path);
        $pathArr    = explode(self::DS, $normalized);

        return implode(self::DS, array_slice($pathArr, 0, count($pathArr) - 1));
    }
}

// 使用例
// 何か適当なフレームワークのデータベース中のテーブルをマッピングしたクラス
class ORM extend BaseORM
{
    /**
     * ファイルとレコードをまとめて削除
     * @param  string[]            $files 削除対象ファイルのフルパスのリスト
     * @throws Exception|Throwable
     * @return bool|void|null
     */
    public function deleteWithFiles(array $files): ?bool
    {
        $transactionDeleteFiles = new TransactionDeleteFiles($files);
        try {
            // データベースのトランザクション開始
            \DB::beginTransaction();
            // ファイル削除のトランザクション開始
            $transactionDeleteFiles->begin();
            // データベース中のレコードの削除メソッドを実行
            $ret = $this->delete();
            // データベースのトランザクションをコミット
            \DB::commit();
            // ファイル削除のトランザクションをコミット
            $transactionDeleteFiles->commit();
        } catch (Throwable $e) {
            // データベースのトランザクションをロールバック
            \DB::rollBack();
            // ファイル削除のトランザクションをロールバック
            $transactionDeleteFiles->rollback();
            throw $e;
        }

        return $ret;
    }
}

 こんな感じで比較的データの整合性を保ったまま複数のファイルの削除を扱えます。

 余談ですがファイルの中身を blob 型等で MySQL 等のACID原則をきっちり守ったデータベースの中に保存しておくと取扱いこそ面倒ですが、トランザクションに強いシステムを簡単に作れます

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG