17-Row._t/_o:脏标记的核心设计
browise数据协议的最小单元是Row——一个Map装数据、一个Map装旧值、一个枚举装状态。177行代码,_t/_o两个协议字段的所有语义都在这里。这篇逐行拆setItemValue的状态机分支和original的"首次记录不覆盖"规则。
文章目录
- 17-Row._t/_o:脏标记的核心设计
- 一、三个字段撑起一个数据结构
- 二、setItemValue:11行的状态机
- 三、为什么不用"上一次值"实现undo栈
- 四、三个读方法的语义分层
- 五、getMap返回引用不是副本
- 六、toRaw与序列化的边界
源码:
browise-data/src/main/java/com/browise/data/Row.java(177行)
一、三个字段撑起一个数据结构
publicclassRow{privateRowStatusstatus=RowStatus.NONE;// _tprivatefinalMap<String,Object>original=newLinkedHashMap<>();// _oprivatefinalMap<String,Object>data=newLinkedHashMap<>();// 业务字段}RowStatus枚举四个值:
NONE=0// 未修改——查询加载后的默认状态INSERT=1// 新增——add()创建的行UPDATE=3// 修改——NONE行被改字段后自动转DELETE=4// 删除——被remove()移入delete缓冲的行(跳过2不是疏忽——wiserise时代2曾表示"已存在的新行",browise简化后保留编号间隔兼容旧协议)
二、setItemValue:11行的状态机
publicvoidsetItemValue(Stringkey,Objectvalue){if(status==RowStatus.NONE){// 首次修改该字段时保存原始值if(!original.containsKey(key)&&data.containsKey(key)){original.put(key,data.get(key));}status=RowStatus.UPDATE;// NONE → UPDATE}data.put(key,value);}四个分支行为:
| 当前状态 | original处理 | status处理 |
|---|---|---|
| NONE | 首改字段存旧值 | → UPDATE |
| UPDATE | 不再记录(保持最早旧值) | 不变 |
| INSERT | 不记录(新行没有"旧值"概念) | 不变 |
| DELETE | 不记录 | 不变 |
两个精妙点:
①"首次记录不覆盖"——
Rowrow=newRow(Map.of("name","张三"));row.setItemValue("name","李四");// original={name:张三}, data={name:李四}row.setItemValue("name","王五");// original仍是{张三}!data={王五}original存的是最初值不是"上一次值"。为什么?——rejectChanges要恢复到查询加载时的样子,不是"用户改了几手后"的样子。_o是回滚锚点,锚点必须钉在最初。
②INSERT行不记original——新add的行,字段值都是用户刚填的,"旧值"毫无意义。rejectChanges对INSERT行的处理是整行丢弃(第18篇)——没有字段级恢复的需求。
三、为什么不用"上一次值"实现undo栈
一个自然的疑问:original只存一份,如果想要"多步撤销"(undo/redo)呢?
刻意不做。编辑场景的回滚语义是"放弃修改恢复原样"——一步到位。多步撤销是编辑器功能(Word/CAD),不是数据集功能。政务表单的用户诉求是"改错了,恢复"——恢复到加载时刻就够了。
如果真要多步,在Row外面包一层历史栈即可——协议保持最小,扩展在外层。这是_t/_o能稳定14年的另一面:不往协议里塞可推导的东西。
四、三个读方法的语义分层
publicObjectgetItemValue(Stringkey)// 当前值——渲染用publicObjectgetOriginal(Stringkey)// 最初值——审计/对比用publicbooleanisDirty()// status != NONE——过滤用审计模块对getOriginal的重用——browise-audit的AuditEntityLifecycleListener在实体保存前抓快照,字段级diff就是拿当前值 vs getOriginal(key)逐字段比——"psnName从张三改成李四"这种变更记录的两侧数据源就是这两个方法。_o不只服务回滚,还免费服务了审计。
五、getMap返回引用不是副本
publicMap<String,Object>getMap(){returndata;// 直接返回内部Map}注释明确警告"修改返回值会直接影响行数据"。为什么不defensive copy?
性能——每行每字段都copy一份,几千行编辑时开销可观。约定替代防御——框架内部使用者(RowSet/BaseEntity/审计)遵守"只读getMap"的约定;外部要写值必须走setItemValue(否则脏标记失效)。
这个设计的赌注是:误用导致的bug(改了Map行状态没变)比copy的性能损失更容易被发现——界面上"改了但提交时没带上这个字段"一眼就能看出来。反过来的性能劣化是隐形的。
六、toRaw与序列化的边界
Row还有toRaw()(导出纯数据不含_t/_o)和前后端传输的序列化。传输格式:
{"_t":3,"name":"李四","_o":{"name":"张三"},"age":30}_t和_o以字段形式混在JSON里——不是包裹结构。前端useRowSet解析时按约定名提取,剩余字段全是业务数据。这个扁平结构的代价:业务字段不能叫"_t"或"_o"(下划线开头的名字被协议保留)——14年没出过冲突,因为业务字段规范要求驼峰命名。
✅ 亮点:177行Row拆成三个字段+11行状态机,重点讲original"首次记录不覆盖"锚定最初值的语义、INSERT不记旧值的原因、getMap返回引用的性能/可发现性取舍、_t/_o扁平混在JSON的命名保留约定。适合实现脏标记数据结构的参考。扩展方向:第18篇RowSet三缓冲、第19篇accept/reject、第44篇前端useRowSet镜像。