本节目标:用 JDK 自带的 HTTP 服务器和客户端写出一个可运行的协议转换网关,把 HTTP 语义准确地映射到 gRPC,不引入 Web 框架。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)。
承接的问题
前面八章讨论的都是「服务之间怎么通信」:gRPC、deadline、重试、熔断、限流。真实系统还有一个边界:浏览器、移动端、第三方合作方通常只会说 HTTP/JSON,它们不关心内部用的是不是 gRPC。网关的职责是在这个边界上做协议转换:外面是资源路径和 HTTP 方法,里面是 GetBook、SearchBooks 这样的 RPC。
linkd-v3 的端口划分是:gRPC 9093,HTTP 网关 9094,指标/管理 9095。9094 只做三件事:解析 HTTP 请求、调用下游、把结果和错误转回 HTTP。它不实现业务规则,也不直接连数据库。业务规则在 linkd-v3 的 gRPC 服务里,linkd-v3 再去调 libraryd。
先看路径映射:
| HTTP | gRPC | 说明 |
|---|---|---|
GET /api/v1/books/{isbn} | Library.GetBook | 单本书,返回 Book |
GET /api/v1/search?q=&page_size= | Library.SearchBooks | 服务端流,网关取前 page_size 条 |
GET /api/v1/raw/books/{isbn} | libraryd HTTP | 迁移期的兼容路径,直连 8080 |
GET /healthz | 无 | 网关自身健康检查 |
/api/v1/raw/ 是本节唯一直接用 HttpClient 调 libraryd 的路径,用于迁移期对照和故障定位;它的 handler 把 isbn 交给下面的 LibraryHttpClient.getBook,Optional.empty() 映射 404、IOException 映射 502,结构与 handleBook 同构。生产流量走前两条。
核心机制一:HttpServer 的路由与线程模型
com.sun.net.httpserver.HttpServer 是 JDK 自带的轻量 HTTP 服务器。它没有注解路由、没有拦截器链,只有 createContext(path, handler) 的前缀匹配。对网关这种「路径规则很少、逻辑很薄」的场景,够用;对需要复杂路由、模板、静态资源的场景,应该换框架。
package com.plume.linkd.v3.gateway;
import com.google.protobuf.util.JsonFormat;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.api.SearchBooksRequest;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.grpc.CallOptions;
import io.grpc.Deadline;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCalls;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class GatewayServer {
private static final int MAX_PAGE_SIZE = 100;
private static final String BOOKS_PREFIX = "/api/v1/books/";
private final ManagedChannel channel;
public GatewayServer(String grpcTarget) {
this.channel = ManagedChannelBuilder.forTarget(grpcTarget).usePlaintext().build();
}
public void start(int port) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 128);
server.setExecutor(new ThreadPoolExecutor(
16, 16, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(128),
new ThreadPoolExecutor.AbortPolicy()));
server.createContext(BOOKS_PREFIX, this::handleBook);
server.createContext("/api/v1/search", this::handleSearch);
server.createContext("/healthz", exchange ->
write(exchange, 200, "text/plain; charset=utf-8", "ok"));
server.start();
}
private void handleBook(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
write(exchange, 405, "application/json", "{\"error\":\"method_not_allowed\"}");
return;
}
String isbn = suffix(exchange.getRequestURI().getPath(), BOOKS_PREFIX);
if (isbn.isEmpty() || isbn.contains("/")) {
write(exchange, 404, "application/json", "{\"error\":\"not_found\"}");
return;
}
try {
Book book = ClientCalls.blockingUnaryCall(
channel,
LibraryGrpc.getGetBookMethod(),
CallOptions.DEFAULT.withDeadline(Deadline.after(300, TimeUnit.MILLISECONDS)),
GetBookRequest.newBuilder().setIsbn(isbn).build());
writeBook(exchange, book);
} catch (StatusRuntimeException e) {
writeError(exchange, e);
}
}
private void handleSearch(HttpExchange exchange) throws IOException {
if (!"GET".equals(exchange.getRequestMethod())) {
write(exchange, 405, "application/json", "{\"error\":\"method_not_allowed\"}");
return;
}
String query = queryParam(exchange.getRequestURI(), "q");
int pageSize = Math.min(MAX_PAGE_SIZE,
intParam(exchange.getRequestURI(), "page_size", 20));
if (query == null || query.isBlank() || pageSize < 1) {
write(exchange, 400, "application/json", "{\"error\":\"invalid_query\"}");
return;
}
try {
Iterator<Book> books = ClientCalls.blockingServerStreamingCall(
channel,
LibraryGrpc.getSearchBooksMethod(),
CallOptions.DEFAULT.withDeadline(Deadline.after(300, TimeUnit.MILLISECONDS)),
SearchBooksRequest.newBuilder()
.setQuery(query)
.setPageSize(pageSize)
.build());
StringBuilder json = new StringBuilder("[");
int count = 0;
while (books.hasNext() && count < pageSize) {
if (count++ > 0) {
json.append(',');
}
json.append(JsonFormat.printer().print(books.next()));
}
json.append(']');
write(exchange, 200, "application/json", json.toString());
} catch (StatusRuntimeException e) {
writeError(exchange, e);
}
}
private void writeBook(HttpExchange exchange, Book book) throws IOException {
String accept = exchange.getRequestHeaders().getFirst("Accept");
if (accept != null && accept.contains("application/x-protobuf")) {
write(exchange, 200, "application/x-protobuf", book.toByteArray());
} else {
write(exchange, 200, "application/json", JsonFormat.printer().print(book));
}
}
private void writeError(HttpExchange exchange, StatusRuntimeException e) throws IOException {
String body = "{\"error\":\"" + e.getStatus().getCode().name() + "\"}";
write(exchange, httpStatus(e.getStatus().getCode()), "application/json", body);
}
private static int httpStatus(Status.Code code) {
return switch (code) {
case OK -> 200;
case INVALID_ARGUMENT -> 400;
case UNAUTHENTICATED -> 401;
case PERMISSION_DENIED -> 403;
case NOT_FOUND -> 404;
case ALREADY_EXISTS, ABORTED -> 409;
case FAILED_PRECONDITION -> 412;
case RESOURCE_EXHAUSTED -> 429;
case UNIMPLEMENTED -> 501;
case UNAVAILABLE -> 503;
case DEADLINE_EXCEEDED -> 504;
default -> 500;
};
}
private static String suffix(String path, String prefix) {
return path.startsWith(prefix) ? path.substring(prefix.length()) : "";
}
private static String queryParam(URI uri, String name) {
String raw = uri.getRawQuery();
if (raw == null) {
return null;
}
for (String pair : raw.split("&")) {
int eq = pair.indexOf('=');
if (eq > 0 && pair.substring(0, eq).equals(name)) {
return URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
}
}
return null;
}
private static int intParam(URI uri, String name, int fallback) {
String value = queryParam(uri, name);
if (value == null) {
return fallback;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return -1;
}
}
private static void write(HttpExchange exchange, int status, String contentType, String body)
throws IOException {
write(exchange, status, contentType, body.getBytes(StandardCharsets.UTF_8));
}
private static void write(HttpExchange exchange, int status, String contentType, byte[] body)
throws IOException {
exchange.getResponseHeaders().set("Content-Type", contentType);
exchange.sendResponseHeaders(status, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
public void stop() {
channel.shutdownNow();
}
}
HttpServer.create(address, backlog) 的第二个参数是 TCP backlog,不是队列长度;真正的请求并发由 setExecutor 的执行器决定。这里用 16 个线程、128 个等待位,和 8.2 的服务端参数同一套思路:有界队列、AbortPolicy、拒绝可见。不要用 Executors.newFixedThreadPool,它默认的队列是无界的。
路由用前缀匹配,/api/v1/books/ 会同时匹配 /api/v1/books/978-7 和 /api/v1/books/978-7/stock。后者会被 isbn.contains("/") 拒绝并返回 404,这是有意的:当前版本没有子资源路由,显式拒绝比把 /stock 当成 ISBN 去查更安全。
路径参数要解码。getPath() 返回解码后的路径,getRawPath() 保留 %2F 这类编码;生产实现应该用 getRawPath() 按 / 分段,再对每段单独解码,避免把编码后的斜杠当成路径分隔符。上面的示例为了简洁用了 getPath(),ISBN 只含数字和连字符时没有问题。
核心机制二:JSON 与 protobuf 二选一
网关对外的默认格式是 JSON,因为浏览器和移动端最方便。对内部调用方或高频客户端,可以协商 application/x-protobuf,省掉 JSON 的序列化开销,也避免字段名变更带来的兼容问题。
内容协商看 Accept 头:
Accept: application/json或没有Accept:用JsonFormat.printer().print(message),返回application/json。Accept: application/x-protobuf:用message.toByteArray(),返回application/x-protobuf。- 其他:默认 JSON,不返回 406。对内部网关来说,宽容比严格更实用;如果外部契约要求严格协商,再返回 406。
JsonFormat 是 protobuf 官方提供的转换器,字段名默认用 lowerCamelCase,和 proto 的 snake_case 自动对应。它不会输出默认值(例如 stock=0),如果客户端需要稳定字段集,用 JsonFormat.printer().includingDefaultValueFields()。反过来,解析时用 JsonFormat.parser().ignoringUnknownFields(),这样 libraryd 新增字段不会让网关解析失败。
protobuf 二进制路径要注意 Content-Length 和 Content-Type。sendResponseHeaders(status, body.length) 会自动设置长度;Content-Type 必须显式写成 application/x-protobuf,否则客户端可能按 JSON 解析。错误响应仍然用 JSON,因为 gRPC 的错误详情是结构化的,二进制错误体对调试不友好。
核心机制三:HttpClient 直连 libraryd
/api/v1/raw/books/{isbn} 走 java.net.http.HttpClient,直接调用 libraryd 的 8080。它不经过 gRPC,适合迁移期对照和故障定位。客户端要设置连接超时、请求超时,并把 HTTP 状态码翻译成网关自己的语义。
package com.plume.linkd.v3.gateway;
import com.google.protobuf.util.JsonFormat;
import com.plume.linkd.v3.api.Book;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
public final class LibraryHttpClient {
private final HttpClient client;
private final String baseUrl;
public LibraryHttpClient(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(100))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
public Optional<Book> getBook(String isbn) throws IOException, InterruptedException {
String encoded = URLEncoder.encode(isbn, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/books/" + encoded))
.timeout(Duration.ofMillis(300))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
return Optional.empty();
}
if (response.statusCode() / 100 != 2) {
throw new IOException("libraryd returned HTTP " + response.statusCode());
}
Book.Builder builder = Book.newBuilder();
try {
JsonFormat.parser().ignoringUnknownFields().merge(response.body(), builder);
} catch (IOException e) {
throw new IOException("libraryd returned invalid book JSON", e);
}
return Optional.of(builder.build());
}
}
连接超时 100 ms、请求超时 300 ms,和 gRPC 的单跳 deadline 对齐。HttpClient 的连接池是内部管理的,默认复用 HTTP/1.1 连接;不需要自己维护连接池,但要注意 HttpClient 应该单例复用,不要每个请求 new 一个,否则连接池失效,每次都要 TCP 握手。
URLEncoder.encode 会把空格编码成 +,而路径段里 + 是字面量;路径参数可能包含空格或 + 时,应该用 URI 的多参数构造器或手写百分号编码。
状态码翻译:libraryd 的 404 对应网关的 404,5xx 对应 502,连接超时对应 504 或 503。HttpTimeoutException 是 IOException 的子类,要区分超时和连接拒绝,先 catch 它,再 catch IOException。
在 linkd-v3 里落地:状态码映射表
网关最重要的契约不是 JSON 字段,而是状态码。客户端根据状态码决定「重试、放弃、提示用户」。映射表必须稳定,不能因为内部实现换了就变。
gRPC Status.Code | HTTP | 语义 | 客户端动作 |
|---|---|---|---|
OK | 200 | 成功 | 正常处理 |
INVALID_ARGUMENT | 400 | 请求参数错误 | 修正请求,不重试 |
UNAUTHENTICATED | 401 | 缺少或无效凭证 | 重新登录 |
PERMISSION_DENIED | 403 | 有凭证但无权限 | 不重试 |
NOT_FOUND | 404 | 资源不存在 | 不重试 |
ALREADY_EXISTS / ABORTED | 409 | 冲突 | 重新读取后决定 |
FAILED_PRECONDITION | 412 | 前置条件不满足 | 修正状态后重试 |
RESOURCE_EXHAUSTED | 429 | 限流或配额耗尽 | 按 Retry-After 重试 |
UNIMPLEMENTED | 501 | 方法未实现 | 不重试 |
INTERNAL / UNKNOWN / DATA_LOSS | 500 | 服务端缺陷 | 有限重试并告警 |
UNAVAILABLE | 503 | 下游不可用 | 退避重试 |
DEADLINE_EXCEEDED | 504 | 超时 | 有限重试 |
CANCELLED | 499 | 客户端取消 | 不重试 |
499 是 Nginx 的非标准状态码,表示「客户端关闭了连接」。严格遵循 HTTP 语义时可以不使用它,把取消映射成 499 只在有 Nginx 前置时才有意义;没有 Nginx 时,取消通常不需要响应,因为客户端已经走了。linkd-v3 保留 499 作为日志口径,不把它写进对外契约。
502 不在 gRPC 映射表里,它专门留给「上游返回了无法解析的响应」:libraryd 返回了非 JSON、字段类型不对、或者连接被中间代理截断。502 和 503 的区别是:502 表示「我收到了上游的响应,但它是坏的」,503 表示「我没能完成对上游的调用」。这个区别对排障很重要。
常见错误与边界
错误一:路径参数不编码。 把用户输入直接拼进 URI.create(baseUrl + "/books/" + isbn),ISBN 里出现 /、?、# 时会改变路径或查询语义。路径段单独编码,不要用查询参数的 URLEncoder 规则。
错误二:每个请求 new 一个 HttpClient。 HttpClient 持有连接池和 selector 线程,频繁创建会耗尽文件描述符。网关启动时创建一个,所有请求复用;关闭时调用 shutdown()。
错误三:把 gRPC 的错误描述直接回显给客户端。 内部地址、SQL、堆栈信息会泄漏。对外只返回稳定的错误码和简短消息,详细原因写日志,用 x-request-id 关联。
错误四:搜索接口把整个流读完再返回。 SearchBooks 是服务端流,理论上可以无限。网关必须按 page_size 截断,并且设置总 deadline;否则一个恶意查询会让网关一直读下去。
错误五:HttpServer 用无界线程池。 Executors.newFixedThreadPool 的队列无界,请求堆积时不会拒绝,只会 OOM。用有界 ThreadPoolExecutor,队列满时返回 503。
错误六:JSON 解析不忽略未知字段。 libraryd 新增字段后,网关的 JsonFormat.parser() 默认会抛 InvalidProtocolBufferException,整个接口 500。解析外部输入时用 ignoringUnknownFields()。
边界:HttpServer 不是生产级 HTTP/2 服务器。 它只支持 HTTP/1.1,没有 TLS、没有 HTTP/2、没有细粒度的连接管理。对外暴露时前面通常有 Nginx/Envoy 做 TLS 和连接复用;HttpServer 只监听内网或 localhost。
边界:网关不解决认证。 它可以把 Authorization 透传成 gRPC metadata,但校验在服务端做。网关自己做认证会变成第二个权限系统,难以保持一致。10.2 会讲 token 传播。
边界:gRPC 流式接口不适合直接映射成 HTTP/1.1。 WatchStock 和 Chat 是长连接流,HTTP/1.1 没有双向流;要么用 SSE/WebSocket 单独适配,要么不对外暴露。9.1 只映射一元和服务端流,别把双向流硬塞进请求-响应模型。
小结
HttpServer的createContext是前缀匹配,路由规则少时够用;执行器要用有界队列,别用无界线程池。- 路径参数要按段解码,ISBN 只含数字和连字符时也要防止
/、?、#改变语义。 Accept决定 JSON 或 protobuf;JsonFormat负责转换,解析外部 JSON 时忽略未知字段。HttpClient单例复用,连接超时 100 ms、请求超时 300 ms,和 gRPC 单跳 deadline 对齐。- 状态码映射要稳定:404/400/401/403 不重试,429/503/504 可重试,500 表示服务端缺陷,502 表示上游响应损坏。
- 网关只做协议转换,不做业务规则、不做认证、不把双向流硬塞进 HTTP/1.1。
一个请求进来,网关往往要同时查书、查库存、查推荐,再把结果裁剪成页面需要的形状。9.2 用 CompletableFuture 并行调用多个下游,给每个下游 300 ms 的单跳预算,并处理部分失败。
阅读导航:上一节 8.3 队列与背压 | 下一节 9.2 BFF 聚合与裁剪
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。