不用JSON-RPC和GraphQL自研DataCenter统一数据协议一套格式管全部文章目录不用JSON-RPC和GraphQL自研DataCenter统一数据协议一套格式管全部一、问题前后端数据交互的格式碎片化二、答案DataCenter统一数据协议三、为什么这么做三个核心设计决策决策一多数据块放一个响应里决策二Row 自带状态追踪决策三查询参数原样带回四、RowSet 的增删改模型五、VtoH一个意外的设计六、Header 的设计简单但有底线七、这套协议运行了多少年一、问题前后端数据交互的格式碎片化做政务系统前后端数据交互有个特点一个页面经常需要同时组装多块数据。比如社保个人信息页面要同时展示三个面板上方是基本信息姓名、身份证、参保状态中间是缴费记录列表下面是待遇发放记录列表。每块数据都有自己的查询条件、分页参数、行数据。如果每次请求只返回一个数据块这个页面至少要发三次HTTP请求。更麻烦的是——三个请求是独立的前端要自己管理三个异步回调等全部返回了再渲染。我们当时没有Spring Boot没有页面前后端分离JSP页面在服务端渲染但Ajax交互也很多。需要一个办法一个请求返回多块数据前端只解析一种格式。二、答案DataCenter统一数据协议这个协议的核心结构只有三层DataCenter ├── Header 状态码 消息 └── Body ├── dataStores 多个数据块按名字索引 │ └── DataStore │ ├── RowSet { primary[], delete[] } │ │ └── Row { _t状态, map字段值, _o旧值 } │ ├── name 数据块名称 │ ├── pageSize 每页条数 │ ├── pageNumber 当前页 │ └── parameters 查询条件原样带回 └── parameters 全局参数如当前登录用户信息一个完整的JSON响应长这样{header:{code:1,message:{title:查询成功,detail:}},body:{dataStores:{personInfo:{rowSet:{primary:[{aac001:10001,aac003:张三,_t:0}],delete:[]},name:personInfo,pageSize:50,pageNumber:1,recordCount:1},payList:{rowSet:{primary:[{aae002:202601,aae019:1234.56,_t:0},{aae002:202602,aae019:1234.56,_t:0}],delete:[]},name:payList,pageSize:20,pageNumber:1,recordCount:48,parameters:{aac001:10001}}},parameters:{loginUser:admin}}}一个请求返回了两块数据personInfo 是一次请求的数据payList 是分页列表。前端拿到这个JSON按 dataStores 的名字取对应的数据块渲染到各自的面板。三、为什么这么做三个核心设计决策决策一多数据块放一个响应里当时业界有几种方案发多个HTTP请求——并发管理复杂返回值拍成一个大XML——XML前端解析重自定义分隔符拼字符串——太脆弱我们的方案一个JSON多DataStore。前端代码变成统一的模式vardcJSON.parse(response);varpersonInfodc.body.dataStores[personInfo];varpayListdc.body.dataStores[payList];关键是——后端的 Java 代码也统一了DataCenterdcnewDataCenter();dc.setCode(1);DataStoredsPersonnewDataStore(personInfo);dsPerson.getRowset().getPrimary().add(row);dc.addStore(dsPerson);dc.addStore(dsPay);// 另一个DataStoreStringjsondc.toJson();每个业务方法只管往自己的 DataStore 里塞数据最后统一序列化。决策二Row 自带状态追踪Row 不是简单的 HashMap它有三个关键字段字段含义_t 0未修改从数据库查出来的原始数据_t 1新增前端新增的行_t 3修改前端改了某个字段的值_oHashMap原始值——修改前字段的值被记录到这里这是一个迷你ActiveRecord核心方法是setItemValuepublicvoidsetItemValue(Objectkey,Objectvalue){if(map.get(key)null){if(_t0){_t1;}// 之前是空填了一个值——新增}else{if(_t0){// 之前有值先记下来再改_t3;// 标记为修改_o.put(key,map.get(key));// 备份旧值}}map.put(key.toString(),value);}前端不需要知道自己是在新增行还是在编辑行。修改一个单元格Row 自动把_t从0变成3并把旧值存入_o。提交时后端遍历 RowSet 的 primary 列表根据_t判断_t1调 insert_t3调 update。一条 save() 搞定增删改。决策三查询参数原样带回看 payList 这个 DataStore它在查询请求时传入了parameters: {aac001: 10001}。返回时这些参数原封不动地带着。这不是冗余。前端翻页时不需要重新组装查询条件——直接从 DataStore 里取 parameters 再发出去就行// 翻到第2页pageData.body.dataStores[payList].pageNumber2;// parameters不用重新填round-trip保证了它还在ajax.post(/query,pageData);参数的round-trip设计让前端彻底解耦了查询条件的管理。查询条件是谁填的、从哪里来的、中间有没有被用户改过——前端不需要知道后端给什么前端就用什么。四、RowSet 的增删改模型RowSet 用三个 Vector 管理行数据向量用途primary当前数据行含新增、修改和未改动行delete被删除的行从primary移入这里filter预留的过滤结果集为什么 delete 不是标记删除而是物理移动到另一个集合因为前端渲染时delete集合里的行是不显示的它们已经被移出了 primary。提交时后端同时处理primary新增修改和delete物理删除——一个 RowSet 就包含了本次操作的完整变更集。resetUpdate()方法更体现了这个思想——提交成功后把所有行的_t重置为0清空_o和deleteRowSet 恢复为干净的查询结果状态。五、VtoH一个意外的设计RowSet 还有一个神奇的方法VtoH——纵向转横向。把一个列式存储的数据集变成行式输入纵向 输出横向 col_name col_vale aac001 aac002 aac003 aac001 10001 → 10001 xxxx 张三 aac002 xxxx 101 yyyy 李四 aac003 张三 aac001 101 aac002 yyyy aac003 李四数据审计时变更记录通常以字段名新旧值的列格式存储。VtoH 一键转成用户能看懂的表格。这个方法只有十几行但解决了一个在政务系统中反复出现的问题——审计数据的横向展示。六、Header 的设计简单但有底线publicclassHeader{privateintcode;privateHashMapmessagenewHashMap();// title detail 自定义}code 是状态码1成功负数是具体错误码message 里的 title 是对用户的标题“保存成功”、“参保人不存在”detail 是给技术人员的详细信息。这个设计在今天看来普通但在当时有一个细节code 永远不是 HTTP 状态码。即使业务逻辑报错“该参保人已存在”HTTP Status 依然是200错误信息通过 Header.code 传递。因为我们的前端只认 Header.code不认 HTTP 状态——换了一种错误传递方式前端就崩了。七、这套协议运行了多少年从2010年左右设计出来到系统2023年下线这套 DataCenter 协议跑了十多年。它不是什么高深的技术——没有 schema 校验没有类型系统没有缓存策略。但它在政务系统的实际约束下解决了一个反复出现的问题前后端数据交互的统一格式。今天回头看这套协议有点像简化版的 GraphQL——一个请求返回多个命名的数据集前端按需取用。区别在于 GraphQL 有完整的类型系统和查询语言而 DataCenter 只有一个 JSON 结构和一套 Java 类。前者是工业标准后者是在约束条件下的实用解。最后说一句——这套协议存在了十多年不是因为没有人想过要换。而是每次有人提要不要改成 RESTful改完一个页面后发现其他几百个页面都依赖这个格式就算了。一个设计能活下来的标志不是没人反对是反对的人改了之后又改回来了。