一個簡單的案例入門 gRPC

語言: CN / TW / HK

這篇文章本來要在年前和小夥伴們見面,但是因為我之前的 Mac 系統版本是 10.13.6,這個版本比較老,時至今天在執行一些新鮮玩意的時候有時候會有一些 BUG(例如執行最新版的 Nacos 等),執行 gRPC 的外掛也有 BUG,程式碼總是生成有問題,但是因為系統升級是一個大事,所以一直等到過年放假,在家才慢慢折騰將 Mac 升級到目前的 13.1 版本,之前這些問題現在都沒有了,gRPC 的案例現在也可以順利跑起來了。

所以今天就來和小夥伴們簡單聊一聊 gRPC。

1. 緣起

我為什麼想寫一篇 gRPC 的文章呢?其實本來我是想和小夥伴們梳理一下在微服務中都有哪些跨進城呼叫的方式,在梳理的過程中想到了 gRPC,發現還沒寫文章和小夥伴們聊過 gRPC,因此打算先來幾篇文章和小夥伴們詳細介紹一下 gRPC,然後再梳理微服務中的跨程序方案。

2. 什麼是 gRPC

瞭解 gRPC 之前先來看看什麼是 RPC。

RPC 全稱是 Remote Procedure Call,中文一般譯作遠端過程呼叫。RPC 是一種程序間的通訊模式,程式分佈在不同的地址空間裡。簡單來說,就是兩個程序之間互相呼叫的一種方式。

gRPC 則是一個由 Google 發起的開源的 RPC 框架,它是一個高效能遠端過程呼叫 (RPC) 框架,可以在任何環境中執行。gRPC 通過對負載均衡、跟蹤、健康檢查和身份驗證的可插拔支援,有效地連線資料中心內和資料中心之間的服務。

在 gRPC 中,客戶端應用程式可以直接呼叫部署在不同機器上的服務端應用程式中的方法,就好像它是本地物件一樣,使用 gRPC 可以更容易地建立分散式應用程式和服務。與許多 RPC 系統一樣,gRPC 基於定義服務的思想,指定基於引數和返回型別遠端呼叫的方法。在服務端側,服務端實現介面,執行 gRPC 服務,處理客戶端呼叫。在客戶端側,客戶端擁有存根(Stub,在某些語言中稱為客戶端),它提供與服務端相同的方法。

gRPC 客戶端和服務端可以在各種環境中執行和相互通訊 – 從 Google 內部的伺服器到你自己的桌面 – 並且可以使用 gRPC 支援的任何語言編寫。因此,你可以輕鬆地用 Java 建立 gRPC 服務端,使用 Go、Python 或 Ruby 建立客戶端。此外,最新的 Google API 將包含 gRPC 版本的介面,使你輕鬆地將 Google 功能構建到你的應用程式中。

gRPC 支援的語言版本:

說了這麼多,還是得整兩個小案例小夥伴們可能才會清晰,所以我們也不廢話了,上案例。

3. 實踐

先來看下我們的專案結構:

├── grpc-api
│   ├── pom.xml
│   ├── src
├── grpc-client
│   ├── pom.xml
│   ├── src
├── grpc-server
│   ├── pom.xml
│   ├── src
└── pom.xml

大家看下,這裡首先有一個 grpc-api,這個模組用來放我們的公共程式碼;grpc-server 是我們的服務端,grpc-client 則是我們的客戶端,這些都是普通的 maven 專案。

3.1 grpc-api

在 grpc-api 中,我們首先引入專案依賴,如下:

<dependencies>
    <dependency>
        <groupid>io.grpc</groupid>
        <artifactid>grpc-netty-shaded</artifactid>
        <version>1.52.1</version>
    </dependency>
    <dependency>
        <groupid>io.grpc</groupid>
        <artifactid>grpc-protobuf</artifactid>
        <version>1.52.1</version>
    </dependency>
    <dependency>
        <groupid>io.grpc</groupid>
        <artifactid>grpc-stub</artifactid>
        <version>1.52.1</version>
    </dependency>
    <dependency> <!-- necessary for Java 9+ -->
        <groupid>org.apache.tomcat</groupid>
        <artifactid>annotations-api</artifactid>
        <version>6.0.53</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

除了這些常規的依賴之外,還需要一個外掛:

<build>
    <extensions>
        <extension>
            <groupid>kr.motd.maven</groupid>
            <artifactid>os-maven-plugin</artifactid>
            <version>1.6.2</version>
        </extension>
    </extensions>
    <plugins>
        <plugin>
            <groupid>org.xolstice.maven.plugins</groupid>
            <artifactid>protobuf-maven-plugin</artifactid>
            <version>0.6.1</version>
            <configuration>
                <protocartifact>com.google.protobuf:protoc:3.21.7:exe:${os.detected.classifier}</protocartifact>
                <pluginid>grpc-java</pluginid>
                <pluginartifact>io.grpc:protoc-gen-grpc-java:1.51.0:exe:${os.detected.classifier}</pluginartifact>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compile-custom</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

我來說一下這個外掛的作用。

預設情況下,gRPC 使用 Protocol Buffers,這是 Google 提供的一個成熟的開源的跨平臺的序列化資料結構的協議,我們編寫對應的 proto 檔案,通過上面這個外掛可以將我們編寫的 proto 檔案自動轉為對應的 Java 類。

> 多說一句,使用 Protocol Buffers 並不是必須的,也可以使用 JSON 等,但是目前來說這個場景更常用的還是 Portal Buffers。

接下來我們在 main 目錄下新建 proto 資料夾,如下:

注意,這個資料夾位置是預設的。如果我們的 proto 檔案不是放在 src/main/proto 位置,那麼在配置外掛的時候需要指定 proto 檔案的位置,咱們本篇文章主要是入門,我這裡就使用預設的位置。

在 proto 資料夾中,我們新建一個 product.proto 檔案,內容如下:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "org.javaboy.grpc.demo";
option java_outer_classname = "ProductProto";

package product;

service ProductInfo {
  rpc addProduct (Product) returns (ProductId);
  rpc getProduct(ProductId) returns(Product);
}

message Product {
  string id = 1;
  string name=2;
  string description=3;
  float price=4;
}

message ProductId {
  string value = 1;
}

這段配置算是一個比較核心的配置了,這裡主要說明了負責程序傳輸的類、方法等到底是個啥樣子:

  1. syntax = "proto3";:這個是 protocol buffers 的版本。
  2. option java_multiple_files = true;:這個欄位是可選的,如果設定為 true,表示每一個 message 檔案都會有一個單獨的 class 檔案;否則,message 全部定義在 outerclass 檔案裡。
  3. option java_package = "org.javaboy.grpc.demo";:這個欄位是可選的,用於標識生成的 java 檔案的 package。如果沒有指定,則使用 proto 裡定義的 package,如果package 也沒有指定,那就會生成在根目錄下。
  4. option java_outer_classname = "ProductProto";:這個欄位是可選的,用於指定 proto 檔案生成的 java 類的 outerclass 類名。什麼是 outerclass?簡單來說就是用一個 class 檔案來定義所有的 message 對應的 Java 類,這個 class 就是 outerclass;如果沒有指定,預設是 proto 檔案的駝峰式;
  5. package product;:這個屬性用來定義 message 的包名。包名的含義與平臺語言無關,這個 package 僅僅被用在 proto 檔案中用於區分同名的 message 型別。可以理解為 message 全名的字首,和 message 名稱合起來唯一標識一個 message 型別。當我們在 proto 檔案中匯入其他 proto 檔案的 message,需要加上 package 字首才行。所以包名是用來唯一標識 message 的。
  6. service:我們定義的跨平臺方法都寫在 service 中,上面的案例中我們定義了兩個方法:addProduct 表示新增一件商品,引數是一個 Product 物件,返回值則是剛剛新增成功的商品的 ID;getProduct 則表示根據 ID 查詢一個商品,引數是一個商品 ID,返回值則是查詢到的商品物件。這裡的定義相當於一個介面,將來我們要在 Java 程式碼中實現這個介面。
  7. message:這裡有點像我們在 Java 中定義類,上文中我們定義了兩個類,分別是 Product 和 ProductId 兩個類。這兩個類在 service 中被使用。

message 中定義的有點像我們 Java 中定義的類,但是不能直接使用 Java 中的資料型別,畢竟這是 Protocol buffers,這個是和語言無關的,將來可以據此生成不同語言的程式碼,這裡我們可以使用的型別和我們 Java 型別之間的對應關係如下:

另外我們在 message 中定義的屬性的時候,都會給一個數字,例如 id=1,name=2 等,這個數字將來會在二進位制訊息中標識我們的欄位,並且一旦我們的訊息型別被使用就不應更改,這個有點像序列化的感覺。

實際上,這個 message 編譯後的位元組內容大概像下面這樣:

這裡的標籤中的內容包含兩部分,欄位索引和欄位型別,欄位索引其實就是我們上面定義的數字。

定義完成之後,接下來我們就需要使用外掛來生成對應的 Java 程式碼了,外掛我們在前面已經引入了,現在只需要執行了,如下圖:

注意,compile 和 compile-custom 兩個指令都需要執行。其中 compile 用來編譯訊息物件,compile-custom 則依賴訊息物件,生成介面服務。

首先我們點選 compile 看看生成的程式碼,如下:

再看 compile-custom 生成的程式碼,如下:

好了,這樣我們的準備工作就算完成了。

> 有的小夥伴生成的程式碼資料夾顏色不對勁,此時有兩種解決辦法:1.選中目標資料夾,右鍵單擊,選擇 Mark Directory as-> Generated Sources root;2.選中工程,右鍵單擊,選擇 Maven->Reload project。推薦使用第二種方案。

3.2 grpc-server

接下來我們建立 grpc-server 專案,並使該專案依賴 grpc-api,然後在 grpc-server 中,提供 ProductInfo 的具體實現:

public class ProductInfoImpl extends ProductInfoGrpc.ProductInfoImplBase {
    @Override
    public void addProduct(Product request, StreamObserver<productid> responseObserver) {
        System.out.println("request.toString() = " + request.toString());
        responseObserver.onNext(ProductId.newBuilder().setValue(request.getId()).build());
        responseObserver.onCompleted();
    }

    @Override
    public void getProduct(ProductId request, StreamObserver<product> responseObserver) {
        responseObserver.onNext(Product.newBuilder().setId(request.getValue()).setName("三國演義").build());
        responseObserver.onCompleted();
    }
}

ProductInfoGrpc.ProductInfoImplBase 是根據我們在 proto 檔案中定義的 service 自動生成的,我們的 ProductInfoImpl 繼承自該類,並且提供了我們給出的方法的具體實現。

以 addProduct 方法為例,引數 request 就是將來客戶端呼叫的時候傳來的 Product 物件,返回結果則通過 responseObserver 來完成。我們的方法邏輯很簡單,我就把引數傳來的 Product 物件打印出來,然後構建一個 ProductId 物件並返回,最後呼叫 responseObserver.onCompleted(); 表示資料返回完畢。

剩下的 getProduct 方法邏輯就很好懂了,我這裡就不再贅述了。

最後,我們再把這個 grpc-server 專案啟動起來:

public class ProductInfoServer {
    Server server;

    public static void main(String[] args) throws IOException, InterruptedException {
        ProductInfoServer server = new ProductInfoServer();
        server.start();
        server.blockUntilShutdown();
    }

    public void start() throws IOException {
        int port = 50051;
        server = ServerBuilder.forPort(port)
                .addService(new ProductInfoImpl())
                .build()
                .start();
        Runtime.getRuntime().addShutdownHook(new Thread(() -&gt; {
            ProductInfoServer.this.stop();
        }));
    }

    private void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }
}

由於我們這裡是一個 JavaSE 專案,為了避免專案啟動之後就停止,我們這裡呼叫了 server.awaitTermination(); 方法,就是讓服務啟動成功之後不要停止。

3.3 grpc-client

最後再來看看客戶端的呼叫。首先 grpc-client 專案也是需要依賴 grpc-api 的,然後直接進行方法呼叫,如下:

public class ProductClient {
    public static void main(String[] args) {
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051)
                .usePlaintext()
                .build();
        ProductInfoGrpc.ProductInfoBlockingStub stub = ProductInfoGrpc.newBlockingStub(channel);
        Product p = Product.newBuilder().setId("1")
                .setPrice(399.0f)
                .setName("TienChin專案")
                .setDescription("SpringBoot+Vue3實戰視訊")
                .build();
        ProductId productId = stub.addProduct(p);
        System.out.println("productId.getValue() = " + productId.getValue());
        Product product = stub.getProduct(ProductId.newBuilder().setValue("99999").build());
        System.out.println("product.toString() = " + product.toString());
    }
}

小夥伴們看到,這裡首先需要和服務端建立連線,給出服務端的地址和埠號即可,usePlaintext() 方法表示不使用 TLS 對連線進行加密(預設情況下會使用 TLS 對連線進行加密),生產環境建議使用加密連線。

剩下的程式碼就比較好懂了,建立 Product 物件,呼叫 addProduct 方法進行新增;建立 ProductId 物件,呼叫 getProduct。Product 物件和 ProductId 物件都是根據我們在 proto 中定義的 message 自動生成的。

4. 總結

好啦,一個簡單的例子,小夥伴們先對 gRPC 入個門,後面鬆哥會再整幾篇文章跟大家介紹這裡邊的一些細節。</product></productid>