使用 ReasonML 中的状态机进行域建模

2025-06-09

使用 ReasonML 中的状态机进行域建模

今天,我们将重点关注以功能性的方式对系统的状态和状态处理逻辑进行建模,而不触及用户界面。

这里“函数式方法”背后的想法是应用函数式模式和构造以便:

  • 正确地对域区域进行建模,使不可能状态无法表示,
  • 处理域逻辑,消除状态上不可能的转换,
  • 创建一个方便的状态操作接口,可以插入任何前端(但不一定)应用程序,
  • 编写可读且简洁的代码(更少的代码 - 更少的错误🐛)。

代码示例是用 编写的,但在其他函数式语言(例如或)ReasonML中看起来非常相似。熟悉一些函数式概念会有所帮助,但这不是必需的。F#Elm

我们将广泛使用的重要概念:

  1. 不变性和纯函数,
  2. 变体和模式匹配,
  3. 部分应用和柯里化。

只是想强调一下,我们不会在这里修改任何东西,因为这可能会导致意外的错误,难以跟踪和调试的系统,以及意外的错误。我提到过意外的错误吗?

概述

假设我们正在构建一个时间跟踪系统,就像一个升级版的待办事项列表,用户可以在其中跟踪完成任务所需的时间。这样的系统可以用作私人项目中的个人工具,也可以用作软件开发人员在工作中的时间报告工具。

我们将创建一个模块来处理该系统的状态,并逐步引入更多概念。本文中我们最好的函数式伙伴是变体(或 中的可区分联合F#和 中的自定义类型Elm)和模式匹配,我们将使用它们来构建状态机。

源代码可在github上找到。

建模任务

根据系统的要求,我们应该能够:

  1. 添加任务,
  2. 启动添加和暂停正在运行的任务,
  3. 将正在运行或暂停的任务标记为已完成,
  4. 跟踪任务运行的时间。

因此,除了拥有name和 之外id,任务还应该知道它是否正在运行、暂停、完成等,我们称之为status。然后我们的task可能看起来像这样:

type task = {
  id: string,
  name: string,
  status: ???,
}
Enter fullscreen mode Exit fullscreen mode

该状态只能分配下列值之一:

  • 尚未开始(刚刚添加),
  • 运行了多少时间,
  • paused 以及暂停前运行了多长时间,
  • 总共花了多少时间完成。

此外,还有一些特殊规则定义任务是否可以将其状态更改为特定值。例如,暂停已完成的任务或启动已运行的任务是没有意义的,但是将暂停或正在运行的任务设置为已完成则完全没问题。而对这些规则进行建模的最佳方法是使用状态机,它……

状态机

由一定数量的(有限的!)状态定义的数学模型,其中它一次只能处于其中一种状态,并且可以根据当前状态和某些输入在状态之间转换。

我对状态机定义的第一反应和“单子”(monad)的定义很像 😨 但是,读者朋友们,千万别让这种事发生在你身上!简单来说,状态机就是一个具有有限数量预定义值的东西,它不能随意改变自身的值,为了选择下一个值,它需要知道当前值和一些外部输入(比如用户操作)。

状态机中的状态转换可以表示为一个函数:

nextState=transition (currentState, input)

我们将我们的status财产建模为状态机,并用ReasonML变体表示其可能的状态:

type elapsed = float;

type taskStatus =
  | NotStarted
  | Running(elapsed)
  | Paused(elapsed)
  | Done(elapsed);
Enter fullscreen mode Exit fullscreen mode

除了 之外的每个状态都会保留一个时间间隔,我们将其用别名NotStarted表示(只是为了清晰起见)。这些就是我们这个小状态机的可能状态。现在,我们可以将有效的状态转换定义如下表所示:floatelapsed

  • 未开始 -> 正在运行
  • 正在运行 -> 已暂停
  • 正在运行 -> 完成
  • 暂停 -> 正在运行
  • 暂停 -> 完成

Running我将再添加一个从到的转换,Running每次计时器滴答后,经过的时间都会增加。状态机接受的输入也可以表示为一个变体:

type input =
  | Start
  | Pause
  | Resume
  | Finish
  | Tick(elapsed);
Enter fullscreen mode Exit fullscreen mode

这里Tick经过的时间将来自应用程序中某处运行的计时器,而所有其他输入值将触发来自用户输入的状态转换(例如,单击 UI 中的“暂停”按钮)。

在我们实现转换功能之前,让我们先看看如何使用在线工具来可视化我们的状态机。

状态机可视化器

展示状态机的状态和转换的一个好方法是用状态图来可视化它们。状态图库中有一个开源可视化工具xstate,它是一个交互式工具,你可以点击状态转换来查看状态是如何变化的。

在这里查看任务状态示例,这里有一个小预览:

xstate 可视化工具


xstate是一个非常强大的库,用于在Typescript/JavascriptReact和绑定的项目中实现状态机Vue

过渡函数

转换函数本身将接受输入类型值和当前状态,并对两个值进行模式匹配,仅匹配有效的对组合(state, input)

let transition = (input, state) =>
  switch (state, input) {
  | (NotStarted, Start) => Running(0.0)
  | (Running(elapsed), Pause) => Paused(elapsed)
  | (Running(elapsed), Finish) => Done(elapsed)
  | (Paused(elapsed), Resume) => Running(elapsed)
  | (Paused(elapsed), Finish) => Done(elapsed)
  | (Running(elapsed), Tick(tick)) => Running(elapsed +. tick)
  | _ => state
  };
Enter fullscreen mode Exit fullscreen mode

最后一种情况将通过返回当前状态来处理所有无效转换。


补充:有人指出,在使用通配符匹配 ( _) 处理默认情况时,很容易错过可能有效的转换。此外,在进行更改和添加状态或输入时,如果没有明确处理所有组合,编译器不会发出任何警告。我完全同意!😅

然而,在具有许多状态和输入的更复杂的系统中,考虑所有组合变得不合理,因为cases 的数量是state * inputs。但是让我们看看如何在我们的小状态机中做到这一点:

let transition = (input, state) =>
  switch (state) {
  | NotStarted =>
    switch (input) {
    | Start => Running(0.0)
    | Pause | Finish | Tick(_) | Resume => state
    }
  | Running(elapsed) =>
    switch (input) {
    | Pause => Paused(elapsed)
    | Finish => Done(elapsed)
    | Tick(tick) => Running(elapsed +. tick)
    | Start | Resume => state
    }
  | Paused(elapsed) =>
    switch (input) {
    | Resume => Running(elapsed)
    | Finish => Done(elapsed)
    | Tick(_) | Start | Pause => state
    }
  | Done(_) => state
  };
Enter fullscreen mode Exit fullscreen mode

稍微冗长一点,但仍然可以管理。


注意,我们如何在转换函数中编码系统的业务规则,允许将正在运行和暂停的任务移至已完成状态。如果这些规则将来需要更改(它们肯定会更改),我们只需要在一个地方进行调整,而且修改必要的转换也相当容易。

说到对系统进行更改并确保没有任何中断,可能是时候测试我们的状态机了,幸运的是,这很容易做到。

测试

我们将使用bs-jest编写测试ReasonML,特别是testAll基于数据列表生成测试的函数。

由于我们可能不想为每个有效和无效转换编写一个测试(这将导致总共 20 个测试),因此我们将testAll只使用两个测试 - 有效和无效状态转换 - 来实现 100% 的覆盖率。

测试有效的转换:

  testAll(
    "Transition works correctly for valid state transitions",
    [
      (NotStarted, Start, Running(0.0)),
      (Running(10.0), Pause, Paused(10.0)),
      (Running(10.0), Finish, Done(10.0)),
      (Paused(10.0), Resume, Running(10.0)),
      (Paused(10.0), Finish, Done(10.0)),
      (Running(10.0), Tick(15.0), Running(25.0)),
    ],
    ((currentState, input, nextState)) =>
    expect(currentState |> transition(input)) |> toEqual(nextState)
  );
Enter fullscreen mode Exit fullscreen mode

这里我们向每个测试输入一个包含三个值的元组:当前状态、输入和预期的下一个状态。

测试无效转换非常相似。我们将传递一个包含状态列表和输入的元组,其中每个组合(状态,输入)都是无效的,并且在应用转换函数后应该导致相同的状态。

  testAll(
    "Transition returns current state for invalid state transitions",
    [
      ([|Running(10.0), Paused(10.0), Done(10.0)|], Start),
      ([|NotStarted, Paused(10.0), Done(10.0)|], Pause),
      ([|NotStarted, Running(10.0), Done(10.0)|], Resume),
      ([|NotStarted, Done(10.0)|], Finish),
      ([|NotStarted, Paused(10.0), Done(10.0)|], Tick(15.0)),
    ],
    ((states, input)) =>
    expect(states->Belt.Array.map(state => state |> transition(input)))
    |> toEqual(states)
  );
Enter fullscreen mode Exit fullscreen mode

概括

状态机有助于正确地对领域进行建模,确保无效状态和转换根本不可能出现。此外,借助xstateVisualiser 等工具,我们可以通过状态图可视化复杂系统中的隐藏状态或边缘情况。

状态机在前端开发中尤其有用,因为富应用通常会包含大量根据用户操作而变化的状态。由于用户可能不会按照你的预期使用你的应用,因此在用户点击错误按钮时消除无效的状态转换可以避免应用崩溃。

事实上,一旦你理解了状态机,你就会随处可见它(别客气!)。然而,支持变体(或可区分联合)的语言才能真正展现状态机的威力和魅力。

下一步

在下一篇文章中,我将展示如何操作一系列任务来实现额外的业务需求,同时使用部分应用和 Reducer 模式编写出美观、紧凑且可读的代码。
即将推出🔜

鏂囩珷鏉ユ簮锛�https://dev.to/margaretkrutikova/modelling-domain-with-state-machines-in-reasonml-n29
PREV
如何使用显示网格使你的 HTML 具有响应性。💯✅
NEXT
如何:mobx-state-tree + react + typescript