【PHP】【JavaScript】プログラム内のオブジェクトを永続化する際はソースコードの版も保存すると後の改修が楽

 時折プログラム内のオブジェクトをそのまま永続化したい時があります。これは例えば画面の状態をいつでも再現できるようにしたい時、保存すべき情報が多いが個別で使うこともなくオブジェクトとして一塊となっている時です。こういった際、オブジェクトを永続化できると然程複雑なことを考えなくとも目的を達成できます。
 またあるプログラムの改修が進むとソースコード内のクラスの構造やオブジェクトの構造が変わっていきます。もしそのプログラムの中にインスタンスやオブジェクトを永続化する仕組みが入っていた場合、構造の変化に合わせて永続化した古いインスタンスやオブジェクトの読み取り機能を作る必要があります。この読み取り機能の作成、改修が楽になる方法を紹介します。

 まず大事なのはクラスそのものに依存する状態ではなるべく保存しない様にすることです。もしリファクタリング等によりクラスの改名が起きた場合、元クラスがなくなったことによる異常動作が引き起こされます。この時、元クラスをそのままにしておいたとしても名前と異なった挙動をする値の中継地点にすぎない謎クラスが生まれがちです。クラスそのものに依存した永続化は特に PHP においてインスタンスをシリアライズした文字列を保存する時があてはまります。

PHP: serialize – Manual
PHP: unserialize – Manual

 例えば次の様になります。

<?php
//class A {
    
//}
//echo serialize(new A);
// ↑のシリアライズした結果の文字列を↓でアンシリアライズして復元
$a = unserialize('O:1:"A":0:{}', ['allowed_classes' => ['A']]);
var_dump($a);
/*
object(__PHP_Incomplete_Class)#1 (1) {
  ["__PHP_Incomplete_Class_Name"]=>
  string(1) "A"
}
*/

 クラスが見つからず __PHP_Incomplete_Class となります。これは少なくとも PHP5 以降で共通動作です。もしインスタンスが復元されることを前提としており、メソッドの使用を試みた場合は致命的エラーになります。

 このため永続化するのはオブジェクトが保持している値のみしておく方が無難です。これは例えば次です。

<?php
class A {
    public function __construct(
        private int $b, // 保存例用のパラメーター
        private string $c // 保存例用のパラメーター
    ) {}

    /** 永続化用の文字列を取得 */
    public function getSaveStr():string {
        // プロパティを全て集めます。この例では int|string のみなのでシンプルに foreach で収集します。
        // 何がしかのインスタンスなど複雑なものをプロパティに持っている場合、
        // その複雑なもの様の処理を追加する必要があります。
        $properties = [];
        foreach($this as $k => $v){
            $properties[$k] = $v;
        }
        // 集めたプロパティを JSON 化して返します。
        return json_encode($properties);
    }

    /** 永続化用の文字列からインスタンスを生成 */
    public static function makeFromSaveStr(string $saveStr): self{
        // 雑にインスタンスを作ります。
        $newA = new self(-1, 'dummy');
        // プロパティが集められた JSON をデコードしてプロパティを再現します
        $properties = json_decode($saveStr, true);
        // この例では int|string のみなのでシンプルに foreach で復元します。
        // 何がしかのインスタンスなど複雑なものをプロパティに持っている場合、
        // その複雑なもの様の処理を追加する必要があります。
        foreach($properties as $k => $v){
            $newA->$k = $v;
        }

        return $newA;
    }
}
// 永続化
$saveStr = (new A(12, 'abc'))->getSaveStr();
echo $saveStr . "\n"; // {"b":12,"c":"abc"}
// 読み込み
$recreated = A::makeFromSaveStr($saveStr);
var_dump($recreated);
/*
object(A)#1 (2) {
  ["b":"A":private]=>
  int(12)
  ["c":"A":private]=>
  string(3) "abc"
}
*/

class A {
  constructor(b, c) {
    this.b = b;
    this.c = c;
  }

  getSaveStr() {
    // 自身を JSON 化
    return JSON.stringify(this);
  }

  static makeFromSaveStr(str) {
    // JSON を元にインスタンス化された自身にプロパティを付与
    const props = JSON.parse(str);
    const newA = new this();
    // この例では int|string のみなのでシンプルに forEach で復元
    Object.keys(props).forEach((key) => newA[key] = props[key]);

    return newA;
  }
}
// 永続化
const saveStr = (new A(12, 'abc')).getSaveStr();
console.log(saveStr);// {"b":12,"c":"abc"}
// 読み込み
const recreated = A.makeFromSaveStr(saveStr);
console.log(recreated);// A { b: 12, c: 'abc' }

 こうするとクラスそのものに依存することなくインスタンス等のメソッドを持つオブジェクトの復元ができます。

 クラスへの依存はなくなりましたが、まだ問題が起きる場合があります。それは持っているデータが改修によって変わる場合です。改修によってデータの種類が増減したり、名前が変わったりすることがよくあります。この状態で上記の方法をそのまま使った場合、余分なプロパティを復元してしまう、必要なパラメーターが足りない状態で復元しようとしてしまう、想定される値と違う意味合いの値を持った状態で復元されてしまう、といったことが起きえます。これを防ぐためには保存されたオブジェクトの時期ごとに適切に処理することが必要です。この保存された時期の把握にはソースコードのバージョンがわかる何かしらをまとめて保存するのがいいです。これは例えば次でできます。

<?php
// 最新コミットのタイムスタンプをグローバル変数としてセット
// フレームワーク等を使うならば、デプロイの度に環境変数や設定ファイルに入れ、そこから読み取った方がいいです
global $LAST_COMMIT_TIMESTAMP;
$LAST_COMMIT_TIMESTAMP = trim(shell_exec('git log -n 1 --pretty=\'%ct\''));

class A
{
    public function __construct(
        private int    $b, // 保存例用のパラメーター
        private string $c // 保存例用のパラメーター
    ){}

    /** 永続化用の文字列を取得 */
    public function getSaveStr(): string
    {
        // プロパティを全て集めます。この例では int|string のみなのでシンプルに foreach で収集します。
        // 何がしかのインスタンスなど複雑なものをプロパティに持っている場合、
        // その複雑なもの様の処理を追加する必要があります。
        $properties = [];
        foreach($this as $k => $v) {
            $properties[$k] = $v;
        }
        // 最後のコミットのタイムスタンプ(版)も保存対象とする
        global $LAST_COMMIT_TIMESTAMP;
        $properties['lastCommitTimestamp'] = str_replace(['format:', "'"], '', $LAST_COMMIT_TIMESTAMP);
        // 集めたプロパティを JSON 化して返します。
        return json_encode($properties);
    }

    /** 永続化用の文字列からインスタンスを生成 */
    public static function makeFromSaveStr(string $saveStr): self
    {
        // プロパティが集められた JSON をデコードしてプロパティを再現します
        $properties = json_decode($saveStr, true);
        if(!isset($properties['lastCommitTimestamp'])) {
            throw new \RuntimeException('版が見つかりませんでした');
        }
        // 版ごとに復元メソッドを作ります
        $lastCommitDateTime = date('Y-m-d H:i:s', $properties['lastCommitTimestamp']);
        if('2022-03-01 10:05:11' <= $lastCommitDateTime){
            // 最新版用復元
            $ret = self::makeFromSaveStrAfter20220301100511($properties);
        }elseif ('2021-12-17 14:12:34' <= $lastCommitDateTime && $lastCommitDateTime <= '2022-03-01 10:05:11'){
            // 2021年12月17日から2022年3月1日の時までの版の復元
            $ret = self::makeFromSaveStrBetween20211217141234And20220301100511($properties);
        }else {
            // 2021年12月17日 より前の復元
            $ret = self::makeFromSaveStrBefore20211217141234($properties);
        }

        return $ret;
    }

    /** 2022-03-01 10:05:11 以降の版で有効な復元 */
    protected static function makeFromSaveStrAfter20220301100511(array $props): A
    {
        // 雑にインスタンスを作ります。
        $newA = new self(-1, 'dummy');
        // この例では int|string のみなのでシンプルに foreach で復元します。
        // 何がしかのインスタンスなど複雑なものをプロパティに持っている場合、
        // その複雑なもの様の処理を追加する必要があります。
        foreach($props as $k => $v) {
            $newA->$k = $v;
        }

        return $newA;
    }
}

// 永続化
$saveStr = (new A(12, 'abc'))->getSaveStr();
echo $saveStr . "\n"; // {"b":12,"c":"abc","lastCommitTimestamp":"1646617635"}
// 読み込み
$recreated = A::makeFromSaveStr($saveStr);
var_dump($recreated);
/*
object(A)#1 (3) {
  ["b":"A":private]=>
  int(12)
  ["c":"A":private]=>
  string(3) "abc"
  ["lastCommitTimestamp"]=>
  string(10) "1646617635"
}
*/

// 最新コミットのタイムスタンプをサーバー->html->JavaScriptとリレー
const LAST_COMMIT_DATETIME = document.querySelector('meta#datetime').content;

class A {
  constructor(b, c) {
    this.b = b;
    this.c = c;
  }

  getSaveStr() {
    // 自身と最新のコミットハッシュを JSON 化
    return JSON.stringify({ ...this, LAST_COMMIT_DATETIME });
  }
}

class AFactory {
  static makeFromSaveStr(str) {
    const props = JSON.parse(str);
    // 版ごとに復元メソッドを作ります
    let ret;
    if ('2022-03-01 10:05:11' <= props.LAST_COMMIT_DATETIME) {
      // 最新版用復元
      ret = AFactory.makeFromSaveStrAfter20220301100511(props);
    } else if ('2021-12-17 14:12:34' <= props.LAST_COMMIT_DATETIME && props.LAST_COMMIT_DATETIME <= '2022-03-01 10:05:11') {
      // 2021年12月17日から2022年3月1日の時までの版の復元
      ret = AFactory.makeFromSaveStrBetween20211217141234And20220301100511(props);
    } else {
      // 2021年12月17日 より前の復元
      ret = AFactory.makeFromSaveStrBefore20211217141234(props);
    }
    return ret;
  }

  /** 2022-03-01 10:05:11 以降の版で有効な復元 */
  static makeFromSaveStrAfter20220301100511(props) {
    // JSON を元にインスタンス化されたAにプロパティを付与
    const newA = new A();
    // この例では int|string のみなのでシンプルに forEach で復元
    Object.keys(props).forEach((key) => newA[key] = props[key]);
    return newA;
  }
}

// 永続化
const saveStr = (new A(12, 'abc')).getSaveStr();
console.log(saveStr);// {"b":12,"c":"abc","LAST_COMMIT_DATETIME":"2022-03-08 10:05:11"}
// 読み込み
const recreated = AFactory.makeFromSaveStr(saveStr);
console.log(recreated);// A { b: 12, c: 'abc', LAST_COMMIT_DATETIME: '2022-03-08 10:05:11' }


 この様にして依存が少なく版がわかる形でオブジェクトを永続化すると復元のロジックが明確になり、不具合やセキュリティリスクにつながりにくくなります。

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

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

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

CTR IMG