Firebase Realtime Databaseで読み込み場所と書き込み場所を分けたい場合のテンプレ


## 前提

なんとなーくRealtime Databaseを使っているが、Firestoreでも同じだと思う。

## 概要

ある程度複雑なFirebaseアプリを作っていると、書き込み地点と読み込み地点を分けたくなってくる。
rule.jsonが複雑になってくるからだ。

こういう構成にしたい。

1. Cloud FunctionsがDatabaseの/requestsをlisten
1. フロントエンドが/requestsにpushする
1. Cloud FunctionsがRequestを受け取り、/dataを更新する
1. フロントエンドは/dataをlistenしており、Viewに表示する
1. なお、Cloud FunctionsがRequestに対してお返事があるような場合は/responseに書き込む

こうしておけばアクセス権とか複雑なことは何も考えなくて良くなる。

最近同じようなコードを何度も書いている気がするので、テンプレ化した。

## テンプレ

### rule.json

```rule.json
{
  "rules": {
    ".read": false,
    ".write": false,
    "data" : {
     ".read" : true,
    },
    "requests" : {
      ".write" :true
    },
    "responses": {
      "$uid": {
        ".read": "$uid === auth.uid"
      }
    }
  }
}
```

### サーバーサイド

```functions.ts

export interface Request {
  method: string;
  payload: any;
}

export const onRequest = functions
  .database.ref("/requests/{event_id}")
  .onCreate(async (snapshot, context) => {
    await onCreate(snapshot, context);
    await snapshot.ref.remove();
  });

async function onCreate(snapshot: DataSnapshot, context: EventContext) {
  const req = snapshot.val() as Request;
  const requestID = snapshot.key;
  const fs = new FirebaseServiceImple(snapshot.ref.root);
  const userID = context.auth?.uid;
  if(!userID) return;
  const data = await readData(snapshot);
  const res = update(fs, model, req, requestID, userID);
  snapshot.ref.root.child(`responses/${userID}`).push(res);
}

async function readData(snapshot: DataSnapshot): Promise<Data> {
  return new Promise<any>((resolve) => {
    snapshot.ref.root.child("data").once("value", (ss) => {
      const data = cleansing(ss.val());
      resolve(data);
    });
  });
}

```

```update.ts
// こういうのを作っておくとテストしやすい。
export interface FirebaseService {
  set(path: string, value: any): void;
  push(path: string, value: any): string;
  remove(path: string): void;
}

export function update(
  fs: FirebaseService,
  models: Models,
  request: Request,
  requestID: string,
  userID: string
): Response {
  const method = request.method;
  const payload = request.payload;

  if (method == "POST_MESSAGE") {
    ...
    fs.push( "/data/messages", message);
    return messageID;
  } else {
    throw new Error("method ga nai");
  }
}

```

## フロントエンド
```fontend.ts

// Cloud Functionsからの戻り値をPromiseで紐付ける
export class Requests {
  private ps: Map<string, Callback> = new Map<string, Callback>();

  constructor(private app: firebase.app.App) {
    const path = `responses/${this.app.auth().currentUser?.uid || ""}`;
    this.app.database().ref(path).remove();
    this.app
      .database()
      .ref(path)
      .on("child_added", (snapshot) => {
        const res = snapshot.val() as Response;
        const key = res.requestID;
        const callback = this.ps.get(key);
        if (!callback) return;
        const result = res.result as string;
        callback(result);
        this.ps.delete(key);
      });
  }

  public postMessage(userID: string, user: User): Promies<string> {
    const req: Request = {
      method: "POST_MESSAGE",
      payload: user,
    };
    const res = await this.push(req);
    return res.messageID;
  }

  private push(request: Request): Promise<string> {
    const r = this.app.database().ref("requests").push(request);
    return new Promise((resolve) => {
      this.on(r.key!, (result) => resolve(result));
    });
  }

  private on(key: string, callback: Callback) {
    this.ps.set(key, callback);
  }
}

type Callback = (result: string) => void;

async function main() {
  const app = firebase.initializeApp(
    await (await fetch("/__/firebase/init.json")).json()
  );
  const req = new Requests(app);
  app
    .database()
    .ref("data")
    .on("value", (ss) => {
      const initialData: Data = { messages: {}, users: {} };
      const data = {...initialData, ...(ss.val() || {})} as Data; // Realtime Databaseは空オブジェを削除するので
      ...
    });

}
```

## その他

楽観的UIがしたければ、RequestにPushする時にローカルのdataも書き換えておけばいい。
そのうち最新のデータで上書きされる。