企业应用架构17:Request-Reply关联与超时恢复
两个合同同时等待预留结果,第二个请求先收到答复,第一个请求在等待期限以后仍没有答复。按队列接收顺序配对,会把设备预留结果交给错误合同;把超时直接写成失败,则可能在设备已经预留以后再次发起预留。 本篇保留两条尚未完成的请求,使用真实消息通道逆序返回结果,并在请求进程退出后恢复关联状态。服务端已经提交而回复迟到的场景,通过查询持久化结果恢复,超时本身不承担判断业务成功与否的职责。 三种标识对应三种问题 Request-Reply 以一条请求和对应答复组织交互,Correlation Identifier 则让接收者判断答复属于哪次请求。这不要求答复按请求发送顺序到达,也不要求调用方始终占用一个线程等待。Request-Reply;Correlation Identifier 实验的合同编号为 R1、R2,请求关联编号为 C1、C2,发送消息编号为 M1、M2。合同编号用于业务查询,关联编号区分一次请求,消息编号识别一次业务报文。三者在这个最小样本里一一对应,却不应因此合并:同一合同可能经历多次查询或不同阶段的请求。 JMSCorrelationID 携带 C1 或 C2,消息正文携...
企业应用架构16:拆单路由与交付结果聚合
一份租赁合同包含相机与投影仪,两种设备由不同端点处理。相机完成消息到了两次,投影仪的结果一直没有到。如果聚合器只计算收到了几条消息,合同就可能在缺少一件设备时被标为全部完成。 本篇用真实 ActiveMQ 通道传递拆分任务和完成结果,H2 保存批次与明细。完成条件按合同、交付批次和设备任务判断,实验依次观察乱序、重复、缺项、超时及上一批次的迟到结果。 拆分之前固定完成条件 Splitter 把复合消息拆成可以独立处理的部分;Content-Based Router 根据内容选择通道;Aggregator 再按关联标识与完成条件收集结果。这里需要先定义“全部”,再决定如何拆分,否则最后一条到达的消息没有资格宣布整份合同完成。Splitter;Content-Based Router 实验在批次创建时保存预期任务数。合同 R1 的 B1 批次包含相机 E1 和投影仪 P1,预期为两项;合同 R2 的 B1 批次只有相机 E2,预期为一项。批次号可以在不同合同内重复,因此数据库主键由合同号和批次号共同组成。 每项任务也有独立标识,例如 R1/B1/E1。它既出现在持久化明细表,也作为...
企业应用架构15:Channel、Endpoint与Translator的真实消息边界
租赁合同引用设备 E1,维修系统识别的资产却叫 ASSET-E1。直接把合同对象序列化后发送,既暴露内部字段,也没有解决两套标识之间的关系。消息能够送达,只是集成的一部分;接收方还需要知道它属于哪种报文、该交给哪个端点,以及不能处理时保留在哪里。 本篇将设备维修申请放进真实 ActiveMQ 通道,经 TCP 交给独立消费进程,再将翻译结果写入 H2。消费者离线、broker 停启、未知版本、错误通道和毒消息分别运行,业务拒绝与基础设施重试保留不同证据。 通道与端点承担不同职责 EIP 的 Message Channel 讨论应用之间传输消息的通路;Message Endpoint 是应用与消息系统连接的边界;Message Translator 则处理报文格式差异。它们可以组合,但不能仅根据一条队列名称就认定转换规则已经存在。Message Channel;Message Translator 实验固定 maintenance.request 为维修申请通道。租赁方发送外部合同编号与设备引用,维修端点将 E1 翻译成 ASSET-E1,并生成维修申请文本。这个前缀规则仅服务于...
企业应用架构14:幂等、Outbox与Inbox的退出恢复
合同提交成功以后,发布进程可能尚未发送消息就退出。接收方已经记账以后,也可能尚未确认消息就退出。前一种情况可能漏发,后一种情况会引发重复投递。只在正常流程里调用一次发送函数,无法说明这两个故障窗口怎样恢复。 本实验把业务、待发记录、传输队列和接收方结果分别写入真实文件数据库。业务事务原子写合同与 Outbox,独立发布 JVM 把消息写进持久 SQL 队列,独立消费 JVM 用 Inbox 与记账更新组成另一个事务。驱动在三个已提交位置调用进程级强制退出,再启动新进程读取和恢复,每一步保留实际 SQL 与退出码。 同一事务能够解决的范围 Transactional Outbox 模式要求业务更新与消息记录在同一个数据库事务中持久化,再由另外的发布器发送。这样避免了“业务已提交但没有任何待发依据”的空隙。发布器本身仍可能重复发送,因此消费者需要幂等处理。Transactional Outbox 原文 本例 sender 数据库中有合同表与 Outbox 表。合同编号 R1 对应整数金额二十五,Outbox 的消息编号 M1 携带相同金额。一次事务插入两条记录并提交,然后进程立即退...
企业应用架构13:Event Sourcing与持久事件重放
合同当前状态是“已取消、累计五天”,并不能说明它最初租了几天、何时延期、为什么保留这个计费长度。普通状态存储可以另加审计日志,但事件溯源采用更强的约定:事件序列本身就是恢复当前状态的事实依据。应用状态可以删除,只要事件流完整、解释规则一致,就应能重新计算出来。 这个约定增加了历史解释责任。过去保存的事件会长期参与恢复,新代码不能随意改变旧事件含义。重放还必须避免再次执行外部动作,否则一次修复读模型可能重新发出取消通知。本实验用真实 H2 事件表、版本条件追加、纯状态折叠和独立进程恢复,把这些要求分别变成二值检查。 事件流与审计记录 Fowler 的 Event Sourcing 文章讨论了通过事件序列重建应用状态,以及事件处理与外部系统交互之间的边界。审计日志可能只是已经更新完状态后的说明;事件溯源则要求它能够作为重建输入,遗漏一条必要事件就可能无法恢复正确状态。Event Sourcing 原文 本例事件具有流内版本、事件编号、种类、格式版本和一个整数载荷。合同创建、延期、取消各占一条。当前状态用 State 表示版本、天数和状态,派生表仅保存重放结果。事件表以流版本作为主键...
企业应用架构12:CQRS与可重建读模型
合同写入模型需要检查金额、版本和状态变化;待交付清单则希望直接获得适合展示的列。两种需求长期挤在同一对象里,会使查询越来越依赖内部关联,也会使修改合同的代码背负展示结构。CQRS 把命令与查询所用模型分开,允许它们按各自需求演进。是否把两条路径放在不同数据库、不同进程,仍然需要单独决定。 本实验保留一个合同真值表和一张变化记录表,再用独立 JVM 生成交付清单。停止投影时,写入仍然成功,查询结果会落后;重新运行投影进程后,清单追赶到最新版本。所有状态都写入真实 H2 文件库,水位与读模型在同一事务内推进,删除投影后还能从保留的变化记录重建。 分离到什么程度 Fowler 的 CQRS 文章讨论了分别使用模型处理更新和显示的选择,也提醒这种做法会增加复杂度。简单应用可以先使用同一数据库、同步更新的两个模型;只有查询结构、负载或可用性要求形成实际差异时,才有理由引入异步投影。CQRS 原文 本例把写模型中的合同金额与展示用的交付清单分成两张表。合同表含版本,变化表记录序号、合同编号和金额;清单只保留查询需要的编号与金额。两张表在同一个文件数据库中,但投影由另一个进程执行。这足以暴露...
企业应用架构11:MVC、Presentation Model与展示分离
租赁金额输入为负数时,页面应显示错误,数据库应保持不变。把金额修正为正数后,页面才显示保存结果。这个交互很小,却同时涉及输入状态、领域规则、数据库事务、HTTP 跳转和浏览器焦点。只验证一个控制器方法返回“成功”,无法证明用户实际看到了与后端一致的结果。 MVC 描述模型、视图与控制器的职责分配。Presentation Model 则进一步把展示状态和展示行为从界面控件中提取出来。本实验使用服务端渲染表单和一个简单的 Presentation 类型:控制器调用上一章的租赁入口,再将业务响应转换为错误提示或跳转决定。表单保持普通 HTML,领域中的金额和时段类型不依赖浏览器或控件库。 视图需要什么状态 租赁领域关心设备、时段和金额是否允许预留。页面还关心当前提示应该使用错误语义还是成功语义、按钮是否可用、用户输入是否应保留。这些信息通常不是合同实体的一部分。若把它们放进领域对象,命令行或批处理也会被迫认识页面提示;若全部写在模板中,展示决策又难以单独检查。 Fowler 的 Presentation Model 将展示状态与行为组织为独立对象,视图负责表现这些状态。它可以服务于...
企业应用架构10:DTO、Assembler与远程边界
合作方提交一次租赁预留,需要提供设备、时段、金额和请求编号。把内部合同对象直接序列化,很容易把数据库主键、租户归属或持久化版本一起变成外部接口。以后修改实体结构时,原本只影响数据库适配器的变更,也会变成合作方升级。DTO 的实际作用是给这个变化设置一个明确边界:远程请求只携带完成用例所需的数据,应用代码决定怎样构造内部对象,响应只暴露经过选择的结果。 本实验沿用系列中的租赁语义,但只实现单租户、单设备的教学端点。运行时固定 Java 21 与 H2 2.1.214,HTTP 使用 JDK 自带的 HttpServer。请求编码选用标准表单编码,响应使用一个字段集合固定的 JSON 对象。这样可以直接观察编码、解析、组装和落库四个环节,也避免把序列化框架配置当成 DTO 模式本身。 一次远程调用携带哪些数据 Fowler 的 DTO 条目把减少远程方法调用次数作为主要动机,并把序列化机制的封装列为额外好处。租赁接口如果分别请求合同编号、金额、币种,就需要三个往返。一次返回三个字段,调用次数可以降为一次。这是接口形状产生的差别;本机环回请求的耗时无法说明跨地域网络能节省多少毫秒。D...
企业应用架构09:乐观、悲观并发与事务边界
两个客服都查到同一台设备可租,随后各自确认请求。两个事务可能都正常提交,数据库没有语法错误,却留下两份重叠预约。企业应用架构08:聚合映射、继承与Lazy Load 检查了映射与加载,本篇让两个真实连接按受控顺序交错,比较无保护、版本校验和显式锁。 并发测试必须让两份旧观察同时存在 实验使用两个线程,每个线程单独创建 JDBC 连接,显式关闭自动提交并设置 READ_COMMITTED。屏障确保两个线程都完成读取,闩锁再控制第一个事务提交后,第二个事务使用此前读到的值继续执行。它们不是在单线程里依次读到最新数据的两个函数调用。 SQL 日志保留连接编号、线程名称、绑定版本、受影响行数和提交回滚。每个等待都设置上限,调度条件不成立时测试失败,不会无限等待。正常运行要求两个连接编号不同,避免错误地把共享一个连接的串行操作称作数据库并发。 首先测试一个金额累加教学场景。初值为 25.00,两个事务分别要增加 5.00 和 7.00,期望都被接受时最终为 37.00。两者先读取 25.00,第一个写 30.00 并提交,第二个继续根据旧值写 32.00,最终读回 32.00,第一份增量...
JPA 持久化 E02:乐观锁以外的并发边界
乐观锁以外的并发边界:同一行与跨行约束不是一回事 两名审批员同时修改一份采购申请,@Version 很有用:一个事务提交后,另一个不能静悄悄覆盖这份申请的旧版本。问题是租户每月预算一万元时,两份各六千元的申请分别由两名审批员下单:它们写的是不同申请行,两个版本号都可以合法增加,但合计已经超额。另一个看起来相似的问题是两个人同时给同一份申请建订单,这时每申请唯一订单约束比版本号覆盖得更直接。选修 E02 以 07 篇的本地事务与冲突 和 09 篇的综合应用 为先修,把行版本、唯一键、悲观锁、隔离级别分别放在各自能够保护的边界上。 本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。 仍使用 examples/jpa 的 Java 21、Jakarta Persistence 3.2、Hibernate ORM 7.1.36.Final、PostgreSQL 16 和 pgJDBC 42.7.7。本工程只配置 RESOURCE_LOCAL;迁移脚本 有 purchase_request.version、purchase_order.r...










