はじめに
現在配属されているプロジェクトで、既存システムのマイクロサービス化対応を行っています。
その中でgRPCも使っているのですが、名前は聞いたことがあって、なんとなくは知っているけど、実際に触ったことがなかったので、これを機に基礎から学んでみようと思いました。
ということで、「完全に理解した」状態を一緒に目指しましょう!
gRPCとは
Googleが開発したRPC(Remote Procedure Call)1を実現するためのフレームワークです。
-
Protocol Buffersというバイナリ形式のシリアライズデータを利用し、データをシリアライズして高速な通信を実現します。
(Protocol Buffersがデファクトスタンダード) -
protoファイルと呼ばれるIDL(Interface Definition Language)にAPIの仕様を記述し、サーバサイドとクライアントサイドのスタブコードを自動生成できます。
-
自動生成コードは多言語対応で、サーバサイドとクライアンサイドが異なる言語でも問題ありません。
-
通信プロトコルにはHTTP/2が使われています。
(Webで基本的に利用されているHTTP/1.1より通信速度が速い)
特にHTTP/2はHTTP/1.1よりも通信速度が速いことや、ProtoBufはJSONなどと比較するとデータ量が少ないことから、サービス間の効率的かつ高速な通信が可能であり、マイクロサービスアーキテクチャに適しています。
触ってみよう
百聞は一見にしかずということで、実際に簡単な例を用いてgRPCを実装してみましょう。
今回はChatGPTさんに聞いたら、初心者はpythonがおすすめとのことだったので、pythonで実装します。
公式のチュートリアルはGoですが、ChatGPTさんを信じます。笑
※前提:pythonがインストールされていること
0. 実装の大まかな流れ & 最終構成イメージ
1. .proto
ファイルを作成する
2. .proto
ファイルからスタブを作成する
3. ClientサービスとServerサービスを作成する(スタブを利用)
Server側でサービスを立ち上げた状態で、Clientからリクエストを投げると、Serverからレスポンスが返ってくる
という状態を目指します。
1. gRPCとProtocol Buffersのインストール
gRPCを実装する上で必要なパッケージのインストールをします。
pip install grpcio
pip install grpcio-tools
2. Protocol Buffers定義の作成
.proto
ファイルでは、gRPCで使用されるProtocol Buffers(ProtoBuf)のインターフェース定義言語(IDL)を使って、サービスのインターフェース(約束事・契約などと呼ばれます)とメッセージ構造を定義しています。
syntax = "proto3";
package helloworld;
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings.
message HelloReply {
string message = 1;
}
コード詳細
syntax = "proto3";
- ファイルがProtoBufのバージョン3の構文(proto3)を使用していることを示しています。
パフォーマンス向上やシンプルな構文、サポート言語の幅広さなどからバージョン3を使用することが推奨されています。
package helloworld;
-
helloworld
という「名前空間」を定義しています。 - このパッケージ名は、生成されるコード内での名前の衝突を防ぐために使用されます。
- 同じ
.proto
ファイル内、または他の.proto
ファイルから参照される際に、このパッケージ名を通じて特定のメッセージやサービスを識別できます。
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
- gRPC定義の本体。gRPCサービスを定義しています。
- サービスの中には、一つまたは複数のRPCを定義し、クライアントがサーバにリクエストを送信し、サーバがレスポンスを返すためのインターフェースを提供します。
- ここでは
Greeter
という名前のサービスが定義されており、その中で一つのRPCメソッドSayHello
を定義しています。 -
SayHello
は、HelloRequest
型のメッセージを受け取り、HelloReply
型のメッセージを返します。
一般的にはビジネスロジック単位(ユーザ管理、在庫管理など)や機能単位(支払い処理、メール送信など)でサービスを分割して作成します。
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
- gRPCでは、やり取りされるデータをメッセージとして定義します。
-
HelloRequest
メッセージにはname
という文字列型のフィールド名が定義されています。
フィールド名の右の数字はフィールド番号といい、メッセージごとに一意な整数(19000~19999を除く、1~536,870,911(2^29-1))を指定します。
連番じゃなくても良いです。
Protocol Buffersでは、フィールド名ではなくフィールド番号を使ってメッセージ構造を保存します。
(整数を使うことで少ないバイト数でやり取りができる=速い!!)
3. サーバとクライアントのスタブコード生成
先程作成した、.proto
ファイルからPythonのサーバとクライアントのスタブコードを生成します。
一番初めにインストールした、grpc_tools
の中のprotoc
コマンドを用いて、スタブコードを自動生成できます。
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. helloworld.proto
-
-I
:.proto
ファイルが格納されているディレクトリを指定 -
--python_out
: 生成されるPythonクラスの出力ディレクトリを指定 -
--grpc_python_out
: 生成されるgRPCのサービススタブの出力ディレクトリを指定
生成されるファイルは以下の2つ。
-
helloworld_pb2.py
: メッセージ定義ファイル(メッセージクラスの定義を含むファイル) -
helloworld_pb2_grpc.py
: サービス定義ファイル(gRPCサービスのクライアントとサーバーのスタブを含むファイル)
補足
先程の図の再掲になりますが、実際には、クライアント・サーバ間通信はStubが行います。
具体的な流れは以下のとおりです。
- ①クライアントアプリケーション
-
_pb2_grpc.py
ファイルから生成されたクライアントスタブを使用してサーバーのメソッドを呼び出します。リクエストデータは、_pb2.py
で定義されたメッセージ形式に従って構築されます。 - ②gRPC通信
- リクエストはgRPCプロトコルを使用してHTTP/2ベースの通信でサーバーに送信されます。
- ③サーバアプリケーション
-
_pb2_grpc.py
ファイルから生成されたサーバースタブがクライアントからのリクエストを受け取ります。リクエストは_pb2.py
で定義されたメッセージ形式に従って解析され、適切なサービスメソッドが呼び出されます。 - ④レスポンスの送信
- サービスメソッドの実行結果は、再び
_pb2.py
で定義されたメッセージ形式に従ってレスポンスとして構築され、クライアントに返送されます。
このように、gRPCのスタブはクライアントとサーバ間の通信を容易にしてくれます。
(内部では、やり取りされるデータをProtocol Buffers形式に変換(シリアライズ/デシリアライズ)してくれています。)
他にも、異なる言語で書かれたクライアントとサーバ間でもスタブを通じて簡単に通信ができたり、インターフェースを提供してくれるため、コードミスに気が付きやすいなどもメリットです。
さらにこれらのスタブは.proto
ファイルさえ定義してしまえば自動で生成してくれるため、開発コストも削減できてしまいます。
スタブについては以下の記事がわかりやすかったです。
https://zenn.dev/efx/articles/e90a93c1bd210e#grpc%E3%81%AEstub%E3%81%A8%E3%81%AF
4. サーバの実装
先程生成したスタブコードを用いて、サーバの実装をします。
from concurrent import futures
import grpc
import helloworld_pb2
import helloworld_pb2_grpc
class Greeter(helloworld_pb2_grpc.GreeterServicer):
def SayHello(self, request, context):
return helloworld_pb2.HelloReply(message='Hello, %s!' % request.name)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
if __name__ == '__main__':
serve()
コード詳細
from concurrent import futures
import grpc
import helloworld_pb2
import helloworld_pb2_grpc
- 必要なものをインポートします。
-
concurrent import futures
は非同期実行をサポートするためのモジュール -
grpc
は言わずもがな、gRPCのPythonパッケージ - 残り2つは先程作成したスタブコード
class Greeter(helloworld_pb2_grpc.GreeterServicer):
def SayHello(self, request, context):
return helloworld_pb2.HelloReply(message='Hello, %s!' % request.name)
-
Greeter
クラスはスタブ内で定義されているGreeterServicer
を継承しており、.proto
ファイルで定義したGreeter
サービスの実装を提供しています。 -
SayHello
メソッドは、クライアントからのHelloRequest
リクエストを受け取り、HelloReply
メッセージを返します。このメソッドは、クライアントが送信した名前(request.name
)を使ってレスポンスを作成します。
gRPCを実装する上で、(今回でいうと)GreeterServicer
を継承したGreeter
クラスを定義することは必須であり、その中でSayHello
メソッドを定義することも必須です。
(.proto
ファイルの中でGreeter
クラスがあり、SayHello
メソッドを持つと定義したため。)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
helloworld_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
- gRPCサーバの起動処理が記述されています。
-
add_GreeterServicer_to_server
メソッドは、スタブ内で定義されており、Greeter
サービスをサーバに登録します。 -
50051
ポートでリクエストを受け付けます。 -
server.start()
でサーバが起動します。 -
server.wait_for_termination()
は、サーバが終了するまで現在のスレッドをブロックします。
if __name__ == '__main__':
serve()
- スクリプトが直接実行された場合に
serve
関数を呼び出します。これにより、gRPCサーバが起動し、クライアントからのリクエストを待ち受けます。
5. クライアントの実装
最後に、サーバにリクエストを送信するクライアント側の実装を行います。
import grpc
import helloworld_pb2
import helloworld_pb2_grpc
def run():
with grpc.insecure_channel('localhost:50051') as channel:
stub = helloworld_pb2_grpc.GreeterStub(channel)
response = stub.SayHello(helloworld_pb2.HelloRequest(name='you'))
print("Greeter client received: " + response.message)
if __name__ == '__main__':
run()
コード詳細
def run():
with grpc.insecure_channel('localhost:50051') as channel:
stub = helloworld_pb2_grpc.GreeterStub(channel)
response = stub.SayHello(helloworld_pb2.HelloRequest(name='you'))
print("Greeter client received: " + response.message)
-
grpc.insecure_channel('localhost:50051')
: gRPCサーバへの接続を開設します。ここでは、セキュリティがない(非暗号化の)接続を意味するinsecure_channel
を使用し、ローカルホスト上の50051
ポートに接続します。 -
helloworld_pb2_grpc.GreeterStub(channel)
: サーバのGreeter
サービスにアクセスするためのスタブ(クライアントサイドのプロキシ)を生成します。このスタブを使ってサーバのメソッドを呼び出します。 -
stub.SayHello(helloworld_pb2.HelloRequest(name='you'))
:SayHello
メソッドをリモート呼び出しします。リクエストにはHelloRequest
メッセージを使用し、name='you'
というパラメータを設定します。 -
print("Greeter client received: " + response.message)
: サーバからのレスポンス(HelloReply
メッセージ)を受け取り、そのmessageフィールドの内容をコンソールに出力します。
他の要素はサーバの実装と同等なため、説明は省きます。
6. 稼働確認
一通り実装が完了したので、稼働確認をしましょう。
まずは、ターミナルを2つ(サーバ用とクライント用)立ち上げます。
サーバ側で以下のコマンドを叩き、サーバを起動します。
python greeter_server.py
サーバ側が待機状態になったら以下のコマンドをクライアント側で叩いて、リクエストを送信します。
python greeter_client.py
以下のようにクライアント側に返ってきたら成功です!!
お疲れさまでした!
Greeter client received: Hello, you!
せっかくなので、もう少し踏み込んでみる
もう少し実務に近い実装をしてみたいと思います。以下の簡単なシナリオを想定して、実装してみましょう。
- ユーザ情報を管理するマイクロサービス(UserService)と、ユーザの注文を処理する別のマイクロサービス(OrderService)を作成します。
- UserServiceはユーザの詳細を返し、OrderServiceはそのユーザの注文情報を返します。
※今回は各コードの説明は省きます。
0. 実装イメージ
- UserService : ユーザ情報を取得するサービス。
- OrderService : 特定のユーザの注文情報を取得するサービス。
- API Gateway : クライアントのリクエストを適切なサービスにルーティングする。
最終的には、2つのサービスを起動した状態でAPIGatewayを起動し、ユーザIDを入力すると、そのIDに対応する「ユーザ情報」と「注文情報」が表示されます。
1. .protoファイルの作成・コンパイル
syntax = "proto3";
package user;
service UserService {
rpc GetUser (UserRequest) returns (UserResponse) {}
}
message UserRequest {
string user_id = 1;
}
message UserResponse {
string user_id = 1;
string name = 2;
string email = 3;
}
syntax = "proto3";
package order;
service OrderService {
rpc GetOrder (OrderRequest) returns (OrderResponse) {}
}
message OrderRequest {
string user_id = 1;
}
message OrderResponse {
string order_id = 1;
string user_id = 2;
string product_name = 3;
int32 quantity = 4;
}
2. サーバの実装
UserServiceサーバは50051
ポートで、OrderServiceサーバは50052
ポートで受け付けます。
from concurrent import futures
import grpc
import user_service_pb2
import user_service_pb2_grpc
class UserService(user_service_pb2_grpc.UserServiceServicer):
def GetUser(self, request, context):
# ビジネスロジックの例: ユーザIDに基づいてユーザ情報を検索
if request.user_id == "123":
# 特定のユーザIDに対するレスポンス
return user_service_pb2.UserResponse(user_id=request.user_id, name="Alice", email="alice@example.com")
else:
# ユーザが見つからない場合のレスポンス
return user_service_pb2.UserResponse(user_id=request.user_id, name="Unknown", email="")
def serve():
# サーバー設定
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
user_service_pb2_grpc.add_UserServiceServicer_to_server(UserService(), server)
server.add_insecure_port('[::]:50051')
server.start()
server.wait_for_termination()
if __name__ == '__main__':
serve()
from concurrent import futures
import grpc
import order_service_pb2
import order_service_pb2_grpc
class OrderService(order_service_pb2_grpc.OrderServiceServicer):
def GetOrder(self, request, context):
# ビジネスロジックの例: ユーザIDに基づいて注文情報を取得
if request.user_id == "123":
# 特定のユーザIDに対する注文情報のレスポンス
return order_service_pb2.OrderResponse(order_id="order123", user_id=request.user_id, product_name="Book", quantity=1)
else:
# 注文が見つからない場合のレスポンス
return order_service_pb2.OrderResponse(order_id="", user_id=request.user_id, product_name="", quantity=0)
def serve():
# サーバー設定
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
order_service_pb2_grpc.add_OrderServiceServicer_to_server(OrderService(), server)
server.add_insecure_port('[::]:50052')
server.start()
server.wait_for_termination()
if __name__ == '__main__':
serve()
3. クライアントの実装
import grpc
import user_service_pb2
import user_service_pb2_grpc
import order_service_pb2
import order_service_pb2_grpc
def get_user_info(user_id):
with grpc.insecure_channel('localhost:50051') as channel:
stub = user_service_pb2_grpc.UserServiceStub(channel)
response = stub.GetUser(user_service_pb2.UserRequest(user_id=user_id))
return response
def get_order_info(user_id):
with grpc.insecure_channel('localhost:50052') as channel:
stub = order_service_pb2_grpc.OrderServiceStub(channel)
response = stub.GetOrder(order_service_pb2.OrderRequest(user_id=user_id))
return response
def run():
user_id = input("Enter user ID: ") # ユーザの入力を受け取る
user_info = get_user_info(user_id)
order_info = get_order_info(user_id)
print("User Info:", user_info)
print("Order Info:", order_info)
if __name__ == '__main__':
run()
4. 稼働確認
サーバが先程から1つ増えたので、ターミナルを3つ立ち上げます。
UserService用のターミナル
python user_service.py
OrderService用のターミナル
python order_service.py
サーバを立ち上げた状態で、APIGateway用のターミナルで以下コマンドを叩くと、IDの入力を促されるので、「123」と入力します。
python api_gateway.py
Enter user ID: 123
すると、以下のようにレスポンスが返ってくるはずです。
User Info: user_id: "123"
name: "Alice"
email: "alice@example.com"
Order Info: order_id: "order123"
user_id: "123"
product_name: "Book"
quantity: 1
試しに、IDを「111」と入力すると、以下のように返ってきます。
User Info: user_id: "111"
name: "Unknown"
Order Info: user_id: "111"
これでおしまいです!!
お疲れさまでした!!
おわりに
ここまで読んでいただいた方ありがとうございました!!
今回はgRPCの概要説明からはじめて、2つの例を用いてハンズオンを行いました。
名前だけ聞いたことがあったgRPCですが、たぶんこれで完全に理解しました。笑
いずれはk8sと組み合わせて、実務に近い実装ができたらいいなあなんて思ったり…
-
RPC : 別のコンピュータ上にあるプログラムを、自分のプログラム内から呼び出すための技術 ↩