Skip to content

gRPC Java 基于 Netty 的分层封装与执行模型研究 ​

概览 ​

系统分析 gRPC Java 如何在 Netty HTTP/2 transport 之上封装 Stub、Channel、Transport、Stream、Call、Interceptor、Listener 和 Observer 等 RPC 语义抽象。

摘要 ​

gRPC Java 并不是直接把业务方法挂在 Netty ChannelHandler 上,而是在 Netty 之上建立了 Stub、Channel、Transport、Stream、Call、Interceptor、Listener/Observer 等一组 RPC 语义抽象。该设计的核心事实是:gRPC 本身定义的是“基于接口定义语言的远程过程调用模型”,而 Netty 提供的是“异步事件驱动网络 I/O 模型”。grpc-java 官方 README 将库划分为 Stub、Channel、Transport 三层,其中 Transport 层负责“putting and taking bytes off the wire”,且 Netty-based HTTP/2 transport 是主要传输实现。(GitHub)

因此,gRPC Java 对 Netty 的封装目标不是简化 Netty Handler 编程,而是把 HTTP/2 字节流、帧、连接、流控、Header/Trailer、DATA frame 转换成 RPC 方法、请求消息、响应消息、状态码、元数据、取消、deadline、流式回调 等上层语义。gRPC 官方协议说明也表明,gRPC 的双向消息流映射到 HTTP/2 stream,Call Header 与 Initial Metadata 映射为 HTTP/2 headers,Payload Message 被编码为 length-prefixed gRPC frame 后再拆成 HTTP/2 frames,Status 与 Trailing Metadata 映射为 HTTP/2 trailers。(GitHub)


1. 引言 ​

gRPC 的基础不是 HTTP API 风格,而是 RPC 风格。官方概念文档说明,开发者从 .proto 文件中的服务定义开始,gRPC 通过 Protocol Buffer compiler plugins 生成客户端和服务端代码;客户端通过本地 stub 调用远端方法,服务端实现对应服务方法,gRPC infrastructure 负责解码请求、执行服务方法、编码响应。(gRPC)

grpc-java 的公共 API 文档进一步给出 Java 侧的基本模型:gRPC 基于 client-server RPC 模型;客户端创建连接到服务端的 Channel;RPC 由客户端发起,服务端响应;双方发送完消息后分别 half close;服务端关闭后 RPC 完成。客户端通常通过 generated stub 调用服务端,服务端通过 ServerBuilder 暴露服务实现。(grpc.github.io)

由此可得出一个事实性的结构判断:gRPC Java 的核心对象不是 Netty Channel,而是 RPC Call;Netty Channel 只是某种 Transport 的底层 I/O 承载。


2. gRPC 的基本设计原理 ​

2.1 接口优先:.proto 是服务契约 ​

gRPC 官方概念文档说明,开发者使用语言无关的 RPC 服务描述,gRPC 会生成客户端和服务端接口;服务端实现接口,客户端通过对应接口远程调用。默认情况下,gRPC 使用 Protocol Buffers 作为 IDL 描述服务接口和消息结构。(GitHub)

这决定了 grpc-java 的上层封装必须围绕 MethodDescriptor、Stub、Marshaller、ServiceDescriptor 展开,而不是围绕 Netty 的 byte buffer 展开。Netty 只知道字节、事件、连接、pipeline;gRPC 需要知道“哪个服务、哪个方法、请求类型、响应类型、调用类型、元数据、状态码”。

2.2 RPC 生命周期:一次调用是一个双向消息流 ​

gRPC 官方 CONCEPTS 文档定义,一个 gRPC call 是客户端发起的双向消息流。客户端到服务端方向包含 mandatory Call Header、optional Initial-Metadata、零个或多个 Payload Messages;服务端到客户端方向包含 optional Initial-Metadata、零个或多个 Payload Messages,并以 mandatory Status 和 optional Status-Metadata 结束。(GitHub)

这也是为什么 gRPC Java 内部大量使用 callback/listener/observer:因为 RPC 并不总是一个“同步 request -> response”的函数调用。它可以是 unary、server streaming、client streaming、bidirectional streaming。官方文档说明,双向流式 RPC 中客户端流和服务端流相互独立,双方可以按任意顺序读写消息。(gRPC)

2.3 HTTP/2 是传输映射,而不是业务 API ​

gRPC 官方协议说明,抽象 gRPC 协议由 HTTP/2 具体承载:gRPC bidirectional streams 映射到 HTTP/2 streams;headers 受 HPACK 压缩;payload message 被序列化为 length-prefixed gRPC frames,然后再拆分为 HTTP/2 frames;status 和 trailing metadata 通过 HTTP/2 trailing headers 发送。(GitHub)

HTTP/2 协议层支持多路复用、header compression、flow control 等能力。HTTP/2 RFC 说明,HTTP/2 通过 header field compression 和在同一连接上允许多个并发 exchange 来更有效使用网络资源并降低感知延迟。(IETF HTTP Working Group)

因此,grpc-java 基于 Netty 的封装必然要处理:

gRPC 抽象HTTP/2/Netty 层对应物
RPC CallHTTP/2 stream
MetadataHTTP/2 headers / trailers
Request / Response messagelength-prefixed gRPC message over DATA frames
Statusgrpc-status trailer
Deadline / cancellationgRPC call control + HTTP/2 stream control
Flow controlHTTP/2 flow control
InterceptorRPC 调用链扩展点
Netty HandlerI/O 事件、HTTP/2 frame、连接级处理

3. grpc-java 的三层封装模型 ​

grpc-java 官方 README 将库分为三层:Stub、Channel、Transport。Stub 层面向大多数开发者,提供类型安全绑定;Channel 层是 Transport 处理之上的抽象,适合 interception/decoration,并暴露比 Stub 更多的行为;Transport 层负责网络字节收发,接口抽象到可插拔不同实现。(GitHub)

3.1 Stub 层 ​

Stub 是业务开发者主要接触的入口。Java basics 文档说明,Java 客户端可以创建 blocking/synchronous stub,也可以创建 non-blocking/asynchronous stub;异步 stub 以异步方式返回响应,并且某些 streaming call 只能通过 asynchronous stub 使用。(gRPC)

io.grpc.stub 包文档说明,客户端 stub 类是 AbstractStub 的实现,RPC 方法内部使用 ClientCalls 与 call layer 交互;异步 stub 的 RPC 方法会接收 StreamObserver responseObserver,对于 client-streaming 或 bidirectional-streaming,还会返回一个 requestObserver。(grpc.github.io)

3.2 Channel 层 ​

Channel 是客户端侧对远端端点的抽象。grpc-java Javadoc 描述,客户端创建 Channel,generated stub 包装该 channel,stub 是客户端与服务端交互的主要方式。(grpc.github.io)

Channel 层适合做装饰与拦截。grpc-java README 明确说明,Channel 层适合应用框架处理 logging、monitoring、auth 等 cross-cutting concerns。(GitHub)

3.3 Transport 层 ​

Transport 层负责真正的字节收发。grpc-java README 明确说明,Transport 层 “does the heavy lifting of putting and taking bytes off the wire”,并且 Transport API 属于 gRPC internal API,API 稳定性弱于 io.grpc core API。(GitHub)

旧版 grpc-java README 还明确指出,Transport 被建模为 Stream factories;server stream 与 client stream 接口差异用于编码它们在 cancellation 和 error reporting 上的不同语义。(android.googlesource.com)

这解释了“为什么不是直接暴露 Netty Handler”:因为 grpc-java 要允许 Transport 可插拔。官方 README 列出的 Transport 包括 Netty、OkHttp、in-process、Binder 等;Netty 只是其中一种实现。(GitHub)


4. gRPC Java 基于 Netty 实现的主要封装 ​

4.1 Netty transport:HTTP/2 transport 的主要实现 ​

grpc-java README 明确说明,Netty-based HTTP/2 transport 是基于 Netty 的主要 transport implementation;grpc-netty-shaded 通常优先于直接使用 Netty transport,因为它减少依赖管理并更容易升级。(GitHub)

由此可见,grpc-java 对 Netty 的封装主要发生在 Transport 层,而不是 Stub 或 Channel 层。应用代码通常看不到 Netty pipeline;它看到的是 ManagedChannel、Stub、ServerBuilder、StreamObserver、ClientInterceptor、ServerInterceptor。

4.2 Netty Channel / Pipeline / Handler 被封装为 Transport 内部机制 ​

Netty 官方 ChannelPipeline Javadoc 说明,ChannelPipeline 是一组 ChannelHandler,用于处理或拦截一个 Channel 的 inbound events 和 outbound operations;每个 Channel 自动创建自己的 pipeline;I/O event 由 inbound 或 outbound handler 处理,并通过 ChannelHandlerContext.fireChannelRead、write 等方法向相邻 handler 传播。(netty.io)

grpc-java 的 Netty 实现把这些 Handler 用作 HTTP/2 transport 内部事件处理。grpc-java 源码搜索结果显示,NettyClientHandler 是 client-side Netty handler for GRPC processing,并注明所有 event handlers 都在 Netty Channel thread 上下文中执行。(GitHub) NettyServerHandler 也被描述为 server-side Netty handler for GRPC processing,所有 event handlers 同样在 Netty Channel thread 上下文执行。(GitHub)

这形成了两层回调模型:

text
业务层回调:
  StreamObserver / ClientCall.Listener / ServerCall.Listener

gRPC Transport 层:
  ClientStream / ServerStream / TransportState / WriteQueue

Netty I/O 层:
  ChannelPipeline / ChannelHandler / EventLoop / ByteBuf / HTTP/2 frame

4.3 HTTP/2 header 到 gRPC metadata/method 的封装 ​

NettyServerHandler 源码片段显示,服务端接收 headers 后会检查 path、content-type、HTTP method;path 去掉前导 / 后得到 fully qualified method name;headers 被转换为 Metadata;随后创建 StatsTraceContext、NettyServerStream.TransportState 和 NettyServerStream。(android.googlesource.com)

这说明 Netty 层看到的是 HTTP/2 headers;gRPC 层需要将其提升为:

text
:path: /package.Service/Method
:method: POST
content-type: application/grpc
te: trailers
custom metadata...

对应的 gRPC 语义是:

text
method = package.Service/Method
metadata = Metadata
stream = ServerStream
call = ServerCall
handler = ServerCallHandler
service method invocation

5. Interceptor 与 Netty Handler 的关系 ​

5.1 二者都类似“拦截链”,但作用层级不同 ​

Netty ChannelPipeline 是 ChannelHandler 链,用于处理 Channel 的 inbound events 和 outbound operations。(netty.io) gRPC Interceptor 是 RPC call 层的扩展点。gRPC 官方 Interceptors 文档说明,Interceptor 适合实现独立于具体 RPC 方法、适用于全部或大多数 RPC 的通用功能,例如 metadata handling、logging、fault injection、caching、metrics、policy enforcement、server-side authentication、server-side authorization。(gRPC)

所以,二者关系如下:

对比项gRPC InterceptorNetty ChannelHandler
所在层级gRPC Call / Channel / ServerCall 层Netty I/O / ChannelPipeline 层
处理对象RPC method、Metadata、CallOptions、ServerCall、ClientCall、消息、StatusByteBuf、HTTP/2 frame、Channel event、write/read/flush
典型用途鉴权、日志、指标、metadata、策略、重写请求/响应编解码、TLS、HTTP/2 frame、连接事件、背压、flush
生命周期粒度per-callper-channel / per-connection
是否适合管理 TCP/TLS不适合适合
是否面向业务 RPC 语义是否,除非在 transport 内部转换后

gRPC 官方 Interceptors 文档明确说明,interceptors 是 per-call 的;它们不适合管理 TCP connections、配置 TCP port 或配置 TLS。(gRPC)

因此,gRPC Interceptor 不是 Netty Handler 的替代品,也不是 Netty Handler 的简单包装;它是位于 Channel/ServerCall 抽象上的 RPC 拦截器。

5.2 ClientInterceptor 的位置 ​

ClientInterceptor Javadoc 说明,它用于在 outgoing calls 被 Channel dispatch 之前进行拦截;实现者可以为 Channel 和 stub 添加 cross-cutting behavior,例如 logging/monitoring、添加 metadata、request/response rewriting。(grpc.github.io)

客户端路径可以抽象为:

text
Generated Stub
  -> ClientCalls
    -> ClientInterceptor chain
      -> Channel.newCall(method, callOptions)
        -> ClientCall
          -> ClientTransport.newStream(...)
            -> NettyClientStream
              -> NettyClientHandler
                -> HTTP/2 HEADERS/DATA frames

Interceptor 看到的是 MethodDescriptor、CallOptions、Metadata、ClientCall。Netty Handler 看到的是 HTTP/2 headers/data/trailers frame 和 channel event。

5.3 ServerInterceptor 的位置 ​

ServerInterceptor Javadoc 说明,它用于在 incoming calls 被 ServerCallHandler dispatch 之前拦截;实现者可以添加 server-side call 的 cross-cutting behavior,例如 enforcing authentication credentials、logging/monitoring、delegating calls。(grpc.github.io)

服务端路径可以抽象为:

text
Socket / TCP
  -> Netty EventLoop
    -> Netty ChannelPipeline
      -> NettyServerHandler
        -> HTTP/2 headers/data/trailers parsing
          -> ServerTransportListener.streamCreated(...)
            -> ServerCall
              -> ServerInterceptor chain
                -> ServerCallHandler.startCall(...)
                  -> Generated service implementation
                    -> StreamObserver.onNext/onCompleted/onError

6. 为什么 gRPC Java 使用大量 callback/listener/observer 接口 ​

6.1 网络是异步的,RPC 模型同时支持同步与异步 ​

gRPC CONCEPTS 文档说明,同步 RPC 最接近 RPC 所追求的 procedure call 抽象;但网络本质上是异步的,在许多场景中需要不阻塞当前线程就启动 RPC。gRPC 在多数语言中同时提供同步和异步形式。(GitHub)

这解释了 grpc-java 中多个 callback 接口存在的基础原因:底层 I/O 是异步事件,RPC 语义还支持流式消息,因此 API 需要表达“未来某个时刻收到 headers、消息、完成状态、可继续发送”的事件。

6.2 ClientCall.Listener 表达客户端接收事件 ​

ClientCall.Listener Javadoc 说明,它是用于接收服务端 metadata、response messages 和 completion status 的 callbacks;方法包括 onHeaders、onMessage、onClose、onReady。其中 onMessage 可以调用零次或多次,取决于响应为空、单消息或流式消息;onReady 表示 ClientCall 可能已经能够发送更多消息而不需要过度内部缓冲。(grpc.github.io)

这对应 HTTP/2/gRPC 接收方向:

text
HTTP/2 response HEADERS
  -> ClientCall.Listener.onHeaders(metadata)

HTTP/2 DATA frames -> gRPC message deframe -> RespT
  -> ClientCall.Listener.onMessage(response)

HTTP/2 trailing HEADERS with grpc-status
  -> ClientCall.Listener.onClose(status, trailers)

HTTP/2 flow-control writable / buffer state
  -> ClientCall.Listener.onReady()

6.3 StreamObserver 表达应用层流式收发 ​

io.grpc.stub 包文档说明,异步 stub 方法会接收 StreamObserver responseObserver;client-streaming 或 bidirectional-streaming 会返回 requestObserver。(grpc.github.io)

因此,StreamObserver 是比 ClientCall.Listener 更靠近用户 API 的抽象。可以理解为:

text
应用 API:
  StreamObserver<RespT>.onNext(response)
  StreamObserver<RespT>.onError(error)
  StreamObserver<RespT>.onCompleted()

Call 层:
  ClientCall.Listener.onMessage(...)
  ClientCall.Listener.onClose(...)

Transport 层:
  ClientStreamListener
  ServerStreamListener

Netty 层:
  ChannelInboundHandler.channelRead(...)
  HTTP/2 frame listener

6.4 callback 数量多是由四类 RPC 形态决定的 ​

gRPC 官方 core concepts 文档列出四类 RPC:unary、server streaming、client streaming、bidirectional streaming。服务端流式 RPC 是一个请求对应响应流;客户端流式 RPC 是请求流对应一个响应;双向流式 RPC 中两条流相互独立,双方可以以任意顺序读写消息。(gRPC)

这意味着 Java API 不能只靠一个同步返回值表达所有情况。它必须表达:

text
收到 headers
收到 0..N 条消息
发送 0..N 条消息
本端 half close
对端 half close
收到 trailers/status
取消
deadline exceeded
流控可写
异常

所以 callback/listener/observer 不是装饰性设计,而是异步流式 RPC 语义的直接映射。


7. 一次 unary RPC 的内部执行流程 ​

以下流程采用 grpc-java + Netty transport 的抽象路径描述。

7.1 客户端发起调用 ​

text
1. 用户调用 generated blocking/async/future stub 方法
2. stub 根据 proto 生成的 MethodDescriptor 找到 RPC 方法定义
3. stub 通过 ClientCalls 创建 ClientCall
4. ClientInterceptor chain 在 Channel dispatch 前拦截 outgoing call
5. ManagedChannel 根据 name resolver / load balancer / subchannel 选择 transport
6. ClientTransport 创建 ClientStream
7. NettyClientStream 将 metadata 和 request message 写入 transport
8. NettyClientHandler 在 Netty EventLoop 中写出 HTTP/2 HEADERS 和 DATA

官方 ClientInterceptor Javadoc 支持第 4 步:它拦截 outgoing calls before dispatched by Channel。(grpc.github.io) grpc-java README 支持第 5-8 步的分层事实:Channel 位于 Transport 之上,Transport 负责字节收发,Netty transport 是主要 HTTP/2 transport。(GitHub)

7.2 网络传输 ​

text
HTTP/2 HEADERS:
  :method = POST
  :path = /package.Service/Method
  content-type = application/grpc
  te = trailers
  custom metadata...

HTTP/2 DATA:
  gRPC length-prefixed message bytes

HTTP/2 END_STREAM:
  unary request 发送完后,客户端 half close

gRPC 协议文档说明,payload messages 会被序列化为 length-prefixed gRPC frames,再由 HTTP/2 frames 承载;客户端通过最后一个 DATA frame 的 END_STREAM 标志表示其消息流结束。(GitHub)

7.3 服务端接收并分发 ​

text
1. Netty EventLoop 读取 socket 数据
2. Netty HTTP/2 codec 解析 HEADERS/DATA frame
3. NettyServerHandler 验证 path / content-type / method
4. headers 转换为 gRPC Metadata
5. 创建 ServerStream / TransportState
6. gRPC Server transport 创建 ServerCall
7. ServerInterceptor chain 执行
8. ServerCallHandler.startCall 分发到 generated service method
9. 业务实现读取 request,写 response

NettyServerHandler 源码片段显示,它会验证 path、content-type、method,并将 headers 转换为 Metadata,再创建 NettyServerStream.TransportState 和 NettyServerStream。(android.googlesource.com) ServerInterceptor Javadoc 支持第 7 步:它在 incoming calls dispatch by ServerCallHandler 之前拦截。(grpc.github.io)

7.4 服务端响应 ​

text
1. service implementation 调用 responseObserver.onNext(response)
2. gRPC 将 response message marshal 成字节
3. NettyServerStream 写 HTTP/2 response DATA
4. service implementation 调用 responseObserver.onCompleted()
5. gRPC 写 trailers,其中包含 grpc-status
6. HTTP/2 trailers HEADERS 携带 END_STREAM

gRPC over HTTP/2 协议规定,响应包含 Response-Headers、零个或多个 length-prefixed message、Trailers;grpc-status 必须在 Trailers 中发送,即使状态是 OK。(GitHub)

7.5 客户端完成调用 ​

text
1. NettyClientHandler 接收 response HEADERS
2. 转换为 Metadata,触发 ClientCall.Listener.onHeaders
3. 接收 DATA frame,deframe + unmarshal 成 RespT
4. 触发 ClientCall.Listener.onMessage
5. 接收 trailers,解析 grpc-status
6. 触发 ClientCall.Listener.onClose
7. async stub 再映射为 StreamObserver.onNext/onCompleted/onError
8. blocking stub 阻塞等待结果并返回或抛异常

ClientCall.Listener Javadoc 明确说明 onHeaders 表示收到 response headers,onMessage 表示收到 response message,onClose 表示 call 已关闭并携带 status 与 trailers。(grpc.github.io)


8. 一次 bidirectional streaming RPC 的内部执行流程 ​

双向流式 RPC 与 unary RPC 的根本区别是:请求方向和响应方向都允许 0..N 条消息,且两条流相互独立。官方文档说明,双向流式 RPC 中 client-side 与 server-side stream processing 是 application specific;两条流独立,因此客户端和服务端可以按任意顺序读写消息。(gRPC)

其执行模型为:

text
客户端:
  requestObserver = asyncStub.bidiCall(responseObserver)

发送方向:
  requestObserver.onNext(req1)
    -> ClientCall.sendMessage
      -> ClientStream.writeMessage
        -> Netty write DATA

  requestObserver.onNext(req2)
    -> Netty write DATA

  requestObserver.onCompleted()
    -> ClientCall.halfClose
      -> HTTP/2 END_STREAM on client-to-server direction

接收方向:
  Netty read DATA
    -> ClientCall.Listener.onMessage
      -> responseObserver.onNext(respN)

  Netty read trailers
    -> ClientCall.Listener.onClose
      -> responseObserver.onCompleted/onError

服务端也是类似结构:

text
服务端方法入参:
  StreamObserver<ResponseT> responseObserver

服务端方法返回:
  StreamObserver<RequestT> requestObserver

接收请求:
  requestObserver.onNext(reqN)

发送响应:
  responseObserver.onNext(respN)

完成:
  requestObserver.onCompleted()
  responseObserver.onCompleted()

这说明 StreamObserver 的双向存在不是偶然设计,而是对双向独立消息流的 Java API 表达。


9. 为什么 gRPC Java 不让业务直接使用 Netty Handler ​

基于以上官方资料,可以从事实关系中归纳出四个原因。

9.1 gRPC 需要跨语言一致的 RPC 语义 ​

gRPC 官方说明,开发者从语言无关服务定义开始,gRPC 生成不同语言的客户端和服务端接口。(GitHub) 如果 Java 业务直接依赖 Netty Handler,那么 Java 实现会暴露传输层细节,破坏 gRPC 跨语言接口模型。

9.2 gRPC Transport 是可插拔的 ​

grpc-java README 明确列出多个 Transport:Netty、OkHttp、in-process、Binder。(GitHub) 如果应用层直接绑定 Netty Handler,则无法保持同一 Stub/Channel API 在不同 Transport 上工作。

9.3 Netty Handler 处理的是 I/O 事件,不是 RPC 语义 ​

Netty ChannelPipeline 处理 inbound/outbound I/O events 和 operations。(netty.io) gRPC Interceptor 处理的是 per-call 的 RPC 通用逻辑,并且官方明确说 interceptor 不适合管理 TCP connections、TCP port、TLS。(gRPC)

因此二者边界明确:

text
Netty Handler:
  连接级 / 帧级 / 字节级 / I/O 事件级

gRPC Interceptor:
  调用级 / 方法级 / Metadata / Status / Message 级

9.4 gRPC 的完成状态依赖 trailers,而不是普通 HTTP response body ​

gRPC over HTTP/2 协议规定,grpc-status 在 trailers 中发送,且 OK 状态也必须发送在 trailers 中。(GitHub) 这使得“请求已收到响应 DATA”不等价于“RPC 成功完成”。gRPC Java 必须在 transport 层把 response headers、messages、trailers、status 组合成完整 RPC 生命周期,再向上触发 onClose。


10. 结论 ​

gRPC Java 基于 Netty 的封装可以概括为:

text
Netty 提供异步 I/O 与 HTTP/2 frame 处理能力;
grpc-java Transport 把 HTTP/2 frame 转换为 gRPC stream;
Channel 把 transport stream 抽象为 RPC call;
Stub 把 RPC call 抽象为类型安全的 Java 方法;
Interceptor 在 call 级别提供横切扩展;
Listener/Observer 用 callback 表达异步、流式、完成、流控事件。

更精确地说,gRPC Java 不是“用 Netty Handler 实现业务 RPC”,而是“用 Netty 实现一个 HTTP/2 Transport,再在其上实现 gRPC 的 call/stream/status/metadata/stub/interceptor 语义”。这也是 Interceptor 与 Netty Handler 的本质区别:Interceptor 属于 RPC 调用链,Handler 属于网络 I/O 事件链。

对于 Java 工程实践,判断边界应当非常明确:

text
鉴权、租户、traceId、metrics、日志、限流策略、metadata 处理:
  使用 gRPC Interceptor

连接参数、TLS、HTTP/2 keepalive、frame、ByteBuf、EventLoop、pipeline:
  属于 Netty transport / builder 配置范围

业务请求/响应:
  使用 generated Stub + StreamObserver

不要把业务 RPC 语义下沉到 Netty Handler;
也不要用 Interceptor 解决 TCP/TLS/HTTP2 frame 级问题。
GitHub Discussions

参与讨论

评论会同步到 stellhub/stell-web 仓库的 GitHub Discussions。

Powered by VitePress and GitHub Discussions.