🐏

Cloud Functions for firebaseでPrismaを使ってみる[Dockerでローカル開発環境構築]

2021/08/08に公開

cloud functionsからfirestoreを使うのはとても便利ですが、cloud functionsからRDBを使いたいときがあると思います。

そこで、prismaというtypescript向けのORMがとても使い心地がよく、これをcloud functionsで手軽に使いたいなと思ったので、dockerを使ってそれらをローカルで開発できる環境を構築してみます。

この記事でやること

  • Cloud Functions for Firebaseとmysqlの環境をdockerで構築
  • prismaでfunctionからmysqlにアクセスして、todoリストのAPIを作る。

Prismaとは

Prismaとは、Node.js & Typescript向けの次世代ORM、とのことです。
https://www.prisma.io/

大きな特徴はprisma.schemaという独自のDSLを使って、SQL文や型定義を生成することです。
(ORMとなったのはPrisma2からで、Prsima1まではGraphQLサーバーを構築できるフレームワークだったようです。)

クエリの結果の型もこのスキーマファイルから生成してくれるので、非常に型安全な実装ができるORMだと思います。
下の方に実装があるのでそちらを参照してみてください。

firebase プロジェクトの作成 & Dockerの環境構築

まず、Firebase CLIが入っていない場合は、npm install -g firebase-toolsでインストールしましょう。
そして、firebase initでfirebaseプロジェクトを作成します。
対話型で色々選択するのですが、今回はfunctionだけなので、Functionstypescriptを選択しましょう。
すると以下のようなファイル構成ができると思います

├── firebase.json
├── .firebaserc
├── functions
    ├── lib
    ├── node_modules
    ├── package-lock.json
    ├── package.json
    ├── src
    │   └── index.ts
    ├── tree
    ├── tsconfig.dev.json
    └── tsconfig.json

これができたら、dockerの設定をします。
まずは以下のDockerfileを作成します。場所はどこでもいいですが、今回はdocker/firebaseというディレクトリを作ってその中に書きます

node.jsのイメージは軽量なalpineを使います。

Dockerfile
FROM node:12-alpine

ARG FIREBASE_PROJECT
ARG FIREBASE_TOKEN

RUN apk add --update &&\
  npm install -g firebase-tools

WORKDIR /app

COPY ["./firebase.json", "./"]

RUN firebase use ${FIREBASE_PROJECT} --token ${FIREBASE_TOKEN}

WORKDIR /app/functions

COPY ["./functions/package.json", "./functions/package-lock.json", "./"]

RUN npm install

EXPOSE 4000 5000 5001

そして、今度はdocker/mysqlを作り、その中にmy.cnfというファイルを作ります。これはdockerで立ち上げるmysqlの設定ですね。

my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

[client]
default-character-set=utf8mb4

そして次は、ルート直下にdocker-compose.ymlを作ります。

docker-compose.yml
version: "3.1"

services:
  firebase:
    build:
      context: .
      dockerfile: ./docker/firebase/Dockerfile
      args:
        - FIREBASE_PROJECT=$FIREBASE_PROJECT
        - FIREBASE_TOKEN=$FIREBASE_TOKEN
    image: firebase
    container_name: firebase-prisma
    ports:
      - 4000:4000
      - 5000:5000
      - 5001:5001
    volumes:
      - ./:/app
      - firebase-lib:/app/functions/lib
      - firebase-node_modules:/app/functions/node_modules
    environment:
      - FIREBASE_PROJECT=$FIREBASE_PROJECT
      - FIREBASE_TOKEN=$FIREBASE_TOKEN
    tty: true
  db:
    image: mysql:5.7
    container_name: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: password
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
    restart: always
    volumes:
      - mysql-data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    ports:
    - 3306:3306

volumes:
  firebase-node_modules:
  firebase-lib:
  mysql-data:

これでfirebaseのエミュレーターとmysqlの2つのコンテナが立ち上がります。
FIREBASE_PROJECTFIREBASE_TOKENをfirebaseのコンテナ内の環境変数に渡すことで、firebaseの認証を行っています。
FIREBASE_TOKENは、firebase login:ciというfirebase CLIのコマンドで取得できます(参考)。

次に、.envファイルを作成して、これらの環境変数や、DBの接続情報を設定します。

DATABASE_URL=mysql://root:root@db:3306/<データベース名>
FIREBASE_TOKEN=<firebase login:ciで取れるトークン>
FIREBASE_PROJECT=<firebaseのプロジェクト名>

最後にfunctionsディレクトリ配下で、必要なモジュールをインストールします。

$ npm install typescript ts-node @types/node tsc-watch --save-dev 

tsc-watchはなくても大丈夫ですが、functionsの開発をする際にホットリロードを有効にできるので開発しやすいと思います。

そしてpackage.jsonのscriptsを以下のように編集しましょう。

"scripts": {
  "lint": "eslint --ext .js,.ts .",
  "build": "tsc",
  "serve": "npm run build && npm run emulators",
  "shell": "npm run build && firebase functions:shell",
  "emulators": "tsc-watch --onFirstSuccess 'firebase emulators:start --only functions'",
  "start": "npm run shell",
  "deploy": "firebase deploy --only functions",
  "logs": "firebase functions:log"
}

firebase emulators:startをtsc-watchコマンドで囲うことで監視対象にできるので、ファイル変更があるたびにコンパイルしてくれます。便利!

そして、docker-compose up -dでdockerを立ち上げたあと、docker-compose exec firebase shでfirebaseのコンテナに入ります。
そこで、npm run serveを実行すると、firebaseのエミュレータが立ち上がります。

その後、curlコマンドでhttp://0.0.0.0:5001/<プロジェクト名>/us-central1/helloWorldにアクセスして、「Hello from Firebase!」が返ってくれば、接続成功です!

prismaの環境構築

ここまでで、ディレクトリ構造は以下のようになっています。

├── docker
│   ├── firebase
│   │   └── Dockerfile
│   └── mysql
│       └── my.cnf
├── docker-compose.yml
├── firebase.json
├── .firebaserc
├── functions
    ├── lib
    ├── node_modules
    ├── package-lock.json
    ├── package.json
    ├── src
    │   └── index.ts
    ├── tree
    ├── tsconfig.dev.json
    └── tsconfig.json

次はここに、prismaの設定を追加していきます。
まず、以下のコマンドでprimsaとprisma clientをインストールします

$ npm install prisma --save-dev 
$ npm install @prisma/client

そして、以下のコマンドで、prismaのセットアップをします

npx prisma init

するとfunctions配下に、prismaというディレクトリと、その中にprisma.schemaというファイルが生成されていて、ファイルは以下のような感じになってると思います。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

ここのファイルに色々DBの情報を書き込んでいくことで、スキーマ情報を定義していきます。

prisma migrateでテーブルの作成

今回はTodoアプリを想定しているので、UserとTodoというテーブルを作ります。
ユーザーは複数のTodoを持つという関係です。

prisma.schemaファイルを以下のように書き換えます。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  email     String   @unique
  name      String?
  todos     Todo[]
}

model Todo {
  id         Int      @id @default(autoincrement())
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  deadline   DateTime @db.DateTime
  done       Boolean  @default(false)
  title      String   @db.VarChar(255)
  assignee   User?    @relation(fields: [assigneeId], references: [id])
  assigneeId Int?
}

そしてまた、docker-compose exec firebase shでfirebaseのコンテナに入り、以下のコマンドを実行します。

$ npx prisma migrate dev --create-only --name init

このコマンドにより、schema.prismaの変更を検知して、それをDBに反映させるSQLファイルを生成してくれます。
実行すると、以下のようにmigration.sqlmigration_lock.tomlというファイルができています。

生成されたmigration.sqlの中身は以下のようになっています。

-- CreateTable
CREATE TABLE `User` (
    `id` INTEGER NOT NULL AUTO_INCREMENT,
    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `email` VARCHAR(191) NOT NULL,
    `name` VARCHAR(191),

    UNIQUE INDEX `User.email_unique`(`email`),
    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `Todo` (
    `id` INTEGER NOT NULL AUTO_INCREMENT,
    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updatedAt` DATETIME(3) NOT NULL,
    `deadline` DATETIME(0) NOT NULL,
    `done` BOOLEAN NOT NULL DEFAULT false,
    `title` VARCHAR(255) NOT NULL,
    `assigneeId` INTEGER,

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `Todo` ADD FOREIGN KEY (`assigneeId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

生成された定義で問題なければ、以下のコマンドでこのSQLを実行します。(もし変更したい場合はこのコマンド実行前に修正しましょう。)

$ npx prisma migrate dev

DBのテーブルを見てみると、しっかり作成されているのが確認できます👏

ユーザーのCRUDの実装

次はいよいよAPIの実装をしていきます!
まずはuserの作成と取得です

ユーザー作成(/createUser)
import { PrismaClient } from "@prisma/client";
import * as functions from "firebase-functions";

const prisma = new PrismaClient();

export const createUser = functions.https.onRequest(
  async (request, response) => {
    if (request.method == "POST") {
      await prisma.user.create({
        data: {
          email: request.body.email,
          name: request.body.name,
        },
      });
      response.status(200).send("success!");
    } else {
      response.status(403).send("forbidden!");
    }
  }
);
ユーザー取得(/getUsers)
export const getUsers = functions.https.onRequest(async (request, response) => {
  if (request.method == "GET") {
    const getUsersResponse = await prisma.user.findMany();
    response.status(200).send(getUsersResponse);
  } else {
    response.status(403).send("forbidden!");
  }
});

curlで作ったAPIを叩いてみます

まずはUserの作成。

$ curl -X POST -H "Content-Type: application/json" -d '{"email":"user1", "email":"test@example.com"}' http://0.0.0.0:5001/practice-firebase-prisma/us-central1/createUser
success!

そしてこちらはUserの取得です。

$ curl http://0.0.0.0:5001/practice-firebase-prisma/us-central1/getUsers
[{"id":8,"createdAt":"2021-07-23T06:12:30.447Z","email":"test@example.com","name":null}]%

ちゃんと作成、取得できていることがわかります!

この調子でユーザーの更新、削除も作っていきます。

ユーザー更新(/updateUser)
export const updateUser = functions.https.onRequest(
  async (request, response) => {
    if (request.method == "POST") {
      if (!request.body.userId) {
        response.status(400).send("userId should be passed in parameter");
        return;
      }

      const updateUserResponse = await prisma.user.update({
        where: {
          id: request.body.userId,
        },
        data: {
          email: request.body.email,
          name: request.body.name,
        },
      });
      response.status(200).send(updateUserResponse);
    } else {
      response.status(403).send("forbidden!");
    }
  }
);
ユーザー削除(/deleteUser)
export const deleteUser = functions.https.onRequest(
  async (request, response) => {
    if (request.method == "POST") {
      if (!request.body.userId) {
        response.status(400).send("userId should be passed in parameter");
        return;
      }
      const updateUserResponse = await prisma.user.delete({
        where: {
          id: Number(request.body.userId),
        },
      });
      response.status(200).send(updateUserResponse);
    } else {
      response.status(403).send("forbidden!");
    }
  }
);

TODOのCRUDの実装

次は既にあるUserに、Todoを作って紐付けたり、取得したりをやっていきます。

TODO作成(/createTodo)
export const createTodo = functions.https.onRequest(
  async (request, response) => {
    if (request.method == "POST") {
      await prisma.todo.create({
        data: {
          deadline: request.body.deadline,
          title: request.body.title,
          assignee: {
            connect: {
              id: request.body.userId,
            },
          },
        },
      });
      response.status(200).send("success!");
    } else {
      response.status(403).send("forbidden!");
    }
  }
);

TODO取得(userIdでの絞り込みもあり)(/getTodos)
export const getTodos = functions.https.onRequest(async (request, response) => {
  if (request.method == "GET") {
    const getTodosResponse = await prisma.todo.findMany({
      where: {
        assigneeId: request.params.userId
          ? Number(request.params.userId)
          : undefined,
      },
    });
    response.status(200).send(getTodosResponse);
  } else {
    response.status(403).send("forbidden!");
  }
});

TODO更新(/updateTodo)
export const updateTodo = functions.https.onRequest(
  async (request, response) => {
    if (request.method == "POST") {
      if (!request.body.todoId) {
        response.status(400).send("todoId should be passed in parameter");
        return;
      }

      const updateUserResponse = await prisma.todo.update({
        where: {
          id: request.body.todoId,
        },
        data: {
          title: request.body.title,
          deadline: new Date(request.body.deadline),
          done: request.body.done === "true",
        },
      });
      response.status(200).send(updateUserResponse);
    } else {
      response.status(403).send("forbidden!");
    }
  }
);

TODO削除(/deleteTodo)
export const deleteTodo = functions.https.onRequest(
  async (request, response) => {
    if (request.method == "POST") {
      if (!request.body.todoId) {
        response.status(400).send("todoId should be passed in parameter");
        return;
      }
      const deleteTodoResponse = await prisma.todo.delete({
        where: {
          id: Number(request.body.todoId),
        },
      });
      response.status(200).send(deleteTodoResponse);
    } else {
      response.status(403).send("forbidden!");
    }
  }
);

以上が実装になります!

以下のリポジトリにコードは上げてあるので、参考にしてみてください。
https://github.com/masamichhhhi/practice-firebase-prisma

感想

実装の方は少し駆け足になってしまいましたが、一通りの実装をしてみました。
typescriptのormといえばtypeormが挙げられますが、これに比べてテーブル構造をすべてschema.prismaで管理する点や、各クエリに対応する型定義をすべて用意してくれる点は使いやすいですね。

ですが、出たてのライブラリということもあり、まだ未対応な機能もいくつかあります。
例えばcascade deleteなんかはprisma.schema内では設定できず、生のsqlを実行して手動で設定する必要があります(参考)。

ここらへんはこれからに期待という感じですね。

今回はローカル環境で動かしただけなので、次はcloud sqlとつなげてデプロイする記事を書いてみようかなと思います。

参考にしたサイト

https://github.com/prisma/e2e-tests/tree/dev/platforms-serverless/firebase-functions
https://qiita.com/pannpers/items/244a7e3c18d8c8422e4f
https://zenn.dev/shuneihayakawa/articles/021f4cb06c2b30

Discussion