初识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(())
}

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

评论