[料理佳餚] 除了 Web API 之外的新選擇 - gRPC 服務

gRPC 全名叫 gRPC Remote Procedure Calls,是一個由 Google 開發的 RPC 框架,基於 HTTP/2 協定及 Protocol Buffers 序列化協定設計而成的,主打著高性能、跨平台、跨語言(這一點頗吸引我),我們可以將 gRPC Host 在 ASP.NET Core 上做為一個服務發佈出去。

什麼是 RPC?

RPC 是 Remote Procedure Calls(遠端程序呼叫)的縮寫,顧名思義,就是去呼叫位於遠端主機上的一個程序,要完成這樣一件事情,標準的 RPC 架構需要四個元件:ClientClient StubServerServer Stub,其互動方式如下圖:

關於 RPC 更詳細的資訊,網路上已經有很多了,這個就請大家自行 Google 了。

gRPC 服務

RPC 的架構就如剛剛所描述的,至少需要一個 RPC 的 Server 提供服務,gRPC 也不例外,關於這點 gRPC 官網的 Tutorials 有各種程式語言的範例,可以一步一步地純手工打造,但是我們是寫 C# 的,微軟已經幫我們準備好 gRPC 服務的專案範本,直接拿來用就好了,我們打開 Visual Studio 2019(這邊使用的版本是 16.3.2),專案類型選擇「Web」,就可以看到 gRPC 服務,點擊下一步把專案建起來。

Protocol Buffers

Protocol Buffers 是由 Google 所定義的一種資料格式,檔案的副檔名是 .proto,很像 JSON 但不是 JSON,還記得剛剛所說的 RPC 架構的四個元件吧? gRPC 中的 Client Stub 及 Server Stub 就必須依賴 proto 檔來產生,可以把 proto 檔看成是 API 的定義檔,所以撰寫 gRPC 服務得先從 proto 檔下手。

我們使用的 Protocol Buffers 格式的版本是 proto3Google 的文件也很清楚地告訴我們該怎麼撰寫,那我就不使用專案範本中 GreeterService 的範例,而是自己想一個範例,從無到有地建立起來,現在我打算建立一個 EmployeeService,裡面有四個 API 分別是 GetEmployee()GetAllEmployees()AddEmployee()AddEmployees(),那麼我就來撰寫 proto 檔,在新增項目的畫面中「Visual C#」->「ASP.NET Core」->「一般」底下就可以找到通訊協定緩衝區檔案

proto 檔我就新增在 Protos 資料夾底下,接著我們需要在檔案屬性中,將建置動作改成Protobuf compiler,以及將 gRPC Stub Classes 改成 Server only

proto 檔的撰寫風格可以遵循官方的 Style Guide,才不會寫出來的 proto 檔跟其他開發者的習慣差太多,撰寫好的 proto 檔如下:

syntax = "proto3";
package grpcserver.humanresource;

option csharp_namespace = "GrpcServer.HumanResource";

service Employee {
	rpc GetEmployee (EmployeeRequest) returns (EmployeeModel);
	rpc GetAllEmployees (EmployeeRequest) returns (stream EmployeeModel);
	rpc AddEmployee (EmployeeModel) returns (EmployeeAddedResult);
	rpc AddEmployees (stream EmployeeModel) returns (EmployeeAddedResult);
}

message EmployeeRequest {
	int32 id = 1;
}

message EmployeeModel {
	int32 id = 1;
	string name = 2;

	message PhoneNumber {
		string value = 1;
	}

	repeated PhoneNumber phone_numbers = 3;

	EmployeeType employee_type = 4;

	int64 modifiedTime = 5;
}

enum EmployeeType {
	FIRST_LEVEL = 0;
	SECOND_LEVEL = 1;
	LAST_LEVEL = 2;
}

message EmployeeAddedResult {
	bool is_success = 1;
}

我們就上面這段寫好的 proto 檔來重點說明:

  • syntax:描述 Protocol Buffers 格式是哪一個版本?
  • package:類似 Namespace 的效果,選擇性填寫,但是如果遇到 service 或 message 有衝突的時候,package 將有助於明確化。
  • option csharp_namespace:C# 在產生程式碼時所使用的 Namespace 名稱
  • service:定義一個服務
  • rpc ... returns ...:定義一個 API
  • message:定義一個訊息結構
  • int32、int64、string、...:Protocol Buffers 的資料類型
  • 欄位後面的數字:這個是指定欄位在結構中的順序,從 1 開始,也是 gRPC 實際上在對應 message 欄位時的順序。
  • enum:定義一個列舉結構
我們常用的 DateTime 在 Protocol Buffers 資料類型的定義中是沒有的,必須額外處理,這部分可以參考這篇文章,而我選擇用 int64 的解決方案。

完成 proto 檔之後,我們必須建置專案以產生 Server Stub,那麼 proto 檔到這邊可以告一個段落了,接下來我們要撰寫 Server Service。

Server Service

proto 檔只是 API 定義檔,用它來產生 Server Stub 之後我們還必須從 Server Stub 生出實際執行的 Server Service,我接著就新增一個 EmployeeService 繼承 Employee.EmployeeBase,這個 Employee.EmployeeBase 是從 proto 檔中自動產生出來的 Server Stub Class,有興趣的朋友可以使用移至定義進到裡面去看它自動產生的程式碼,繼承 Employee.EmployeeBase 之後我們必須使用 override 的方式覆寫我們剛剛定義的那四個 API 方法。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Grpc.Core;
using GrpcServer.Extensions;
using GrpcServer.HumanResource;
using Microsoft.Extensions.Logging;

namespace GrpcServer.Services
{
    public class EmployeeService : Employee.EmployeeBase
    {
        private readonly ILogger<EmployeeService> logger;

        public EmployeeService(ILogger<EmployeeService> logger)
        {
            this.logger = logger;
        }

        public override Task<EmployeeModel> GetEmployee(EmployeeRequest request, ServerCallContext context)
        {
            // Emulate get an employee.
            var employee = new EmployeeModel
                           {
                               Id = 1,
                               Name = "Johnny",
                               EmployeeType = EmployeeType.FirstLevel,
                               PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0912345678" } },
                               ModifiedTime = new DateTime(2019, 10, 03, 17, 45, 00).ToUnixTimeMilliseconds()
                           };

            return Task.FromResult(employee);
        }

        public override async Task GetAllEmployees(EmployeeRequest request, IServerStreamWriter<EmployeeModel> responseStream, ServerCallContext context)
        {
            // Emulate get all employees.
            var allEmployees = new List<EmployeeModel>
                               {
                                   new EmployeeModel
                                   {
                                       Id = 1,
                                       Name = "Johnny",
                                       EmployeeType = EmployeeType.FirstLevel,
                                       PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0912345678" } },
                                       ModifiedTime = new DateTime(2019, 10, 3, 17, 45, 00).ToUnixTimeMilliseconds()
                                   },
                                   new EmployeeModel
                                   {
                                       Id = 2,
                                       Name = "Mary",
                                       EmployeeType = EmployeeType.SecondLevel,
                                       PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0923456789" } },
                                       ModifiedTime = new DateTime(2019, 10, 4, 9, 21, 00).ToUnixTimeMilliseconds()
                                   },
                                   new EmployeeModel
                                   {
                                       Id = 3,
                                       Name = "Tom",
                                       EmployeeType = EmployeeType.LastLevel,
                                       PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0934567890" } },
                                       ModifiedTime = new DateTime(2019, 10, 5, 10, 34, 00).ToUnixTimeMilliseconds()
                                   }
                               };

            foreach (var employee in allEmployees)
            {
                await responseStream.WriteAsync(employee);
            }
        }

        public override Task<EmployeeAddedResult> AddEmployee(EmployeeModel request, ServerCallContext context)
        {
            request.ModifiedTime = DateTime.Now.ToUnixTimeMilliseconds();

            // ... Do add an employee.

            return Task.FromResult(new EmployeeAddedResult { IsSuccess = true });
        }

        public override async Task<EmployeeAddedResult> AddEmployees(IAsyncStreamReader<EmployeeModel> requestStream, ServerCallContext context)
        {
            var employees = new List<EmployeeModel>();

            while (await requestStream.MoveNext())
            {
                employees.Add(requestStream.Current);
            }

            // ... Do batch add employees.

            return new EmployeeAddedResult { IsSuccess = true };
        }
    }
}

有兩個 API 方法 GetAllEmployees() 跟 AddEmployees() 需要特別說明一下,由於 Protocol Buffers 所定義的 API 輸出入的參數必須是 message,message 又是單一的結構,當我們需要輸出入 message 的集合怎麼辦? 可以加入 stream 這個保留字,宣告我們要輸出入的參數是一串 message,所以我們在實作 Server Service 的時候,實際操作的是一個串流,需要跑迴圈依序把 message 輸出或輸入。

最後我們還需要到 Startup.cs 對應寫好的 Server Service

gRPC Server 的部分到這邊就大致完成了,我們可以按 F5 執行起來看看,我們會看到 gRPC 服務的 Endpoint 是 https://localhost:5001

實際用瀏覽器去瀏覽 https://localhost:5001 是沒有什麼作用的,我們會看到網頁的內容提示我們需要用 gRPC Client 去溝通,所以接下來我們就要來寫 Client 的部分。

Client Call

建立一個 GrpcClient 的主控台應用程式,接著從 NuGet 安裝這三個 Packages:Grpc.Net.ClientGrpc.ToolsGoogle.Protobuf

然後,一樣我們從 proto 檔先著手,不過剛剛在撰寫 Server Service 的時候已經寫好 proto 檔了,直接拿來用就行了,我們可以選擇複製 proto 檔,或是加入做為連結,我這邊選擇用複製的。

proto 檔的檔案屬性一樣要將建置動作設定成 Protobuf compiler,而 gRPC Stub Classes 則設定為 Client only,這邊有個 GUI 的 Bug,我無法直接透過檔案的屬性頁去改,它會跳出一個 Error,怎麼弄都沒辦法修改,但是這個現象如果是用加入做為連結的方式就不會。

遇到這個錯誤我們只能直接改專案檔了,在專案上點擊左鍵兩下就可以直接在 Visual Studio 編輯專案檔,我們把 GrpcServices 改成 Client,之後建置專案以產生 Client Stub。

至此,我們已經可以開始呼叫 Server 端了,我就按順序一一呼叫。

GetEmployee()

呼叫 GetEmployee() 的程式碼如下,說明在注釋中。

using System;
using System.Text.Json;
using System.Threading.Tasks;
using Grpc.Net.Client;
using GrpcServer.HumanResource;

namespace GrpcClient
{
    internal class Program
    {
        private static async Task Main(string[] args)
        {
            // 等待 Server 啟動
            await Task.Delay(5000);

            // 建立連接到 https://localhost:5001 的通道
            var channel = GrpcChannel.ForAddress("https://localhost:5001");

            // 建立 EmployeeClient
            var client = new Employee.EmployeeClient(channel);

            // 呼叫 GetEmployee()
            var employee = await client.GetEmployeeAsync(new EmployeeRequest { Id = 1 });

            // 輸出 EmployeeModel 的序列化結果
            Console.WriteLine(JsonSerializer.Serialize(employee, new JsonSerializerOptions { WriteIndented = true }));

            Console.ReadKey();
        }
    }
}

執行結果

順帶一提,.NET Core 3.0 已經內建 System.Text.Json,想要序列化物件不需要再去引用 Json.NET 或其他的 Library,關於 System.Text.Json 更詳細的資訊請參考黑大的文章

GetAllEmployees()

GetAllEmployees() 收到的結果比較不一樣,是一個串流,所以我們要用另外一種方法把內容讀取出來,程式碼如下:

using System;
using System.Text.Json;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Net.Client;
using GrpcServer.HumanResource;

namespace GrpcClient
{
    internal class Program
    {
        private static async Task Main(string[] args)
        {
            // 等待 Server 啟動
            await Task.Delay(5000);

            // 建立連接到 https://localhost:5001 的通道
            var channel = GrpcChannel.ForAddress("https://localhost:5001");

            // 建立 EmployeeClient
            var client = new Employee.EmployeeClient(channel);

            // 呼叫 GetAllEmployees()
            var employeesStream = client.GetAllEmployees(new EmployeeRequest { Id = 0 });

            // 讀取 Employees 串流
            while (await employeesStream.ResponseStream.MoveNext())
            {
                var employee = employeesStream.ResponseStream.Current;

                // 輸出 EmployeeModel 的序列化結果
                Console.WriteLine(JsonSerializer.Serialize(employee, new JsonSerializerOptions { WriteIndented = true }));
            }

            Console.ReadKey();
        }
    }
}

執行結果

AddEmployee()

AddEmployee() 基本上與 GetEmployee() 的呼叫方式無異,但是我會在這邊測試一個情境,API 總是會遇到需要修改的時候,我假設 Server 的 proto 檔是新版,Client 的 proto 檔是舊版,新舊差在 EmployeeModel 在新版多了 modifiedTime 欄位,如果 Client 與 Server 的 proto 檔是不同版本會發生什麼事?

答案是 Server 的 API 照樣會 Work,只是 Server 端的 ModifiedTime 會是預設值而已。

但是這邊有個陷阱,我們在前面有提到過,宣告欄位的時候要給它順序,這個順序很重要,如果 Client 及 Server 兩邊的欄位順序不一樣,Server 端在反序列化的時候是不會報錯的,對應不到的欄位會給預設值,所以當欄位順序不一樣的時候,程式照樣能跑,但是處理的資料可能是錯的。

AddEmployees()

最後 AddEmployees() 方法主要是在呈現 Client 端怎麼傳一個集合到 Server 端,程式碼如下:

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Net.Client;
using GrpcClient.Extensions;
using GrpcServer.HumanResource;

namespace GrpcClient
{
    internal class Program
    {
        private static async Task Main(string[] args)
        {
            // 等待 Server 啟動
            await Task.Delay(5000);

            // 建立連接到 https://localhost:5001 的通道
            var channel = GrpcChannel.ForAddress("https://localhost:5001");

            // 建立 EmployeeClient
            var client = new Employee.EmployeeClient(channel);

            // 呼叫 AddEmployees()
            var employees = new List<EmployeeModel>
                               {
                                   new EmployeeModel
                                   {
                                       Id = 1,
                                       Name = "Johnny",
                                       EmployeeType = EmployeeType.FirstLevel,
                                       PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0912345678" } }
                                   },
                                   new EmployeeModel
                                   {
                                       Id = 2,
                                       Name = "Mary",
                                       EmployeeType = EmployeeType.SecondLevel,
                                       PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0923456789" } }
                                   },
                                   new EmployeeModel
                                   {
                                       Id = 3,
                                       Name = "Tom",
                                       EmployeeType = EmployeeType.LastLevel,
                                       PhoneNumbers = { new EmployeeModel.Types.PhoneNumber { Value = "0934567890" } }
                                   }
                               };

            AsyncClientStreamingCall<EmployeeModel, EmployeeAddedResult> employeesStream;

            using (employeesStream = client.AddEmployees())
            {
                foreach (var employee in employees)
                {
                    await employeesStream.RequestStream.WriteAsync(employee);
                }

                // Dispose() 會嘗試將狀態設為 Cancled (https://github.com/grpc/grpc-dotnet/blob/ca6cb660a5b9410d5b50a78387c52590dc31d13e/src/Grpc.Net.Client/Internal/GrpcCall.cs#L166)
                // CompleteAsync() 則是會將完成的狀態設為 true (https://github.com/grpc/grpc-dotnet/blob/ca6cb660a5b9410d5b50a78387c52590dc31d13e/src/Grpc.Net.Client/Internal/HttpContentClientStreamWriter.cs#L73)
                // 因此資料傳輸完畢之後,CompleteAsync() 是需要呼叫的。
                await employeesStream.RequestStream.CompleteAsync();

                var addedResult = await employeesStream.ResponseAsync;

                // 輸出 EmployeeAddedResult 的序列化結果
                Console.WriteLine(JsonSerializer.Serialize(addedResult, new JsonSerializerOptions { WriteIndented = true }));
            }

            Console.ReadKey();
        }
    }
}

執行結果

小結

我想各位應該可以發現,我們平常習慣使用的 Web API 就可以完成這個範例,gRPC 服務顯得好像有點多餘,其實不是的,gRPC 服務提供除了 Web API 之外的另一種選擇,其優點就如同 gRPC 官網所主打的高性能、跨平台、跨語言,尤其是高性能,gRPC 服務使用更少的傳輸量、更快的序列化機制,讓 Client 與 Server 之間的溝通更即時,我個人認為 gRPC 服務要全面性地取代 Web API 就目前看起來應該是不太可能,但是我們在開發 Remote API 的時候,如果沒有特別需要依賴 Web API 的話,gRPC 服務會是個很好的選擇。

參考資料

 < Source Code >

C# 指南 ASP.NET 教學 ASP.NET MVC 指引
Azure SQL Database 教學 SQL Server 教學 Xamarin.Forms 教學