1. HOME
  2. テックブログ
  3. XStateの状態の永続化をお試し

XStateの状態の永続化をお試し

2022/12/14 テクノロジー

ステートマシンのライブラリのXStateについて。サーバサイドで永続化して使う様なユースケースについて、小さなアプリケーションを作って試しましたので、内容を共有します。

XStateの紹介

XStateというJavaScriptのライブラリがあります。「ステートチャート(状態遷移図)」の拡張記法である「SCXML」の仕様を踏襲した独自のJSON記法でステートマシンを実装できるものです。

https://xstate.js.org/

ビジュアライザーが用意されているため、それを使ってステートマシンの挙動を動作させ、確認しながらロジックのデザインができて捗ります。また、デザインに使ったJSON記述がそのまま実装に使えるため無駄がありません。

WEBアプリケーション開発のさまざまな箇所で、宣言的な記法(DSLなど)を使って開発者体験が良くなってきています。ステートマシンはフロントエンドのUIの挙動から、サーバサイドでのビジネスロジックまで、適用できる箇所が多いため、事前に使い慣れておくと良さそうです。

お試しの背景

  1. 「決裁ワークフロー」の様な、いわゆる「ワークフロー」をWEBアプリケーションのインタフェースで作ることができる。
  2. ワークフローの実行(適用)段階では、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>
  )
}

動作の様子

追加

状態更新

永続化

ファブリカコミュニケーションズで働いてみませんか?

あったらいいな、をカタチに。人々を幸せにする革新的なサービスを、私たちと一緒に創っていくメンバーを募集しています。

ファブリカコミュニケーションズの社員は「全員がクリエイター」。アイデアの発信に社歴や部署の垣根はありません。

“自分から発信できる人に、どんどんチャンスが与えられる“そんな環境で活躍してみませんか?ご興味のある方は、以下の採用ページをご覧ください。

◎ 新卒採用の方はこちら
◎ キャリア採用の方はこちら

この記事を書いた人

大西 秀典
プロダクト開発本部 IT戦略統括部長兼CDO
大西 秀典

おすすめの記事