Grpc.Core.Api深度解析:从类型到调用链,避开新旧混用陷阱
2026/9/10 10:10:52 网站建设 项目流程

写这篇文章之前,我在脑海里先过了一遍:到底有多少人还在用Grpc.Core.Api?说实话,现在新项目基本都上了Grpc.Net.Client,纯托管的实现,性能好、生态新。但架不住存量项目多,尤其是一些老服务、老框架内部还引着Grpc.Core.Api,打开包管理器一看,版本还停在 2.46.x。如果你也接手过这种项目,大概率对着ChannelCallInvokerMarshaller这些类型懵过圈——它和新的Grpc.Net.Client类型体系长得像,又不是一回事。这篇就把Grpc.Core.Api这个程序集彻底拆开讲透,从类型动机到手写调用链,再到和Grpc.Net.Client混用时的那些坑,一次性聊明白。

1. Grpc.Core.Api 到底是个什么东西:新旧技术栈的岔路口

很多人会有一个误解,以为Grpc.Core.Api是 gRPC 的"官方 API 文档"或者某个工具库。实际上它是一个实实在在的 .NET 程序集(Assembly),NuGet 包名叫Grpc.Core.Api,是旧版 gRPC C# 全栈实现Grpc.Core的契约层。什么叫契约层?就是它只定义接口、抽象类、核心数据结构,不负责具体的网络传输和 HTTP/2 协议解析,那部分在Grpc.Core.Native里,通过 C/C++ 的 native 库完成。

这就有意思了。Grpc.Core全家桶拆开看是这样的:

  • Grpc.Core.Api:公开的 API 类型,比如ChannelCallInvokerMethod<TRequest, TResponse>CallOptionsMarshaller<T>ServerCallContext、拦截器相关类型。
  • Grpc.Core.Native:封装了 gRPC C-core 的 native 二进制,负责传输、流量控制、连接管理等脏活累活。
  • Grpc.Core:元包,把上面两个包串起来,让使用者一句using Grpc.Core;就能干活。

而新的技术栈Grpc.Net.Client则是纯托管实现,基于HttpClientSystem.Net.Http,不再需要 native 库。API 名字虽然很多重合(也有ChannelBaseCallInvokerMarshaller),但命名空间和细粒度完全不同。Grpc.Net.Client的核心类型放在Grpc.Net.Client命名空间里,而Grpc.Core.Api的类型基本都在Grpc.Core命名空间下。

如果你维护的项目有用到Grpc.Core.Api,通常逃不出这么几种情况:

  • 老服务直接用的Grpc.Core包,生成代码的基类型还是Grpc.Core.ClientBase
  • 项目里某个底层库(比如自定义的服务发现、链路追踪、拦截器组件)编译时引用了Grpc.Core.Api,间接传递到了你的应用里。
  • 团队在.NET Framework时代就引入了 gRPC,当时Grpc.Net.Client还没成熟,官方推荐就是Grpc.Core

新旧共存的情况下,最烦的就是类型冲突。比如你用Grpc.Net.Client写新代码,但另一个库内部引用了Grpc.Core.ApiGrpc.Core.Channel,两个Channel同时出现在一个项目里,不处理的话编译直接给你报CS0433。这个问题我在后面专门开一节讲。

所以Grpc.Core.Api不是个过时到可以忽略的包,它是一个具体的历史产物,承载着 C# 生态里 gRPC 从 native 到托管迁移的关键过渡。理解它,你才能在混用环境里游刃有余。

2. 先拆命名空间:Grpc.Core.Api 里的类型地图与核心类型职责

用任何库之前,先花半小时把类型地图看明白,后面能省很多踩坑时间。Grpc.Core.Api的类型分布很有规律,没有文档里写的那么玄乎。我按使用频率把这些类型分成三类来讲。

2.1 客户端侧的核心类型:Channel、CallInvoker、Method、Marshaller

先说Channel。这名字容易让人想到 .NET 里的System.Threading.Channels.Channel(做生产者消费者那个)。完全两码事。Grpc.Core.Channel是客户端到 gRPC 服务的连接抽象,它维护连接池、负载均衡、健康检查等底层状态。构造时需要指定目标地址(host:port)和ChannelCredentials

var channel = new Channel("localhost:50051", ChannelCredentials.Insecure);

注意默认情况下,同一个进程里的相同地址Channel是可以复用的,它内部有连接池,不是每次调用都新建 TCP 连接。但你也不要在一个服务里无脑创建几百个 Channel 然后不 Dispose,那属于把连接池机制当摆设了。实际项目里常常用单例或者按目标地址缓存的模式。

然后是CallInvoker。它是真正"发起调用"的入口。Channel本身继承自ChannelBase,而ChannelBase有一个CreateCallInvoker()方法。生成的 gRPC 客户端代码里,ClientBase就是通过CallInvoker来完成具体的 RPC 调用的:

var invoker = channel.CreateCallInvoker();

CallInvoker的方法签名长得像这样:

AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( Method<TRequest, TResponse> method, string? host, CallOptions options, TRequest request);

看到没有,一次调用的四个要素齐了:Method描述调用哪个接口、host覆盖目标主机、CallOptions携带超时和元数据、request是请求体。

Method<TRequest, TResponse>是什么?它描述一个 RPC 方法的完整标识,包括服务名、方法名、请求和响应的Marshaller。生成的代码里通常会有一个静态字段缓存:

public static readonly Marshaller<HelloRequest> __Marshaller_HelloRequest = ...; public static readonly Method<HelloRequest, HelloReply> __Method_SayHello = new Method<HelloRequest, HelloReply>( MethodType.Unary, "Greeter", "SayHello", __Marshaller_HelloRequest, __Marshaller_HelloReply);

MethodType.Unary是调用类型,还有ClientStreamingServerStreamingDuplexStreaming三种。为什么要缓存?因为Method对象本身是元数据,每次调用都会用到,缓存之后能减少对象构造开销和序列化器的查找开销。

Marshaller<T>是序列化与反序列化的抽象。默认情况下用 Protobuf 序列化器,但你可以自定义成 JSON 或者其他格式(后面我会拿一个实际场景说明什么时候需要自定义)。

2.2 调用选项:CallOptions、Metadata、Deadline、CancellationToken

CallOptions是一个结构体,把一次调用的所有附加信息打包在一起。这是Grpc.Core.Api里被低估的一个类型,很多人图省事直接传default,导致超时全走默认(没有超时),服务端一旦卡住,客户端就无限等下去。

最常用的几个字段:

  • Deadline:该次调用的绝对截止时间,类型是DateTime?
  • CancellationToken:取消令牌。
  • Headers:要发送的元数据(认证 token、trace id 之类)。
  • Credentials:调用级凭证,适合需要动态获取 token 的场景。

还有不算常用但必须知道的WriteOptionsPropagationTokenFlagsWriteOptions在流式请求里控制写行为,比如是否立即刷新。PropagationToken用于父子调用间传播上下文,做分布式追踪时有用。

2.3 服务端侧类型:ServerCallContext、Interceptor

服务端开发用的核心类型也在Grpc.Core.Api里,最典型的就是ServerCallContext。它相当于 ASP.NET Core 里的HttpContext,提供了请求元数据、取消通知、响应头设置的入口。拦截器类型InterceptorInterceptorContext也在这个包里,允许你在服务端和客户端两侧做统一的横切处理。

有人可能会问:服务端处理类不是应该放在Grpc.AspNetCore里吗?确实,真正跑服务端用到的一堆辅助类型在Grpc.AspNetCore,但拦截器的抽象、上下文类型的定义仍然在Grpc.Core.Api。这也就是为什么就算你用新框架,也会间接依赖到Grpc.Core.Api的原因之一。

下面这张表,是我总结的常用类型和职责,建议收藏:

类型职责备注
Channel管理客户端连接池、目标地址、传输安全继承自ChannelBase
CallInvoker发起 RPC 调用的核心入口常用于自定义拦截器链
Method<TRequest, TResponse>描述 RPC 方法元数据与序列化器静态缓存,避免重复构造
Marshaller<T>请求/响应序列化与反序列化抽象可自定义 JSON 或压缩编码
CallOptions一次调用的超时、取消、元数据、凭证集合结构体,按值传递
MetadatagRPC 元数据的键值集合类似 HTTP Header
AsyncUnaryCall<TResponse>一元异步调用的结果句柄包含响应、状态、头尾元数据
ServerCallContext服务端调用上下文提供 CancellationToken 等
Interceptor客户端/服务端拦截器基类用于日志、鉴权、熔断

3. 手写调用链:不靠生成代码,用 Channel、Method、CallInvoker 调通一个 RPC

之前有人问过我一个问题:"如果我不想用 protoc 生成代码,能直接调 gRPC 接口吗?"答案是能,而且这在做网关、泛化调用、调试工具时非常有用。Grpc.Core.Api恰好提供了完整的手写调用能力。我拿一个"动态调用 Greeter 服务"的场景来演示。

3.1 为什么需要手写调用链

先想清楚使用场景。一般情况你是有.proto文件的,直接用Grpc.Tools生成强类型客户端是最省事的。但在三种场景下,手写调用链更合适:

  • 你拿到的只是一个.proto描述,不想往项目里塞代码生成器。
  • 你需要做一个通用的 API 调试工具,运行时动态决定调用哪个方法。
  • 你正在处理一个没有生成代码的历史项目,想快速验证服务是否活着。

在这些场景里,核心就是组装Method<TRequest, TResponse>Marshaller<T>

3.2 自定义一个 JSON Marshaller

假设对端服务能接受 JSON 格式(别忘了,默认 gRPC 是 Protobuf 二进制,除非服务端开启了 JSON 转码,否则不能直接用 JSON 调),我们要先定义序列化器。这里我举个更通用的做法:基于System.Text.Json的 Marshaller。

public static class JsonMarshaller { private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; public static Marshaller<T> Create<T>() { return new Marshaller<T>( request => JsonSerializer.SerializeToUtf8Bytes(request, Options), response => JsonSerializer.Deserialize<T>(response, Options) ?? throw new InvalidOperationException("反序列化结果为空")); } }

注意Marshaller<T>的构造函数要求两个委托:一个是Action<T, Stream>或者Func<T, byte[]>,另一个是Func<byte[], T>。不同的重载签名略有差异,我这里用的byte[]版本在 .NET Standard 2.0 以上都支持。自定义序列化时最常踩的坑是——你序列化成 JSON 了,但服务端是标准 Protobuf 序列化器,两边一握手就报Unimplemented或者解析错误。所以这个方案一定要保证对端确实认 JSON。

3.3 组装 Method 并完成一次调用

接下来定义一个请求类型,并组装Method

public record HelloRequest(string Name); public record HelloReply(string Message); public static class DynamicGreeterClient { private static readonly Marshaller<HelloRequest> RequestMarshaller = JsonMarshaller.Create<HelloRequest>(); private static readonly Marshaller<HelloReply> ResponseMarshaller = JsonMarshaller.Create<HelloReply>(); public static readonly Method<HelloRequest, HelloReply> SayHelloMethod = new( MethodType.Unary, "greet.Greeter", "SayHello", RequestMarshaller, ResponseMarshaller); }

注意Method的第二个参数是"服务名",这里用的是 proto 文件里的package greet;加服务名Greeter,最终拼接成greet.Greeter。第三个参数是方法名SayHello。如果你填错了,服务端会直接给你返回StatusCode.Unimplemented,这个排查点必须记住。

然后就是调用:

using var channel = new Channel("localhost:50051", ChannelCredentials.Insecure); var invoker = channel.CreateCallInvoker(); var call = invoker.AsyncUnaryCall( DynamicGreeterClient.SayHelloMethod, null, new CallOptions(deadline: DateTime.UtcNow.AddSeconds(5)), new HelloRequest("张三")); var reply = await call.ResponseAsync; var status = call.GetStatus(); var trailers = call.GetTrailers(); Console.WriteLine($"服务端响应: {reply.Message},状态码: {status.StatusCode}");

这里有几个点值得一提:

  • channelusing包裹没问题,但生产环境通常会复用单例。
  • CallOptionsdeadline明确传了DateTime.UtcNow.AddSeconds(5)。注意必须是 UTC 时间,用本地时间会导致 Linux/Windows 跨环境时出现偏移,这个是特别容易踩的隐性 Bug。
  • 调用结果AsyncUnaryCall<TResponse>上同时有ResponseAsyncGetStatus()GetTrailers(),这三个方法可以分别拿到响应体、最终状态、响应尾部元数据。

3.4 流式调用同样可以手写

如果你要手写流式调用,逻辑也一样,只是调用方法换成AsyncServerStreamingCallAsyncClientStreamingCall或者AsyncDuplexStreamingCall。拿服务端流举例:

var call = invoker.AsyncServerStreamingCall( DynamicGreeterClient.SayHelloStreamMethod, null, new CallOptions(deadline: DateTime.UtcNow.AddSeconds(10)), new HelloRequest("张三")); await foreach (var item in call.ResponseStream.ReadAllAsync()) { Console.WriteLine(item.Message); }

ResponseStream的类型是IAsyncStreamReader<T>,可以直接用ReadAllAsync()遍历。注意流式响应读取过程中如果出现异常,await foreach会抛出RpcException,捕获的时候结合call.GetStatus()一起看,能更快定位是网络中断还是服务端主动断掉。

4. 讲清楚 CallOptions 里那几个真正要命的配置:超时、取消、元数据与凭证

CallOptions看着就是一个普通结构体,但里面的每一项配置都直接影响线上稳定性。很多事故(调用卡死、连接泄漏、拿不到认证信息)都是从CallOptions传错开始的。这一节我逐项讲透。

4.1 Deadline:没有默认超时就是最大的隐患

Deadline语义是"客户端整体等待的最晚绝对时间",不是相对超时时长。也就是说,你设置 5 秒超时,实际传的是DateTime.UtcNow.AddSeconds(5)。它的设计初衷是为了让超时能跨服务传播:A 调用 B 时设置了 deadline,B 再调用 C 时可以直接把剩余时间传过去,形成一条超时链。这也是 gRPC 跟 HTTP 的"每跳超时"不一样的地方。

实际使用中我强烈建议每个对外调用都显式传Deadline,哪怕你觉得服务端很快。因为一旦出现网络分区、服务端死锁、GC 长暂停,没有 deadline 的调用会一直挂在连接池里,最终拖垮整个应用。有一个不算冷的知识:CallOptionsCancellationToken取消后,请求会中断,但服务端不一定能及时感知。而Deadline超时后,gRPC 层会主动关闭连接并返回DeadlineExceeded,比单纯依赖取消更可控。

如果你是从CancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(5))转过来的,我建议改成 deadline 方案:new CallOptions(deadline: DateTime.UtcNow.AddSeconds(5))。这不是说CancellationToken没用,而是"绝对时间"在分布式场景下语义更清晰。

4.2 CancellationToken:取消的层级和传播

CancellationTokenDeadline可以同时存在,它俩不是互斥的。token 触发取消时,客户端会收到StatusCode.Cancelled。这里有个隐蔽细节:如果你只取消了客户端 token,服务端那边是通过ServerCallContext.CancellationToken感知调用的取消,但是很多服务端代码根本没检查这个 token。所以服务端要做好配合:

public override async Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context) { // 长耗时任务里要主动检测取消 var result = await SomeLongTaskAsync(context.CancellationToken); return result; }

客户端取消只是让客户端不再等结果,服务端该跑还会继续跑,直到它也响应取消信号。这里要预期到资源浪费。

4.3 Headers:认证信息传递的正确姿势

gRPC 里没有 HTTP Header 的概念,用的是Metadata。调用前塞 heades:

var headers = new Metadata { { "authorization", $"Bearer {token}" }, { "x-request-id", Guid.NewGuid().ToString() } }; var options = new CallOptions(headers: headers, deadline: DateTime.UtcNow.AddSeconds(3));

值得注意的两点:

  • 键名在Metadata内部会做小写化处理,你传Authorization还是authorization效果一样,但取的时候最好用TryGetValue,避免大小写问题。
  • Metadata是允许重复键的,比如要传多个x-tag,直接headers.Add("x-tag", "a"); headers.Add("x-tag", "b");没有问题。不要用字典类型,丢了重复语义。

4.4 Credentials:调用级凭证和通道级凭证的区别

ChannelCredentialsCallCredentials是两回事。前者管传输层安全(TLS/SSL),在创建Channel时传入;后者是每个调用的身份凭证,放在CallOptions.Headers里或者通过CallCredentials机制动态生成。

实际项目里一种典型做法:TLS 由通道级SslCredentials负责,而 JWT token 因过期时间短,需要每次调用动态获取。这时候CallCredentials就很有用:

var callCredentials = CallCredentials.FromInterceptor(async (context, metadata) => { var token = await GetTokenFromCacheAsync(); metadata.Add("authorization", $"Bearer {token}"); }); var channelCredentials = ChannelCredentials.Create( new SslCredentials(), callCredentials); var channel = new Channel("api.example.com:443", channelCredentials);

这个组合的妙处在于:每次调用前会自动触发GetTokenFromCacheAsync去拿最新 token,甚至你可以在这层做 token 刷新和数据竞争保护,比在业务代码里手动拼Metadata干净很多。

4.5 WriteOptions 与冷门但有用的字段

WriteOptions主要影响流式写入的行为。最典型的是WriteOptions.FlushHint,在写大数据时控制是否立即发送。默认 gRPC 写操作是立即发送的,但在高吞吐场景下你可以设置WriteOptions来延迟 flush,把多次写合并成一个 TCP 包,能有效降低小包数量。不过这需要服务端和客户端配合设计,不能只看单侧。

PropagationToken我单独拿出来说,它用于跨 RPC 调用传播"截止时间和取消状态"。A 服务收到请求后带着ServerCallContext里的上下文去调 B 服务,如果希望 B 继承 A 的 deadline,就可以用PropagationToken

var propagationToken = context.CreatePropagationToken(); var options = new CallOptions(propagationToken: propagationToken);

这个在做级联调用时非常有用,能避免上游已经超时,下游还在傻傻处理的情况。

5. 实际踩坑记录:Grpc.Core.Api 和 Grpc.Net.Client 共存时的三个典型问题

这一节写的是我在真实项目里踩过的坑,每一个都花了半天以上排查。如果你也是新旧依赖混用,可以对照着提前避雷。

5.1 类型冲突 CS0433:两个 Channel 教你做人

我第一次在同一个项目里遇到CS0433错误时,也有点懵。错误信息大概是这样:

error CS0433: 类型 'Channel' 同时存在于 'Grpc.Core.Api, Version=2.46.0.0' 和 'Grpc.Net.Client, Version=2.60.0.0' 中

原因很清楚:Grpc.Core.Api的命名空间是Grpc.CoreGrpc.Net.Client的命名空间是Grpc.Net.Client,两个包里都存在一个叫Channel或者ChannelBase的类型。正常情况下你不会同时引用它们,但传递依赖会把它们带到你的项目里。

解决办法看情况:

  • 如果你确定用新栈,在引用旧包的地方加extern alias是个工程化手段,但很麻烦,不推荐。更推荐把传递依赖从新代码里剔除——找到哪个旧库引用了Grpc.Core,看它是否兼容新 API。
  • 如果老代码不能动,新代码引Grpc.Net.Client时用别名引用(extern alias NewGrpc;),保证新老类型互不干扰。

我的建议顺序是:先升级旧库到支持新 API 的版本,实在不行再上extern alias。别一开始就上别名,那等于往项目里埋了定时炸弹。

5.2 拦截器注册方式不同

Grpc.Core有自己的一套InterceptorClientInterceptor,能实现类似 ASP.NET Core 中间件的横切逻辑。但没有像Grpc.Net.ClientAddInterceptor()那么便捷的扩展方法。在老代码里需要这样手动包装:

public class LoggingInterceptor : Interceptor { public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( TRequest request, ClientInterceptorContext<TRequest, TResponse> context, AsyncUnaryCallContinuation<TRequest, TResponse> continuation) { Console.WriteLine($">>> 调用 {context.Method.FullName}"); var call = continuation(request, context); var responseTask = call.ResponseAsync.ContinueWith(t => { Console.WriteLine($"<<< 状态 {call.GetStatus().StatusCode}"); return t.Result; }); return new AsyncUnaryCall<TResponse>( responseTask, call.ResponseHeadersAsync, call.GetStatus, call.GetTrailers, call.Dispose); } } // 使用时 var invoker = channel.CreateCallInvoker(); var decoratedInvoker = new ClientInterceptor().Intercept(invoker); // 实际用法略有差异

这跟新栈UseInterceptors的方式差很多。如果你在迁移过程中发现"拦截器没生效",大概率就是新旧注册方式混用了。

另外要注意,拦截器包装AsyncUnaryCall时,必须把ResponseHeadersAsyncGetStatusGetTrailersDispose这些成员原样透传,否则会出现 headers 回调丢失、连接泄漏等问题。我自己就漏过Dispose,导致长连接数量缓慢增长,最后被运维报警。

5.3 Deadline 字段的隐式类型问题不是编译错误

有一种 bug 是编译器不报错,但运行结果完全不对的:DateTime.Now传给了Deadline。上面提到 deadline 要用 UTC 时间,但很多人图省事直接写DateTime.Now.AddSeconds(5)。如果你在 UTC+8 环境,服务端部署在 UTC 时区,差异就出来了——本地时间转成 UTC 后实际是提前 8 小时,等于一进去就超时。这个 bug 特别隐蔽,因为本地单测可能都跑在同一个时区,根本测不出来。

排查方法是:在服务端拦截器或者日志里把context.Deadline打出来,看是否是预期的未来时间。一旦发现Deadline在几百毫秒内就过期,先检查客户端传的是不是 UTC。

6. 关于调试与诊断:从反编译到性能排查的一点经验

最后聊聊怎么在实战里真正用顺Grpc.Core.Api。官方文档写得相对简略,很多内部行为需要靠反编译或者日志才能看明白。

6.1 用 ILSpy/dnSpy 快速理解内部结构

我强烈建议在项目里遇到奇怪行为时,直接把Grpc.Core.Api.dll拖进 ILSpy 里看。比如你会发现Marshaller<T>的构造器其实要求Func<T, byte[]>Func<byte[], T>两个委托,而官方 XML 注释没有完整说明异常情况和边界行为。反编译之后,AsyncUnaryCall<TResponse>的包装逻辑也一目了然:它内部持有Task<TResponse>Task<Metadata>Func<Status>Func<Metadata>Action,五元组透传。

这不是鼓励你每次写代码都反编译,而是当遇到"看起来合规但行为诡异"的 API 时,不要只靠猜,直接看实现是最快的。

6.2 环境变量排查:GRPC_TRACE 和 GRPC_VERBOSITY

Grpc.Core底层是 C-core,native 层可以通过环境变量打开详细日志:

GRPC_VERBOSITY=debug GRPC_TRACE=all

这两个变量对排查连接建立失败、TLS 握手失败、流量控制异常特别有效。注意这俩变量名是 C-core 的约定,在 Windows 和 Linux 下都能用。打开GRPC_TRACE=all之后输出非常吵,建议只在联调环境开,而且最好配合日志过滤只保留grpc关键字。

值得说明的是,新栈Grpc.Net.Client用的是另一个日志体系(Grpc.Net.ClientILogger),所以如果你判断问题出在传输层,优先看 native 日志;如果问题出在应用拦截器或者序列化,那看托管日志更准。

6.3 连接状态与性能排查

ChannelConnectAsync()方法和State属性可以帮助你判断连接是否健康。常见状态有IdleConnectingReadyTransientFailureShutdown。如果发现大量TransientFailure,先确认服务端地址是否可达,再看是不是客户端把Channel短命创建导致连接反复重建。

性能排查方面,有几个我在项目里反复遇到的点:

  • 每调用一次就new Channel,导致连接池形同虚设。
  • Protobuf 序列化器在每次调用时重复实例化(实际上默认的Marshaller内部会缓存序列化器,但如果你手写了Marshaller,要自己注意线程安全)。
  • 并发很高时CallOptions里的Metadata每次构造大字典,造成 GC 压力。可以把静态不变的 metadata(比如x-sdk-version)提取出来复用。

6.4 终止时的优雅关闭

最后是Channel.ShutdownAsync()。老的Channel实现里,官方推荐在应用退出时调用它来优雅关闭所有活跃 RPC,而不是直接Dispose()

await channel.ShutdownAsync();

ShutdownAsync会先停止接受新调用,等待已有调用完成(或者到 deadline),然后才释放底层资源。我见过不少项目直接Dispose(),结果在滚动发布时,正在处理的长请求被硬切,客户端疯狂报错。用ShutdownAsync后发布过程明显平稳很多。

如果你正在维护老项目,我的建议是先别急着把Grpc.Core全部替换成Grpc.Net.Client,那个迁移工作是另一个大话题。更稳妥的做法是:弄懂Grpc.Core.Api的类型边界和调用链,把超时、取消、凭证这些基础能力用扎实,然后在现有基础上逐步将新建的调用迁移到新栈,让老 API 慢慢自然退出。毕竟工具新旧不是核心,关键是推理链路清晰,知道每个 API 背后到底发生了什么,出了问题才不会抓瞎。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询