1. 小公司做前后端分离,到底在解决什么问题
先说个真实场景:我之前待过一家不到三十人的小公司,后端就我和另一个同事,前端三个人,产品经理还兼职测试。项目是典型的内部管理系统加一个对外展示站点,一开始为了省事,所有页面都是后端渲染的模板,接口随意写,谁能想到后面会痛成那样。
痛点集中在三件事上。第一,接口风格五花八门,有人写 getData,有人写 queryList,还有人直接把操作写在 URL 里,比如 /deleteUser?id=1 这种。前端每次联调都要先问“这个接口是删还是改”。第二,前后端共享同一套代码库,改个页面样式都要经过后端重新部署,前端想并行开发根本不可能。第三,也是最致命的,没有接口约定,前端等后端写完才能动,后端被前端的需求追着改,两边互相抱怨。
后来我们痛定思痛,决定走前后端分离的路子,而且不是简单地“前端用 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 是 1001,message 是“余额不足”。前端拿到响应先看 code,code 是 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 和 sortOrder:
GET /api/orders?status=PAID&page=1&pageSize=20&sortBy=createdAt&sortOrder=desc&startTime=1780000000000&endTime=1781000000000这里有个取舍:为什么不把分页参数放在 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 200,data 为 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 这些常见工具,核心就是把规矩定好、把模板做好、把文档维护好。做完之后最大的感受不是代码写得多漂亮,而是前后端之间安静了很多,大家终于可以把时间花在业务上,而不是花在“这个接口到底是什么意思”上。最后再提一个小建议:接口约定这件事,一定要让后端和前端一起坐下来商量着定,单方面定出来的规范大概率会水土不服。两边都觉得顺手的规则,才是真正能长久执行下去的规则。