Cloud Functions for firebaseでPrismaを使ってみる[Dockerでローカル開発環境構築]
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、とのことです。
大きな特徴はprisma.schema
という独自のDSLを使って、SQL文や型定義を生成することです。
(ORMとなったのはPrisma2からで、Prsima1まではGraphQLサーバーを構築できるフレームワークだったようです。)
クエリの結果の型もこのスキーマファイルから生成してくれるので、非常に型安全な実装ができるORMだと思います。
下の方に実装があるのでそちらを参照してみてください。
firebase プロジェクトの作成 & Dockerの環境構築
まず、Firebase CLIが入っていない場合は、npm install -g firebase-tools
でインストールしましょう。
そして、firebase init
でfirebaseプロジェクトを作成します。
対話型で色々選択するのですが、今回はfunctionだけなので、Functions
とtypescript
を選択しましょう。
すると以下のようなファイル構成ができると思います
├── 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を使います。
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の設定ですね。
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
[client]
default-character-set=utf8mb4
そして次は、ルート直下に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_PROJECT
とFIREBASE_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.sql
とmigration_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!");
}
}
);
以上が実装になります!
以下のリポジトリにコードは上げてあるので、参考にしてみてください。
感想
実装の方は少し駆け足になってしまいましたが、一通りの実装をしてみました。
typescriptのormといえばtypeormが挙げられますが、これに比べてテーブル構造をすべてschema.prisma
で管理する点や、各クエリに対応する型定義をすべて用意してくれる点は使いやすいですね。
ですが、出たてのライブラリということもあり、まだ未対応な機能もいくつかあります。
例えばcascade deleteなんかはprisma.schema内では設定できず、生のsqlを実行して手動で設定する必要があります(参考)。
ここらへんはこれからに期待という感じですね。
今回はローカル環境で動かしただけなので、次はcloud sqlとつなげてデプロイする記事を書いてみようかなと思います。
参考にしたサイト
Discussion