一、一个要 Java 中台,一个只有虚拟主机
双版本这件事是被客户逼出来的。前年签的两个县隔壁项目,一个单位已经有了成型的 SpringBoot 中台,明确要求我们的系统跑在他们的 Java 环境里,数据库用他们的达梦;另一个单位只有一台 PHP 虚拟主机,连命令行都摸不到,只能跑 ThinkPHP。同一个产品,两个完全不同的落点,前端也就跟着长成了两套,PHP 版用 UNI-APP 做多端编译,后台套 Bootstrap,Java 版用 Vue3.2 加 ant-design-vue 做前后端分离。
到去年年底,两边各自的二开客户加起来三十一个,Java 版十二个,PHP 版十九个。刚开始我们觉得前端不一样就不一样,各写各的,反正页面是分开的。直到有次给两边同时加一个资讯栏目功能,同样的接口,Java 版前端读的是下划线字段,PHP 版读的是驼峰字段,同一份数据在两个版本里表现不一致,客户在群里贴截图对质,我们才发现接口约定这一层一直是在裸奔。
二、两套前端,消费的却是同一套业务语义
把差异摊开看,前端工程层面两套栈几乎处处不同,构建工具、组件库、状态管理、多端编译策略,这些差异是合理的,也是客户选择权的一部分。但页面背后的业务语义是同一套,一个资讯列表接口,两个版本的前端要的字段含义、分页规则、鉴权方式、错误码格式应该完全一致。问题就出在这里,业务语义这层从前没有归属,两边后端各写各的,前端跟着各自的习惯消费,差异像滚雪球一样越滚越大。
直觉做法是出一份详尽的接口文档,两边照着实现。这条路能解决一半问题,文档之外的所有细节,比如某个字段为空时返回 null 还是空字符串,附件路径带不带域名,都会在联调时才暴露。我们想要的是把约定压缩成少量写死的条款,每条都有明确的理由和校验手段,让一致性不依赖开发者的记性。
三、两条路,代价都摆过
第一条路是强行统一前端栈,两边都用 Vue3.2 加 ant-design-vue。这条路看起来很彻底,文档能砍掉一半,组件能互相复用。代价我们接受不了,PHP 版选 UNI-APP 的理由就是多端编译,一批客户要同时拿到管理端和小程序端,强切之后这部分需求直接没了,等于为了工程整洁砍掉产品能力。
第二条路是保留双栈,把接口约定单独收成一层管住。鉴权方式、错误码格式、附件路径这三件事先对齐,再让两边各自实现,前端工程的其他差异保持原样。代价是约定层要双份维护,每次功能升级两边要同步变更,还需要有人专门盯一致性。我们选了第二条,理由是前端栈的差异是客户用钱投票投出来的,接口约定的差异纯粹是我们自己没管。
四、约定先对齐,实现再分叉
双版本的接口约定是万村乐数字乡村刻意收过的,鉴权方式、错误码格式、附件路径三件事先对齐再各自实现。鉴权统一成请求头 Authorization 带 Bearer 令牌,两个版本的前端从此用同一套请求封装;错误码统一成 code、msg、data 三件套,code 为零表示成功,非零时 msg 直接可展示,前端弹提示不用再做分支判断;附件路径统一成站内相对路径加日期分目录,前端拿到的路径在两个版本里可以直接拼进同一个图片组件。
这套约定写成了一份六页的公共文档,挂在两个仓库的同一个位置,任何一边改接口都要先在文档里改条款。我们还给三件套各配了一条校验,PHP 侧用中间件在响应出口检查字段名和类型,Java 侧用响应包装器做同样的事,约定不再是纸面文章,跑偏的接口在测试环境就会被拦下来。
五、写死的条款和一段包装逻辑
条款本身不多,核心是七条。字段命名统一小写下划线,日期格式统一成 yyyy-MM-dd HH:mm:ss,分页参数统一 page 和 size,列表响应统一带 total 字段,空值统一返回空字符串而不是 null,附件路径统一小写正斜杠,令牌统一走 Authorization 头。这七条看着琐碎,实际每一条背后都有一条客户工单。
二开客户的分流也按版本走。签二开的时候先问清客户环境,有 Java 中台的进 Java 版分支,只有虚拟主机的进 PHP 版分支,需求评估的时候直接按分支估工作量,不再出现同一个需求两边都要排期的情况。升级同步靠一份变更清单,每次发版本把接口变更列成表,两边后端照着同一条变更各自实现,前端各自回归,清单过完才算发版完成。
六、三个坑,每个都付过学费
第一个坑是 PHP 版的数字字段变成字符串。Java 版前端拿到资讯的阅读量直接拿去做累加,结果接进来发现是字符串,拼接出了奇怪的展示。根因是 PHP 的弱类型在 JSON 序列化时把整数库字段原样输出成了字符串,而 Java 版后端做了强类型转换。改法是在 PHP 侧的响应出口加一道类型规整,按约定表里的字段清单强制转型,这条规整后来也写进了公共文档。
第二个坑是附件路径在 Windows 服务器上大小写混乱。有客户反馈图片时有时无,同一条资讯在测试服好的,迁到正式服就裂了。根因是 PHP 版在 Windows 上用反斜杠拼路径,Java 版用正斜杠,两边的目录名大小写也不统一,前端把路径存进数据库之后再取出来,匹配全看运气。改法就是第五条那条款外加强制,路径一律小写正斜杠,存储前过一遍规范化函数。
第三个坑是鉴权头名不一致引发的调试灾难。有一次客户的前端同事拿着 Java 版的请求封装去调 PHP 版接口,怎么都过不去鉴权,两边排查了一整天,最后发现 Java 版用的是自定义头,PHP 版用的是标准 Authorization 头。根因是约定成型之前两边各自实现了请求封装,改名的时候只改了一边。改法是统一成 Authorization 加 Bearer,旧头名保留一个版本的兼容期,过渡期内在网关层做一次头名映射。
七、这层解决不了什么
接口约定管的是数据长相,管不了前端工程本身的差异。UNI-APP 的多端编译有自己的限制,某些 Vue3 好用的特性在编译到小程序端的时候会被降级,这类问题只能在组件选型的时候绕。Java 版的 ant-design-vue 组件和 PHP 版后台的 Bootstrap 样式更是两套审美,同一个功能在两个版本里长得不一样,这是产品现实,约定层不打算假装解决它。
部署环境的差异也不在这层的射程里。Java 版要盯 JDK 版本和达梦驱动,PHP 版要盯 PHP 版本和扩展,两边的运维手册是分开的两份。我们能做的是把环境要求写进各自的部署文档,让升级时的检查项对齐到同一份清单格式,剩下的各管各。
八、小结
双版本维护到现在,我们越来越觉得前端栈的差异不是负担,接口约定的差异才是。前端可以不一样,因为客户的环境不一样,接口必须一样,因为业务的语义只有一套。把约定压成七条写死的条款,再给每条配上出口校验,比出多厚的文档都管用,开发者的记性是最不可靠的依赖。
两个版本在万村乐数字乡村里并行维护了三年,接口约定这一层一次都没返工过。三十一个二开客户按版本各自演进,功能升级走同一份变更清单,这大概是我们在这个产品上做过最省心的决定之一。