Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

构建完整 Todo 应用

本教程使用 isomorphic 模板连接共享契约、原生状态和 Rabbita 界面。先运行生成的 Todo 应用,再跨三个模块加入 Complete all 和 Reopen all 操作。

请先完成环境准备,包括 Warren 构建工具需要的 Node.js。无需修改之前的 minimal 应用。

创建并体验应用

在另一个 MoonBit 工作区之外执行:

proton_cli new todo-app --template isomorphic --yes
cd todo-app
moon update
moon install moonbit-community/warren@0.4.5
proton_cli cef setup

生成的配置已经调用安装好的 Warren 可执行文件。启动应用:

proton_cli dev

添加“Alpha”和“Beta”,标记其中一个完成,再搜索它。生成的应用已经支持创建、完成、删除和过滤查询。列表保存在内存中,重启后会清空。

编辑前先关闭应用。保留生成的模块名与包导入,以下修改均在现有模板上追加。

模板组织

isomorphic 模板采用 MoonBit 前端,包含三个模块:

todo-app/
  moon.work
  proton.project.json
  shared/
    moon.mod
    moon.pkg
    todo_contract.mbt
  backend/
    moon.mod
    app/
      moon.pkg
      main.mbt
    todo/
      moon.pkg
      backend.mbt
      commands.mbt
  frontend/
    moon.mod
    main/
      moon.pkg
      main.mbt
    internal/query/
    public/

根目录的 moon.work 连接三个模块:

  • shared 定义可序列化的载荷,以及命令、事件描述符,供两个构建目标使用。
  • backend 编译为原生代码。todo/backend.mbt 保存 Todo 状态和业务操作,todo/commands.mbt 绑定操作,app/main.mbt 启动应用。
  • frontend 编译为 JavaScript。main/main.mbt 定义 Rabbita 界面,public/ 保存 HTML 和样式表。

模板中的 frontend/internal/query 管理查询状态与订阅。它属于生成的应用,可以随应用一起修改,并不是 Proton 公共 API。

应用命令通过普通 bind 调用注册,这个模板无需命令代码生成规则。

理解已有流程

已有的创建操作中,Create 调用 create_todo,后端校验并修改状态,MutationReply 返回成功或业务拒绝,todos_changed 让前端快照失效并触发查询。

前端草稿是本地 UI 状态,后端列表是应用状态的权威来源。新操作应修改后端完整列表,而不只是当前搜索结果中可见的行。

1. 扩展共享契约

在 shared/todo_contract.mbt 末尾追加:

///|
pub(all) struct SetAllCompletedRequest {
  completed : Bool
} derive(ToJson, FromJson)

///|
pub extend SetAllCompletedRequest with ToJson::{to_json}

///|
pub extend SetAllCompletedRequest with FromJson::{from_json}

///|
pub let set_all_completed : @proton_contract.Command[
  SetAllCompletedRequest,
  MutationReply,
] = @proton_contract.command("set_all_completed")

布尔值选择完成或重新打开。复用模板的 MutationReply,可以继续使用现有响应处理逻辑。两个构建目标导入同一个描述符。

2. 实现状态修改

在 backend/todo/backend.mbt 末尾追加:

///|
fn Backend::set_all_completed(
  self : Backend,
  completed : Bool,
) -> @shared.MutationReply {
  let mut changed = false
  for index = 0; index < self.todos.length(); index = index + 1 {
    let todo = self.todos[index]
    if todo.completed != completed {
      self.todos[index] = { ..todo, completed, }
      changed = true
    }
  }
  if changed {
    self.version += 1
  }
  @shared.Changed(version=self.version)
}

操作遍历完整列表,有实际变化时只将修订号增加一次。重复相同操作不会改变修订号;即使列表为空,也返回当前版本。

3. 注册处理器并通知数据失效

在 backend/todo/commands.mbt 的已有 Backend::register_commands 方法内,与其他绑定并列添加:

registrar.bind(@shared.set_all_completed, (_context, request) => {
  let reply = self.set_all_completed(request.completed)
  self.notify(reply)
  reply
})

保留原有绑定。self.notify 是生成后端中已有的辅助方法,会向应用窗口生命周期中保存的事件目标发送 todos_changed。

这里不需要新事件类型。它与其他 Todo 修改一样改变了状态,已有观察者重新执行相同查询即可。

4. 接入前端操作

本步骤全部修改 frontend/main/main.mbt。

在已有的 enum Msg 内添加一个分支:

SetAllCompleted(Bool)

在 update 已有的 match msg 内添加以下分支:

SetAllCompleted(completed) =>
  (
    { ..model, error: None, },
    @proton_rabbita.invoke(
      @shared.set_all_completed,
      { completed, },
      reply => emit(MutationReceived(reply)),
      error => emit(CommandFailed(error)),
    ),
  )

model、emit、MutationReceived 和 CommandFailed 已由该函数与模块定义。成功路径复用模板的修改响应处理,错误路径继续显示 bridge 失败。

在 view 中找到 div(class="todo-toolbar", [...]),将下面两个按钮追加到其子元素数组中,放在已有 Refresh 按钮之后:

button(
  type_="button",
  on_click=emit(SetAllCompleted(true)),
  "Complete all",
),
button(
  type_="button",
  on_click=emit(SetAllCompleted(false)),
  "Reopen all",
),

模板已经导入 button,无需增加导入。消息中的布尔值经由请求传到后端。

5. 检查行为

在 backend/todo/backend_wbtest.mbt 中追加这个回归检查:

///|
test "bulk completion changes the whole list once" {
  let backend = Backend()
  ignore(backend.create("Alpha"))
  ignore(backend.create("Beta"))
  assert_true(backend.set_all_completed(true) is @shared.Changed(version=3))
  assert_true(backend.snapshot("").todos.all(todo => todo.completed))
  assert_true(backend.set_all_completed(true) is @shared.Changed(version=3))
  assert_true(backend.set_all_completed(false) is @shared.Changed(version=4))
  assert_true(backend.snapshot("").todos.all(todo => !todo.completed))
}

在项目根目录执行:

moon check --target js,native
moon -C backend test todo --target native
moon -C frontend test --target js
proton_cli dev

添加两个待办项,Complete all 应将两项全部完成,Reopen all 应重新打开两项。搜索到其中一项后重复操作,再清空过滤条件,应看到两项都发生变化。重复点击 Complete all 不应继续增加修订号。

如果后端变化但显示不更新,检查是否保留 self.notify 和窗口 attach/detach 钩子。如果新命令不可用,检查注册代码并重启后端。

界面如何保持最新

模板的 todo_app 创建 list_todos 查询,并用 todos_changed 使其失效。应用内的查询辅助包先订阅,再获取数据,在输入变化时取消被替代的旧请求。

修改响应刷新发起操作的界面,事件则使观察者的数据失效。两者可能重叠,所以辅助包跟踪请求代次,避免旧结果覆盖新的搜索。这些是 frontend/internal/query 中的应用代码,不是另一个服务器,也不是 Proton 的公共状态管理框架。

构建完成后的应用

停止开发进程,先执行 proton_cli build。下一步按宿主平台选择;以下命令都在 todo-app 项目根目录执行。

macOS 与 Windows

本地验收先生成未签名的 app 格式;Windows 的 app 是包含 EXE 和依赖的目录,不是 macOS bundle。

proton_cli package --release --format app --dry-run
proton_cli package --release --format app

打开 CLI 输出的 dist 下的应用路径。macOS 从 .app 启动,Windows 从产物目录中的应用 EXE 启动,不能只复制 EXE。本步骤不需要 NSIS,也没有完成面向用户分发的签名/公证。

Linux

默认模板不附带图标。将一张实际有效的 PNG 图标保存为项目内的 icons/app.png,并在 proton.project.json 的已有 package 对象中加入:

"icons": ["icons/app.png"]

确认 appimagetool --version 能执行。若尚未安装,可在 Ubuntu x64 终端执行以下命令,将工具保存在用户目录中;解包方式无需依赖 FUSE 来运行打包工具:

mkdir -p "$HOME/.local/lib/proton-appimagetool"
cd "$HOME/.local/lib/proton-appimagetool"
curl -fL -o tool.AppImage https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage
echo 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0  tool.AppImage' | sha256sum -c -
chmod +x tool.AppImage
./tool.AppImage --appimage-extract
ln -sf squashfs-root/AppRun appimagetool
export PATH="$HOME/.local/lib/proton-appimagetool:$PATH"
appimagetool --version

回到 todo-app 项目根目录,再执行:

proton_cli package --release --format appimage --dry-run
proton_cli package --release --format appimage

从图形会话启动输出的 AppImage。运行环境无法挂载 AppImage 时,可先对产物执行 --appimage-extract,再运行生成目录中的 AppRun。

验收

在开发服务器已经停止的情况下启动产物,添加两个项目并验证 Complete all、Reopen all 和搜索操作。打包失败、启动失败与按钮行为失败是不同阶段;仅 dry-run 成功不算完成。

本教程的列表保存在内存,重启清空是预期行为。持久化应在后端实现。图标与分发要求见打包参考。