gRPC和RESTful API是两种流行的服务通信方案,相比RESTful API,gRPC性能更强、开发效率更高、跨语言支持更完善,在许多场景下得到应用。
本文对gRPC的整体情况做一个简要介绍,首先基于. NET 8来实现gRPC,最后介绍基于. NET Framework 4.8来实现和部署gRPC服务。
Proto文件
以下proto文件创建了两个远程调用函数,并且定义了四个消息用于传参和返回值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 syntax = "proto3" ; option csharp_namespace = "GrpcService1" ;package greet;service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) ; rpc Compute (ComputeData) returns (ComputeResult) ; } message HelloRequest { string name = 1 ; } message HelloReply { string message = 1 ; } message ComputeData { string name = 1 ; int32 age = 2 ; repeated string hobbies = 3 ; map<string , int32 > scores = 4 ; Address address = 5 ; enum Gender { UNKNOWN = 0 ; MALE = 1 ; FEMALE = 2 ; } Gender gender = 7 ; } message ComputeResult { string res=1 ; } message Address { string city = 1 ; string street = 2 ; }
proto中的message类似C#中的class,可以包含普通数据类型、数组、字典、嵌套message和枚举。
message的定义是使用gRPC的核心之一。
服务端
主要步骤
创建ASP.NET Core gRPC服务项目
创建proto文件,自动生成后台代码
创建服务(依赖自动生成的后台代码)
在主函数中对服务进行发布
服务实现
定义服务类,继承自动生成的类,重载远程调用函数即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public class GreeterService : Greeter.GreeterBase { private readonly ILogger<GreeterService> _logger; public GreeterService (ILogger<GreeterService> logger ) { _logger = logger; } public override Task<ComputeResult> Compute (ComputeData request, ServerCallContext context ) { StringBuilder sb = new StringBuilder(); sb.AppendLine($"姓名:{request.Name} " ); sb.AppendLine($"年龄:{request.Age} " ); sb.Append($"爱好:" ); foreach (var hobby in request.Hobbies) sb.Append($"{hobby} " ); sb.AppendLine(); sb.Append($"成绩:" ); foreach (var (key,value ) in request.Scores) sb.Append($"{key} -{value } " ); sb.AppendLine(); sb.AppendLine($"地址:{request.Address.City} ,{request.Address.Street} " ); sb.Append($"性别:{request.Gender.ToString()} " ); return Task.FromResult(new ComputeResult { Res = sb.ToString() }); } public override Task<HelloReply> SayHello (HelloRequest request, ServerCallContext context ) { return Task.FromResult(new HelloReply { Message = "Hello " + request.Name }); } }
服务发布
在主函数中对上述服务类进行注册:
1 2 3 4 5 6 7 8 9 10 11 public static void Main (string [] args ){ var builder = WebApplication.CreateBuilder(args ); builder.Services.AddGrpc(); var app = builder.Build(); app.MapGrpcService<GreeterService>(); app.MapGet("/" , () => "Communication with gRPC endpoints must be made through a gRPC client." ); app.Run(); }
服务部署
将gRPC服务部署到服务器并运行时,需要为其指定一个可见的端口,可以通过以下两种方式设置服务运行端口:
服务打包:在本实例中,采用了.NET框架进行开发,可以使用AOT的方式进行打包,打包完成之后会有一个exe程序以及多个json格式的配置文件
1 dotnet publish -c Release -r win-x64 --self-contained
1 2 var builder = WebApplication.CreateBuilder(args );builder.WebHost.UseUrls("http://*:50051" );
配置文件中指定:在appsettings.json中指定服务的运行端口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "Logging" : { "LogLevel" : { "Default" : "Information" , "Microsoft.AspNetCore" : "Warning" } } , "AllowedHosts" : "*" , "Kestrel" : { "EndpointDefaults" : { "Protocols" : "Http2" } , "EndPoints" : { "Http" : { "Url" : "http://*:50051" } } } }
客户端
主要步骤
拷贝服务端的proto文件,设置其生成类型为Client only
调用服务代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using Grpc.Net.Client;using GrpcGreeterClient;var channel = GrpcChannel.ForAddress("https://localhost:7109" );var client = new Greeter.GreeterClient(channel);var reply = await client.SayHelloAsync(new HelloRequest { Name = "李浩" });var data = new ComputeData(){ Name = "李浩" , Age = 31 , Hobbies = { "跑步" , "看书" , "编程" }, Scores = { { "数学" , 140 }, { "语文" , 130 } }, Address = new Address { Street = "和平大道" , City = "武汉市" , }, Gender = ComputeData.Types.Gender.Male, }; var computeReply = await client.ComputeAsync(data);Console.WriteLine("Greeting: " + reply.Message); Console.WriteLine("Compute Result:" + computeReply.Res); Console.ReadKey();
proto生成代码的探究
对于以下简单的proto文件:
1 2 3 4 5 6 7 8 9 10 11 12 syntax = "proto3" ; option csharp_namespace = "GrpcService1" ;package greet;service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) ; } message HelloRequest { string name = 1 ; } message HelloReply { string message = 1 ; }
在服务端会生成如下图所示的代码元素:
在客户端会生成如下图所示的代码元素:
使用proto文件的心智简化:
在服务端会自动生成一个抽象类(是嵌套类),其中定义了远程调用函数,用户需要定义新的服务类并具体实现该远程调用函数。
在客户端会自动生成一个类,其中会包含远程调用函数的多版本实现,用户直接调用这些函数就可以与服务端通信。
在proto文件中定义的message会转换成C#类,字段也会相应映射。
我们在使用gRPC时,只需要牢记以上三点并熟练掌握proto编写语法即可。
基于. NET Framework的gRPC
为什么要使用基于. NET Framework的gRPC服务?原因只有一个:我们的gRPC服务依赖. NET Framework的库!
基于. NET Framework实现与上述. NET 8相同的gRPC例子。
Proto类库项目
与其手动将proto文件拷贝到服务端和客户端项目,更好的一种方式是:在一个类库项目中添加proto文件,然后同时生成服务端和客户端代码,在服务端和客户端项目中引用该项目即可。
服务端
定义服务同. NET 8:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class GreetImpl : Greeter.GreeterBase { public override Task<ComputeResult> Compute (ComputeData request, ServerCallContext context ) { StringBuilder sb = new StringBuilder(); sb.AppendLine($"姓名:{request.Name} " ); sb.AppendLine($"年龄:{request.Age} " ); sb.Append($"爱好:" ); foreach (var hobby in request.Hobbies) sb.Append($"{hobby} " ); sb.AppendLine(); foreach (var pair in request.Scores) { sb.Append($"{pair.Key} -{pair.Value} " ); } sb.AppendLine(); sb.Append($"成绩:" ); sb.AppendLine(); sb.AppendLine($"地址:{request.Address.City} ,{request.Address.Street} " ); sb.Append($"性别:{request.Gender.ToString()} " ); return Task.FromResult(new ComputeResult { Res = sb.ToString() }); } public override Task<HelloReply> SayHello (HelloRequest request, ServerCallContext context ) { return Task.FromResult(new HelloReply { Message = $"Hello {request.Name} " }); } }
主要区别是如何启动服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static void Main (string [] args ) { int port = int .Parse(AppConfig.Get("port" )); Server server = new Server { Services = { Greeter.BindService(new GreetImpl()) }, Ports = { new ServerPort($"{AppConfig.Get("ip" )} " , port, ServerCredentials.Insecure) } }; server.Start(); Console.WriteLine($"Greeter Server Listening on port {port} " ); Console.WriteLine("Press Enter to exit" ); Console.ReadLine(); server.ShutdownAsync().Wait(); }
客户端
服务端代码同. NET 8:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public static async Task Main (string [] args ) { Channel channel = new Channel($"{AppConfig.Get("ip" )} :{AppConfig.Get("port" )} " , ChannelCredentials.Insecure); var client = new Greeter.GreeterClient(channel); var reply = await client.SayHelloAsync(new HelloRequest { Name = "李诗吟" }); var data = new ComputeData() { Name = "李若晗" , Age = 1 , Hobbies = { "跑步" , "看书" , "编程" }, Scores = { { "数学" , 140 }, { "语文" , 130 } }, Address = new Address { Street = "和平大道" , City = "武汉市" , }, Gender = ComputeData.Types.Gender.Male, }; var computeReply = await client.ComputeAsync(data); Console.WriteLine("Greeting: " + reply.Message); Console.WriteLine(); Console.WriteLine("Compute Result:" + computeReply.Res); Console.ReadKey(); }
部署
以控制台程序的方式部署gRPC服务,可以使用NSSM工具(非常好用)将其部署成Windows服务,有以下几点需要注意:
IP地址:使用0.0.0.0
端口:应该使用已经暴露出来的端口,例如50051
protobuf-net
在某些场景下,使用protobuf会有一些明显的不足:
proto生成的代码存在较大冗余,如果一个项目的proto结构较复杂,这种影响可能比较大。
proto屏蔽了语言差异,使用统一的格式定义数据结构,可以生成多种语言下的代码,这对于跨语言场景非常有用,但如果项目语言本身就是单一的,那么这种强大似乎显得多余!
通过proto生成的数据结构与程序中使用的数据结构还存在差异,开发者需要对两者进行转换(可借助AutoMapper等工具)。
protobuf-net为. NET量身定制,可以使用注解对. NET类直接进行标注,从而快速定义通信数据结构。
需要安装以下NuGet包:
服务定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [ProtoContract ] public class HelloRequest { [ProtoMember(1) ] public string Name { get ; set ; } } [ProtoContract ] public class HelloResponse { [ProtoMember(1) ] public string Message { get ; set ; } } [Service ] public interface IHelloService { Task<HelloResponse> SayHello (HelloRequest helloRequest, CallContext context = default ) ; }
1 2 3 4 5 public class HelloService : IHelloService { public Task<HelloResponse> SayHello (HelloRequest helloRequest, CallContext context = null ) { return Task.FromResult(new HelloResponse { Message = $"Hello, {helloRequest.Name} " }); } }
服务端
注意:创建Method时,前面两个字符串只起到标记作用,客户端与服务端保持一致即可,并不是用来定位服务函数的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 internal class Program { private static void Main (string [] args ) { var method = new Method<byte [], byte []>( MethodType.Unary, "HelloService" , "SayHello" , Marshallers.Create( data => data, data => data ), Marshallers.Create( data => data, data => data ) ); var server = new Server { Services = { ServerServiceDefinition.CreateBuilder() .AddMethod(method, HandleSayHello) .Build() }, Ports = { new ServerPort("localhost" , 50051 , ServerCredentials.Insecure) } }; server.Start(); Console.WriteLine($"Greeter Server Listening on port {50051 } " ); Console.WriteLine("Press Enter to exit" ); Console.ReadLine(); server.ShutdownAsync().Wait(); } private static Task<byte []> HandleSayHello(byte [] requestData, ServerCallContext context) { var request = ProtobufNetSerializer.Deserialize<HelloRequest>(requestData); var response = new HelloService().SayHello(request).Result; return Task.FromResult(ProtobufNetSerializer.Serialize(response)); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static class ProtobufNetSerializer { public static byte [] Serialize <T >(T obj ) { using (var stream = new MemoryStream()) { Serializer.Serialize(stream, obj); return stream.ToArray(); } } public static T Deserialize <T >(byte [] data ) { using (var stream = new MemoryStream(data)) { return Serializer.Deserialize<T>(stream); } } }
客户端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 internal static class Program { private static void Main () { var channel = new Channel("localhost:50051" , ChannelCredentials.Insecure); var invoker = new DefaultCallInvoker(channel); var method = new Method<byte [], byte []>( MethodType.Unary, "HelloService" , "SayHello" , Marshallers.Create( data => data, data => data ), Marshallers.Create( data => data, data => data ) ); var requestData = new HelloRequest { Name = "LiRuohan" }; var requestBytes = ProtobufNetSerializer.Serialize(requestData); var responseBytes = invoker.AsyncUnaryCall( method, null , new CallOptions(), requestBytes).ResponseAsync.Result; var response = ProtobufNetSerializer.Deserialize<HelloResponse>(responseBytes); Console.WriteLine(response.Message); Console.ReadKey(); } }
使用Rust的Tonic库实现gRPC
项目结构
proto文件如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 syntax = "proto3" ; package helloworld;service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) ; } message HelloRequest { string name = 1 ; } message HelloReply { string message = 1 ; }
编写build.rs,用来自动生成Rust代码:
1 2 3 4 fn main () -> Result <(), Box <dyn std::error::Error>> { tonic_build::compile_protos ("proto/helloworld.proto" )?; Ok (()) }
项目配置文件是关键:注意prost的版本必须是0.13,用最新版会出问题!!!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 [package] name = "helloworld-tonic" version = "0.1.0" edition = "2024" [[bin]] name = "helloworld-server" path = "src/server.rs" [[bin]] name = "helloworld-client" path = "src/client.rs" [dependencies] tonic = "0.13" prost = "0.13" tokio = { version = "1.45" , features = ["macros" , "rt-multi-thread" ] }[build-dependencies] tonic-build = "0.13" [profile.dev] incremental = true [profile.release] codegen-units = 1 lto = true opt-level = "s" panic = "abort" strip = true
服务端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 use tonic::{transport::Server, Request, Response, Status};use hello_world::greeter_server::{Greeter, GreeterServer};use hello_world::{HelloReply, HelloRequest};pub mod hello_world { tonic::include_proto!("helloworld" ); } #[derive(Debug, Default)] pub struct MyGreeter {}#[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello ( &self , request: Request<HelloRequest>, ) -> Result <Response<HelloReply>, Status> { println! ("Got a request: {:?}" , request); let reply = HelloReply { message: format! ("Hello {}!" , request.into_inner ().name), }; Ok (Response::new (reply)) } } #[tokio::main] async fn main () -> Result <(), Box <dyn std::error::Error>> { let addr = "0.0.0.0:50051" .parse ()?; let greeter = MyGreeter::default (); Server::builder () .add_service (GreeterServer::new (greeter)) .serve (addr) .await ?; Ok (()) }
客户端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 use hello_world::greeter_client::GreeterClient;use hello_world::HelloRequest;pub mod hello_world { tonic::include_proto!("helloworld" ); } #[tokio::main] async fn main () -> Result <(), Box <dyn std::error::Error>> { let mut client = GreeterClient::connect ("http://192.10.49.135:50051" ).await ?; let request = tonic::Request::new (HelloRequest { name: "Tonic" .into (), }); let response = client.say_hello (request).await ?; println! ("RESPONSE={:?}" , response); Ok (()) }
在AutoCAD中集成gRPC服务
如何实现这种需求:在服务端部署AutoCAD,当收到客户端请求时,怎么能驱动AutoCAD绘图?
这种场景非常适合使用gRPC服务!以插件形式开发gRPC服务,在插件初始化函数中启动gRPC服务,从而实时监听客户端请求。当服务端绘图完成之后,可以保存图纸并通过MinIO服务传输文件。
由于2024及以下版本的AutoCAD使用.NET Framework作为开发平台,所以和《基于. NET Framework的gRPC》的集成方式基本一致,不同之处在于服务初始化位置。
Proto文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 syntax = "proto3" ; package cadservice;service Drawer { rpc DrawLine (LineRequest) returns (LineResponse) ; } message LineRequest { double x1 = 1 ; double y1 = 2 ; double x2 = 3 ; double y2 = 4 ; } message LineResponse { bool isSuccess = 1 ; string filePath = 2 ; }
服务端实现
在插件初始化函数中启动服务,并进行其它的初始化工作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 using Autodesk.AutoCAD.Runtime;using Cadservice;using Grpc.Core;using AcadApp = Autodesk.AutoCAD.ApplicationServices.Application;[assembly: ExtensionApplication(typeof(CADGRPCServer.Init)) ] namespace CADGRPCServer { internal class Init : IExtensionApplication { public void Initialize () { AcadApp.BeginQuit += BeginQuit; AcadOperationQueue.Initialize(); StartService(); AcadApp.DocumentManager.MdiActiveDocument.Editor.WriteMessage("\nGRPC服务已启动,监听端口50051。\n" ); } public void Terminate () { AcadApp.BeginQuit -= BeginQuit; } private void BeginQuit (Object sender, EventArgs e ) { StopService(); } #region GRPC服务管理 private Server _grpcServer = null ; private void StartService (string ip = "0.0.0.0" , int port = 50051 ) { if (_grpcServer != null ) return ; _grpcServer = new Server { Services = { Drawer.BindService(new DrawerImpl()) }, Ports = { new ServerPort(ip, port, ServerCredentials.Insecure) } }; _grpcServer.Start(); } private void StopService () { if (_grpcServer == null ) return ; _grpcServer.ShutdownAsync().Wait(); } #endregion } }
服务实现:绘图,并将成果上传至MinIO服务器供客户端下载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 class DrawerImpl : Drawer.DrawerBase { public override async Task<LineResponse> DrawLine (LineRequest request, ServerCallContext context ) { string guid = Guid.NewGuid().ToString(); string filePath = $"D:\\{guid} .dwg" ; await AcadOperationQueue.EnqueueAsync(() => { Document doc = AcadApp.DocumentManager.Add("acadiso.dwt" ); Database db = doc.Database; using (var docLocker = doc.LockDocument()) { using (Transaction tr = db.TransactionManager.StartTransaction()) { BlockTable bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead); BlockTableRecord btr = (BlockTableRecord)tr.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite); for (int i = 0 ; i < 100 ; i++) { Line line = new Line( new Autodesk.AutoCAD.Geometry.Point3d(request.X1 + 10 * i, request.Y1 + 10 * i, 0 ), new Autodesk.AutoCAD.Geometry.Point3d(request.X2 + 10 * i, request.Y2 + 10 * i, 0 ) ); btr.AppendEntity(line); tr.AddNewlyCreatedDBObject(line, true ); } tr.Commit(); } doc.SendStringToExecute("zoom a " , true , false , false ); } db.SaveAs(filePath, DwgVersion.Current); doc.CloseAndDiscard(); }); const string endpoint = "192.10.223.137:9000" ; const string accessKey = "admin" ; const string secretKey = "Lizi@710" ; const string bucketName = "dwgs" ; using var minio = new MinioService(endpoint, accessKey, secretKey, useSSL: false ); await minio.UploadFileAsync(bucketName, $"{guid} .dwg" , filePath); return new LineResponse { IsSuccess = true , FilePath = $"{guid} .dwg" }; } }
注意:AutoCAD只支持串行化操作,当多用户同时发送绘图请求时会出问题,必须对绘图请求进行串行化,这里的AcadOperationQueue.EnqueueAsync()就是这个作用,非常关键!代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public static class AcadOperationQueue { private static SynchronizationContext? _context = null ; private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1 , 1 ); public static void Initialize () { _context = SynchronizationContext.Current; } public static async Task EnqueueAsync (Action action ) { await EnqueueAsync(() => { action(); return 0 ; }); } public static async Task <T > EnqueueAsync <T >(Func<T> operation ) { await _semaphore.WaitAsync(); try { return Send(operation); } catch (Exception ex) { throw new InvalidOperationException($"{ex.Message} " , ex); } finally { _semaphore.Release(); } } private static T Send <T >(Func<T> func ) { if (_context == null ) throw new InvalidOperationException("AcadSyncContext 未初始化" ); T result = default (T); var tcs = new TaskCompletionSource<bool >(); _context.Send(_ => { try { result = func(); tcs.SetResult(true ); } catch (Exception ex) { tcs.SetException(ex); } }, null ); tcs.Task.Wait(); return result; } }
客户端测试
1 2 3 4 5 6 7 8 9 10 11 internal class Program { static async Task Main (string [] args ) { var channel = GrpcChannel.ForAddress("http://localhost:50051" ); var client = new Drawer.DrawerClient(channel); var response = await client.DrawLineAsync( new LineRequest { X1 = 0.0 , Y1 = 0.0 , X2 = 1000.0 , Y2 = 500.0 } ); Console.ReadKey(); } }