見出し画像

supabaseでさくっとWebアプリを作ってみた

はじめに

はじめまして!SHIFT DAAE(ダーエ) 開発グループ所属の武藤です。

早速ですが、supabaseご存知でしょうか?最近試しに使ってみたところ、バックエンドに欲しい機能が簡単に作成できるサービスだったので、Webアプリケーションの開発デモを交えてご紹介します。

なお、当記事は2022/6/28時点の情報をもとにしています。

supabaseについて

サービス概要

subabaseは、認証やデータべースといったバックエンドに必要な機能を提供している、いわゆるBaaSの1つです。

BaaSの他サービスとしてはGoogleのFirebaseが有名ですが、supabaseはそのFirebaseに取って替わるサービスであると謡っています。まだPublic Beta版(企業以外のほとんどのユースケースに耐えうる)ではありますが、大きな注目を集めているオープンソースの1つです。

機能

supabaseが提供している機能は以下です。

  • Database

  • Authentication

  • Storage

  • Edge Functions(執筆時点ではアルファ版。2022/8月頃に正式版となる予定)

DatabaseはPostgresベースのRDBです。その為、supabaseはFirebaseのRDB版と表現されたりもします。FirebaseにはNoSQLデータベースのFirestoreがありますが、RDBに慣れ親しんだ方にとってはsupabaseは検討の価値があるのではないでしょうか。

反面、Firebaseの代替と言っても、当然のことながらFirebaseの全機能を網羅しているわけでない点には注意が必要です。Firebaseの機能数は改めてやはり凄いですね...

この後のデモでは、DatabaseとAuthenticationの機能を利用していきます。

料金

料金プランとしては、無料のFree、月額$25のPro、大規模向けのEnterpriseがあります。

  • Free

    • 500MBまでのデータベース、1GBのファイルストレージが利用可能

    • 最大2GBの帯域幅

    • 利用ユーザーは月あたり50,000人まで

    • 7日間未利用の場合、サーバの一時停止

    • 作成できるプロジェクト数は2つまで

  • Pro

    • 8GBまでのデータベース、100GBのファイルストレージ

    • 50GBの帯域幅

    • 利用ユーザーは月あたり100,000人まで

    • 未利用時のサーバの一時停止無し

FirebaseやAWSなどではネットワーク量に応じた従量課金がありますが、それが無いのは嬉しいですね。

料金プランの最新情報は以下リンクにてご確認ください。

デモアプリ作成

それではsupabaseを利用して、麻雀の成績を記録するためのWebアプリケーションを作成していきます(なぜ麻雀かというと、筆者の趣味だからです!最近社内で開催された大会に参加したこともあり、麻雀熱が高まってきてます!結果は予選落ちでしたが...)。

作成するアプリケーションには、以下機能を持たせていきます。

  • ログインページにて、Eメールアドレスとパスワードによるユーザー登録及びログインを行う

  • ゲーム終了時の成績(順位と点数)を、ログイン後のメインページで登録/閲覧できる

  • ユーザーの成績は、他ユーザーからは閲覧できない

作成時の言語バージョンは以下です。

  • React 18.2

  • Node.js 16.14

  • supabase_js 1.35.4

バックエンド(supabase)の作成

プロジェクトの作成

まず、まだアカウントを作成していない場合はsupabaseにサインアップします。サインアップには、GitHubアカウントが必要です。

ログインしたら、新しいプロジェクトを作成します。Name、Database Password、Region(Tokyoも選択可能)はお好きな内容を設定してください。

すると、プロジェクトのダッシュボードが表示されます。ダッシュボードの下部に表示されている以下情報は後程必要な接続情報になりますので、コピーしておきましょう。

プロジェクトの作成は終わりましたので、次からDB・認証部分の設定を行っていきます!

DB準備

それでは、DBの準備を行っていきます。

まず、supabaseではデータベースのタイムゾーンがデフォルトではUTCなので、JSTに変更します。なお、supabaseではUTCのままとすることを推奨してますが、今回は好みの問題でJSTにしてしまいます。

左メニューのSQL Editorより、SQLを実行してタイムゾーンを変更します。

タイムゾーンの設定が終わりましたので、次にTableを準備します。左メニューのTable Editorを押下し、更にCreate new tableにて麻雀の成績を管理するrecordsテーブルを作成します。

画面下部のColumnsには以下フィールドをセットします。更に、user_idは認証テーブルを外部キー参照させることで、ログインユーザーとの関連付けを行います。

Tableの一覧にrecordsが表示されていればOKです!

RLS(Row Level Security)の設定

先程作成したrecordsテーブルに対し、RLSを有効化します。この機能を有効化することにより、RLSのポリシーに沿ったアクセス制御が自動で行われます。この機能を用いて、ユーザー認証を活用したデータ制御が可能となります。

RLSを有効にするには、左メニューのAuthentication、次にそのメニュー内のPoliciesを選択します。

これからrecordsのRLSを有効化するわけですが、まずは制御ポリシーを作成します。supabaseが用意したテンプレートベースか、もしくは自分で好きなポリシーを作成することも可能です。今回は、テンプレートから作成していきます。

まずは、認証済みの場合のみデータ登録が可能となるポリシーを作成します。テンプレートよりinsert用のポリシーを選択し、そのまま保存します。

次に、SELECT用のポリシーを作成します。delete用のテンプレートを選択し、更に赤枠箇所をSELECTに変更し、保存します。

ポリシーの作成が完了したら、最後にEnable RLSを押しRLSを有効化します。以下の様になれば、RLS関連の作業は完了です!

認証関連の設定

最後に、ユーザー認証に関する設定を行います。といっても、supabaseのデフォルト設定でEメールアドレスによる認証が有効化されています。そのため、何かを有効化する必要はありません。

念の為ダッシュボードより設定を確認しておきます。左メニューのAuthenticationを押下し、更にSettingsを開きます。

すると、Eメールによる認証が有効であることを確認できます。デモでダミーのアドレスを使用したい場合は、以下を無効化しておくと登録したアドレス宛にメールが送られなくなるため便利です。

  • Double confirm email changes

  • Enable email confirmations

画面の更に下部には、電話番号や他プロバイダによる認証が選択できますが、今回は必要ないので割愛します。

これで、認証情報の設定が完了しました。バックエンドの構築は完了しましたので、次にフロントエンドの開発に移っていきましょう!

フロントエンド(React)の作成

テンプレートの準備

フロントエンド(React)の作成を行っていきます。

なお、今後出てくるサンプルコードでは、supabaseの情報になるべく焦点を当てるため、その他の部分は最小限の実装としておりますので、その点ご理解ください。

それでは、適当なフォルダを作成しcreate-react-appでテンプレートを用意します。

$ mkdir mahjong-supabase-app
$ cd mahjong-supabase-app
$ npx create-react-app .

プロジェクトに対し、supabase-jsをインストールします。

$ yarn add @supabase/supabase-js

ページ雛型に関する実装を行っていきます。後程、雛形の各関数に対しsupabaseの各処理を追加していきます。

// src/App.jsx
import { useEffect, useState } from "react";
import Home from "./Home";
import Login from "./Login";

const App = () => {
  const [session, setSession] = useState(null);

  useEffect(() => {}, []);

  return (
    <div
      style={{ minWidth: "100vw", minHeight: "100vh", backgroundColor: "#F5F5F5", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center" }}
    >
      {session ? <Home /> : <Login />}
    </div>
  );
};

export default App;
// src/Login.jsx
import { useState } from "react";

const Login = () => {
  const [isLogin, setIsLogin] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  // ログイン・ユーザー登録を行う
  const handleAuth = (e) => {
    e.preventDefault();
  };

  return (
    <div style={{ textAlign: "center" }}>
      <div>
        <h1>ログイン</h1>
      </div>
      <div>
        <form onSubmit={handleAuth}>
          <div style={{ marginBottom: "16px" }}>
            <input
              type="text"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>
          <div style={{ marginBottom: "16px" }}>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>
          <div style={{ marginBottom: "16px" }}>
            <span onClick={() => setIsLogin(!isLogin)}>
              {`${isLogin ? "登録" : "ログイン"}モードへ切り替える`}
            </span>
          </div>
          <div>
            <button type="submit">{isLogin ? "ログイン" : "登録"}</button>
          </div>
        </form>
      </div>
    </div>
  );
};

export default Login;
// src/Home.jsx
import { useEffect, useState } from "react";

const Home = () => {
  const [records, setRecords] = useState([]);
  const [newRanking, setNewRanking] = useState("");
  const [newScore, setNewScore] = useState("");

  useEffect(() => {}, []);

  // 成績を登録する
  const addRecord = (e) => {
    e.preventDefault();
  };

  // 成績を取得する
  const getRecords = () => {};

  // ログアウトする
  const signOut = () => {};

  return (
    <div
      style={{ width: "100%", display: "flex", flexDirection: "column", alignItems: "center" }}
    >
      <div>
        <h1>成績</h1>
      </div>
      <div style={{ marginBottom: "16px" }}>
        <button onClick={signOut}>ログアウト</button>
      </div>
      <div
        style={{
          width: "60%",
          display: "flex",
          flexDirection: "column",
        }}
      >
        <div style={{ width: "100%" }}>
          <form onSubmit={addRecord}>
            <div
              style={{
                marginBottom: "8px",
                width: "100%",
                display: "flex",
                flexDirection: "row",
                justifyContent: "space-between",
                alignItems: "center",
              }}
            >
              <div style={{ flexBasis: "100px", textAlign: "center" }}>
                <button type="submit">登録</button>
              </div>
              <div style={{ flexBasis: "60px", textAlign: "center" }}>
                <input
                  style={{ width: "30px" }}
                  type="text"
                  value={newRanking}
                  onChange={(e) => setNewRanking(e.target.value)}
                />
              </div>
              <div style={{ flexBasis: "100px", textAlign: "center" }}>
                <input
                  style={{ width: "70px" }}
                  type="text"
                  value={newScore}
                  onChange={(e) => setNewScore(e.target.value)}
                />
              </div>
            </div>
          </form>
        </div>
        {records.map((record, idx) => (
          <div
            key={idx}
            style={{ width: "100%", display: "flex", flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}
          >
            <div style={{ flexBasis: "100px", textAlign: "center" }}>
              <span>{record.created_at.substr(0, 10)}</span>
            </div>
            <div style={{ flexBasis: "60px", textAlign: "center" }}>
              <span>{`${record.ranking}位`}</span>
            </div>
            <div style={{ flexBasis: "100px", textAlign: "center" }}>
              <span>{`${record.score}点`}</span>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

export default Home;

上記の作成が完了すると、以下の画面が表示できるようになります。ザ・シンプルな見た目ですね。

supabaseクライアントを実装する

ようやく、supabaseに関する実装です。

まずは、supabaseクライアントを作成していきます。その際に先程ダッシュボードよりコピーしてきたAPI Key等が必要になりますが、それらは.env.localにセットしておきます。

// supabase.js
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.REACT_APP_SUPABASE_URL;
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// .env.local
REACT_APP_SUPABASE_URL=<ダッシュボードよりコピーしたProject URL>
REACT_APP_SUPABASE_ANON_KEY=<ダッシュボードよりコピーした Project API Key>

出来上がったこのsupabaseクライアントを各コンポーネントでインポートし、supabase関連の処理に使用していきます。

認証機能を実装する

認証機能を各コンポーネントに追加していきます。 各コンポーネントと認証機能の関連はそれぞれ以下の通りです。

  • App.jsx ... セッションデータを取得し、セッションの有無に応じて、ログインページ(Login.jsx)とメインページ(Home.jsx)の表示を切り替えます。

  • Login.jsx ... Eメールアドレスとパスワードによる、ログイン/ユーザー登録用のコンポーネントです。

  • Home.jsx ... メインコンポーネントです。ログアウトが出来るようにします。

必要な認証機能を実現するためのメソッドは以下の通りです。

// セッションデータ取得
supabase.auth.session();
// 認証状態の変更を検知
supabase.auth.onAuthStateChange();
// ログイン
supabase.auth.signIn();
// ユーザー登録
supabase.auth.signUp();
// ログアウト
supabase.auth.signOut();

上記を踏まえ、各コンポーネントに対して認証機能の実装を行うと、以下の通りとなります。

// src/App.jsx

// ... 略
import Login from "./Login";
+ import { supabase } from "./supabase";

// ... 略

  useEffect(() => {
+    setSession(supabase.auth.session());
+
+    supabase.auth.onAuthStateChange((event, session) => {
+      setSession(session);
+    });
  }, []);

// ... 略
// src/Login.jsx
import { useState } from "react";
+ import { supabase } from "./supabase";

// ... 略

-  const handleAuth = (e) => {
+  const handleAuth = async (e) => {
     e.preventDefault();
+    try {
+      if (isLogin) {
+        const { error } = await supabase.auth.signIn({
+          email: email,
+          password: password,
+        });
+        if (error) throw error;
+      } else {
+        const { error } = await supabase.auth.signUp({
+          email: email,
+          password: password,
+        });
+        if (error) throw error;
+      }
+    } catch (error) {
+      alert(error.error_description || error.message);
+    }
   };
   
// ... 略
// src/Home.jsx
import { useEffect, useState } from "react";
+ import { supabase } from "./supabase";

// ... 略

  // ログアウトする
  const signOut = () => {
+   supabase.auth.signOut();
  };
// ... 略

認証部分が完成しましたので、動作を確認します。 まずは、適当なアドレスとパスワードでユーザー登録をしてみます。

登録に成功すると、App.jsxが認証状態の変更と検知し、メインページへページが切り替わります。また、この状態でsupabaseダッシュボードを確認してみると、ユーザーが1件登録されています。

これで、認証系の実装は完了です。

データ操作を実装する

データ関連機能の実装に移ります。

必要となる操作は、recordsテーブルに対するSELECT/INSERTです。

まず、SELECTに関してですが、SQLの場合とsupabase_jsの場合のコードは以下の様になります。

-- sql
-- ユーザーのレコードを登録日降順で取得する場合
SELECT * FROM records WHERE user_id = 'xxx' ORDER BY created_at DESC;
// supabase_js
// ユーザーのレコードを登録日降順で取得する場合
supabase.from("records").select("*").order("created_at", { ascending: false });

supabase_jsにおいてもSQLに近しいイメージで記述できることが分かります。

一点ポイントとなるのは、supabase_js側にはwhere句に相当する部分が不要であるということです。RLSによるアクセス制御を設定済みの為です。

また、INSERTの場合も、SELECTの場合とそう大差はありません。

// supabase_js
// recordsテーブルへのレコード登録
supabase.from("records").insert();

それでは、Homeコンポーネントに対してデータ取得/登録処理を追加していきましょう。

// src/Home.jsx

// ... 略
   useEffect(() => {
+    (async () => await getRecords())();
   }, []);

// 成績を登録する
-  const addRecord = (e) => {
+  const addRecord = async (e) => {
     e.preventDefault();
+
+    try {
+     const { error } = await supabase.from("records").insert([
+       {
+         ranking: Number(newRanking),
+         score: Number(newScore),
+         user_id: supabase.auth.user().id,
+       },
+     ]);
+     if (error) throw error;
+
+     await getRecords();
+
+     setNewRanking("");
+     setNewScore("");
+    } catch (error) {
+     alert(error.message);
+    }
   }

// 成績を取得する
-  const getRecords = () => {
+  const getRecords = async () => {
+    try {
+      const { data, error } = await supabase
+        .from("records")
+        .select("*")
+        .order("created_at", { ascending: false });
+      if (error) throw error;
+
+      setRecords(data);
+    } catch (error) {
+      alert(error.message);
+      setRecords([]);
+    }
+  };

// ... 略

実装した機能を確認してみましょう。

適当な情報を入力し、登録ボタンを押します。入力フォーム下部に入力した内容が表示されれば、登録処理及び取得処理は正常に動作しています。

supabaseダッシュボードで確認してみると、データが正しく登録されています。

これで、データ操作機能も完成となります!

RLSの確認

最後に、RLSによるアクセス制御の効果を確認します。

先程までのユーザーに、私の社内麻雀大会予選でのスコア(結果は予選落ち...)を反映しました。このデータが、他ユーザーからは閲覧できないことを確認します。

別のEメールアドレスにてユーザー登録をします。すると、完了後自動でメインページに遷移しますが、先程の成績は表示されていません。RLSのポリシー通りに制限がされていることが確認できました。

データ登録に関するポリシーの確認もしてみます。ソースコードを一時的に書き換え、未ログイン状態での登録を試してみたところ、RLSによりデータ登録に失敗しました。期待した通りの挙動です。

これで、今回作成したかったアプリケーションは完成です!

おわりに

長文にお付き合いいただきありがとうございました!

初めてのブログ投稿だったので、読みづらい、分かりづらい部分等あったかもしれませんが、この記事によって少しでもsupabaseに興味を持ってもらえたならば幸いです!その際には、Freeプランもありますので是非試してみてください!

ソースコードの完成版

// src/App.jsx
import { useEffect, useState } from "react";
import Home from "./Home";
import Login from "./Login";
import { supabase } from "./supabase";

const App = () => {
  const [session, setSession] = useState(null);

  useEffect(() => {
    setSession(supabase.auth.session());

    supabase.auth.onAuthStateChange((event, session) => {
      setSession(session);
    });
  }, []);

  return (
    <div
      style={{ minWidth: "100vw", minHeight: "100vh", backgroundColor: "#F5F5F5", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center" }}
    >
      {session ? <Home /> : <Login />}
    </div>
  );
};

export default App;
// src/Login.jsx
import { useState } from "react";
import { supabase } from "./supabase";

const Login = () => {
  const [isLogin, setIsLogin] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  // ログイン・ユーザー登録を行う
  const handleAuth = async (e) => {
    e.preventDefault();
    try {
      if (isLogin) {
        const { error } = await supabase.auth.signIn({
          email: email,
          password: password,
        });
        if (error) throw error;
      } else {
        const { error } = await supabase.auth.signUp({
          email: email,
          password: password,
        });
        if (error) throw error;
      }
    } catch (error) {
      alert(error.error_description || error.message);
    }
  };

  return (
    <div style={{ textAlign: "center" }}>
      <div>
        <h1>ログイン</h1>
      </div>
      <div>
        <form onSubmit={handleAuth}>
          <div style={{ marginBottom: "16px" }}>
            <input
              type="text"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>
          <div style={{ marginBottom: "16px" }}>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>
          <div style={{ marginBottom: "16px" }}>
            <span onClick={() => setIsLogin(!isLogin)}>
              {`${isLogin ? "登録" : "ログイン"}モードへ切り替える`}
            </span>
          </div>
          <div>
            <button type="submit">{isLogin ? "ログイン" : "登録"}</button>
          </div>
        </form>
      </div>
    </div>
  );
};

export default Login;
// src/Home.jsx
import { useEffect, useState } from "react";
import { supabase } from "./supabase";

const Home = () => {
  const [records, setRecords] = useState([]);
  const [newRanking, setNewRanking] = useState("");
  const [newScore, setNewScore] = useState("");

  useEffect(() => {
    (async () => await getRecords())();
  }, []);

  // 成績を登録する
  const addRecord = async (e) => {
    e.preventDefault();

    try {
      const { error } = await supabase.from("records").insert([
        {
          ranking: Number(newRanking),
          score: Number(newScore),
          user_id: supabase.auth.user().id,
        },
      ]);
      if (error) throw error;

      await getRecords();

      setNewRanking("");
      setNewScore("");
    } catch (error) {
      alert(error.message);
    }
  };

  // 成績を取得する
  const getRecords = async () => {
    try {
      const { data, error } = await supabase
        .from("records")
        .select("*")
        .order("created_at", { ascending: false });
      if (error) throw error;

      setRecords(data);
    } catch (error) {
      alert(error.message);
      setRecords([]);
    }
  };

  // ログアウトする
  const signOut = () => {
    supabase.auth.signOut();
  };

  return (
    <div
      style={{ width: "100%", display: "flex", flexDirection: "column", alignItems: "center" }}
    >
      <div>
        <h1>成績</h1>
      </div>
      <div style={{ marginBottom: "16px" }}>
        <button onClick={signOut}>ログアウト</button>
      </div>
      <div
        style={{
          width: "60%",
          display: "flex",
          flexDirection: "column",
        }}
      >
        <div style={{ width: "100%" }}>
          <form onSubmit={addRecord}>
            <div
              style={{
                marginBottom: "8px",
                width: "100%",
                display: "flex",
                flexDirection: "row",
                justifyContent: "space-between",
                alignItems: "center",
              }}
            >
              <div style={{ flexBasis: "100px", textAlign: "center" }}>
                <button type="submit">登録</button>
              </div>
              <div style={{ flexBasis: "60px", textAlign: "center" }}>
                <input
                  style={{ width: "30px" }}
                  type="text"
                  value={newRanking}
                  onChange={(e) => setNewRanking(e.target.value)}
                />
              </div>
              <div style={{ flexBasis: "100px", textAlign: "center" }}>
                <input
                  style={{ width: "70px" }}
                  type="text"
                  value={newScore}
                  onChange={(e) => setNewScore(e.target.value)}
                />
              </div>
            </div>
          </form>
        </div>
        {records.map((record, idx) => (
          <div
            key={idx}
            style={{ width: "100%", display: "flex", flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}
          >
            <div style={{ flexBasis: "100px", textAlign: "center" }}>
              <span>{record.created_at.substr(0, 10)}</span>
            </div>
            <div style={{ flexBasis: "60px", textAlign: "center" }}>
              <span>{`${record.ranking}位`}</span>
            </div>
            <div style={{ flexBasis: "100px", textAlign: "center" }}>
              <span>{`${record.score}点`}</span>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

export default Home;

\もっと身近にもっとリアルに!DAAE公式Twitter/


執筆者プロフィール:武藤 将太
SHIFT DAAE部のエンジニア。主にWebシステムの開発経験を経て、SHIFTに入社。
USキーボードを最近買いました、キーボードが変わるだけでなんだか楽しいです。最近の趣味は麻雀。点数計算を覚えることと、副露率を高めていくことが今後の目標。子供の頃は不思議と思ったこと無かったのですが、ツナっておいしいですね。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら 
https://recruit.shiftinc.jp/career/