
1. 小公司做前后端分离到底在解决什么问题先说个真实场景我之前待过一家不到三十人的小公司后端就我和另一个同事前端三个人产品经理还兼职测试。项目是典型的内部管理系统加一个对外展示站点一开始为了省事所有页面都是后端渲染的模板接口随意写谁能想到后面会痛成那样。痛点集中在三件事上。第一接口风格五花八门有人写 getData有人写 queryList还有人直接把操作写在 URL 里比如 /deleteUser?id1 这种。前端每次联调都要先问“这个接口是删还是改”。第二前后端共享同一套代码库改个页面样式都要经过后端重新部署前端想并行开发根本不可能。第三也是最致命的没有接口约定前端等后端写完才能动后端被前端的需求追着改两边互相抱怨。后来我们痛定思痛决定走前后端分离的路子而且不是简单地“前端用 Vue、后端提供 JSON”而是把 RESTful、共用接口、接口约定这三件事一起落地。这篇文章就把我们整个实践过程、踩过的坑、最后沉淀下来的规范原原本本写出来希望能给同样规模的小团队一点参考。先说结论前后端分离不是技术问题是协作问题。RESTful 也不是什么高深理论它是一套让双方少吵架的“共同语言”。共用接口更不是偷懒它是避免接口数量爆炸的关键思路。这三者合在一起核心目标是让接口变得可预测前端看到 URL 大概能猜到语义后端看到请求大概知道要做什么两边不需要反复沟通也能把活干完。2. 接口约定的设计思路先定规矩再写代码2.1 RESTful 风格在小公司的落地取舍网上讲 RESTful 的文章很多动不动就扯到 Richardson 成熟度模型、HATEOAS说实话小公司根本用不上那么重的东西。我们最终采取的是一套“务实版”RESTful核心就三条第一资源用名词操作交给 HTTP 方法。比如用户这个资源就是 /api/users获取列表用 GET新增用 POST修改用 PUT 或 PATCH删除用 DELETE。而不是搞出 /api/getUsers、/api/deleteUserById 这种动词式 URL。第二URL 层级表达从属关系。比如获取某个用户的订单就是 GET /api/users/{userId}/orders。虽然从性能上多一次路由解析但语义非常清晰。第三状态码要真的用起来。200 表示成功201 表示创建成功400 表示参数错误401 表示未认证403 表示无权限404 表示资源不存在500 表示服务器异常。很多团队只把 200 当成功其他一律不管这等于放弃了 HTTP 协议自带的信息传递机制。但这里有个务实取舍RESTful 严格意义上要求使用 HATEOAS也就是响应里带上下一步可执行的操作链接。我们直接舍弃了因为前端是 SPA路由控制在前端手里后端返回链接没有意义反而增加沟通成本。还有一点很多人忽略就是 URL 中的动词问题。网上的“原教旨主义”会说 URL 里绝对不能出现动词必须全部用名词表达。我们实践下来发现有些操作本质上是动作而不是资源比如“发送验证码”“刷新 token”“导入文件”。硬要用名词表达会很别扭所以我们放宽了规则纯资源操作用 RESTful 标准写法动作类操作允许使用 POST 动词式端点比如 POST /api/auth/send-code。这种做法在业界也不算少见关键是保持一致别一会这样一会那样。2.2 共用接口为什么能减少一半开发量共用接口这个概念说白了就是同一个接口Web 端用移动端也用内部管理后台也用。而不是每个端各写一套。小公司最容易犯的错误就是觉得“前端页面不一样所以接口也不一样”。实际上大多数场景下页面差异只是字段展示的差异底层数据模型是一样的。比如订单列表Web 端可能展示下单时间、金额、状态移动端展示的也是这些。非要各写一套接口前端和后端都要多维护一份代码联调时间翻倍出 bug 的概率也翻倍。我们当时做了一个关键决定所有接口默认都是共用接口除非有明确理由拆分。那“明确理由”是什么主要有三类一是性能差异特别大比如移动端对网络延迟更敏感需要精简字段二是安全级别不同比如管理后台的接口不能暴露给用户端三是交互场景完全不同比如用户端是一次性提交整个表单后台是逐步保存。这三类情况确实需要单独接口但我们会把这类接口数量控制在总接口量的两成以内。剩下的八成接口全部走共用通道。为了支撑共用我们在接口设计时强制做两件事字段冗余率和兼容性控制。字段冗余率的意思是宁可多返回几个前端暂时用不到的字段也不要让前端因为缺字段再来找后端加。当然不是无限冗余而是基于数据模型把关联对象的基础字段一并返回。比如订单详情除了订单本身把用户昵称、商品名称、订单状态文案也一起给了。前端直接用不用再发一次请求去查关联数据。兼容性控制的意思是接口字段只增不改不删。改名一个字段前端所有用到的地方都要跟着改在共用接口场景下这个改动成本会放大好几倍。所以我们对字段命名的要求是“起名时多花十分钟想清楚之后尽量不动”。2.3 统一响应结构一个 code 字段解决 90% 的联调争议前后端分离最容易吵起来的就是响应格式。有的接口返回 {status: 1}有的返回 {code: 200}有的干脆直接在 data 里塞一个字符串。前端每个接口都要单独写处理逻辑开发效率低还容易漏判。我们最终把响应结构定成这样{ code: 0, message: success, data: {} }三个字段的含义code 是业务状态码0 表示成功非 0 表示业务失败。这里特别注意HTTP 状态码和业务状态码是两回事。HTTP 200 只代表请求被服务器正常处理了但业务可能是失败的。比如用户余额不足HTTP 返回 200但响应体里 code 是 1001message 是“余额不足”。前端拿到响应先看 codecode 是 0 才继续处理 data否则直接弹 message。message 是给用户看的提示文案。这个字段很有讲究它必须是人话不是“系统异常请稍后重试”这种纯敷衍文案而是尽可能具体的描述。后端在抛出业务异常时直接把这个 message 返回给前端前端弹出来就行。这样后端做参数校验的时候顺便把文案写好了前端不用自己拼提示。data 是真正的业务数据。可以是对象可以是数组也可以是 null。没有数据时不要返回空字符串一律 null前端处理起来统一。这个结构看起来简单但统一的过程中也付出了代价。之前有一个老接口data 直接是字符串前端已经写好了处理逻辑改成对象结构后前端要改很多地方。所以我们的切换策略是“新接口全部用新结构旧接口在迭代时顺带迁移”大概花了两周时间把所有接口全部统一。还有一个细节文件流接口不套这个结构。比如导出 Excel、下载文件返回的是二进制流一旦包成 JSON 就废了。这类接口单独约定响应头用 Content-Disposition 处理文件名前端用 blob 方式接收。2.4 命名规范URL、字段、枚举值都要有章可循接口约定里最容易被忽视、但实际最耗沟通成本的就是命名。我们定了几条硬性规则URL 命名统一用复数名词小写字母单词之间用连字符。比如 /api/order-items而不是 /api/orderItem 或 /api/order_items。复数是为了语义统一GET /api/users 是列表GET /api/users/1 是单个新增是 POST /api/users不会混淆。字段命名统一用驼峰式。这个选择是为了贴合前端 JavaScript 的习惯后端 Java 里用 MapStruct 或手动转换都可以。有人会建议用下划线说数据库字段就是下划线但前端的 JSON 解析对象属性驼峰更自然而且主流接口工具比如 Swagger对驼峰支持也更友好。枚举值的命名必须用英文大写禁止用中文或数字。比如订单状态PENDING、PAID、SHIPPED、COMPLETED、CANCELLED前端根据这些值做状态映射后端返回的 message 里可以带中文解释但值本身必须是稳定的英文标识。千万不要返回 1、2、3 这种数字因为没人记得住 2 到底是什么意思一旦中间插入一个新状态数字含义全乱。时间格式统一用时间戳毫秒数。虽然在可读性上不如 ISO 8601 字符串但前端在展示时几乎都要用 JavaScript 的 Date 对象处理时间戳是最不生歧义的格式。要显示成什么样交给前端自己格式化。3. 实操落地一个真实接口从约定到交付的全过程3.1 项目结构和技术选型我们后端用的是 Spring Boot前端有两个端一个是管理后台用的 Vue 3 Element Plus一个是面向 C 端的 Next.js。数据库是 MySQL缓存用 Redis。这套选型很主流网上资料多招人也好招小公司选技术栈不用追求新稳定和生态才是关键。项目结构上前后端完全分仓库。后端仓库按模块分包controller、service、mapper、entity、dto。前端按页面和组件组织。两边通过接口文档对接文档用 Apifox 管理它支持在线调试和 Mock 数据比手写 Markdown 文档效率高很多。这里有个经验接口文档一定要在写代码之前出第一版哪怕粗糙一点。我们最初是先写代码再补文档结果文档总是滞后前端对着旧文档联调后端已经改了参数来回返工。后来改成先定义接口再写实现文档优先问题一下子少了。3.2 从需求到接口定义的一次完整推演拿“订单列表”这个需求举例。产品经理的原话是“后台能看到所有订单能按状态筛能按时间倒序排点击订单能看详情”。第一步识别资源。核心资源是订单order关联资源是用户user、订单项order item。列表页的接口定义如下GET /api/orders第二步确定查询参数。筛选状态用 status分页用 page 和 pageSize时间范围用 startTime 和 endTime排序用 sortBy 和 sortOrderGET /api/orders?statusPAIDpage1pageSize20sortBycreatedAtsortOrderdescstartTime1780000000000endTime1781000000000这里有个取舍为什么不把分页参数放在 URL 路径里比如 /api/orders/page/1。我们统一放在 query string 里因为page、pageSize这些是通用参数放 query 里前端拼起来更灵活后端解析也统一。第三步确定响应结构。列表接口的 data 里不只是数组还必须有分页信息{ code: 0, message: success, data: { list: [ { orderId: ORD202501010001, userId: 1001, userName: 张三, totalAmount: 299.00, status: PAID, statusText: 已支付, createdAt: 1780000000000 } ], total: 1, page: 1, pageSize: 20 } }注意看这个结构里的几个细节list是数组total是总数page和pageSize回显了请求参数。有些团队后端会把total单独拆一个接口前端要发两次请求才能拿到列表和总数完全没必要。另外userName冗余在订单对象里前端展示列表时不用再查用户接口这就是共用接口的思路。第四步确定详情接口。点击某条订单需要查看完整信息包括商品明细、收货地址、支付记录GET /api/orders/{orderId}响应里 data 包含订单主信息、订单项数组、地址对象、支付流水数组。这里再强调一次字段冗余地址对象里把省市区文本和详细地址都带上前端直接展示不要给一个 addressId 让前端去猜。3.3 增删改查接口的完整约定模板列表接口说完了再总结一下我们沉淀的标准接口模板后面对照套用就行。创建资源POST /api/orders Content-Type: application/json请求体是前端提交的数据比如{ userId: 1001, items: [{productId: 1, quantity: 2}], remark: 尽快发货 }。后端返回 HTTP 201响应体里 data 是创建后的完整对象包括生成的 ID 和默认状态。前端拿这个返回值做后续跳转或提示。这里有一个非常重要的约定创建接口返回的是完整对象不是只有 ID。原因很简单前端在创建成功后通常需要展示新数据如果只返回 ID前端还得再发一次查询请求。更新资源PUT /api/orders/{orderId}请求体是完整对象语义是“用这个对象整体替换原有资源”。但实际业务中很多更新是部分字段更新比如只改备注。所以我们也约定 PATCH 方法用于部分更新PATCH /api/orders/{orderId}请求体只需要包含要修改的字段。后端用 Selective 更新策略哪些字段不为 null 就更新哪些。这里要特别注意前端如果需要把某个字段置空不能传 null要传空字符串因为 null 会被后端当成“不更新”处理。这个约定在文档里写得很清楚避免踩坑。删除资源DELETE /api/orders/{orderId}成功返回 HTTP 200data 为 null。注意不是 204 No Content因为我们的前端框架在处理响应时统一要读取 body返回 204 会导致解析异常。这个细节很小但影响体验统一返回 200 更省事。3.4 鉴权、分页、排序等公共能力的统一封装小公司做共用接口最怕的就是每个接口自己处理鉴权、分页、排序这些公共逻辑。我们直接用 Spring Boot 的拦截器和自定义注解把这些统一起来。鉴权这块我们采用最简单的 Token 方案用户登录成功后后端生成一个包含用户 ID、过期时间的 Token存 Redis 并设置过期时间。前端在每次请求的请求头里带上Authorization: Bearer {token}后端写一个拦截器统一校验。拦截器里做三件事从请求头取 token 并解析解析失败或过期直接返回 401成功则把用户信息放到 ThreadLocal 里后面的 Service 层直接用。这样接口的 Controller 里完全不用写鉴权代码每个接口天然就知道当前登录用户是谁。分页和排序的统一封装也值得说说。我们定义了一个通用分页请求对象PageQuery包含page、pageSize、sortBy、sortOrder四个字段。Controller 的方法参数直接接收这个对象Service 层通过 MyBatis-Plus 的 Page 对象进行分页查询最后统一封装成上面提到的分页响应结构。排序字段做了白名单机制。sortBy是前端传的字段名比如createdAt但我们不允许直接拼接到 SQL 里而是先映射到数据库实际字段名再校验是否在白名单里。这么做是为了防止 SQL 注入小公司也必须有这个安全意识。日志这块我们给所有接口加了一个注解自动记录请求路径、参数、耗时和操作人。实现方式很简单用 AOP 环绕通知在方法执行前后记录时间戳执行完后写日志表。出了问题排查起来非常方便谁在什么时间操作了什么一目了然。4. 小公司接口实践的踩坑记录与排查技巧4.1 前端调用接口踩过的三个高频问题第一个是跨域问题。前后端分离后前端跑在localhost:8080后端跑在localhost:9090浏览器直接请求必然跨域。我们后端的处理是配置了 CORS 过滤器允许的域名是配置化的线上环境只放行正式域名。这里有个坑前端的预检请求是 OPTIONS 方法后端必须对 OPTIONS 请求也放行否则前端会出现“请求已发送但没响应”的怪问题。第二个是表单提交的 Content-Type 问题。前端用 axios 默认的application/json提交后端用RequestBody接收一切正常。但有时候前端会误把数据拼成 URL 参数导致后端收到的对象全是 null。排查方法很简单打开浏览器开发者工具的 Network 面板看一下请求的 Content-Type 和 payload 是什么格式。如果看到 Form Data那说明不是 JSON 提交。第三个是日期时间字段的时区问题。时间戳本身没有时区概念但后端在 Java 里用Date类型接收 JSON 字符串时如果不指定格式和时区可能会有 8 小时的偏差。我们的解决方案是前后端传递统一用时间戳数字不用 JSON 字符串传日期。这个规则直接杜绝了时区问题。4.2 共用接口演进导致的兼容性难题共用接口有个必然的问题多个调用方改了一个接口其他端也受影响。我们遇到过最典型的一次是管理后台要求订单列表新增一个“买家备注”字段后端直接加了结果 C 端页面因为没适配新字段导致解析出错。这个问题的根因在于前端没有对未知字段做容错。前端在解析 JSON 时用的是order.userName这样的直接访问方式一旦后端数据结构变化某些端就会崩。我们的解决方案有两个第一后端新增字段必须遵循“只增不改”原则旧字段值必须保持兼容。能设为 null 的字段不要强行填值填充逻辑放在前端各端自行处理。第二前端的取值逻辑不做“强解构”。不推荐const { userName } order这么写而是统一用order.userName || 这样的兜底方式。这样即使后端某天不返回这个字段前端也不会报错最多显示空字符串。4.3 接口文档和调试工具的高效配合接口文档这件事小公司特别容易走极端要么不写要么写一大堆没人看。我们的做法是用 Apifox 管理核心思路是“文档即调试工具”。后端开发在写完 Controller 后通过 Apifox 的 IDEA 插件一键导入接口定义生成在线文档。前端在 Apifox 里可以直接调试每个接口也可以看 Mock 数据提前开发页面。这样文档不再是事后的负担而是开发流程的一部分。有个经验值得分享Apifox 可以自动生成“在线 Mock 地址”前端在真实接口没写好之前可以直接请求 Mock 数据先行渲染页面。这个能力让我们前后端并行开发的效率大幅提升后端不用被前端频繁打断。接口变更时Apifox 会记录历史版本。有一次我们改了某个字段的类型前端在联调时发现有问题直接对比文档历史版本很快就定位到是后端改了字段类型而不是前端代码的问题。这种追溯能力非常重要。4.4 小团队接口规范落地的几条硬经验最后总结几条我们在推进规范过程中得到的实战经验一是规范要简单可执行。一页纸能写完的规范才算好规范几十页的规范没人看。我们的接口规范文档就两页一页是 RESTful 规则和命名约定一页是响应结构和示例代码。二是搭好脚手架比培训管用。我们做了一个后端的 Controller 基础类把通用的响应封装、异常处理、参数校验全部内置新接口只需要继承基础类写自己的业务方法规范自然就遵守了。人可能会忘记规范但代码模板不会。三是联调前先过一遍文档。每次新功能开发前前后端负责人坐在一起把接口定义过一遍确认每个字段的含义和格式。这个环节只要二十分钟但能省下后面好几天扯皮的时间。四是留一个“接口治理日”。我们每两周抽半天时间专门检查和重构接口。不合理的命名改掉多余的字段标记废弃重复的接口合并。这个习惯让接口体系一直保持健康不会因为日久失修变成一团乱麻。接口规范这东西在小公司往往被认为是“大厂才需要做的事情”但实际上恰恰相反。小公司人少一人要干多人的活沟通成本更高接口约定越清晰团队协作越轻松。我们这套实践没有用到任何高大上的技术全部是 Spring Boot、Vue、Apifox 这些常见工具核心就是把规矩定好、把模板做好、把文档维护好。做完之后最大的感受不是代码写得多漂亮而是前后端之间安静了很多大家终于可以把时间花在业务上而不是花在“这个接口到底是什么意思”上。最后再提一个小建议接口约定这件事一定要让后端和前端一起坐下来商量着定单方面定出来的规范大概率会水土不服。两边都觉得顺手的规则才是真正能长久执行下去的规则。