TanStack DBを調べていた際に、そこで使われているElectricに興味を持ったので調べてみました(TanStack DBに付いては、いずれ書きます)。
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
)も用意されています
- React専用API(
- Electricはデータの呼出しだけで動作します、データベースへのinsert、update、delete等の機能は持っていません
- 言い換えるとデータベース周りのコードは既存のコードや既存の作成方法が使えます、特殊なライブラリーをかます必要がありません
- 同期する単位はShapeと呼ばれる、データベースのテーブルと取得条件などを指定した範囲で行われます
- ElectricのサーバーはElixirで書かれています。素晴らしい😄
いつものジャンケンアプリをElectric対応にしてみました
下の画像にあるように、アプリケーションバーの右にプレイヤーの選択肢を追加しましした。
- ①のプレイヤーはJames で Jamesの対戦結果のみ表示されています
- ②③のプレイヤーはMaryです。②でジャンケンを行うと③のウィンドウにもリアルタイムで結果が表示されます
環境構築
開発環境の作り方は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コード
概要
- React: バージョン 19.1
- 開発環境: Vite
- ルーティング: TanStack Router
- 広域ステート管理: Jotai
- スタイリング: Tailwind CSS
ファイル構成
├── 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 QueryやSWRのように通信+ステート管理+キャッシュ管理を含むライブラリーです
- 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コンポーネントを使っています
- ③ プレイヤー名(文字列)の配列
nameList
がlibs/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コードを書けばリアルタイムで情報を表示するページが出来ます。