租赁金额输入为负数时,页面应显示错误,数据库应保持不变。把金额修正为正数后,页面才显示保存结果。这个交互很小,却同时涉及输入状态、领域规则、数据库事务、HTTP 跳转和浏览器焦点。只验证一个控制器方法返回“成功”,无法证明用户实际看到了与后端一致的结果。

MVC 描述模型、视图与控制器的职责分配。Presentation Model 则进一步把展示状态和展示行为从界面控件中提取出来。本实验使用服务端渲染表单和一个简单的 Presentation 类型:控制器调用上一章的租赁入口,再将业务响应转换为错误提示或跳转决定。表单保持普通 HTML,领域中的金额和时段类型不依赖浏览器或控件库。

视图需要什么状态

租赁领域关心设备、时段和金额是否允许预留。页面还关心当前提示应该使用错误语义还是成功语义、按钮是否可用、用户输入是否应保留。这些信息通常不是合同实体的一部分。若把它们放进领域对象,命令行或批处理也会被迫认识页面提示;若全部写在模板中,展示决策又难以单独检查。

Fowler 的 Presentation Model 将展示状态与行为组织为独立对象,视图负责表现这些状态。它可以服务于多个领域对象,也不要求一张界面只能对应一个实体。本例的 Presentation.from 接收应用返回码,产生状态、中文消息和成功标志;控制器只根据成功标志选择重定向,错误时把消息放进页面。Presentation Model 原文

这个类型是非常小的展示模型,没有实现浏览器端的数据绑定。它不保存每个输入字段,也不提供复杂的启用状态。展示模型的规模应随交互需要增长:存在多个互相影响的选项时,计算字段可用性值得集中;只有几个固定输入框时,先引入完整状态框架会增加同步路径和调试成本。

控制器不重新计算领域规则

浏览器可以通过 required 检查空输入,但它不能替代服务端校验。请求能够绕过页面直接发出,HTML 属性也能被用户修改。金额正值和时间先后关系仍由上一章的应用入口与冻结领域类型检查。页面只是把拒绝结果转换成可理解的说明,不维护第二份租赁合法性判断。

PageServer 接到 POST 后读取有长度上限的表单数据,调用 RentalEndpoint.create。成功时返回 303,让浏览器继续访问一个 GET 地址;失败时返回错误页面。失败页面使用 role="alert",成功页面使用 role="status",两个状态在 DOM 中有不同语义。测试不仅搜索中文提示,也确认失败页面不存在成功消息,避免误把响应体中的固定文本当成业务结果。

将所有非成功状态映射为一条“保存失败”在教学页面中可以运行,但正式产品需要更具体的修正路径。金额错误应指向金额字段,过期状态需要重新读取合同,权限失败则不应鼓励反复点击。展示模型适合承载这些差异;它仍应消费应用定义的结果,不直接解读 SQL 异常字符串。

失败后保留输入也属于展示状态。本例错误页会回到固定默认值,尚未实现用户输入回填;因此浏览器验证只证明错误显示和之后的正确提交。增加回填时,必须对文本做 HTML 转义,并把每个值与对应字段关联。直接把原请求拼回 value 属性,会把展示改进变成注入入口。这个限制在小实验中可见,比宣称“完整表单体验”更有价值。

标签、焦点与键盘

表单有请求编号、金额、币种、开始时间、结束时间五个可见输入框。每个标签的 for 属性对应输入框的 id,按钮具有原生提交语义。使用原生控件可以直接获得常见键盘行为,但仍要在实际浏览器中验证焦点顺序,尤其是后来加入隐藏区域、弹层或自定义按钮之后。

源码检查比较全部可见字段和标签关联,随后把金额标签故意改成不存在的目标,确认检查会拒绝这个变体。静态检查只能证明引用关系,不能证明焦点真的能到达按钮。浏览器场景另外执行从结束时间输入框按 Tab,确认焦点进入保存按钮,再按 Return 完成提交。截图与操作记录分别保存在本章 evidence 目录。

交互中的成功应与真实后端一致。浏览器先提交金额负一,服务端返回校验错误;修正为二十五后,按键提交得到成功提示。后台日志对应两个 POST,只有合法请求出现 INSERT 和 COMMIT。标签、页面提示与 SQL 日志描述的是不同层次,只有把这些证据对应到同一请求序列,才能排除“页面显示成功但服务端没有保存”的问题。

刷新与重复请求

直接用 POST 响应成功页面,刷新时浏览器可能提示再次发送表单。POST/Redirect/GET 把刷新动作变成 GET,减少重复提交的常见入口。本例成功后跳到 /?saved=1,浏览器刷新日志只有 GET,合同数量仍然是一。这个流程改进用户交互,但不能代替业务幂等,因为网络重试和双击仍可能重复发送 POST。

重复保护沿用请求编号。同样的编号和同样的有效载荷返回已有结果,不再插入;编号相同而金额不同会冲突。自动实验会再次提交同样的合同,随后退出服务,在新 JVM 中查询数量。这样同时覆盖浏览器刷新路径和真正重复 POST 的路径,不能把两种重复来源混为一个测试。

成功页的查询参数只是展示标志,任何人都能手动输入,因此不能当成授权凭据或持久业务状态。正式页面应在 GET 中按已认证身份读取合同,并展示可核查的结果摘要。本例只用它观察跳转行为,最终保存结果以数据库读回为准。把“收到成功响应”“当前页面展示成功”“数据库确有合同”分开后,错误定位也更具体。

运行和检查

下载 ZIP 后配置 Java 21,执行 bash run-lab.sh 11。入口编译本批 Java 源码,启动本机 HTTP 服务,通过真实请求验证错误页面、成功跳转和重复提交,然后停止服务并读取文件数据库。命令行检查不冒充浏览器交互;键盘焦点、标签关联和截图另有浏览器记录。

下载本章累计源码 · 校验清单

需要手动打开表单时,运行 bash labs/11/serve.sh /tmp/rental-form-demo 18911,再访问 http://127.0.0.1:18911/。工作目录保存文件数据库,使用新的目录可以从空状态开始。测试完成后终止该服务,重新启动仍能读取之前的合同。端口仅绑定环回地址,这个服务器不是可部署的公开租赁系统。

form-http.json 保存自动 HTTP 响应,form-http-server.log 保存对应请求与 SQL;browser-invalid.png 和 browser-success.png 是真实界面截图。浏览器服务退出后的 browser-restart-readback.log 显示持久合同数量为一。截图证明展示,重启读回证明保存,两者没有相互替代。

读者修改表单时可以保留三个快速反馈。删除标签关联应触发静态拒绝;将失败分支错误地映射成成功应触发响应体断言;取消请求编号检查导致重复落库,则应触发新进程计数断言。键盘行为必须再用浏览器检查,不能通过扫描源码里有没有 button 就推断它仍可操作。

页面不执行客户端脚本,错误响应会导致完整页面刷新。这让请求、响应和页面状态之间的对应关系容易观察,却没有覆盖单页应用中较常见的竞态。例如先发出的预览请求晚于后发请求返回,如果界面直接采纳最后收到的结果,就会显示旧价格。要解决这种问题,展示模型还需要记录请求代次或取消标记,并在响应到达时检查它是否仍对应当前输入。

界面截图也有时间边界。成功截图只说明拍摄时页面显示成功,不能证明稍后数据没有被取消或回滚。把请求编号放入记录、保存服务端日志并执行独立读回,才能将截图与某一次用例关联起来。对更复杂的前端,录制步骤与网络请求会比单张最终截图提供更多故障定位信息。

展示分离的适用范围

这个页面同时使用 MVC 的职责划分与一个轻量展示模型,不代表两种模式互相排斥。控制器负责接收交互、调用用例和选择响应;视图负责标签与布局;展示模型集中提示状态。领域对象仍描述租赁事实。不同框架可能把控制器、页面模型和绑定代码放在不同位置,判断依赖关系比文件名更可靠。

当界面包含动态价格预览、局部保存、多人编辑或离线草稿,展示状态会显著增多。此时需要明确哪些状态可由服务器重建,哪些只存在当前页面,以及失去网络以后显示的旧值是否允许继续提交。把界面状态全部复制进合同数据库会混淆临时编辑与正式事实;完全留在控件中,又会使恢复和测试困难。

本例尚未实现认证、CSRF 防护、错误字段定位、输入保留和无障碍审计全覆盖。它验证的是一个更窄的交互链:非法输入不会显示成功,修正后能通过键盘提交,刷新不会产生新合同,且新 JVM 能读取保存结果。后续增加交互时,应沿着这条链扩展证据,而不是只增加更多模板判断。

参考资料