EY-Office ブログ

Electricを使うと既存のWebアプリにリアルタイム情報表示機能を簡単に追加できます!

TanStack DBを調べていた際に、そこで使われているElectricに興味を持ったので調べてみました(TanStack DBに付いては、いずれ書きます)。

Electric ElectricSQL Blogより

Electricとは

ElectricのホームページElectric sync engineにある説明です(DeepLで翻訳しました)。

Electric sync engine

Electricシンク・エンジンは、Postgresからローカル・アプリケーションやサービスに、データを必要とする場所に、データの小さなサブセットをシンクする。

以下のようなデータを同期させることができます:

  • ウェブアプリやモバイルアプリで、データ取得をデータ同期に置き換える
  • エッジワーカーやサービス(低レイテンシのエッジデータキャッシュの維持など)
  • ローカルAIシステム(pgvectorを使用したRAGの実行など)
  • 開発環境とテスト環境。例えば、組み込みPGliteデータベースにデータを同期する

How does it work?

Electric同期エンジンはElixirアプリケーションで、packages/sync-serviceで開発されている。DATABASE_URLを使ってPostgresに接続し、論理レプリケーションストリームを消費し、データをShapeに展開します。

これにより、膨大な数のクライアントがデータベースのサブセットに対するクエリを実行し、リアルタイムの更新を得ることができる。このようにして、ElectricはPostgresをリアルタイムデータベースに変えました。

著者補足

使ってみたわかったことは、

  • Electricは特殊なデータベースではなく、PostgreSQLと連携して動くサーバーとライブラリーです
    • React専用API(useShape)の他にTypeScriptの汎用API(ShapeStream)も用意されています
  • Electricはデータの呼出しだけで動作します、データベースへのinsert、update、delete等の機能は持っていません
    • 言い換えるとデータベース周りのコードは既存のコードや既存の作成方法が使えます、特殊なライブラリーをかます必要がありません
  • 同期する単位はShapeと呼ばれる、データベースのテーブルと取得条件などを指定した範囲で行われます
  • ElectricのサーバーはElixirで書かれています。素晴らしい😄

いつものジャンケンアプリをElectric対応にしてみました

下の画像にあるように、アプリケーションバーの右にプレイヤーの選択肢を追加しましした。

  • ①のプレイヤーはJamesJamesの対戦結果のみ表示されています
  • ②③のプレイヤーはMaryです。②でジャンケンを行うと③のウィンドウにもリアルタイムで結果が表示されます

Electric

環境構築

開発環境の作り方はQuickstartにあるように簡単です。PostgreSQLとElectricの動くDockerが準備されています。
React環境はViteで作り、npm install @electric-sql/reactでライブラリーをインストールするだけです(Next.jsでも開発できますがSSR周りが少し面倒です。ちゃんとドキュメントがあります)。

バックエンド作成

Electricは同期(呼出し)のAPIは提供していますが、変更系のAPIはないので独自のバックエンドを作りました。

  • Expressを使った普通のAPIサーバーです
    • CORS対応しています
  • PostgreSQLの接続はPostgres.jsライブラリーを使っています
    • 良いライブラリーですね
  • POST /scoresでジャンケン結果をscoresテーブルにinsertします
  • GET /scoresはジャンケンの結果を取得できます。動作確認用に作ったAPIでジャンケンアプリでは使っていません
const express = require('express');
const app = express();
const cors = require('cors');
const postgres = require('postgres');

const ServerPort = 3030;
const AllowOrigins = ['http://localhost:5173'];

const logger = (req, res, next) => {
  console.log(`${(new Date()).toISOString()} : ${req.method} ${req.url}`);
  next();
};

app.use(express.json());
app.use(cors({origin: AllowOrigins}));
app.use(logger);

const sql = postgres("postgresql://postgres:password@localhost:54321/electric");

const makeTable = async() => {
  await sql`
    CREATE TABLE IF NOT EXISTS scores (
      id SERIAL PRIMARY KEY,
      owner VARCHAR(255) NOT NULL,
      human INTEGER NOT NULL,
      computer INTEGER NOT NULL,
      judgment INTEGER NOT NULL,
      match_date TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );
  `;
};

app.get('/scores', async (_req, res) => {
  const scores = await sql`SELECT * FROM scores ORDER BY id`;
  res.json(scores);
});

app.post('/scores', async (req, res) => {
  console.log(req.body);
  await sql`insert into scores ${
    sql(req.body, 'human', 'computer', 'judgment', 'owner')}`;
  res.json({success: true});
})

makeTable();

app.listen(ServerPort, () => {
  console.log(`Start server on port:${ServerPort}`);
});

Reactコード

概要
ファイル構成
├── src
│   ├── assets
│   │   └── react.svg
│   ├── components
│   │   ├── ApplicationBar.tsx
│   │   ├── JyankenBox.tsx
│   │   ├── ScoreList.tsx
│   │   └── StatusBox.tsx
│   ├── index.css
│   ├── libs
│   │   ├── jyanken.ts
│   │   └── playerAtom.ts
│   ├── main.tsx
│   ├── routes
│   │   ├── __root.tsx
│   │   ├── index.tsx
│   │   ├── scores.tsx
│   │   └── status.tsx
│   ├── routeTree.gen.ts
│   └── vite-env.d.ts
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
│   └── vite.svg
├── README.md
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
対戦成績のコンポーネント: src/routers/scores.tsx

対戦成績のメインのコンポーネントです。

  • ① アプリケーションバーにあるプレイヤー名はjotaiで管理しています
  • useShapeがElectricのデータ同期(呼出し)フックです
    • TanStack QuerySWRのように通信+ステート管理+キャッシュ管理を含むライブラリーです
    • paramsで指定されたShapeに変更が起こると、自動的にuseShapeが再実行(再読み込み)が行われています
  • ③ paramsで同期する単位のShapeを定義しています
    • scoresテーブルのownerが一致するデータのみが同期されます
  • ④ JSONデータの独自パーサーを組み込めます、ここではDate型のパーサーを定義しています。
    • JSONにがDate型が無いので、この機能はありがたいですね
import { createFileRoute } from '@tanstack/react-router'
import { useShape } from '@electric-sql/react';
import JyankenBox from "../components/JyankenBox";
import ScoreList from "../components/ScoreList";
import { getLastest, type Score } from '../libs/jyanken';
import { useAtom } from 'jotai';
import { playerAtom } from '../libs/playerAtom';

export const Route = createFileRoute('/scores')({
  component: Scores,
})

function Scores() {
  const [player] = useAtom(playerAtom);                  // ← ①
  const { data:scores } = useShape<Score>({              // ← ②
    url: `http://localhost:3000/v1/shape`,
    params: {                                            // ← ③
      table: `scores`,
      where: 'owner = $1',
      params: [player]
    },
     parser: {                                           // ← ④
      timestamptz: (date: string) => new Date(date)
    }
  });

  return (
    <>
      <div className="mx-2 md:mx-8 md:w-1/2">
        <h1 className="my-6 text-center text-xl font-bold">
          対戦結果
        </h1>
        <JyankenBox />
        <ScoreList scores={getLastest(scores, 10)} />  {/* ← ⑤ */}
      </div>
    </>
  );
}
  • ⑤ 同期(呼出し)されたデータの順序(order by)や大きさ(limit)は指定できないのでgetLastest関数で行っています
export const getLastest = (scores:Score[], limit:number) => {
  const sorted = [...scores];
  sorted.sort((a, b) => -(a.match_date < b.match_date ? -1 :
                           (a.match_date == b.match_date ? 0 : 1)));
  return sorted.slice(0, limit);
}
ジャンケンボタンのコンポーネント: src/components/JyankenBox.tsx

ジャンケンを行い、結果のオブジェクトをバックエンドにPOSTしています。→ ①

import { useAtom } from "jotai";
import { judge, randomHand, Te, type Score } from "../libs/jyanken";
import { playerAtom } from "../libs/playerAtom";

export default function JyankenBox () {
  const [player] = useAtom(playerAtom);

  const pon = async (humanHand: Te) => {
    const computerHand = randomHand();
    const score: Score = {
      human: humanHand,
      computer: computerHand,
      judgment: judge(humanHand, computerHand),
      match_date: new Date(),
      owner: player
    };
    const res = await fetch("http://localhost:3030/scores", {   // ← ①
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(score)
    });
    console.log(res);
  };

  const buttonClass = `text-white text-center text-sm rounded w-16 px-2 py-2
    bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50`;

  return (
    <div className="w-[230px] mx-auto flex mb-10">
      <button type="button" onClick={() => pon(Te.Guu)} className={buttonClass}>
        グー
      </button>
      <button type="button" onClick={() => pon(Te.Choki)} className={`${buttonClass} mx-5`}>
        チョキ
      </button>
      <button type="button" onClick={() => pon(Te.Paa)} className={buttonClass}>
        パー
      </button>
    </div>
  );
}
アプリケーションバーのコンポーネント: src/components/ApplicationBar.tsx

アプリケーションバーのコンポーネントです。

  • ① プレイヤー名はjotaiで管理しています
  • ② メニューはTanStack QueryのLinkコンポーネントを使っています
  • ③ プレイヤー名(文字列)の配列nameListlibs/playerAtom.tsファイル内に定義されています
import { Link } from '@tanstack/react-router'
import { useAtom } from 'jotai';
import { playerAtom, nameList } from '../libs/playerAtom';

export default function ApplicationBar() {
  const [, setPlayer] = useAtom(playerAtom);                        // ← ①

  const linkCSS = "py-2 px-3 text-blue-100 rounded hover:bg-blue-700";
  return (
    <nav className="bg-blue-600 border-gray-50">
      <div className="max-w-screen-xl flex flex-wrap items-center mx-auto p-3">
        <h1 className="ml-5 text-2xl font-bold text-white">じゃんけん ポン!</h1>
        <ul className="font-medium flex p-2 bg-blue-600">
          <li>
            <Link to="/scores" className={linkCSS}>対戦結果</Link>  {/* ← ② */}
          </li>
          <li>
            <Link to="/status"  className={linkCSS}>対戦成績</Link>
          </li>
        </ul>
        <label className="text-white ml-auto me-2">Player :</label>
        <select id="names" onChange={e => setPlayer(e.target.value)}
          className="bg-gray-50 border border-gray-300 text-gray-900 text-right text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2">
          {nameList.map((name, ix) => (                             // ← ③
            <option key={ix} value={name}>
              {name}
            </option>))}
        </select>
      </div>
    </nav>
  );
}
対戦結果のコンポーネント: src/routers/status.tsx

対戦成績のコンポーネントとほぼ同じです。ScoreListコンポーネントを呼び出すかStatusBoxコンポーネントを呼び出すかの違いです。

その他のコンポーネント

過去のブログに出てくるコードと同じです。

まとめ

リアルタイムでデータが同期されるデータベースとして、以前Convexに付いてのブログを書きました。しかし、Electricは特殊なデータベースでもデータベースを完全にラッピングしてしまうライブラリーではなく、同期を行うAPIでけを提供し、データベースの操作(CRUD)は従来のAPIが使えるのが良いですね。

ようするにElectricを使えば、既存のWebアプリにリアルタイムで情報を表示する機能を簡単に追加できるのです。素晴らしい❗
Electricサーバーをバックエンドで動かし、ブラウザー上に簡単なReactコードを書けばリアルタイムで情報を表示するページが出来ます。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ