gRPC的简单使用
前言
八月初的时候,在公司内部做了一个主题为《gRPC的简单使用》的分享,其实就是和小伙伴们扯扯淡,现在抽空回忆一下,也算是一个小小的总结吧。
现在市面上耳熟能详的RPC框架也很多,下面列举几个遇到比较多的。
- 谷歌的gRPC
- 推特的Thrift
- 阿里的Dubbo
- 。。。。
它们都是支持多语言的,相对来说,这三个之中,Dubbo支持的语言略微少一点。现在在一个公司内都能见到多种语言的技术栈都已经是十分常见的事了,好比我司,都有JAVA,C#,Python三种语言了,所以在多语言支持这方面,在技术选型的时候,肯定是要有所考虑的。
下面进入正式的主题,gRPC。
gRPC的简单介绍
gRPC是一个现代的开源高性能RPC框架,可以在任何环境中运行。它可以高效地将数据中心内和跨数据中心的服务连接起来,并支持可插拔的负载平衡、跟踪、健康检查和身份验证。同时,它还把设备,移动应用程序和浏览器连接到后端服务的分布式计算变得很容易。
gRPC有什么优点呢?
- 简单的服务定义 (使用Protocol Buffers定义服务,这是一个功能强大的二进制序列化工具集和语言)
- 跨语言和平台工作 (在微服务式架构中有效地连接多语言服务(10+种语言支持)并能自动为各种语言和平台的服务生成惯用的客户端和服务器存根)
- 快速启动并扩展 (使用单行安装运行时和开发环境,并使用框架每秒扩展到数百万个RPC)
- 双向流媒体和集成的身份验证 (双向流媒体和集成的身份验证 基于http/2的传输的双向流和完全集成的可插拔身份验证)
gRPC在使用的时候有4种模式供我们选择
- 一元RPC(Unary RPCs ):这是最简单的定义,客户端发送一个请求,服务端返回一个结果
- 服务器流RPC(Server streaming RPCs):客户端发送一个请求,服务端返回一个流给客户端,客户从流中读取一系列消息,直到读取所有消息
- 客户端流RPC(Client streaming RPCs ):客户端通过流向服务端发送一系列消息,然后等待服务端读取完数据并返回处理结果
- 双向流RPC(Bidirectional streaming RPCs):客户端和服务端都可以独立向对方发送或接受一系列的消息。客户端和服务端读写的顺序是任意。
我们要根据具体的场景来决定选择那一种。
这里只介绍一元RPC。正常来说,一元RPC应该可以满足我们日常60~70%的需求了吧。
基本用法
gRPC的基本用法可以简单的分为三个点:
- 服务的定义,即proto文件的编写
- 服务端代码编写
- 客户端代码编写
下面我们依次来看一下
服务的定义
既然要定义一个服务,肯定是知道了这个服务要完成什么事之后。
在定义之前,要对proto3和proto2有所了解。不过proto3是推荐的格式。所以我们基本上只要用proto3就可以了。
下面先来看一个后面要用到的proto文件。
syntax = "proto3";
option csharp_namespace = "XXXService";
package UserInfo;
service UserInfoService {
rpc GetList(GetUserListRequest) returns (GetUserListReply){}
rpc GetById(GetUserByIdRequest) returns (GetUserByIdRelpy){}
rpc Save(SaveUserRequest) returns (SaveUserReply){}
}
message GetUserByIdRequest {
int32 id = 1;
}
message GetUserByIdRelpy{
int32 id = 1;
string name = 2;
int32 age = 3;
int64 create_time = 4;
}
message GetUserListRequest {
int32 id = 1;
string name = 2;
}
message GetUserListReply {
message MsgItem {
int32 id = 1;
string name = 2;
int32 age = 3;
int64 create_time = 4;
}
int32 code = 1;
string msg = 2;
repeated MsgItem data = 3;
}
message SaveUserRequest {
string name = 1;
int32 age = 2;
}
message SaveUserReply {
int32 code = 1;
string msg = 2;
}
它有下面的几个部分
- syntax , 指定要用那个版本的语法
- service , 指定rpc服务的接口,简单理解成我们平时定义的接口
- message , 指定要传输的消息体,简单理解成我们平常用的 DTO
- package , 指定包名
- option , 可选参数的定义,不同语言有不同的选项
其实看上去还是比较容易懂的。至少一眼看过去能知道是些什么意思。
如果对proto3还没有了解的,可以参考这个文档Language Guide (proto3),里面很清楚的介绍了一些数据类型和不同语言数据类型的对应关系。
这里有一个要注意的是,时间类型,在proto3中,没有datetime类型,过去很长一段时间,我们是只能用时间戳来表示时间,也就是定义一个长整型,现在是可以用timestamp表处理了。
在写服务端和客户端代码之前,我们需要根据proto文件生成对应的代码。
一个命令即可搞定。
protoc --proto_path=IMPORT_PATH \
--cpp_out=DST_DIR \
--java_out=DST_DIR \
--python_out=DST_DIR \
--go_out=DST_DIR \
--objc_out=DST_DIR \
--csharp_out=DST_DIR \
path/to/file.proto
现在时代进步的这么快,不少语言已经有工具做了集成,可以在build项目的时候就生成对应的文件了,不需要我们再单独去执行一次上面的那个命令。
好比我们的.NET项目,可以在ItemGroup中直接指定Protobuf,然后告诉它,proto文件是那个,是要生成服务端代码还是客户端代码。
可以看看下面这个具体的例子。
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Protobuf Include="Protos\userinfo.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" PrivateAssets="All" />
<PackageReference Include="Google.Protobuf" Version="3.8.0" />
<PackageReference Include="Grpc.Core" Version="1.22.0" />
<PackageReference Include="Grpc.Tools" Version="1.22.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
再往下,就是写代码了。
服务端代码编写
服务端代码分两部分,一部分是服务具体的实现,一部分是服务怎么起来。
先来看看服务的具体实现。
namespace MyBasedServiceA
{
using Grpc.Core;
using System.Linq;
using System.Threading.Tasks;
public class UserInfoServiceImpl : UserInfoService.UserInfoServiceBase
{
public override Task<GetUserByIdRelpy> GetById(GetUserByIdRequest request, ServerCallContext context)
{
var result = new GetUserByIdRelpy();
var user = FakeUserInfoDb.GetById(request.Id);
result.Id = user.Id;
result.Name = user.Name;
result.Age = user.Age;
result.CreateTime = user.CreateTime;
return Task.FromResult(result);
}
public override Task<GetUserListReply> GetList(GetUserListRequest request, ServerCallContext context)
{
var result = new GetUserListReply();
var userList = FakeUserInfoDb.GetList(request.Id, request.Name);
result.Code = 0;
result.Msg = "成功";
result.Data.AddRange(userList.Select(x => new GetUserListReply.Types.MsgItem
{
Id = x.Id,
Age = x.Age,
CreateTime = x.CreateTime,
Name = x.Name
}));
return Task.FromResult(result);
}
public override Task<SaveUserReply> Save(SaveUserRequest request, ServerCallContext context)
{
var result = new SaveUserReply();
var flag = FakeUserInfoDb.Save(request.Name, request.Age);
result.Code = 0;
result.Msg = "成功";
return Task.FromResult(result);
}
}
}
可以看到上面的代码,我们只要继承由proto文件生成的一个基类,然后去重写它的实现,就可以认为是实现了一个服务。这个其实就是写我们具体的业务逻辑,大boss有什么需求,堆上去就好了。
然后来看第二部分,服务怎么起来。
在这里我选择的方案是使用通用主机来跑。当然也可以直接在Startup的Configure
方法中去启动服务。只要能起来就行 😄
namespace MyBasedServiceA
{
using Grpc.Core;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
public class MyBasedServiceAHostedService : BackgroundService
{
private readonly ILogger _logger;
private Server _server;
public MyBasedServiceAHostedService(ILoggerFactory loggerFactory)
{
this._logger = loggerFactory.CreateLogger<MyBasedServiceAHostedService>();
_server = new Server
{
Services = { UserInfoService.BindService(new UserInfoServiceImpl()) },
// ServerCredentials.Insecure还是没有用https,只用于演示
// 生产环境,建议还是弄个证书
Ports = { new ServerPort("0.0.0.0", 9999, ServerCredentials.Insecure) }
};
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
_server.Start();
return Task.CompletedTask;
}
}
}
然后是Program中的代码。
namespace MyBasedServiceA
{
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public class Program
{
public static void Main(string[] args)
{
var host = new HostBuilder()
.ConfigureLogging((hostContext, configLogging) =>
{
configLogging.AddConsole();
configLogging.AddDebug();
})
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<MyBasedServiceAHostedService>();
})
.Build();
host.Run();
}
}
}
到这里,服务端已经可以了。
下面就是客户端了。
客户端代码编写
在这里客户端,我们写两个,一个基于C#(.net core), 一个基于python。刚好也验证一下gRPC的多语言。
正常来说,我们所说的客户端可能很大一部分是对外的WEB API了,就是说api的内部实现,是rpc的调用,而对外的是常见的返回JSON的rest api。
我们先通过控制台来体验一下它的客户端调用。
C#(.net core)客户端
class Program
{
static void Main(string[] args)
{
var channel = new Channel("localhost:9999", ChannelCredentials.Insecure);
var client = new UserInfoService.UserInfoServiceClient(channel);
var saveResponse = client.Save(new SaveUserRequest { Age = 99, Name = "c#name" });
Console.WriteLine($"Save received: code = {saveResponse.Code} , msg = {saveResponse.Msg}");
var getListResponse = client.GetList(new GetUserListRequest { });
Console.WriteLine($"GetList received: code = {getListResponse.Code} , msg = {getListResponse.Msg}");
foreach (var item in getListResponse.Data)
{
Console.WriteLine(item.Name);
}
Console.ReadKey();
}
}
其实这种方式我们很容易联想到WCF,都是生成代码,可以直接点出来的方法,强类型的使用体验。不过我是基本没有用过WCF的,貌似暴露了年龄了,逃~~
python客户端
python要想运行gRPC相关的,要先安装 grpcio-tools
,然后再用命令生成相应的文件。
# 安装
pip install grpcio-tools
# 生成
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. ./userinfo.proto
具体实现
import grpc
import userinfo_pb2, userinfo_pb2_grpc
_HOST = 'localhost'
_PORT = '9999'
def run():
conn = grpc.insecure_channel(_HOST + ':' + _PORT)
client = userinfo_pb2_grpc.UserInfoServiceStub(channel=conn)
saveResponse = client.Save(userinfo_pb2.SaveUserRequest(name="pyname", age=39))
print("Save received: code = " + str(saveResponse.code) + ", msg = "+ saveResponse.msg)
getListResponse = client.GetList(userinfo_pb2.GetUserListRequest())
print("GetList received: code = " + str(getListResponse.code) + ", msg = "+ getListResponse.msg)
for d in getListResponse.data:
print(d.name)
if __name__ == '__main__':
run()
同样也是很简洁。
运行效果
在服务端起来的情况下,先运行.net core的客户端,然后再运行python的客户端,结果大致如下。
注: 在调用的时候,有几个概念要知道!!
- gRPC中没有采用传统的timeout方式去处理,而是采用了Deadline机制,觉得这个机制和我们的CancellationToken很相似
- 无论是客户端还是服务端,都可以随时取消RPC
可以看到我们现在的地址都是硬编码的,因为只有一个节点,然后在线上环境,都会是多节点的,所以我们需要有服务注册和服务发现,下面我们就结合consul来完成服务注册与发现。
服务治理(注册与发现)
当然现在可选的工具还是有很多的,consul,etcd,eureka等,当然最好的还是直接上K8S,不过我们公司还有很长的一段路才能上,所以我们就怎么简单怎么来了。
下面我们调整一下服务端的代码,让gRPC的服务可以注册到consul上面。
public class MyBasedServiceAHostedService : BackgroundService
{
private readonly Microsoft.Extensions.Logging.ILogger _logger;
private readonly IConfiguration _configuration;
private readonly IConsulClient _consulClient;
private Server _server;
private AgentServiceRegistration registration;
public MyBasedServiceAHostedService(ILoggerFactory loggerFactory, IConfiguration configuration, IConsulClient consulClient, IHostingEnvironment environment)
{
this._logger = loggerFactory.CreateLogger<MyBasedServiceAHostedService>();
this._configuration = configuration;
this._consulClient = consulClient;
var port = _configuration.GetValue<int>("AppSettings:Port");
_logger.LogInformation($"{environment.EnvironmentName} Current Port is : {port}");
// global logger for grpc
GrpcEnvironment.SetLogger(new GrpcAdapterLogger(loggerFactory));
var address = GetLocalIP();
_logger.LogInformation($"{environment.EnvironmentName} Current IP is : {address}");
registration = new AgentServiceRegistration()
{
ID = $"MyBasedServiceA-{Guid.NewGuid().ToString("N")}",
Name = "MyBasedServiceA",
Address = address,
Port = port,
Check = new AgentServiceCheck
{
TCP = $"{address}:{port}",
DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5),
Interval = TimeSpan.FromSeconds(10),
Timeout = TimeSpan.FromSeconds(5)
}
};
_server = new Server
{
Ports = { new ServerPort("0.0.0.0", port, ServerCredentials.Insecure) }
};
// not production record some things
if (!environment.IsProduction())
{
_server.Services.Add(UserInfoService.BindService(new UserInfoServiceImpl()).Intercept(new AccessLogInterceptor(loggerFactory)));
}
else
{
_server.Services.Add(UserInfoService.BindService(new UserInfoServiceImpl()));
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _consulClient.Agent.ServiceDeregister(registration.ID);
await _consulClient.Agent.ServiceRegister(registration);
_logger.LogInformation($"Registering with Consul {registration.ID} OK");
_server.Start();
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Unregistering from Consul");
await _consulClient.Agent.ServiceDeregister(registration.ID);
await _server.KillAsync();
await base.StopAsync(cancellationToken);
}
private string GetLocalIP()
{
try
{
string hostName = Dns.GetHostName();
IPHostEntry ipEntry = Dns.GetHostEntry(hostName);
for (int i = 0; i < ipEntry.AddressList.Length; i++)
{
if (ipEntry.AddressList[i].AddressFamily == AddressFamily.InterNetwork)
{
return ipEntry.AddressList[i].ToString();
}
}
return "127.0.0.1";
}
catch (Exception ex)
{
_logger.LogError(ex, "Get Local Ip error");
return "127.0.0.1";
}
}
}
然后客户端要定义一个从consul中读取实例的方法。
从consul中读取对应service的健康实例,正常会用定时轮训的方式去读取,或者写入短时间的缓存。
public class FindService : IFindService
{
private readonly ILogger _logger;
private readonly IConsulClient _consulClient;
private readonly ConcurrentDictionary<string, (List<string> List, DateTimeOffset Expiration)> _dict;
public FindService(ILoggerFactory loggerFactory, IConsulClient consulClient)
{
_logger = loggerFactory.CreateLogger<FindService>();
_consulClient = consulClient;
_dict = new ConcurrentDictionary<string, (List<string> List, DateTimeOffset Expiration)>();
}
public async Task<string> FindServiceAsync(string serviceName)
{
var key = $"SD:{serviceName}";
if (_dict.TryGetValue(key, out var item) && item.Expiration > DateTimeOffset.UtcNow)
{
_logger.LogInformation($"Read from cache");
return item.List[new Random().Next(0, item.List.Count)];
}
else
{
var queryResult = await _consulClient.Health.Service(serviceName, string.Empty, true);
var result = new List<string>();
foreach (var serviceEntry in queryResult.Response)
{
result.Add(serviceEntry.Service.Address + ":" + serviceEntry.Service.Port);
}
_logger.LogInformation($"Read from consul : {string.Join(",", result)}");
if (result != null && result.Any())
{
// for demonstration, we make expiration a little big
var val = (result, DateTimeOffset.UtcNow.AddSeconds(600));
_dict.AddOrUpdate(key, val, (x, y) => val);
var count = result.Count;
return result[new Random().Next(0, count)];
}
return "";
}
}
}
调用的时候。
private async Task<(UserInfoService.UserInfoServiceClient Client, string Msg)> GetClientAsync(string name)
{
var target = await _findService.FindServiceAsync(name);
_logger.LogInformation($"Current target = {target}");
if (string.IsNullOrWhiteSpace(target))
{
return (null, "can not find a service");
}
else
{
var channel = new Channel(target, ChannelCredentials.Insecure);
var client = new UserInfoService.UserInfoServiceClient(channel);
return (client, string.Empty);
}
}
然后我们编写docker-compose.yml
, 让它在docker中跑
version: '3.4'
services:
xxxserver1:
image: ${DOCKER_REGISTRY-}xxxserver
build:
context: .
dockerfile: MyBasedServiceA/Dockerfile
ports:
- "9999:9999" # 绑定容器的9999端口到主机的9999端口
depends_on:
- consuldev
networks:
backend:
xxxserver2:
image: ${DOCKER_REGISTRY-}xxxserver
build:
context: .
dockerfile: MyBasedServiceA/Dockerfile
ports:
- "9995:9999" # 绑定容器的9999端口到主机的9995端口
depends_on:
- consuldev
networks:
backend:
xxxclient:
image: ${DOCKER_REGISTRY-}xxxclient
build:
context: .
dockerfile: XXXService/Dockerfile
ports:
- "9000:80"
depends_on:
- consuldev
- xxxserver1
- xxxserver2
networks:
backend:
consuldev:
image: consul:latest
ports:
- "8300:8300"
- "8400:8400"
- "8500:8500"
networks:
backend:
networks:
backend:
driver: bridge
运行结果如下:
当用 docker 把这几个服务都跑起来之后, 可以看到类似下面的输出
也可以用docker ps
命令来看一下那几个服务是不是真的在运行。
同时,我们打开consul的UI界面,可以看到我们服务端的两个实例已经注册上来了。
当我们用客户端去访问的时候,可以发现,第一次它是从consul中取下来了两个ip,然后随机选了一个进行访问。
我们把其中一个服务端(0.4)stop,用来模拟某个节点出现异常,被剔除的情况 ,可以发现consul上面已经看不到了,只剩下0.3这个节点了。
如果我们的调度策略没有及时将"死掉"的节点剔除,就会出现下面的这种情况。
最后,把stop的服务端启动,模拟恢复正常,这个时候可以发现无论调度到那个节点都可以正常访问了。
.NET Core 2.x 和 .NET Core 3.0的细微区别
在.NET Core 2.x中,我们的Server,是需要手动控制的,在.NET Core 3.0中,可以认为它已经和Kestrel融为一体了,不再需要我们再手动去Start了。
同样的,服务的实现,也和Endpoint Routing紧密的结合在一起了,不再和之前一样了。
可以看看下面的例子,可能会发现,这是一种熟悉的不能再熟悉的感觉。
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddGrpc(x=>
{
x.EnableDetailedErrors = true;
x.Interceptors.Add<AccessLogInterceptor>();
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGrpcService<GreeterService>();
endpoints.MapGrpcService<UserService>();
endpoints.MapGet("/", async context =>
{
await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
});
});
}
}
客户端的用法也和以前不一样了,直接看一个例子就很清晰了。
[HttpGet]
public async Task<string> GetAsync(CancellationToken cancellationToken)
{
// fixed https error
var httpclientHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (message, cert, chain, error) => true
};
var httpClient = new HttpClient(httpclientHandler)
{
// The port number(5001) must match the port of the gRPC server.
BaseAddress = new Uri("https://localhost:5001")
};
try
{
var client = GrpcClient.Create<UserInfoRpcService.UserInfoRpcServiceClient>(httpClient);
var callOptions = new Grpc.Core.CallOptions()
// StatusCode=Cancelled
.WithCancellationToken(cancellationToken)
// StatusCode=DeadlineExceeded
.WithDeadline(DateTime.UtcNow.AddMilliseconds(2000));
var reply = await client.GetByIdAsync(new GetUserByIdRequest { Id = 1 }, callOptions);
return reply.Name;
}
catch (Exception ex)
{
_logger.LogError(ex, "some exception occure");
return "error";
}
}
从它的用法上,我们也有熟悉的面孔 HttpClient。
虽然gRPC是基于HTTP/2的,但是可以看到我们上面小节的例子中,还是能够指定不使用的。然而到.NET Core 3.0之后,我们就必须要使用https了,不然客户端就是调不通的。同样的,我们也可以在grpc-dotnet的仓库上面看到,如果想不使用HTTP/2,就让我们用回之前的老库,不要用新库,James Newton-King就是这么直接。
https://github.com/grpc/grpc-dotnet/issues/277
https://github.com/grpc/grpc-dotnet/issues/405
https://github.com/grpc/grpc-dotnet/issues/431
扩展阅读
- 思考gRPC :为什么是HTTP/2
- Grpc中Deadline分析
- protocol-buffers官网
- Using CancellationTokens in ASP.NET Core MVC controllers
文中出现的示例代码都可以在下面这个仓库找到