XStateの状態の永続化をお試し
ステートマシンのライブラリのXStateについて。サーバサイドで永続化して使う様なユースケースについて、小さなアプリケーションを作って試しましたので、内容を共有します。
XStateの紹介
XStateというJavaScriptのライブラリがあります。「ステートチャート(状態遷移図)」の拡張記法である「SCXML」の仕様を踏襲した独自のJSON記法でステートマシンを実装できるものです。
ビジュアライザーが用意されているため、それを使ってステートマシンの挙動を動作させ、確認しながらロジックのデザインができて捗ります。また、デザインに使ったJSON記述がそのまま実装に使えるため無駄がありません。
WEBアプリケーション開発のさまざまな箇所で、宣言的な記法(DSLなど)を使って開発者体験が良くなってきています。ステートマシンはフロントエンドのUIの挙動から、サーバサイドでのビジネスロジックまで、適用できる箇所が多いため、事前に使い慣れておくと良さそうです。

お試しの背景
- 「決裁ワークフロー」の様な、いわゆる「ワークフロー」をWEBアプリケーションのインタフェースで作ることができる。
- ワークフローの実行(適用)段階では、WEBアプリケーションが「業務フロー」で規定されている制約(ロジック)を実現する。
この様な「ワークフローを定義して実行する」というアプリケーションをデザインする機会が増えてきました。プログラミングを行わずにシステムを柔軟に変更したいというノーコード的な要請によるものです。
ReactなどのUIライブラリが広く使われる様になり、Webの従来技術だと実装が面倒だった複雑なインターフェースも比較的容易に実現が可能になり、エンドユーザが使えるレベルまで洗練することができる(=つまりノーコード)ため、この様なデザインパターンが使える様になってきたのだと思います。
今後もソフトウェア開発において、この「インターフェースで可能な範囲で、エンドユーザが直接的にシステムの挙動を定義する」というパターンが適用されることが増えてくるのではないでしょうか。
さて、その様なアプリケーションを実装する際に、そのワークフローの定義と実装をどうするか課題になります。「ステートチャートをそのまま使えば良いじゃない」と考える人も多いと思いますので、その足がかりとなる小さいアプリケーションを作って試してみました。
XStateの状態の永続化について
先に書いておくと、特に情報が少なかったのが、XStateの状態の永続化に関わる部分です。公式ドキュメントでは、次の箇所に記載がありましたが、少し情報が足りなかったです。二つ目のリンクはライブラリに付属の実行環境(サービス)での機能です。service.start()でサービスを開始する際にステートを与えられて、ハイドレートするのに便利という旨が書かれています。
https://xstate.js.org/docs/guides/states.html#persisting-state
https://xstate.js.org/docs/guides/interpretation.html#starting-and-stopping
今回、主に試したかった部分は、サーバサイドにステートマシンがあり、クライアントからのリクエストに応じて、状態を変更してDBに保存するというところです。付属の実行環境を使って、この部分だけ抜き出して書いておくと次の様になります。
1. State.createでJSONから状態オブジェクトを復帰
2. service.startの引数として与える。
3. ここではNEXTというトリガーを送信
4. service.stop()を呼ぶ
5. service.getSnapShot()で永続化する状態オブジェクトを得る
ポイントとしては、4の service.stop を5の service.getSnapShot より先に呼ぶ 必要があるところでした。そうしないと、3のトリガーが次回にstartした時にもう一度実行されてしまい、意図した挙動になりませんでした。
const currentStateStr = ... //DBから取得済みのStringified
const currentState = State.create(JSON.parse(currentStateStr))
const service = interpret(collatzProblemMachine).start(currentState) service.send("NEXT")
service.stop()
const nextState = service.getSnapshot()
const nextStateStr = JSON.stringify(nextState)
お試しした内容
CollatzProblemのステートマシンをサーバサイドに実際し、クライアント側から操作する様なアプリケーションを作ります。CollatzProblemというのは、プログラミングの練習でよく使われるもので「ある数が偶数の場合は2で割る。奇数の場合は3をかけて1を加えるという手続きを行い、値が1になったら停止する」というプログラムを作るものです。
今回は、サーバサイドが複数のCollatzProblemマシンを永続化していて、クライアントはサーバから受けた値を表示するだけにして。またクライアントからは状態を変更したいマシンに「次の数値を計算する」という要求を出す様にします。
バックエンド
1. CollatzProblemマシンを生成し初期状態をDBに保存し、成功可否を返す。
2. CollatzProblemマシンの状態を復帰して、NEXTをトリガーし、新しい状態をDBに保存して成功可否を返す。
3. 全てのCollatszProblemマシンの状態の状態を返す。
4. 全てのステートマシンを削除して成功可否を返す。
フロントエンド
1. 新しいCollatzProblemステートマシーンの生成を要求する
2. 全てのステートマシーンを破棄を要求する
3. ステートマシンを表現して、現在の状態を描画する
4. 特定のステートマシンを選択し「NEXT」をトリガーすることを要求する
5. 4はステートマシンが終了状態になったら使えないこと
動作サンプル
https://github.com/hidenoriohnishi/xstatesample
ステートマシン
import { createMachine, MachineConfig, MachineOptions } from "xstate"
export type CollatzProblemMachineContext = {
value: number
count: number
}
export type CollatzProblemMachineEvent = {
type: "NEXT"
}
const config: MachineConfig<CollatzProblemMachineContext, any, CollatzProblemMachineEvent> = {
predictableActionArguments: true,
initial: "idle",
states: {
idle: {
on: {
NEXT: {
target: "proc",
actions: "operate",
}
}
},
proc: {
always: [
{
target: "done",
cond: "isOne",
},
{
target: "idle",
}
],
},
done: { type: "final" }
}
}
const option: MachineOptions<CollatzProblemMachineContext, CollatzProblemMachineEvent> = {
guards: {
isOne: (ctx) => ctx.value === 1
},
actions: {
operate: (ctx) => {
if (ctx.value % 2) ctx.value = ctx.value * 3 + 1
else ctx.value = ctx.value / 2
ctx.count += 1
}
}
}
export const collatzProblemMachine = createMachine(config, option)
バックエンド:API Route
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from "../../../src/prisma"
import { tryit } from "../../../src/helper"
import { State, interpret } from "xstate"
import { collatzProblemMachine, CollatzProblemMachineContext, CollatzProblemMachineEvent } from '../../../src/machines/CollatzProblemMachine'
type ResponseData = {
success: boolean
message?: string
data?: {}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
const [, id] = tryit(Number)(req.query.id)
if (!id) {
res.status(400).json({ success: false, message: "id is required" })
return
}
switch (req.method) {
case "GET": {
const data = await prisma.machine.findUnique({ where: { id } })
if (!data) {
res.status(404).json({ success: false, message: "machine not found" })
} else {
res.status(200).json({ success: true, data })
}
break
}
case "POST": {
const current = await prisma.machine.findUnique({ where: { id } })
if (!current) {
res.status(404).json({ success: false, message: "machine not found" })
return
}
const currentState = State.create<CollatzProblemMachineContext, CollatzProblemMachineEvent>(JSON.parse(current.data))
const service = interpret(collatzProblemMachine).start(currentState)
service.send("NEXT")
service.stop()
const nextStateStr = JSON.stringify(service.getSnapshot())
const machine = await prisma.machine.update({
where: { id },
data: {
data: nextStateStr
}
})
res.status(200).json({ success: true, data: machine })
break
}
default:
res.status(400).json({ success: false, message: "method not allowed" })
}
}
フロントエンド
import { Box, Container, VStack, Button, HStack, Heading, Spacer, Text } from "@chakra-ui/react"
import useSWR from "swr"
export default function Home() {
const { data, error, mutate } = useSWR("/api/machine", (url: string) => fetch(url).then(res => res.json()))
return (
<Container mt={6}>
<HStack>
<Heading>Test</Heading>
<Spacer></Spacer>
<Button
colorScheme="teal"
onClick={async () => {
await fetch("/api/machine", { method: "POST" })
await mutate()
}}
>
Add
</Button>
<Button
colorScheme="pink"
onClick={async () => {
await fetch("/api/machine", { method: "DELETE" })
await mutate()
}}
>
Clear All
</Button>
</HStack>
{error ? <Box>Error loading data:{JSON.stringify(error)}</Box> : null}
{!error && !data ? <Box>Loading...</Box> : null}
{data && (
<VStack spacing={3} w={"full"} rounded={"md"} m={3} alignItems={"flex-start"}>
{data?.data &&
data.data.map((machine: any, index: number) => (
<HStack key={index}>
<Button
minW={"5rem"}
onClick={async () => {
await fetch(`/api/machine/${machine.id}`, { method: "POST" })
await mutate()
}}
disabled={JSON.parse(machine.data).context.value == 1}
>
{machine.name}
</Button>
<Text>{JSON.parse(machine.data).context.count}</Text> /
<Text>{JSON.parse(machine.data).context.value}</Text>
</HStack>
))}
</VStack>
)}
</Container>
)
}
動作の様子
追加

状態更新

永続化

ファブリカコミュニケーションズで働いてみませんか?
あったらいいな、をカタチに。人々を幸せにする革新的なサービスを、私たちと一緒に創っていくメンバーを募集しています。
ファブリカコミュニケーションズの社員は「全員がクリエイター」。アイデアの発信に社歴や部署の垣根はありません。
“自分から発信できる人に、どんどんチャンスが与えられる“そんな環境で活躍してみませんか?ご興味のある方は、以下の採用ページをご覧ください。