Open27

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するコードを実装する

test.ts
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上に作ったテーブルは基本的に誰でも読み書きできる状態になっている。

test.ts
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の二種類のキーがある

Client API Keys (SUPABASE_KEY)

  • データベースに「匿名ユーザー」としてアクセスするキー
  • ユーザーが認証されるまでに使うキー。
    • つまり、認証系のAPIはこのキーを使って叩くことになる。
    • 認証時にユーザーごとにキーが与えられるので、認証後はユーザーのキーを使ってAPIを叩く。
  • Client API Keysは、フロントエンドで使うことを想定されたキーで、フロントエンドに公開しても大丈夫なキー。
    • JAMStackなアプリでは、このClient API Keysを用いる。

Service Keys (SERVICE_KEY)

  • データベースにフルアクセスできるキー
  • Supabaseでは「セキュリティーポリシー」があるが、Service Keysはこのセキュリティーポリシーを無視してアクセスできる強力な権限がある。
  • 取り扱いに細心の注意を払うべきキー
  • 絶対にクライアントやブラウザに公開してはいけないキー

キーのありか

Client API KeysとService Keysは「Settings」→「API」→「API Keys」にて調べられる。

SupabaseでAPI経由でユーザーを登録する

test.ts
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を実行するとユーザーがログインできるようになる。

test.ts
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権限でできなくなっていることを確認する:

test.ts
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操作をする:

test.ts
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
);

このうちnamepriceはユーザーが入力するものになる。このテーブル定義だと、望まない値である空欄の商品名やマイナスの価格が保存できてしまう:

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単体だとストアド・プロシージャを書くことになるようだ。

https://stackoverflow.com/a/53448944/9844125

だが、そこまでやる必要があるなら、サーバーサイドロジックを実装するべきなのではないかと思いつつ、結論を出せずにいる。

ここまでに確認できたことまとめ

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ライブラリがある

https://www.npmjs.com/package/@supabase/ui

ログイン画面のUIコンポーネントがある

  • 文言のローカライズはできない

Supabaseについてツイートしてたら

SupabaseについてのノウハウをTwitterでいくつかツイートしてたら、Supabaseの中の人から「沢山ツイートしてくれたのに気がつきました。よかったらSupabaseのグッズを送りたいです」という旨のDMが来た😌 嬉しい。

ra-supabaseはリリースされていない

react-admin用のSupabaseデータプロバイダーは今日現在まだ開発途中でリリースされていない。

https://github.com/marmelab/ra-supabase

SupabaseのAPIは結局はPostgRESTなのでPostgRESTのデータプロバイダーを使えばいいかな?

https://github.com/raphiniert-com/ra-data-postgrest

@raphiniert/ra-data-postgrestを使ってみる

インストール

yarn add @raphiniert/ra-data-postgrest

ra-data-postgrestとSupabaseを組み合わせるのに、CORS対応するのと必要な認証系ヘッダを追加する必要があった:

supabaseDataProvider.ts
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
);
ログインするとコメントできます