今回は、Bun + TypeScriptで構築したアプリケーション上でサーバーレスDBである「Neon」を使って、簡単な環境構築と疎通までの実装についてまとめていきます。サーバーレスアプリケーションの開発に役立つ知見が得られるかと思いますので、参考にしていただければと思います。
Neonについて
Neonとは
Neon は、Rust言語で作られたクラウド上で動作するサーバーレスの PostgreSQL プラットフォームです。従来の PostgreSQL データベースソフトウェアと同じような使い方ができますが、サーバーを準備したり運用する必要がないため、フルマネージドサービスとして利用可能です。
Neonの特徴
- サーバー運用が一切不要なフルマネージドサービス
- 利用量に応じて自動でリソースのスケーリングが行われる
- データのレプリケーションにより高い可用性を実現
- ブランチ機能で本番環境に影響を与えずにデータ編集可能
- オートサスペンド機能で一定期間アクセスがないとストレージ層だけに切り替わり、コストを最小化
- PostgreSQLプロトコルに完全準拠しているため、既存のツール・アプリと連携可能
無料プランについて
- 0.5GiBのストレージ容量
- リソース制限: 0.25vCPU、1GB RAM
- 1つのプロジェクトで最大10ブランチまで作成可能
- プロジェクト共有機能、論理レプリケーション機能が利用可能
- コミュニティサポートが提供される
無料プランでも、ブランチ機能やプロジェクト共有、論理レプリケーションなどの主要機能が利用できます。CPUやRAMなどのリソースに一定の制限がありますが、個人開発やPoCの開発などには十分です。
今回の実装でももちろん無料プランで使用していきますので、コストはかかりません。
他のサーバーレスDBについて
他にもデータベース機能に特化したDBaaSやそれらの機能を持ち合わせているサービス(PaaSやBaaS)が展開されています。その一部を紹介いたします。
サービス | 無料枠 | データベースエンジン | 主な特徴 |
---|---|---|---|
Supabase | PostgreSQL | 開発者向け統合機能、リアルタイム同期、Firebaseの代替としての地位 | |
PlanetScale | MySQL | Vitessによる拡張性、バージョン管理、ブランチ機能を持つマネージドサービス | |
Turso | SQLite互換 | 高スケーラビリティ、低レイテンシー、分散配置が可能なlibSQLベース | |
AstroDB | PostgreSQL互換 | 分散型、エッジコンピューティング対応、TypeScriptとDrizzle ORM対応 | |
Cloudflare D1 | SQLite互換 | Cloudflareネットワークへの分散配置、エコシステム内最適化、低レイテンシー |
実装の流れ
開発環境
- Bun:
v1.0.29
- TypeScript:
v5.4.2
- OS:
MacOS Ventura
構築手順
Bun × TypeScriptの開発環境構築
まずはBunとTypeScriptをベースとした基本的なアプリケーションを構築していきます。
以前構築手順を記事にしていますので、よかったら参考にしてください。
で構築したアプリケーションでも問題ありません
Neonデータベースの作成
アカウント作成後、プロジェクトの作成を行います。
「Project name」、「Database name」や「Region」を決めてプロジェクトを作成します。
Neonへの接続設定
接続情報の確認
「Project Dashboard」から接続に必要な情報を確認していきます。
「Connection Details」というエリアの中において、 「Connection string」や「Node.js」と選んで確認することができます。
今回は、「Node.js」を選んで設定を進めていきます。
パッケージのインストール
必要なパッケージをインストールしていきます。
- pg:PostgreSQLデータベースと連携するためのパッケージ。typescriptを使うため、
@type/pg
も併せてインストール。 - ts-dotenv:
.env
ファイルに接続情報を記載し、その.env
ファイルを読み込むためのパッケージ。
$ bun add pg
$ bun add -D @types/pg ts-dotenv
接続情報の追加とClientの作成
NeonコンソールのDashboardに記載されている接続情報をコピーして使用
.env
PGHOST='ep-xxxx-yyyy-zzzz.ap-southeast-1.aws.neon.tech'
PGDATABASE='sandbox'
PGUSER='user_name'
PGPASSWORD='*******'
ENDPOINT_ID='ep-xxxx-yyyy-zzzz'
index.ts
import { Client } from "pg";
import { load } from "ts-dotenv";
const env = load({
PGHOST: String,
PGDATABASE: String,
PGUSER: String,
PGPASSWORD: String,
ENDPOINT_ID: String,
});
export const client = new Client({
user: env.PGUSER,
password: env.PGPASSWORD,
host: env.PGHOST,
database: env.PGDATABASE,
port: 5432,
ssl: true,
});
もしくは、DATABASE_URL
として全ての情報を一つにまとめて使用することも可能
//.envファイルに以下を記載
DATABASE_URL='postgresql://user_name:*******@ep-xxxx-yyyy-zzzz.ap-southeast-1.aws.neon.tech/sandbox?sslmode=require'
const client = new Client({
connectionString: env.DATABASE_URL,
});
疎通確認
とりあえずNeonから作成したdatabaseが取得できるか確認していきます。
import { Client } from "pg";
import { load } from "ts-dotenv";
const env = load({
PGHOST: String,
PGDATABASE: String,
PGUSER: String,
PGPASSWORD: String,
ENDPOINT_ID: String,
});
export const client = new Client({
user: env.PGUSER,
password: env.PGPASSWORD,
host: env.PGHOST,
database: env.PGDATABASE,
port: 5432,
ssl: true,
});
await client.connect();
const server = Bun.serve({
port: 3000,
async fetch(req) {
const databases = await client.query("SELECT datname FROM pg_database");
const result = databases.rows.map((row) => row.datname);
return new Response(`databases: ${JSON.stringify(result)}`);
},
});
console.log(`Listening on http://localhost:${server.port} ..`);
curlコマンドでリクエストを投げると、以下のように作成した「sandbox」が含まれていることが確認できました。
$ curl http://localhost:3000
databases: ["postgres","template1","template0","sandbox"]
@neondatabase/serverless
を使う場合
Neon専用のパッケージも存在していたため、一応こちらでも疎通確認をしてみました。
$ bun add @neondatabase/serverless
import { neon } from '@neondatabase/serverless';
import { load } from 'ts-dotenv';
const env = load({
DATABASE_URL: String,
});
const sql = neon(env.DATABASE_URL);
const server = Bun.serve({
port: 3000,
async fetch(req) {
const databases = await sql("SELECT datname FROM pg_database");
const result = databases.rows.map((row) => row.datname);
return new Response(`databases: ${JSON.stringify(result)}`);
},
});
一応問題なく疎通はできましたが、後ほど実装するsqlコマンドにて、型がサポートされていないため、現時点においては選択肢から外れます。(TypeScript対応のORMパッケージと併せて使用するのが前提かもしれないです)
詳しくはこちら:https://github.com/neondatabase/serverless/issues/59
テーブル作成
次にNeon上でテーブルを作成してみます。
作成するテーブルは「tasks」テーブルとし、テーブルスキーマは以下のように作成していきます。
カラム | 型 | その他 |
---|---|---|
id | number | |
title | string | |
is_completed | boolean | |
created_at | timestamp | デフォルト値:登録時の日本時刻 |
NeonのコンソールでSQLを実行
SQLエディターを用いて実施
CREATE TABLE IF NOT EXISTS tasks
(
id SERIAL PRIMARY KEY,
title VARCHAR(255),
is_completed BOOLEAN,
created_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo')
)
「Tables」を開いて、tasks
テーブルが作成されていればOKです。
アプリケーション側から作成
アプリケーション側から作成する場合は以下のような関数を実行することで作成可能です。
async function createTasksTable() {
await client.query(
`CREATE TABLE IF NOT EXISTS tasks
(
id SERIAL PRIMARY KEY,
title VARCHAR(255),
is_completed BOOLEAN,
created_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo')
)
`
);
}
テストデータ追加
先ほど作成したテーブルにデータを追加していきます。
NeonのコンソールでSQLを実行
SQLエディターを用いて実施
INSERT INTO tasks (title, is_completed)
SELECT
'タスク_' || generate_series(1, 10) as title,
generate_series(1, 10)::int % 2 = 1 as is_completed;
アプリケーション側から作成
async function insertTasks() {
const insertValues = [...Array(10).keys()]
.map((i) => {
const id = i + 1;
const title = `'task_${id}'`;
const isCompleted = id % 2 === 0;
return `(${id}, ${title}, ${isCompleted})`;
})
.join(", ");
await client.query<Task>(
`INSERT INTO tasks (id, title, is_completed) VALUES ${insertValues}`
);
}
アプリケーション側からデータ取得
以下の流れを実装していきます。
- DBの初期化
- テーブルの削除
- テーブルの作成
- データの挿入
- 全てのデータを取得
- 指定したデータのみ取得
最終的なソースコードは以下
index.ts
import { Client } from "pg";
import { load } from "ts-dotenv";
const env = load({
PGHOST: String,
PGDATABASE: String,
PGUSER: String,
PGPASSWORD: String,
ENDPOINT_ID: String,
});
const client = new Client({
user: env.PGUSER,
password: env.PGPASSWORD,
host: env.PGHOST,
database: env.PGDATABASE,
port: 5432,
ssl: true,
});
type Task = {
id: number;
title: string;
isCompleted: boolean;
createdAt: Date;
};
async function createTasksTable() {
await client.query(
`CREATE TABLE IF NOT EXISTS tasks
(
id SERIAL PRIMARY KEY,
title VARCHAR(255),
is_completed BOOLEAN,
created_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo')
)
`
);
}
async function dropTasksTable() {
await client.query("DROP TABLE IF EXISTS tasks");
}
async function insertTasks() {
const insertValues = [...Array(10).keys()]
.map((i) => {
const id = i + 1;
const title = `'task_${id}'`;
const isCompleted = id % 2 === 0;
return `(${id}, ${title}, ${isCompleted})`;
})
.join(", ");
await client.query<Task>(
`INSERT INTO tasks (id, title, is_completed) VALUES ${insertValues}`
);
}
async function initDB() {
await dropTasksTable();
await createTasksTable();
await insertTasks();
}
async function fetchAllTasks(): Promise<Task[]> {
const tasks = await client.query<Task>("SELECT * FROM tasks");
return tasks.rows;
}
async function fetchTaskBy(id: number): Promise<Task> {
const tasks = await client.query<Task>(
`SELECT * FROM tasks WHERE id = ${id}`
);
return tasks.rows[0];
}
await client.connect();
const server = Bun.serve({
port: 3000,
async fetch(req) {
await initDB();
const tasks = await fetchAllTasks();
const task = await fetchTaskBy(1);
return new Response(
`task: ${JSON.stringify(task)}\ntasks: ${JSON.stringify(tasks)}`
);
},
});
console.log(`Listening on http://localhost:${server.port} ..`);
これでサーバーを起動させて、リクエストを投げると正常にデータを取得できました。
再度実行すると、created_atの日時が変わっていることも確認できるため、初期化も無事できていそうです。
$ curl http://localhost:3000
task: {"id":1,"title":"task_1","is_completed":false,"created_at":"2024-03-17T08:02:05.274Z"}
tasks: [{"id":1,"title":"task_1","is_completed":false,"created_at":"2024-03-17T08:02:05.274Z"},{"id":2,"title":"task_2","is_completed":true,"created_at":"2024-03-17T08:02:05.274Z"},{"id":3,"title":"task_3","is_completed":false,"created_at":"2024-03-17T08:02:05.274Z"},{"id":4,"title":"task_4","is_completed":true,"created_at":"2024-03-17T08:02:05.274Z"},{"id":5,"title":"task_5","is_completed":false,"created_at":"2024-03-17T08:02:05.274Z"},{"id":6,"title":"task_6","is_completed":true,"created_at":"2024-03-17T08:02:05.274Z"},{"id":7,"title":"task_7","is_completed":false,"created_at":"2024-03-17T08:02:05.274Z"},{"id":8,"title":"task_8","is_completed":true,"created_at":"2024-03-17T08:02:05.274Z"},{"id":9,"title":"task_9","is_completed":false,"created_at":"2024-03-17T08:02:05.274Z"},{"id":10,"title":"task_10","is_completed":true,"created_at":"2024-03-17T08:02:05.274Z"}]
その他機能
最後に、Neonの一部機能にも触れて終わりにしたいと思います。
ブランチ機能
Neonのブランチ機能は、開発、テスト、その他の目的でデータを迅速かつコスト効率良く分岐させることができる機能です。
Gitをイメージするとわかりやすいですが、コピーはできてもマージはできない点に注意してください。
主なユースケースは以下
- 開発: 本番データベースのブランチを作成し、開発者が自由に変更を加えることが可能。ブランチは親ブランチのデータをすべて含んで作成されるため、開発データベースをデプロイおよび維持するための設定時間が不要。
- テスト: スキーマ変更のテスト、新しいクエリの検証、または本番環境にデプロイする前にテストするために使用。本番データに影響はないため、存分に検証ができる。
- データ復旧: 意図しない削除やその他のイベントによりデータを失った場合、過去からブランチを作成して失われたデータを復旧することができる。別ブランチをmainのブランチとして入れ替えることも可能。
SQLエディター
前にも紹介しましたが、Neonのコンソール上からSQLを実行することも可能です。
主な特徴は以下です
- よく使うSQLに関しては登録しておくことが可能
- SQLの実行対象のブランチ、データベースを切り替えられる
- ExplainやAnalyzeといったお馴染みにコマンドを使用してクエリパフォーマンスのチェックも可能
まとめ
Node.js × TypeScript × Neonのサーバーレスデータベース環境を構築し、簡単な疎通確認まで実装できました。
Neonに関してもいつまで無料枠が存在するのかわかりませんが、個人開発をする上では非常にありがたいサービスです。
今後もNeonに注目していきたいと思います。