使用Envoy将gRPC转码为HTTP/JSON

Posted ServiceMesher

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用Envoy将gRPC转码为HTTP/JSON相关的知识,希望对你有一定的参考价值。

试用gRPC构建服务时要在.proto文件中定义消息(message)和服务(service)。gRPC支持多种语言自动生成客户端、服务端和DTO实现。在读完这篇文章后,你将了解到使用Envoy作为转码代理,使gRPC API也可以通过HTTP/JSON的方式访问。你可以通过github代码库中的Java代码来测试它。有关gRPC的介绍请参阅blog.jdriven.com/2018/10/grpc-as-an-alternative-to-rest/。

为什么要对gRPC服务进行转码?

一旦有了一个可用的gRPC服务,可以通过向服务添加一些额外的注解(annotation)将其作为HTTP/JSON API发布。你需要一个代理来转换HTTP/JSON调用并将其传递给gRPC服务。我们称这个过程为转码。然后你的服务就可以通过gRPC和HTTP/JSON访问。大多数时候我更倾向使用gRPC,因为使用遵循“契约”生成的类型安全的代码更方便、更安全,但有时转码也很有用:

  1. web应用程序可以通过HTTP/JSON调用与gRPC服务通信。github.com/grpc/grpc-web是一个可以在浏览器中使用的javascript的gRPC实现。这个项目很有前途,但还不成熟。

  2. 因为gRPC在网络通信上使用二进制格式,所以很难看到实际发送和接收的内容。将其作为HTTP/JSON API发布,可以使用cURL或postman等工具更容易地检查服务。

  3. 如果你使用的语言gRPC不支持,你可以通过HTTP/JSON访问它。

  4. 它为在项目中更平稳地采用gRPC铺平了道路,允许其他团队逐步过渡。

创建一个gRPC服务:ReservationService

让我们创建一个简单的gRPC服务作为示例。在gRPC中,定义包含远程过程调用(rpc)的类型和服务。你可以随意设计自己的服务,但是谷歌建议使用面向资源的设计(源代码:cloud.google.com/apis/design/resources),因为用户无需知道每个方法是做什么的就可以容易地理解API。如果你创建了许多不固定格式的rpc,用户必须理解每种方法的作用,从而使你的API更难学习。面向资源的设计还可以更好地转换为HTTP/JSON API。

在本例中,我们将创建一个会议预订服务。该服务称为ReservationService,由创建、获取、获取列表和删除预订4个操作组成。服务定义如下:

 
   
   
 
  1. //reservation_service.proto

  2. syntax = "proto3";

  3. package reservations.v1;

  4. option java_multiple_files = true;

  5. option java_outer_classname = "ReservationServiceProto";

  6. option java_package = "nl.toefel.reservations.v1";

  7. import "google/protobuf/empty.proto";

  8. service ReservationService {

  9.    rpc CreateReservation(CreateReservationRequest) returns (Reservation) {  }

  10.    rpc GetReservation(GetReservationRequest) returns (Reservation) {  }

  11.    rpc ListReservations(ListReservationsRequest) returns (stream Reservation) {  }

  12.    rpc DeleteReservation(DeleteReservationRequest) returns (google.protobuf.Empty) {  }

  13. }

  14. message Reservation {

  15.    string id = 1;

  16.    string title = 2;

  17.    string venue = 3;

  18.    string room = 4;

  19.    string timestamp = 5;

  20.    repeated Person attendees = 6;

  21. }

  22. message Person {

  23.    string ssn = 1;

  24.    string firstName = 2;

  25.    string lastName = 3;

  26. }

  27. message CreateReservationRequest {

  28.    Reservation reservation = 2;

  29. }

  30. message CreateReservationResponse {

  31.    Reservation reservation = 1;

  32. }

  33. message GetReservationRequest {

  34.    string id = 1;

  35. }

  36. message ListReservationsRequest {

  37.    string venue = 1;

  38.    string timestamp = 2;

  39.    string room = 3;

  40.    Attendees attendees = 4;

  41.    message Attendees {

  42.        repeated string lastName = 1;

  43.    }

  44. }

  45. message DeleteReservationRequest {

  46.    string id = 1;

  47. }

通常的做法是将操作的入参封装在请求对象中。这会在以后的操作中添加额外的字段或选项时更加容易。ListReservations操作返回一个Reservations列表。在Java中,这意味着你将得到Reservations对象的一个迭代(Iterator)。客户端甚至可以在服务器发送完响应之前就开始处理它们,非常棒。

如果你想知道这个gRPC服务在Java中是如何被使用的,请查看 ServerMain.java 和 ClientMain.java实现。

使用HTTP选项标注服务进行转码

在每个rpc操作的花括号中可以添加选项。Google定义了一个java option,允许你指定如何将操作转换到HTTP请求(endpoint)。在reservation_service.proto中引入 ‘google/api/annotations.proto’即可使用该选项。默认情况下这个import是不可用的,但是你可以通过向build.gradle添加以下编译依赖来实现它:

 
   
   
 
  1. compile "com.google.api.grpc:proto-google-common-protos:1.13.0-pre2"

这个依赖将由protobuf解压并生成几个.proto文件放入构建目录中。现在可以把google/api/annotations.proto引入你的.proto文件中并开始说明如何转换API。

转码GetReservation操作为GET方法

让我们从GetReservation操作开始,我已经添加了GetReservationRequest到代码示例中:

 
   
   
 
  1.  message GetReservationRequest {

  2.       string id = 1;

  3.   }

  4.   rpc GetReservation(GetReservationRequest) returns (Reservation) {

  5.       option (google.api.http) = {

  6.           get: "/v1/reservations/{id}"

  7.       };

  8.   }

在选项定义中有一个名为“get”的字段,设置为“/v1/reservation /{id}”。字段名对应于HTTP客户端应该使用的HTTP请求方法。get的值对应于请求URL。在URL中有一个名为id的路径变量,这个变量会自动映射到输入操作中同名的字段。在本例中,它将是GetReservationRequest.id。

发送 GET /v1/reservations/1234 到代理将转码到下面的伪代码:

 
   
   
 
  1. var request = GetReservationRequest.builder().setId(“1234”).build()

  2. var reservation = reservationServiceClient.GetReservation(request)

  3. return toJson(reservation)

HTTP响应体(response body)将返回预订的所有非空字段的JSON形式。

记住:转码不是由gRPC服务完成的。单独运行这个示例不会将其发布为HTTP JSON API。前端的代理负责转码。我们稍后将对此进行配置。

转码CreateReservation操作为POST方法

现在来考虑CreateReservation操作。

 
   
   
 
  1. message CreateReservationRequest {

  2.   Reservation reservation = 2;

  3. }

  4. rpc CreateReservation(CreateReservationRequest) returns (Reservation) {

  5.   option(google.api.http) = {

  6.      post: "/v1/reservations"

  7.      body: "reservation"

  8.   };

  9. }

这个操作被转为POST请求/v1/reservation。选项中的body字段告诉转码器将请求体转成CreateReservationRequest中的字段。这意味着我们可以使用以下curl调用:

 
   
   
 
  1. curl -X POST \

  2.    http://localhost:51051/v1/reservations \

  3.    -H 'Content-Type: application/json' \

  4.    -d '{

  5.    "title": "Lunchmeeting",

  6.    "venue": "JDriven Coltbaan 3",

  7.    "room": "atrium",

  8.    "timestamp": "2018-10-10T11:12:13",

  9.    "attendees": [

  10.       {

  11.           "ssn": "1234567890",

  12.           "firstName": "Jimmy",

  13.           "lastName": "Jones"

  14.       },

  15.       {

  16.           "ssn": "9999999999",

  17.           "firstName": "Dennis",

  18.           "lastName": "Richie"

  19.       }

  20.    ]

  21. }'

响应包含同样的对象,只不过多了一个生成的id字段。

转码带查询参数过滤的ListReservations

查询集合资源的一种常见方法是提供查询参数作为过滤器。ListReservations的gRPC服务就有此功能。它接收到一个包含可选字段的ListReservationRequest,用于过滤预订集合。

 
   
   
 
  1. message ListReservationsRequest {

  2.    string venue = 1;

  3.    string timestamp = 2;

  4.    string room = 3;

  5.    Attendees attendees = 4;

  6.    message Attendees {

  7.        repeated string lastName = 1;

  8.    }

  9. }

  10. rpc ListReservations(ListReservationsRequest) returns (stream Reservation) {

  11.   option (google.api.http) = {

  12.       get: "/v1/reservations"

  13.   };

  14. }

在这里,转码器将自动创建ListReservationsRequest,并将查询参数映射到ListReservationRequest的内部字段。没有指定的字段都取默认值,对于字符串来说是""。例如:

 
   
   
 
  1. curl http://localhost:51051/v1/reservations?room=atrium

字段room设置为atrium并映射到ListReservationRequest里,其余字段设置为默认值。还可以提供以下子消息字段:

 
   
   
 
  1. curl "http://localhost:51051/v1/reservations?attendees.lastName=Richie"

attendees.lastName是一个repeated的字段,可以被设置多次:

 
   
   
 
  1. curl  "http://localhost:51051/v1/reservations?attendees.lastName=Richie&attendees.lastName=Kruger"

gRPC服务将会知道ListReservationRequest.attendees.lastName是一个有两个元素的列表:Richie和Kruger. Supernice。

运行转码器

是时候让这些运行起来了。Google cloud支持转码,即使运行在Kubernetes (incl GKE) 或计算引擎中。更多信息请参看cloud.google.com/endpoints/docs/grpc/tutorials。

如果你不在Google cloud中运行,或者是在本地运行,那么可以使用Envoy。它是一个由Lyft创建的非常灵活的代理。它也是istio.io中的主要组件。在这个例子中我们将使用它。

为了转码我们需要:

  1. 一个gRPC服务的项目,在.proto文件中包含转码选项。

  2. 从.proto文件中生成的.pd文件包含gRPC服务描述。

  3. 使用该定义,配置Envoy作为gRPC服务的HTTP请求代理。

  4. 使用docker运行Envoy。

步骤 1

我已经创建了如上描述的项目并发布在github上。你可以从这里clone: github.com/toefel18/transcoding-grpc-to-http-json。然后构建它:

 
   
   
 
  1. # Script will download gradle if it’s not installed, no need to install it :)

  2. ./gradlew.sh clean build    # windows: ./gradlew.bat clean build

提示:我创建了脚本自动执行步骤2到4,脚本在项目github.com/toefel18/transcoding-grpc-to-http-json的根目录下。这将节省你的开发时间。步骤2到4详细的解释了它是如何工作的。

 
   
   
 
  1. ./start-envoy.sh

步骤 2

然后我们需要创建.pb文件。我们需要先下载预编译的protoc可执行文件:github.com/protocolbuffers/protobuf/releases/latest(为你的平台选择正确的版本,例如针对Mac的protoc-3.6.1-osx-x86_64.zip),然后解压到你的路径,很简单。

在transcoding-grpc-to-http-json目录下运行下面的命令生成Envoy可以理解的文件 reservation_service_definition.pb (别忘了先构建项目并导入 reservation_service.proto需要的.proto文件)。

 
   
   
 
  1. protoc -I. -Ibuild/extracted-include-protos/main --include_imports \

  2.               --include_source_info \

  3.               --descriptor_set_out=reservation_service_definition.pb \

  4.               src/main/proto/reservation_service.proto

这个命令可能看起来很复杂,但实际上非常简单。-I代表include,protoc寻找.proto文件的目录。–descriptor_set_out表示包含定义的输出文件,最后一个参数是我们要处理的原始文件。

步骤 3

我们快要完成了,在运行Envoy之前,最后一件事是创建配置文件。Envoy的配置文件以yaml描述。你可以使用Envoy做很多事情,但是现在让我们专注于转码我们的服务。我从Envoy的网站中获取了一个基本的配置示例,并使用#标记了感兴趣的部分。

 
   
   
 
  1. admin:

  2.  access_log_path: /tmp/admin_access.log

  3.  address:

  4.    socket_address: { address: 0.0.0.0, port_value: 9901 }         #1

  5. static_resources:

  6.  listeners:

  7.  - name: main-listener

  8.    address:

  9.      socket_address: { address: 0.0.0.0, port_value: 51051 }      #2

  10.    filter_chains:

  11.    - filters:

  12.      - name: envoy.http_connection_manager

  13.        config:

  14.          stat_prefix: grpc_json

  15.          codec_type: AUTO

  16.          route_config:

  17.            name: local_route

  18.            virtual_hosts:

  19.            - name: local_service

  20.              domains: ["*"]

  21.              routes:

  22.              - match: { prefix: "/", grpc: {} }

  23.                #3 see next line!

  24.                route: { cluster: grpc-backend-services, timeout: { seconds: 60 } }

  25.          http_filters:

  26.          - name: envoy.grpc_json_transcoder

  27.            config:

  28.              proto_descriptor: "/data/reservation_service_definition.pb" #4

  29.              services: ["reservations.v1.ReservationService"]            #5

  30.              print_options:

  31.                add_whitespace: true

  32.                always_print_primitive_fields: true

  33.                always_print_enums_as_ints: false

  34.                preserve_proto_field_names: false                        #6

  35.          - name: envoy.router

  36.  clusters:

  37.  - name: grpc-backend-services                  #7

  38.    connect_timeout: 1.25s

  39.    type: logical_dns

  40.    lb_policy: round_robin

  41.    dns_lookup_family: V4_ONLY

  42.    http2_protocol_options: {}

  43.    hosts:

  44.    - socket_address:

  45.        address: 127.0.0.1                       #8

  46.        port_value: 53000

我已经在配置文件中添加了一些标记来强调我们感兴趣的部分:

  • #3 将请求路由到后端服务的名称。步骤 #7 定义这个名字。

  • #4 我们之前生成的.pb描述符文件的路径。

  • #5 转码的服务。

  • #6 Protobuf字段名通常包含下划线。设置该选项为false会将字段名转换为驼峰式。

  • #7 集群定义了上游服务(在步骤#3中Envoy代理的服务)。

步骤 4

我们现在准备运行Envoy。最简单的方式是通过Docker镜像。这需要先安装Docker。如果你还没有,请先安装docker 。

有两个Envoy需要的资源,配置文件和.pb描述文件。我们可以先把文件导入容器以便Envoy启动时找到他们。运行下面github代码库根目录的命令:

 
   
   
 
  1. sudo docker run -it --rm --name envoy --network="host" \

  2.  -v "$(pwd)/reservation_service_definition.pb:/data/reservation_service_definition.pb:ro" \

  3.  -v "$(pwd)/envoy-config.yml:/etc/envoy/envoy.yaml:ro" \

  4.  envoyproxy/envoy

如果Envoy成功启动将会看到下面的日志:

 
   
   
 
  1. [2018-11-10 14:55:02.058][000009][info][main] [source/server/server.cc:454] starting main dispatch loop

通过HTTP访问服务

通过HTTP创建预订

 
   
   
 
  1. curl -X POST http://localhost:51051/v1/reservations \

  2.          -H 'Content-Type: application/json' \

  3.          -d '{

  4.            "title": "Lunchmeeting2",

  5.            "venue": "JDriven Coltbaan 3",

  6.            "room": "atrium",

  7.            "timestamp": "2018-10-10T11:12:13",

  8.            "attendees": [

  9.                {

  10.                    "ssn": "1234567890",

  11.                    "firstName": "Jimmy",

  12.                    "lastName": "Jones"

  13.                },

  14.                {

  15.                    "ssn": "9999999999",

  16.                    "firstName": "Dennis",

  17.                    "lastName": "Richie"

  18.                }

  19.            ]

  20.        }'

输出:

 
   
   
 
  1. {

  2.        "id": "2cec91a7-d2d6-4600-8cc3-4ebf5417ac4b",

  3.        "title": "Lunchmeeting2",

  4.        "venue": "JDriven Coltbaan 3",

  5. ...

通过HTTP获取预订

使用上面创建的ID:

 
   
   
 
  1. curl http://localhost:51051/v1/reservations/ENTER-ID-HERE!

输出应该和创建结果一致。

通过HTTP获取预订列表

对于这个例子可能需要以不同的字段多次执行CreateReservation来验证过滤器的行为。

 
   
   
 
  1. curl "http://localhost:51051/v1/reservations"

 
   
   
 
  1. curl "http://localhost:51051/v1/reservations?room=atrium"

 
   
   
 
  1. curl "http://localhost:51051/v1/reservations?room=atrium&attendees.lastName=Jones"

响应结果是Reservations的数组。

删除预订

 
   
   
 
  1. curl -X DELETE http://localhost:51051/v1/reservations/ENTER-ID-HERE!

返回头

gRPC会返回一些HTTP头。有些可以在调试的时候帮到你:

  • grpc-status:这个值是io.grpc.Status.Code的序数,它能帮助查看gRPC的返回状态。

  • grpc-message:一旦出现问题返回的错误信息。

更多信息请查看github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md

缺陷

1. 如果路径不存在响应很奇怪

Envoy工作的很好,但在我看来有时候会返回不正确的状态码。比如当我获取一个合法的预订:

 
   
   
 
  1. curl http://localhost:51051/v1/reservations/ENTER-ID-HERE!

返回状态码200,没错,但如果我这样做:

 
   
   
 
  1. curl http://localhost:51051/v1/reservations/ENTER-ID-HERE!/blabla

Envoy会返回:

 
   
   
 
  1. 415 Unsupported Media Type

  2. Content-Type is missing from the request

我期望返回404而不是上面解释的错误信息。这有一个相关的问题:github.com/envoyproxy/envoy/issues/5010

解决: Envoy将所有请求路由到gRPC服务,如果服务中不存在该路径,gRPC服务本身就会响应该错误。解决方案是在Envoy的配置中添加' gRPC:{} ',使其仅转发在gRPC服务中实现了的请求:

 
   
   
 
  1. name: local_route

  2.            virtual_hosts:

  3.            - name: local_service

  4.              domains: ["*"]

  5.              routes:

  6.              - match: { prefix: "/" , grpc: {}}  # <--- this fixes it

  7.                route: { cluster: grpc-backend-services, timeout: { seconds: 60 } }

2. 有时候在查询集合时,即使服务器有错误响应,依然会返回空资源‘[]’

我提交了这一问题给Envoy开发者: github.com/envoyproxy/envoy/issues/5011

部分解决方案: 其中一部分是已知的转码限制,因为状态和头是先发送的。在一个响应中转换器首先发送一个200状态码,然后对流进行转码。

即将到来的特性

将来还可以在响应体中返回响应消息的子字段,以便你不想返回完整的响应体。这可以通过HTTP选项中的“response_body”字段完成。如果你想在HTTP API中裁剪包装的对象这是非常合适的。

结语

我希望这篇文章对将gRPC API转码HTTP/JSON提供了一个很好的概述。

相关阅读推荐






Istio

IBM Istio

  • 111 Istio

  • 118 Istio上手

  • 1115 Istio

  • 11月22日 Envoy

  • 1129 使Istio

  • 126 Istio mixer -

  • 1213 Istio

  • 1220 Istio使Serverless knative

点击【阅读原文】跳转到ServiceMesher网站上浏览可以查看文中的链接。

相关阅读推荐





使用Envoy将gRPC转码为HTTP/JSON

  • SOFAMesh(https://github.com/alipay/sofa-mesh)基于Istio的大规模服务网格解决方案

  • SOFAMosn(https://github.com/alipay/sofa-mosn)使用Go语言开发的高性能Sidecar代理

合作社区

参与社区

以下是参与ServiceMesher社区的方式,最简单的方式是联系我!

  • 社区网址:http://www.servicemesher.com

  • Slack:https://servicemesher.slack.com (需要邀请才能加入)

  • GitHub:https://github.com/servicemesher

  • Istio中文文档进度追踪:https://github.com/servicemesher/istio-official-translation

  • Twitter: https://twitter.com/servicemesher

  • 提供文章线索与投稿:https://github.com/servicemesher/trans


以上是关于使用Envoy将gRPC转码为HTTP/JSON的主要内容,如果未能解决你的问题,请参考以下文章

如何将带有请求正文的 HTTP DELETE 转码为 gRPC

ASP.NET Core 搭载 Envoy 实现微服务身份认证(JWT)

ASP.NET Core 搭载 Envoy 实现微服务身份认证(JWT)

使用Istio和Envoy实践服务网格gRPC度量

python2 将TXT文本转码为utf-8

如何使用Babel将ES6转码为ES5