🔯

Convex に入門する

に公開

Convex は,Supabase や Firebase のような BaaS(Backend as a Service)のひとつです.

Tech系YouTubeチャンネル「WebDevCody」で,Convex が強く推されているのを見て気になりました.彼は「Supabaseよりも好きだ」とまで言っています.最近のBaaSといえば,Supabase 一強という印象がありますが,その中であえて Convex を選ぶ理由とは一体何なのでしょか?

この記事では,実際に Convex でチャットアプリを作成するチュートリアル に取り組みながら,その魅力を探っていきます.

開発環境のセットアップ

チュートリアル用のコードをダウンロードし,必要なパッケージをインストールします.

git clone https://github.com/get-convex/convex-tutorial.git
cd convex-tutorial
npm install

開発環境をセットアップします.

npm run dev

このコマンドを実行するとコンベックスにログインするように促されます.ログインすることで開発環境が Convex のサーバー上に作成され,プロジェクト内に convex ディレクトリが作成されます.

.
├── README.md
├── convex
│   ├── README.md
│   ├── _generated
│   └── tsconfig.json
├── index.html
├── package-lock.json
├── package.json
├── src
│   ├── App.tsx
│   ├── index.css
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.json
└── vite.config.mts

Vite + React のプロジェクトの中に, convex ディレクトリが作成された形になっていることがわかります.

開発中はnpm run devを起動したままにします. コードが変更されると,この後に作成する Query 関数 や Mutation 関数 を Convex のサーバーと同期したり,必要なコードを生成したりしてくれます.

ターミナルの作業用のタブとは別のタブで npm run dev をしたままにしておきます.

localhost:5173 で Vite の開発サーバーが起動しているので、ブラウザで開いて確認します。

UI が表示されますが,メッセージを送信しようとすると,「まだ Mutation が実装されていません」と表示されます.

Convex の構成要素

Convex の重要な構成要素である,データベース,Mutation 関数,Query 関数について説明します.

  • データベース:JSONライクなドキュメントを持つドキュメントリレーショナル型.すべてのドキュメントには自動生成される_idがあり,これでリレーションを作れる.
  • Mutation 関数:TypeScriptで書かれる書き込み用関数.すべてトランザクションとして実行され,変更は一括で反映 or ロールバック
  • Query 関数:TypeScriptで書かれる読み取り専用関数.クライアントは Query 関数をWebSocket経由で購読.データが変わるとクエリが再実行され,購読中のすべてのアプリが自動更新される

下の図は,上の図とほぼ同じ内容ですが,どの部分がどこで実行されているのかがわかりやすくなっています.

Convex は「データ層(DB)」→「ロジック層(API/サーバー)」→「UI層(フロントエンド)」というよくある3層アーキテクチャになっています.Mutation 関数や Query 関数がロジック層に相当します.UI層(フロントエンド)がユーザーのブラウザで動作し,インターネットを通してロジック層(API/サーバー)と通信します.ロジック層(API/サーバー)とUI層(フロントエンド)は Convex のサーバー上で実行されます.

UI層とロジック層の通信は HTTP ではなく WebSocket になっています.これは Convex の特徴であるリアルタイム同期を実現するためです.


Convex Overview

説明が抽象的でよくわからなくても大丈夫です.実際にコードを書いてみて実行してみればイメージが掴めるはずです.

メッセージ送信用の Mutation 関数を作成する

convex ディレクトリに chat.ts を作成します.

convex/chat.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const sendMessage = mutation({
  args: {
    user: v.string(),
    body: v.string(),
  },
  handler: async (ctx, args) => {
    console.log("This TypeScript function is running on the server.");
    await ctx.db.insert("messages", {
      user: args.user,
      body: args.body,
    });
  },
});
  • sendMessage は公開APIとして追加される Mutation 関数.バックエンド,つまり Convex のサーバー上で実行される.
  • この関数は自動的にトランザクションとして実行されるので,例外が発生すれば変更はロールバックされる.
  • 普通のTypeScript関数なので,console.log() でサーバーサイドの簡単なデバッグが可能.
  • args を使って引数が user と body の2つの文字列であることを型レベルと実行時の両方で保証している.
  • ctx.db.insert を使って新しいメッセージを messages テーブルに追加している.

試しに convex/chat.ts に変更を加えて保存してから npm run dev のログを見てみると,今作成した sendMessage 関数が Convex のサーバーに送信されていることがわかります.

同時に,フロントエンドから sendMessage 関数を呼び出すための型やコードが convex/_generated ディレクトリに生成されます.このディレクトリの中身は自動的に更新されるので,自分で変更する必要はありません.

convex/_generated/
├── api.d.ts
├── api.js
├── dataModel.d.ts
├── server.d.ts
└── server.js

フロントエンドから sendMessages 関数を呼び出す

sendMessages 関数を作成したので,これをフロントエンドから呼び出します. src/App.tsx を次のように変更します.

src/App.tsx
import { useEffect, useState } from "react";
import { faker } from "@faker-js/faker";

+import { useMutation } from "convex/react";
+import { api } from "../convex/_generated/api";

//...

export default function App() {
-  // TODO: Add mutation hook here.
+  const sendMessage = useMutation(api.chat.sendMessage);

  //...

  return (
    <main className="chat">
      {/* ... */}
      <form
        onSubmit={async (e) => {
          e.preventDefault();
-          alert("Mutation not implemented yet");
+          await sendMessage({ user: NAME, body: newMessageText });

          setNewMessageText("");
        }}
      >
        {/* ... */}
      </form>
    </main>
  );
}

フロントエンドから 先ほど作成した sendMessage 関数を呼び出しています.注意する必要があるのは, sendMessage 関数を convex/chat.ts からインポートして直接呼び出すのではなく,自動生成された convex/_generated/api からインポートして呼び出している点です.

また,sendMessage 関数の引数に型がついていることも確認できます.

メッセージを送信する

送信したメッセージがちゃんとデータベースに保存されることを確認します.フロントエンドでは今のところ固定のメッセージを表示しているだけなので, Convex のダッシュボードからデータベースの中身を確認します.

https://dashboard.convex.dev/ をブラウザで開き,「Projects」の中から convex-tutorial という名前のプロジェクトを開きます.

左のメニューの中から「Data」を選択します.

「まだテーブルはありません」と表示されていますが,問題ありません.

フロントエンドとダッシュボードを左右に並べてメッセージを送信してみます.すると,ダッシュボード上で messages テーブルが自動で作成され,送信したメッセージがレコードとして追加されます.

Convex は最初のメッセージが送信されたときに自動的に messages テーブルを作成します.データベースのスキーマを設定することもできますが,必須ではありません.

スキーマを設定することで,より強い型安全性が得られます.好みが分かれるところですが,例えばプロトタイピング中はスピード重視でテーブルを自動的に作成し,形が決まってきたらスキーマを設定して型安全性を得る,という使い方ができます.

メッセージを取得する

ここまででメッセージの送信部分は完成しました.ここからはデータベースに保存されたメッセージを表示する部分を作っていきます.

convex/chat.ts ファイルを次のように変更します.

convex/chat.ts
-import { mutation } from "./_generated/server";
+import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const sendMessage = mutation({
  args: {
    user: v.string(),
    body: v.string(),
  },
  handler: async (ctx, args) => {
    console.log("This TypeScript function is running on the server.");
    await ctx.db.insert("messages", {
      user: args.user,
      body: args.body,
    });
  },
});

+export const getMessages = query({
+  args: {},
+  handler: async (ctx) => {
+    // Get most recent messages first
+    const messages = await ctx.db.query("messages").order("desc").take(50);
+    // Reverse the list so that it's in a chronological order.
+    return messages.reverse();
+  },
+});

getMessages は公開APIの Query 関数として定義されています. Query 関数なので,ctx.db ではデータの読み取りしかできません

次にフロントエンドで getMessages を呼び出すために, src/App.tsx を次のように更新します.

src/App.tsx
-import { useMutation } from "convex/react";
+import { useQuery, useMutation } from "convex/react";

//...

export default function App() {
-  const messages = [
-    { _id: "1", user: "Alice", body: "Good morning!" },
-    { _id: "2", user: NAME, body: "Beautiful sunrise today" },
-  ];
+  const messages = useQuery(api.chat.getMessages);

  //...
}

変更を保存してからフロントエンドを確認すると, messages テーブル内のメッセージがUIに表示されています.

リアルタイム同期

Convex の Query 関数は,データベースとリアルタイムに同期されます.特別な処理を書かずにリアルタイム同期を実現できるのが Convex の特徴の1つです.

実際に試してみます.フロントエンドを2つのタブで同時に開いて,どちらかのタブでメッセージを送信してみます.

片方のタブで送信したメッセージが,すぐにもう片方のタブにも反映されることがわかります.

Supbase で同様のリアルタイム同期を実現しようとすると,特定のテーブルの変更を Listen するコードを書く必要があります.

const channelA = supabase
  .channel('schema-db-changes')
  .on(
    'postgres_changes',
    {
      event: '*',
      schema: 'public',
    },
    (payload) => console.log(payload)
  )
  .subscribe()

フロントエンドにおいて,データベースの状態とUIの状態をどう同期させるかは,大きなテーマです.その課題を解決するために,TanStack Query や Vercel SWR といったデータフェッチライブラリが登場しました.しかし Convex では,普通にクエリ関数(Query)を書くだけで,データベースとの同期が自動で行われます
その分,開発者が意識すべきことが減るのは嬉しいポイントです.

WebSocket の中身を見てみる

ブラウザの開発者ツールを開き,「ネットワーク」タブから WebSocket の通信を探してみましょう.Convex のクエリ関数を使っているページでは, wss://*.convex.cloud に接続している通信があるはずです.

この中身をのぞいてみると,以下のようなやりとりが確認できます.

  • クエリ関数を購読するための subscribe メッセージ
  • サーバーからの データの更新通知

まとめ

初めて Convex を使ってみましたが,とても使いやすくて驚きました.特に気に入っているのは,すべてを TypeScript で完結できるところです.Supabase の場合,たとえば RLS や Cron Job を使うには SQL を書く必要があります.これは私のスキルの問題でもありますが,「やりたいことを実現するための SQL がわからない」,「書いた SQL のどこが間違っているのかがわからない」……といった経験がよくありました.その点 Convex は,これらの処理を TypeScript で記述できるため,自分にとっては格段に書きやすいと感じました.

Discussion