使用Envoy將gRPC轉碼為HTTP/JSON

ServiceMesher2018-12-07 00:30:14

原文鏈接:https://blog.jdriven.com/2018/11/transcoding-grpc-to-http-json-using-envoy/ 作者:Christophe Hesters 譯者:馬若飛

試用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

我已經在配置文件中添加了一些標記來強調我們感興趣的部分:

  • #1 admin接口的地址。你也可以在這裡獲取prometheus的測量數據去查詢服務是怎樣執行的。

  • #2 HTTP API的可用地址。

  • #3 將請求路由到後端服務的名稱。步驟 #7 定義這個名字。

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

  • #5 轉碼的服務。

  • #6 Protobuf字段名通常包含下劃線。設置該選項為false會將字段名轉換為駝峰式。

  • #7 集群定義了上游服務(在步驟#3中Envoy代理的服務)。

  • #8 可連接後端服務的地址和端口。我使用了127.0.0.1/localhost。

步驟 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

注意,我在docker run命令中將-network設置為“host”。這意味著在本地可以訪問正在運行的容器,而不需要額外的網絡配置。根據頁面 docs.docker.com/docker-for-mac/networking/的建議,應該更改步驟#8中Envoy配置的IP地址為host.docker.internal 或 gateway.docker.internal。

通過HTTP訪問服務

如果一切順利,你現在可以使用curl命令來訪問服務。Linux下你可以直接連接localhost,但是在windows或者Mac下你可能需要通過虛擬機或docker容器的IP地址連接。有很多方法可以配置docker,這裡使用localhost。

通過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

  • IBMIstio

點擊【閱讀原文】跳轉到ServiceMesher網站上瀏覽可以查看文中的鏈接。

相關閱讀推薦

  • 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


閱讀原文

TAGS: