关于 dotnet-grpc
面向 .NET 开发者的 gRPC 生命周期指南,涵盖从 .proto 定义和代码生成到服务端、客户端及流式通信的实现,并介绍认证、负载均衡和健康检查。
主要功能
- 01.proto 定义与代码生成
- 02ASP.NET Core gRPC 服务端托管
- 03Grpc.Net.Client 客户端模式
- 04四种 gRPC 流式模式
- 05认证、负载均衡与健康检查
适用场景
- 01为 .NET 应用定义并生成 gRPC 服务契约
- 02实现服务间双向流式通信
- 03配置 gRPC 服务认证与健康探测
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-grpc安装后在 Claude Code 中输入/dotnet-grpc确认已加载。
详细介绍
dotnet-grpc
为 .NET 应用提供完整的 gRPC 生命周期支持。涵盖 .proto 服务定义、代码生成、ASP.NET Core gRPC 服务实现与端点托管、Grpc.Net.Client 客户端模式、全部四种流式模式(一元、服务端流式、客户端流式、双向流式)、身份验证、负载均衡和运行状况检查。
**范围之外:**源生成器编写模式(增量生成器 API、Roslyn 语法树)——请参阅 [skill:dotnet-csharp-source-generators]。HTTP 客户端工厂模式和弹性管道配置——请参阅 [skill:dotnet-http-client] 和 [skill:dotnet-resilience]。原生 AOT 架构和裁剪策略——AOT 编译请参阅 [skill:dotnet-native-aot],AOT 优先设计模式请参阅 [skill:dotnet-aot-architecture],安全裁剪开发请参阅 [skill:dotnet-trimming]。
交叉参考:[skill:dotnet-resilience] 介绍 gRPC 通道的重试/断路器,[skill:dotnet-serialization] 介绍 Protobuf 线格式详情。gRPC 服务测试请参阅 [skill:dotnet-integration-testing]。
Proto 定义和代码生成
项目设置
gRPC 使用 Protocol Buffers 作为接口定义语言。Grpc.Tools 包会在构建时从 .proto 文件生成 C# 代码。
服务端项目:
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.*" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
</ItemGroup>
客户端项目:
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.*" />
<PackageReference Include="Grpc.Net.Client" Version="2.*" />
<PackageReference Include="Grpc.Tools" Version="2.*" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Client" />
</ItemGroup>
共享契约项目(较大型服务推荐):
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.*" />
<PackageReference Include="Grpc.Tools" Version="2.*" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\*.proto" GrpcServices="Both" />
</ItemGroup>
Proto 文件定义
syntax = "proto3";
option csharp_namespace = "MyApp.Grpc";
package myapp;
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
// Service definition with all 4 streaming patterns
service OrderService {
// Unary: single request, single response
rpc GetOrder (GetOrderRequest) returns (OrderResponse);
// Server streaming: single request, stream of responses
rpc ListOrders (ListOrdersRequest) returns (stream OrderResponse);
// Client streaming: stream of requests, single response
rpc UploadOrders (stream CreateOrderRequest) returns (UploadOrdersResponse);
// Bidirectional streaming: stream of requests, stream of responses
rpc ProcessOrders (stream CreateOrderRequest) returns (stream OrderResponse);
}
message GetOrderRequest {
int32 id = 1;
}
message ListOrdersRequest {
string customer_id = 1;
int32 page_size = 2;
string page_token = 3;
}
message CreateOrderRequest {
string customer_id = 1;
repeated OrderItemMessage items = 2;
}
message OrderResponse {
int32 id = 1;
string customer_id = 2;
repeated OrderItemMessage items = 3;
google.protobuf.Timestamp created_at = 4;
}
message OrderItemMessage {
string product_id = 1;
int32 quantity = 2;
double unit_price = 3;
}
message UploadOrdersResponse {
int32 orders_created = 1;
}
代码生成工作流
Grpc.Tools 包会在构建时运行 Protobuf 编译器(protoc)和 C# gRPC 插件。生成的文件会出现在 obj/ 中,并自动包含在项目内:
- 通过
<Protobuf>项将.proto文件添加到项目中 - 将
GrpcServices设为Server、Client或Both - 构建项目——生成的 C# 类型和服务存根会出现在
obj/Debug/net10.0/Protos/中 - 实现生成的抽象基类(服务端),或使用生成的客户端类
gRPC 代码生成工具链使用源生成,根据 .proto 定义生成 C# 存根。其概念与 [skill:dotnet-csharp-source-generators] 类似,但使用的是 protoc,而不是 Roslyn 增量生成器。
ASP.NET Core gRPC 服务端
服务实现
实现生成的抽象基类:
using Grpc.Core;
using MyApp.Grpc;
public sealed class OrderGrpcService(
OrderRepository repository,
ILogger<OrderGrpcService> logger) : OrderService.OrderServiceBase
{
// Unary
public override async Task<OrderResponse> GetOrder(
GetOrderRequest request,
ServerCallContext context)
{
var order = await repository.GetByIdAsync(request.Id, context.CancellationToken);
if (order is null)
{
throw new RpcException(new Status(StatusCode.NotFound,
$"Order {request.Id} not found"));
}
return MapToResponse(order);
}
// Server streaming
public override async Task ListOrders(
ListOrdersRequest request,
IServerStreamWriter<OrderResponse> responseStream,
ServerCallContext context)
{
await foreach (var order in repository.ListByCustomerAsync(
request.CustomerId, context.CancellationToken))
{
await responseStream.WriteAsync(MapToResponse(order),
context.CancellationToken);
}
}
// Client streaming
public override async Task<UploadOrdersResponse> UploadOrders(
IAsyncStreamReader<CreateOrderRequest> requestStream,
ServerCallContext context)
{
var count = 0;
await foreach (var request in requestStream.ReadAllAsync(
context.CancellationToken))
{
await repository.CreateAsync(MapFromRequest(request),
context.CancellationToken);
count++;
}
return new UploadOrdersResponse { OrdersCreated = count };
}
// Bidirectional streaming
public override async Task ProcessOrders(
IAsyncStreamReader<CreateOrderRequest> requestStream,
IServerStreamWriter<OrderResponse> responseStream,
ServerCallContext context)
{
await foreach (var request in requestStream.ReadAllAsync(
context.CancellationToken))
{
var order = await repository.CreateAsync(MapFromRequest(request),
context.CancellationToken);
await responseStream.WriteAsync(MapToResponse(order),
context.CancellationToken);
}
}
private static OrderResponse MapToResponse(Order order) =>
new()
{
Id = order.Id,
CustomerId = order.CustomerId,
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(
order.CreatedAt)
};
private static Order MapFromRequest(CreateOrderRequest request) =>
new()
{
CustomerId = request.CustomerId,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = (decimal)i.UnitPrice
}).ToList()
};
}
端点托管
在 ASP.NET Core 管道中注册 gRPC 服务:
var builder = WebApplication.CreateBuilder(args);
// Add gRPC services
builder.Services.AddGrpc(options =>
{
options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB
options.MaxSendMessageSize = 4 * 1024 * 1024;
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});
var app = builder.Build();
// Map gRPC service endpoints
app.MapGrpcService<OrderGrpcService>();
app.Run();
gRPC 反射(开发环境)
启用 gRPC 反射,以支持 grpcurl 和 grpcui 等工具:
builder.Services.AddGrpc();
builder.Services.AddGrpcReflection();
var app = builder.Build();
app.MapGrpcService<OrderGrpcService>();
if (app.Environment.IsDevelopment())
{
app.MapGrpcReflectionService();
}
使用 Grpc.Net.Client 的客户端模式
基本客户端
using Grpc.Net.Client;
using MyApp.Grpc;
// Create a channel (reuse across calls -- channels are expensive to create)
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new OrderService.OrderServiceClient(channel);
// Unary call
var response = await client.GetOrderAsync(
new GetOrderRequest { Id = 42 });
使用 IHttpClientFactory 注册 DI 客户端
通过 IHttpClientFactory 注册 gRPC 客户端,以实现连接池和弹性处理:
builder.Services
.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri("https://order-service:5001");
})
.ConfigureChannel(options =>
{
options.MaxReceiveMessageSize = 4 * 1024 * 1024;
});
通过 [skill:dotnet-resilience] 应用弹性处理:
builder.Services
.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri("https://order-service:5001");
})
.AddStandardResilienceHandler();
读取服务端流
using var call = client.ListOrders(
new ListOrdersRequest { CustomerId = "cust-123" });
await foreach (var order in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"Order {order.Id}: {order.CustomerId}");
}
客户端流式
using var call = client.UploadOrders();
foreach (var order in ordersToCreate)
{
await call.RequestStream.WriteAsync(new CreateOrderRequest
{
CustomerId = order.CustomerId
});
}
// Signal completion
await call.RequestStream.CompleteAsync();
// Read the response
var response = await call;
Console.WriteLine($"Created {response.OrdersCreated} orders");
双向流式
using var call = client.ProcessOrders();
// Start reading responses in background
var readTask = Task.Run(async () =>
{
await foreach (var response in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"Processed order {response.Id}");
}
});
// Send requests
foreach (var order in ordersToProcess)
{
await call.RequestStream.WriteAsync(new CreateOrderRequest
{
CustomerId = order.CustomerId
});
}
await call.RequestStream.CompleteAsync();
await readTask;
流式模式摘要
gRPC 支持四种通信模式:
| 模式 | 请求 | 响应 | 使用场景 |
|---|---|---|---|
| 一元 | 单条消息 | 单条消息 | 标准请求-响应(CRUD、查询) |
| 服务端流式 | 单条消息 | 消息流 | 实时信息流、大型结果集、推送通知 |
| 客户端流式 | 消息流 | 单条消息 | 批量上传、聚合、遥测数据接收 |
| 双向流式 | 消息流 | 消息流 | 聊天、实时协作、事件处理 |
身份验证
Bearer Token(JWT)
服务端身份验证:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.TokenValidationParameters.ValidAudience = "order-api";
});
builder.Services.AddAuthorization();
builder.Services.AddGrpc();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGrpcService<OrderGrpcService>().RequireAuthorization();
客户端令牌传递:
builder.Services
.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri("https://order-service:5001");
})
.AddCallCredentials(async (context, metadata, serviceProvider) =>
{
var tokenProvider = serviceProvider.GetRequiredService<ITokenProvider>();
var token = await tokenProvider.GetTokenAsync(context.CancellationToken);
metadata.Add("Authorization", $"Bearer {token}");
});
证书身份验证(mTLS)
使用双向 TLS 进行服务间身份验证:
// Server: require client certificates
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
});
});
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.Chained;
options.RevocationMode = X509RevocationMode.NoCheck; // Configure per environment
});
// Client: provide client certificate
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(
new X509Certificate2("client.pfx", "password"));
using var channel = GrpcChannel.ForAddress("https://order-service:5001",
new GrpcChannelOptions
{
HttpHandler = handler
});
负载均衡
客户端负载均衡
gRPC 支持通过服务发现实现客户端负载均衡:
// DNS-based service discovery with round-robin
builder.Services
.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri("dns:///order-service:5001");
})
.ConfigureChannel(options =>
{
options.Credentials = ChannelCredentials.Insecure;
options.ServiceConfig = new ServiceConfig
{
LoadBalancingConfigs = { new RoundRobinConfig() }
};
});
基于代理的负载均衡
对于使用负载均衡器的环境(例如 Kubernetes、Envoy、YARP):
- 使用 L7(支持 HTTP/2)负载均衡器——L4 负载均衡器在 TCP 层路由,并会将所有 gRPC 请求固定到单个后端,因为 HTTP/2 会在单个连接上进行多路复用。
- 支持 gRPC 的 Envoy、Linkerd 和 Kubernetes ingress 控制器会在 RPC 层分发请求。
- 在代理后配置
SocketsHttpHandler.EnableMultipleHttp2Connections = true,以允许建立多个连接:
builder.Services
.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri("https://order-service-lb:5001");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true
});
健康检查
gRPC 健康检查协议
实现标准 gRPC 健康检查协议(grpc.health.v1.Health),以便编排器和负载均衡器探测服务状态:
builder.Services.AddGrpc();
builder.Services.AddGrpcHealthChecks()
.AddCheck("database", () =>
{
// Custom health check logic
return HealthCheckResult.Healthy();
});
var app = builder.Build();
app.MapGrpcService<OrderGrpcService>();
app.MapGrpcHealthChecksService();
与 ASP.NET Core 健康检查集成
gRPC 健康检查可与标准 ASP.NET Core 健康检查系统集成:
builder.Services.AddHealthChecks()
.AddNpgSql(
builder.Configuration.GetConnectionString("OrderDb")!,
name: "order-db",
tags: ["ready"]);
builder.Services.AddGrpc();
builder.Services.AddGrpcHealthChecks()
.AddAsyncCheck("order-db", async (sp, ct) =>
{
var healthCheckService = sp.GetRequiredService<HealthCheckService>();
var report = await healthCheckService.CheckHealthAsync(
r => r.Tags.Contains("ready"), ct);
return report.Status == HealthStatus.Healthy
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy();
});
用于 gRPC 的 Kubernetes 探针
# Use grpc health check probe (Kubernetes 1.24+)
livenessProbe:
grpc:
port: 5001
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
grpc:
port: 5001
initialDelaySeconds: 5
periodSeconds: 10
拦截器
gRPC 拦截器是用于 gRPC 调用的中间件,类似于 ASP.NET Core 中间件或 HTTP DelegatingHandlers。
服务端拦截器
public sealed class LoggingInterceptor(ILogger<LoggingInterceptor> logger)
: Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
var stopwatch = Stopwatch.StartNew();
try
{
var response = await continuation(request, context);
logger.LogInformation(
"gRPC {Method} completed in {ElapsedMs}ms",
context.Method, stopwatch.ElapsedMilliseconds);
return response;
}
catch (RpcException ex)
{
logger.LogError(ex,
"gRPC {Method} failed with {StatusCode}",
context.Method, ex.StatusCode);
throw;
}
}
}
// Register
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<LoggingInterceptor>();
});
客户端拦截器
public sealed class AuthInterceptor(ITokenProvider tokenProvider) : Interceptor
{
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
var token = tokenProvider.GetCachedToken();
var headers = context.Options.Headers ?? new Metadata();
headers.Add("Authorization", $"Bearer {token}");
var newContext = new ClientInterceptorContext<TRequest, TResponse>(
context.Method, context.Host,
context.Options.WithHeaders(headers));
return continuation(request, newContext);
}
}
错误处理
状态码
将领域错误映射到 gRPC 状态码:
| gRPC 状态 | HTTP 等效状态 | 使用场景 |
|---|---|---|
OK | 200 | 成功 |
NotFound | 404 | 资源不存在 |
InvalidArgument | 400 | 客户端发送了错误数据 |
PermissionDenied | 403 | 调用方没有权限 |
Unauthenticated | 401 | 没有有效凭据 |
AlreadyExists | 409 | 尝试重复创建 |
ResourceExhausted | 429 | 触发速率限制 |
Internal | 500 | 未处理的服务端错误 |
Unavailable | 503 | 暂时性故障——可安全重试 |
DeadlineExceeded | 504 | 操作超时 |
丰富的错误详情
// Server: throw with metadata
var status = new Status(StatusCode.InvalidArgument, "Validation failed");
var metadata = new Metadata
{
{ "field", "customer_id" },
{ "reason", "Customer ID is required" }
};
throw new RpcException(status, metadata);
// Client: read error metadata
try
{
var response = await client.GetOrderAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
{
var field = ex.Trailers.GetValue("field");
var reason = ex.Trailers.GetValue("reason");
logger.LogWarning("Validation error on {Field}: {Reason}", field, reason);
}
截止时间与取消
始终为 gRPC 调用设置截止时间,以防止无限期等待:
// Client: set a deadline
var deadline = DateTime.UtcNow.AddSeconds(10);
var response = await client.GetOrderAsync(
new GetOrderRequest { Id = 42 },
deadline: deadline);
// Server: check deadline and propagate cancellation
public override async Task<OrderResponse> GetOrder(
GetOrderRequest request,
ServerCallContext context)
{
// context.CancellationToken is automatically cancelled when deadline expires
var order = await repository.GetByIdAsync(request.Id, context.CancellationToken);
// ...
}
面向浏览器客户端的 gRPC-Web
浏览器不支持原生 gRPC 所需的 HTTP/2 尾部。gRPC-Web 是一种可通过 HTTP/1.1 和 HTTP/2 工作且无需尾部的协议变体,因此浏览器 JavaScript 客户端能够调用 gRPC 服务。
服务端配置
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
builder.Services.AddCors(options =>
{
options.AddPolicy("GrpcWeb", policy =>
{
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding");
});
});
var app = builder.Build();
app.UseRouting();
app.UseCors();
app.UseGrpcWeb(); // Must be between UseRouting and MapGrpcService
app.MapGrpcService<OrderGrpcService>()
.EnableGrpcWeb()
.RequireCors("GrpcWeb");
JavaScript 客户端(grpc-web)
// Using @improbable-eng/grpc-web or grpc-web package
import { OrderServiceClient } from './generated/order_grpc_web_pb';
import { GetOrderRequest } from './generated/order_pb';
const client = new OrderServiceClient('https://api.example.com');
const request = new GetOrderRequest();
request.setId(42);
client.getOrder(request, {}, (err, response) => {
if (err) {
console.error('gRPC error:', err.message);
return;
}
console.log('Order:', response.toObject());
});
Envoy 代理替代方案
除了使用 ASP.NET Core gRPC-Web 中间件外,还可以使用 Envoy 代理将 gRPC-Web 请求转换为原生 gRPC。当无法修改 gRPC 服务时,此方案很有用:
# Envoy filter configuration
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
gRPC-Web 的限制
- 仅支持一元调用和服务端流式传输——gRPC-Web 不支持客户端流式传输和双向流式传输
- 不支持 HTTP/2 尾部——状态和尾随元数据会编码在响应正文中
- 需要 CORS——跨源请求需要在服务端进行明确的 CORS 配置
- 如需在浏览器中进行全双工通信,可考虑 SignalR——当需要双向流式传输时,请参阅 [skill:dotnet-realtime-communication] 了解替代方案
核心原则
- 将
.proto文件作为契约 —— 它们是 API 结构的唯一事实来源,由客户端和服务端共用 - 在
<Protobuf>项上设置GrpcServices—— 服务项目使用Server,消费者项目使用Client,共用契约使用Both - 复用通道 ——
GrpcChannel会管理 HTTP/2 连接;每次调用都新建通道会浪费资源 - 通过 DI 注册 gRPC 客户端 ——
AddGrpcClient会与IHttpClientFactory集成,以实现连接池和弹性处理 - 始终设置截止时间 —— 未设置截止时间的调用可能会在服务端响应缓慢或无法访问时无限期挂起
- 使用 L7 负载均衡器 —— 由于 HTTP/2 会在单个 TCP 连接上进行多路复用,L4 负载均衡器会将所有流量固定到一个后端
- 实现 gRPC 健康检查协议 —— 这可让 Kubernetes 探针和负载均衡器监控服务健康状况
- 浏览器客户端使用 gRPC-Web —— 原生 gRPC 需要浏览器不支持的 HTTP/2 trailers;gRPC-Web 可弥补这一差距
构建使用预先编译的 gRPC 服务时,请参阅 [skill:dotnet-native-aot],了解 Native AOT 编译流水线;另请参阅 [skill:dotnet-aot-architecture],了解兼容 AOT 的模式。
Agent 常见问题
- 不要为每个请求都新建
GrpcChannel—— 创建通道的成本很高,而且通道会管理 HTTP/2 连接。请复用通道,或使用通过 DI 注册的客户端。 - 不要省略
<Protobuf>项上的GrpcServices—— 默认值为Both,会同时生成服务端和客户端存根。这会让客户端项目包含未使用的服务端代码,反之亦然,造成代码膨胀。 - 使用 L4 负载均衡器处理 gRPC 时,若未启用
EnableMultipleHttp2Connections,请勿使用 —— HTTP/2 多路复用意味着单个连接会处理所有 RPC,从而无法实现负载分发。 - 不要在 gRPC 服务中抛出通用的
Exception—— 应抛出带有适当StatusCode和明确消息的RpcException。未处理的异常会变成StatusCode.Internal,且不包含有用的细节。 - 不要忘记在客户端流上调用
CompleteAsync()—— 服务端会等待流完成后再发送响应。忘记调用会导致请求挂起。 - 不要在未注册健康检查的情况下使用
grpc.health.v1.Health—— 空的健康检查服务始终报告Serving,这会让健康监控失去意义。 - 未配置 CORS 时,不要全局启用 gRPC-Web —— 未设置 CORS 策略的
UseGrpcWeb()会允许任何来源调用你的 gRPC 服务。务必同时配置显式的RequireCors()。 - 不要尝试通过 gRPC-Web 使用客户端流或双向流 —— gRPC-Web 协议仅支持一元调用和服务端流。浏览器需要全双工通信时,请使用 SignalR 或原生 gRPC。
致谢
改编自 Aaronontheweb/dotnet-skills(MIT 许可证)。