REST、gRPC 与 OpenFeign:远程接口、生成代码和调用结果
GET /quotes/BOOK-1 和 Catalog.GetQuote 都可以查询商品报价。前者用 HTTP 方法和资源路径表达接口,后者用服务与方法定义 RPC。Java 调用代码则可以分别写成 Feign 接口或生成的 gRPC stub。
比较它们时,先分清正在选择哪一层:接口怎样表达、数据怎样编码、网络怎样传输,以及开发者用什么 API 发起调用。
接口风格、协议与客户端各管一层
四层选择
| 层次 | 需要决定的问题 | 常见形式 |
|---|---|---|
| 接口设计 | 暴露哪些资源、操作和业务含义 | 资源式 HTTP API、RPC 方法 |
| 消息格式 | 字段类型、编码和兼容规则 | JSON、Protocol Buffers |
| 通信协议 | 请求、响应、流和错误怎样在网络上传递 | HTTP/1.1、HTTP/2 上的 gRPC |
| 客户端模型 | 调用方怎样组织代码、配置连接和处理结果 | RestClient、WebClient、HTTP Service Client、OpenFeign、gRPC stub |
REST 是一种架构风格。常见资源式 API 使用 HTTP 方法、状态、表示和缓存语义,但仅返回 JSON 并不能体现完整的 REST 约束。gRPC 定义远程服务调用机制,通常使用 Protocol Buffers 描述消息,并通过 HTTP/2 传输。OpenFeign 则是声明式 HTTP 客户端,本身不提供一种与 HTTP 并列的新通信协议。
对于公开 HTTP 接口,浏览器、curl、缓存和代理的配套往往更直接。跨语言内部接口需要生成代码、明确消息类型或流式交互时,可以考虑 gRPC。已有 Spring Cloud 应用使用 OpenFeign 时,仍需选择下层 HTTP client、连接池和调用策略;换成一个 Java 接口不会消除网络等待。
HTTP 接口的组成
报价 HTTP API 可以直接拆成:
GET /quotes/BOOK-1
├── GET 查询资源,遵守方法语义
├── /quotes/BOOK-1 报价资源标识
├── Accept 调用方接受的响应格式
└── 响应
├── 状态码 200 / 404 / 503 等
├── Content-Type application/json
└── 表示 金额、币种、价格版本等字段成功状态、错误状态和响应体需要一起设计。错误响应应给调用方足够的信息区分参数错误、权限拒绝、资源不存在和暂时不可用,同时避免暴露内部路径、SQL 或凭据。方法的安全性、幂等性与重试条件以 HTTP 语义为依据,再结合具体业务实现。HTTP Semantics
Feign 接口把请求构造映射到 Java 方法:
@FeignClient(name = "catalog", url = "${provider.http-url}")
public interface CatalogHttp {
@GetMapping("/quotes/{sku}")
Map<String, Object> quote(@PathVariable("sku") String sku);
}name 标识这个命名客户端的配置上下文;这里的 url 明确指定提供者地址,不经过注册发现。成功响应经 Decoder 转成返回类型,非成功响应按 ErrorDecoder 等规则形成异常。大项目宜使用清晰 DTO;示例用 Map 让返回字段直接可见。
Spring Cloud OpenFeign 当前定位为 feature-complete,官方建议新功能需求关注 Spring HTTP Service Clients,而不是宣称现有 Feign 已经无法使用。HTTP Service Clients 也通过声明式接口建立代理,可由 RestClient 或 WebClient 支持;迁移时仍须比对超时、错误映射、拦截器和观测配置。OpenFeign 项目说明、Spring HTTP 客户端
gRPC 的服务、消息与状态
gRPC 方法由 service 和 rpc 定义,消息字段通过编号识别:
service Catalog {
rpc GetQuote(QuoteRequest) returns (QuoteReply);
rpc WatchQuotes(QuoteRequest) returns (stream QuoteReply);
}
message QuoteRequest {
optional string sku = 1;
}
message QuoteReply {
string sku = 1;
int64 amount_minor = 2;
string currency = 3;
int64 version = 4;
reserved 5;
reserved "legacy_price";
}amount_minor=1990 与 CNY 组合表示 19.90 元。金额单位是接口约定,不能只看 int64 就知道它表示元还是分。version 表示示例报价版本,也需要与商品版本、接口版本区分。
protoc 根据消息定义生成类型,gRPC Java 插件生成服务基类与 stub。提供者实现服务方法,调用者通过 channel 建立通信并使用 stub;客户端与服务端在实际网络上传递序列化消息。gRPC Java 基础
一次调用的 gRPC 结果由 status 表达,例如 OK、INVALID_ARGUMENT、NOT_FOUND、UNAVAILABLE、DEADLINE_EXCEEDED。应用错误可以在 HTTP/2 连接正常时通过 gRPC status 返回;只看 HTTP 传输层的 200 会漏掉 RPC 失败。gRPC 状态码
metadata 用于附加上下文;传输后的 trailing metadata 可以携带结束状态等信息。跟踪标识、认证材料和业务字段不应随意互换位置,尤其不能把可伪造的 metadata 当成已验证身份。
怎样选择交互形式和演进契约
一次响应与四种 RPC 形态
| gRPC 形态 | 请求与响应 | 常见用途 |
|---|---|---|
| unary | 一个请求、一个响应 | 查询报价、提交一项操作 |
| server streaming | 一个请求、多个响应消息 | 持续结果、分段查询、订阅更新 |
| client streaming | 多个请求消息、一个最终响应 | 汇总上报、分段上传 |
| bidirectional streaming | 双方各自持续发送消息 | 双向协作、交互式流处理 |
同一条流内的消息有顺序,但双向流的两侧可以独立发送。多条消息逐条到达,与返回一个装满全部结果的数组具有不同的内存和延迟表现。
示例的 WatchQuotes 返回三条有限消息,便于直接检查 stream;它没有实现长期订阅、断点续传或行情一致性。Java blocking stub 可以用 Iterator 读取服务端流,长流应结合异步 API、流控及取消设计,不能无限收集进 List。gRPC 流控
浏览器接入还要考虑 gRPC-Web 及代理适配。不能仅因为浏览器支持 HTTP/2,就假设普通 fetch 可以直接替代完整 gRPC 客户端;gRPC-Web 的传输形式和可用流类型需要按工具链确认。gRPC-Web 基础
字段兼容与业务校验
Protocol Buffers 的字段编号属于 wire format 的长期约定。删除字段后保留编号与旧名称,避免以后用同一个编号表示不同含义。新增字段时,也要考虑旧调用方看不到新字段后如何工作。
proto3 的 optional 标量可以区分“未提供”与“显式设置成默认值”。示例使用 hasSku() 检查字段是否出现,再检查非空;可解析的消息仍可能不符合业务要求。协议解析、字段存在性、取值范围和业务授权是不同检查。Proto3 语言与演进规则
二进制消息能被旧版本解析,也不代表新业务语义已经兼容。例如把 amount_minor 的单位从分改成元,不改变字段类型就可能造成错误结算。接口升级应保留原有含义,通过新字段、新方法或明确版本安排迁移。
JSON 接口也需考虑字段新增、枚举扩展、数字精度、null 与缺失值,以及错误体结构。Java DTO 反序列化成功后,调用方还应检查真正必需的业务字段,不能把默认值当成服务端已经明确提供的结果。
Deadline、取消和重试
gRPC 调用默认不会替应用设置一个合适的 deadline。调用方应在业务允许的时间内明确指定:
CatalogGrpc.newBlockingStub(channel)
.withDeadlineAfter(2, TimeUnit.SECONDS)
.getQuote(request);deadline 到达后,客户端可能收到 DEADLINE_EXCEEDED。服务端会感知取消,但应用自行调度的工作仍需合作处理;已经完成的数据库提交、消息发送或外部调用不会被网络取消自动回滚。gRPC Deadline、取消机制
重复调用可能来自客户端库、上层框架、网关或业务代码。Spring Cloud OpenFeign 默认提供 Retryer.NEVER_RETRY,与裸 Feign 的默认行为不同。是否增加重试应按失败类型和方法副作用决定,而不是看到 503 就在每一层各补一次循环。OpenFeign 配置与默认组件
连接超时与读取超时也要分别配置。示例为命名客户端 catalog 设置连接 1000 毫秒、读取 2000 毫秒;它们不自动构成包含所有上层重试和排队的整体截止时间。更完整的等待与重试安排见负载均衡、超时与重试。
调用真实 REST 与 gRPC 服务
版本、工具与工程布局
在 Linux、Bash、Docker Engine 和 Compose 插件上运行;宿主机安装 curl、jq、unzip。使用有 Docker 权限的普通用户,选择隔离实验机。下载完整源码 ZIP,解压目录中的 README.md 列出实验入口。
test "$(id -u)" -ne 0 || exit 1
set -o pipefail
docker version
docker compose version
curl --version
jq --version
unzip microservice-rpc-lab.zip
cd microservice-rpc-lab
export COMPOSE_PROJECT_NAME=ms-rpc-lab
export LAB_DIR="$PWD"
export LAB_UID="$(id -u)"
export LAB_GID="$(id -g)"
mkdir -p .m2三个模块分别保存:
wire/
└── src/main/proto/catalog.proto 公共消息与服务定义
provider/
├── QuoteController 真正的 Boot HTTP 接口
├── QuoteService gRPC 生成基类的实现
└── GrpcHost Netty gRPC server 生命周期
caller/
├── CatalogHttp Spring Cloud OpenFeign 接口
├── RpcClient 复用 channel 的 gRPC 客户端
├── RpcCli 直接调用 gRPC 的命令行入口
└── RpcTests 六个真实 HTTP/gRPC 集成测试Boot 4.1.1 与 Cloud 版本 2025.1.3 按官方支持关系组合,OpenFeign 由 Cloud BOM 管理。Spring Cloud 版本关系
gRPC 则显式固定为 1.84.0,protoc 与 Protobuf Java runtime 为 3.25.9,采用 gRPC 对应版本官方示例的组合。POM 覆盖 Boot 的 gRPC/Protobuf 默认版本,这套实验直接管理 gRPC SDK,没有引入 Spring gRPC starter。gRPC 1.84.0 示例 POM
Protobuf Java 3.25.x 属维护线。升级生成工具与 runtime 时应检查支持窗口并重新生成代码,不把源码语法 proto3、protoc 产品版本和 Java runtime 主版本当成同一个编号。Protobuf 支持说明
生成代码并打包
Maven 3.9.12 构建容器使用宿主 UID/GID;Maven 缓存与临时主目录均可写,编译目标为 Java 17:
docker run --rm --user "$LAB_UID:$LAB_GID" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$LAB_DIR:/work" -v "$LAB_DIR/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify
test -s provider/target/provider-exec.jar
test -s caller/target/caller.jar
find wire/target/generated-sources -name 'CatalogGrpc.java' -o -name 'QuoteReply.java'应看到 protoc 和 grpc-java 生成步骤、六个测试通过及 BUILD SUCCESS。生成代码只放在 target,不手工修改;需要改接口时修改 proto 并重新生成。
provider.jar 是可被测试模块依赖的普通 JAR,provider-exec.jar 是带依赖和启动器的可执行 JAR。caller.jar 使用明确的 CallerApplication 启动类;RpcCli 则由后面的命令指定,避免两个 main 入口混淆。
启动提供者与调用者
两个进程使用 Temurin 25.0.4_7,运行 UID/GID 为10001,根文件系统只读,/tmp 可写。端口仅绑定宿主回环:
| 端口 | 对象 |
|---|---|
| 18162 | provider 的 HTTP 和实验计数接口 |
| 19090 | provider 的 gRPC 9090 端口映射 |
| 18161 | caller 的 HTTP 演示入口 |
gRPC 使用明文传输,无认证,仅用于隔离实验;生产应配置 TLS、必要时 mTLS,并建立方法与资源权限检查。gRPC 认证与传输凭据
docker compose up -d
for endpoint in \
http://127.0.0.1:18162/actuator/health \
http://127.0.0.1:18161/actuator/health; do
ready=0
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 "$endpoint" > health.json; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail=80; exit 1; }
done
for path in /feign/BOOK-1 /grpc/BOOK-1; do
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
"http://127.0.0.1:18161$path" > quote.json
jq -e '.sku == "BOOK-1" and .amountMinor == 1990 and .currency == "CNY"' quote.json
done两个 HTTP 演示入口都会返回报价,但内部路径不同:/feign 调用提供者的 HTTP Controller;/grpc 用生成 stub 发送真正的 gRPC 请求,再把结果映射为展示用 JSON。
curl 始终将 -q 放在第一个选项位置,并关闭代理。HTTP 负例会保留传输退出码、HTTP 状态和响应体三项,不把连接失败当预期业务错误。curl 参数手册
直接调用 gRPC 与服务端流
RpcCli 运行在 caller 容器中,但不启动第二个 Boot Web 应用。PropertiesLauncher 从同一个 JAR 中加载指定 main 类:
docker compose exec -T caller java -Xmx128m \
-Dloader.main=dev.example.caller.RpcCli \
-cp /app/caller.jar org.springframework.boot.loader.launch.PropertiesLauncher \
provider 9090 quote响应以 Protobuf 文本形式输出:
sku: "BOOK-1"
amount_minor: 1990
currency: "CNY"
version: 1这里的文本是命令行展示格式,网络传输使用二进制消息。将最后的 quote 换成 stream:
docker compose exec -T caller java -Xmx128m \
-Dloader.main=dev.example.caller.RpcCli \
-cp /app/caller.jar org.springframework.boot.loader.launch.PropertiesLauncher \
provider 9090 stream应依次输出三个 QuoteReply,version 分别为1、2、3。CLI 消费真实 Iterator,结束时关闭 channel 并等待终止。caller 的 /stream/BOOK-1 会把这三条有限消息收集成 JSON 数组,方便 HTTP 观察;它没有向浏览器提供同样的逐条流式交付。
JDK 25 可能输出 native access 或 Unsafe 的兼容警告;容器 /tmp 的 noexec 也可能使 Netty 原生库回退。应以真实连接结果与终止状态判断,不能把警告当成功,也不能仅因出现警告就判断 RPC 已失败。
OpenFeign 默认会重试几次
先读取提供者计数,再通过 Feign 请求实验失败 SKU:
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18162/lab/counts > before.json
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o feign-failed.json -w '%{http_code}' \
http://127.0.0.1:18161/feign/FAIL)
transport=$?
test "$transport" -eq 0 && test "$status" = 503 || exit 1
jq -e '.downstreamStatus == 503' feign-failed.json
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18162/lab/counts > after.json
test "$(jq -r .httpCalls after.json)" -eq \
"$(( $(jq -r .httpCalls before.json) + 1 ))" || exit 1提供者实际只接收一次 HTTP 调用。该结果对应本工程的 Spring Cloud OpenFeign 默认 Retryer,没有额外 LB 或网关重试。接入其他重试层后,需要重新检查总尝试次数。
参数缺失的 gRPC 负例可以直接运行:
docker compose exec -T caller java -Xmx128m \
-Dloader.main=dev.example.caller.RpcCli \
-cp /app/caller.jar org.springframework.boot.loader.launch.PropertiesLauncher \
provider 9090 invalid > invalid.log 2>&1
result=$?
test "$result" -eq 2 || exit 1
grep -F 'gRPC status=INVALID_ARGUMENT' invalid.log应输出 gRPC status=INVALID_ARGUMENT。消息本身可以正确解析,服务端因缺少 sku 拒绝业务调用;CLI 将该 RPC 失败转换为进程退出2。若 Docker 命令本身失败,应先排查容器状态,不能只看一个非零值。
Deadline 后的动作是否仍会执行
RunDelayedAction 在服务端安排一个1秒后执行的计数动作。CLI 先查询一次报价预热真实连接,再给该动作设置300毫秒 deadline,减少首次连接对观察的影响。计数在进程内,不代表数据库事务。
先记录计数:
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18162/lab/counts > before.json
docker compose exec -T caller java -Xmx128m \
-Dloader.main=dev.example.caller.RpcCli \
-cp /app/caller.jar org.springframework.boot.loader.launch.PropertiesLauncher \
provider 9090 deadline false > deadline.log 2>&1
result=$?
test "$result" -eq 2 || exit 1
grep -F 'gRPC status=DEADLINE_EXCEEDED' deadline.log
sleep 2
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18162/lab/counts > after.json
jq -s '{
started: (.[1].started-.[0].started),
completed: (.[1].completed-.[0].completed),
canceled: (.[1].canceled-.[0].canceled)
}' before.json after.jsonCLI 返回 DEADLINE_EXCEEDED,随后计数差值应为 started=1、completed=1、canceled=0。服务端没有采用合作取消,客户端不再等待以后,安排好的动作仍执行。
重新保存 before.json,再把命令改成 deadline true,并按同样步骤查看差值。这次应为 started=1、completed=0、canceled=1。服务端通过 gRPC Context 的取消监听撤销尚未执行的计划任务,并用原子状态避免完成和取消重复计数。
如果 started 没增加,说明动作根本没有进入服务端,不能用该次结果推导取消行为。先确认普通 gRPC 查询成功,再重跑。若应用任务已经执行到不可撤销的外部副作用,取消监听只能处理后续可取消工作,需要结合幂等键和业务状态查询决定下一步。
停止提供者并恢复
docker compose stop provider
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o unavailable.json -w '%{http_code}' \
http://127.0.0.1:18161/grpc/BOOK-1)
transport=$?
test "$transport" -eq 0 || exit 1
case "$status" in
503|504) jq -e '.grpcStatus == "UNAVAILABLE" or .grpcStatus == "DEADLINE_EXCEEDED"' unavailable.json ;;
*) cat unavailable.json; exit 1 ;;
esac
docker compose start provider本次容器网络中先达到客户端 deadline,观察到 DEADLINE_EXCEEDED;连接更早明确失败时,也可能是 UNAVAILABLE。示例 HTTP 适配层分别映射为504或503,这属于应用错误处理策略,不是 gRPC 规定的唯一 HTTP 映射。
恢复后重新执行前面的健康等待,并确认 /grpc/BOOK-1 与 /feign/BOOK-1 均返回200。channel 可在正常运行中复用,不必为每次请求重新创建;关闭应用时才统一关闭它。
docker compose downdown 删除本项目容器和网络,提供者内存计数随进程消失;宿主源码、JAR、缓存与响应文件保留。再次启动后可使用 bash verify.sh 复验常用分支。
从错误状态、超时和资源泄漏定位失败
按实际错误层次处理
| 现象 | 检查位置 | 下一步 |
|---|---|---|
| Feign 反序列化失败 | Content-Type、实际响应体、返回 DTO | 区分上游返回错误页面与字段契约变化 |
| HTTP 404 | 调用 URL、资源是否存在、上下文路径 | 保留实际路径,不立即按服务不可用重试 |
| INVALID_ARGUMENT | 缺失字段、范围与业务条件 | 修正请求,避免原样重试 |
| UNAUTHENTICATED / PERMISSION_DENIED | 凭据验证与具体资源授权 | 修复身份或授权,不降级成成功 |
| DEADLINE_EXCEEDED | 解析、连接、排队、服务处理及截止配置 | 查询副作用状态后再决定重试 |
| UNAVAILABLE | 服务状态、地址解析、TLS和连接 | 确认是否暂时故障,并按幂等规则重试 |
| 流处理内存持续增长 | 消费速度、消息大小、缓存与背压 | 限制消息和积压,避免无限聚合 |
| 线程与连接数量不断增加 | channel、HTTP pool、executor生命周期 | 复用客户端,统一关闭自建资源 |
一个 gRPC channel 可以管理连接、解析结果和重连过程,stub 则可以派生不同 deadline 等调用选项。反复创建 channel 会增加握手、线程和连接资源;长期持有却不关闭,也会妨碍测试和应用退出。
HTTP 客户端同样需要明确连接池上限、获取连接的等待、连接与读取超时。平台线程、虚拟线程和响应式 API 改变了等待资源的使用方式,但不会自动增加下游处理能力。
契约测试和升级
对外发布接口之前,应同时测试正常消息、缺失业务字段、未知枚举值、较大消息和旧客户端。gRPC 代码生成阶段能发现定义语法错误,不能发现“分被解释成元”之类的业务兼容问题。测试应比较最终业务字段及错误语义。
生成工具、生成代码和 runtime 要协调升级。固定依赖只是让实验可重复,生产仍需跟随支持线检查修复;升级后重新生成、编译,再执行跨进程调用。二进制可解析、Java 编译通过、部署后调用成功分别覆盖不同问题。
客户端发送写操作后超时,优先使用业务标识查询当前状态。直接换节点重试可能导致重复处理;即使服务端检测到取消,也要确认具体事务是否已提交。数据一致性方案见从写入到一致状态。
在多个服务之间传播身份、截止信息和追踪上下文时,应限制允许字段,并按每一跳实际配置注入与提取。声明式客户端简化了请求代码,但这些运行策略仍需明确配置;跨线程与跨服务的观测安排见链路追踪与灰度发布。
