註:本文為翻譯內容,首發於社區:使用Envoy將gRPC轉碼為HTTP/JSON,原文地址:Transcoding gRPC to HTTP JSON using Envoy


試用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個操作組成。服務定義如下:

//reservation_service.proto

syntax = "proto3";

package reservations.v1;
option java_multiple_files = true;
option java_outer_classname = "ReservationServiceProto";
option java_package = "nl.toefel.reservations.v1";

import "google/protobuf/empty.proto";

service ReservationService {

rpc CreateReservation(CreateReservationRequest) returns (Reservation) { }
rpc GetReservation(GetReservationRequest) returns (Reservation) { }
rpc ListReservations(ListReservationsRequest) returns (stream Reservation) { }
rpc DeleteReservation(DeleteReservationRequest) returns (google.protobuf.Empty) { }

}

message Reservation {
string id = 1;
string title = 2;
string venue = 3;
string room = 4;
string timestamp = 5;
repeated Person attendees = 6;
}

message Person {
string ssn = 1;
string firstName = 2;
string lastName = 3;
}

message CreateReservationRequest {
Reservation reservation = 2;
}

message CreateReservationResponse {
Reservation reservation = 1;
}

message GetReservationRequest {
string id = 1;
}

message ListReservationsRequest {
string venue = 1;
string timestamp = 2;
string room = 3;

Attendees attendees = 4;

message Attendees {
repeated string lastName = 1;
}
}

message DeleteReservationRequest {
string id = 1;
}

通常的做法是將操作的入參封裝在請求對象中。這會在以後的操作中添加額外的欄位或選項時更加容易。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添加以下編譯依賴來實現它:

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到代碼示例中:

message GetReservationRequest {
string id = 1;
}

rpc GetReservation(GetReservationRequest) returns (Reservation) {
option (google.api.http) = {
get: "/v1/reservations/{id}"
};
}

在選項定義中有一個名為「get」的欄位,設置為「/v1/reservation /{id}」。欄位名對應於HTTP客戶端應該使用的HTTP請求方法。get的值對應於請求URL。在URL中有一個名為id的路徑變數,這個變數會自動映射到輸入操作中同名的欄位。在本例中,它將是GetReservationRequest.id。

發送 GET /v1/reservations/1234 到代理將轉碼到下面的偽代碼:

var request = GetReservationRequest.builder().setId(1234).build()
var reservation = reservationServiceClient.GetReservation(request)
return toJson(reservation)

HTTP響應體(response body)將返回預訂的所有非空欄位的JSON形式。

記住:轉碼不是由gRPC服務完成的。單獨運行這個示例不會將其發布為HTTP JSON API。前端的代理負責轉碼。我們稍後將對此進行配置。

轉碼CreateReservation操作為POST方法

現在來考慮CreateReservation操作。

message CreateReservationRequest {
Reservation reservation = 2;
}

rpc CreateReservation(CreateReservationRequest) returns (Reservation) {
option(google.api.http) = {
post: "/v1/reservations"
body: "reservation"
};
}

這個操作被轉為POST請求/v1/reservation。選項中的body欄位告訴轉碼器將請求體轉成CreateReservationRequest中的欄位。這意味著我們可以使用以下curl調用:

curl -X POST
http://localhost:51051/v1/reservations
-H Content-Type: application/json
-d {
"title": "Lunchmeeting",
"venue": "JDriven Coltbaan 3",
"room": "atrium",
"timestamp": "2018-10-10T11:12:13",
"attendees": [
{
"ssn": "1234567890",
"firstName": "Jimmy",
"lastName": "Jones"
},
{
"ssn": "9999999999",
"firstName": "Dennis",
"lastName": "Richie"
}
]
}

響應包含同樣的對象,只不過多了一個生成的id欄位。

轉碼帶查詢參數過濾的ListReservations

查詢集合資源的一種常見方法是提供查詢參數作為過濾器。ListReservations的gRPC服務就有此功能。它接收到一個包含可選欄位的ListReservationRequest,用於過濾預訂集合。

message ListReservationsRequest {
string venue = 1;
string timestamp = 2;
string room = 3;

Attendees attendees = 4;

message Attendees {
repeated string lastName = 1;
}
}

rpc ListReservations(ListReservationsRequest) returns (stream Reservation) {
option (google.api.http) = {
get: "/v1/reservations"
};
}

在這裡,轉碼器將自動創建ListReservationsRequest,並將查詢參數映射到ListReservationRequest的內部欄位。沒有指定的欄位都取默認值,對於字元串來說是""。例如:

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

欄位room設置為atrium並映射到ListReservationRequest里,其餘欄位設置為默認值。還可以提供以下子消息欄位:

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

attendees.lastName是一個repeated的欄位,可以被設置多次:

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。然後構建它:

# Script will download gradle if it』s not installed, no need to install it :)
./gradlew.sh clean build # windows: ./gradlew.bat clean build

提示:我創建了腳本自動執行步驟2到4,腳本在項目github.com/toefel18/transcoding-grpc-to-http-json的根目錄下。這將節省你的開發時間。步驟2到4詳細的解釋了它是如何工作的。

./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文件)。

protoc -I. -Ibuild/extracted-include-protos/main --include_imports
--include_source_info
--descriptor_set_out=reservation_service_definition.pb
src/main/proto/reservation_service.proto

這個命令可能看起來很複雜,但實際上非常簡單。-I代表include,protoc尋找.proto文件的目錄。–descriptor_set_out表示包含定義的輸出文件,最後一個參數是我們要處理的原始文件。

步驟 3

我們快要完成了,在運行Envoy之前,最後一件事是創建配置文件。Envoy的配置文件以yaml描述。你可以使用Envoy做很多事情,但是現在讓我們專註於轉碼我們的服務。我從Envoy的網站中獲取了一個基本的配置示例,並使用#標記了感興趣的部分。

admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 } #1

static_resources:
listeners:
- name: main-listener
address:
socket_address: { address: 0.0.0.0, port_value: 51051 } #2
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: grpc_json
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/", grpc: {} }
#3 see next line!
route: { cluster: grpc-backend-services, timeout: { seconds: 60 } }
http_filters:
- name: envoy.grpc_json_transcoder
config:
proto_descriptor: "/data/reservation_service_definition.pb" #4
services: ["reservations.v1.ReservationService"] #5
print_options:
add_whitespace: true
always_print_primitive_fields: true
always_print_enums_as_ints: false
preserve_proto_field_names: false #6
- name: envoy.router

clusters:
- name: grpc-backend-services #7
connect_timeout: 1.25s
type: logical_dns
lb_policy: round_robin
dns_lookup_family: V4_ONLY
http2_protocol_options: {}
hosts:
- socket_address:
address: 127.0.0.1 #8
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代碼庫根目錄的命令:

sudo docker run -it --rm --name envoy --network="host"
-v "$(pwd)/reservation_service_definition.pb:/data/reservation_service_definition.pb:ro"
-v "$(pwd)/envoy-config.yml:/etc/envoy/envoy.yaml:ro"
envoyproxy/envoy

如果Envoy成功啟動將會看到下面的日誌:

[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創建預訂

curl -X POST http://localhost:51051/v1/reservations
-H Content-Type: application/json
-d {
"title": "Lunchmeeting2",
"venue": "JDriven Coltbaan 3",
"room": "atrium",
"timestamp": "2018-10-10T11:12:13",
"attendees": [
{
"ssn": "1234567890",
"firstName": "Jimmy",
"lastName": "Jones"
},
{
"ssn": "9999999999",
"firstName": "Dennis",
"lastName": "Richie"
}
]
}

輸出:

{
"id": "2cec91a7-d2d6-4600-8cc3-4ebf5417ac4b",
"title": "Lunchmeeting2",
"venue": "JDriven Coltbaan 3",
...

通過HTTP獲取預訂

使用上面創建的ID:

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

輸出應該和創建結果一致。

通過HTTP獲取預訂列表

對於這個例子可能需要以不同的欄位多次執行CreateReservation來驗證過濾器的行為。

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

響應結果是Reservations的數組。

刪除預訂

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工作的很好,但在我看來有時候會返回不正確的狀態碼。比如當我獲取一個合法的預訂:

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

返回狀態碼200,沒錯,但如果我這樣做:

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

Envoy會返回:

415 Unsupported Media Type
Content-Type is missing from the request

我期望返回404而不是上面解釋的錯誤信息。這有一個相關的問題:github.com/envoyproxy/envoy/issues/5010

解決: Envoy將所有請求路由到gRPC服務,如果服務中不存在該路徑,gRPC服務本身就會響應該錯誤。解決方案是在Envoy的配置中添加 gRPC:{} ,使其僅轉發在gRPC服務中實現了的請求:

name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" , grpc: {}} # <--- this fixes it
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提供了一個很好的概述。


推薦閱讀:
相关文章