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 invalid");
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)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM