【基礎編】で作成したファイルを元に、ディレクトリ構成を実際のプロジェクトに似せて実装を行います。
こうすることでgRPCの良さをより理解できて欲しい。
【基礎編】で作成したファイルを元に、ディレクトリ構成を実際のプロジェクトに似せて実装を行います。
こうすることでgRPCの良さをより理解できて欲しい。
こんにちは。サーバーエンジニアのりつきです。
当記事は【基礎編】と題した記事の続きですのでまだ読んでいない方はそちらの記事を読んでからご覧ください。
該当記事はこちら。
今回は前回生成するところまで行った.goファイルを利用して実際にgRPCのサーバーとクライアントを実装していきたいと思います。
それにあたり、GitHubを利用しますのでアカウントを作成しておいてください。
実装を行うにあたり、何点か準備が必要です。
基礎編で解説したgRPCの便利なところをより理解できるようにするため、実務の開発を行っているプロジェクトの構成を模倣しているものと理解していただければと思います。
まず手始めにサーバーとクライアントそれぞれのコードを置いておくプロジェクトを用意するのですが、今回はそれぞれのソースコードを「別のプロジェクト」として扱います。
下記を参考にディレクトリとファイルを作成してください。ファイルの中身はまだ空で問題ありません。
- server/
- src/
- gateway/
- greeting.go
- main.go
- client/
- src/
- gateway/
- greeting.go
- main.go
ディレクトリをどのように配置しても実装上は問題はありませんが、親のディレクトリを作成して下記のような構成にしてしまうとgRPCの利点が分かりづらくなったり実装中に混乱を招くことがありますのでご注意ください。
- parent/
- server/...
- client/...
PC内で離れた位置に作成するか、可能であればローカルネットワーク内の別のPCなどを利用してみるとより分かりやすいかと思います。
基礎編で作成した一連のファイルをGitのリモートリポジトリにpushし、各プロジェクトでモジュールとして利用できるようにします。
GitHubの自身のアカウントから空のリポジトリを作成した後examgrpc/直下で以下の作業を行なってください。
リポジトリ名はローカルのディレクトリに合わせてexamgrpcとしておきます。
$ go mod edit -module github.com/{GitHubアカウント名}/examgrpc
$ git init
$ git add .
$ git commit -m "initial commit"
$ git remote add origin {作成したリポジトリのURL}
$ git push origin master
以上で下準備は完了です。次のセクションからサンプルコードを実装していきます。
実装を記載するにあたり、最低限のコメント記載は行いますがgRPCサーバーの立ち上げなど基礎部分の解説は省略します。
他の導入記事や公式リファレンスに委ねつつ今回の記事を通して伝えたい部分の解説に重点を置いて解説しますのでご了承ください。
まずはserver/直下で下記のコマンドを実行してください。
$ go mod init github.com/{GitHubアカウント名}/server
$ go get github.com/{GitHubアカウント名}/examgrpc
server/直下にgo.mod,go.sumが作成されており、go.modの中に今回作成した自作モジュールが記載されていれば準備完了なので、下記のコードを実装してください。
main.go
package main
import (
"fmt"
"log"
"net"
"os"
"os/signal"
examgrpc "github.com/{GitHubアカウント名}/examgrpc/pkg/grpc"
"github.com/{GitHubアカウント名}/server/src/gateway"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
func main() {
// 8080番portのListenerを作成
port := 8080
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
panic(err)
}
// gRPCサーバーを作成
s := grpc.NewServer()
// サーバーにサービスを作成する: ルーティングの定義に近い
examgrpc.RegisterGreetingServiceServer(s, gateway.NewGreetingService())
// サーバーリフレクションの設定
reflection.Register(s)
// 作成したgRPCサーバーを、8080番ポートで稼働させる
go func() {
log.Printf("start gRPC server port: %v", port)
err := s.Serve(listener)
if err != nil {
return
}
}()
// Ctrl+Cが入力されたらGraceful shutdownされるようにする
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
log.Println("stopping gRPC server...")
s.GracefulStop()
}
greeting.go
package gateway
import (
"context"
"fmt"
examgrpc "github.com/{GitHubアカウント名}/examgrpc/pkg/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// インターフェイスを実装した構造体のコンストラクタを定義
func NewGreetingService() *GreetingService {
return &GreetingService{}
}
// 自動生成されたインターフェイスを持った構造体を定義
type GreetingService struct {
// サービスの前方互換性を保つために、自作サービス構造体にはこのUnimplementedGreetingServiceServer型を組み込むべき
examgrpc.UnimplementedGreetingServiceServer
}
// インターフェイスに定義された引数と返り値を持つメソッドを実装する
func (s *GreetingService) Hello(_ context.Context, request *examgrpc.HelloRequest) (*examgrpc.HelloResponse, error) {
message := request.GetMessage()
if message == "error" {
return nil, status.Error(codes.Unknown, "unknown error occurred")
}
// Unary RPC
return &examgrpc.HelloResponse{
ResMessage: fmt.Sprintf("%s too.", message),
}, nil
}
ここまで実装してmain.goを実行すればgRPCサーバーが立ち上がります。
gRPCクライアントアプリのEvansやgRPCurlを利用すれば実装したHelloメソッドが実行できることが確認できます。
まず注目していただきたいのは/gateway/greeting.goの実装です。一つずつ解説していきます。
// インターフェイスを実装した構造体のコンストラクタを定義
func NewGreetingService() *GreetingService {
return &GreetingService{}
}
// 自動生成されたインターフェイスを持った構造体を定義
type GreetingService struct {
// サービスの前方互換性を保つために、自作サービス構造体にはこのUnimplementedGreetingServiceServer型を組み込むべき
examgrpc.UnimplementedGreetingServiceServer
}
ここで定義しているGreetingService型に組み込まれているのは基礎編で自動生成したソースに含まれる構造体で、GreetingServiceServerインターフェイスを実装するために最低限必要なメソッドが実装されています。
service.pb.go
type UnimplementedGreetingServiceServer struct{}
func (UnimplementedGreetingServiceServer) Hello(context.Context, *HelloRequest) (*HelloResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Hello not implemented")
}
func (UnimplementedGreetingServiceServer) mustEmbedUnimplementedGreetingServiceServer() {}
これを組み込んでおくことで、新しくメソッドを定義したとしても自作構造体にGreetingServiceServerインターフェイスが実装されている状態が保てます。
// インターフェイスに定義された引数と返り値を持つメソッドを実装する
func (s *GreetingService) Hello(_ context.Context, request *examgrpc.HelloRequest) (*examgrpc.HelloResponse, error) {
message := request.GetMessage()
if message == "error" {
return nil, status.Error(codes.Unknown, "unknown error occurred")
}
// Unary RPC
return &examgrpc.HelloResponse{
ResMessage: fmt.Sprintf("%s too.", message),
}, nil
}
上記はサービスで定義したメソッドの実装部分で、通信の定義に対して実際に行いたい処理を実装します。
この記事では文字列を付け足すのみですが、実際の開発ではここでリクエストを元にDBを更新したりログを出力したり、さらに別のサーバーへリクエストを飛ばしたりして実務での要件を満たします。
メソッドと構造体の定義自体は自動生成されているので、強く意識することなく通信仕様に準じた実装をすることができます。
続けてmain.go内の実装の解説をしていくのですが、こちらは要点に絞って解説します。
// gRPCサーバーを作成
s := grpc.NewServer()
// サーバーにサービスを登録する: ルーティングの定義に近い
examgrpc.RegisterGreetingServiceServer(s, gateway.NewGreetingService())
この部分で実行されているRegisterGreetingServiceServer()メソッドは基礎編で自動生成したソースに含まれるメソッドで、httpサーバーにルーティングの定義をするようにgRPCサーバーにサービスを登録するメソッドです。
第二引数にGreetingServiceServerインターフェイスを持つ何かしらの構造体を受け付けており、ここに渡した構造体に実装されたメソッドをrpcが呼び出された時に実行します。
greeting.goに実装したGreetingService構造体がインターフェイスを持っているのでこのメソッドに渡すことで、実装されたHelloメソッドを実行することができるのです。
続けてクライアントの実装に移ります。自作モジュールの取得まではサーバーと同様です。
$ go mod init github.com/{GitHubアカウント名}/client
$ go get github.com/{GitHubアカウント名}/examgrpc
問題なければクライアント側は下記にのように実装します。
main.go
package main
import (
"bufio"
"context"
"fmt"
"log"
"os"
"time"
"github.com/{GitHubアカウント名}/client/src/gateway"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
scanner *bufio.Scanner
)
func main() {
fmt.Println("example gRPC Client.")
fmt.Print("please enter any text >")
// 標準入力から文字列を受け取るスキャナを用意
scanner = bufio.NewScanner(os.Stdin)
scanner.Scan()
message := scanner.Text()
// gRPCサーバーとのコネクションを確立
address := "localhost:8080" // サーバー実装が別PCにある場合は"localhost"をipアドレスに変更する
conn, err := grpc.NewClient(
"dns:///"+address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal("Connection failed.")
return
}
defer func(conn *grpc.ClientConn) {
err := conn.Close()
if err != nil {
log.Fatal("Failed to close connection.")
}
}(conn)
// 接続を開始
conn.Connect()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 接続状態を確認
for conn.GetState().String() != "READY" {
if !conn.WaitForStateChange(ctx, conn.GetState()) {
log.Fatalf("Failed to connect within the timeout")
}
}
log.Println("Client connected successfully")
// gRPCクライアントを持った自作の構造体の生成
greetingServiceClient := gateway.NewGreetingService(conn)
// RPCメソッドの呼び出し
resMessage, err := greetingServiceClient.Hello(ctx, message)
if err != nil {
log.Fatalf("greetingServiceClient.Hello() failed: %v", err)
return
}
fmt.Println(resMessage)
}
greeting.go
package gateway
import (
"context"
examgrpc "github.com/{GitHubアカウント名}/examgrpc/pkg/grpc"
"google.golang.org/grpc"
)
func NewGreetingService(conn *grpc.ClientConn) *GreetingService {
return &GreetingService{
client: examgrpc.NewGreetingServiceClient(conn),
}
}
type GreetingService struct {
client examgrpc.GreetingServiceClient // GreetingServiceClientは単体でも動くことには動く
}
func (s *GreetingService) Hello(ctx context.Context, message string) (string, error) {
req := &examgrpc.HelloRequest{
Message: message,
}
res, err := s.client.Hello(ctx, req)
if err != nil {
return "", err
}
return res.GetResMessage(), nil
}
ここまで実装してサーバーとクライアントのmain.goを起動すればクライアント側で入力したメッセージをサーバーに渡し、レスポンスを標準出力に吐き出すと言う動作を実行できます。
クライアントの実装ではmain.goの実装から解説します。
// gRPCサーバーとのコネクションを確立
address := "localhost:8080"
conn, err := grpc.NewClient(
"dns:///"+address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal("Connection failed.")
return
}
defer func(conn *grpc.ClientConn) {
err := conn.Close()
if err != nil {
log.Fatal("Failed to close connection.")
}
}(conn)
// 接続を開始
conn.Connect()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 接続状態を確認
for conn.GetState().String() != "READY" {
if !conn.WaitForStateChange(ctx, conn.GetState()) {
log.Fatalf("Failed to connect within the timeout")
}
}
log.Println("Client connected successfully")
まずgrpc.NewClientメソッドを利用して非同期で接続を確立、その後明示的にConnect()メソッドでサーバーとの接続を試みます。
その後ループの中で接続状態を確認して、ステータスが"READY"になったら後続の処理へ進みます。
後続の処理では標準入力から受け取った文字列を後述するRPCメソッドを呼び出すための構造体に渡してレスポンスを受け取り、それを標準出力に出力しています。
それでは実際に通信を行うための処理について解説します。
func NewGreetingService(conn *grpc.ClientConn) *GreetingService {
return &GreetingService{
client: examgrpc.NewGreetingServiceClient(conn),
}
}
type GreetingService struct {
client examgrpc.GreetingServiceClient // GreetingServiceClientはそのままでも動くことには動く
}
上記はサーバーのGreetingServiceにリクエストを送るためのgRPCクライアントのインスタンスを生成する処理です。
main.goで確立したコネクションを受け取り、自動生成されたコンストラクタに渡してクライアントを初期化しています。
コメントにもあるように、この時GreetingServiceClient構造体は自動生成されたものをそのまま利用しても実行は可能です。
しかし、main.goの処理の中に文字列からリクエストの型へ変換する処理やレスポンスの型から文字列を取り出して出力する処理などを記載するとmain.goが持つ責務が大きくなってしまうため、
gRPCに関わる処理をまとめて切り出してしまうことでmain.goの実装が通信部分の実装に依存しない構造にしています。
func (s *GreetingService) Hello(ctx context.Context, message string) (string, error) {
req := &examgrpc.HelloRequest{
Message: message,
}
res, err := s.client.Hello(ctx, req)
if err != nil {
return "", err
}
return res.GetResMessage(), nil
}
上記が実際の通信の実装です。ここでの実装はHelloRequestに値を入れてRPCメソッドを呼び出し、受け取ったHelloResponseから文字列を返す処理を行なっています。
実際にRPCメソッドのエンドポイントを呼び出して通信を行う部分は自動生成された構造体に実装されています。
service_grpc.pb.go
func (c *greetingServiceClient) Hello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HelloResponse)
err := c.cc.Invoke(ctx, GreetingService_Hello_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
クライアント側も通信に利用するメソッドの実装と構造体の定義は自動生成されたコードが担保してくれているので、仕様を強く意識せずとも通常のメソッドを実装するように通信処理の実装ができる、と言うことになります。
ここまでのサーバーとクライアントの実装から分かるように、gRPC及びProtocol Bufferを利用すれば
自然と通信仕様に則った実装を行うことができるので実装者は開発の要件を満たすためのビジネスロジックの実装に注力できます。
いかがでしょうか。gRPC、使ってみたくなりませんか?
次回は最終回、応用編と言うことでgRPCそのものからは離れてしまいますが実際にアサインされたプロジェクトでどのようにgRPCを利用した開発が行われているか、という点に触れていきたいと思います。
よければ読んでいただけると幸いです。
また、EMoshUでは一緒に開発できる仲間を募集しております。
ご興味を持たれましたら、ぜひ募集要項をご覧ください。カジュアル面談なども実施しております。