初识gRPC

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"
}
}
}
}

客户端

主要步骤

  • 创建控制台程序
  • 安装NuGet包

  • 拷贝服务端的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]] # Bin to run the HelloWorld gRPC server
name = "helloworld-server"
path = "src/server.rs"

[[bin]] # Bin to run the HelloWorld gRPC client
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 # Compile your binary in smaller steps.
[profile.release]
codegen-units = 1 # Allows LLVM to perform better optimization.
lto = true # Enables link-time-optimizations.
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
panic = "abort" # Higher performance by disabling panic handlers.
strip = true # Ensures debug symbols are removed.

服务端

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();

// 启动GRPC服务
StartService();
AcadApp.DocumentManager.MdiActiveDocument.Editor.WriteMessage("\nGRPC服务已启动,监听端口50051。\n");
}

public void Terminate() {
AcadApp.BeginQuit -= BeginQuit;
}

private void BeginQuit(Object sender, EventArgs e) {
// 终止GRPC服务
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";
// 绘图,由于AutoCAD只支持串行化操作,因此需要通过AcadOperationQueue排队执行
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();
});

// 通过minio上传成果文件
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 }
);
// 通过MinIO下载成果文件...
Console.ReadKey();
}
}

(转载本站文章请注明作者和出处lihaohello.top,请勿用于任何商业用途)

评论