🕶️

FlutterのDBをFirestoreからサーバーレスRDBに置き換えてみた

2022/11/27に公開

はじめに

今回はFlutterのデータベースにRDBを使ってみたいと思い、Firebase Cloud Functions / PlanetScale / Prisma の組み合わせを試してみました。

普段Firebase(Firestore)を使っている場合はあまり意識することはないと思いますが、本来自分でデータベース(DB)を用意する場合、↓のようにAPI/DBのサーバーを用意する必要があります。

ここでFirebase Cloud FunctionsとPlanetScaleを使うと、それぞれ自分でサーバーを管理することなく(サーバーレス)運用できるというわけです。

さらにPrismaを使うことで、SQLではなくTypeScript(JavaScriptも可)でRDBの読み書きをしていきます。

各リンク

Prisma: https://www.prisma.io
PlanetScale: https://app.planetscale.com

セットアップ

Firebase Cloud Functions

特に難しい手順はないので割愛します。
Firebaseのプロジェクトを作成し、firebase initまで済ませておくと良いです。

PlanetScale

こちらも特に難しい手順はないので割愛します。

databaseを作って、発行されるパスワードをメモしておいてください。
(次の手順で使用します)

Prisma

初期設定のため以下コマンドを実行します。

// firebase initで作られるfunctions/に移動
$ cd functions

$ npm install prisma -D

$ npx prisma init

PlanetScaleでConnect > Connect with: Prismaを選択すると、.envschema.prismaの例が表示されるので、そのままコピペします。

.envの************部分は、発行したパスワードに置き換える必要があるので注意してください。

Scheme作成

PrismaのModel定義

今回は投稿(POST)を扱います。
Firestoreとの連携のため、idと別にユニークなpostIdを持たせています。

/functions/prisma/schema.prisma

generator client {
 // ↑でPlanetScaleからコピペした内容
}

datasource db {
 // ↑でPlanetScaleからコピペした内容
}

model Post {
  id            Int           @id @default(autoincrement())
  authorId      String
  imageUrl      String
  postId        String        @unique
  isDeleted     Boolean       @default(false)
  createdAt     DateTime      @default(now()) @map(name: "created_at")
  updatedAt     DateTime      @updatedAt @map(name: "updated_at")

  @@map(name: "posts")
}

PlanetScaleに反映

$ npx prisma db push

上記コマンドでPlanetScaleに反映します。
PlanetScaleのコンソールから反映内容を可能です。

Cloud Functions実装

ここまででデータベースの用意はできたので、データの読み書きをするためのFunctionsを実装していきます。

今回はとりあえず、新規登録を試してみます。

Prismaを通してデータの読み書きをするので、こちらを参考にしました。

/functions/src/index.ts

import * as admin from 'firebase-admin';

export const firebaseAdmin = admin.initializeApp();

import { addPost } from './methods/addPost';

module.exports = {
  addPost,
};

/functions/src/methods/addPost.ts

import * as functions from 'firebase-functions';
import { successResponse, throwError } from '../../services/httpService';
import { setPostToPrisma } from '../../services/prismaHandler';

export const addPost = functions
  .region('asia-northeast1')
  .https.onCall(async (data, context) => {
    const auth = context.auth;
    if (auth == undefined) {
      throwError('unauthenticated', 'auth is undefined');
      return;
    }
    const authorId = auth.uid;

    const imageUrl: string | undefined = data.imageUrl;
    const postId: string | undefined = data.postId;

    if (postId == undefined || imageUrl == undefined) {
      throwError('invalid-argument', 'parameter is invalid');
      return;
    }

    await setPostToPrisma({ authorId, imageUrl, postId });
    console.log('Addition Complete ', postId);
    return successResponse();
  });

/functions/services/httpService.ts

import { HttpsError } from 'firebase-functions/v1/auth';
import { FunctionsErrorCode } from 'firebase-functions/v1/https';

export const successResponse = () => {
  return { result: 'ok' };
};

export const throwError = (code: FunctionsErrorCode, message: string) => {
  console.log(message);
  throw new HttpsError(code, message);
};

/functions/services/prismaHandler.ts

import { PrismaClient } from '@prisma/client';
import { Post, PrismaPost } from '../models/Post';

export const setPostToPrisma = async (postData: PrismaPost) => {
  const prisma = new PrismaClient();

  const result = await prisma.post.create({
    data: postData,
  });
  return Post.fromJson(result);
};

/functions/models/Post.ts

export class Post {
  public readonly authorId: string;
  public readonly imageUrl: string;
  public readonly postId: string;
  public readonly isDeleted: boolean;
  public readonly createdAt: Date;
  public readonly updatedAt: Date;

  constructor(
    authorId: string,
    imageUrl: string,
    postId: string, 
    isDeleted: boolean,
    createdAt: Date,
    updatedAt: Date
  ) {
    this.authorId = authorId;
    this.imageUrl = imageUrl;
    this.postId = postId;
    this.isDeleted = isDeleted;
    this.createdAt = createdAt;
    this.updatedAt = updatedAt;
  }

  public static fromJson = (json: any) => {
    const {
      authorId,
      imageUrl,
      postId,
      isDeleted,
      createdAt,
      updatedAt,
    } = Object.assign({}, json);

    return new Post(
      authorId,
      imageUrl,
      postId,
      isDeleted,
      createdAt,
      updatedAt
    );
  };
}

export type PrismaPost = {
  authorId: string;
  imageUrl: string;
  postId: string;
};

Functionsデプロイ

実装が済んだのでデプロイしたところ、↓のようにエラーが大量発生してデプロイできませんでした。

node_modules/@types/express-serve-static-core/index.d.ts:1187:33 - error TS1005: ';' expected.

1187      * application (which is a `Function`) as its
                                     ~~~~~~~~

node_modules/@types/express-serve-static-core/index.d.ts:1255:1 - error TS1160: Unterminated template literal.

1255 
     


Found 127 errors.

express-serve-static-coreとか特に使ってない認識でしたが、firebase関連のパッケージが依存しているのでは?と思い、最新のバージョンに更新してみたところ解決しました。

参考までにpackage.jsonは↓こんな感じです。

"dependencies": {
    "@prisma/client": "^4.6.1",
    "firebase-admin": "^11.3.0",    // 最新に更新
    "firebase-functions": "^4.1.0"  // 最新に更新
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^3.9.1",
    "@typescript-eslint/parser": "^3.8.0",
    "eslint": "^7.6.0",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-import": "^2.22.0",
    "firebase-functions-test": "^0.2.0",
    "prisma": "^4.6.1",
    "typescript": "^4.9.3"   // 最新に更新
  },

アプリ(Flutter)側の実装

Cloud Functionsのデプロイができたので、あとはアプリ側でこの関数を呼び出してあげればOKです。

今回はパッケージcloud_functionsを使います。

画像のアップロード等絡んで長くなるので詳細は割愛しますが、関数の呼び出し自体はこんな↓感じです。

 Future<void> addPost({
    required String imageUrl,
    required String postId,
  }) async {
    try {
      final callable = FirebaseFunctions.instanceFor(region: 'asia-northeast1')
          .httpsCallable('addPost');
      await callable.call({
        'imageUrl': imageUrl,
        'postId': postId,
      });
    } on Exception catch (_) {
      rethrow;
    }
  }

Cloud Functionsの関数はaddPostという名前にしているので、.httpsCallable('addPost')とすることで呼び出すことができます。

アプリを起動して登録操作し、

npx prisma studio

で確認したところ、無事データが登録されていました🎉

さいごに

無事にFirestore → Firebase Cloud Functions / PlanetScale / Prisma に置き換えることができました。

Cloud FunctionsとPlanetScaleを使うことでサーバーレスでRDBを扱うことができますし、低予算で試すことができるので、個人開発レベルでの導入もオススメかなと思います。

とはいえ、Firestoreが便利なことに変わりはないので、ユースケースに応じて組み合わせて使うのがよさそうです。

だいぶ省略したので、ご質問等あればお気軽にコメントください!

参考記事

https://zenn.dev/nbr41to/articles/adabca83b2e6ea

Discussion