-
c#网络编程之gRPC实战(Protocol Buffers、双向流、拦截器)
第46章 gRPC实战
46.1 gRPC实战(Protocol Buffers、双向流、拦截器)
一、我踩过的gRPC坑:从HTTP/1.1到gRPC的性能跃迁与踩坑血泪史
做实时聊天系统时,我一开始用HTTP/1.1的REST API,结果并发1000人时消息延迟超过500ms,QPS只有200;换成gRPC后,QPS直接涨到800,延迟降到50ms,但又遇到双向流的粘包问题,导致消息乱序;后来加了拦截器,结果没处理异步日志,导致日志丢失了30%的请求记录。这节我把这些vb.net教程C#教程python教程SQL教程access 2010教程踩坑经验揉进去,用大白话讲透gRPC的核心原理,结合C#实战代码逐行拆解Protocol Buffers、双向流、拦截器的实现,拓展生产级优化技巧,让你的gRPC服务既高性能又稳定。
二、Protocol Buffers:gRPC性能的“秘密武器”,比JSON快5倍
Protocol Buffers(Protobuf)是gRPC的序列化协议,核心是“二进制序列化+强类型”,比JSON快5-10倍,适合高性能API、微服务内部调用。
核心原理(大白话+快递包裹例子)
把Protobuf比作“快递包裹”:
1.定义包裹格式(.proto文件):提前约定包裹里有什么东西(比如用户ID、消息内容),每个东西的位置(标签);
2.打包(序列化):把数据按约定格式打包成二进制,体积小,传输快;
3.拆包(反序列化):按约定格式拆包,直接得到强类型数据,不用解析字符串。
我踩过的坑:一开始在.proto文件中改了字段标签,导致旧版本客户端解析失败——Protobuf的字段标签是唯一标识,不能改,只能新增!
实战1:Protobuf基础与C#代码生成
步骤1:编写.proto文件(chat.proto)
proto
syntax = "proto3"; // 使用Protobuf 3版本,比2更简洁
// 生成的C#代码命名空间
option csharp_namespace = "GrpcDemo.Chat";
// 聊天服务定义
service ChatService {
// 双向流RPC:客户端和服务器可以互相发送消息
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
// 聊天消息定义
message ChatMessage {
string user_id = 1; // 标签1,唯一标识,不能改
string content = 2; // 标签2
int64 timestamp = 3; // 标签3,Unix时间戳(秒)
optional string room_id = 4; // 可选字段,旧版本客户端会忽略,新增字段用optional避免兼容问题
}
核心语法拆解:
1.syntax = "proto3":必须指定版本,Protobuf 3默认字段是optional,比2更简洁;
2.option csharp_namespace:生成的C#代码的命名空间,避免和其他代码冲突;
3.service:定义gRPC服务,里面是RPC方法;
4.rpc Chat(stream ChatMessage) returns (stream ChatMessage):双向流RPC,客户端和服务器都可以发送消息流;
5.message:定义数据结构,每个字段有标签(数字),标签是唯一标识,不能改,顺序可以换;
6.optional:可选字段,旧版本客户端会忽略,新增字段时必须用optional,避免旧版本客户端报错。
步骤2:生成C#代码
1.安装Protobuf编译器和gRPC C#插件:
1.下载Protobuf编译器:protoc官网
2.下载gRPC C#插件:grpc_csharp_plugin
2.运行生成命令:
bash
3.
protoc --csharp_out=. --grpc_out=. --plugin=protoc-gen-grpc=C:path ogrpc_csharp_plugin.exe chat.proto
4.生成两个文件:
1.Chat.cs:消息类的C#代码,包含ChatMessage类和序列化/反序列化方法;
2.ChatGrpc.cs:gRPC服务和客户端的C#代码,包含ChatService抽象类和ChatServiceClient类。
步骤3:Protobuf序列化与JSON对比(C#代码)
csharp
using GrpcDemo.Chat;
using System;
using System.Text.Json;
using System.Diagnostics;
namespace GrpcDemo.Protobuf;
class Program
{
static void Main(string[] args)
{
var message = new ChatMessage
{
UserId = "user_123",
Content = "Hello gRPC!",
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
RoomId = "room_456"
};
// Protobuf序列化
var stopwatch = Stopwatch.StartNew();
byte[] protobufBytes = message.ToByteArray();
stopwatch.Stop();
Console.WriteLine($"Protobuf序列化耗时:{stopwatch.ElapsedMilliseconds}ms,体积:{protobufBytes.Length}字节");
// JSON序列化
stopwatch.Restart();
string jsonString = JsonSerializer.Serialize(message);
byte[] jsonBytes = System.Text.Encoding.UTF8.GetBytes(jsonString);
stopwatch.Stop();
Console.WriteLine($"JSON序列化耗时:{stopwatch.ElapsedMilliseconds}ms,体积:{jsonBytes.Length}字节");
// Protobuf反序列化
stopwatch.Restart();
ChatMessage deserializedMessage = ChatMessage.Parser.ParseFrom(protobufBytes);
stopwatch.Stop();
Console.WriteLine($"Protobuf反序列化耗时:{stopwatch.ElapsedMilliseconds}ms");
// JSON反序列化
stopwatch.Restart();
ChatMessage jsonDeserialized = JsonSerializer.Deserialize<ChatMessage>(jsonString);
stopwatch.Stop();
Console.WriteLine($"JSON反序列化耗时:{stopwatch.ElapsedMilliseconds}ms");
}
}
运行结果:
Protobuf序列化耗时:0ms,体积:42字节
JSON序列化耗时:1ms,体积:118字节
Protobuf反序列化耗时:0ms
JSON反序列化耗时:0ms
核心对比:
体积:Protobuf体积是JSON的35%,传输更快;
性能:Protobuf序列化/反序列化速度是JSON的2-5倍,适合高性能场景;
强类型:Protobuf生成的是强类型代码,不用手动解析字符串,避免类型错误;
版本兼容:新增字段用optional,旧版本客户端会忽略,不会报错。
拓展知识:Protobuf的版本兼容原则
1.不能改字段标签:标签是字段的唯一标识,改了会导致旧版本客户端解析失败;
2.新增字段用optional:旧版本客户端会忽略可选字段,不会报错;
3.不能删除字段:删除字段会导致旧版本客户端解析时出现默认值(比如0、空字符串);
4.可以改字段名称:字段名称不影响序列化,只影响生成的C#代码;
5.可以改字段类型:但要注意兼容,比如int32改成int64,旧版本客户端会解析成int32(如果值在int32范围内)。
三、gRPC双向流:实时通信的“神器”,实现聊天系统、实时监控
gRPC支持四种通信模式,其中双向流式RPC是最强大的,适合实时通信场景(如聊天系统、实时监控、股票行情)。
核心原理(大白话+电话通话例子)
把双向流比作“电话通话”:
1.客户端拨号:客户端和服务器建立连接;
2.互相通话:客户端和服务器可以随时发送消息,不用等待对方回复;
3.挂断电话:任何一方可以断开连接,通话结束。
我踩过的坑:一开始没处理客户端断开连接的情况,导致服务器内存泄漏——必须用try-finally注销客户端!
实战2:gRPC双向流实现实时聊天系统(C#)
步骤1:服务端实现(ChatServiceImpl.cs)
csharp
using Grpc.Core;
using GrpcDemo.Chat;
using System.Collections.Concurrent;
namespace GrpcDemo.Server;
public class ChatServiceImpl : ChatService.ChatServiceBase
{
// 存储在线客户端,key是用户ID,value是服务器流写入器
private readonly ConcurrentDictionary<string, IServerStreamWriter<ChatMessage>> _onlineClients = new();
/// <summary>
/// 双向流RPC:处理聊天消息
/// </summary>
public override async Task Chat(IAsyncStreamReader<ChatMessage> requestStream, IServerStreamWriter<ChatMessage> responseStream, ServerCallContext context)
{
// 1. 从请求头获取用户ID(客户端认证后传递)
if (!context.RequestHeaders.TryGetValue("user-id", out var userIdEntry) || string.IsNullOrEmpty(userIdEntry.Value))
{
await responseStream.WriteAsync(new ChatMessage { Content = "错误:未提供用户ID" });
return;
}
string userId = userIdEntry.Value;
// 2. 注册客户端到在线列表
if (!_onlineClients.TryAdd(userId, responseStream))
{
await responseStream.WriteAsync(new ChatMessage { Content = "错误:用户已在线" });
return;
}
Console.WriteLine($"用户 {userId} 上线,当前在线人数:{_onlineClients.Count}");
try
{
// 3. 广播用户上线消息
await BroadcastMessageAsync(new ChatMessage { UserId = "系统", Content = $"{userId} 已上线" });
// 4. 循环接收客户端发送的消息
await foreach (var clientMessage in requestStream.ReadAllAsync())
{
// 5. 广播客户端消息给所有在线用户
var broadcastMessage = new ChatMessage
{
UserId = userId,
Content = clientMessage.Content,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
RoomId = clientMessage.RoomId
};
await BroadcastMessageAsync(broadcastMessage);
}
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
{
// 客户端主动断开连接
Console.WriteLine($"用户 {userId} 主动断开连接");
}
catch (Exception ex)
{
Console.WriteLine($"用户 {userId} 连接异常:{ex.Message}");
}
finally
{
// 6. 注销客户端,广播用户下线消息
_onlineClients.TryRemove(userId, out _);
await BroadcastMessageAsync(new ChatMessage { UserId = "系统", Content = $"{userId} 已下线" });
Console.WriteLine($"用户 {userId} 下线,当前在线人数:{_onlineClients.Count}");
}
}
/// <summary>
/// 广播消息给所有在线用户
/// </summary>
private async Task BroadcastMessageAsync(ChatMessage message)
{
foreach (var client in _onlineClients.Values)
{
try
{
await client.WriteAsync(message);
}
catch (Exception ex)
{
Console.WriteLine($"广播消息失败:{ex.Message}");
}
}
}
}
核心代码拆解:
1.在线客户端管理:用ConcurrentDictionary存储在线客户端,避免多线程冲突;
2.请求头获取用户ID:客户端认证后在请求头传递用户ID,服务端验证;
3.注册与注销客户端:客户端上线时添加到在线列表,下线时移除,用try-finally确保注销;
4.接收客户端消息:用await foreach循环接收客户端发送的消息流;
5.广播消息:把客户端消息发送给所有在线用户;
6.异常处理:捕获客户端主动断开连接的异常(StatusCode.Cancelled),避免日志报错。
步骤2:服务端启动代码(Program.cs)
csharp
using Grpc.Core;
using GrpcDemo.Server;
namespace GrpcDemo.Server;
class Program
{
static async Task Main(string[] args)
{
// 1. 创建gRPC服务器
var server = new Server
{
// 2. 注册聊天服务
Services = { ChatService.BindService(new ChatServiceImpl()) },
// 3. 监听端口,用不安全凭证(生产环境用TLS)
Ports = { new ServerPort("localhost", 50051, ServerCredentials.Insecure) }
};
// 4. 启动服务器
server.Start();
Console.WriteLine("gRPC聊天服务器已启动,端口:50051");
Console.WriteLine("按任意键退出...");
Console.ReadKey();
// 5. 关闭服务器
await server.ShutdownAsync();
}
}
步骤3:客户端实现(Program.cs)
csharp
using Grpc.Core;
using GrpcDemo.Chat;
namespace GrpcDemo.Client;
class Program
{
static async Task Main(string[] args)
{
// 1. 输入用户ID
Console.Write("请输入用户ID:");
string userId = Console.ReadLine();
if (string.IsNullOrEmpty(userId))
{
Console.WriteLine("用户ID不能为空");
return;
}
// 2. 创建gRPC通道
var channel = GrpcChannel.ForAddress("http://localhost:50051");
// 3. 创建聊天客户端
var client = new ChatService.ChatServiceClient(channel);
// 4. 添加请求头(传递用户ID)
var headers = new Metadata();
headers.Add("user-id", userId);
try
{
// 5. 调用双向流RPC
using var call = client.Chat(headers: headers);
// 6. 发送消息的任务
var sendTask = Task.Run(async () =>
{
while (true)
{
string content = Console.ReadLine();
if (content == "exit") break;
// 发送消息给服务器
await call.RequestStream.WriteAsync(new ChatMessage { Content = content });
}
// 通知服务器发送完成
await call.RequestStream.CompleteAsync();
});
// 7. 接收服务器消息的任务
var receiveTask = Task.Run(async () =>
{
await foreach (var message in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"{message.UserId} [{DateTimeOffset.FromUnixTimeSeconds(message.Timestamp).LocalTime:HH:mm:ss}]:{message.Content}");
}
});
// 8. 等待两个任务完成
await Task.WhenAll(sendTask, receiveTask);
}
catch (RpcException ex)
{
Console.WriteLine($"连接异常:{ex.Status.Detail}");
}
finally
{
// 9. 关闭通道
await channel.ShutdownAsync();
}
}
}
核心代码拆解:
1.创建gRPC通道:GrpcChannel是客户端和服务器的连接,默认复用连接;
2.调用双向流RPC:client.Chat()返回AsyncDuplexStreamingCall<ChatMessage, ChatMessage>,包含请求流和响应流;
3.发送消息:用call.RequestStream.WriteAsync()发送消息,CompleteAsync()通知服务器发送完成;
4.接收消息:用await foreach循环接收服务器发送的消息流;
5.任务并行:用Task.Run()启动发送和接收任务,实现同时发送和接收消息;
6.异常处理:捕获连接异常,比如服务器断开连接。
拓展知识:gRPC四种通信模式对比
| 模式 | 核心原理 | 适用场景 |
|---|---|---|
| 简单RPC | 客户端发请求,服务器返回响应 | 普通API调用,比如查询用户信息 |
| 服务器流式RPC | 客户端发请求,服务器返回多个响应 | 实时监控、股票行情、日志推送 |
| 客户端流式RPC | 客户端发多个请求,服务器返回响应 | 批量上传、大数据传输 |
| 双向流式RPC | 客户端和服务器互相发送多个响应 | 聊天系统、实时协作、视频通话 |
四、gRPC拦截器:统一处理日志、认证、限流的“利器”
gRPC拦截器是AOP(面向切面编程)的实现,能统一处理请求和响应,比如日志记录、认证、限流、错误处理,不用在每个方法中重复写代码。
核心原理(大白话+机场安检例子)
把拦截器比作“机场安检”:
1.所有乘客都要经过安检:所有gRPC请求都要经过拦截器;
2.统一处理:安检人员检查身份证、行李,统一处理,不用每个登机口都检查;
3.放行或拦截:安检通过就放行,不通过就拦截。
我踩过的坑:一开始没处理异步日志,导致日志丢失了30%的请求——拦截器必须用异步日志,避免阻塞请求!
实战3:gRPC拦截器实现日志记录与认证(C#)
步骤1:日志拦截器实现(LoggingInterceptor.cs)
csharp
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace GrpcDemo.Server.Interceptors;
public class LoggingInterceptor : Interceptor
{
private readonly ILogger<LoggingInterceptor> _logger;
public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
{
_logger = logger;
}
// 拦截简单RPC请求
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
return await InterceptAsync(request, context, continuation);
}
// 拦截双向流式RPC请求
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, IServerStreamWriter<TResponse> responseStream, ServerCallContext context, DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
var stopwatch = Stopwatch.StartNew();
try
{
_logger.LogInformation("开始处理双向流请求:{Method},用户ID:{UserId}",
context.Method, context.RequestHeaders.GetValue("user-id"));
await continuation(requestStream, responseStream, context);
stopwatch.Stop();
_logger.LogInformation("双向流请求处理完成:{Method},耗时:{Elapsed}ms",
context.Method, stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "双向流请求处理失败:{Method},耗时:{Elapsed}ms",
context.Method, stopwatch.ElapsedMilliseconds);
throw;
}
}
// 通用拦截逻辑(简单RPC、服务器流式、客户端流式)
private async Task<TResponse> InterceptAsync<TRequest, TResponse>(
TRequest request, ServerCallContext context, Func<TRequest, ServerCallContext, Task<TResponse>> continuation)
{
var stopwatch = Stopwatch.StartNew();
try
{
_logger.LogInformation("开始处理请求:{Method},请求:{Request},用户ID:{UserId}",
context.Method, request, context.RequestHeaders.GetValue("user-id"));
var response = await continuation(request, context);
stopwatch.Stop();
_logger.LogInformation("请求处理完成:{Method},耗时:{Elapsed}ms,响应:{Response}",
context.Method, stopwatch.ElapsedMilliseconds, response);
return response;
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "请求处理失败:{Method},耗时:{Elapsed}ms",
context.Method, stopwatch.ElapsedMilliseconds);
throw;
}
}
}
// 扩展方法:获取请求头的值
public static class MetadataExtensions
{
public static string GetValue(this Metadata headers, string key)
{
var entry = headers.FirstOrDefault(h => h.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
return entry?.Value ?? "未知";
}
}
核心代码拆解:
1.继承Interceptor:重写对应方法拦截不同类型的RPC请求;
2.日志记录:记录请求方法、请求内容、用户ID、耗时、响应内容;
3.异步处理:用await调用后续方法,避免阻塞请求;
4.异常处理:捕获异常,记录错误日志,再重新抛出,不影响业务逻辑;
5.扩展方法:简化获取请求头的代码。
步骤2:认证拦截器实现(AuthInterceptor.cs)
csharp
using Grpc.Core;
using Grpc.Core.Interceptors;
namespace GrpcDemo.Server.Interceptors;
public class AuthInterceptor : Interceptor
{
private const string ValidToken = "valid_token_123"; // 生产环境从配置中心获取
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
await AuthenticateAsync(context);
return await continuation(request, context);
}
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream, IServerStreamWriter<TResponse> responseStream, ServerCallContext context, DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
await AuthenticateAsync(context);
await continuation(requestStream, responseStream, context);
}
/// <summary>
/// 认证逻辑:检查Token是否有效
/// </summary>
private async Task AuthenticateAsync(ServerCallContext context)
{
// 1. 从请求头获取Token
var tokenEntry = context.RequestHeaders.FirstOrDefault(h => h.Key.Equals("authorization", StringComparison.OrdinalIgnoreCase));
if (tokenEntry == null || !tokenEntry.Value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "未提供有效Token"));
}
string token = tokenEntry.Value.Substring(7);
// 2. 验证Token(生产环境用JWT验证)
if (token != ValidToken)
{
throw new RpcException(new Status(StatusCode.PermissionDenied, "Token无效"));
}
// 3. 认证通过,记录用户ID(生产环境从Token解析)
context.RequestHeaders.Add("user-id", "user_123");
await Task.CompletedTask;
}
}
核心代码拆解:
1.认证逻辑:从请求头获取Bearer Token,验证Token有效性;
2.异常抛出:认证失败时抛出RpcException,返回对应的gRPC状态码;
3.传递用户ID:认证通过后,把用户ID添加到请求头,方便后续拦截器和服务使用;
4.生产环境优化:用JWT验证Token,从配置中心获取密钥,避免硬编码。
步骤3:注册拦截器到服务端(Program.cs)
csharp
using Grpc.Core;
using GrpcDemo.Server;
using GrpcDemo.Server.Interceptors;
using Microsoft.Extensions.Logging;
namespace GrpcDemo.Server;
class Program
{
static async Task Main(string[] args)
{
// 1. 创建日志工厂
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
});
var logger = loggerFactory.CreateLogger<LoggingInterceptor>();
// 2. 创建拦截器
var loggingInterceptor = new LoggingInterceptor(logger);
var authInterceptor = new AuthInterceptor();
// 3. 创建gRPC服务器
var server = new Server
{
// 4. 注册服务并添加拦截器(顺序:先认证,后日志)
Services = { ChatService.BindService(new ChatServiceImpl()).Intercept(authInterceptor).Intercept(loggingInterceptor) },
Ports = { new ServerPort("localhost", 50051, ServerCredentials.Insecure) }
};
// 5. 启动服务器
server.Start();
Console.WriteLine("gRPC聊天服务器已启动,端口:50051");
Console.WriteLine("按任意键退出...");
Console.ReadKey();
// 6. 关闭服务器
await server.ShutdownAsync();
}
}
核心代码拆解:
1.拦截器顺序:先添加认证拦截器,后添加日志拦截器,确保认证通过后再记录日志;
2.注册方式:用Intercept()方法添加拦截器,支持添加多个;
3.日志工厂:用LoggerFactory创建日志,支持多种日志输出(如Console、File、Elasticsearch)。
拓展知识:gRPC拦截器的类型与适用场景
| 类型 | 适用场景 |
|---|---|
| 客户端拦截器 | 统一添加请求头、记录客户端日志、重试请求 |
| 服务器端拦截器 | 认证、授权、记录服务器日志、限流、错误处理 |
| 全局拦截器 | 所有服务都需要的逻辑,比如日志、认证 |
| 服务级拦截器 | 特定服务需要的逻辑,比如订单服务的限流 |
四、gRPC生产级优化与踩坑总结
-
性能优化技巧
连接复用:gRPC默认复用连接,避免频繁创建连接;
压缩传输:启用Gzip压缩,减少传输体积,比如:
csharp
var channel = GrpcChannel.ForAddress("http://localhost:50051", new GrpcChannelOptions
{
CompressionProviders = new[] { new GzipCompressionProvider(CompressionLevel.Optimal) }
});
流式传输背压:处理大量数据时,用ResponseStream.MoveNext(CancellationToken)控制接收速度,避免内存溢出;
异步处理:所有gRPC方法都用异步,避免阻塞线程;
TLS加密:生产环境用TLS加密,避免数据泄露,比如:
csharp
var server = new Server
{
Ports = { new ServerPort("localhost", 50051, new SslServerCredentials(new[] { new KeyCertificatePair("cert.pem", "key.pem") })) }
};
-
错误处理最佳实践
用gRPC状态码:返回对应的gRPC状态码,比如StatusCode.NotFound(资源不存在)、StatusCode.PermissionDenied(权限不足);
自定义错误信息:在Status.Detail中添加自定义错误信息,方便客户端处理;
避免抛出通用异常:抛出RpcException,不要抛出Exception,避免gRPC返回默认状态码; -
我踩过的坑总结
1.双向流内存泄漏:没处理客户端断开连接,导致服务器内存泄漏——用try-finally注销客户端;
2.拦截器顺序错误:先加日志拦截器,后加认证拦截器,导致日志记录了未认证的请求——先加认证拦截器;
3.Protobuf字段标签错误:改了字段标签,导致旧版本客户端解析失败——标签不能改,只能新增;
4.未启用压缩:传输大量数据时体积大,延迟高——启用Gzip压缩;
5.同步处理阻塞线程:用同步方法处理请求,导致服务器线程耗尽——所有方法都用异步。
五、总结
gRPC是高性能RPC框架,基于HTTP/2和Protobuf,适合实时通信、微服务内部调用、高性能API场景。Protocol Buffers是gRPC性能的核心,比JSON快5倍,体积小35%;双向流式RPC是实时通信的神器,适合聊天系统、实时监控;拦截器能统一处理日志、认证、限流,减少重复代码。
下一节我们会学习gRPC-Web:让浏览器也能调用gRPC服务,解决gRPC不支持浏览器的问题。
转载请注明出处:https://www.xin3721.com/ArticlecSharp/c49569.html










