こんにちは!今年の 4 月からスマートキャンプに入社し、只今新卒エンジニア研修期間中の中田です。本記事は、インターフェース定義の悩みを解決するために gRPC、Protocol Buffers を調査してみた!という内容のエントリです。
背景
新卒エンジニア研修では、同期のメンバーと 2 人で Go (REST API) + React/TS 構成の SPA を作っています。
このアプリの開発では、Server - Client 間でインターフェースの定義を一元化できておらず、それぞれでリクエスト/レスポンスの型を定義しているために、一方に修正が入ったタイミングでもう一方も修正する必要があり、面倒だなぁと感じていました。 また、インターフェースの定義内容をドキュメントなどで管理していないため、開発メンバー間での認識にずれが生じてしまうこともありました。
この辺りの解決策を考えていた際に、gRPC や Protocol Buffers について知り、興味を持ったので調査してみました。
gRPC とは
gRPC は Google が開発した RPC のフレームワークです。 データのシリアライズとインターフェースの定義に Protocol Buffers を用い、プログラミング言語に依存しない実装で高速な通信が可能になるという特徴があります。
Protocol Buffers とは
構造化データをネットワーク経由で送信できる形へシリアライズする役割と、構造化データ定義用の IDL(Interface Depription Language)としての役割を機能として備えています。
公式では以下のように定義されています。
プロトコルバッファは、構造化されたデータをシリアル化するための、言語やプラットフォームに依存しない、Google の拡張可能なメカニズムです。
引用:https://developers.google.com/protocol-buffers/
4 つの通信方式を試してみた
※本記事で扱っているコードはココに置いてあります。
gRPC では Stream を利用して、1 リクエスト内でクライアントとサーバー間でのメッセージのやりとりを複数回行うことができます。
これにより、gRPC では以下の 4 つの方式で通信ができます。
- Unary RPC (シンプル RPC)
- Server streaming RPC (サーバーサイドストリーミング RPC)
- Client streaming RPC (クライアントサイドストリーミング RPC)
- Bidirectional streaming RPC (双方向ストリーミング RPC)
引用:https://grpc.io/docs/what-is-grpc/core-concepts/#rpc-life-cycle
それぞれに、リクエストとレスポンスの関係が以下のように変化します。
- Unary RPC - リクエスト:レスポンス = 1:1
- Server streaming RPC - リクエスト:レスポンス = 1:N
- Client streaming RPC - リクエスト:レスポンス = N:1
- Bidirectional streaming RPC - リクエスト:レスポンス = N:N
今回は上記の各通信方式を Go で実装してみます。
実装
準備
初めに、以下をインストールします。
- Protocol Buffers v3
- grpc-go(https://pkg.go.dev/google.golang.org/grpc)
- protoc-gen-go(https://pkg.go.dev/github.com/golang/protobuf/protoc-gen-go)
> brew install protobuf > go get -u google.golang.org/grpc > go get -u github.com/golang/protobuf/protoc-gen-go
インターフェース定義
次に、.proto ファイルにインターフェースの定義を書いていきます。
今回は 4 つの通信方式を試すため、service Call
に対して各通信方式の service メソッドを定義しています。
./proto/call.proto
syntax = "proto3"; package call; option go_package = "gen/pb"; service Call { // Unary Request:Response = 1:1 rpc UnaryCall (CallRequest) returns (CallResponse) {} // ClientStreaming Request:Response = N:1 rpc ClientStreamingCall (stream CallRequest) returns (CallResponse) {} // ServerStreaming Request:Response = 1:N rpc ServerStreamingCall (ServerStreamingCallRequest) returns (stream CallResponse) {} // BidirectionalStreaming Request:Response = N:N rpc BidirectionalStreamingCall (stream CallRequest) returns (stream BidirectionalStreamingResponse) {} } message CallRequest { string name = 1; } message CallResponse { string message = 1; } message ServerStreamingCallRequest { string name = 1; uint32 responseCnt = 2; } message BidirectionalStreamingResponse { map<string, uint32> callCounter = 1; }
直感的な記法で定義できて分かりやすいなという印象でした。
コンパイル
定義した.proto ファイルを protoc
でコンパイルします。
> protoc --proto_path ./proto --go_out=plugins=grpc:${APP_ROOT} call.proto
./gen/pb 以下に call.pb.go
ファイルが自動生成されました。
生成された call.pb.go
には以下のような内容が含まれています。
- 定義した message に対応する構造体
type CallRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` }
- service メソッドを呼び出す gRPC クライアント
type CallClient interface { // Unary Request:Response = 1:1 UnaryCall(ctx context.Context, in *CallRequest, opts ...grpc.CallOption) (*CallResponse, error) // ClientStreaming Request:Response = N:1 ClientStreamingCall(ctx context.Context, opts ...grpc.CallOption) (Call_ClientStreamingCallClient, error) // ServerStreaming Request:Response = 1:N ServerStreamingCall(ctx context.Context, in *ServerStreamingCallRequest, opts ...grpc.CallOption) (Call_ServerStreamingCallClient, error) // BidirectionalStreaming Request:Response = N:N BidirectionalStreamingCall(ctx context.Context, opts ...grpc.CallOption) (Call_BidirectionalStreamingCallClient, error) } type callClient struct { cc grpc.ClientConnInterface } func NewCallClient(cc grpc.ClientConnInterface) CallClient { return &callClient{cc} } func (c *callClient) UnaryCall(ctx context.Context, in *CallRequest, opts ...grpc.CallOption) (*CallResponse, error) { out := new(CallResponse) err := c.cc.Invoke(ctx, "/call.Call/UnaryCall", in, out, opts...) if err != nil { return nil, err } return out, nil }
- service メソッドを実装する gRPC サーバーのインターフェース
type CallServer interface { // Unary Request:Response = 1:1 UnaryCall(context.Context, *CallRequest) (*CallResponse, error) // ClientStreaming Request:Response = N:1 ClientStreamingCall(Call_ClientStreamingCallServer) error // ServerStreaming Request:Response = 1:N ServerStreamingCall(*ServerStreamingCallRequest, Call_ServerStreamingCallServer) error // BidirectionalStreaming Request:Response = N:N BidirectionalStreamingCall(Call_BidirectionalStreamingCallServer) error }
サーバーとクライアントの実装
次に、生成したコードを用いて、サーバーとクライアントの実装をしていきます。
実装は公式に用意されている各通信方式の実装例を参考に進めました。
実際に書いたコードは以下になります。
./client/cmd/main.go
package main import ( "context" "grpc-lesson/gen/pb" "io" "log" "time" "google.golang.org/grpc" ) const ( address = "localhost:50051" ) func runUnaryCall(c pb.CallClient, name string) error { log.Println("--- Unary ---") in := &pb.CallRequest{Name: name} ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() res, err := c.UnaryCall(ctx, in) if err != nil { return err } log.Printf("response: %s\n", res.GetMessage()) return nil } func runClientStreamingCall(c pb.CallClient, names []string) error { log.Println("--- ClientStreaming ---") ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() stream, err := c.ClientStreamingCall(ctx) if err != nil { return err } for _, name := range names { in := &pb.CallRequest{Name: name} if err := stream.Send(in); err != nil { if err == io.EOF { break } return err } time.Sleep(time.Second) } res, err := stream.CloseAndRecv() if err != nil { return err } log.Printf("response: %s", res.GetMessage()) return nil } func runServerStreamingCall(c pb.CallClient, name string, responseCnt uint32) error { log.Println("--- ServerStreaming ---") in := &pb.ServerStreamingCallRequest{Name: name, ResponseCnt: responseCnt} ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() stream, err := c.ServerStreamingCall(ctx, in) if err != nil { return err } for { res, err := stream.Recv() if err == io.EOF { break } if err != nil { return err } log.Printf("response: %s", res.GetMessage()) } return nil } func runBidirectionalStreamingCall(c pb.CallClient, names []string) error { log.Println("--- BidirectionalStreaming ---") ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() stream, err := c.BidirectionalStreamingCall(ctx) if err != nil { return err } done := make(chan struct{}) go recv(done, stream) if err := send(names, stream); err != nil { return err } <- done return nil } func send(names []string, stream pb.Call_BidirectionalStreamingCallClient) error { for _, name := range names { in := &pb.CallRequest{Name:name} if err := stream.Send(in); err != nil { return err } } if err := stream.CloseSend(); err != nil { return err } return nil } func recv(done chan struct{}, stream pb.Call_BidirectionalStreamingCallClient) { for { res, err := stream.Recv() if err == io.EOF { close(done) return } if err != nil { log.Fatalln(err) } log.Printf("response: %v\n", res.CallCounter) } } func main() { conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock()) if err != nil { log.Fatalf("did not connect %v", err) } defer conn.Close() c := pb.NewCallClient(conn) if err = runUnaryCall(c, "John"); err != nil { log.Fatalln(err) } names := []string{"John", "Paul", "George", "Ringo"} if err = runClientStreamingCall(c, names); err != nil { log.Fatalln(err) } if err = runServerStreamingCall(c, "John", 10); err != nil { log.Fatalln(err) } names = []string{"John", "Paul", "John", "George", "Ringo", "Paul", "John", "Paul", "George", "John"} if err = runBidirectionalStreamingCall(c, names); err != nil { log.Fatalln(err) } }
./server/cmd/main.go
package main import ( "context" "errors" "fmt" "grpc-lesson/gen/pb" "io" "log" "net" "time" "google.golang.org/grpc" ) const port = ":50051" type CallServer struct { pb.UnimplementedCallServer } func (s *CallServer) UnaryCall(ctx context.Context, in *pb.CallRequest) (*pb.CallResponse, error) { log.Println("--- Unary ---") log.Printf("request: %s\n", in.GetName()) resp := &pb.CallResponse{} resp.Message = fmt.Sprintf("Hello. I'm %s", in.GetName()) return resp, nil } func (s *CallServer) ClientStreamingCall(stream pb.Call_ClientStreamingCallServer) error { log.Println("--- ClientStreaming ---") message := "Hello. We're" for { in, err := stream.Recv() if err == io.EOF { return stream.SendAndClose(&pb.CallResponse{Message: message}) } if err != nil { return err } log.Printf("request: %s\n", in.GetName()) message = fmt.Sprintf("%s %s", message, in.GetName()) } } func (s *CallServer) ServerStreamingCall(in *pb.ServerStreamingCallRequest, stream pb.Call_ServerStreamingCallServer) error { log.Println("--- ClientStreaming ---") log.Printf("request: %s\n", in.GetName()) var message string for i := uint32(1); i <= in.ResponseCnt; i++ { if i <= 5 { message = fmt.Sprintf("Hello. I'm %s", in.GetName()) } else { message = fmt.Sprintf("I'm so tired. (%s)", in.GetName()) } if err := stream.Send(&pb.CallResponse{Message: message}); err != nil { return err } time.Sleep(time.Second) } return nil } func (s *CallServer) BidirectionalStreamingCall(stream pb.Call_BidirectionalStreamingCallServer) error { log.Println("--- BidirectionalStreaming ---") counter := make(map[string]uint32) for { in, err := stream.Recv() if err == io.EOF { return nil } if err != nil { return err } log.Printf("request: %s\n", in.GetName()) counter[in.Name] += 1 res := &pb.BidirectionalStreamingResponse{CallCounter: counter} if err := stream.Send(res); err != nil { return err } } } func main() { fmt.Printf("server is listening on port%s\n", port) if err := set(); err != nil { log.Fatalln(err.Error()) } } func set() error { lis, err := net.Listen("tcp", port) if err != nil { log.Fatalln(err) } s := grpc.NewServer() pb.RegisterCallServer(s, &CallServer{}) if err := s.Serve(lis); err != nil { return errors.New("serve is failed") } return nil }
各通信方式の実装を見ていきます。
UnaryCall
UnaryCall はリクエスト:レスポンス=1:1 の通信です。
- ソースコード
./proto/call.proto
service Call { // Unary Request:Response = 1:1 rpc UnaryCall (CallRequest) returns (CallResponse) {} ... } message CallRequest { string name = 1; } message CallResponse { string message = 1; }
./client/cmd/main.go
func runUnaryCall(c pb.CallClient, name string) error { log.Println("--- Unary ---") in := &pb.CallRequest{Name: name} ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() res, err := c.UnaryCall(ctx, in) if err != nil { return err } log.Printf("response: %s\n", res.GetMessage()) return nil } func main() { ... c := pb.NewCallClient(conn) if err = runUnaryCall(c, "John"); err != nil { log.Fatalln(err) } ... }
UnaryCall のクライアントでは、CallRequest をサーバーへ投げ CallResponse を受け取りメッセージを log に吐いています。
./server/cmd/main.go
func (s *CallServer) UnaryCall(ctx context.Context, in *pb.CallRequest) (*pb.CallResponse, error) { log.Println("--- Unary ---") log.Printf("request: %s\n", in.GetName()) resp := &pb.CallResponse{} resp.Message = fmt.Sprintf("Hello. I'm %s", in.GetName()) return resp, nil }
UnaryCall のサーバーでは、CallRequest を受け取って log へ吐き、受け取った CallRequest.Name をもとに CallResponse を生成してクライアントを返しています。
- 実行結果
サーバー
2021/06/08 00:57:37 --- Unary --- 2021/06/08 00:57:37 request: John
クライアント
2021/06/08 00:57:37 --- Unary --- 2021/06/08 00:57:37 response: Hello. I'm John
ClientStreamingCall
ClientStreamingCall はリクエスト:レスポンス=N:1 の通信です。
- ソースコード
./proto/call.proto
service Call { ... // ClientStreaming Request:Response = N:1 rpc ClientStreamingCall (stream CallRequest) returns (CallResponse) {} ... } message CallRequest { string name = 1; } message CallResponse { string message = 1; }
./client/cmd/main.go
func runClientStreamingCall(c pb.CallClient, names []string) error { log.Println("--- ClientStreaming ---") ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() stream, err := c.ClientStreamingCall(ctx) if err != nil { return err } for _, name := range names { in := &pb.CallRequest{Name: name} if err := stream.Send(in); err != nil { if err == io.EOF { break } return err } time.Sleep(time.Second) } res, err := stream.CloseAndRecv() if err != nil { return err } log.Printf("response: %s", res.GetMessage()) return nil } func main() { ... c := pb.NewCallClient(conn) ... names := []string{"John", "Paul", "George", "Ringo"} if err = runClientStreamingCall(c, names); err != nil { log.Fatalln(err) } ... }
ClientStreamingCall のクライアントでは、1 秒おきに CallRequest をサーバーへ投げ、全て投げ終わったら 1 つのレスポンスを受け取り、受け取ったメッセージを log に吐いています。
./server/cmd/main.go
func (s *CallServer) ClientStreamingCall(stream pb.Call_ClientStreamingCallServer) error { log.Println("--- ClientStreaming ---") message := "Hello. We're" for { in, err := stream.Recv() if err == io.EOF { return stream.SendAndClose(&pb.CallResponse{Message: message}) } if err != nil { return err } log.Printf("request: %s\n", in.GetName()) message = fmt.Sprintf("%s %s", message, in.GetName()) } }
ClientStreamingCall のサーバーでは、CallRequest を複数回受け取って log へ吐き、受け取った複数のリクエストをもとに 1 つの CallResponse を生成して返しています。
- 実行結果
サーバー
2021/06/08 00:57:37 --- ClientStreaming --- 2021/06/08 00:57:37 request: John 2021/06/08 00:57:38 request: Paul 2021/06/08 00:57:39 request: George 2021/06/08 00:57:40 request: Ringo
クライアント
2021/06/08 00:57:37 --- ClientStreaming --- 2021/06/08 00:57:41 response: Hello. We're John Paul George Ringo
ServerStreamingCall
ServerStreamingCall はリクエスト:レスポンス=1:N の通信です。
- ソースコード
./proto/call.proto
service Call { ... // ServerStreaming Request:Response = 1:N rpc ServerStreamingCall (ServerStreamingCallRequest) returns (stream CallResponse) {} ... } message ServerStreamingCallRequest { string name = 1; uint32 responseCnt = 2; } message CallResponse { string message = 1; }
./client/cmd/main.go
func runServerStreamingCall(c pb.CallClient, name string, responseCnt uint32) error { log.Println("--- ServerStreaming ---") in := &pb.ServerStreamingCallRequest{Name: name, ResponseCnt: responseCnt} ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() stream, err := c.ServerStreamingCall(ctx, in) if err != nil { return err } for { res, err := stream.Recv() if err == io.EOF { break } if err != nil { return err } log.Printf("response: %s", res.GetMessage()) } return nil } func main() { ... c := pb.NewCallClient(conn) ... if err = runServerStreamingCall(c, "John", 10); err != nil { log.Fatalln(err) } ... }
ServerStreamingCall のクライアントでは、Name と ResponseCnt というフィールドを持つ ServerStreamingCallRequest をサーバーへ投げ、複数回の CallResponse を受け取り、受け取ったそれぞれのメッセージを log に吐いています。
./server/cmd/main.go
func (s *CallServer) ServerStreamingCall(in *pb.ServerStreamingCallRequest, stream pb.Call_ServerStreamingCallServer) error { log.Println("--- ClientStreaming ---") log.Printf("request: %s\n", in.GetName()) var message string for i := uint32(1); i <= in.ResponseCnt; i++ { if i <= 5 { message = fmt.Sprintf("Hello. I'm %s", in.GetName()) } else { message = fmt.Sprintf("I'm so tired. (%s)", in.GetName()) } if err := stream.Send(&pb.CallResponse{Message: message}); err != nil { return err } time.Sleep(time.Second) } return nil }
ServerStreamingCall のサーバーでは、ServerStreamingCallRequest を受け取って Name を log へ吐き、受け取ったリクエストの ResponseCnt フィールドで指定された回数だけ CallResponse を生成して返しています。
- 実行結果
サーバー
2021/06/08 01:29:03 --- ServerStreaming --- 2021/06/08 01:29:03 request: John
クライアント
2021/06/08 01:29:03 --- ServerStreaming --- 2021/06/08 01:29:03 response: Hello. I'm John 2021/06/08 01:29:04 response: Hello. I'm John 2021/06/08 01:29:05 response: Hello. I'm John 2021/06/08 01:29:06 response: Hello. I'm John 2021/06/08 01:29:07 response: Hello. I'm John 2021/06/08 01:29:08 response: I'm so tired. (John) 2021/06/08 01:29:09 response: I'm so tired. (John) 2021/06/08 01:29:10 response: I'm so tired. (John) 2021/06/08 01:29:11 response: I'm so tired. (John)
BidirectionalStreamingCall
BidirectionalStreamingCall はリクエスト:レスポンス=N:N の双方向の通信です。
- ソースコード
./proto/call.proto
service Call { ... // BidirectionalStreaming Request:Response = N:N rpc BidirectionalStreamingCall (stream CallRequest) returns (stream BidirectionalStreamingResponse) {} } message ServerStreamingCallRequest { string name = 1; uint32 responseCnt = 2; } message BidirectionalStreamingResponse { map<string, uint32> callCounter = 1; }
./client/cmd/main.go
func runBidirectionalStreamingCall(c pb.CallClient, names []string) error { log.Println("--- BidirectionalStreaming ---") ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() stream, err := c.BidirectionalStreamingCall(ctx) if err != nil { return err } done := make(chan struct{}) go recv(done, stream) if err := send(names, stream); err != nil { return err } <- done return nil } func send(names []string, stream pb.Call_BidirectionalStreamingCallClient) error { for _, name := range names { in := &pb.CallRequest{Name:name} if err := stream.Send(in); err != nil { return err } } if err := stream.CloseSend(); err != nil { return err } return nil } func recv(done chan struct{}, stream pb.Call_BidirectionalStreamingCallClient) { for { res, err := stream.Recv() if err == io.EOF { close(done) return } if err != nil { log.Fatalln(err) } log.Printf("response: %v\n", res.CallCounter) } } func main() { ... c := pb.NewCallClient(conn) ... names = []string{"John", "Paul", "John", "George", "Ringo", "Paul", "John", "Paul", "George", "John"} if err = runBidirectionalStreamingCall(c, names); err != nil { log.Fatalln(err) } ... }
BidirectionalStreamingCall のクライアントでは、CallRequest を複数回サーバーへ投げ、BidirectionalCallResponse を複数回受け取り、レスポンスの CallCounter をそれぞれ log に吐いています。
./server/cmd/main.go
func (s *CallServer) BidirectionalStreamingCall(stream pb.Call_BidirectionalStreamingCallServer) error { log.Println("--- BidirectionalStreaming ---") counter := make(map[string]uint32) for { in, err := stream.Recv() if err == io.EOF { return nil } if err != nil { return err } log.Printf("request: %s\n", in.GetName()) counter[in.Name] += 1 res := &pb.BidirectionalStreamingResponse{CallCounter: counter} if err := stream.Send(res); err != nil { return err } } }
BidirectionalStreamingCall のサーバーでは、CallRequest を複数回受けつけ、各リクエストを受け取る度に BidirectionalStreamingCallResponse の CallCounter に名前を呼ばれた数を CountUp してレスポンスとして返却しています。
- 実行結果
サーバー
2021/06/08 01:29:13 --- BidirectionalStreaming --- 2021/06/08 01:29:13 request: John 2021/06/08 01:29:13 request: Paul 2021/06/08 01:29:13 request: John 2021/06/08 01:29:13 request: George 2021/06/08 01:29:13 request: Ringo 2021/06/08 01:29:13 request: Paul 2021/06/08 01:29:13 request: John 2021/06/08 01:29:13 request: Paul 2021/06/08 01:29:13 request: George 2021/06/08 01:29:13 request: John
クライアント
2021/06/08 01:29:13 --- BidirectionalStreaming --- 2021/06/08 01:29:13 response: map[John:1] 2021/06/08 01:29:13 response: map[John:1 Paul:1] 2021/06/08 01:29:13 response: map[John:2 Paul:1] 2021/06/08 01:29:13 response: map[George:1 John:2 Paul:1] 2021/06/08 01:29:13 response: map[George:1 John:2 Paul:1 Ringo:1] 2021/06/08 01:29:13 response: map[George:1 John:2 Paul:2 Ringo:1] 2021/06/08 01:29:13 response: map[George:1 John:3 Paul:2 Ringo:1] 2021/06/08 01:29:13 response: map[George:1 John:3 Paul:3 Ringo:1] 2021/06/08 01:29:13 response: map[George:2 John:3 Paul:3 Ringo:1] 2021/06/08 01:29:13 response: map[George:2 John:4 Paul:3 Ringo:1]
ドキュメント生成
最後にインターフェース定義のドキュメントを作成します。
protoc のドキュメント生成用のプラグインをインストールします。
> go get -u github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc
このprotoc-gen-doc
では、.proto ファイルから HTML,JSON,DocBook,Markdown のいずれかの形式でドキュメントを生成することができます。
今回は HTML 形式でドキュメントを生成します。
> protoc --doc_out=./proto/doc --doc_opt=html,index.html ./proto/*.proto
./proto/doc 以下に index.html というドキュメントファイルが生成されました。
コマンド 1 発で整ったドキュメントが作られてとても便利ですね。
学び
4 つの通信方式のいずれも、protobuf で生成された情報をもとにそれほど苦労せずに実装することができました。
今回は「試してみた」記事ということで各通信方式の特徴を生かしたコードは書けていませんが、公式の例のように ServerStreaming であればサーバーからクライアントへの Push 通知機能、BidirectionalStreaming であればサーバーを介してのチャット機能など、実現したい事に応じて通信方式を使い分けると各通信方式の良さがより実感できそうだなと思いました。
また、新卒エンジニア研修で作成している REST API のアプリケーションでも、protobuf で リクエストとレスポンスの message のみ定義するような使い方で定義の一元化に取り組めそうだなと感じました。ドキュメントの生成もかなり楽で良かったです。
まとめ
いかがでしたでしょうか。今回は gRPC の 4 つの通信方式を Go で試してみました。
僕はこれまで REST での開発経験がほとんどなので、他の方法での API 設計は新鮮で楽しかったです。かなりシンプルに開発できそうだったので、今後業務でも機会を見て利用していければと思います。
最後までお読みくださりありがとうございました!