欢迎光临
我们一直在努力

API接口设计别再拍脑袋:RESTful还是RPC,我用飞算JavaAI怎么选

/* 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)的 /前后端设计 指令,我通常是这么用的:

  • 先跑完 /需求分析,让项目 docs 目录里有需求文档和业务设计文档;
  • 智能会话 → 添加指令 → 选 /前后端设计;
  • 输入类似"帮我完成运维管理后台的前后端设计";
  • 它产出工作区上下文、技术栈决策、数据库设计、接口设计、技术需求覆盖、后端设计规范基线等一整套文档;
  • 设计遇到边界模糊的地方会主动让我确认,比如"主机列表是否需要支持跨机房聚合查询"。
  • 接口设计文档这一份是我每次必看的。它会把每个接口的路径、方法、入参、出参、错误码列出来,而且字段级别都有类型和说明。这对我最大的价值不是省时间,而是它逼着我把那些我本来会口头交代的东西落成文字。

    举个真实例子。我上次让它设计"告警规则管理"模块,它给出来的接口清单里有一个 GET /api/v1/alert/rules,返回体里带了 total、pages、以及一个 items 数组。我当时觉得这很自然,但转念一想——我之前手写接口的时候,total 这个字段经常忘,前端做分页只能靠"下一页还有数据吗"这种试探式逻辑,多打一次请求。

    有个细节我挺认可的:它在设计文档里会显式标注哪些接口是幂等的、哪些需要幂等键。这个东西人肉写接口的时候太容易漏了,而漏掉的后果是用户双击提交按钮产生两条脏数据。

    不过它也有个毛病:它倾向于给出"教科书式"的 RESTful 设计。这是大模型的通病,训练数据里 RESTful 的规范文档太多了。所以我通常会在 /前后端设计 之前就把我的风格约束写进去,比如"内网管理后台,主体采用 RESTful,但对列表页的聚合统计场景允许使用面向动作的 POST 接口,路径统一为 /api/v1/{module}/{action}"。

    三、RESTful、RPC、GraphQL 在管理后台怎么选

    内网管理后台这个场景有几个特点:用户量小(几十到几百人)、页面复杂度高(大量表格+筛选+批量操作)、迭代快、几乎不考虑公网缓存、前后端通常同一个团队。

    这三个特点直接决定了选型倾向。我做了个对比表:

    维度RESTfulRPC(HTTP+POST)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 服务端问题。这个如果不统一,前端的全局错误拦截器就没法写。

    三是字段级权限和脱敏标注。 它不知道你们公司哪些字段对哪些角色可见。像手机号、身份证、成本金额这类字段,必须在接口文档里显式标注脱敏规则和可见角色,否则生成出来的代码会把原始值直接吐给前端。这个锅最后还是后端背。

    还有一点提醒:接口文档生成完之后,别急着往下走。我通常会拿着这份文档跟前端过一遍,重点确认三件事——分页参数的默认值、列表接口的排序字段、以及批量接口的失败语义(是全部回滚还是部分成功)。这三个地方最容易在联调时出问题,而且改起来都是伤筋动骨的。

    七、一句话总结

    接口风格是手段,契约一致才是目的;风格可以妥协,七个字段一个都不能少。

    我的接口契约检查清单(每次提测前过一遍):

  • 路径带 /api/v1 版本号了吗?
  • 响应体统一 Result<T>,code/message/traceId/timestamp 四个字段齐了吗?
  • 分页接口 total 是真总数,还是写死的 -1?
  • 非幂等写操作有没有强制 Idempotency-Key?
  • 错误码是全局统一分段的,还是各接口自己编的?
  • OpenAPI 文件签入仓库了吗,前端的类型是从它生成的吗?
  • 批量接口的"部分失败"语义,跟前端对过吗?
  • 这七条过完,联调阶段的扯皮能少一大半。剩下的锅,至少能清晰地分清楚是谁的。

    赞(0)
    未经允许不得转载:171主机测评 » API接口设计别再拍脑袋:RESTful还是RPC,我用飞算JavaAI怎么选
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址