Buf を使ってgRPCコトハジメ
この記事について
この記事は3-shake Advent Calendar 2024の20日目の記事です。
最近、Buf + Protobufを用いたgRPCのアプリケーションを開発しているので、そちらについて書いていきます。
個人的にgRPCを使った開発が初めてだったので、メモ/備忘録がてらでもあります。
そもそもgRPCってなんすか?
- Googleが開発した高速かつ効率的なRPC(Remote Procedure Call)フレームワーク
- サーバーとクライアントが事前に定義されたサービス仕様(Protocol Buffersを使用)に基づいて通信を行う
- 特徴
- HTTP/2を基盤とした双方向ストリーミング通信のサポート
- バイナリデータを使用する効率的なシリアライズ(Protocol Buffers)
- 多言語対応
Bufってなに?
- Bufは、Protocol Buffers(Protobuf)のモダンなツールセット
- 特徴
-
.proto
ファイルのコード生成とLintチェックを統一的に管理 - 複数の言語に対応したコード生成がシュッとできる
-
サンプル用のプロジェクトの構成
.
├── protos
└── python_project
- Pythonをバックエンドとして使うようにしている
-
protos
ディレクトリにBuf関連の設定ファイルをまとめておく
Buf CLIのインストール
- mac OSの場合
% brew install bufbuild/buf/buf
% buf --version
1.47.2
- Linux, Windowsの場合はインストールガイドを元に提供されているバイナリをダウンロードしてPATHに設定
buf.yamlの作成
以下のコマンドでbufの初期化を行うと
% buf config init
このようなbuf.yamlが作成されます。
# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
version: v2
lint:
use:
- STANDARD
breaking:
use:
- FILE
詳細はこちらにある通り、オプションの指定や、モジュールの導入などを行うことも可能です。
一旦、このままで進めてみましょう。
.proto
の作成
以下のようにprotoファイルを作成する
syntax = "proto3";
package buf_sample_pj.ping.v1;
message PingRequest {
string message = 1;
}
message PingResponse {
string message = 1;
}
service PingService {
rpc Get(PingRequest) returns (PingResponse);
}
この状態で buf lint
を実行すると以下のようにBufで用意されているlint ruleをもとに警告が出されます。
buf_sample_pj/ping/v1/ping.proto:14:11:RPC request type "PingRequest" should be named "GetRequest" or "PingServiceGetRequest".
buf_sample_pj/ping/v1/ping.proto:14:33:RPC response type "PingResponse" should be named "GetResponse" or "PingServiceGetResponse".
警告を元に修正してみます。
syntax = "proto3";
package buf_sample_pj.ping.v1;
message PingServiceGetRequest {
string message = 1;
}
message PingServiceGetResponse {
string message = 1;
}
service PingService {
rpc Get(PingServiceGetRequest) returns (PingServiceGetResponse);
}
buf.gen.yamlの作成
開発言語のインターフェイスファイルを生成するための設定ファイル、buf.gen.yamlを作成する。
今回はPython用に設定しています。
version: v1
plugins:
- plugin: python
out: ../python_project/protos
- plugin: buf.build/protocolbuffers/pyi:v28.3
out: ../python_project/protos
- plugin: buf.build/grpc/python:v1.67.1
out: ../python_project/protos
各フィールドの詳細は以下の通り
-
version: v2
- buf.gen.yaml ファイルのバージョンを指定
- Buf のコード生成に使用する仕様のバージョン
-
plugins
- コード生成時に使用するプラグインをリストで指定
- 各プラグインの設定項目
- plugin: 使用するプラグインの名前またはリモートリポジトリでのパスを指定
- out: 生成されたコードの出力先ディレクトリを指定
- opt: プラグインに渡す追加オプションを指定(今回は未設定)
- 各プラグインの詳細
-
plugin: python
- Python 用のコードを生成する標準プラグイン
-
plugin: buf.build/protocolbuffers/pyi:v28.3
- Python の型ヒント (.pyi ファイル) を生成するプラグイン
-
plugin: buf.build/grpc/python:v1.67.1
- Python 用の gRPC サービスコードを生成するプラグイン
-
buf generateでコードを生成する
protobufをインストール後、
% brew install protobuf
以下のようなシェルを作成して、
#!/bin/bash
rm -rf ../python_project/protos/*
buf generate
find ../python_project/protos/ -type d -exec touch {}/__init__.py \;
実行してみます。
% ./build.sh
生成されるコード
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
from protos.ping.v1 import ping_pb2 as protos_dot_ping_dot_v1_dot_ping__pb2
class PingServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Get = channel.unary_unary(
'/ping.v1.PingService/Get',
request_serializer=protos_dot_ping_dot_v1_dot_ping__pb2.PingServiceGetRequest.SerializeToString,
response_deserializer=protos_dot_ping_dot_v1_dot_ping__pb2.PingServiceGetResponse.FromString,
_registered_method=True)
class PingServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def Get(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_PingServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'Get': grpc.unary_unary_rpc_method_handler(
servicer.Get,
request_deserializer=protos_dot_ping_dot_v1_dot_ping__pb2.PingServiceGetRequest.FromString,
response_serializer=protos_dot_ping_dot_v1_dot_ping__pb2.PingServiceGetResponse.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'ping.v1.PingService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('ping.v1.PingService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class PingService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def Get(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/ping.v1.PingService/Get',
protos_dot_ping_dot_v1_dot_ping__pb2.PingServiceGetRequest.SerializeToString,
protos_dot_ping_dot_v1_dot_ping__pb2.PingServiceGetResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/ping/v1/ping.proto
# Protobuf Python Version: 5.29.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
5,
29,
1,
'',
'protos/ping/v1/ping.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19protos/ping/v1/ping.proto\x12\x07ping.v1\"1\n\x15PingServiceGetRequest\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message\"2\n\x16PingServiceGetResponse\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message2U\n\x0bPingService\x12\x46\n\x03Get\x12\x1e.ping.v1.PingServiceGetRequest\x1a\x1f.ping.v1.PingServiceGetResponseb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'protos.ping.v1.ping_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_PINGSERVICEGETREQUEST']._serialized_start=38
_globals['_PINGSERVICEGETREQUEST']._serialized_end=87
_globals['_PINGSERVICEGETRESPONSE']._serialized_start=89
_globals['_PINGSERVICEGETRESPONSE']._serialized_end=139
_globals['_PINGSERVICE']._serialized_start=141
_globals['_PINGSERVICE']._serialized_end=226
# @@protoc_insertion_point(module_scope)
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Optional as _Optional
DESCRIPTOR: _descriptor.FileDescriptor
class PingServiceGetRequest(_message.Message):
__slots__ = ("message",)
MESSAGE_FIELD_NUMBER: _ClassVar[int]
message: str
def __init__(self, message: _Optional[str] = ...) -> None: ...
class PingServiceGetResponse(_message.Message):
__slots__ = ("message",)
MESSAGE_FIELD_NUMBER: _ClassVar[int]
message: str
def __init__(self, message: _Optional[str] = ...) -> None: ...
生成されたコードを使ってgRPCサービスを実装する
必要なモジュールをインストール
今回はuvを使用しています。
% uv add grpcio
Resolved 2 packages in 357ms
Prepared 1 package in 890ms
Installed 1 package in 3ms
+ grpcio==1.68.1
% uv add protobuf
Resolved 3 packages in 269ms
Prepared 1 package in 149ms
Installed 1 package in 1ms
+ protobuf==5.29.2
% uv add grpcio-reflection
Resolved 4 packages in 196ms
Prepared 1 package in 69ms
Installed 1 package in 2ms
+ grpcio-reflection==1.68.1
Serviceの実装
BaseServiceという抽象クラスをまず実装してみましょう。
add_to_server
という抽象メソッドでgrpcサーバへの登録をカプセル化しています。
from abc import ABC, abstractmethod
import grpc
class BaseService(ABC):
@abstractmethod
def add_to_server(self, server: grpc.aio.Server) -> None:
pass
生成されたPingServiceServicerとBaseServiceと継承したPingServiceを実装。
具象メソッド add_to_server
でServiceをgrpc.serverに追加できるようにしています。
また、protoで定義していた Get
メソッドを実装。ここでは単純にリクエストで受け取ったmessageをそのまま返すだけに留めておきます。
import grpc
from protos.ping.v1.ping_pb2 import PingServiceGetRequest, PingServiceGetResponse
from protos.ping.v1.ping_pb2_grpc import (
PingServiceServicer,
add_PingServiceServicer_to_server,
)
from services.base_service import BaseService
class PingService(PingServiceServicer, BaseService):
def add_to_server(self, server: grpc.aio.server) -> None:
add_PingServiceServicer_to_server(self, server)
def Get(
self, request: PingServiceGetRequest, context: grpc.aio.ServicerContext
) -> PingServiceGetResponse:
return PingServiceGetResponse(message=request.message)
main.pyの実装
main.pyの実装は以下の通りです。
import logging
from concurrent import futures
import grpc
from grpc_reflection.v1alpha import reflection
from services.ping import PingService
def main():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
service_names = [
reflection.SERVICE_NAME,
]
services = [PingService()]
for service in services:
logging.info("Create service: %s", service.__class__.__name__)
service.add_to_server(server)
service_names.append(service.__class__.__name__)
reflection.enable_server_reflection(service_names, server)
bind_address = "[::]:50051"
server.add_insecure_port(bind_address)
logging.info("Starting server on %s", bind_address)
server.start()
logging.info("Server started, waiting for termination...")
server.wait_for_termination()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()
ポイント
-
service_names.append(service.__class__.__name__)
- ここでサービス名を Reflection に追加している。これにより、grpcurlを使うことでサービス名が確認できる。
-
reflection.enable_server_reflection(service_names, server)
- Reflection機能を有効化。これにより、登録されたサービスを外部ツールが動的に検出可能になる。
まとめ
こんな感じでprotoファイルに定義したI/FをBufを用いてLintやコードの生成を行うことができました。
今回はPythonのサーバー側の実装だけに留めましたが、クラアント側のコードの生成も行うことができます。
Bufの導入で統一的なLintとコード生成できるので、良かったですね、という気持ち。
Discussion