突然のデータ不整合!原因は Realtime Database の更新処理かも? 更新失敗を防ぐ TypeScript の解決策

TypeScript で Firebase の Realtime Database を利用すると、使い方次第でエラーが生じてしまう可能性があります。これは TypeScript の型チェックでは検知が難しいような undefined なプロパティを格納しようとしてしまうことがあるためです。この問題が起こるとデータ更新処理が失敗し、不整合な状態が発生してしまいます。 この記事では その問題を防ぐ方法を紹介します。

はじめに

こんにちは、NeWork 開発チームの加藤です。 Firebase の Realtime Database は使ったことがあるでしょうか?直感的に利用でき便利な NoSQL のサービスですが、意図しないところで更新に失敗することはありませんか?

この記事では、Realtime Database で undefined なプロパティが入り込むことによりエラーが発生する問題について、3 つの対策アプローチとそれぞれの長所・短所を解説します。特に最後に紹介するプロキシを用いた方法は、チーム開発での利用や更新処理が多い場合におすすめです。

環境

今回の記事の前提として、以下の環境を想定しています。

  • TypeScript 5.8.2
  • firebase-admin 11.11.1

背景

Firebase Realtime Database の仕様

Realtime Database ではデータ保存・更新の際に、更新対象のプロパティに undefined を指定するとエラーが発生します。公式ドキュメントにも、渡すことのできる形式について記載されています。

set には文字列、数値、ブール値、null、配列、または任意の JSON オブジェクトを渡すことができます。

TypeScript の Partial 型

データ更新のための関数を作成する際には、与える変数に柔軟性を持たせるために、Partial 型を利用できます。これにより、更新したいプロパティのみ指定できる関数を作成できます。

例えば以下のようにユーザーデータを更新できます。

type User = {
  name: string;
  age: number;
  email: string;
};

const updateUser = async (userId: string, user: Partial<User>) => {
  await firebase.database().ref(`users/${userId}`).update(user);
};

// 使用例1
updateUser("user1", { name: "Alice", age: 20 });

// 使用例2
updateUser("user2", { name: "Bob" });

上記の使用例 1、2 の場合であれば、undefined の値は含まれないため想定通りに機能します。 しかし Partial 型を使うと、undefined を含むデータも渡すことができてしまいます。これがエラーの原因となります。

エラーの例

以下のようなコードで undefined を含むデータを渡すと Realtime Database のエラーが発生します。

// 使用例3
updateUser("user3", { name: "Bob", age: undefined }); // エラー発生

使用例 2 の場合と異なり、undefined を格納しようとしたため、Realtime Database のエラーが生じてしまいました。また、Partial型による型チェックではこの問題が検知できません。 上記のように update メソッドに直接 undefined を入れるケースはほぼないと思います。しかし、既存の DB に新しいパラメータを追加する際や、条件分岐によってパラメータを追加する場合、プロジェクトが大きくなってきた時などには、undefined 書き込みが発生するかもしれません。特に複数の開発者が関わるプロジェクトでは、その可能性が高まります。

この問題が発生してしまうと DB の更新処理が失敗してしまい、データの整合性を保つ上で問題となります。そのため今回は、この問題を改善する方法をいくつか紹介します。

解決策

解決策の案としてはいくつか考えられます。ここでは 3 つの案から比較検討を行いました。

全パターンの更新関数を用意する

まずは、undefined を許容しないようにする方法です。こちらは真っ先に思いつく方法ですが、全パターンの更新関数を用意する必要があります。例えば以下のように、name, age, email の全パターンの更新関数を用意することになります。

const updateUserName = async (userId: string, name: string) => {
  await firebase.database().ref(`users/${userId}/name`).update(name);
};

const updateUserAge = async (userId: string, age: number) => {
  await firebase.database().ref(`users/${userId}/age`).update(age);
};

const updateUserEmail = async (userId: string, email: string) => {
  await firebase.database().ref(`users/${userId}/email`).update(email);
};

const updateUserNameAndAge = async (
  userId: string,
  name: string,
  age: number
) => {
  await firebase.database().ref(`users/${userId}`).update({ name, age });
};

許容する更新パターンが少ない場合はこの方法でも問題ないかもしれません。しかし更新パターンが多い場合はメンテナンス性が悪くなります。

更新関数の中で undefined を除外する

以下のように、undefined を除外する関数を作成し、更新関数内で除去する処理を追加します。

const removeUndefined = <T extends Record<string, unknown>>(
  obj: T
): Partial<T> => {
  return Object.entries(obj).reduce(
    (acc: Partial<T>, [k, v]) =>
      typeof v === "undefined" ? acc : { ...acc, [k]: v },
    {}
  );
};

const updateUser = async (userId: string, user: Partial<User>) => {
  const filteredUser = removeUndefined(user);
  await firebase.database().ref(`users/${userId}`).update(filteredUser);
};

この方法では、更新関数の中で undefined を除外することで、undefined を許容しつつエラーを回避できます。ただし、update 関数を作成するたびに removeUndefined 関数を呼び出す必要があります。そのため更新関数が多い場合は、メンテナンス性が悪くなるかもしれません。

JavaScript のプロキシを使う

最後に Realtime Database の関数をラップし、undefined を除外しつつ更新する方法を紹介します。

プロキシの概要

TypeScript(JavaScript)の Proxy は、オブジェクトの挙動をカスタマイズするための機能です。以下は公式ドキュメントの記載例です。

const target = {
  message1: "hello",
  message2: "everyone",
};

const handler3 = {
  get(target, prop, receiver) {
    if (prop === "message2") {
      return "world";
    }
    return Reflect.get(...arguments);
  },
};

const proxy3 = new Proxy(target, handler3);

console.log(proxy3.message1); // hello
console.log(proxy3.message2); // world

この例では、message2 へのアクセス時に値を書き換え world が帰ってくるようにしています。このように Proxy を使うことで、挙動を柔軟に変更できます。

プロキシを使った解決策の概要

Realtime Database の更新処理では、update や set メソッドに渡すデータから undefined を除外する必要があります。これをすべての更新関数に入れると2つめの案で記載の通り、コードが冗長になりメンテナンス性が低下します。

そこで、プロキシを使って update や set メソッドをラップし、データを渡す際自動的に undefined を除外する仕組みを作ります。これにより、開発者は undefined を気にせずコードを書けるようになります。

以下は、プロキシを使った解決策のイメージです。

const removeUndefined; // undefinedを除外する関数

// プロキシを使ってupdateメソッドをラップ
const proxy = new Proxy(firebase.database().ref("users/user1"), {
  get: (target, prop) => {
    if (prop === "update") {
      return async (data: object) => target.update(removeUndefined(data));
    }
    return target[prop];
  },
});

// undefined を含むデータを渡してもエラーが発生しない
proxy.update({ name: "Alice", age: undefined }); // 正常に動作

これにより、update 関数をラップし、undefined を除外しつつ更新できます。

実際の実装

実際にプロキシを使って Realtime Database の関数をラップし、undefined を除外しつつ更新する方法を実装してみます。 上記のコードを前提としつつ、以下の観点を追加して実装しています。

  • ref のパスを users/user1 で固定せず、任意のパスに対応
  • ref 以外のメソッドも利用可能
  • 利用者が proxy を意識しないようにする

ここでは簡略化のために update 以外の set, push, child メソッドへの対応は省略します。また undefined を再起的に除去する関数についても 2 つめの方法で提示したものの拡張のため省略します。

// ラップ関数の定義:
export class EnhancedRTDB {
  private db: Database;
  private proxy: Database;
  private static instance: EnhancedRTDB;

  constructor() {
    this.db = admin.database();

    // Proxyを使用してメソッドの呼び出しをハンドリング
    this.proxy = new Proxy(this.db, {
      get: (target, prop) => this.handleGet(target, prop),
    });
  }

  private handleGet(target: Database, prop: string | symbol) {
    if (typeof prop === "symbol") return;
    if (prop === "ref") {
      return (path: string) => {
        return this.createRefProxy(target.ref(path));
      };
    }
    // 他のメソッドの場合はそのまま返す
    const originalMethod = (target as unknown as Record<string, unknown>)[prop];
    if (typeof originalMethod === "function") {
      return originalMethod.bind(target);
    }
    return originalMethod;
  }

  private createRefProxy(ref: admin.database.Reference) {
    return new Proxy(ref, {
      get: (target, prop) => this.handleRefGet(target, prop),
    });
  }

  private handleRefGet(
    target: admin.database.Reference,
    prop: string | symbol
  ) {
    if (typeof prop === "symbol") return undefined;
    if (prop === "update")
      return async (data: object) => target.update(this.preProcess(data));

    // 他のメソッドの場合はそのまま返す
    const originalMethod = (target as unknown as Record<string, unknown>)[prop];
    if (typeof originalMethod === "function") {
      return originalMethod.bind(target);
    }
    return originalMethod;
  }

  public static getInstance(): Database {
    if (!EnhancedRTDB.instance) {
      EnhancedRTDB.instance = new EnhancedRTDB();
    }
    return EnhancedRTDB.instance.proxy;
  }

  private preProcess(data: object): object {
    return this.isRecord(data) ? removeUndefinedRecursive(data) : data;
  }

  private isRecord(data: unknown): data is Record<string, unknown> {
    return typeof data === "object" && data !== null && !Array.isArray(data);
  }
}

// 再起的にundefinedを削除する関数
const removeUndefinedRecursive = <T extends Record<string, unknown>>(
  obj: T
): Partial<T> => {
  // 割愛
};

ラップした関数を使用する際のイメージは以下のようになります。

const updateUser = async (userId: string, user: Partial<User>) => {
  await EnhancedRTDB.getInstance().ref(`users/${userId}`).update(user);
};

// 使用例
updateUser("user1", { name: "Alice", age: 20 });
updateUser("user2", { age: 30 });
updateUser("user3", { name: "Bob", age: undefined }); // エラー回避

プロキシを使って Realtime Database の関数をラップし、undefined を除外しつつ更新するようにしています。この方法では更新関数を作成する際に removeUndefinedRecursive 関数を呼ぶ必要がなくなります。そのためメンテナンス性が向上します。しかしプロキシ処理を挟んでいるため、パフォーマンスに影響する可能性があります。

各メソッドの解説

  • handleGet メソッド
    • Database インスタンスのプロパティを取得する際に呼ばれるメソッドです。その後の処理を振り分けます。
    • ref メソッドを呼び出すと、createRefProxy メソッドを呼び出して、Reference インスタンスをラップします。
    • その他のメソッドはそのまま返します。
  • handleRefGet メソッド
    • Reference インスタンスのプロパティを取得する際に呼ばれるメソッドです。その後の処理を振り分けます。
    • update, set, push メソッドを呼び出すと、preProcess メソッドを呼び出して、undefined を除外します。
    • child メソッドを呼び出すと、createRefProxy メソッドを再度呼び出して、子 Reference インスタンスをラップします。
    • その他のメソッドはそのまま返します。
  • preProcess メソッド
    • removeUndefinedRecursive メソッドを呼び出して、undefined を除外します。

プロキシ処理の妥当性確認

参考として、この処理が正しいかの確認のためにテストコードも記載しておきます。

テストコード

const createMockRef = () => {
  const mockMethods = {
    update: jest.fn(),
  };

  // child メソッドが呼ばれた時、新しいモック Ref を返すように設定
  mockMethods.child.mockImplementation(() => createMockRef());
  return mockMethods;
};

jest.mock("firebase-admin", () => ({
  apps: [],
  database: () => ({
    ref: () => createMockRef(),
  }),
}));

let db: Database;
let ref: Reference;
beforeEach(() => {
  db = EnhancedRTDB.getInstance();
  ref = db.ref("test");
});

// ref.getやref.keyの動作確認は省略
describe("preProcess が呼ばれていることを確認", () => {
  let input: {
    test: string;
    nullValue: null;
    undefinedValue: undefined;
    nestedObject: {
      valid: string;
      shouldBeRemoved: undefined;
    };
    emptyString: "";
    zero: number;
  };

  beforeEach(() => {
    input = {
      test: "test",
      nullValue: null,
      undefinedValue: undefined,
      nestedObject: {
        valid: "valid",
        shouldBeRemoved: undefined,
      },
      emptyString: "",
      zero: 0,
    };
  });
  const verifyProcessedData = (
    targetMock: jest.Mock<void, [Record<string, unknown>]>
  ) => {
    // input には存在する undefined なプロパティが mock 引数にはないことを確認
    expect(Object.keys(input)).toContain("undefinedValue");
    expect(Object.keys(targetMock.mock.calls[0][0])).not.toContain(
      "undefinedValue"
    );
    expect(Object.keys(input.nestedObject)).toContain("shouldBeRemoved");
    const mockNestedObject = targetMock.mock.calls[0][0].nestedObject;
    expect(mockNestedObject).toBeInstanceOf(Object);
    expect(
      Object.keys(mockNestedObject as Record<string, unknown>)
    ).not.toContain("shouldBeRemoved");
  };

  test("正常系_ref.update 時に undefined なプロパティが削除されること", () => {
    const updateMock = jest.fn();
    ref.update = updateMock;
    ref.update(input);
    verifyProcessedData(updateMock);
  });
  // set, push についても同様のテストを行う(省略)
});

各解決策の比較

それぞれの解決策の特徴をまとめます。

  • 全パターンの更新関数を用意する
    • シンプルで直感的
    • 小規模なプロジェクトでは十分
    • 更新パターンが多い場合は関数が膨大になりメンテナンス性が悪くなる
  • 更新関数の中で undefined を除外する
    • 比較的簡単に実装できる
    • 各更新関数で除外処理を呼び出す必要があり、コードの重複が発生する。
  • プロキシを使う
    • 一度実装すれば、すべての更新処理で自動的に undefined を除外できるため、問題を意識しなくて良い。(オリジナルの sdk を利用しないように周知は必要です)
    • 実装が複雑で理解しにくい。
    • プロキシ処理を挟むため、パフォーマンスに若干影響する可能性がある。

まとめ

今回は TypeScript で Firebase の Realtime Database を使う際に発生する undefined プロパティの問題について、3つの解決策を紹介しました。

  1. 全パターンの更新関数を用意する
  2. 更新関数の中で undefined を除外する
  3. プロキシを使う

複数の開発者が利用する場合や、更新する対象・メソッドが多い場合はプロキシを利用する案がおすすめです。私たちは、更新系のメソッドが 10 を超えるほどあったので、プロキシを利用する方法を選びました。どの方法を選択するかは、状況に応じて検討してみてください。

以上、Firebase の Realtime Database で undefined なプロパティが入り込むことによりエラーが発生する問題について、その解決案を紹介しました。お役に立てれば幸いです。

© NTT Communications Corporation 2014