zoukankan      html  css  js  c++  java
  • gRPC:在ASP.NET Core上的基本应用

    gRPC是Google基于HTTP/2和protobuf推出的一款也是当下热门的开源RPC(Remote Procedure Call)框架。可在程序或者服务之间进行高性能低带宽的通信,并且支持身份认证、日志系统等等需要用到的功能。在微服务作为主流的时代,各个服务之间的通信也是一个亟需解决的问题。在ASP.NET Core 3.x下,gRPC也是微软传统RPC框架WCF的有效替代。

    使用gRPC,可以让客户端像调用本地方法一样地去调用服务端中的方法。gRPC是一种合约优先的API开发模式,就是我们需要先具体地定义好方法和参数后,再进行服务端功能开发和客户端调用。并且客户端和服务端可以是使用不同语言开发的程序,通过gRPC,一旦我们在自己的服务中定义了proto文件,任何其他gRPC支持的语言开发的程序都可以来调用这个通信,通信中涉及到的环境、序列化等gRPC都帮我们完成了。默认情况下,gRPC是使用Protocol Buffers作为其接口定义语言(Interface Definition Language),就是用来定义通信中要用的方法和参数。Protocol Buffers不依赖特定的语言,根据不同需求,编译器就可以将其转换生成C#、Java、Python、Go等十几种语言供我们开发使用,并且在通信中数据是序列化成二进制流的,从而获得更好的传输性能。

    本文接下来简单介绍Protocol Buffers和gRPC在.NET Core中的基本用法,主要参考为官方文档和各位大佬的教程(文末有链接)。本文Demo已上传至☞GitHub

    那么先来简单介绍一下Protocol Buffers的语法。

    Protocol Buffers 基本用法

    ○ 文件名后缀用 ".proto"
    ○ 别忘记在文首加上一句“syntax = "proto3",来指明使用的是proto3的语法(因为之前还有一个proto2)
    ○ 通过在proto文件中定义message类型来指明你想序列化传输的对象,可以类比成一个类其中包含你需要的多个字段。比如定义一个叫Person的message,其中包含3个字段。

    1 message Person {
    2   string name = 1;
    3   int32 id = 2;
    4   bool has_ponycopter = 3;
    5 }

    ○ 简单介绍下Protocol Buffer中常用的数据类型
      § 数值:double, float, int32, int64, uint32, uint64, sint32, sint64, fixed32, fixed64, sfixed32, sfixed64
      § 字符串:string
      § 布尔:bool
      § 字节:bytes ,最大长度232
      § 枚举:enum
        □ 定义枚举的方法,另外支持使用别名,别名是用不同的名称表示同一个枚举值,如下面的EnumAllowingAlias.STARTED和EnumAllowingAlias.RUNNING表示的是同一个枚举值。

    1 enum EnumAllowingAlias {
    2     option allow_alias = true;  //表示可以使用别名
    3     UNKNOWN = 0;     //枚举的编号是从0开始的
    4     STARTED = 1;
    5     RUNNING = 1;
    6 }

    ○ 定义message时,注意到每个字段都加了一个“唯一标识”的编号,这些编号用来在message转成二进制形式后标识具体字段是啥,在投入使用后尽可能避免修改字段的编号。编号范围是1~229-1其中编号1~15的字段使用一个字节来编码,编号16~2047则使用两个字节。所以将使用频率高的字段用1~15来编号,另外19000~19999是Protocol Buffers的保留字段,最好别用。
    ○ 字段的规则分为两种,singular(proto3中默认)和repeated,定义字段时二选一
      § singular
        大概是表示为单值,这个字段的值最多一个,与repeated相对
      § repeated
        大概像是集合类型,比如定义一个字段如“repeated string emails”大概意思可理解为List<string> emails
    ○ 注释的写法和C#文件中注释的写法基本一致,用“//”或者“/* … */”
    ○ 保留字段。如果一个已投入使用的proto中移除了某些字段的话,而用户仍然使用这些字段编号就会造成一些较严重的错误。解决办法是将这些想移除的字段名或者字段编号使用reserved关键字修饰。使用了reserved标注的字段在未来使用时,protocol buffer编译器将会抛出错误。

    1 message Foo {
    2   reserved 2, 15, 9 to 11;
    3   reserved "foo", "bar";
    4 }

    ○ 当使用protocol buffer编译器将proto文件编译成C#语言,会自动生成.cs文件,其中为每个message编译成class。
    ○ 编译后的类型与C#中类型的对应关系

    proto类型 double float int32 int64 string bool bytes enum
    C#类型 double float int long string bool ByteString enum
    默认值 0 0 0 0 string.Empty false 空字节数组 枚举中的第一个值

    ○ 定义完message后,可以将其打包供其他service或者message引用,那么在protocol buffer中打包和引用的方法也很简单
      § 打包语法:

    package foo.bar;
    message Open { ... } 

      § 指定生成自定义的C#命名空间的语法:

    option csharp_namespace = "Foo.MyBar";

      § 引用其他proto文件中定义的message类型的语法:

    import "myproject/other_protos.proto";

    ○ 有了message的定义,要在RPC中应用的话就需要定义“方法”,在protocol buffer中即是service。在.proto文件中定义service的语法是:

    service SearchService {
      rpc Search (SearchRequest) returns (SearchResponse);
    }

    ○ 其中service和rpc都是关键字,Search是“方法”名,SearchRequest与SearchResponse都是定义的message类型。
    ○ 定义好service后,proto编译器就会将service使用我们选择的语言编译成的服务接口的代码。
    ○ Protocol buffers的一些其他关键字如any,oneof等暂时没用上,就先不列出了,可参考官方文档

    gRPC Demo实践

    IDE使用的VS2019,首先使用ASP.NET Core建立一个gRPC的服务端,使用一个WPF程序作为客户端实现最基本的gRPC通信Demo,以一个员工信息的增查为例来演示gRPC中的常用场景。
    gRPC 通常有四种模式的通信,分别是“一元(unary)”,“客户端流(client streaming)”,“服务端流(server streaming)” 以及“双向流模式( bidirectional streaming)”,对于 HTTP 2 来说其实都是用流的模式,以下实验是参考杨旭大佬的教程

    首先创建gRPC服务端,新建一个空白的ASP.NET Core Web应用程序命名为gRPC.Server。用NuGet安装“Grpc.AspNetCore”。新建一个Protos文件夹来存放.proto文件,新建一个名为Message.proto的文件来定义通信过程中需要的message。

     1 syntax = "proto3";    //给编译器指明语法为proto3
     2 
     3 //员工
     4 message Employee{
     5     int32 Id = 1;                //Id
     6     string Name = 2;                //姓名
     7     int32 EmployeeNo = 3;        //工号
     8     Gender Gender = 4;            //性别
     9     Date BirthDay = 5;            //生日
    10     string Department = 6;        //部门
    11     bool IsValid = 7;            //有效性
    12     bytes Photo = 8;            //照片
    13 }
    14 //性别(枚举)
    15 enum Gender{
    16     NOT_SPESIFICED = 0;
    17     FEMALE = 1;
    18     MALE = 2;
    19 }
    20 //日期
    21 message Date{
    22     int32 Year = 1;
    23     int32 Month = 2;
    24     int32 Day = 3;
    25 }
    26 
    27 //Service 用的参数:
    28 //根据Id查询员工信息
    29 message GetEmployeeByIdRequest{    
    30     int32 Id = 1;
    31 }
    32 //上传的员工信息请求
    33 message EmployeeRequest{
    34     Employee Employee = 1;
    35 }
    36 //返回员工信息
    37 message EmployeeResponse{
    38     Employee Employee = 1;
    39 }
    40 //根据条件查询员工
    41 message GetEmployeeCollectionRequest{
    42     string SearchTerm = 1;
    43     bool IsValid = 2;
    44 }
    45 //返回员工信息集合
    46 message GetEmployeeCollectionReponse{
    47     Employee Employee = 1;
    48 }
    49 //上传员工照片
    50 message AddPhotoRequest{
    51     bytes Photo = 1;
    52 }
    53 //上传员工照片响应
    54 message AddPhotoReponse{
    55     bool IsOK = 1;
    56 }
    message.proto

     接着新建定义service的proto文件,其中定义了5个方法,包括了gRPC的四种通行模式。

     1 syntax = "proto3";
     2 import "Message.proto";
     3 
     4 service EmployeeService{
     5     //根据ID获取员工(一元消息)
     6     rpc GetEmployeeById(GetEmployeeByIdRequest) returns (EmployeeResponse);
     7     //上传员工信息(一元消息)
     8     rpc SaveEmployee(EmployeeRequest) returns (EmployeeResponse);
     9     //根据条件获取全部员工(服务端流)
    10     rpc GetEmployeeCollection(GetEmployeeCollectionRequest) returns (stream GetEmployeeCollectionReponse);
    11     //员工上传照片(客户端流)
    12     rpc AddPhoto(stream AddPhotoRequest) returns (AddPhotoReponse);
    13     //(双向流)
    14     rpc SaveEmployees(stream EmployeeRequest) returns (stream EmployeeResponse);
    15 }

    定义好proto文件之后便可以在VS中编译项目,在编译前,需要为两个proto文件配置好属性。在message.proto上右键属性,build action选择protobuf compiler,gRPC Stub Classes选择“Do not generate”。类似的对Service.proto文件build action选择protobuf compiler,gRPC Stub Classes选择“Server only”。

    编译好之后可以发现编译器帮我们生成了两个同名的cs文件,包含生成了一个 EmployeeServiceBase 类。将我们使用Protocol Buffer定义的message和service生成相应的class和method。此时,在服务端的通信接口相当于就已经定义好了。客户端若需要与之通信就需要通过这个接口的定义来发送请求。接下来新建一个客户端程序(本文这里选用了一个WPF程序),在NuGet上添加需要用到的三个包,分别是“Google.Protobuf”,“Grpc.Tools”,“Grpc.Net.Client”。接下来通过强大的VS可以快速获得服务端定义的接口信息。在项目上右键,选择“添加”→选择“服务引用”→选择“添加新的gRPC引用”,可以选择在服务端定义好的两个proto文件,在选择文件界面的下方的选项将message.proto选择生成为“仅限消息”,service.proto选择生成为“客户端”便完成了添加操作,这时客户端项目中会多出一个Protos文件夹,刚刚添加的两个文件也在其中。编译后编译器依然会为我们生成两个同名的cs文件,包含生成了一个 EmployeeServiceClient 类。

    在定义好了通信接口之后,只需要在客户端和服务端实现具体的接口业务便可以完成通信了。

    (1)Simple RPC

    首先看下最简单的调用方式即一元模式,该演示用的方法 GetEmployeeById 从客户端获取单个参数并返回相应的员工信息,为了简单起见这里的数据就不涉及数据库操作了使用一个静态的 List<Employee> 。

    由于已经定好了通信的接口,服务端只需实现服务端定义的接口方法即可,创建一个Service类来继承从Service.proto生成的 EmployeeServiceBase 类型,命名为 GrpcEmployeeService ,我们通过override EmployeeServiceBase 中的抽象方法来实现具体业务逻辑。编译器生成的方法签名除了我们在proto文件中定义的请求参数类型 GetEmployeeByIdRequest 外,另外还有一个 ServerCallContext 类型的上下文参数,通过它便可以操作通信过程中的Header和HttpStatus等。

     1 public class GrpcEmployeeService : EmployeeService.EmployeeServiceBase  //继承
     2 {
     3     /// <summary>
     4     /// 一元操作演示 —— 根据id获取员工数据
     5     /// </summary>
     6     /// <param name="request"></param>
     7     /// <param name="context"></param>
     8     /// <returns></returns>
     9     public override async Task<EmployeeResponse> GetEmployeeById(GetEmployeeByIdRequest request,
    10         ServerCallContext context)
    11     {
    12         //读取请求头中的元数据(应用层自定义的 key-value 对)
    13         var metaDataIdHeaders = context.RequestHeaders;
    14         foreach (var data in metaDataIdHeaders)
    15         {
    16             Console.WriteLine($"{data.Key} => {data.Value}");
    17         }
    18 
    19         //根据请求的Id找到员工信息
    20         var employee = EmployeeRepository.Emloyees.SingleOrDefault(emp => emp.Id == request.Id);
    21 
    22         if (employee == null)
    23             throw new RpcException(Status.DefaultSuccess
    24                 , $"Employee of {request.Id} is not found");
    25 
    26         var response = new EmployeeResponse {Employee = employee};
    27         return await Task.FromResult(response);
    28     }
    29 }

    一旦客户端调用了存根(Stab,客户端上接口方法),服务端的RPC方法便会被调用并收到客户端发送的参数与元数据信息。但是服务端怎样将客户端的请求定位到接口定义的实现呢?来到服务端项目的Startup.cs中指定映射即可。

     1 public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
     2 {
     3     if (env.IsDevelopment())
     4     {
     5         app.UseDeveloperExceptionPage();
     6     }
     7     app.UseRouting();
     8 
     9     app.UseEndpoints(endpoints =>
    10     {
    11         endpoints.MapGrpcService<GrpcEmployeeService>(); //将进入的请求映射到特定的服务类中
    12     });
    13 }

    另外还需要将gRPC的服务注册到服务容器中(在ConfigureServices中增加 services.AddGrpc(); )

    接下来在客户端调用方法存根即可。

     1 private const string serverAdderss = "https://localhost:5001";  //服务端的地址
     2 protected void GetEmployeeById()
     3 {
     4     Response1 = string.Empty;   //清空前台显示
     5     var metaData = new Metadata   //元数据都是一些 key-value对
     6     {
     7         { "myKey","myValue"}    //随便假装一点 key-value对
     8     };
     9 
    10     if (int.TryParse(Request1, out var id))
    11     {
    12         //*****************************主要是这里********************************
    13         using var channel = GrpcChannel.ForAddress(serverAdderss);          //创建通道
    14         var client = new EmployeeService.EmployeeServiceClient(channel);
    15         var response = client.GetEmployeeById(
    16             new GetEmployeeByIdRequest { Id = id }  //参数一:request参数(员工Id)
    17             , metaData);                            //参数二:用户自定义的元数据
    18         //*********************************************************************
    19 
    20         Response1 = response.ToString();    //将响应信息输出前台显示
    21         return;
    22     }
    23     MessageBox.Show("request is unValid");
    24 }

    (2)Server-side streaming RPC

    与一元模式不同的是,服务端流模式中服务端向客户端返回数据是一个流响应,编译器帮我们生成的服务端的方法 GetEmployeeCollection 中包含 IServerStreamWriter<GetEmployeeCollectionReponse> 类型的参数,我们需要做的就是将需要返回的数据写入这个流中即可。该演示方法是客户端使用一些查询条件向服务端请求用户数据,服务端将员工集合数据以“流”的模式返回给客户端。

     1 /// <summary>
     2 /// 服务端流演示 —— 根据条件获取员工数据
     3 /// </summary>
     4 /// <param name="request"></param>
     5 /// <param name="responseStream"></param>
     6 /// <param name="context"></param>
     7 /// <returns></returns>
     8 public override async Task GetEmployeeCollection(GetEmployeeCollectionRequest request, IServerStreamWriter<GetEmployeeCollectionReponse> responseStream,
     9     ServerCallContext context)
    10 {
    11     List<Employee> employees;
    12     if (!string.IsNullOrWhiteSpace(request.SearchTerm))  //有条件就根据条件查询
    13     {
    14         employees = EmployeeRepository.Emloyees
    15               .FindAll(emp => emp.Name.Contains(request.SearchTerm) ||
    16                               emp.Department.Contains(request.SearchTerm) ||
    17                               emp.EmployeeNo.ToString().Contains(request.SearchTerm));
    18     }
    19     else
    20     {
    21         employees = EmployeeRepository.Emloyees;
    22     }
    23     employees = employees.FindAll(emp => emp.IsValid == request.IsValid);
    24 
    25     foreach (var employee in employees)
    26     {
    27         //***********************************向响应流中写入数据**************************************
    28         await responseStream.WriteAsync(new GetEmployeeCollectionReponse { Employee = employee });
    29         //****************************************************************************************
    30     }
    31 }

    在客户端使用存根方法进行RPC请求,并从响应流中读取返回的员工数据。

     1 protected async void GetEmployeeCollection()
     2 {
     3     Response2 = string.Empty;       //清空前台显示
     4     using var channel = GrpcChannel.ForAddress(serverAdderss);
     5     var client = new EmployeeService.EmployeeServiceClient(channel);
     6 
     7     //发送请求,注意和一元模式不同的是,使用client调用存根方法的返回类型是AsyncServerStreamingCall
     8     using var serverStreamingCall =
     9         client.GetEmployeeCollection(
    10         new GetEmployeeCollectionRequest
    11         {   //两个查询参数而已,没啥
    12             IsValid = true,         
    13             SearchTerm = Request2.Trim()
    14         });
    15     var responseStream = serverStreamingCall.ResponseStream;
    16 
    17     //读取流数据,调用响应流的MoveNext方法
    18     while (await responseStream.MoveNext(new CancellationToken()))
    19     {
    20         // 将消息显示到前端
    21         Response2 += responseStream.Current.Employee + Environment.NewLine;
    22     }
    23 }

    (3)Client-side streaming RPC

     客户端流模式的话与服务端流模式类似,服务端流模式中是将响应数据写入响应流中,客户端流模式相似的就是将请求数据写入请求流中发送到服务端,这样发送到服务端的就不是单个的请求了,服务端接收请求流的数据也需要向上面“服务端流模式”的客户端那样利用stream的 MoveNext 方法来获取。本方法是将一张图片读取成文件流后以1024个字节的大小依次写入请求流中发送给服务端来模拟客户端流模式。

    由于是客户端流模式,那先看客户端的写法。

     1 protected async void AddPhoto()
     2 {
     3     Response3 = string.Empty;       //清空前台显示
     4     using var channel = GrpcChannel.ForAddress(serverAdderss);
     5     var client = new EmployeeService.EmployeeServiceClient(channel);
     6     // 调用这个存根方法得到的是“AsyncClientStreamingCall类型”
     7     using var clientStreamingCall = client.AddPhoto();
     8     // 拿到“请求流”
     9     var requestStream = clientStreamingCall.RequestStream;
    10 
    11     //向“请求流”中写数据
    12     await using var fs = File.OpenRead(Request3);
    13     while (true)
    14     {
    15         var buffer = new byte[1024]; //模拟多次传递,将缓存设置小一点
    16         var length = await fs.ReadAsync(buffer, 0, buffer.Length); //将数据读取到buffer中
    17         if (length == 0)  //读取完毕
    18         {
    19             break;  //跳出循环
    20         }
    21         else if (length < buffer.Length)    //最后一次读取长度无法填满buffer的长度
    22         {
    23             Array.Resize(ref buffer, length);   //改变buffer数组的长度
    24         }
    25         var streamData = ByteString.CopyFrom(buffer);   //将byte数组数据转成传递时需要的ByteString类型
    26         //将ByteString数据写入“请求流”中
    27         await requestStream.WriteAsync(new AddPhotoRequest { Photo = streamData });
    28     }
    29 
    30     await requestStream.CompleteAsync();  //告知服务端数据传递完毕
    31     var response = await clientStreamingCall.ResponseAsync;
    32     Response3 = response.IsOK ? "congratulations" : "ah oh"; // 将消息显示到前端
    33 }

    来到服务端,可以看到编译器为客户端流模式的方法生成的请求参数是 IAsyncStreamReader<TRequest> 类型,表示客户端传来的参数是一串流模式的,服务端读取流数据写法如下。

     1 public override async Task<AddPhotoReponse> AddPhoto(IAsyncStreamReader<AddPhotoRequest> requestStream, ServerCallContext context)
     2 {
     3     var buffer = new List<byte>();
     4     var count = 0;
     5     while (await requestStream.MoveNext(new CancellationToken()))
     6     {
     7         buffer.AddRange(requestStream.Current.Photo);
     8         //每接收一次请求打印一条消息来显示
     9         Console.WriteLine($"{++count} : receive requestStreamData's length is {requestStream.Current.Photo.Length}");
    10     }
    11     //只是将收到的全部数据还原成原来的图片数据
    12     File.WriteAllBytes(@"photo.jpg", buffer.ToArray());
    13     return new AddPhotoReponse { IsOK = true };
    14 }

    (4)Bidirectional streaming RPC

     最后是双向流模式,在熟悉了服务端流模式和客户端流模式之后,这个模式也不难理解了,也就是双方都采用流模式,将上面两个写法进行融合即可。接下来将传递一个员工集合给服务端进行存储,服务端接收到每个员工数据并保存后都向客户端返回一次,将刚刚保存的用户信息在返回给客户端。

    客户端写法。

     1 protected async void SaveEmployees()
     2 {
     3     Response5 = string.Empty;       //清空前台显示
     4     using var channel = GrpcChannel.ForAddress(serverAdderss);
     5     var client = new EmployeeService.EmployeeServiceClient(channel);
     6     var serverStreamingCall = client.SaveEmployees();
     7     //因为是双向流的方式,我们需要同时操作“请求流”和“响应流”
     8     var requestStream = serverStreamingCall.RequestStream;
     9     var responseStream = serverStreamingCall.ResponseStream;
    10     //获取员工数据
    11     var employees = GetNewEmployees(Request5.Trim());
    12 
    13     //依次将员工数据写入请求流中
    14     foreach (var employee in employees)
    15     {
    16         await requestStream.WriteAsync(new EmployeeRequest { Employee = employee });
    17     }
    18     //告知服务端数据传递完毕
    19     await requestStream.CompleteAsync();
    20     //读取服务端返回的流式数据
    21     await Task.Run(async () =>
    22     {
    23         while (await responseStream.MoveNext(new CancellationToken()))
    24         {
    25             Response5 += $"New Employee “{responseStream.Current.Employee.Name}” is Saved"
    26                 + Environment.NewLine;
    27         }
    28     });
    29 }

    服务端写法,可以看到这次编译器生成的方法参数包含了请求流 IAsyncStreamReader<TRequest> 和响应流 IServerStreamWriter<TResponse> 。

     1 public override async Task SaveEmployees(IAsyncStreamReader<EmployeeRequest> requestStream, IServerStreamWriter<EmployeeResponse> responseStream, ServerCallContext context)
     2 {
     3     while (await requestStream.MoveNext(new CancellationToken()))
     4     {
     5         //从请求流中获取数据
     6         var newEmployee = requestStream.Current.Employee;
     7         if (!EmployeeRepository.Emloyees.Exists(emp => emp.Id == newEmployee.Id))
     8         {
     9             EmployeeRepository.Emloyees.Add(newEmployee);
    10         }
    11         //每存储一条员工数据后在控制台上打印一条记录
    12         Console.WriteLine($"receive NewEmployee {newEmployee.Name}");
    13         //每存储一条员工数据后向响应流中写入数据返回给客户端
    14         await responseStream.WriteAsync(new EmployeeResponse()
    15         {
    16             Employee = newEmployee
    17         });
    18     }
    19 }

    以上便演示了gRPC四种调用模式的使用,将服务端和客户端全都运行起来进行调用,一切OK,泪目。

     本次学习只涉及到gRPC如何简单的进行通信,在gRPC调用中的异常处理,日志,授权等内容在后续学习中再加以记录,谢谢。

    参考资料

    ○ https://www.grpc.io/docs/guides/
    ○ https://www.cnblogs.com/cgzl/p/11246324.html
    ○ http://www.csharpkit.com/2017-10-14_90705.html
    ○ https://unwcf.com/posts/wcf-vs-grpc-round-2/ (WCF PK gRPC)

  • 相关阅读:
    TDengine社区版
    进程&线程
    I2总线
    S3C2440的GPIO编程
    NPN&PNP
    旁路电容和去耦电容
    战胜C语言中令人头疼的问题
    今天神经有点大。。
    JZs3c2440裸板程序GPIO操作总结
    JZs3c2440学习笔记一
  • 原文地址:https://www.cnblogs.com/xhy0826/p/12665307.html
Copyright © 2011-2022 走看看