/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
API接口设计别再拍脑袋:RESTful还是RPC,我用飞算JavaAI怎么选
去年年底我们内部有个运维管理后台要重构,我负责后端,前端是隔壁组借调来的一个小伙子。开工第一周我就被拉进了一个线上会议,主题是"接口路径到底用 /user/list 还是 /users"。
吵了四十分钟没结论,最后技术总监拍桌子:“按 RESTful 来,别问了。”
结果呢?两个月后联调阶段,前端为了渲染一个"主机列表带告警统计"的页面,要连着发四个请求:查主机、查告警数、查负责人、查机房。四个请求串行等下来,首屏白屏两秒多。前端来找我,我说这是 RESTful 的正统用法啊,一个资源一个接口。他说你要不给我一个聚合接口吧。我又说那你早说啊。
来回扯皮的次数,比写代码的时间还多。
这件事之后我复盘过一次:接口风格本身没有绝对的对错,真正的问题是我们在没有任何约束条件的情况下拍脑袋定风格,而且没把契约写死。风格选错只是让联调难受,契约没定死才是让两边的锅互相甩。
这篇就讲讲我现在怎么定这两件事,以及飞算JavaAI的 /前后端设计 在接口这块能帮到我什么程度。
一、接口风格选错的代价,比你想的大
很多后端对接口风格的认知停留在"路径好不好看"这个层面。其实风格选择会连锁影响三件事:
第一,前端的数据组装成本。 RESTful 强调资源单一职责,一个页面需要的数据往往散在多个资源里,前端要么并发拉,要么串行拉。RPC 风格(这里指的是面向动作的 HTTP 接口,不是 Dubbo 那类二进制协议)允许一个接口直接返回页面所需的聚合视图,前端拿到的就是渲染要的东西。
第二,缓存和幂等的天然支持度。 RESTful 的 GET 语义天然可被浏览器、网关、CDN 缓存;RPC 风格全是 POST,缓存就得自己做,但好处是幂等键、重试这些机制容易统一挂在拦截器上。
第三,演进成本。 接口一旦上线,前端、App、第三方脚本都在调。RESTful 的版本演进靠 URL 前缀或者 Header,RPC 风格靠方法名版本号。选错了后面改一发动全身。
我见过最惨的一个项目:所有接口全部 POST + JSON,方法名全是 doSomething,上线一年后没人敢动任何一个接口,因为不知道谁在调。日志里只有 /api/handler 一条路径,全靠 body 里的 method 字段区分,监控告警完全没法按接口维度做。
所以风格这件事,值得在动工前花半小时定死。
二、飞算JavaAI在接口设计这块干了什么
飞算JavaAI(https://www.feisuanyz.com)的 /前后端设计 指令,我通常是这么用的:
接口设计文档这一份是我每次必看的。它会把每个接口的路径、方法、入参、出参、错误码列出来,而且字段级别都有类型和说明。这对我最大的价值不是省时间,而是它逼着我把那些我本来会口头交代的东西落成文字。
举个真实例子。我上次让它设计"告警规则管理"模块,它给出来的接口清单里有一个 GET /api/v1/alert/rules,返回体里带了 total、pages、以及一个 items 数组。我当时觉得这很自然,但转念一想——我之前手写接口的时候,total 这个字段经常忘,前端做分页只能靠"下一页还有数据吗"这种试探式逻辑,多打一次请求。
有个细节我挺认可的:它在设计文档里会显式标注哪些接口是幂等的、哪些需要幂等键。这个东西人肉写接口的时候太容易漏了,而漏掉的后果是用户双击提交按钮产生两条脏数据。
不过它也有个毛病:它倾向于给出"教科书式"的 RESTful 设计。这是大模型的通病,训练数据里 RESTful 的规范文档太多了。所以我通常会在 /前后端设计 之前就把我的风格约束写进去,比如"内网管理后台,主体采用 RESTful,但对列表页的聚合统计场景允许使用面向动作的 POST 接口,路径统一为 /api/v1/{module}/{action}"。
三、RESTful、RPC、GraphQL 在管理后台怎么选
内网管理后台这个场景有几个特点:用户量小(几十到几百人)、页面复杂度高(大量表格+筛选+批量操作)、迭代快、几乎不考虑公网缓存、前后端通常同一个团队。
这三个特点直接决定了选型倾向。我做了个对比表:
| 单页面请求数 | 多,需前端聚合 | 少,可按需定制 | 1 个,但可能很重 |
| 前端自由度 | 低,受资源边界约束 | 中,需后端配合加字段 | 高,前端自己决定要什么 |
| 后端实现成本 | 低,CRUD 可代码生成 | 中,聚合逻辑要手写 | 高,Resolver + N+1 问题 |
| 错误码语义 | 靠 HTTP 状态码 + body | 靠 body 里的 code | 全部 200,靠 errors 数组 |
| 缓存/网关友好 | 好 | 差 | 差 |
| 监控与排障 | 好,路径即接口 | 中,需解析 body | 差,单点压力大 |
| 权限控制粒度 | 按资源+方法,清晰 | 按动作,需额外映射 | 需做到字段级,复杂 |
| 适合场景 | 标准 CRUD、开放 API | 复杂操作、批量动作 | 多端异构、字段差异大 |
我现在的判断标准其实就三句话:
标准 CRUD 走 RESTful。 用户、角色、菜单、字典、配置这些,一个资源一套接口,CRUD 代码甚至可以用生成器直接出,前端也能用统一的封装。
带业务动作、带聚合、带批量的走 RPC 风格。 比如"批量禁用主机"、“导出巡检报告”、“仪表盘聚合统计”。这些操作本质上不是一个资源的增删改查,硬套 RESTful 会写出 POST /hosts/batch-disable 这种不伦不类的东西,不如干脆统一成 POST /api/v1/host/action/batchDisable。
GraphQL 在管理后台我基本不选。 除非你的后台要同时服务 Web、App、小程序三端且字段需求差异极大。否则引入 GraphQL 带来的 Resolver 编写成本、N+1 查询问题、以及"一个接口扛所有流量导致无法按接口限流监控"的问题,会远超它省下的那点联调沟通。我试过一次,最后在权限控制上卡住了——要做到字段级权限,Resolver 里到处埋判断,代码脏得没法维护。
内网管理后台不是对外产品,没有"接口要优雅"的负担,可维护性和可排障性排第一。
一个真实对比:同一个页面,两种风格差多少
我把那个"主机列表带告警统计"的页面做了个对比实验,数据是在内网千兆环境、单用户、无并发的情况下测的,仅供参考:
纯 RESTful 方案:前端要发 4 个请求(主机分页列表、告警数聚合、负责人字典、机房树)。其中负责人和机房可以缓存,主机列表和告警数必须每次拉。首屏耗时 2100ms 左右,且告警数返回前表格的告警列是空的,用户能看到一次明显的"内容闪现"。
混合方案:保留 GET /api/v1/hosts 做标准分页查询,另外加一个 POST /api/v1/host/action/summary 返回聚合视图 { hostId: { alarmCount, ownerName, idcName } }。前端一次请求拿到列表,一次请求补齐统计,首屏降到 900ms,且可以用骨架屏统一处理。
差别不在"RESTful 慢",而在于纯资源视角的接口逼着前端做多次往返。内网管理后台的用户往往一坐就是半天,页面的响应感比架构的纯粹性重要得多。
这里我给自己定了个可执行的判据:一个页面的首屏如果需要三次以上串行请求,就该考虑加聚合接口了。三次以内,前端并发拉,可以接受;超过三次,沟通成本和体验损耗都会指数上升。
四、接口契约必须写死的七个字段
风格定了,接下来是契约。这七个字段无论你选哪种风格,我都建议在第一天就落进文档和代码里。
| 1 | version 版本号 | URL 前缀 /api/v1 或 Header | 没有版本号的接口等于没有演进空间 |
| 2 | code 业务码 | 响应体 | HTTP 200 不代表业务成功,前端不能靠状态码判断 |
| 3 | traceId 链路 ID | 响应体 + Header | 联调时前端甩过来一个 traceId,我 10 秒能定位日志 |
| 4 | idempotencyKey 幂等键 | 写操作请求头 | 防重复提交、防网络重试造成脏数据 |
| 5 | pageNum / pageSize / total | 分页接口 | 三个缺一,前端分页就得靠猜 |
| 6 | timestamp 服务端时间戳 | 响应体 | 前端时间显示、倒计时、缓存判断都要用 |
| 7 | data 统一包装体 | 响应体 | 所有数据都在 data 下,前端一套拦截器搞定 |
第 2 条和第 7 条经常被合在一起讨论,也就是那个经典的 Result<T> 包装。我见过太多团队纠结"要不要用 HTTP 状态码表达业务错误",我的结论很明确:HTTP 状态码交给网关和框架,业务状态一律走 body 里的 code。原因很简单,你们公司的网关、WAF、监控大概率会对 4xx/5xx 做特殊处理,把业务错误塞进 HTTP 状态码,等于把业务问题混进基础设施告警里。
第 4 条幂等键是我踩过坑之后才加的。有一次财务模块的"生成对账单"接口,用户点了提交,网络卡了没响应,又点了一次,结果生成了两张对账单。后来我们统一要求所有非幂等的写操作必须带 Idempotency-Key 请求头,后端用 Redis 做 5 分钟窗口去重。
第 3 条 traceId 看着不起眼,实际是联调阶段的救命稻草。以前前端报错只会说"接口挂了",我得去日志里按时间翻。现在前端把响应里的 traceId 贴给我,直接 grep 就行。这个字段成本几乎为零,收益极大。
五、把契约落到代码里:Result 和 OpenAPI
先说响应体。这是我现在项目里在用的包装类,字段就是上面那张表的落地:
/**
* 全局统一响应包装体。
* 约定:HTTP 状态码恒为 200,业务结果一律看 code。
*/
@Data
public class Result<T> implements Serializable {
/** 业务码:0 成功,非 0 为业务失败 */
private int code;
/** 面向前端的提示文案,可直接 toast */
private String message;
/** 业务数据,失败时为 null */
private T data;
/** 链路追踪 ID,日志排查用 */
private String traceId;
/** 服务端时间戳(毫秒),前端做时间校准与倒计时 */
private long timestamp;
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.setCode(0);
r.setMessage("success");
r.setData(data);
r.setTraceId(TraceContext.getTraceId());
r.setTimestamp(System.currentTimeMillis());
return r;
}
public static <T> Result<T> fail(int code, String message) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(message);
r.setTraceId(TraceContext.getTraceId());
r.setTimestamp(System.currentTimeMillis());
return r;
}
}
分页那部分单独抽一个结构体,避免每个接口手搓 pageNum:
/**
* 分页响应体。total 必填,不允许返回 -1 或缺省,
* 否则前端只能靠"下一页是否还有数据"试探,多打一次请求。
*/
@Data
public class PageResult<T> {
private long total;
private int pageNum;
private int pageSize;
private List<T> items;
public static <T> PageResult<T> of(long total, int pageNum, int pageSize, List<T> items) {
PageResult<T> p = new PageResult<>();
p.setTotal(total);
p.setPageNum(pageNum);
p.setPageSize(pageSize);
p.setItems(items == null ? Collections.emptyList() : items);
return p;
}
}
Controller 层就长这样,注意幂等键从请求头取:
@RestController
@RequestMapping("/api/v1/alert")
public class AlertRuleController {
@GetMapping("/rules")
public Result<PageResult<AlertRuleVO>> listRules(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return Result.ok(alertRuleService.pageRules(pageNum, pageSize));
}
/**
* 非幂等写操作:必须带 Idempotency-Key 请求头。
* 缺失时直接拒绝,而不是放行——契约要硬。
*/
@PostMapping("/rules/{id}/trigger")
public Result<TriggerResultVO> trigger(
@PathVariable Long id,
@RequestHeader(value = "Idempotency-Key") String idempotencyKey) {
return Result.ok(alertRuleService.trigger(id, idempotencyKey));
}
}
幂等键的拦截用 AOP 做,别散落在业务代码里:
@Aspect
@Component
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redis;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
String key = IdempotencyContext.get();
if (!StringUtils.hasText(key)) {
throw new BizException(40001, "缺少 Idempotency-Key 请求头");
}
String redisKey = "idem:" + key;
// setIfAbsent 等价于 SETNX,5 分钟窗口
Boolean ok = redis.opsForValue()
.setIfAbsent(redisKey, "1", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(ok)) {
throw new BizException(40009, "重复提交,请勿重复操作");
}
return pjp.proceed();
}
}
再说接口文档。契约写死之后,必须有一份机器可读的东西,否则前端还是得来问你。我现在用 OpenAPI 3,SpringDoc 自动生成,traceId 和 Idempotency-Key 这类跨接口字段用全局参数声明一次:
openapi: 3.0.3
info:
title: 运维管理后台 API
version: 1.0.0
servers:
– url: https://internal.example.com
# 全局响应头:所有接口都带 traceId
components:
parameters:
IdempotencyKey:
name: Idempotency–Key
in: header
required: true
schema:
type: string
description: 幂等键,写操作必填,UUID 即可
headers:
TraceId:
description: 链路追踪 ID,报错时请一并提供给后端
schema:
type: string
responses:
BadRequest:
description: 业务失败
headers:
X-Trace-Id:
$ref: '#/components/headers/TraceId'
content:
application/json:
schema:
$ref: '#/components/schemas/Result'
schemas:
Result:
type: object
required: [code, message, traceId, timestamp]
properties:
code: { type: integer, example: 0 }
message: { type: string, example: success }
traceId: { type: string, example: 8f2c1a9b3d7e4f10 }
timestamp: { type: integer, format: int64 }
data: { type: object, nullable: true }
paths:
/api/v1/alert/rules:
get:
summary: 告警规则分页列表
parameters:
– name: pageNum
in: query
schema: { type: integer, default: 1 }
– name: pageSize
in: query
schema: { type: integer, default: 20 }
responses:
'200':
description: OK
content:
application/json:
schema:
allOf:
– $ref: '#/components/schemas/Result'
– type: object
properties:
data:
type: object
properties:
total: { type: integer, format: int64 }
pageNum: { type: integer }
pageSize: { type: integer }
items:
type: array
items: { $ref: '#/components/schemas/AlertRuleVO' }
这份 YAML 一旦签入仓库,前端就可以用 openapi-generator 直接生成 TypeScript 类型和请求函数,接口一改 CI 就能发现类型不匹配。我们组现在的做法是:OpenAPI 文件变更必须走 CR,等于把接口契约的评审前移到了编码之前。
六、飞算JavaAI给的文档,我还要人工改什么
/前后端设计 产出的接口设计文档已经相当完整,但有三处我每次都会手动改:
一是风格纠偏。 前面说了,它默认偏 RESTful。遇到"仪表盘聚合"、“批量操作”、"导入导出"这类场景,我会手动改成 RPC 风格路径,并在文档顶部加一段风格约定说明,避免后续生成代码时又跑回去。
二是错误码体系。 它给出的错误码通常是散的,每个接口自己定义。我会统一收拢成一张全局错误码表,按模块分段(用户 10xxx、权限 11xxx、告警 12xxx……),并约定 0 成功、4xxxx 客户端问题、5xxxx 服务端问题。这个如果不统一,前端的全局错误拦截器就没法写。
三是字段级权限和脱敏标注。 它不知道你们公司哪些字段对哪些角色可见。像手机号、身份证、成本金额这类字段,必须在接口文档里显式标注脱敏规则和可见角色,否则生成出来的代码会把原始值直接吐给前端。这个锅最后还是后端背。
还有一点提醒:接口文档生成完之后,别急着往下走。我通常会拿着这份文档跟前端过一遍,重点确认三件事——分页参数的默认值、列表接口的排序字段、以及批量接口的失败语义(是全部回滚还是部分成功)。这三个地方最容易在联调时出问题,而且改起来都是伤筋动骨的。
七、一句话总结
接口风格是手段,契约一致才是目的;风格可以妥协,七个字段一个都不能少。
我的接口契约检查清单(每次提测前过一遍):
这七条过完,联调阶段的扯皮能少一大半。剩下的锅,至少能清晰地分清楚是谁的。

