ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

大盘零报错,前端成功率却掉了 18 个点:GraphQL 把错误藏进 HTTP 200 的 30 天

大盘零报错,前端成功率却掉了 18 个点:GraphQL 把错误藏进 HTTP 200 的 30 天 title: 大盘零报错前端成功率却掉了 18 个点GraphQL 把错误藏进 HTTP 200 的 30 天date: 2026-10-04tags: [Spring Boot, GraphQL, REST, API 设计, 微服务监控]凌晨一点十七分值班群安静得反常。按惯例我们商品中心的告警阈值是5 分钟内 5xx 超过 0.5% 就打电话那晚 Prometheus 大盘上 5xx 曲线是一条纹丝不动的直线稳稳地贴着零。与此同时运营在另一个群里甩了三张截图App 首页的商品瀑布流全是灰块评价数和店铺名全部加载失败。前端同学贴出 RUM 数据——过去 40 分钟GraphQL 接口的业务成功率从 99.4% 跌到 81.6%掉了将近 18 个点。后端零报错前端哀鸿遍野。这不是灵异事件这是我们商品中心把聚合层从 REST 迁到 GraphQL 之后的第 30 天一次教科书级的可观测性真空事故。这篇文章复盘那次事故的完整链条顺着它把 REST 和 GraphQL 在错误语义、缓存、监控这三件事上的差异摊开讲清楚。先把我的判断放在前面GraphQL 解决的是客户端取数灵活性这一个问题而它把 HTTP 语义这个 REST 用了二十年的免费福利给弄丢了——这笔账很多团队在选型时根本没算过。事故的起点一次看起来很划算的迁移先交代背景。我们的商品中心是一个 Java 体系Spring Boot 3.2 Spring Cloud 2023.0BFF 层原来是 5 个 REST 聚合接口/aggregate/home、/aggregate/detail之类。前端一直抱怨两点一是首页改版一次后端就要跟着改一次聚合接口的字段拼装逻辑发版节奏被互相拖住二是有些端快应用、小程序只想要三分之一的数据却被迫下载全量响应流量白白多花 60%。2024 年 2 月我们决定在 BFF 层引入 GraphQL用的是 spring-graphql 1.2.5注解驱动的编程模型。迁移本身很顺schema 三天写完QueryMapping和SchemaMapping把 5 个聚合接口收敛成 1 个/graphql端点前端按需取字段两个痛点看起来都解决了。灰度两周RT 正常错误率正常——注意是错误率正常这个词后面会变成整篇文章的题眼。3 月 8 日晚上全量放量后的第 30 天对象存储那边一次密钥轮换出了问题图片签名服务开始批量超时。BFF 里图片签名是通过SchemaMapping给每个商品卡片补signedUrl字段的签名服务挂了这个字段的数据获取器DataFetcher抛异常。前端拿到的响应是什么样的HTTP 200body 里data大部分为 nullerrors数组里躺着十几条 INTERNAL_ERROR。网关看到 200健康检查看到 200Prometheus 的 http_server_requests 指标按状态码统计——还是 200。于是大盘全绿用户白屏47 分钟后靠运营的人工反馈才发现问题。事故时间线47 分钟里每一步都正常复盘时我们把时间线一帧一帧拼了出来越拼越觉得后背发凉。00:30图片签名服务的密钥过期批量请求开始 5 秒超时。签名服务自己的指标红了但它的告警只通知了基建组的值班同学对方以为是例行密钥轮换的抖动准备观察十分钟。00:31BFF 的signedUrl字段解析开始抛异常spring-graphql 把每个商品卡片的异常都收集进 errors 数组响应状态码照旧 200。00:33前端的 RUM 开始记录失败——但前端团队没有针对 GraphQL errors 配置上报规则RUM 里的接口失败判定条件还是status 500所以 RUM 面板上同样一片绿。00:52运营在内部群里发截图前端同学手动 curl 了一次接口看到满屏的 null 和 errors 才惊觉出事。01:17故障确认、密钥回滚恢复。四十七分钟里基础设施没有说谎它只是没有被告知真相。状态码是 200这是事实数据拿不到这也是事实。两个事实之间的缝隙就是我们欠下的可观测性债务。如果那天晚上挂的不是签名服务而是商品主数据服务整个首页就是全白屏用户投诉量会是当时的十倍——这个如果是我们后来下决心整改的直接动力。原理拆解为什么 GraphQL 天生把错误装进 200要理解这次事故得回到两套协议的错误模型本身。REST 的错误语义构建在 HTTP 状态码上。404 是资源不存在401 是没登录429 是限流500 是服务端炸了。这套语义的真正价值不在标准而在于它是一条公用的总线网关、负载均衡器、CDN、APM、日志采集器整条链路上的所有基础设施都认状态码。Nginx 会按状态码打 access logPrometheus 的http_server_requests_seconds_count{status500}按状态码打点Sentry 的 SDK 默认只上报 4xx/5xx连公司的拨测平台都是拿状态码当探活信号。你在 Controller 里抛一个异常Spring 的RestControllerAdvice把它转成 500整条可观测性链路是自动接管的一行监控代码都不用写。GraphQL 走了另一条路。它把传输层协议完全架空——规范只约束请求里是 query响应里有 data 和 errors至于你用 HTTP 200 装还是用信鸽传规范不管。实践中几乎所有实现包括 spring-graphql都默认只要 GraphQL 引擎本身执行了查询就返回 HTTP 200哪怕每个字段都解析失败哪怕 resolver 里抛了 10 个异常。错误信息被装进响应体的errors数组字段级的失败还伴随data里对应字段的 null。HTTP 状态码在这里退化成了一个只反映传输是否成功的信号跟业务成败脱钩了。这不是 spring-graphql 的 bug是 GraphQL 规范的有意设计一个 query 可以部分成功——商品名解析出来了评价列表挂了签名 URL 挂了你不能用一个 500 把整个响应否掉前端确实拿到了它能用的部分数据。部分失败这个语义HTTP 状态码表达不了。所以 GraphQL 的选择有其合理性问题是选择它意味着你把错误可见性从基础设施手里接过来变成了自己的责任。我们栽的跟头就是接了权却没接活。再往深挖一层两套模型其实对应着两种错误分类的哲学。我习惯把后端错误分成三层传输层错误连接拒绝、超时、4xx/5xx这层 REST 交给 HTTP执行层错误query 语法错误、字段不存在、权限不足这层 GraphQL 交给 errors 数组和 errorType业务层错误库存不足、积分不够这层谁都不管得你自己定义。REST 的做法是把执行层错误也塞进 HTTP 状态码——400、403、422配合 ProblemDetail 可以表达得很清楚代价是部分失败没法表达。GraphQL 的做法是把传输层语义也拉平进 errors 数组好处是响应体自包含代价是基础设施集体失明。两种选择各有代价成熟团队的标志不是选了哪个而是清楚知道自己付了哪种代价并且为它买了保险。先看 REST 这边错误处理其实是有免费午餐的复盘时我把 BFF 迁移前的 REST 代码翻出来对照那段代码很土但事故当晚要是它在线上告警 30 秒内就会响。它长这样RestController RequestMapping(/aggregate) public class HomeAggregateController { private final ProductClient productClient; private final ImageSignClient imageSignClient; public HomeAggregateController(ProductClient productClient, ImageSignClient imageSignClient) { this.productClient productClient; this.imageSignClient imageSignClient; } GetMapping(/home) public ResponseEntityHomeVO home(RequestParam long categoryId, RequestHeader(X-User-Id) long userId) { ListProduct products productClient.listByCategory(categoryId, 20); ListLong imageIds products.stream().map(Product::getImageId).toList(); MapLong, String signedUrls; try { signedUrls imageSignClient.batchSign(imageIds); } catch (SignServiceException e) { // 签名失败是可降级的返回占位图不让整个首页失败 signedUrls Collections.emptyMap(); log.warn(签名服务失败首页降级为占位图, size{}, imageIds.size(), e); } return ResponseEntity.ok(HomeVO.from(products, signedUrls)); } ExceptionHandler(UpstreamTimeoutException.class) public ResponseEntityProblemDetail onTimeout(UpstreamTimeoutException e) { ProblemDetail pd ProblemDetail.forStatusAndDetail( HttpStatus.GATEWAY_TIMEOUT, 上游服务超时: e.getUpstream()); pd.setTitle(aggregate-timeout); return ResponseEntity.of(pd).build(); } }逐段拆一下这段代码的错误语义。构造器注入的ProductClient和ImageSignClient是两个上游的 Feign 客户端真正的关键在try/catch那一段签名服务失败被显式地归为可降级错误打 warn 日志、返回空 Map、首页用占位图兜底——降级决策写在了代码里谁来兜底一目了然。stream().map().toList()收集所有商品的主图 ID 用于批量签名这是把 REST 聚合层的 N1 调用压成一次批量调用的常规手段。而ExceptionHandler方法处理的是另一种不可降级的错误上游超时直接映射成 HTTP 504返回 RFC 7807 格式的 ProblemDetailsetTitle给错误起了个可检索的名字值班同学在日志里 grep 这个词就能定位到同一类故障。504 一旦出现网关日志、Prometheus 指标、告警规则全部自动生效。注意这段代码里没有一行是为了监控而写的——状态码本身就是监控信号这就是 REST 的免费午餐。对照事故当晚的 GraphQL 版本同一个签名失败在SchemaMapping方法里抛出去spring-graphql 把它包成graphql.GraphqlException装进 errors 数组HTTP 层面一片祥和。降级逻辑没人写schema 层做字段级降级比 Controller 里麻烦得多监控信号没人接errors 数组对指标系统是个黑盒。同一个故障两种命运。迁移前的另一段代码schema 层的取数逻辑为了讲清楚 GraphQL 侧的问题得把它当时的实现也摆出来。这是收敛后的 schema 入口和商品字段的解析器Controller public class ProductGraphQlController { private final ProductClient productClient; private final ImageSignClient imageSignClient; private final ReviewClient reviewClient; public ProductGraphQlController(ProductClient productClient, ImageSignClient imageSignClient, ReviewClient reviewClient) { this.productClient productClient; this.imageSignClient imageSignClient; this.reviewClient reviewClient; } QueryMapping public CompletableFutureListProduct homeProducts( Argument long categoryId, Argument int first, GraphQLContext context) { long userId context.get(userId, 0L); return productClient.listByCategoryAsync(categoryId, first, userId); } SchemaMapping public CompletableFutureString signedUrl(Product product, GraphQLContext context) { return imageSignClient.signAsync(product.getImageId()) .orTimeout(800, TimeUnit.MILLISECONDS); } SchemaMapping public CompletableFutureInteger reviewCount(Product product) { return reviewClient.countAsync(product.getId()); } }这段代码里藏着几个值得单独拎出来说的细节。QueryMapping标注的homeProducts是查询入口对应 schema 里的homeProducts(categoryId, first)字段返回CompletableFuture是为了把上游调用交给线程池异步执行避免 GraphQL 引擎的工作线程被慢调用占满。SchemaMapping标注的两个方法是字段级解析器只有当客户端的 query 里真的写了signedUrl或reviewCount时它们才会被调用这就是按需取数的实现机制——字段的存在不等于执行选择权在客户端这也是 GraphQL 交给前端的能力和交给后端的风险。注意signedUrl里的orTimeout(800, MILLISECONDS)这是我们事后补上的兜底单个字段解析最多等 800 毫秒超时抛异常进 errors 数组至少不让一个慢字段拖垮整个 query 的 RT。事故当晚的版本里没有这行签名服务 5 秒超时会被原样透传一个 20 个商品的首页 query 要串行等 20 次 5 秒超时后来靠 DataLoader 批量化才缓解这就是为什么当晚 P99 直接飙到 8 秒。结构上这段代码比 REST 版干净——没有手工拼装的 HomeVO字段各自独立加一个字段不用动别的字段。这就是 GraphQL 的真实收益我不否认它。但你也看到了干净的取数逻辑和脏的故障处理是同一个迁移的两面而我们只验收了前者。实战修复给 GraphQL 补上三条命事故复盘后我们定了三条整改错误分类要有契约、指标要从 errors 数组里挖出来、危险查询要设闸门。三条都有代码一条条看。整改一把业务异常翻译成带分类的 GraphQLError。spring-graphql 提供了GraphQlExceptionHandler可以让异常带着明确的 errorExtensions 冒出来ControllerAdvice public class GraphQlErrorAdvice { GraphQlExceptionHandler public GraphQLError handleSignFailure(SignServiceException ex) { return GraphQLError.newError() .errorType(ErrorType.INTERNAL_ERROR) .message(图片签名暂不可用) .path(List.of(homeProducts, signedUrl)) .extensions(Map.of( code, IMAGE_SIGN_DOWN, recoverable, true, upstream, image-sign-service )) .build(); } GraphQlExceptionHandler public GraphQLError handleAuth(UnauthorizedException ex) { return GraphQLError.newError() .errorType(ErrorType.UNAUTHORIZED) .message(登录态失效) .extensions(Map.of(code, AUTH_EXPIRED)) .build(); } }这段代码在干一件事给 errors 数组里的错误建立机器可读的契约。errorType用的是 GraphQL 规范预定义的枚举UNAUTHORIZED 和 INTERNAL_ERROR 的区别前端可以直接编码判断语义上对标 REST 的 401 和 500path指明了错误发生在 query 的哪个字段路径上前端据此可以做字段级的 UI 降级而不是整页报错extensions里的code是我们内部的错误码规范recoverable: true告诉前端这个字段可以静默降级不要弹错误页upstream记录了故障源头排障时不用再翻堆栈去猜是哪个下游挂了。没有这一层前端拿到的默认错误信息只有一句Internal Server Error加一堆堆栈路径既没法降级也没法归因。做 GraphQL错误契约必须像定义 schema 一样被认真设计这是我这次学到最贵的一课。整改二从响应体里挖监控指标。errors 数组不会自己变成 Prometheus 指标我们用WebGraphQlInterceptor在响应出站前做了拦截Component public class GraphQlMetricsInterceptor implements WebGraphQlInterceptor { private final Counter errorCounter; public GraphQlMetricsInterceptor(MeterRegistry registry) { this.errorCounter Counter.builder(graphql_errors_total) .description(GraphQL response errors) .register(registry); } Override public MonoWebGraphQlResponse intercept(WebGraphQlRequest request, WebGraphQlChain chain) { return chain.next(request).doOnNext(response - { ListGraphQLError errors response.response().getErrors(); if (!errors.isEmpty()) { errorCounter.increment(errors.size()); log.error(graphql_query_failed operation{} errorCount{} codes{}, request.getOperationName(), errors.size(), errors.stream() .map(e - (String) e.getExtensions() .getOrDefault(code, UNKNOWN)) .distinct().toList()); } }); } }逐行看职责。构造器里注册的 Countergraphql_errors_total是总量指标用于触发告警阈值intercept方法挂在响应链上chain.next(request)放行请求后在doOnNext里检查出站响应的 errors 列表——这一步等价于 REST 世界里按状态码打点只不过信号源从 HTTP 层下沉到了响应体层。getOrDefault(code, UNKNOWN)兜住了没有经过 Advice 翻译的裸异常让这类错误在日志里以 UNKNOWN 面目出现本身就是一种契约不完整的信号。日志那几行把 operationName 和去重后的错误码打进 error 级别日志这样 ELK 里可以直接按错误码聚合跟指标形成交叉验证。上线这套之后我们的告警规则从5xx 0.5%电话告警改成rate(graphql_errors_total[5m]) / rate(graphql_queries_total[5m]) 1%电话告警语义跟原来对齐了。有一个实现细节要提醒如果你们走的是 WebSocket 订阅而不是 HTTP这个拦截器覆盖不到需要在WebSocketGraphQlInterceptor里再做一遍我们就是在这一步漏了订阅端点被 QA 提了个缺陷。整改三别让客户端的 query 无限生长。这次事故还暴露出一个次要问题某个版本的前端把 query 嵌套写到 9 层深单个查询触发的 DataFetcher 调用超过 400 次。GraphQL Java 提供了现成的深度和复杂度限流 InstrumentationBean public GraphQlSourceBuilderCustomizer queryGuards() { return builder - builder.configureGraphQl(graphQlBuilder - graphQlBuilder.instrumentation(new ChainedInstrumentation(List.of( new MaxQueryDepthInstrumentation(6), new MaxQueryComplexityInstrumentation(100))))); }这两行配置分别把查询深度上限压到 6 层、复杂度评分压到 100 分超限的 query 会在执行前被拒绝返回 BAD_REQUEST 级别的 GraphQLError不消耗任何下游资源。REST 世界里接口的复杂度上限由后端硬编码决定——一个分页接口最多返回 20 条天然封顶GraphQL 把查询的形状交给客户端等于把服务端的成本模型也交了出去深度和复杂度限制不是可选项是 GraphQL 上线的入场券。评分规则我们定的是叶子字段 1 分、列表字段 2 分再乘以 first 参数100 分的上限意味着前端最多一个 query 拉约 40 个商品卡片的全量字段。两道闸门配合才把 P99 从灰度初期的 2.1 秒压回了 340 毫秒。设限的具体数值不重要重要的是必须设限这个决策本身——它决定你的 GraphQL 是内部服务还是公共靶场。那几笔没算过的账缓存、分页和生态位错误语义之外这次复盘还逼我把另外几笔账算清了。头一笔是 HTTP 缓存。REST 的 GET 接口享受着整个 CDN 体系的红利Cache-Control、ETag、Vary商品分类这种准静态数据在 CDN 上命中率 97%回源量几乎可以忽略。而 GraphQL 的主流实践是 POST 提交 queryPOST 默认不被任何 HTTP 缓存层缓存我们迁移后 CDN 对商品数据的命中率直接归零回源 QPS 涨了 3.6 倍BFF 加了 4 个实例才扛住。后来我们做了混合方案准静态数据走 REST GET 接口留在 CDN 体系里个性化和聚合数据走 GraphQL回源量才回到健康水位。自动持久化查询APQ也能部分缓解但那是优化不是等价替代。第二笔是分页语义。REST 的 offset 分页写起来最省事但深翻页性能差、数据漂移会重复游标分页cursor解决了这两个问题代价是客户端要透传不透明的 token。GraphQL 社区在这一点上被 Relay 规范推动着直接站在了正确的一边Connection 模式edges { node cursor } pageInfo { hasNextPage endCursor }把游标分页变成了事实标准schema 类型系统还能强约束分页参数。说实话我们在 REST 时代想推游标分页推了两年没推成——接口文档里一句cursor 传上一次响应的 endCursor总能被前端传成页码。GraphQL 的强类型 schema 把这个约定变成了编译期检查。这一项GraphQL 是净胜我不想因为事故就把它一棍子打死。第三笔是团队协作契约。OpenAPI 的生态成熟度远超 GraphQL schema 工具链我们的 API 网关按 OpenAPI 规范做灰度发布和字段级 mock测试平台按 OpenAPI 生成自动化用例这些在切到 GraphQL 后都要重新找轮子。spring-graphql 对 schema 的校验、federation 的网关支持这两年进步很快但周边生态的差距是客观存在的小团队踩这些坑的边际成本不低。还有人的因素Java 团队学 GraphQL 的思维成本被普遍低估我们组 8 个后端真正能独立设计好 schema 里关联关系的两周之后只有 5 个剩下 3 个写出的 schema 要么是把 REST 响应原样搬进来的伪 GraphQL要么关联关系设计得过深导致查询树失控。我的分界线什么时候用哪个复盘结束我在团队 wiki 上写了一页选型判断核心就三条。我不建议为了技术先进性把已有的、运转良好的 REST 体系整体迁到 GraphQL。GraphQL 的收益集中在客户端多样、取数组合爆炸、迭代节奏被后端拖累的场景——我们是 4 个端的 App 小程序 快应用首页卡片组合有 30 多种这个场景下按需取数是刚需值得付可观测性和缓存的成本。如果你的客户端就一个 Web 后台接口数量 30 个以内上 GraphQL 纯属给自己加戏你要先补错误契约、补指标拦截、补深度限制、补缓存策略才能追平 REST 的起点这笔账我劝每个想迁移的团队先算三遍。REST 更适合的场景对外部合作伙伴开放的 API状态码和 CDN 是公共语言别让对接方去解析你的 errors 数组、高缓存的读多写少数据、以及团队规模小到没有精力维护 schema 治理的项目。GraphQL 更适合的场景端多且取数差异大、BFF 层聚合链路长、前后端可以紧密协作共同维护 schema 的团队。还有一个容易被忽略的判断维度是组织成熟度如果团队连 REST 时代的统一异常处理和错误码规范都没建立先别碰 GraphQL——它不是让混乱消失而是把混乱的表面积从接口级别扩大到字段级别。如果让我再选一次我还是会做那次 GraphQL 迁移——四个端的按需取数收益是实打实的灰度后 BFF 的代码量从 5 个聚合接口的 8000 行拼装逻辑降到 2400 行 schema 加 resolver前端一次首页改版不用再等后端排期。但我一定会把顺序反过来先上错误契约和指标拦截器再开放首个端点的灰度。可观测性不是上线后的补丁它是你选择 GraphQL 那一刻起就欠下的债。一年后回头看这套判断经住了考验吗文章写到这里离那次事故已经过去一年多补一段后记说说这套选型判断后来的命运。2025 年公司启动了出海项目面向海外用户的内容站是典型的单端、读多写少、强 CDN 依赖场景——我们毫不犹豫用了纯 RESTCache-Control: public, max-age300加stale-while-revalidateCloudFront 命中率 94%源站压力小到监控图上像没上线。同期的国内 App 端持续迭代GraphQL schema 从 38 个类型长到 120 多个暴露了新的治理问题schema 变更的破坏性检测靠 graphql-inspector 在 CI 里卡关废弃字段的生命周期管理比 REST 多花了一整轮设计——REST 删一个接口看网关日志就知道还有谁在调GraphQL 删一个字段要看十几个端的 query 落盘分析。灵活性是有复利的治理成本同样有复利这句话现在挂在我们 schema 评审模板的置顶位置。还有一个当初没预料到的收益方向GraphQL 把接口的消费情况变成了结构化数据。因为每个 query 都带 operationName 和字段集合我们做了个简单的 query 落盘分析发现 120 个 schema 字段里有 31 个上线半年从没被任何端的 query 引用过直接标记废弃而 REST 时代一个接口是否还有人在调我们只能靠猜。从接口有没有人用到字段有没有人用粒度细了一个数量级这是 GraphQL 强类型 schema 附赠的、选型时没人提过的能力。所以一年后我的分界线没有变反而更清晰了GraphQL 的甜点区是多端、高迭代、字段级消费分析有真实价值的产品主链路REST 的甜点区是开放、可缓存、面向陌生调用方的对外接口。两者在我们 200 多人的后端团队里共存了一年能称得上冲突的只有一点新人入职培训要讲两套这大概是混合架构最便宜的代价。收尾事故编号 INC-20240308-11 的遗产那次事故最后定级 P2影响时长 47 分钟波及约 6 万次首页请求。事后依赖签名服务本身的告警才定位到根因——讽刺的是全链路最诚实的信号来自最先挂掉的那个服务而不是离用户最近的 BFF。三条整改上线后三个月里又发生过两次上游抖动两次都在 90 秒内触发告警值班同学赶在用户感知前完成切换。指标不说话的系统比没有指标的系统更危险因为它给了你虚假的安全感。这次事故还留下了一个团队习惯任何新协议、新框架接入评审清单里多了一栏错误如何被看见答不上来的方案不允许上生产。这一栏后来拦下过一次 gRPC 直连的提案——错误码在 trailers 里我们的日志采集器当时不解析 trailers故事和这次如出一辙。留一个问题给你你现在的系统里有没有哪类错误正在以 HTTP 200 的形式静默流动网关超时被业务代码 catch 住返回空数组、下游异常被统一异常处理器包成{code: 0, msg: success, data: null}——这些都和 GraphQL 的 errors 数组同构。花十分钟查一下你的错误率指标统计的是HTTP 失败还是业务失败答案可能会让你后背发凉。查完之后欢迎在评论区聊聊你查到了什么或者你在 REST 和 GraphQL 之间画的那条线画在了哪里。更新评论区高频问题补充文章发到团队内网后有同事问了两个好问题一并补充。为什么不直接把 GraphQL 的响应状态码改成非 200有一些框架支持把带错误的响应映射成 4xx/5xx。我们的结论是不这样做原因回到部分失败语义一个 query 12 个字段挂了 2 个映射成 500 会让 CDN 和重试机制把那 10 个成功字段的数据也一起丢掉客户端体验反而劣化。方向应该是让监控下沉到响应体而不是让响应体上浮去骗传输层。折中做法是在网关层把errors 数组非空且 errorType 全是 INTERNAL_ERROR的响应用日志标红处理。errors 数组和前端错误码表怎么同步我们的 schema 里给每个错误码定义了 enumCI 里有一个单测校验advice 里抛出的 code 必须存在于 schema enum防止后端随手加错误码而前端不知情。契约测试在 REST 世界有现成方案Pact、Schemathesis 都能开箱用GraphQL 世界要自己搭又是选型时容易被漏算的一笔成本。选型没有站队只有场景。REST 和 GraphQL 这场持续十年的争论里最值得带走的不是谁赢的结论而是那个更底层的认知你选的不只是一个取数协议而是整套错误可见性、缓存策略和协作契约的责任归属。想清楚这一点选哪个都不会太错。
返回列表