为什么选择 gRPC

历史

长久以来,我们在前后端交互时使用WebApi + JSON方式,后端服务之间调用同样如此(或者更久远之前的WCF + XML方式)。WebApi + JSON 是优选的,很重要的一点是它们两者都是平台无关的三方标准,且足够语义化,便于程序员使用,在异构(前后端、多语言后端)交互场景下是不二选择。然而,在后端服务体系改进特别是后来微服务兴起后,我们发现,前后端交互理所当然认可的 WebApi + JSON 在后端体系内显得有点不太合适:

  1. JSON 字符编码方式使得传输数据量较大,而后端一般并不需要直接操作 JSON,都会将 JSON 转为平台专有类型后再处理;既然需要转换,为什么不选择一个数据量更小,转换更方便的格式呢?
  2. 调用双方要事先约定数据结构和调用接口,稍有变动就要手动更新相关代码(Model 类和方法签名);是否可以将约定固化为文档,服务提供者维护该文档,调用方根据该文档可以方便地生成自己需要的代码,在文档变化时代码也可以自动更新?
  3. [之前] WebApi 基于的 Http[1.1] 协议已经诞生 20 多年,其定义的交互模式在今日已经捉襟见肘;业界需要一个更有效率的协议。

高效传输-Http2.0

我们先来说第 3 个问题,其实很多大厂内部早已开始着手处理,并诞生了一些应用广泛的框架,如阿里开源的Dubbo,直接抛弃了 Http 改为基于 Tcp 实现,效率得到明显提升,不过 Dubbo 依赖 Java 环境,无法跨平台使用,不在我们考虑范围。

另一个大厂 Google,内部也在长期使用自研的Stubby框架,与 Dubbo 不同的是,Studdy 是跨平台的,但是 Google 认为 Studdy 不基于任何标准,而且与其的内部基础设施紧密耦合,并不适合公开发布。

同时 Google 也在对 Http1.1 协议进行增强,该项目是 2012 年提出的 SPDY 方案,其优化了 Http 协议层,新增的功能包括数据流的多路复用、请求优先级以及HTTP报头压缩。Google 表示,引入 SPDY 协议后,在实验室测试中页面加载速度比原先快 64%。巨大的提升让大家开始从正面看待和解决老版本 Http 协议的问题,这也直接加速了 Http2.0 的诞生。实际上,Http2.0 是以 SPDY 为原型进行讨论和标准化的,当然也做了更多的改进和调整。

随着 Http2.0 的出现和普及,许多与 Stubby 相同的功能已经出现在公共标准中,包括 Stubby 未提供的其他功能。很明显,是时候重做 Stubby 以利用这种标准化,并将其适用范围扩展到分布式计算的最后一英里,支持移动设备(如安卓)、物联网(IOT)、和浏览器连接到后端服务。

2015 年 3 月,Google决定在公开场合构建下一版 Stubby,以便与业界分享经验,并进行相关合作,也就是本文的主角gRPC

高效编码-protobuf

回头来看第 1 个问题,解决起来相对比较简单,无非是将傻瓜式字符编码转为更有效的二进制编码(比如数字 10000 JSON 编码后是 5 个字节,按整型编码就是 4 个字节),同时加上些事先约定的编码算法使得最终结果更紧凑。常见的平台无关的编码格式有MessagePackprotobuf等,我们以 protobuf 为例。

protobuf 采用 varint 和 处理负数的 ZigZag 两种编码方式使得数值字段占用空间大大减少;同时它约定了字段类型和标识,采用 TLV 方式,将字段名映射为小范围结果集中的一项(比如对于不超过 256 个字段的数据体来说,不管字段名本身的长度多少,每个字段名都只要 1 个字节就能标识),同时移除了分隔符,并且可以过滤空字段(若字段没有被赋值,那么该字段不会出现在序列化结果中)。

高效编程-代码生成工具

第 2 个问题呢,其实需要的就是[每个平台]一套代码生成工具。生成的代码需要覆盖类的定义、对象的序列化/反序列化、服务接口的暴露和远程调用等等必要的模板代码,如此,开发人员只需要负责接口文档的维护和业务代码的实现(很自然的面向接口编程:))。此时,采用 protobuf 的gRPC自然而然的映入眼帘,因为对于目前所有主要的编程语言和平台,都有 gRPC 工具和库,包括 .NET、Java、Python、Go、C++、Node.js、Swift、Dart、Ruby 以及 PHP。可以说,这些工具和库的提供,使得 gRPC 可以跨多种语言和平台一致地工作,成为一个全面的 RPC 解决方案。

gRPC 在 .NET 中的使用

ASP.NET Core 3.0 开始,支持gRPC作为 .NET 平台中的“一等公民”。

服务端

在 VS 中新建ASP.NET Core gRPC 服务,会发现在项目文件中自动引入了Microsoft.NET.Sdk.Web类库,很明显,gRPC 服务仍然是 Web 服务,毕竟它走的是 Http 协议。同时还引入了Grpc.AspNetCore类库,该类库引用了几个子类库需要了解下:

  • Google.Protobuf:包含 protobuf 预定义 message 类型在 C# 中的实现;
  • Grpc.Tools:上面讲到的代码生成工具,编译时使用,运行时不需要,因此依赖项标记为PrivateAssets="All"
  • Grpc.AspNetCore.Server:服务端专用;
  • Grpc.Net.ClientFactory:客户端专用,如果只是提供服务的话,那么该类库可以移除。

定义接口文件:

syntax = "proto3";

// 指定自动生成的类所在的命名空间,如果不指定则以下面的 package 为命名空间,这主要便于本项目内部的模块划分
option csharp_namespace = "Demo.Grpc"; // 对外提供服务的命名空间
package TestDemo; // 服务
service Greeter {
// 接口
rpc SayHello (HelloRequest) returns (HelloReply);
} // 不太好的一点是就算只有一个基础类型字段,也要新建一个 message 进行包装
message HelloRequest {
string name = 1;
} message HelloReply {
string message = 1;
}

然后把它包含到项目文件中:

<ItemGroup>
<Protobuf Include="Protos\greeter.proto" GrpcServices="Server" />
</ItemGroup>

编译一下,Grpc.Tools 将帮我们生成 GreeterBase 类及两个模型类:

public abstract partial class GreeterBase
{
public virtual Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
throw new RpcException(new Status(StatusCode.Unimplemented, ""));
}
} public class HelloRequest
{
public string Name { get; set; }
} public class HelloReply
{
public string Message { get; set; }
}

这里的 SayHello 是个空实现,我们新建一个实现类并填充业务逻辑,比如:

public class GreeterService : GreeterBase
{
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" });
}
}

最后将服务添加到路由管道,对外暴露:

using Demo.Grpc.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddGrpc(); var app = builder.Build(); // Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>(); app.Run();

protobuf-net.Grpc

如果觉得写 .proto 文件太别扭,希望可以按传统方式写接口,那么社区项目protobuf-net.Grpc值得尝试,使用它可以它通过特性批注的 .NET 类型来定义应用的 gRPC 服务和消息。

首先我们不需要再引用 Grpc.AspNetCore,而是改为引用 protobuf-net.Grpc 库。同样也不需要写 .proto 文件,而是直接写接口类:

using ProtoBuf.Grpc;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading.Tasks; namespace Demo.Grpc; [DataContract]
public class HelloReply
{
[DataMember(Order = 1)]
public string Message { get; set; }
} [DataContract]
public class HelloRequest
{
[DataMember(Order = 1)]
public string Name { get; set; }
} [ServiceContract(Name = "TestDemo.GreeterService")]
public interface IGreeterService
{
[OperationContract]
Task<HelloReply> SayHelloAsync(HelloRequest request, CallContext context = default);
}

注意其中特性的修饰。

写完实现类后,在 Program.cs 中注册即可,此处不再赘述。

使用 protobuf-net.Grpc,我们不需要写 .proto 文件,但是调用方特别是其它平台的调用方,需要 .proto 文件来生成相应的客户端,难道我们还要另外再写一份吗?别急,我们可以引入protobuf-net.Grpc.AspNetCore.Reflection,它引用的protobuf-net.Grpc.Reflection提供了根据 C# 接口生成 .proto 文件的方法;同时使用它还便于客户端测试,同Grpc.AspNetCore.Server.Reflection的作用一样,下文会讲到。

异常处理

.Net 为 gRPC 提供了拦截器机制,可新建一个拦截器统一处理业务异常,比如:

public class GrpcGlobalExceptionInterceptor : Interceptor
{
private readonly ILogger<GrpcGlobalExceptionInterceptor> _logger; public GrpcGlobalExceptionInterceptor(ILogger<GrpcGlobalExceptionInterceptor> logger)
{
_logger = logger;
} public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
return await continuation(request, context);
}
catch (Exception ex)
{
_logger.LogError(new EventId(ex.HResult), ex, ex.Message); // do something // then you can choose throw the exception again
throw ex;
}
}
}

上述代码在处理完异常后重新抛出,旨在让客户端接收处理该异常,然而,实际上客户端是无法接收到该异常信息的,除非服务端抛出的是RpcException;同时,为使客户端得到正确的 HttpStatusCode(默认是 200,即使客户端得到是 RpcException),需要显式给HttpContext.Response.StatusCode赋值,如下:

// ...

catch(Exception ex)
{
var httpContext = context.GetHttpContext();
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
             
// 注意,RpcException 的 StatusCode 和 Http 的 StatusCode 不是一一对应的
throw new RpcException(new Status(StatusCode.XXX, "some messages"));
} // ...

我们可以在构造 RpcException 对象时传递Metadata,用于携带额外的数据到客户端,如果需要传递复杂对象,那么要先按约定序列化成字节数组。

拦截器逻辑完成后,需要在服务注入时设置如下:

builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<GrpcGlobalExceptionInterceptor>();
});

测试

服务端完成后,如果要借助Postman或者gRPCurl测试,那么它们其实就是调用服务的客户端,要让它们事先知道服务约定信息,有两种方法:

  1. 给它们提供 .proto 文件,这个很好理解,关于服务的所有信息就定义在 .proto 文件中;
  2. 服务端暴露一个可以获取服务信息的接口。

如果要用方法 2,那么要先引入Grpc.AspNetCore.Server.Reflection类库,然后在 Program.cs 中注册接口:

// ...
builder.Services.AddGrpcReflection(); var app = builder.Build(); // ... IWebHostEnvironment env = app.Environment; if (env.IsDevelopment())
{
app.MapGrpcReflectionService();
}

客户端

客户端不需要 Grpc.AspNetCore.Server,所以我们直接引用 Google.Protobuf、Grpc.Tools、Grpc.Net.ClientFactory。

将服务端提供的 .proto 文件添加到项目中,并在项目文件中包含:

<ItemGroup>
<Protobuf Include="Protos\greeter.proto" GrpcServices="Client" />
</ItemGroup>

注意,如果只需要服务端提供的部分接口,那么 .proto 文件中只保留必要的接口即可,真正做到按需索取:)。

我们还可以更改 .proto 文件中 message 的字段名(只要不改动字段类型和顺序),不会影响服务的调用。这也直接反映了 protobuf 不是按字段名而是事先定义的字段标识编码的。

由此,假如我们有多个 .proto 文件,使用到了相同结构的 message,无所谓字段名是否相同,我们都可以将这些 message 抽离为单独的一个 .proto 文件,然后其它的 .proto 文件使用import "Protos/xxx.proto";引入它。

编译一下,然后在 Program.cs 中注册服务客户端:

// .proto 文件中的 package
using TestDemo; // 这里注入的服务是 Transient 模式
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://localhost:5001");
});

如此,其它地方就可以愉快地使用客户端调用远程服务了。

如果服务端返回上文提到的 RpcException,客户端可以通过 RpcException.Trailers 获取异常的 metadata 数据。同服务端一样,我们可以给客户端配置统一的异常处理器(或其它拦截器)。

builder.Services
.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://localhost:5001");
})
.AddInterceptor<ExceptionInterceptor>(); // 默认创建一次,并在 GreeterClient 实例之间共享
//.AddInterceptor<ExceptionInterceptor>(InterceptorScope.Client); // 每个 GreeterClient 实例拥有自己的拦截器

当然,如果项目是普通的 ASP.NET Core Web 服务,那么使用原先的 ActionFilterAttributeIExceptionFilter等拦截器也是一样的。

进阶知识

本文未涉及的 .NET-gRPC 的进阶知识诸如单元测试服务调用中止负载均衡健康监控等,以后有机会再与大家分享。其实这方面微软官方文档已经讲解得相当全面了,但也难以覆盖在实操过程中遇到的所有问题,所以有此文以飨读者,还望不吝指教。

参考资料

HTTP 2.0的前世今生

.NET性能优化-是时候换个序列化协议了

MessagePack简析

Varint编码

Protobuf 标量数据类型

gRPC入门与实操(.NET篇)的更多相关文章

  1. ESP8266 NodeMCU小白手把手入门(实操篇)以土壤湿度和DHT传感器为例讲解读取传感器的值

    物联网使得现实世界中的实体和数字世界比以往任何时候都更紧密地联系在一起.NodeMCU作为其中的一个重要设备,作用之一就是与传感器相连以实现万物互联通讯.这篇关于NodeMCU的实操篇以土壤湿度传感器 ...

  2. 汇编初入门debug实操

    修改cs:ip的值 jmp 段地址:偏移地址 //在汇编指令中用,不是在debug上用的 如 jmp 2AE3:3 //执行后CS=2AE3H ip=0003H 若只修改IP内容 jmp 某一个合法的 ...

  3. ABP入门系列(1)——学习Abp框架之实操演练

    作为.Net工地搬砖长工一名,一直致力于挖坑(Bug)填坑(Debug),但技术却不见长进.也曾热情于新技术的学习,憧憬过成为技术大拿.从前端到后端,从bootstrap到javascript,从py ...

  4. Kafka集群优化篇-调整broker的堆内存(heap)案例实操

    Kafka集群优化篇-调整broker的堆内存(heap)案例实操 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 一.查看kafka集群的broker的堆内存使用情况 1>. ...

  5. HDFS集群PB级数据迁移方案-DistCp生产环境实操篇

    HDFS集群PB级数据迁移方案-DistCp生产环境实操篇 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 用了接近2个星期的时间,终于把公司的需要的大数据组建部署完毕了,当然,在部 ...

  6. 大数据学习笔记——Linux完整部署篇(实操部分)

    Linux环境搭建完整操作流程(包含mysql的安装步骤) 从现在开始,就正式进入到大数据学习的前置工作了,即Linux的学习以及安装,作为运行大数据框架的基础环境,Linux操作系统的重要性自然不言 ...

  7. 跟我一起学Go系列:gRPC 入门必备

    RPC 的定义这里就不再说,看文章的同学都是成熟的开发.gRPC 是 Google 开源的高性能跨语言的 RPC 方案,该框架的作者 Louis Ryan 阐述了设计这款框架的动机,有兴趣的同学可以看 ...

  8. 《5天学会卡西欧fx-5800p之实操视频教程(初级)》目录和我的工作室现场曝光

    很多人给我讲,想让我录制一份卡西欧fx-5800p的视频教程,我也一直在准备,准备了半年,录制视频真的不是件容易的事,条件有限,而且工作也很忙,中途还会有想放弃的念头,真的是花费了我很多的心血,但不管 ...

  9. css知识笔记:水平垂直居中(别只看,请实操!!!)

    css实现元素的水平垂直居中. (尝试采用5W2H方法说明): 别只看,请实操!!! What: 1.这篇文档主要描述元素水平方向居中的几种最常见和最实用的几种方式,并说明优缺点. 2.写这篇文章的目 ...

  10. css知识笔记:垂直居中(别只看,请实操!!!)

    css实现元素的垂直居中. (尝试采用5W2H方法说明): 别只看,请实操!!! What: 1.这篇文档主要描述元素水平方向居中的几种最常见和最实用的几种方式,并说明优缺点. 2.写这篇文章的目的, ...

随机推荐

  1. Codeforces Round #804 (Div. 2) C(组合 + mex)

    Codeforces Round #804 (Div. 2) C(组合 + mex) 本萌新的第一篇题解qwq 题目链接: 传送门QAQ 题意: 给定一个\(\left [0,n-1 \right ] ...

  2. SQL语句编写的练习(MySQL)

    SQL语句编写的练习(MySQL) 一.建表 1.学生表(Student) 学号 | 姓名 | 性别 | 出生年月 | 所在班级 create table Student( sno varchar(2 ...

  3. 解决在vue中设置的height: 100%没有效果

    在新的页面设置height无效果的时候.需要改动App这个文件的heigth 解决办法.给app这个盒子设置高度.默认情况下为0 设置高度100%时,div的高度会等同于其父元素的高度.而上面中id为 ...

  4. 常用Linux命令(常年更新)

    Linux后台运行脚本: nohup python -u test.py > out.log 2>&1 & nohup sh **.sh > /dev/null 2& ...

  5. Codeforces 1670 E. Hemose on the Tree

    题意 给你个数p,n = 2^p: 有一棵树有n个节点,告诉你怎么连边: 每个点有个权值,每条边也有个权值,权值需要自行分配,[1,2,3..n...2n-1],总共2n-1个权值: 你需要选一个节点 ...

  6. Vue 实现小小记事本

    1.实现效果 用户输入后按回车,输入的内容自动保存,下方会显示记录的条数,鼠标移动到文字所在div上,会显示删除按钮,点击按钮,相应记录会被删除,下方的记录条数会相应变化,点击clear,所有记录会被 ...

  7. 纯css爱心代码-最近超级火的打火机与公主裙中的爱心代码(简易版)

    theme: cyanosis 最近打火机与公主裙中的爱心代码超级火,看着特别心动,让俺用css来写个简易版!!! 先看效果: 代码拆解: 主要是分为3大部分 分子颗粒 爱心 动画 代码实现: 分子颗 ...

  8. Karmada大规模测试报告发布:突破100倍集群规模

    摘要:在本文中,我们将介绍用于测试的相关指标,如何进行大规模测试,以及我们如何实现大规模的集群接入. 本文分享自华为云社区<突破100倍集群规模!Karmada大规模测试报告发布>,作者: ...

  9. 一次SpringBoot版本升级,引发的血案

    前言 最近项目组升级了SpringBoot版本,由之前的2.0.4升级到最新版本2.7.5,却引出了一个大Bug. 到底是怎么回事呢? 1.案发现场 有一天,项目组的同事反馈给我说,我之前有个接口在新 ...

  10. Go语言核心36讲10

    我们在上次讨论了数组和切片,当我们提到数组的时候,往往会想起链表.那么Go语言的链表是什么样的呢? Go语言的链表实现在标准库的container/list代码包中.这个代码包中有两个公开的程序实体- ...