Next.jsのバックエンドとして便利そうなPostgreSQLの無料BaaS「Supabase」について調べてたことのメモ
Supabaseをどこで知ったか?
- Vercel動かすNext.jsでDB使いたかった
- Vercelのインテグレーションリストを見ていたら、Supabaseを見つけた
- そこで知った。
Supabaseとはどんなもの?
- PostgreSQLのBaaS
- オープンソース
- オープンソースだけどマネージドホスティングサービスがある
- 無料のホスティングプランもある
- データベースに対応したREST APIをはやしてくれる
- オープンソースのPostgRESTを使っている
- Firebaseのようなリアルタイム要素があるっぽい
- ファイルを保存するストレージ機能もあるっぽい
- Firebaseの代替と謳うが、FirebaseはNoSQLで、SupabaseはRDBなので、ワンタッチで置き換えできる代物ではない
- 簡易的な認証・認可の仕組みが備わっている
- サインアップ、メールアドレス確認、ログイン、ログアウト、パスワードリセットなど
- サードパーティ認証に対応: Google, Apple, Facebook, Azure, Twitter, GitHub, ...
- 行ごとに認可できる(RLS: Row Level Security)
- 細かいアクセス権限はPostgreSQLのGrantやRevokeでやる
AWS RDSやGCP CloudSQLとの違い
- Supabaseではデータベースに対応したREST APIが生える
- RDSやCloudSQLはDBのホスティングだけ
CloudSQL+Vercel Functionsの組み合わせはコネクション数がすぐ枯渇する問題がある。FaaSはステートレスなのでリクエストごとにコネクションを新規作成するため。
SupabaseはREST APIでコネクションプーリングしてくれるっぽいので、その心配がなさそう。つまり、DBをラップしたRESTサーバーやpgBouncerのようなサーバーなど、何もサーバを建てずにフロントエンドからDBを気軽に使える
SupabaseでDBを作るのは簡単!東京リージョンもある!
DB名と強いパスワード、リージョンを選ぶだけ。
リージョンは東京も対応しているのが嬉しい。
DBの新規作成は2〜3分かかかる
プロジェクト新規作成して、DBができあがるまで2〜3分待たされる。
Supabaseのテーブルの作成画面
管理画面にはテーブル作成機能がある。シンプルな作り。Airtableとかと使い勝手は似ている。
トラブルシューティング: 管理画面からテーブル作成するとエラーになる!
管理画面でテーブルを作ろうとすると、NOT NULL成約関係のエラーが出てしまう。
いろいろ調べた結果、UIのテーブル作成機能はうまく動作していないらしい。
回避策
回避策として、SQLを直実行できる画面があるので、そこでcreate tabale
すればいいらしい。
感想
新規作成のUIはテーブル名とPrimary Keyを指定するくらいしかできないので、むしろSQL直実行のほうでいいと思ったので、このバグがSupabaseを使わない理由にはならないと思った。
JetBrains IDEのDatabase機能でSupabaseのDBを管理する
- SupabaseのUIがバギーなら、ローカルのPostgreSQLクライアントアプリで操作すればいいと思った。
- JetBrains IDEにDatabase機能が付属している。
- これはPostgreSQLにも対応しているSQL GUIクライアント。
- 今回はこれを使ってSupabaseのDBを操作できるようにしようと思う。
SupabaseのPostgreSQLのURLを調べる
ローカルのSQLクライアントからSupabaseのDBにつなぐために、DBのURLなどを調べる。
Supabaseの「Settings」→「Database」にその情報がある:
JetBrains IDEのDatabase接続設定をする
JetBrains IDEからSupabaseのDBにテーブルを作る
感想
- 普通に問題なく操作できる。
- SSL接続ではなさそうなのが気にはなる。
- 誰かSSL接続する方法を知ってたら教えてほしい
SupabaseのDBにJavaScriptからSELECTする
準備
まず、データベースにテーブルとデータを作る:
create table if not exists posts
(
id serial not null,
title varchar not null,
body text not null
);
insert into posts (title, body)
values ('first post', 'first post body'),
('second post', 'second post body');
APIのURLとAPI Keysのanon, publicのキーを調べる。
JavaScriptからSELECTする
クライアントライブラリをインストールする:
npm install --save @supabase/supabase-js
posts
テーブルをSELECT
するコードを実装する
import { createClient } from "@supabase/supabase-js";
const SUPABASE_KEY = "...";
const SUPABASE_URL = "...";
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
async function main() {
const { data: posts, error } = await supabase.from("posts").select("*");
console.log(posts);
}
main().catch(console.error);
実行する
npx ts-node test.ts
[
{ id: 1, title: 'first post', body: 'first post body' },
{ id: 2, title: 'second post', body: 'second post body' }
]
できた。
Supabaseの認証まわりのデータはどこにあるのか?
auth
スキーマにある。
Supabaseのユーザーデータはどこにあるか?
auth.users
テーブルにある。
DB作りたてのときはユーザーは誰もいない。
Supabaseにユーザーを登録するには?
SupabaseのUIからユーザーを追加できる。
管理画面の「Authentication」→「Users」→「Invite」からメールアドレスを指定して招待する。
メールが届くので「Accept the invite」をクリックする。
するとユーザーが登録された状態になる。
この状態では、パスワードは誰も知らないので、まだログインはできない。
Supabaseのデータベースの権限はデフォルトでどうなっているか?
Supabase上に作ったテーブルは基本的に誰でも読み書きできる状態になっている。
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!
);
async function insert() {
const { data, error } = await supabase
.from("posts")
.insert([{ title: "投稿タイトル", body: "本文" }]);
console.log("insert", { data, error });
}
async function select() {
const { data, error } = await supabase.from("posts").select();
console.log("select", { data, error });
}
insert().then(select).catch(console.error);
実行結果
insert { data: [ { id: 5, title: '投稿タイトル', body: '本文' } ], error: null }
select { data: [ { id: 5, title: '投稿タイトル', body: '本文' } ], error: null }
テーブルや行へのアクセス権限は、ポリシーを設定してあげる必要がある。
SupabaseのクライアントAPIキーとサービスキーの違い
- SupabaseにはClient API Keysと、Service Keysの二種類のキーがある
SUPABASE_KEY
)
Client API Keys (- データベースに「匿名ユーザー」としてアクセスするキー
- ユーザーが認証されるまでに使うキー。
- つまり、認証系のAPIはこのキーを使って叩くことになる。
- 認証時にユーザーごとにキーが与えられるので、認証後はユーザーのキーを使ってAPIを叩く。
- Client API Keysは、フロントエンドで使うことを想定されたキーで、フロントエンドに公開しても大丈夫なキー。
- JAMStackなアプリでは、このClient API Keysを用いる。
SERVICE_KEY
)
Service Keys (- データベースにフルアクセスできるキー
- Supabaseでは「セキュリティーポリシー」があるが、Service Keysはこのセキュリティーポリシーを無視してアクセスできる強力な権限がある。
- 取り扱いに細心の注意を払うべきキー
- 絶対にクライアントやブラウザに公開してはいけないキー
キーのありか
Client API KeysとService Keysは「Settings」→「API」→「API Keys」にて調べられる。
SupabaseでAPI経由でユーザーを登録する
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!
);
async function createUser() {
const { data, error } = await supabase.auth.signUp({
email: "alice@example.com",
password: "p@ssW0rd123456",
});
console.log({ data, error });
}
createUser().catch(console.error);
実行結果
{
data: {
id: 'c6dbd971-6811-45b9-a4a1-b83004138890',
aud: 'authenticated',
role: 'authenticated',
email: 'alice@example.com',
confirmation_sent_at: '2021-07-14T00:27:09.817413531Z',
app_metadata: { provider: 'email' },
user_metadata: null,
created_at: '2021-07-14T00:27:09.814313Z',
updated_at: '2021-07-14T00:27:12.194661Z'
},
error: null
}
登録するとメールに登録確認メールが届く。
ユーザーがこのメールで「Confirm your email」をクリックすると登録が完了する。
Supabase APIでユーザーがログインする方法
supabase.auth.signIn
を実行するとユーザーがログインできるようになる。
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!
);
async function logIn() {
const { user, error } = await supabase.auth.signIn({
email: "alice@example.com",
password: "p@ssW0rd123456",
});
console.log({ user, error });
}
logIn().catch(console.error);
認証済みユーザーだけがテーブルをCRUDできるようにするには?
- Supabaseのデフォルトは全テーブルが匿名ユーザーに対して読み書き可になっている。
- 実際のアプリ(業務アプリを想定)では、データは認証済みユーザーだけが見れるようにしたい。
どうしたら、テーブル全体をprivateにできるか?
そもそもの権限デフォルト設定を見直す
PostgreSQLには、デフォルト権限設定があり、この設定に従ってテーブルなどの作成時に、各テーブルに権限が設定される。したがって、そもそものデフォルト権限を見直しておかないと、新たにテーブルを作ったときに思わずデータ公開が起こってしまう恐れがある。
Supabaseのデフォルト権限設定は、デフォルトの権限を確認するSQLで確認できる。見てみよう。
ご覧の通り、未認証ユーザーにあたるanon
ユーザーのデフォルト権限は、CRUDなんでもできるモードになっている。
ここから、テーブル、関数、シーケンスの全権限を取るようにする。
alter default privileges in schema public revoke all on tables from anon;
alter default privileges in schema public revoke all on functions from anon;
alter default privileges in schema public revoke all on sequences from anon;
これで、新規にテーブルなどを作ったときに、anon
にCRUD全権が与えられることがなくなる。
既存テーブルの権限の見直し
上は「今後の」テーブルの権限を見直しただけだ。既存のテーブルはまだ、anon
がCRUDできるようになっている。これを見直していこう。
まず、現状の権限がどうなっているかを権限を確認するSQLを使って確認していく。
見てのとおり、anon
にCRUD全権がある。
次のクエリを実行して、この全権を剥奪する:
revoke all privileges on all tables in schema public from anon;
これでanon
からは何も見えなくなり、何も変更できなくなる。
さらに、関数やシーケンスの権限なども剥奪しておく必要があるが、シーケンスはSupabase API越しにはいじれないのと、関数は使ってなかったのでここでは割愛する。
anonが何もできなくなっていることを確認する
次のコードを実行して、CRUD操作がanon権限でできなくなっていることを確認する:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!
);
async function anonymousUsersOperation() {
// INSERT
const { data: data1, error: error1 } = await supabase
.from("posts")
.insert({ title: "匿名ユーザーのINSERT", body: "テスト" });
console.log({ data1, error1 });
// SELECT
const { data: data2, error: error2 } = await supabase.from("posts").select();
console.log({ data2, error2 });
// UPDATE
const { data: data3, error: error3 } = await supabase
.from("posts")
.update({ title: "匿名ユーザーのUPDATE" })
.match({ id: 1 });
console.log({ data3, error3 });
// DELETE
const { data: data4, error: error4 } = await supabase
.from("posts")
.delete()
.match({ id: 1 });
console.log({ data4, error4 });
}
anonymousUsersOperation().catch(console.error);
実行結果としては、すべての操作でpermission denied for table postsとなり、期待通りanon
が何もできなくなっているのが分かる:
{
data1: null,
error1: {
hint: null,
details: null,
code: '42501',
message: 'permission denied for table posts'
}
}
{
data2: null,
error2: {
hint: null,
details: null,
code: '42501',
message: 'permission denied for table posts'
}
}
{
data3: null,
error3: {
hint: null,
details: null,
code: '42501',
message: 'permission denied for table posts'
}
}
{
data4: null,
error4: {
hint: null,
details: null,
code: '42501',
message: 'permission denied for table posts'
}
}
認証済みユーザはちゃんとCRUDできるか?
テストデータとして、初期のテーブルは次のようになっている:
これに対して、認証済みユーザーでCRUD操作をする:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_KEY!
);
async function authenticatedUsersOperation() {
await supabase.auth.signIn({
email: "alice@example.com",
password: "p@ssW0rd123456",
});
// INSERT
const { data: data1, error: error1 } = await supabase
.from("posts")
.insert({ title: "認証済みユーザーのINSERT", body: "テスト" });
console.log({ data1, error1 });
// SELECT
const { data: data2, error: error2 } = await supabase.from("posts").select();
console.log({ data2, error2 });
// UPDATE
const { data: data3, error: error3 } = await supabase
.from("posts")
.update({ title: "認証済みユーザーのUPDATE" })
.match({ id: 1 });
console.log({ data3, error3 });
// DELETE
const { data: data4, error: error4 } = await supabase
.from("posts")
.delete()
.match({ id: 1 });
console.log({ data4, error4 });
}
authenticatedUsersOperation().catch(console.error);
実行結果: エラーなくすべての操作が完了した。
{
data1: [ { id: 5, title: '認証済みユーザーのINSERT', body: 'テスト' } ],
error1: null
}
{
data2: [
{ id: 1, title: '投稿1', body: '' },
{ id: 2, title: '投稿2', body: '' },
{ id: 5, title: '認証済みユーザーのINSERT', body: 'テスト' }
],
error2: null
}
{
data3: [ { id: 1, title: '認証済みユーザーのUPDATE', body: '' } ],
error3: null
}
{
data4: [ { id: 1, title: '認証済みユーザーのUPDATE', body: '' } ],
error4: null
}
操作後のテーブルの状態は次のようになった:
結論: anon
の全権を剥奪しても、認証ユーザーは問題なく使える。
SupabaseのRealtime機能とは
- テーブルにCRUDがあったタイミングでプログラムが通知を受け取れる機能
- 通信プロトコルはWebSocketだが、ライブラリに隠蔽されているので、プロトコルレベルのことを気にせず使える
認証・セキュリティ
- Realtimeには認証がない
- ユーザーごとにどのRealtime通知を受け取るかといった設定はできない
- Realtimeはサーバーサイドで使うことを推奨している
- セキュリティ上の理由から、Realtimeはデフォルトではオフになっている
感想1: このへんは今後のアップデートで改善されるだろうと思う。
感想2: Realtimeをクライアントサイドから直接使うのは、セキュリティ的にもパフォーマンス的にもよろしくない。Realtimeを使うとしたらクライアントサイドとSupabaseの間にミドルウェアを入れ、ミドルウェア上でユーザー認証だったり、ユーザーごとの通知データの振り分けを行うアーキテクチャにする必要がありそう。
感想3: FirebaseのRealtime Databaseと比べたら、このへんはまだまだ発展途上という感じがある。
SupabaseのREST APIの仕様書(OpenAPI)を取得するには?
SupabaseはREST APIを自動的に立ててくれるが、そのAPI仕様は次のURLにアクセスすると入手できる。
https://your-project.supabase.co/rest/v1/?apikey=your-anon-key
httpieで取得する例:
http "https://${SUPABASE_PROJECT}.supabase.co/rest/v1/?apikey=${SUPABASE_KEY}"
ここから取得できるのはOpenAPIのJSON。このJSONデータをもとにTypeScriptの型情報を生成するといったことができる。
Supabaseでデータのバリデーションはどうするのか?
古典的なLAMP型のアーキテクチャでは、ユーザーがPOSTした値のバリデーションはPHPなどサーバーサイドで行っていた。SupabaseはPHPのような自由なサーバーサイドが無いわけだが、データのバリデーションはどうしたらいいのだろうか?
PostgreSQLのCHECKで行う
考えられる方法としては、PostgreSQLの制約(constraint)を活用することだ。
例えば、次のような商品を保存するテーブルがあるとする:
create table product
(
id serial not null primary key,
name varchar,
price int4
);
このうちname
とprice
はユーザーが入力するものになる。このテーブル定義だと、望まない値である空欄の商品名やマイナスの価格が保存できてしまう:
insert into product (name, price) values ('', -100);
なので、name
フィールドには1文字以上の制約を、price
フィールドには0円以上の制約を加えたい。これを実現するには、テーブル定義にcheck
を追加する:
create table product
(
id serial not null primary key,
name varchar check (length(name) > 0),
price int4 check ( price > 0 )
);
これでデータベース自体が0文字のname
やマイナスのprice
を受け付けなくなる。
checkを追加した状態で、Supabase API越しにproductを作ろうとすると次のようなエラーになる:
{
hint: null,
details: 'Failing row contains (4, , -1).',
code: '23514',
message: 'new row for relation "product" violates check constraint "product_name_check"'
}
エラーメッセージが「product_nameのチェックにひっかかりました」レベルのざっくりしたものになるのが分かりにくい場合、テーブル定義に制約名を追加してやると多少ましになる。
create table product
(
id serial not null primary key,
name varchar
constraint product_name_must_be_non_empty check (length(name) > 0),
price int4
constraint product_price_must_be_positive check ( price > 0 )
);
変更後のエラーメッセージ: new row for relation "product" violates check constraint "product_name_must_be_non_empty"
もっと人が読んで分かるエラーメッセージにしたい場合、PostgreSQL単体だとストアド・プロシージャを書くことになるようだ。
だが、そこまでやる必要があるなら、サーバーサイドロジックを実装するべきなのではないかと思いつつ、結論を出せずにいる。
ここまでに確認できたことまとめ
Good
- パフォーマンス
- 東京リージョンがある
- PostgreSQLの自由度
- 今のところSQLでできることに関しては100%の自由度がある
- 開発環境
- ローカルSQLクライアントから直接PostgreSQLに接続できる
- クライアントライブラリ
- 公式のものあり、支障なく使える
- REST API
- 生成されたREST APIのOpenAPI形式仕様書がダウンロードできる
- バリデーション
- 細かいバリデーションはPostgreSQLのCHECKで行う
- 認証・権限・セキュリティ
- Supabase APIの認証はSupabaseの認証システムが使える
- テーブルレベルの権限はPostgreSQLのACLで制御できる
- 行レベルの権限はPostgreSQLのRow Level Securityで制御できるっぽい
- Realtime機能
- いまのところサーバーサイド向け
- JAMStackでサーバーサイドを持たない場合は使わないほうがよさげ
Bad
- テーブル作成がUIからやるとエラーになる
- あまり問題ないと思う。実運用ではマイグレーションツールが直接CREATE TABLEすると思うので。
TypeScriptとクライアントライブラリの相性
Supabase公式のクライアントライブラリはTypeScriptで作られている。
なので、TypeScriptでクライアントライブラリを使ってみた分には、違和感なく使えた。
Supabaseの料金はどうか?
- 課金はプロジェクトごと
- 毎月支払いのみ
/ | Free | Pro | Pay as you go |
---|---|---|---|
料金 | $0 | $25 | $75 + 従量課金 |
DB容量 | 500MB | 8GB | $0.124/GB |
バックアップ | ─ | 7日間 | 30日間 |
DBサーバのストップ | 1週間利用なしで停止 | ─ | ─ |
ユーザー数 | 1万 | 10万 | 無制限 |
オブジェクトストレージ容量 | 1GB | 100GB | $0.021/GB |
オブジェクトストレージダウンロード制限 | 2GB | 200GB | $0.07/GB |
Freeでも十分使えるスペック。
業務用途なら$25から始められるので、そんな高い感じがしない。
SupabaseのReact向けUIライブラリ
- Supabase公式のUIライブラリがある
ログイン画面のUIコンポーネントがある
- 文言のローカライズはできない
react-adminの認証にSupabaseのAuthを使う方法
についてQiita書いた
Supabaseについてツイートしてたら
SupabaseについてのノウハウをTwitterでいくつかツイートしてたら、Supabaseの中の人から「沢山ツイートしてくれたのに気がつきました。よかったらSupabaseのグッズを送りたいです」という旨のDMが来た😌 嬉しい。
ra-supabaseはリリースされていない
react-admin用のSupabaseデータプロバイダーは今日現在まだ開発途中でリリースされていない。
SupabaseのAPIは結局はPostgRESTなのでPostgRESTのデータプロバイダーを使えばいいかな?
@raphiniert/ra-data-postgrestを使ってみる
インストール
yarn add @raphiniert/ra-data-postgrest
ra-data-postgrestとSupabaseを組み合わせるのに、CORS対応するのと必要な認証系ヘッダを追加する必要があった:
import postgrestRestProvider from "@raphiniert/ra-data-postgrest";
import { fetchUtils } from "react-admin";
import { supabase } from "./supabaseClient";
const httpClient = (url: any, options: fetchUtils.Options = {}) => {
const apikey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const authBearer = supabase.auth.session()?.access_token ?? apikey;
const Authorization = `Bearer ${authBearer}`;
const headers =
options.headers instanceof Headers ? options.headers : new Headers();
headers.set("apikey", apikey);
headers.set("Authorization", Authorization);
options.headers = headers;
return fetchUtils.fetchJson(url, options);
};
export const dataProvider = postgrestRestProvider(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1`,
httpClient
);