Hono.js / Cloud Run / Cloud SQL / Prisma構成でのDocker環境構築メモ
What
Hono.js / Cloud Run / Cloud SQL / PrismaでデータベースをクエリしJSONを作成するAPIを作る際の環境構築メモ。
Motivation
Ruby on Railsで動いているバックエンドの移行先としての検証。
既存のRailsアプリは責務が多岐に渡り、環境構築、Rspec作成、バージョンアップが困難なため、軽いAPIに分化させるための選択肢としてHono.jsを検討。
Hono.jsのセットアップ〜デプロイ先の検討〜
- Cloudflare Workers
- Cloudflare Pages
- Deno
- Bun
- Vercel
- AWS Lambda
- Lambda@Edge
など。
データベースにCloudSQL(MySQL)を使う、という条件で絞り込まれる。
できればCloudflare Workersを採用したいが、Edge RuntimeでPrismaを使うためにはPrisma Accelerateが必要。
まだ成熟していないのと、費用が気になってくるため見送り。
HyperdriveはMySQLがcoming soonなので使えない。
次善としてCloudSQLを使うので、デプロイ先はGoogle Cloud系のサービスにしたい。
Cloud Functions for Firebase / Cloud Run Functionsは、Hono.jsのドキュメント、コード例が少ないので、回避し、コンテナに入れてCloud Runで使うことにした。
Node.js用セットアップ
下記のドキュメントにしたがってNode.jsテンプレートでpnpm create hono
する。
下の方に書いてある、下記を忘れないようにする。
- Add "outDir": "./dist" to the compilerOptions section tsconfig.json.
- Add "exclude": ["node_modules"] to tsconfig.json.
- Add "build": "tsc" to script section of package.json.
- Run npm install typescript --save-dev.
- Add "type": "module" to package.json.
Dockerfile
Cloud Run用のDockerfile
と開発用のDockerfile.dev
の2つ作った。
ドキュメント掲載のものからの差分は以下。
- pnpmを使う
- マルチステージで2回pnpmを入れているのは直したい。
- prsima関連の処理
- portを8080に。(Cloud Run用)
Dockerfile
FROM node:20-alpine AS base
FROM base AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package*json tsconfig.json src prisma ./
RUN pnpm install
RUN pnpm build
RUN pnpm prisma generate
FROM base AS runner
WORKDIR /app
RUN npm install -g pnpm
# Userの設定
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 hono
COPY /app/node_modules /app/node_modules
COPY /app/dist /app/dist
COPY /app/package.json /app/package.json
USER hono
EXPOSE 8080
CMD ["pnpm", "start"]
Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package*.json ./
RUN pnpm install
# ソースコードはvolume mount
CMD ["pnpm", "dev"]
compose.yml
こちらもテスト用DBとCloudSQL(開発用)に接続する2つを作る。
CloudSQLに繋ぐバージョン
Cloud SQL Auth ProxyのDockerイメージがあるので、それを使う。
注意点として、V1とV2が記法の差異があるので確認しておく。(ネットの記事ではV1が多い)
事前準備
Cloud SQLにアクセス権のあるサービスアカウントのキーをコンテナからアクセスできるようにしておく。
今回は**Application Default Credentials (ADC)**を使った。
$gcloud auth login
これで~/.config/gcloud/application_default_credentials.json
が生成される。
これをプロジェクトのcredentials/application_default_credentials.json
として保管し、mountする。[1]
--credentials-file=/credentials/application_default_credentials.json
で認証キーのありかを指定する。また、別コンテナからアクセスできるように--address 0.0.0.0
を指定。
services:
sql_proxy:
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:latest
volumes:
- ./credentials/:/credentials
ports:
- "127.0.0.1:3306:3306" # localhost限定でバインド
restart: always
command: --address 0.0.0.0 <project_name>:<region>:<instance_name> --credentials-file=/credentials/application_default_credentials.json
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
environment:
- DATABASE_URL=${DATABASE_URL}
depends_on:
- sql_proxy
volumes:
- .:/app
- /app/node_modules
ローカルのMySQLを立てる
こちらはより簡単。パスワード等、ベタ打ち。
DATABASE_URL
にrootを使っているのがよろしくないが、参考記事を見てもどうしてもprisma migrate dev
が通らなくて諦めた。
services:
db:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: testdb
MYSQL_USER: testuser
MYSQL_PASSWORD: testpassword
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 10s
retries: 10
seminar-list-backend-test:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
environment:
- DATABASE_URL=mysql://root:rootpassword@db:3306/testdb
- ALLOW_INTEGRATION_TEST=true # vitest環境で誤ってDBテストを回さないようにするため。
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
- /app/node_modules
volumes:
mysql_data:
-
volume mountしている記事がほとんどだったので従ったが、
--mount=type=secret
などよりセキュアなやり方はありそう。 ↩︎
Prisma Schema作成
docker compose up
でCloud SQL用のコンテナ群を立てたら、下記に従ってSchemaを落としてくる。
具体的には.env
のDATABASE_URL
は以下になる。
$mysql://<user_name>:<password>@sql_proxy:3306/<db_name>
@sql_proxy
とcompose.yml
で指定したサービス名になる。
package.json
先に掲示すると、package.json
は下記のような感じ。(工事中)
テスト環境は隔離したいので、container:~
に分けているが、少し見苦しい感じになっている。
VSCodeでDev Containerを使うのも選択肢。
{
"name": "seminar-list-backend",
"type": "module",
"scripts": {
"dev": "npx prisma generate & tsx watch src/index.ts",
"build": "tsc",
"test": "pnpm vitest",
"start": "node dist/index.js",
"migrate": "prisma migrate dev",
"generate": "prisma generate",
"container:up": "docker-compose -f compose.test.yml up -d",
"container:down": "docker-compose -f compose.test.yml down",
"container:remove": "docker-compose -f compose.test.yml down -v",
"container:install": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm install",
"container:migrate": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm migrate",
"container:generate": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm generate",
"container:setup": "pnpm container:install && pnpm container:migrate && pnpm container:generate",
"container:test": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm test"
},
"dependencies": {
"@hono/node-server": "^1.13.3",
"@hono/swagger-ui": "^0.4.1",
"@hono/zod-openapi": "^0.16.4",
"@prisma/client": "^5.21.1",
"hono": "^4.6.8",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@quramy/prisma-fabbrica": "^2.2.1",
"@types/node": "^20.11.17",
"prisma": "^5.21.1",
"tsx": "^4.7.1",
"typescript": "^5.6.3",
"vitest": "^2.1.4",
"vitest-environment-vprisma": "^1.3.0"
}
}
@hono/zod-openapi
ValidationとOpenAPI Specが一緒に書ける。
@quramy/prisma-fabbrica
テストデータ作成用。
vitest-environment-vprisma
jest-prismaのVitest版。
テストごとにrollbackしてくれて、実際のデータを用いながらデータベースを汚さず、分離(isolation)を維持したテストができる。
Cloud Runへのデプロイについて
デプロイについて早めに。
Cloud Runなのでコンテナが起動できればgcloud run deploy <options>
でだいたい問題なく動くが、CloudSQLへの接続で設定が必要。
- サービスアカウント
- 別プロジェクトのCloudSQLを使うケースなどでは、
Default compute service account
では権限が足りないため、指定が必要。(--service-account
)
- 別プロジェクトのCloudSQLを使うケースなどでは、
- Cloud Runでの接続設定
-
DATABASE_URL
の変更- socket通信にする。
- https://zenn.dev/link/comments/befc1272ab1a6d
- 具体的には
mysql://<username>:<password>@localhost/<db_name>?socket=/cloudsql/<接続名>
- socket通信にする。
Cloud Runへのアクセス(gcloud run services proxy)
publicなAPIであっても開発環境のCloud Runにはallow-unauthenticated
はNoにし、認証なしアクセスは禁止する。
gcloud auth login
してあれば、
$gcloud run services proxy my-service --port=8080
として、localhostをCloud Runのサービスにproxyできる。
Hono.jsを書く
ここからはベストプラクティスなどを見ながらAPIを書いていく。
下記の記事を参考に、Honoインスタンスをシングルトンで引き回し、routeを登録していく形式とした。
factory
以下のようにしてみた。
HonoではなくOpenAPIHono
の方のインスタンスを使う。
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
import { logger } from "hono/logger";
import { secureHeaders } from "hono/secure-headers";
export const createApp = () => {
const app = new OpenAPIHono();
app.use(logger())
app.use(secureHeaders())
app.onError(handleError);
/**
* Swagger UI
*/
app.get("/ui", swaggerUI({ url: "/doc" }));
app.doc("/doc", {
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "バックエンドAPI",
description: "Hono.jsで作ってみるバックエンドAPI",
},
});
return app;
}
export type App = ReturnType<typeof createApp>;
/**
* グローバルエラーハンドリング
*/
function handleError(err: Error) {
return new Response(JSON.stringify({
message: 'エラーが発生しました',
error: err.message
}), {
status: 500,
headers: {
'Content-Type': 'application/json'
}
});
}
prismaも後々のmockも考えてfactory関数経由にしたいので、以下のように作った。
import { PrismaClient } from '@prisma/client'
/**
* PrismaClientを取得する
* @param database_url 環境変数のDATABASE_URL
*/
export const getPrisma = (database_url: string | undefined) => {
if (!database_url) {
throw new Error("DATABASE_URL is not set")
}
const prisma = new PrismaClient({
datasourceUrl: database_url,
})
return prisma
}
コネクション管理のため、これもシングルトンにしたり、contextに注入したりした方が良いのかな、と思ったが、ドキュメントでは上記のような実装が推奨だった。
Prismaの方には「faasでは、handlerの外に書いてね」とあるので、ちょっと悩みどころ。
contextに入れるかどうかの議論は下記があった。
index.ts
index.ts
は綺麗に保ちたいので、下記のようにした。
/seminar
というエンドポイントを生やすために、seminarApi(app)
という関数を足す。
この形式にしておくと、整然とエンドポイントを足していくことができるので、書き味が良さそう。
これも下記で紹介されていたやり方。
import { serve } from '@hono/node-server'
import { seminarApi } from './routes/seminar/index.js'
import { createApp } from './factory/createApp.js'
const app = createApp()
seminarApi(app)
const port = 8080
console.log(`Server is running on http://localhost:${port}`)
serve({
fetch: app.fetch,
port
})
portはベタ打ちにしているが、環境変数から取った方がベター。
src/routes/seminar/index.ts
上記のseminarApi
の実装は下記のようになっている。
index.ts
と同様のことを行い、/seminar
以下のエンドポイントを生やしていく。
import { teacherIndexApi } from "./teachers/index.js";
import { teacherIdApi } from "./teachers/id.js";
import { holdsApi } from "./holds/index.js";
import type { App } from "../../factory/createApp.js";
/**
* seminar/のAPIを登録する。
*/
export const seminarApi = (app: App) => {
teacherIndexApi(app);
teacherIdApi(app);
holdsApi(app);
}
こうすることで階層的にエンドポイントを生やしていけるが、too muchな感もある。
HonoXだとFile-based routingできるようだが、詳しくは調べなかった。
src/routes/seminar/teachers/index.ts
実際のエンドポイントを定義する。
import { createRoute } from "@hono/zod-openapi";
import type { App } from "../../../factory/createApp.js";
import { env } from "hono/adapter";
import type { APP_ENV } from "../../../types.js";
import { getPrisma } from "../../../factory/getPrisma.js";
import type { PrismaClient } from "@prisma/client";
import { TeachersIndexResponseSchema } from "../../../schema/seminar/teachers.js";
const teacherIndexRoute = createRoute({
method: "get",
path: "/seminar/teachers",
description: "講師一覧を取得する",
responses: {
200: {
content: {
"application/json": {
schema: TeachersIndexResponseSchema,
},
},
description: "Returns the teacher list",
},
404: {
content: {
"application/json": {
schema: {
message: "Not Found",
},
},
},
description: "Teacher list not found",
},
},
});
/**
* 講師一覧を取得する
*/
export const teacherIndexApi = (app: App) => {
app.openapi(teacherIndexRoute, async (c) => {
const databaseUrl = env<APP_ENV>(c).DATABASE_URL;
const prisma = getPrisma(databaseUrl);
const seminarTeachers = await getSeminarTeachers(prisma);
return c.json(seminarTeachers, 200);
});
}
/**
* 講師一覧を取得する
*/
async function getSeminarTeachers(prisma: PrismaClient) {
const seminarTeachers = await prisma.seminar_teachers.findMany({
include: {
judge: {
select: {
ptna_id: true,
user: {
select: {
name: true,
name_kana: true
}
}
}
}
}
});
return seminarTeachers.map((seminarTeacher) => ({
name: seminarTeacher.judge.user?.name ?? "",
name_kana: seminarTeacher.judge.user?.name_kana ?? "",
ptna_id: seminarTeacher.judge.ptna_id,
teacher_point: seminarTeacher.teacher_point || 0
}))
}
ここでnpx prisma generate
で型エラーを見たり、schema.tsのリレーションを整えたり、少し苦労。
Railsのmigrationと併用したい場合、どう管理するのが良いのだろう...
Zod OpenAPI用のSchema
ファイルが大きくなってしまうので、切り分けた。
z
のimport元は@hono/zod-openapi
でzod
ではない模様。
import { z } from "@hono/zod-openapi";
const TeacherSchema = z.object({
name: z.string(),
name_kana: z.string(),
ptna_id: z.number().nullable(),
teacher_point: z.number()
});
export const TeachersIndexResponseSchema = TeacherSchema.array();
requestの検証
requestの検証をしたい場合の一例。path paramsの場合。
request: {
params: z.object({
id: z.string().refine((val) => !Number.isNaN(Number(val)), {
message: "Invalid Id, must be a number"
}).transform((val) => Number(val)).describe("userId")
})
},
Test
Vitestを使ったテスト環境を作っていく。テストにあたってはPrismaClientをスタブせず、Docker上のMySQLにテストデータを与える統合テストを行う。
Prismaでの統合テストについては、決定版はなさそうなので、調べて決めるしかない。
この子達で戦うことにした。
"vitest": "^2.1.4",
"vitest-environment-vprisma": "^1.3.0"
"@quramy/prisma-fabbrica": "^2.2.1",
tests/vitest.setup.ts
-
vitest-environment-vprisma
の差し込み-
prisma
のfactoryをmockすることで実現
-
-
@quramy/prisma-fabbrica
のinitialize
を行うセットアップ関数を作成。
不要かもしれないが、compose
ファイルの指定を間違えてAuth Proxyで繋がっているCloudSQLを向いてしまうのが怖いので、ALLOW_INTEGRATION_TEST
という独自環境変数を検証して、明示的に指定がない場合は、エラーを出すようにした。(rails_helper
でのRAILS_ENVの検証みたいな...?)
import { initialize } from "../src/__generated__/fabbrica/index.js"
vi.mock("../src/factory/getPrisma.js", () => {
console.log(`ALLOW_INTEGRATION_TEST: ${process.env.ALLOW_INTEGRATION_TEST}`)
if (!process.env.ALLOW_INTEGRATION_TEST) {
throw new Error("Integration test is not allowed. Please set ALLOW_INTEGRATION_TEST=true")
}
console.log("mocking prisma...")
return {
getPrisma: vi.fn(() => vPrisma.client),
}
})
const prisma = vPrisma.client;
initialize({ prisma });
vitest.config.ts
しょぼいが下記。databaseUrl
も怖いのであえて指定している。デフォルトはprocess.env.DATABASE_URL
になる。
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
clearMocks: true,
globals: true,
environment: "vprisma",
setupFiles: ["vitest-environment-vprisma/setup", "tests/vitest.setup.ts"],
environmentOptions: {
vprisma: {
databaseUrl: "mysql://root:rootpassword@db:3306/testdb", // 誤削除防止のため、process.envではなく、文字列で指定
verboseQuery: false,
// Commit without rolling back the transaction. (default: false)
disableRollback: false,
},
},
},
})
テストを書いてみる
faker.js
を使うのが良いかもしれないが、シンプルにseq
で通し番号をつけてみた。
import { describe } from "node:test"
import { beforeEach, expect, test, vi } from "vitest"
import { createApp, type App } from "../../../src/factory/createApp.js"
import { seminarApi } from "../../../src/routes/seminar/index.js"
import { definejudgesFactory, defineseminar_teachersFactory, defineusersFactory, initialize } from "../../../src/__generated__/fabbrica/index.js"
describe("seminar/teachers", () => {
let app: App;
beforeEach(() => {
// 各テスト前にモックをリセット
vi.clearAllMocks();
app = createApp();
seminarApi(app);
})
test("GET /seminar/teachers", async () => {
const UserFactory = defineusersFactory({
defaultData: ({ seq }) => ({
name: `user_${seq}`,
name_kana: "ユーザー",
ptna_id: 1000 + seq,
})
});
const JudgeFactory = definejudgesFactory({
defaultData: {
user: UserFactory
}
});
const SeminarTeachersFactory = defineseminar_teachersFactory({
defaultData: async ({ seq }) => ({
judge: JudgeFactory,
teacher_point: seq + 1
})
});
await SeminarTeachersFactory.createList(10);
const response = await app.request("/seminar/teachers");
// セミナー講師一覧が取得できること
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toHaveLength(10);
body.forEach((teacher: unknown, index: number) => {
expect(teacher).toEqual({
name: `user_${index}`,
name_kana: 'ユーザー',
ptna_id: 1000 + index,
teacher_point: index + 1
});
});
});
})
これを実行するとテストがpassした。
ログを入れてみると、テストデータで想定のJSONが返ってきていた。
テスト用DBを見てみると、vitest-environment-vprisma
のおかげでレコードはできていない。
disableRollbackをtrueにしてみる。
試しに、vitest.config.ts
の設定を変えて、データが残るようにしてみる。
environmentOptions: {
vprisma: {
databaseUrl: "mysql://root:rootpassword@db:3306/testdb", // 誤削除防止のため、process.envではなく、文字列で指定
verboseQuery: false,
// Commit without rolling back the transaction. (default: false)
- disableRollback: false,
+ disableRollback: true,
},
},
これでテストを再度実行してみると、レコードができる。
しかし、これだと次回実行時にエラーが出る。
- ユニーク規制の違反
- レコード数違いによるテスト失敗
これを防ぐためにvitest-environment-vprisma
を使うので、基本はdisableRollback
はfalse
(デフォルト)にする。