PR

Go言語(Golang)のgRPCでバックエンドAPIを開発する方法まとめ

応用

こんにちは。Tomoyuki(@tomoyuki65)です。

Go言語(Golang)はマイクロサービスで利用されることが多かったりしますが、その際にgRPC(Google Remote Procedure Call)が使われていることがあります。

gRPCはGoogleが開発した高性能なオープンソースRPC(Remote Procedure Call)フレームワークであり、HTTP/2上でバイナリ形式でデータをやり取りすることで高速かつ効率的な通信を実現し、主にマイクロサービス間通信やモバイルアプリのバックエンドAPIなどに利用されています。

そんなgRPCですが、Go言語を極めていくならもちろんキャッチアップしておくべきだと思うので、今回も色々と試しました。

そこでこの記事では、Go言語のgRPCでバックエンドAPIを開発する方法についてまとめます。

 

Go言語(Golang)のgRPCでバックエンドAPIを開発する方法まとめ

gRPCについてはまだ一般的によく利用されている技術ではないため、実際にコードを書く前に、具体的にどういった場面で使われるのがを理解しておいた方がいいと思います。

 

一つ目は、iOSやAndroidアプリのバックエンドAPIとしてgRPCサーバーが使われます。

アプリ側ではgRPCネイティブクライアントというライブラリを使うことでgRPCサーバーと直接通信が可能であり、通信データのファイルサイズが小さく電力消費を抑える効果があったりするため、モバイルアプリのバックエンドAPIに最適です。

ただし、gRPCサーバーのエンドポイント(アクセス先)をインターネット上に外部公開するのはセキュリティリスクが高いため、間にプロキシサーバー(中継させて内部からgRPCへ接続させる)を置いてそのエンドポイントを外部公開させるようにするのが一般的です。

 

二つ目は、マイクロサービスの一つとしてgRPCサーバーを使うことです。

各種マイクロサービスの呼び出しをまとめるBFF(Backend For Frontend)から直接呼び出したり、またはマイクロサービス間連携(サーバー間通信)をさせるために使ったりします。

 

三つ目としてはもちろんWebブラウザからの利用もありますが、アプリのようにブラウザから直接gRPCサーバーに対して通信はできない(通信方法が異なる)ため、Webブラウザからの利用には工夫が必要になります。

その一つの方法としては、gRPC側でgRPC-Gatewayのライブラリを使ってREST API用のエンドポイントを立てることです。

この場合はWebブラウザからのアクセスについてはプロキシサーバーによってリクエストをgRPC-Gatewayのポートへ振り分けることになります。

ただし、gRPC-GatewayではgRPCのストリーミング機能(リアルタイム通信など)は使えないため、その点に注意する必要があります。将来的にもWebブラウザからgRPCのストリーミング機能を使うことがない場合に有効な方法です。

 

もしWebブラウザからもgRPCのストリーミング機能を使いたい場合は、別途外部公開用のREST APIでWebSocket(Webブラウザとサーバー間で双方向通信を可能にする仕組み)のエンドポイントを立て、そこからgRPCにサーバーに通信させる必要があります。

このように、まずはgRPCが利用される場合の用途について理解しておくと、これからご紹介するgRPCサーバーの開発方法についての理解が深めやすくなると思います。

 

スポンサーリンク

.protoファイルからProtocol Buffersのコードを生成

ではここからGo言語のgRPCでバックエンドAPIを開発する方法を解説していきますが、まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir go-grpc && cd go-grpc
$ mkdir -p docker/local/go && touch docker/local/go/Dockerfile
$ mkdir src && touch src/main.go src/.env
$ mkdir -p src/proto/sample && touch src/proto/sample/sample.proto
$ touch compose.yml

 

次に作成したファイルをそれぞれ以下のように記述します。

・「docker/local/go/Dockerfile」

FROM golang:1.24-alpine3.21

WORKDIR /go/src

# # インストール可能なパッケージ一覧の更新
RUN apk update && \
    apk upgrade && \
    # パッケージのインストール(--no-cacheでキャッシュ削除)
    apk add --no-cache \
            protobuf-dev \
            protoc

COPY ./src .

# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
  go install; \
fi

# Protocol BuffersのGoとgRPCのプラグインをインストール
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5

# バリデーションプラグインをインストール
RUN go install github.com/envoyproxy/protoc-gen-validate@v1.2.1

# ドキュメント生成用ライブラリをインストール
RUN go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@v1.5.1

# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@latest
RUN go install honnef.co/go/tools/cmd/staticcheck@latest

# gRPC-Gateway用のライブラリをインストール
RUN go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26
RUN go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.26
# RUN go get github.com/googleapis/googleapis@v0.0.0-20250610203048-111b73837522

EXPOSE 8080

 

・「src/main.go」

package main

func main() {
// TODO: gRPCサーバー起動処理
}

 

・「src/.env」

ENV=local
GRPC_PORT=50051
GATEWAY_PORT=8080

 

・「src/proto/sample/sample.proto」

syntax = "proto3";

import "validate/validate.proto";

package sample;

option go_package="pb/sample";

// 空のリクエストパラメータ
message Empty {}

// Helloメソッドのレスポンス結果
message HelloResponseBody {
  // メッセージ
  string message = 1;
}

// HelloAddTextメソッドのリクエストパラメータ
message HelloAddTextRequestBody {
  // テキスト
  string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloAddTextメソッドのレスポンス結果
message HelloAddTextResponseBody {
  // メッセージ
  string message = 1;
}

// サンプルサービス
service SampleService {
  // 「Hello World !!」を出力
  rpc Hello(Empty) returns (HelloResponseBody) {}
    // Returns:
    // - 0 OK: HelloResponseBodyを出力
    // - 2 Unknown: 不明なエラー

  // 「Hello {リクエストパラメータのtext}」を出力
  rpc HelloAddText(HelloAddTextRequestBody) returns (HelloAddTextResponseBody) {}
    // Returns: 
    // - 0 OK: HelloAddTextResponseBodyを出力 
    // - 2 Unknown: 不明なエラー 
    // - 3 INVALID_ARGUMENT: バリデーションエラー 
}

※この.protoファイルを元にProtocol Buffers(スキーマ言語として、Google が作成したデータをシリアライズするためのメカニズム)のコードを生成するため、gRPCはスキーマ駆動開発になります。また、後述のドキュメントに反映させるためにコメント部分を記述していますが、関数の下に記述しているReturnsの戻り値の部分は反映されないため、戻り値を確認したい場合はこの.protoファイルを参照することになります。

 

・「compose.yml」

services:
  grpc:
    container_name: go-grpc
    build:
      context: .
      dockerfile: ./docker/local/go/Dockerfile
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    ports:
      # gRPC Server用のポート設定
      - "50051:50051"
      # gRPC Gateway用のポート設定
      - "8080:8080"
    environment:
      - ENV
      - GRPC_PORT
      - GATEWAY_PORT
    tty: true
    stdin_open: true

 

次に以下のコマンドを実行し、Dockerコンテナをビルドおよび各初期化処理を行います。

$ docker compose build --no-cache
$ docker compose run --rm grpc go mod init go-grpc
$ docker compose run --rm grpc air init

 

次にDockerfileのgRPC-Gateway用に必要なライブラリとして、コメントアウト部分「RUN go get github.com/googleapis/googleapis@v0.0.0-20250610203048-111b73837522」のコメントを外して保存します。

FROM golang:1.24-alpine3.21

WORKDIR /go/src

# # インストール可能なパッケージ一覧の更新
RUN apk update && \
    apk upgrade && \
    # パッケージのインストール(--no-cacheでキャッシュ削除)
    apk add --no-cache \
            protobuf-dev \
            protoc

COPY ./src .

# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
  go install; \
fi

# Protocol BuffersのGoとgRPCのプラグインをインストール
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5

# バリデーションプラグインをインストール
RUN go install github.com/envoyproxy/protoc-gen-validate@v1.2.1

# ドキュメント生成用ライブラリをインストール
RUN go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@v1.5.1

# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@latest
RUN go install honnef.co/go/tools/cmd/staticcheck@latest

# gRPC-Gateway用のライブラリをインストール
RUN go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26
RUN go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.26
RUN go get github.com/googleapis/googleapis@v0.0.0-20250610203048-111b73837522

EXPOSE 8080

※「github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.26」は後述のOpenAPI用で、「github.com/googleapis/googleapis@v0.0.0-20250610203048-111b73837522」は後述のgRPC-Gateway用です。

 

次に以下のコマンドを実行し、Dockerコンテナを再ビルドします。

$ docker compose build --no-cache

 

次に以下のコマンドを実行し、.protoファイルからProtocol Buffersのコードを生成します。

$ docker compose run --rm grpc protoc -I=.:../pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1 --go_out=. --go-grpc_out=. --validate_out=lang=go:. ./proto/sample/sample.proto

※.protoファイルのimportでコンテナ内のライブラリを使用する場合、オプション「-I」でパスの指定が必要です。また、生成されるコードの出力先は.protoファイルの「option go_package=”pb/sample”;」の部分で指定しています。

 

コマンド実行後、以下のようにProtocol Buffersのファイルが生成されればOKです。

 

また、以下のコマンドを実行し、.protoファイルからProtocol Buffersの仕様に関するドキュメントも生成可能です。

$ mkdir -p src/doc
$ docker compose run --rm grpc protoc -I=.:../pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1 --doc_out=./doc --doc_opt=markdown,docs.md ./proto/sample/sample.proto

 

実行後、以下のようにmdファイルが生成されればOKです。

※VSCodeの場合はmdファイルのプレビュー機能を使うと上記のように表示可能です。

 

スポンサーリンク

生成したgRPC用コードの関数を利用してAPIの処理を作成

次に.protoファイルから生成したProtocol Buffersのコードにある関数を利用してAPIの処理を実装するため、まずは以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/internal/repositories/sample && touch src/internal/repositories/sample/sample.go
$ mkdir -p src/internal/services/sample && touch src/internal/services/sample/sample.go
$ mkdir -p src/internal/usecases/sample
$ touch src/internal/usecases/sample/hello_sample.go src/internal/usecases/sample/hello_sample_test.go
$ touch src/internal/usecases/sample/hello_add_text_sample.go src/internal/usecases/sample/hello_add_text_sample_test.go
$ mkdir -p src/internal/servers/grpc/sample
$ touch src/internal/servers/grpc/sample/sample.go src/internal/servers/grpc/sample/sample_test.go

※今回もクリーンアーキテクチャを参考に、ハンドラー層(サーバー層)、ユースケース層、サービス層、リポジトリ層にファイルを分け、それらを依存注入できる形で作成します。これによりテストコードでリポジトリ層等のモック化がしやすくなります。

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/repositories/sample/sample.go」

package sample

import (
    "os"
)

// インターフェース定義
type SampleRepository interface {
    Hello() string
}

// 構造体定義
type sampleRepository struct{}

// インスタンス生成用関数
func NewSampleRepository() SampleRepository {
    return &sampleRepository{}
}

// メソッド定義
func (r *sampleRepository) Hello() string {
    env := os.Getenv("ENV")

    res := "Hello World !!"
    if env == "testing" {
        res = "Testing Hello World !!"
    }

    return res
}

 

・「src/internal/services/sample/sample.go」

package sample

import (
    "fmt"

    "go-grpc/internal/repositories/sample"
)

// インターフェース定義
type SampleService interface {
    Sample() (string, error)
}

// 構造体定義
type sampleService struct {
    sampleRepository sample.SampleRepository
}

// インスタンス生成用関数
func NewSampleService(
    sampleRepository sample.SampleRepository,
) SampleService {
    return &sampleService{
        sampleRepository: sampleRepository,
    }
}

func (s *sampleService) Sample() (string, error) {
    text := s.sampleRepository.Hello()
    if text == "" {
        return "", fmt.Errorf("textが空です。")
    }

    return text, nil
}

※サービス層は機能単位の処理を書きます。

 

・「src/internal/usecases/sample/hello_sample.go」

package sample

import (
    "context"

    serviceSample "go-grpc/internal/services/sample"
    pb "go-grpc/pb/sample"
)

// インターフェースの定義
type SampleHelloUsecase interface {
    Exec(ctx context.Context, in *pb.Empty) (*pb.HelloResponseBody, error)
}

// 構造体の定義
type sampleHelloUsecase struct {
    sampleService serviceSample.SampleService
}

// インスタンス生成用関数の定義
func NewSampleHelloUsecase(
    sampleService serviceSample.SampleService,
) SampleHelloUsecase {
    return &sampleHelloUsecase{
        sampleService: sampleService,
    }
}

func (u *sampleHelloUsecase) Exec(ctx context.Context, in *pb.Empty) (*pb.HelloResponseBody, error) {
    text, err := u.sampleService.Sample()
    if err != nil {
        return nil, err
    }

    return &pb.HelloResponseBody{Message: text}, nil
}

※ユースケース層はユースケース単位の業務ロジックを記述します。

 

・「src/internal/usecases/sample/hello_sample_test.go」

package sample

import (
    "context"
    "testing"

    repoSample "go-grpc/internal/repositories/sample"
    mockRepoSample "go-grpc/internal/repositories/sample/mock_sample"
    serviceSample "go-grpc/internal/services/sample"
    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
    "go.uber.org/mock/gomock"
)

func TestSampleHelloOK(t *testing.T) {
    // ユースケースのインスタンス化
    sampleRepository := repoSample.NewSampleRepository()
    sampleService := serviceSample.NewSampleService(sampleRepository)
    sampleUsecase := NewSampleHelloUsecase(sampleService)

    // パラメータ設定
    ctx := context.Background()
    in := &pb.Empty{}

    // テストの実行
    res, err := sampleUsecase.Exec(ctx, in)

    // 検証
    assert.Equal(t, "Testing Hello World !!", res.Message)
    assert.Equal(t, nil, err)
}

func TestSampleHelloErr(t *testing.T) {
    // リポジトリのモック化
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockSampleRepository := mockRepoSample.NewMockSampleRepository(ctrl)
    mockSampleRepository.EXPECT().Hello().Return("")

    // ユースケースのインスタンス化
    sampleService := serviceSample.NewSampleService(mockSampleRepository)
    sampleUsecase := NewSampleHelloUsecase(sampleService)

    // パラメータ設定
    ctx := context.Background()
    in := &pb.Empty{}

    // テストの実行
    res, err := sampleUsecase.Exec(ctx, in)

    // 検証
    assert.Equal(t, "", res.GetMessage())
    assert.NotEqual(t, nil, err)
}

 

・「src/internal/usecases/sample/hello_add_text_sample.go」

package sample

import (
    "context"
    "fmt"

    pb "go-grpc/pb/sample"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// インターフェースの定義
type SampleHelloAddTextUsecase interface {
    Exec(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error)
}

// 構造体の定義
type sampleHelloAddTextUsecase struct{}

// インスタンス生成用関数の定義
func NewSampleHelloAddTextUsecase() SampleHelloAddTextUsecase {
    return &sampleHelloAddTextUsecase{}
}

func (u *sampleHelloAddTextUsecase) Exec(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error) {
    // バリデーションチェック
    if err := in.Validate(); err != nil {
        return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
    }

    msg := fmt.Sprintf("Hello %s", in.Text)

    return &pb.HelloAddTextResponseBody{Message: msg}, nil
}

※エラー時等のレスポンスのステータスコードを明示的に指定したい場合は、「status.Errorf()」と「codes」を使って指定します。

 

・「src/internal/usecases/sample/hello_add_text_sample_test.go」

package sample

import (
    "context"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
)

func TestSampleHelloAddTextOK(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloAddTextUsecase()

    // パラメータの設定
    ctx := context.Background()
    in := &pb.HelloAddTextRequestBody{Text: "Add World !"}

    // テストの実行
    res, err := sampleUsecase.Exec(ctx, in)

    // 検証
    assert.Equal(t, "Hello Add World !", res.Message)
    assert.Equal(t, nil, err)
}

func TestSampleHelloAddTextValidateErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloAddTextUsecase()

    // パラメータの設定
    ctx := context.Background()
    in := &pb.HelloAddTextRequestBody{}

    // テストの実行
    res, err := sampleUsecase.Exec(ctx, in)

    // 検証
    assert.Equal(t, "", res.GetMessage())
    assert.NotEqual(t, nil, err)
}

 

・「src/internal/servers/grpc/sample/sample.go」

package sample

import (
    "context"

    repositorySample "go-grpc/internal/repositories/sample"
    serviceSample "go-grpc/internal/services/sample"
    usecaseSample "go-grpc/internal/usecases/sample"
    pb "go-grpc/pb/sample"
)

type sample struct {
    pb.UnimplementedSampleServiceServer
}

func NewSample() *sample {
    return &sample{}
}

func (s *sample) Hello(ctx context.Context, in *pb.Empty) (*pb.HelloResponseBody, error) {
    // インスタンス生成
    sampleRepository := repositorySample.NewSampleRepository()
    sampleService := serviceSample.NewSampleService(sampleRepository)
    sampleUsecase := usecaseSample.NewSampleHelloUsecase(sampleService)

    // ユースケースを実行
    return sampleUsecase.Exec(ctx, in)
}

func (s *sample) HelloAddText(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error) {
    // インスタンス生成
    sampleUsecase := usecaseSample.NewSampleHelloAddTextUsecase()

    // ユースケースの実行
    return sampleUsecase.Exec(ctx, in)
}

※メソッドのパラメータや戻り値の型は生成されたProtocol Buffersのコードを参照して作ります。

 

・「src/internal/servers/grpc/sample/sample_test.go」

package sample

import (
    "context"
    "fmt"
    "os"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/metadata"
)

// カバレッジの対象から除外
func TestExcludeFromCoverage(t *testing.T) {
    s := NewSample()
    _, _ = s.Hello(context.Background(), &pb.Empty{})
    _, _ = s.HelloAddText(context.Background(), &pb.HelloAddTextRequestBody{Text: "Add World !"})
}

func TestSampleHello(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // テストの実行
    res, err := client.Hello(context.Background(), &pb.Empty{})
    if err != nil {
        t.Fatalf("Failed to call Hello: %v", err)
    }

    // 検証
    assert.Equal(t, "Testing Hello World !!", res.Message)
}

func TestSampleHelloAddText(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // メタデータにauthorizationを追加
    ctx := context.Background()
    md := metadata.New(map[string]string{"authorization": "Bearer token"})
    ctx = metadata.NewOutgoingContext(ctx, md)

    // テストの実行
    res, err := client.HelloAddText(ctx, &pb.HelloAddTextRequestBody{Text: "Add World !"})
    if err != nil {
        t.Fatalf("Failed to call HelloAddText: %v", err)
    }

    // 検証
    assert.Equal(t, "Hello Add World !", res.Message)
}

 

次に以下のコマンドを実行し、後述のテストで使用するためのリポジトリ用のモックファイルを生成しておきます。

$ docker compose run --rm grpc mockgen -source=./internal/repositories/sample/sample.go -destination=./internal/repositories/sample/mock_sample/mock_sample.go

 

次にファイル「src/main.go」を以下のように修正します。

package main

import (
    "fmt"
    "log/slog"
    "net"
    "os"

    sGrpcSample "go-grpc/internal/servers/grpc/sample"
    pbSample "go-grpc/pb/sample"

    "github.com/joho/godotenv"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

func main() {
    // .env ファイルの読み込み
    err := godotenv.Load()
    if err != nil {
        slog.Error(fmt.Sprintf(".envファイルの読み込みに失敗しました。: %v", err))
    }

    // ENVの設定
    env := os.Getenv("ENV")
    if env == "" {
        env = "local"
    }

    // gRPC用のポート番号の設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }

    // Listenerの設定
    listener, err := net.Listen("tcp", fmt.Sprintf(":%s", grpcPort))
    if err != nil {
        slog.Error(fmt.Sprintf("Listenerの設定に失敗しました。: %v", err))
    }

    // gRPCサーバーの作成
    s := grpc.NewServer()

    // サービス設定
    pbSample.RegisterSampleServiceServer(s, sGrpcSample.NewSample())

    // リフレクション設定
    reflection.Register(s)

    // gRPCサーバーの起動(非同期)
    slog.Info(fmt.Sprintf("[ENV=%s] start gRPC-Server port: %s", env, grpcPort))
    if err := s.Serve(listener); err != nil {
        slog.Error(fmt.Sprintf("gRPC-Server の起動に失敗しました。: %v", err))
    }
}

 

次に以下のコマンドを実行し、go.modファイルを更新します。

$ docker compose run --rm grpc go mod tidy

 

次に以下のコマンドを実行し、フォーマッターの実行および、コード解析によるエラーチェックを行います。(コード修正時などは必ず実行する)

$ docker compose run --rm grpc go fmt ./internal/...
$ docker compose run --rm grpc staticcheck ./internal/...

※フォーマッターは実務において大事になります。そしてコード解析にはDockerfileでインストールした「staticcheck」を使い、エラーがなければOKです。

 

次に以下のコマンドを実行し、Dockerコンテナの再ビルドおよびコンテナの起動を行います。

$ docker compose build --no-cache
$ docker compose up -d

 

次に作成したAPIをPostmanを使って試してみます。

まずはgRPC用のリクエスト画面を表示し、URLに「localhost:50051」を入力後、サービス定義にあるサーバーリフレクションのボタンなどから利用できるメソッドを読み込みます。

※「src/main.go」の「reflection.Register(s)」部分の処理でサーバーリフレクションの機能が使えます。コードにエラーがあったりするとメソッドの読み込みに失敗するので、その場合はコードの修正をして下さい。

 

サーバーリフレクション等により利用できるメソッドを読み込みできたら、「SampleService/Hello」選択し、「呼び出す」をクリックします。

実行後、想定通りのレスポンス結果が出力されればOKです。

 

次にメソッドで「SampleService/HelloAddText」を選択し、メッセージタブからリクエストボディを入力して実行します。

実行後、想定通りのレスポンス結果が出力されればOKです。

 

メソッド「SampleService/HelloAddText」ではリクエストボディのバリデーションチェックも行なっているため、パラメータを空の文字列でメソッドを実行するとエラーになります。

※gRPCのレスポンスのステータスコードはREST APIのとは違うので注意。

 

スポンサーリンク

gRPCのレスポンスのステータスコード一覧

ステータスコード ステータス名 概要
0 OK 処理が正常に完了したことを示します。
1 CANCELLED 呼び出し元によって処理がキャンセルされたことを示します。
2 UNKNOWN 不明なエラーが発生したことを示します。他のどのエラーにも分類できない場合に使用されます。
3 INVALID_ARGUMENT クライアントが無効な引数を指定したことを示します (例: フォーマットが不正なリソース名)。
4 DEADLINE_EXCEEDED 処理が完了する前にタイムアウト(デッドライン)したことを示します。
5 NOT_FOUND 要求されたリソースが見つからなかったことを示します。
6 ALREADY_EXISTS 作成しようとしたリソースがすでに存在していることを示します。
7 PERMISSION_DENIED 呼び出し元に、指定された処理を実行する権限がないことを示します。
8 RESOURCE_EXHAUSTED リソースが枯渇したことを示します (例: ディスク容量不足、割り当て超過など)。
9 FAILED_PRECONDITION 処理を実行するために必要なシステムの状態が満たされていないことを示します。
10 ABORTED 競合状態 (例: トランザクションの中断) などが原因で処理が中断されたことを示します。
11 OUT_OF_RANGE 範囲外の操作を試みたことを示します (例: ファイルの終端を超えて読み込もうとした)。
12 UNIMPLEMENTED その操作が実装されていないか、サービスで有効になっていないことを示します。
13 INTERNAL 予期しない内部エラーが発生したことを示します。回復が困難な深刻なエラーの場合に使用されます。
14 UNAVAILABLE サービスが一時的に利用できないことを示します。リトライによって解決する可能性があります。
15 DATA_LOSS 回復不能なデータの損失や破損が発生したことを示します。
16 UNAUTHENTICATED リクエストに有効な認証情報が含まれていないことを示します。

※gRPCでは上記のようなステータスを使います。

 

テストコードを実行して試す(カバレッジも確認)

上記ではテストコードも追加したので、テストコードを実行して試してみます。

今回はテスト実行前にテスト用の環境変数ファイル「.env.testing」を使用したいので、まずは以下のコマンドを実行してファイルを作成します。

$ touch src/.env.testing

 

次に作成したファイルを以下のように記述します。

・「src/.env.testing」

ENV=testing
GRPC_PORT=50051
GATEWAY_PORT=8080

 

次に作成した「.env.testing」を使いたいので、以下のコマンドを実行してコンテナを再起動します。

$ docker compose down
$ docker compose --env-file ./src/.env.testing up -d

 

コンテナ再起動後、以下のコマンドを実行してテストを試します。

$ docker compose exec grpc go test -v -cover ./internal/servers/...
$ docker compose exec grpc go test -v -cover ./internal/usecases/...

※go testコマンドでは、オプション「-cover」を使用するとカバレッジ率(テストがどれくらい網羅されているか)も確認できます。カバレッジ率は80%以上になるようにテストコードを書くのが推薦です。ただし、テストの整合性はチェックできないので、テスト内容は仕様に合わせてしっかり検討して下さい。

 

テスト実行後、以下のように全てのテストがPASSすればOKです。

※今回は例としてハンドラー層(サーバー層)とユースケース層のテストを書きましたが、必要に応じてサービス層のテストも書くようにして下さい。

 

カバレッジの対象箇所を確認したい場合

上記のテスト実行時にはカバレッジ率も確認しましたが、どこのテストコードが足りてないか確認したい場合、以下のコマンドでファイルを出力すると対象箇所を確認可能です。

・ハンドラー層(サーバー層)

$ docker compose exec grpc go test -v -coverprofile=internal/servers/coverage.out ./internal/servers/...
$ docker compose exec grpc go tool cover -html=internal/servers/coverage.out -o=internal/servers/coverage.html

 

・ユースケース層

$ docker compose exec grpc go test -v -coverprofile=internal/usecases/coverage.out ./internal/usecases/...
$ docker compose exec grpc go tool cover -html=internal/usecases/coverage.out -o=internal/usecases/coverage.html

 

コマンド実行後、以下のようなファイル「src/internal/servers/coverage.out」が出力され、対象箇所に関する情報が記述されます。一番右の数値が0ならテストが未実装ということです。

 

そしてgo toolコマンドによってファイル「src/internal/servers/coverage.out」からHTMLファイル「src/internal/servers/coverage.html」を生成しています。

 

VSCodeなら拡張機能「HTML Preview」を使うと、以下のようにブラウザでプレビュー可能です。

※赤文字の箇所があったらそこがテスト未実装

 

スポンサーリンク

ロガーの設定を追加

次にロガーの設定を追加するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/internal/util/context && touch src/internal/util/context/context.go
$ mkdir -p src/internal/util/logger && touch src/internal/util/logger/logger.go

 

・「src/internal/util/context/context.go」

package context

type contextKey string

const (
    XRequestId contextKey = "x-request-id"
    XRequestSource contextKey = "x-request-source"
    XUid contextKey = "x-uid"
    Status contextKey = "status"
    StatusCode contextKey = "statusCode"
)

 

・「src/internal/util/logger/logger.go」

package logger

import (
    "context"
    "log/slog"
    "os"

    utilCtx "go-grpc/internal/util/context"
)

type SlogHandler struct {
    slog.Handler
}

func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
    requestId, ok := ctx.Value(utilCtx.XRequestId).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "requestId", Value: slog.String("requestId", requestId).Value})
    }

    requestSource, ok := ctx.Value(utilCtx.XRequestSource).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "requestSource", Value: slog.String("requestSource", requestSource).Value})
    }

    uid, ok := ctx.Value(utilCtx.XUid).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "uid", Value: slog.String("uid", uid).Value})
    }

    status, ok := ctx.Value(utilCtx.Status).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "status", Value: slog.String("status", status).Value})
    }

    statusCode, ok := ctx.Value(utilCtx.StatusCode).(string)
    if ok {
        r.AddAttrs(slog.Attr{Key: "statusCode", Value: slog.String("statusCode", statusCode).Value})
    }

    return h.Handler.Handle(ctx, r)
}

var slogHandler = &SlogHandler{
    slog.NewTextHandler(os.Stdout, nil),
}

var logger = slog.New(slogHandler)

func Info(ctx context.Context, message string) {
    env := os.Getenv("ENV")
    if env != "testing" {
        logger.InfoContext(ctx, message)
    }
}

func Warn(ctx context.Context, message string) {
    env := os.Getenv("ENV")
    if env != "testing" {
        logger.WarnContext(ctx, message)
    }
}

func Error(ctx context.Context, message string) {
    env := os.Getenv("ENV")
    if env != "testing" {
        logger.ErrorContext(ctx, message)
    }
}

 

次にファイル「src/internal/usecases/sample/hello_add_text_sample.go」を以下のように修正し、ロガーを追加してみます。

・「src/internal/usecases/sample/hello_add_text_sample.go」

package sample

import (
    "context"
    "fmt"

    "go-grpc/internal/util/logger"
    pb "go-grpc/pb/sample"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// インターフェースの定義
type SampleHelloAddTextUsecase interface {
    Exec(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error)
}

// 構造体の定義
type sampleHelloAddTextUsecase struct{}

// インスタンス生成用関数の定義
func NewSampleHelloAddTextUsecase() SampleHelloAddTextUsecase {
    return &sampleHelloAddTextUsecase{}
}

func (u *sampleHelloAddTextUsecase) Exec(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error) {
    // バリデーションチェック
    if err := in.Validate(); err != nil {
        msg := fmt.Sprintf("バリデーションエラー:%s", err.Error())
        logger.Warn(ctx, msg)
        return nil, status.Errorf(codes.InvalidArgument, "%s", err.Error())
    }

    msg := fmt.Sprintf("Hello %s", in.Text)

    return &pb.HelloAddTextResponseBody{Message: msg}, nil
}

 

次に再度Postmanでメソッド「SampleService/HelloAddText」を実行し、バリデーションチェックでエラーにします。

 

次に以下のコマンドを実行し、ログ出力を確認します。

$ docker compose logs

 

コマンド実行後、以下のように設定したログが出力されればOKです。

 

gRPCのミドルウェアであるインターセプター(Interceptor)を追加

次にgRPCのミドルウェアにあたるインターセプター(Interceptor)を追加するため、以下のコマンドを実行して各種ファイルを作成します。

$ mkdir -p src/internal/interceptor && touch src/internal/interceptor/interceptor.go

 

次に作成したファイルを以下のように記述します。

・「src/internal/interceptor/interceptor.go」

package interceptor

import (
    "context"
    "fmt"
    "strings"

    utilCtx "go-grpc/internal/util/context"
    "go-grpc/internal/util/logger"
    pb "go-grpc/pb/sample"

    "github.com/google/uuid"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
)

// Streamでコンテキストを共有させるためのラッパー構造体(対象メソッドのオーバーライド)
type wrappedServerStream struct {
    grpc.ServerStream
    ctx context.Context
}

func (w *wrappedServerStream) SendMsg(m interface{}) error {
    return w.ServerStream.SendMsg(m)
}
func (w *wrappedServerStream) Context() context.Context {
    return w.ctx
}

// リクエスト用のUnaryインターセプター
func RequestUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // ctxにx-request-idを設定
    requestId := uuid.New().String()
    ctx = context.WithValue(ctx, utilCtx.XRequestId, requestId)

    // レスポンスのメタデータにx-request-idを追加
    headerMD := metadata.New(map[string]string{string(utilCtx.XRequestId): requestId})
    if err := grpc.SetHeader(ctx, headerMD); err != nil {
        return nil, err
    }

    // メタデータを取得
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, fmt.Errorf("メタデータを取得できません。")
    }

    // リクエストのメタデータからx-request-sourceを取得
    requestSource, ok := md[string(utilCtx.XRequestSource)]
    if !ok {
        requestSource = []string{"-"}
    }

    // ctxにx-request-sourceを設定
    ctx = context.WithValue(ctx, utilCtx.XRequestSource, requestSource[0])

    // レスポンスのメタデータにx-request-sourceを追加
    headerMD2 := metadata.New(map[string]string{string(utilCtx.XRequestSource): requestSource[0]})
    if err := grpc.SetHeader(ctx, headerMD2); err != nil {
        return nil, err
    }

    // リクエスト開始のログ出力
    logger.Info(ctx, "start gRPC-Server request")

    // 処理を実行
    res, err := handler(ctx, req)

    // ステータスコードを取得
    st, ok := status.FromError(err)
    if !ok {
        return nil, fmt.Errorf("ステータスコードを取得できませんでした。")
    }

    // ctxにstatusとstatusCodeを設定
    ctx = context.WithValue(ctx, utilCtx.Status, st.Code().String())
    ctx = context.WithValue(ctx, utilCtx.StatusCode, fmt.Sprintf("%d", int(st.Code())))

    // トレーラーに情報追加
    if err != nil {
        tStatus := metadata.New(map[string]string{"status": fmt.Sprintf("ERROR ( %d %s )", int(st.Code()), st.Code().String())})
        if err := grpc.SetTrailer(ctx, tStatus); err != nil {
            return nil, err
        }
    } else {
        tStatus := metadata.New(map[string]string{"status": fmt.Sprintf("SUCCESS ( %d %s )", int(st.Code()), st.Code().String())})
        if err := grpc.SetTrailer(ctx, tStatus); err != nil {
            return nil, err
        }
    }

    // リクエスト終了のログ出力
    logger.Info(ctx, "finish gRPC-Server request")

    return res, err
}

// リクエスト用のStreamインターセプター
func RequestStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
    // ctxにx-request-idを設定
    requestId := uuid.New().String()
    ctx := ss.Context()
    ctx = context.WithValue(ctx, utilCtx.XRequestId, requestId)

    // レスポンスのメタデータにx-request-idを追加
    headerMD := metadata.New(map[string]string{string(utilCtx.XRequestId): requestId})
    if err := grpc.SetHeader(ctx, headerMD); err != nil {
        return err
    }

    // メタデータを取得
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return fmt.Errorf("メタデータを取得できません。")
    }

    // リクエストのメタデータからx-request-sourceを取得
    requestSource, ok := md[string(utilCtx.XRequestSource)]
    if !ok {
        requestSource = []string{"-"}
    }

    // ctxにx-request-sourceを設定
    ctx = context.WithValue(ctx, utilCtx.XRequestSource, requestSource[0])

    // レスポンスのメタデータにx-request-sourceを追加
    headerMD2 := metadata.New(map[string]string{string(utilCtx.XRequestSource): requestSource[0]})
    if err := grpc.SetHeader(ctx, headerMD2); err != nil {
        return err
    }

    // リクエスト開始のログ出力
    logger.Info(ctx, "start gRPC-Server stream request")

    err := handler(srv, &wrappedServerStream{ss, ctx})

    // ステータスコードを取得
    st, ok := status.FromError(err)
    if !ok {
        return fmt.Errorf("ステータスコードを取得できませんでした。")
    }

    // ctxにstatusとstatusCodeを設定
    ctx = context.WithValue(ctx, utilCtx.Status, st.Code().String())
    ctx = context.WithValue(ctx, utilCtx.StatusCode, fmt.Sprintf("%d", int(st.Code())))

    // トレーラーに情報追加
    if err != nil {
        tStatus := metadata.New(map[string]string{"status": fmt.Sprintf("ERROR ( %d %s )", int(st.Code()), st.Code().String())})
        if err := grpc.SetTrailer(ctx, tStatus); err != nil {
            return err
        }
    } else {
        tStatus := metadata.New(map[string]string{"status": fmt.Sprintf("SUCCESS ( %d %s )", int(st.Code()), st.Code().String())})
        if err := grpc.SetTrailer(ctx, tStatus); err != nil {
            return err
        }
    }

    // リクエスト終了のログ出力
    logger.Info(ctx, "finish gRPC-Server stream request")

    return err
}

// 認証用のUnaryインターセプター
func AuthUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // 対象外のメソッドを設定
    skipMethods := []string{
        pb.SampleService_Hello_FullMethodName,
    }

    // 対象外メソッドの場合はスキップ
    for _, method := range skipMethods {
        if info.FullMethod == method {
            return handler(ctx, req)
        }
    }

    // authorizationからトークンを取得
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        logger.Error(ctx, "メタデータを取得できません。")
        return nil, fmt.Errorf("メタデータを取得できません。")
    }
    authHeader, ok := md["authorization"]
    if !ok {
        logger.Warn(ctx, "認証用トークンが設定されていません。")
        return nil, status.Errorf(codes.InvalidArgument, "%s", "認証用トークンが設定されていません。")
    }
    token := strings.TrimPrefix(authHeader[0], "Bearer ")
    if token == "" {
        logger.Warn(ctx, "認証用トークンが設定されていません。")
        return nil, status.Errorf(codes.InvalidArgument, "%s", "認証用トークンが設定されていません。")
    }

    // TODO: 認証チェック処理を追加

    // 処理を実行
    return handler(ctx, req)
}

// 認証用のStreamインターセプター
func AuthStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
    // 対象外のメソッドを設定
    skipMethods := []string{
    }

    // 対象外メソッドの場合はスキップ
    for _, method := range skipMethods {
        if info.FullMethod == method {
            return handler(srv, ss)
        }
    }

    // authorizationからトークンを取得
    ctx := ss.Context()
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        logger.Error(ctx, "メタデータを取得できません。")
        return fmt.Errorf("メタデータを取得できません。")
    }
    authHeader, ok := md["authorization"]
    if !ok {
        logger.Warn(ctx, "認証用トークンが設定されていません。")
        return status.Errorf(codes.InvalidArgument, "%s", "認証用トークンが設定されていません。")
    }
    token := strings.TrimPrefix(authHeader[0], "Bearer ")
    if token == "" {
        logger.Warn(ctx, "認証用トークンが設定されていません。")
        return status.Errorf(codes.InvalidArgument, "%s", "認証用トークンが設定されていません。")
    }

    // TODO: 認証チェック処理を追加

    return handler(srv, &wrappedServerStream{ss, ctx})
}

※今回もリクエスト単位で実行させたい処理と認証用に必要になる処理の例を記述しています。インターセプターではUnary用(上記で追加した通常のメソッド)とStream用(後述のストリーミング機能用)でそれぞれ別々の処理を記述する必要があります。またメソッド単位で個別に適用などはできないため、適用したくないメソッドがある場合は別途スキップ処理を記述しておく必要があります。

 

次にファイル「src/main.go」を以下のように修正し、インターセプターを追加します。

package main

import (
    "fmt"
    "log/slog"
    "net"
    "os"

    "go-grpc/internal/interceptor"
    sGrpcSample "go-grpc/internal/servers/grpc/sample"
    pbSample "go-grpc/pb/sample"

    "github.com/joho/godotenv"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

func main() {
    // .env ファイルの読み込み
    err := godotenv.Load()
    if err != nil {
        slog.Error(fmt.Sprintf(".envファイルの読み込みに失敗しました。: %v", err))
    }

    // ENVの設定
    env := os.Getenv("ENV")
    if env == "" {
        env = "local"
    }

    // gRPC用のポート番号の設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }

    // Listenerの設定
    listener, err := net.Listen("tcp", fmt.Sprintf(":%s", grpcPort))
    if err != nil {
        slog.Error(fmt.Sprintf("Listenerの設定に失敗しました。: %v", err))
    }

    // gRPCサーバーの作成
    s := grpc.NewServer(
        // インターセプターの適用
        grpc.ChainUnaryInterceptor(
            interceptor.RequestUnaryInterceptor,
            interceptor.AuthUnaryInterceptor,
        ),
        grpc.ChainStreamInterceptor(
            interceptor.RequestStreamInterceptor,
            interceptor.AuthStreamInterceptor,
        ),
    )

    // サービス設定
    pbSample.RegisterSampleServiceServer(s, sGrpcSample.NewSample())

    // リフレクション設定
    reflection.Register(s)

    // gRPCサーバーの起動(非同期)
    slog.Info(fmt.Sprintf("[ENV=%s] start gRPC-Server port: %s", env, grpcPort))
    if err := s.Serve(listener); err != nil {
        slog.Error(fmt.Sprintf("gRPC-Server の起動に失敗しました。: %v", err))
    }
}

 

次に以下のコマンドを実行し、go.modファイルを更新します。

$ docker compose exec grpc go mod tidy

 

次に追加したインターセプターを試すため、再度メソッド「SampleService/Hello」を実行します。

 

実行後、ログ出力を確認し、以下のように想定通りのログが出力されればOKです。

 

次に再度メソッド「SampleService/HelloAddText」を実行し、認証チェックでエラーになればOKです。

 

次にBearerトークンを付与して再度メソッド「SampleService/HelloAddText」実行し、認証処理をPASSして正常終了すればOKです。

※gRPCでリクエストボディ以外の追加要素を送信させる場合はメタデータを使います。

 

スポンサーリンク

gRPCのストリーミング機能を追加する

次にgRPCを使うメリットであるストリーミング機能を追加して試します。

ストリーミング機能については、サーバーストリーミング(1リクエスト-複数レスポンス)、クライアントストリーミング(複数リクエスト-1レスポンス)、双方向ストリーミング(複数リクエスト-複数レスポンス)の三種類があります。

まずは.protoファイル「src/proto/sample/sample.proto」を以下のように修正します。

syntax = "proto3";

import "validate/validate.proto";

package sample;

option go_package="pb/sample";

// 空のリクエストパラメータ
message Empty {}

// Helloメソッドのレスポンス結果
message HelloResponseBody {
    // メッセージ
    string message = 1;
}

// HelloAddTextメソッドのリクエストパラメータ
message HelloAddTextRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloAddTextメソッドのレスポンス結果
message HelloAddTextResponseBody {
    // メッセージ
    string message = 1;
}

// HelloServerStreamメソッドのリクエストパラメータ
message HelloServerStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloServerStreamメソッドのレスポンス結果
message HelloServerStreamResponseBody {
    // メッセージ
    string message = 1;
}

// HelloClientStreamメソッドのリクエストパラメータ
message HelloClientStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloClientStreamメソッドのレスポンス結果
message HelloClientStreamResponseBody {
    // メッセージ
    string message = 1;
}

// HelloBidirectionalStreamメソッドのリクエストパラメータ
message HelloBidirectionalStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloBidirectionalStreamメソッドのレスポンス結果
message HelloBidirectionalStreamResponseBody {
    // メッセージ
    string message = 1;
}

// サンプルサービス
service SampleService {
    // 「Hello World !!」を出力
    rpc Hello(Empty) returns (HelloResponseBody) {}
    // Returns:
    // - 0 OK: HelloResponseBodyを出力
    // - 2 Unknown: 不明なエラー

    // 「Hello {リクエストパラメータのtext}」を出力
    rpc HelloAddText(HelloAddTextRequestBody) returns (HelloAddTextResponseBody) {}
    // Returns: 
    // - 0 OK: HelloAddTextResponseBodyを出力
    // - 2 Unknown: 不明なエラー 
    // - 3 INVALID_ARGUMENT: バリデーションエラー

    // サーバーストリーミング(1リクエスト-複数のレスポンス)
    rpc HelloServerStream(HelloServerStreamRequestBody) returns (stream HelloServerStreamResponseBody) {}
    // Returns:
    // - 0 OK: HelloServerStreamResponseBodyを出力(複数回)
    // - 2 Unknown: 不明なエラー
    // - 3 INVALID_ARGUMENT: バリデーションエラー

    // クライアントストリーミング(複数のリクエスト-1レスポンス)
    rpc HelloClientStream(stream HelloClientStreamRequestBody) returns (HelloClientStreamResponseBody) {}
    // Returns:
    // - 0 OK: HelloClientStreamResponseBodyを出力
    // - 2 Unknown: 不明なエラー
    // - 3 INVALID_ARGUMENT: バリデーションエラー)

    // 双方向ストリーミング(複数のリクエスト-複数のレスポンス)
    rpc HelloBidirectionalStream(stream HelloBidirectionalStreamRequestBody) returns (stream HelloBidirectionalStreamResponseBody) {}
    // Returns:
    // - 0 OK: HelloClientStreamResponseBodyを出力
    // - 2 Unknown: 不明なエラー
    // - 3 INVALID_ARGUMENT: バリデーションエラー)
}

 

次に以下のコマンドを実行し、.protoファイルからProtocol Buffersのコードを生成します。

$ docker compose exec grpc protoc -I=.:../pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1 --go_out=. --go-grpc_out=. --validate_out=lang=go:. ./proto/sample/sample.proto
$ docker compose exec grpc protoc -I=.:../pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1 --doc_out=./doc --doc_opt=markdown,docs.md ./proto/sample/sample.proto

 

次にコマンドを実行し、各種ファイルを作成します。

$ touch src/internal/usecases/sample/hello_server_stream_sample.go src/internal/usecases/sample/hello_server_stream_sample_test.go
$ touch src/internal/usecases/sample/hello_client_stream_sample.go src/internal/usecases/sample/hello_client_stream_sample_test.go
$ touch src/internal/usecases/sample/hello_bidirectional_stream_sample.go src/internal/usecases/sample/hello_bidirectional_stream_sample_test.go

 

・「src/internal/usecases/sample/hello_server_stream_sample.go」

package sample

import (
    "fmt"
    "time"

    "go-grpc/internal/util/logger"
    pb "go-grpc/pb/sample"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// インターフェースの定義
type SampleHelloServerStreamUsecase interface {
    Exec(in *pb.HelloServerStreamRequestBody, stream grpc.ServerStreamingServer[pb.HelloServerStreamResponseBody]) error
}

// 構造体の定義
type sampleHelloServerStreamUsecase struct{}

// インスタンス生成用関数の定義
func NewSampleHelloServerStreamUsecase() SampleHelloServerStreamUsecase {
    return &sampleHelloServerStreamUsecase{}
}

func (u *sampleHelloServerStreamUsecase) Exec(in *pb.HelloServerStreamRequestBody, stream grpc.ServerStreamingServer[pb.HelloServerStreamResponseBody]) error {
    // コンテキストを取得
    ctx := stream.Context()

    // バリデーションチェック
    if err := in.Validate(); err != nil {
        msg := fmt.Sprintf("バリデーションエラー:%s", err.Error())
        logger.Warn(ctx, msg)

        return status.Errorf(codes.InvalidArgument, "%s", err.Error())
    }

    resCount := 3
    for i := 0; i < resCount; i++ {
        if err := stream.Send(
            &pb.HelloServerStreamResponseBody{
                Message: fmt.Sprintf("[%d]Hello, %s", i, in.GetText()),
            },
        ); err != nil {
            return err
        }
        time.Sleep(time.Second * 1)
    }

    return nil
}

 

・「src/internal/usecases/sample/hello_server_stream_sample_test.go」

package sample

import (
    "context"
    "fmt"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
    "google.golang.org/grpc"
)

// HelloServerStreamのstream用のモック構造体
type mockHelloServerStream struct {
    grpc.ServerStreamingServer[pb.HelloServerStreamResponseBody]
    ctx context.Context
    sent []*pb.HelloServerStreamResponseBody
    sendErr error
}

func (m *mockHelloServerStream) Send(resp *pb.HelloServerStreamResponseBody) error {
    m.sent = append(m.sent, resp)
    return m.sendErr
}

func (m *mockHelloServerStream) Context() context.Context {
    return m.ctx
}

func TestSampleHelloServerStreamOK(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloServerStreamUsecase()

    // パラメータの設定
    in := &pb.HelloServerStreamRequestBody{Text: "World !"}
    mockStream := &mockHelloServerStream{
        ctx: context.Background(),
        sent: []*pb.HelloServerStreamResponseBody{},
        sendErr: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(in, mockStream)

    // 検証
    assert.Equal(t, nil, err)
}

func TestSampleHelloServerStreamValidateErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloServerStreamUsecase()

    // パラメータの設定
    in := &pb.HelloServerStreamRequestBody{Text: ""}
    mockStream := &mockHelloServerStream{
        ctx: context.Background(),
        sent: []*pb.HelloServerStreamResponseBody{},
        sendErr: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(in, mockStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

func TestSampleHelloServerStreamSendErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloServerStreamUsecase()

    // パラメータの設定
    in := &pb.HelloServerStreamRequestBody{Text: "World !"}
    mockStream := &mockHelloServerStream{
        ctx: context.Background(),
        sent: []*pb.HelloServerStreamResponseBody{},
        sendErr: fmt.Errorf("error"),
    }

    // テストの実行
    err := sampleUsecase.Exec(in, mockStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

 

・「src/internal/usecases/sample/hello_client_stream_sample.go」

package sample

import (
    "errors"
    "fmt"
    "io"

    "go-grpc/internal/util/logger"
    pb "go-grpc/pb/sample"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// インターフェースの定義
type SampleHelloClientStreamUsecase interface {
    Exec(stream grpc.ClientStreamingServer[pb.HelloClientStreamRequestBody, pb.HelloClientStreamResponseBody]) error
}

// 構造体の定義
type sampleHelloClientStreamUsecase struct{}

// インスタンス生成用関数の定義
func NewSampleHelloClientStreamUsecase() SampleHelloClientStreamUsecase {
    return &sampleHelloClientStreamUsecase{}
}

func (u *sampleHelloClientStreamUsecase) Exec(stream grpc.ClientStreamingServer[pb.HelloClientStreamRequestBody, pb.HelloClientStreamResponseBody]) error {
    // コンテキストを取得
    ctx := stream.Context()

    textList := make([]string, 0)
    for {
        req, err := stream.Recv()
        if errors.Is(err, io.EOF) {
            msg := fmt.Sprintf("Hello, %v!", textList)
           return stream.SendAndClose(&pb.HelloClientStreamResponseBody{Message: msg})
        }
        if err != nil {
            return err
        }

        // バリデーションチェック
        if err := req.Validate(); err != nil {
            msg := fmt.Sprintf("バリデーションエラー:%s", err.Error())
            logger.Warn(ctx, msg)

            return status.Errorf(codes.InvalidArgument, "%s", err.Error())
        }

        textList = append(textList, req.GetText())
    }
}

 

・「src/internal/usecases/sample/hello_client_stream_sample_test.go」

package sample

import (
    "context"
    "fmt"
    "io"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
    "google.golang.org/grpc"
)

// HelloClientStreamのstream用のモック構造体
type mockHelloClientStream struct {
    grpc.ClientStreamingServer[pb.HelloClientStreamRequestBody, pb.HelloClientStreamResponseBody]
    ctx context.Context
    recvData []*pb.HelloClientStreamRequestBody
    recvIndex int
    recvError error
    sendResult *pb.HelloClientStreamResponseBody
    sendError error
}

func (m *mockHelloClientStream) Recv() (*pb.HelloClientStreamRequestBody, error) {
    if m.recvError != nil {
        return nil, m.recvError
    }
    if m.recvIndex >= len(m.recvData) {
        return nil, io.EOF
    }
    data := m.recvData[m.recvIndex]
    m.recvIndex++
    return data, nil
}

func (m *mockHelloClientStream) SendAndClose(resp *pb.HelloClientStreamResponseBody) error {
    m.sendResult = resp
    return m.sendError
}

func (m *mockHelloClientStream) Context() context.Context {
    return m.ctx
}

func TestSampleHelloClientStreamOK(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloClientStreamUsecase()

    // パラメータの設定
    mockClientStream := &mockHelloClientStream{
        ctx: context.Background(),
        recvData: []*pb.HelloClientStreamRequestBody{{Text: "A"}, {Text: "B"}, {Text: "C"}},
        recvError: nil,
        sendError: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(mockClientStream)

    // 検証
    assert.Equal(t, nil, err)
}

func TestSampleHelloClientStreamRecvErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloClientStreamUsecase()

    // パラメータの設定
    mockClientStream := &mockHelloClientStream{
        ctx: context.Background(),
        recvData: []*pb.HelloClientStreamRequestBody{{Text: "A"}, {Text: "B"}, {Text: "C"}},
        recvError: fmt.Errorf("error"),
        sendError: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(mockClientStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

func TestSampleHelloClientStreamValidateErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloClientStreamUsecase()

    // パラメータの設定
    mockClientStream := &mockHelloClientStream{
        ctx: context.Background(),
        recvData: []*pb.HelloClientStreamRequestBody{{Text: ""}, {Text: "B"}, {Text: "C"}},
        recvError: nil,
        sendError: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(mockClientStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

 

・「src/internal/usecases/sample/hello_bidirectional_stream_sample.go」

package sample

import (
    "errors"
    "fmt"
    "io"

    "go-grpc/internal/util/logger"
    pb "go-grpc/pb/sample"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

// インターフェースの定義
type SampleHelloBidirectionalStreamUsecase interface {
    Exec(stream grpc.BidiStreamingServer[pb.HelloBidirectionalStreamRequestBody, pb.HelloBidirectionalStreamResponseBody]) error
}

// 構造体の定義
type sampleHelloBidirectionalStreamUsecase struct{}

// インスタンス生成用関数の定義
func NewSampleHelloBidirectionalStreamUsecase() SampleHelloBidirectionalStreamUsecase {
    return &sampleHelloBidirectionalStreamUsecase{}
}

func (u *sampleHelloBidirectionalStreamUsecase) Exec(stream grpc.BidiStreamingServer[pb.HelloBidirectionalStreamRequestBody, pb.HelloBidirectionalStreamResponseBody]) error {
    // コンテキストを取得
    ctx := stream.Context()

    for {
        req, err := stream.Recv()
        if errors.Is(err, io.EOF) {
            return nil
        }
        if err != nil {
            return err
        }

        // バリデーションチェック
        if err := req.Validate(); err != nil {
            msg := fmt.Sprintf("バリデーションエラー:%s", err.Error())
            logger.Warn(ctx, msg)

            return status.Errorf(codes.InvalidArgument, "%s", err.Error())
        }

        msg := fmt.Sprintf("Hello, %s !!", req.GetText())
        if err := stream.Send(&pb.HelloBidirectionalStreamResponseBody{Message: msg}); err != nil {
            return err
        }
    }
}

 

・「src/internal/usecases/sample/hello_bidirectional_stream_sample_test.go」

package sample

import (
    "context"
    "fmt"
    "io"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
    "google.golang.org/grpc"
)

// HelloBidirectionalStreamのstream用のモック構造体
type mockHelloBidirectionalStream struct {
    grpc.BidiStreamingServer[pb.HelloBidirectionalStreamRequestBody, pb.HelloBidirectionalStreamResponseBody]
    ctx context.Context
    sent []*pb.HelloBidirectionalStreamResponseBody
    recv []*pb.HelloBidirectionalStreamRequestBody
    recvIndex int
    sendError error
    recvError error
}

func (m *mockHelloBidirectionalStream) Send(resp *pb.HelloBidirectionalStreamResponseBody) error {
    if m.sendError != nil {
        return m.sendError
    }
    m.sent = append(m.sent, resp)
    return nil
}

func (m *mockHelloBidirectionalStream) Recv() (*pb.HelloBidirectionalStreamRequestBody, error) {
    if m.recvError != nil {
        return nil, m.recvError
    }
    if m.recvIndex >= len(m.recv) {
        return nil, io.EOF
    }
    req := m.recv[m.recvIndex]
    m.recvIndex++
    return req, nil
}

func (m *mockHelloBidirectionalStream) Context() context.Context {
    return m.ctx
}

func TestSampleHelloBidirectionaStreamOK(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloBidirectionalStreamUsecase()

    // パラメータの設定
    mockBidirectionalStream := &mockHelloBidirectionalStream{
        ctx: context.Background(),
        recv: []*pb.HelloBidirectionalStreamRequestBody{{Text: "Tanaka"}},
        recvError: nil,
        sendError: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(mockBidirectionalStream)

    // 検証
    assert.Equal(t, nil, err)
}

func TestSampleHelloBidirectionaStreamRecvErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloBidirectionalStreamUsecase()

    // パラメータの設定
    mockBidirectionalStream := &mockHelloBidirectionalStream{
        ctx: context.Background(),
        recv: []*pb.HelloBidirectionalStreamRequestBody{{Text: "Tanaka"}},
        recvError: fmt.Errorf("error"),
        sendError: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(mockBidirectionalStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

func TestSampleHelloBidirectionaStreamValidateErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloBidirectionalStreamUsecase()

    // パラメータの設定
    mockBidirectionalStream := &mockHelloBidirectionalStream{
        ctx: context.Background(),
        recv: []*pb.HelloBidirectionalStreamRequestBody{{Text: ""}},
        recvError: nil,
        sendError: nil,
    }

    // テストの実行
    err := sampleUsecase.Exec(mockBidirectionalStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

func TestSampleHelloBidirectionaStreamSendErr(t *testing.T) {
    // ユースケースのインスタンス化
    sampleUsecase := NewSampleHelloBidirectionalStreamUsecase()

    // パラメータの設定
    mockBidirectionalStream := &mockHelloBidirectionalStream{
        ctx: context.Background(),
        recv: []*pb.HelloBidirectionalStreamRequestBody{{Text: "Tanaka"}},
        recvError: nil,
        sendError: fmt.Errorf("error"),
    }

    // テストの実行
    err := sampleUsecase.Exec(mockBidirectionalStream)

    // 検証
    assert.NotEqual(t, nil, err)
}

 

次にファイル「src/internal/servers/grpc/sample/sample.go」、「src/internal/servers/grpc/sample/sample_test.go」をそれぞれ以下のように修正します。

・「src/internal/servers/grpc/sample/sample.go」

package sample

import (
    "context"

    repositorySample "go-grpc/internal/repositories/sample"
    serviceSample "go-grpc/internal/services/sample"
    usecaseSample "go-grpc/internal/usecases/sample"
    pb "go-grpc/pb/sample"

    "google.golang.org/grpc"
)

type sample struct {
    pb.UnimplementedSampleServiceServer
}

func NewSample() *sample {
    return &sample{}
}

func (s *sample) Hello(ctx context.Context, in *pb.Empty) (*pb.HelloResponseBody, error) {
    // インスタンス生成
    sampleRepository := repositorySample.NewSampleRepository()
    sampleService := serviceSample.NewSampleService(sampleRepository)
    sampleUsecase := usecaseSample.NewSampleHelloUsecase(sampleService)

    // ユースケースを実行
    return sampleUsecase.Exec(ctx, in)
}

func (s *sample) HelloAddText(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error) {
    // インスタンス生成
    sampleUsecase := usecaseSample.NewSampleHelloAddTextUsecase()

    // ユースケースの実行
    return sampleUsecase.Exec(ctx, in)
}

// サーバーストリーミングのメソッド
func (s *sample) HelloServerStream(in *pb.HelloServerStreamRequestBody, stream grpc.ServerStreamingServer[pb.HelloServerStreamResponseBody]) error {
    // インスタンス生成
    sampleUsecase := usecaseSample.NewSampleHelloServerStreamUsecase()

    // ユースケースの実行
    return sampleUsecase.Exec(in, stream)
}

// クライアントストリーミングのメソッド
func (s *sample) HelloClientStream(stream grpc.ClientStreamingServer[pb.HelloClientStreamRequestBody, pb.HelloClientStreamResponseBody]) error {
    // インスタンス生成
    sampleUsecase := usecaseSample.NewSampleHelloClientStreamUsecase()

    // ユースケースの実行
    return sampleUsecase.Exec(stream)
}

// 双方向ストリーミングのメソッド
func (s *sample) HelloBidirectionalStream(stream grpc.BidiStreamingServer[pb.HelloBidirectionalStreamRequestBody, pb.HelloBidirectionalStreamResponseBody]) error {
    // インスタンス生成
    sampleUsecase := usecaseSample.NewSampleHelloBidirectionalStreamUsecase()

    // ユースケースの実行
    return sampleUsecase.Exec(stream)
}

 

・「src/internal/servers/grpc/sample/sample_test.go」

package sample

import (
    "context"
    "errors"
    "fmt"
    "io"
    "os"
    "strconv"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/metadata"
)

// HelloServerStreamのstream用のモック構造体
type mockHelloServerStream struct {
    grpc.ServerStreamingServer[pb.HelloServerStreamResponseBody]
    ctx context.Context
    sent []*pb.HelloServerStreamResponseBody
    sendErr error
}

func (m *mockHelloServerStream) Send(resp *pb.HelloServerStreamResponseBody) error {
    m.sent = append(m.sent, resp)
    return m.sendErr
}

func (m *mockHelloServerStream) Context() context.Context {
    return m.ctx
}

// HelloClientStreamのstream用のモック構造体
type mockHelloClientStream struct {
    grpc.ClientStreamingServer[pb.HelloClientStreamRequestBody, pb.HelloClientStreamResponseBody]
    ctx context.Context
    recvData []*pb.HelloClientStreamRequestBody
    recvIndex int
    recvError error
    sendResult *pb.HelloClientStreamResponseBody
    sendError error
}

func (m *mockHelloClientStream) Recv() (*pb.HelloClientStreamRequestBody, error) {
    if m.recvError != nil {
        return nil, m.recvError
    }
    if m.recvIndex >= len(m.recvData) {
        return nil, io.EOF
    }
    data := m.recvData[m.recvIndex]
    m.recvIndex++
    return data, nil
}

func (m *mockHelloClientStream) SendAndClose(resp *pb.HelloClientStreamResponseBody) error {
    m.sendResult = resp
    return m.sendError
}

func (m *mockHelloClientStream) Context() context.Context {
    return m.ctx
}

// HelloBidirectionalStreamのstream用のモック構造体
type mockHelloBidirectionalStream struct {
    grpc.BidiStreamingServer[pb.HelloBidirectionalStreamRequestBody, pb.HelloBidirectionalStreamResponseBody]
    ctx context.Context
    sent []*pb.HelloBidirectionalStreamResponseBody
    recv []*pb.HelloBidirectionalStreamRequestBody
    recvIndex int
    sendError error
    recvError error
}

func (m *mockHelloBidirectionalStream) Send(resp *pb.HelloBidirectionalStreamResponseBody) error {
    if m.sendError != nil {
        return m.sendError
    }
    m.sent = append(m.sent, resp)
    return nil
}

func (m *mockHelloBidirectionalStream) Recv() (*pb.HelloBidirectionalStreamRequestBody, error) {
    if m.recvError != nil {
        return nil, m.recvError
    }
    if m.recvIndex >= len(m.recv) {
        return nil, io.EOF
    }
    req := m.recv[m.recvIndex]
    m.recvIndex++
    return req, nil
}

func (m *mockHelloBidirectionalStream) Context() context.Context {
    return m.ctx
}

// カバレッジの対象から除外
func TestExcludeFromCoverage(t *testing.T) {
    s := NewSample()
    _, _ = s.Hello(context.Background(), &pb.Empty{})
    _, _ = s.HelloAddText(context.Background(), &pb.HelloAddTextRequestBody{Text: "Add World !"})

    // サーバーストリーミング
    in := &pb.HelloServerStreamRequestBody{Text: "World !"}
    mockServerStream := &mockHelloServerStream{}
    _ = s.HelloServerStream(in, mockServerStream)

    // クライアントストリーミング
    mockClientStream := &mockHelloClientStream{}
    _ = s.HelloClientStream(mockClientStream)

    // 双方向ストリーミング
    mockBidirectionalStream := &mockHelloBidirectionalStream{}
    _ = s.HelloBidirectionalStream(mockBidirectionalStream)
}

func TestSampleHello(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // テストの実行
    res, err := client.Hello(context.Background(), &pb.Empty{})
    if err != nil {
        t.Fatalf("Failed to call Hello: %v", err)
    }

    // 検証
    assert.Equal(t, "Testing Hello World !!", res.Message)
}

func TestSampleHelloAddText(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // メタデータにauthorizationを追加
    ctx := context.Background()
    md := metadata.New(map[string]string{"authorization": "Bearer token"})
    ctx = metadata.NewOutgoingContext(ctx, md)

    // テストの実行
    res, err := client.HelloAddText(ctx, &pb.HelloAddTextRequestBody{Text: "Add World !"})
    if err != nil {
        t.Fatalf("Failed to call HelloAddText: %v", err)
    }

    // 検証
    assert.Equal(t, "Hello Add World !", res.Message)
}

func TestSampleHelloServerStream(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // メタデータにauthorizationを追加
    ctx := context.Background()
    md := metadata.New(map[string]string{"authorization": "Bearer token"})
    ctx = metadata.NewOutgoingContext(ctx, md)

    // テストの実行
    in := &pb.HelloServerStreamRequestBody{Text: "World !"}
    stream, err := client.HelloServerStream(ctx, in)
    if err != nil {
        t.Fatalf("Failed to call client.HelloServerStream: %v", err)
    }

    // ストリーミング処理のメッセージを取得
    var msgs []string
    for {
        res, err := stream.Recv()
        if errors.Is(err, io.EOF) {
            break
        }
        msgs = append(msgs, res.Message)
    }

    // 検証
    assert.Equal(t, []string{"[0]Hello, World !", "[1]Hello, World !", "[2]Hello, World !"}, msgs)
}

func TestSampleHelloClientStream(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // メタデータにauthorizationを追加
    ctx := context.Background()
    md := metadata.New(map[string]string{"authorization": "Bearer token"})
    ctx = metadata.NewOutgoingContext(ctx, md)

    // テストの実行
    stream, err := client.HelloClientStream(ctx)
    if err != nil {
        t.Fatalf("Failed to call client.HelloClientStream: %v", err)
    }

    sendCount := 3
    for i := 0; i < sendCount; i++ {
        if err := stream.Send(&pb.HelloClientStreamRequestBody{Text: strconv.Itoa(i)}); err != nil {
            t.Fatalf("Failed to stream.Send: %v", err)
        }
    }

    res, err := stream.CloseAndRecv()
    if err != nil {
        t.Fatalf("Failed to close and stream.CloseAndRecv: %v", err)
    }

    assert.Equal(t, "Hello, [0 1 2]!", res.Message)
}

func TestSampleHelloBidirectionalStream(t *testing.T) {
    // gRPCクライアントの設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }
    target := fmt.Sprintf("dns:///localhost:%s", grpcPort)
    conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        t.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()
    client := pb.NewSampleServiceClient(conn)

    // メタデータにauthorizationを追加
    ctx := context.Background()
    md := metadata.New(map[string]string{"authorization": "Bearer token"})
    ctx = metadata.NewOutgoingContext(ctx, md)

    // テストの実行
    stream, err := client.HelloBidirectionalStream(ctx)
    if err != nil {
        t.Fatalf("Failed to call client.HelloBidirectionalStream: %v", err)
    }

    var sendEnd, recvEnd bool
    for !(sendEnd && recvEnd) {
        // 送信処理
        if !sendEnd {
            if err := stream.Send(&pb.HelloBidirectionalStreamRequestBody{Text: "Tanaka"}); err != nil {
                t.Fatalf("Failed to stream.Send: %v", err)
            }
            if err := stream.CloseSend(); err != nil {
                t.Fatalf("Failed to stream.CloseSend: %v", err)
            }
            sendEnd = true
        }

        // 受信処理
        if !recvEnd {
            res, err := stream.Recv()
            if err != nil {
                t.Fatalf("Failed to stream.Recv: %v", err)
            }

            // 検証
            assert.Equal(t, "Hello, Tanaka !!", res.Message)
            recvEnd = true
        }
    }
}

 

サーバーストリーミングを試す

次にPostmanを使ってサーバーストリーミング機能を試します。

追加されたメソッド「SampleService/HelloServerStream」を選択して実行し、想定通りに3件のレスポンスが返ってこればOKです。

※Bearerトークンも付与して実行して下さい。

 

クライアントストリーミングを試す

次にPostmanを使ってクライアントストリーミング機能を試します。

追加されたメソッド「SampleService/HelloClientStream」を選択して実行し、「呼び出す」をクリックして接続します。

 

接続後に画面右下の「送信」からリクエストを送信できます。

 

リクエスト送信後、画面下のレスポンスタブに送信結果が表示されます。

 

もう一度リクエストを送信後、「ストリーミング終了」をクリックして終了します。

 

終了後、1件のレスポンス結果が返ってこればOKです。

 

双方向ストリーミングを試す

次にPostmanを使って双方向ストリーミング機能を試します。

追加されたメソッド「SampleService/HelloBidirectionalStream」を選択して実行し、「呼び出す」をクリックして接続します。

 

次にリクエストを送信すると、すぐにレスポンス結果が返ってこればOKです。

※接続を終了する場合は「ストリーミングを終了」をクリックして下さい。

 

スポンサーリンク

gRPC-GatewayでREST API用のエンドポイントを追加する

次に上記の図で表したgRPC-GatewayでREST API用のエンドポイントを追加します。

 

まずは.protoファイル「src/proto/sample/sample.proto」を以下のように修正します。

syntax = "proto3";

import "validate/validate.proto";
import "google/api/annotations.proto";
import "protoc-gen-openapiv2/options/annotations.proto";

package sample;

option go_package="pb/sample";

option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
  swagger: "2.0"
  info: {
    title: "gRPC-Gateway"
    description: "gRPC-GatewayのOpenAPI仕様書"
    version: "1.0"
  }
  host: "localhost:8080"
  schemes: HTTP
  security_definitions: {
    security: {
      key: "BearerAuth"
      value: {
        type: TYPE_API_KEY
        in: IN_HEADER
        name: "Authorization"
        description: "Enter the token with the `Bearer ` prefix, e.g., `Bearer abcde12345`"
      }
    }
  }
};

// 空のリクエストパラメータ
message Empty {}

// Helloメソッドのレスポンス結果
message HelloResponseBody {
    // メッセージ
    string message = 1;
}

// HelloAddTextメソッドのリクエストパラメータ
message HelloAddTextRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloAddTextメソッドのレスポンス結果
message HelloAddTextResponseBody {
    // メッセージ
    string message = 1;
}

// HelloServerStreamメソッドのリクエストパラメータ
message HelloServerStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloServerStreamメソッドのレスポンス結果
message HelloServerStreamResponseBody {
    // メッセージ
    string message = 1;
}

// HelloClientStreamメソッドのリクエストパラメータ
message HelloClientStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloClientStreamメソッドのレスポンス結果
message HelloClientStreamResponseBody {
    // メッセージ
    string message = 1;
}

// HelloBidirectionalStreamメソッドのリクエストパラメータ
message HelloBidirectionalStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloBidirectionalStreamメソッドのレスポンス結果
message HelloBidirectionalStreamResponseBody {
    // メッセージ
    string message = 1;
}

// エラーレスポンス
message ErrResponse {
  // メッセージ
  string message = 1;
}

// サンプルサービス
service SampleService {
  // 「Hello World !!」を出力
  rpc Hello(Empty) returns (HelloResponseBody) {}
  // Returns:
  // - 0 OK: HelloResponseBodyを出力
  // - 2 Unknown: 不明なエラー

  // 「Hello {リクエストパラメータのtext}」を出力
  rpc HelloAddText(HelloAddTextRequestBody) returns (HelloAddTextResponseBody) {}
  // Returns: 
  // - 0 OK: HelloAddTextResponseBodyを出力
  // - 2 Unknown: 不明なエラー 
  // - 3 INVALID_ARGUMENT: バリデーションエラー

  // サーバーストリーミング(1リクエスト-複数のレスポンス)
  rpc HelloServerStream(HelloServerStreamRequestBody) returns (stream HelloServerStreamResponseBody) {}
  // Returns:
  // - 0 OK: HelloServerStreamResponseBodyを出力(複数回)
  // - 2 Unknown: 不明なエラー
  // - 3 INVALID_ARGUMENT: バリデーションエラー

  // クライアントストリーミング(複数のリクエスト-1レスポンス)
  rpc HelloClientStream(stream HelloClientStreamRequestBody) returns (HelloClientStreamResponseBody) {}
  // Returns:
  // - 0 OK: HelloClientStreamResponseBodyを出力
  // - 2 Unknown: 不明なエラー
  // - 3 INVALID_ARGUMENT: バリデーションエラー)

  // 双方向ストリーミング(複数のリクエスト-複数のレスポンス)
  rpc HelloBidirectionalStream(stream HelloBidirectionalStreamRequestBody) returns (stream HelloBidirectionalStreamResponseBody) {}
  // Returns:
  // - 0 OK: HelloClientStreamResponseBodyを出力
  // - 2 Unknown: 不明なエラー
  // - 3 INVALID_ARGUMENT: バリデーションエラー)

  // gRPC-Gateway(GETメソッド)
  rpc HelloApi(Empty) returns (HelloResponseBody){
    option (google.api.http) = {
      get: "/api/v1/hello"
    };
    option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
      responses: {
        key: "500"
        value: {
          description: "Internal Server Error"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Internal Server Error\"}"
          }
        }
      }
    };
  }

  // gRPC-Gateway(POSTメソッド)
  rpc HelloAddTextApi(HelloAddTextRequestBody) returns (HelloAddTextResponseBody){
    option (google.api.http) = {
      post: "/api/v1/hello"
      body: "*"
    };
    option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
      responses: {
        key: "400"
        value: {
          description: "Bad Request"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Bad Request\"}"
          }
        }
      }
      responses: {
        key: "401"
        value: {
          description: "Unauthorized"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Unauthorized\"}"
          }
        }
      }
      responses: {
        key: "500"
        value: {
          description: "Internal Server Error"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Internal Server Error\"}"
          }
        }
      }
      security: {
        security_requirement: {
          key: "BearerAuth"
          value: {}
        }
      }
    };
  }
}

※OpenAPI仕様書の内容も合わせて記述しています。

 

次に以下のコマンドを実行し、.protoファイルからProtocol Buffersのコードを生成します。

$ docker compose exec grpc protoc -I=.:../pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1:../pkg/mod/github.com/googleapis/googleapis@v0.0.0-20250610203048-111b73837522:../pkg/mod/github.com/grpc-ecosystem/grpc-gateway/v2@v2.26.3 --go_out=. --go-grpc_out=. --validate_out=lang=go:. --grpc-gateway_out=. ./proto/sample/sample.proto
$ docker compose exec grpc protoc -I=.:../pkg/mod/github.com/envoyproxy/protoc-gen-validate@v1.2.1:../pkg/mod/github.com/googleapis/googleapis@v0.0.0-20250610203048-111b73837522:../pkg/mod/github.com/grpc-ecosystem/grpc-gateway/v2@v2.26.3 --doc_out=./doc --doc_opt=markdown,docs.md --openapiv2_out=allow_merge=true,merge_file_name=./openapi:./doc ./proto/sample/sample.proto

※gRPC-GatewayとOpenAPI用のコードを生成するようにコマンド修正済み

 

次に以下のコマンドを実行し、API用の各種ファイルを作成します。

$ mkdir -p src/internal/servers/gw/sample
$ touch src/internal/servers/gw/sample/sample.go src/internal/servers/gw/sample/sample_test.go
$ mkdir -p src/internal/middleware && touch src/internal/middleware/middleware.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/servers/gw/sample/sample.go」

package sample

import (
    "context"

    repositorySample "go-grpc/internal/repositories/sample"
    serviceSample "go-grpc/internal/services/sample"
    usecaseSample "go-grpc/internal/usecases/sample"
    pb "go-grpc/pb/sample"
)

type sampleApi struct {
    pb.UnimplementedSampleServiceServer
}

func NewSampleApi() *sampleApi {
    return &sampleApi{}
}

// gRPC-Gateway(GETメソッド)
func (s *sampleApi) HelloApi(ctx context.Context, in *pb.Empty) (*pb.HelloResponseBody, error) {
    // インスタンス生成
    sampleRepository := repositorySample.NewSampleRepository()
    sampleService := serviceSample.NewSampleService(sampleRepository)
    sampleUsecase := usecaseSample.NewSampleHelloUsecase(sampleService)

    // ユースケースを実行
    return sampleUsecase.Exec(ctx, in)
}

// gRPC-Gateway(POSTメソッド)
func (s *sampleApi) HelloAddTextApi(ctx context.Context, in *pb.HelloAddTextRequestBody) (*pb.HelloAddTextResponseBody, error) {
    // インスタンス生成
    sampleUsecase := usecaseSample.NewSampleHelloAddTextUsecase()

    // ユースケースの実行
    return sampleUsecase.Exec(ctx, in)
}

※ユースケース層は上記gRPC用に作成したものを使えます。

 

・「src/internal/servers/gw/sample/sample_test.go」

package sample

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "testing"

    pb "go-grpc/pb/sample"

    "github.com/stretchr/testify/assert"
)

// カバレッジの対象から除外
func TestExcludeFromCoverage(t *testing.T) {
    s := NewSampleApi()
    _, _ = s.HelloApi(context.Background(), &pb.Empty{})
    _, _ = s.HelloAddTextApi(context.Background(), &pb.HelloAddTextRequestBody{Text: "Add World !"})
}

func TestSampleHelloApi(t *testing.T) {
    // リクエストURLの設定
    gatewayPort := os.Getenv("GATEWAY_PORT")
    if gatewayPort == "" {
        gatewayPort = "8080"
    }
    path := "/api/v1/hello"
    url := fmt.Sprintf("http://localhost:%s%s", gatewayPort, path)

    // リクエストの設定
    req, _ := http.NewRequest("GET", url, nil)

    // httpクライアントの初期化
    client := new(http.Client)

    // テストの実行
    res, err := client.Do(req)
    if err != nil {
        t.Fatalf("Failed to call client.Do: %v", err)
    }
    defer res.Body.Close()

    // レスポンスボディをデコード
    var resBody pb.HelloResponseBody
    if err := json.NewDecoder(res.Body).Decode(&resBody); err != nil {
        t.Fatalf("Failed to decode response body: %v", err)
    }

    // 検証
    assert.Equal(t, 200, res.StatusCode)
    assert.Equal(t, "Testing Hello World !!", resBody.Message)
}

func TestSampleHelloAddTextApi(t *testing.T) {
    // リクエストURLの設定
    gatewayPort := os.Getenv("GATEWAY_PORT")
    if gatewayPort == "" {
        gatewayPort = "8080"
    }
    path := "/api/v1/hello"
    url := fmt.Sprintf("http://localhost:%s%s", gatewayPort, path)

    // リクエストの設定
    reqBodyJsonByte := []byte(`{"text": "Add World !"}`)
    reqBody := bytes.NewBuffer(reqBodyJsonByte)
    req, err := http.NewRequest("POST", url, reqBody)
    if err != nil {
        t.Fatalf("Failed to create request: %v", err)
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer token")

    // httpクライアントの初期化
    client := new(http.Client)

    // テストの実行
    res, err := client.Do(req)
    if err != nil {
        t.Fatalf("Failed to call client.Do: %v", err)
    }
    defer res.Body.Close()

    // レスポンスボディをデコード
    var resBody pb.HelloAddTextResponseBody
    if err := json.NewDecoder(res.Body).Decode(&resBody); err != nil {
        t.Fatalf("Failed to decode response body: %v", err)
    }

    // 検証
    assert.Equal(t, 200, res.StatusCode)
    assert.Equal(t, "Hello Add World !", resBody.Message)
}

 

・「src/internal/middleware/middleware.go」

package middleware

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "strings"

    utilCtx "go-grpc/internal/util/context"
    "go-grpc/internal/util/logger"

    "github.com/google/uuid"
)

// エラーレスポンス用の構造体
type errorResponse struct {
    Message string `json:"message"`
}

// リクエスト用のミドルウェア(gRPC-Gateway用)
func RequestMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ctxにx-request-idを設定
        requestId := uuid.New().String()
        ctx := r.Context()
        ctx = context.WithValue(ctx, utilCtx.XRequestId, requestId)

        // レスポンスヘッダーにx-request-idを設定
        w.Header().Set(string(utilCtx.XRequestId), requestId)

        // リクエストヘッダーからx-request-sourceを取得
        requestSource := r.Header.Get(string(utilCtx.XRequestSource))
        if requestSource == "" {
            requestSource = "-"
        }

        // ctxにx-request-sourceを設定
        ctx = context.WithValue(ctx, utilCtx.XRequestSource, requestSource)

        // リクエスト開始のログ出力
        logger.Info(ctx, "start gRPC-Gateway request")

        // コンテキストを更新
        r = r.WithContext(ctx)

        // 処理を実行
        next.ServeHTTP(w, r)

        // リクエスト終了のログ出力
        logger.Info(ctx, "finish gRPC-Gateway request")
    })
}

// 認証用ミドルウェア(gRPC-Gateway用)
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 対象外のメソッドとエンドポイントを設定
        skipMethodAndEndpoints := []map[string]string{
            {
                "method": http.MethodGet,
                "endpoint": "/api/v1/hello",
            },
        }

        // 特定のメソッドかつエンドポイントの場合にスキップ
        for _, target := range skipMethodAndEndpoints {
            if r.Method == target["method"] && r.URL.Path == target["endpoint"] {
                next.ServeHTTP(w, r)
                return
            }
        }

        ctx := r.Context()

        // リクエストヘッダーからAuthorizationを取得
        authorization := r.Header.Get("Authorization")
        if authorization == "" {
            // レスポンスをJSON形式で構築
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnauthorized)

            // エラーメッセージをJSONでエンコード
            errRes := errorResponse{Message: "認証用トークンが設定されていません。"}
            if err := json.NewEncoder(w).Encode(errRes); err != nil {
                // ctxにstatusとstatusCodeを設定
                ctx = context.WithValue(ctx, utilCtx.Status, "Internal Server Error")
                ctx = context.WithValue(ctx, utilCtx.StatusCode, "500")

                logger.Error(ctx, fmt.Sprintf("Internal Server Error: %v", err))
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }

            // ctxにstatusとstatusCodeを設定
            ctx = context.WithValue(ctx, utilCtx.Status, "Unauthorized")
            ctx = context.WithValue(ctx, utilCtx.StatusCode, "401")

            logger.Warn(ctx, "認証用トークンが設定されていません。")

            return
        }

        // トークンを取得
        token := strings.TrimPrefix(authorization, "Bearer ")
        if token == "" {
            // レスポンスをJSON形式で構築
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnauthorized)

            // エラーメッセージをJSONでエンコード
            errRes := errorResponse{Message: "認証用トークンが設定されていません。"}
            if err := json.NewEncoder(w).Encode(errRes); err != nil {
                // ctxにstatusとstatusCodeを設定
                ctx = context.WithValue(ctx, utilCtx.Status, "Internal Server Error")
                ctx = context.WithValue(ctx, utilCtx.StatusCode, "500")

                logger.Error(ctx, fmt.Sprintf("Internal Server Error: %v", err))
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }

            // ctxにstatusとstatusCodeを設定
            ctx = context.WithValue(ctx, utilCtx.Status, "Unauthorized")
            ctx = context.WithValue(ctx, utilCtx.StatusCode, "401")

            logger.Warn(ctx, "認証用トークンが設定されていません。")

            return
        }

        // TODO: 認証チェック処理を追加

        // 処理を実行
        next.ServeHTTP(w, r)
    })
}

 

次にファイル「src/main.go」を以下のように修正します。

package main

import (
    "context"
    "fmt"
    "log/slog"
    "net"
    "net/http"
    "os"

    "go-grpc/internal/interceptor"
    mw "go-grpc/internal/middleware"
    sGrpcSample "go-grpc/internal/servers/grpc/sample"
    sGwSample "go-grpc/internal/servers/gw/sample"
    pbSample "go-grpc/pb/sample"

    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "github.com/joho/godotenv"
    "github.com/rs/cors"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/reflection"
)

// gRPC-Gatewayのサーバー起動用の関数
func grpcGateway(grpcPort, gatewayPort string) error {
    // gRPC-Serverへのエンドポイント設定
    ctx := context.Background()
    mux := runtime.NewServeMux()
    grpcServer := fmt.Sprintf("localhost:%s", grpcPort)
    opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
    if err := pbSample.RegisterSampleServiceHandlerFromEndpoint(ctx, mux, grpcServer, opts); err != nil {
        return err
    }

    // gRPC-Gatewayのエンドポイント設定
    if err := pbSample.RegisterSampleServiceHandlerServer(ctx, mux, sGwSample.NewSampleApi()); err != nil {
        return err
    }

    // CORSの設定
    corsHandler := cors.New(cors.Options{
        AllowedOrigins: []string{"http://localhost:3000"},
        AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders: []string{"Authorization", "Content-Type"},
        AllowCredentials: true,
        MaxAge: 300,
    })

    // ミドルウェアの設定(muxをラップ)
    handler := mw.RequestMiddleware(mw.AuthMiddleware(corsHandler.Handler(mux)))

    // HTTPサーバーの起動
    listener := fmt.Sprintf(":%s", gatewayPort)
    return http.ListenAndServe(listener, handler)
}

func main() {
    // .env ファイルの読み込み
    err := godotenv.Load()
    if err != nil {
        slog.Error(fmt.Sprintf(".envファイルの読み込みに失敗しました。: %v", err))
    }

    // ENVの設定
    env := os.Getenv("ENV")
    if env == "" {
        env = "local"
    }

    // gRPC用のポート番号の設定
    grpcPort := os.Getenv("GRPC_PORT")
    if grpcPort == "" {
        grpcPort = "50051"
    }

    // gRPC-Gateway用のポート番号の設定
    gatewayPort := os.Getenv("GATEWAY_PORT")
    if gatewayPort == "" {
        gatewayPort = "8080"
    }

    // Listenerの設定
    listener, err := net.Listen("tcp", fmt.Sprintf(":%s", grpcPort))
    if err != nil {
        slog.Error(fmt.Sprintf("Listenerの設定に失敗しました。: %v", err))
    }

    // gRPCサーバーの作成
    s := grpc.NewServer(
        // インターセプターの適用
        grpc.ChainUnaryInterceptor(
            interceptor.RequestUnaryInterceptor,
            interceptor.AuthUnaryInterceptor,
        ),
        grpc.ChainStreamInterceptor(
            interceptor.RequestStreamInterceptor,
            interceptor.AuthStreamInterceptor,
        ),
    )

    // サービス設定
    pbSample.RegisterSampleServiceServer(s, sGrpcSample.NewSample())

    // リフレクション設定
    reflection.Register(s)

    // gRPCサーバーの起動(非同期)
    slog.Info(fmt.Sprintf("[ENV=%s] start gRPC-Server port: %s", env, grpcPort))
    go func() {
        if err := s.Serve(listener); err != nil {
            slog.Error(fmt.Sprintf("gRPC-Server の起動に失敗しました。: %v", err))
        }
    }()

    // gRPC-Gatewayの起動
    slog.Info(fmt.Sprintf("[ENV=%s] start gRPC-Gateway port: %s", env, gatewayPort))
    if err := grpcGateway(grpcPort, gatewayPort); err != nil {
        slog.Error(fmt.Sprintf("gRPC-Gatewayの起動に失敗しました。: %v", err))
    }
}

 

次に以下のコマンドを実行し、go.modファイルを更新します。

$ docker compose exec grpc go mod tidy

 

次に作成したREST APIをPostmanを使って試します。

まずはGETメソッドで「http://localhost:8080/api/v1/hello」を実行し、想定通りのレスポンス結果を返せばOKです。

 

次にPOSTメソッドで「http://localhost:8080/api/v1/hello」を実行し、想定通りのレスポンス結果を返せばOKです。

※ミドルウェアで認証の設定もしているため、Bearerトークンを付けて実行して下さい。

 

OpenAPIの仕様書を確認する

上記の.protoファイルからProtocol Buffersのコードを生成する際のコマンドでOpenAPIのファイル「src/doc/openapi.swagger.json」も作成しました。

 

VSCodeなら拡張機能の「OpenAPI (Swagger) Editor」で下図のようにプレビュー可能です。

 

スポンサーリンク

本番環境用のDockerコンテナを作る

本番環境にデプロイする際は、デプロイ用のDockerコンテナを作る必要があるため、それについても試します。

まず上記でコンテナを起動中なら以下のコマンドを実行して停止させます。

$ docker compose down

 

次に以下のコマンドを実行し、各種ファイルを作成します。

$ touch src/.env.production
$ mkdir -p docker/prod && touch docker/prod/Dockerfile

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/.env.production」

ENV=production
GRPC_PORT=50051
GATEWAY_PORT=8080

※本番環境用の機密情報を含まない環境変数の設定用として「.env.production」を使いますが、実際の本番環境における機密情報を含む環境変数についてはインフラの方のサービスなどで設定するようにして下さい。

 

・「docker/prod/Dockerfile」

####################
# ビルドステージ
####################
FROM golang:1.24-alpine3.21 AS builder

WORKDIR /go/src

COPY ./src .

# 依存関係をインストール
RUN go install

# ビルド
RUN go build -o main .

####################
# 実行ステージ
####################
FROM alpine:3.21 AS runner

WORKDIR /go/src

# コンテナ用ユーザー作成
RUN addgroup --system --gid 1001 appuser && \
    adduser --system --uid 1001 appuser

# ビルドステージで作成したバイナリをコピー
COPY --from=builder --chown=appuser:appuser ./go/src/main .
COPY --from=builder --chown=appuser:appuser ./go/src/.env.production ./.env

# ポートを設定
EXPOSE 8080

# コンテナ起動ユーザー設定
USER appuser

# APIサーバー起動コマンド
CMD ["./main"]

 

次に以下のコマンドを実行し、コンテナをビルドします。

$ docker build --no-cache -f ./docker/prod/Dockerfile -t go-grpc:latest . 

 

次に以下のコマンドを実行し、ビルドしたコンテナを起動します。

$ docker run -d -p 80:8080 -p 50051:50051 go-grpc:latest

 

コンテナ起動後、Docker Desktopなどでコンテナが起動されているのを確認できればOKです。

 

次に上記で作成した各種APIを実行して試し、想定通りに動作すればOKです。

 

データベース関連について

データベース関連については、REST API用の以下の記事と同様の方法で実装できるため、この記事では割愛させていただきます。

Go言語(Golang)のEchoでシンプルかつ実務的なバックエンドAPI開発方法まとめ
こんにちは。Tomoyuki(@tomoyuki65)です。以前にGo言語のEchoでバックエンドAPIを開発する方法についての記事を書きましたが、あれから私自身もさらに成長し、もっとシンプルかつ実務的にAPIを開発する方法をまとめたいと思...

 

スポンサーリンク

Bufを使ってProtocol Buffersのコードを生成する方法

上記では一般的なprotocコマンドを使ってProtocol Buffersのコードを生成しましたが、より複雑なプロダクトになるとパスの指定などが大変になってきます。

そこでよりモダンな方法として、Bufを使った方法があるため、それについてご紹介します。

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir go-grpc && cd go-grpc
$ mkdir -p docker/local/go && touch docker/local/go/Dockerfile
$ mkdir src && touch src/main.go src/.env
$ mkdir -p src/proto/sample && touch src/proto/sample/sample.proto
$ touch compose.yml

 

次に作成したファイルをそれぞれ以下のように記述します。

・「docker/local/go/Dockerfile」

FROM golang:1.24-alpine3.21

WORKDIR /go/src

COPY ./src .

# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
  go install; \
fi

# Buf CLIをインストール
RUN go install github.com/bufbuild/buf/cmd/buf@latest

# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@latest
RUN go install go.uber.org/mock/mockgen@latest
RUN go install honnef.co/go/tools/cmd/staticcheck@latest

EXPOSE 8080

 

・「src/main.go」

package main

func main() {
// TODO: gRPCサーバー起動処理
}

 

・「src/.env」

ENV=local
GRPC_PORT=50051
GATEWAY_PORT=8080

 

・「src/proto/sample/sample.proto」

syntax = "proto3";

import "validate/validate.proto";
import "google/api/annotations.proto";
import "protoc-gen-openapiv2/options/annotations.proto";

package sample;

option go_package="pb/sample";

option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
  swagger: "2.0"
  info: {
    title: "gRPC-Gateway"
    description: "gRPC-GatewayのOpenAPI仕様書"
    version: "1.0"
  }
  host: "localhost:8080"
  schemes: HTTP
  security_definitions: {
    security: {
      key: "BearerAuth"
      value: {
        type: TYPE_API_KEY
        in: IN_HEADER
        name: "Authorization"
        description: "Enter the token with the `Bearer ` prefix, e.g., `Bearer abcde12345`"
      }
    }
  }
};

// 空のリクエストパラメータ
message Empty {}

// Helloメソッドのレスポンス結果
message HelloResponseBody {
    // メッセージ
    string message = 1;
}

// HelloAddTextメソッドのリクエストパラメータ
message HelloAddTextRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloAddTextメソッドのレスポンス結果
message HelloAddTextResponseBody {
    // メッセージ
    string message = 1;
}

// HelloServerStreamメソッドのリクエストパラメータ
message HelloServerStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloServerStreamメソッドのレスポンス結果
message HelloServerStreamResponseBody {
    // メッセージ
    string message = 1;
}

// HelloClientStreamメソッドのリクエストパラメータ
message HelloClientStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloClientStreamメソッドのレスポンス結果
message HelloClientStreamResponseBody {
    // メッセージ
    string message = 1;
}

// HelloBidirectionalStreamメソッドのリクエストパラメータ
message HelloBidirectionalStreamRequestBody {
    // テキスト
    string text = 1 [(validate.rules).string.min_len = 1];
}

// HelloBidirectionalStreamメソッドのレスポンス結果
message HelloBidirectionalStreamResponseBody {
    // メッセージ
    string message = 1;
}

// エラーレスポンス
message ErrResponse {
  // メッセージ
  string message = 1;
}

// サンプルサービス
service SampleService {
  // 「Hello World !!」を出力
  rpc Hello(Empty) returns (HelloResponseBody) {}
  // Returns:
  // - 0 OK: HelloResponseBodyを出力
  // - 2 Unknown: 不明なエラー

  // 「Hello {リクエストパラメータのtext}」を出力
  rpc HelloAddText(HelloAddTextRequestBody) returns (HelloAddTextResponseBody) {}
  // Returns: 
  // - 0 OK: HelloAddTextResponseBodyを出力
  // - 2 Unknown: 不明なエラー 
  // - 3 INVALID_ARGUMENT: バリデーションエラー

  // サーバーストリーミング(1リクエスト-複数のレスポンス)
  rpc HelloServerStream(HelloServerStreamRequestBody) returns (stream HelloServerStreamResponseBody) {}
  // Returns:
  // - 0 OK: HelloServerStreamResponseBodyを出力(複数回)
  // - 2 Unknown: 不明なエラー
  // - 3 INVALID_ARGUMENT: バリデーションエラー

  // クライアントストリーミング(複数のリクエスト-1レスポンス)
  rpc HelloClientStream(stream HelloClientStreamRequestBody) returns (HelloClientStreamResponseBody) {}
  // Returns:
  // - 0 OK: HelloClientStreamResponseBodyを出力
  // - 2 Unknown: 不明なエラー
  // - 3 INVALID_ARGUMENT: バリデーションエラー)

  // 双方向ストリーミング(複数のリクエスト-複数のレスポンス)
  rpc HelloBidirectionalStream(stream HelloBidirectionalStreamRequestBody) returns (stream HelloBidirectionalStreamResponseBody) {}
  // Returns:
  // - 0 OK: HelloClientStreamResponseBodyを出力
  // - 2 Unknown: 不明なエラー
  // - 3 INVALID_ARGUMENT: バリデーションエラー)

  // gRPC-Gateway(GETメソッド)
  rpc HelloApi(Empty) returns (HelloResponseBody){
    option (google.api.http) = {
      get: "/api/v1/hello"
    };
    option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
      responses: {
        key: "500"
        value: {
          description: "Internal Server Error"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Internal Server Error\"}"
          }
        }
      }
    };
  }

  // gRPC-Gateway(POSTメソッド)
  rpc HelloAddTextApi(HelloAddTextRequestBody) returns (HelloAddTextResponseBody){
    option (google.api.http) = {
      post: "/api/v1/hello"
      body: "*"
    };
    option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
      responses: {
        key: "400"
        value: {
          description: "Bad Request"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Bad Request\"}"
          }
        }
      }
      responses: {
        key: "401"
        value: {
          description: "Unauthorized"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Unauthorized\"}"
          }
        }
      }
      responses: {
        key: "500"
        value: {
          description: "Internal Server Error"
          schema: {
            json_schema: {
              ref: ".sample.ErrResponse"
            }
          }
          examples: {
            key: "application/json"
            value: "{\"message\": \"Internal Server Error\"}"
          }
        }
      }
      security: {
        security_requirement: {
          key: "BearerAuth"
          value: {}
        }
      }
    };
  }
}

※「import “validate/validate.proto”;」を「import “buf/validate/validate.proto”;」に変更して修正

 

・「compose.yml」

services:
  grpc:
    container_name: go-grpc
    build:
      context: .
      dockerfile: ./docker/local/go/Dockerfile
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    ports:
      # gRPC Server用のポート設定
      - "50051:50051"
      # gRPC Gateway用のポート設定
      - "8080:8080"
    environment:
      - ENV
      - GRPC_PORT
      - GATEWAY_PORT
    tty: true
    stdin_open: true

 

次に以下のコマンドを実行し、Dockerコンテナをビルドおよび各初期化処理を行います。

$ docker compose build --no-cache
$ docker compose run --rm grpc go mod init go-grpc
$ docker compose run --rm grpc air init
$ docker compose run --rm grpc buf config init

 

次に作成されたファイル「src/buf.yaml」を以下のように修正します。

# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
version: v2
# .protoファイルのパスを指定
modules:
  - path: proto
# 依存関係のライブラリを指定
deps:
  - buf.build/envoyproxy/protoc-gen-validate:v1.2.1
  - buf.build/googleapis/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway:v2.26.3
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE

 

次に以下のコマンドを実行し、依存関係のライブラリをインストールします。

$ docker compose run --rm grpc buf dep update

※コマンド実行後、buf.lockが作成されます。

 

次に以下のコマンドを実行し、ファイル「src/buf.gen.yaml」を作成します。

$ touch src/buf.gen.yaml

 

次に作成したファイルを以下のように記述します。

version: v2
plugins:
  # 1. --go_out=.
  - remote: buf.build/protocolbuffers/go:v1.36.6
    out: ./pb
    opt: paths=source_relative

  # 2. --go-grpc_out=.
  - remote: buf.build/grpc/go:v1.5.1
    out: ./pb
    opt: paths=source_relative

  # 3. --validate_out=lang=go:.
  - remote: buf.build/bufbuild/validate-go:v1.2.1
    out: ./pb
    opt: paths=source_relative

  # 4. --doc_out=.
  - remote: buf.build/community/pseudomuto-doc:v1.5.1
    out: ./doc
    opt: markdown,docs.md

  # 5. --grpc-gateway_out=.
  - remote: buf.build/grpc-ecosystem/gateway:v2.26.3
    out: ./pb
    opt: paths=source_relative

  # 6. --openapiv2_out=.
  - remote: buf.build/grpc-ecosystem/openapiv2:v2.26.3
    out: ./doc
    opt: allow_merge=true,merge_file_name=./openapi

 

次に以下のコマンドを実行し、Protocol Buffersのコードおよびドキュメントファイルを作成します。

$ docker compose run --rm grpc buf generate

 

コマンド実行後、以下のように上記のprotocコマンド使った場合と同様のファイルが作成されればOKです。

 

スポンサーリンク

最後に

今回はGo言語のgRPCでバックエンドAPIを開発する方法について解説しました。

当初はgRPCを覚えたらもうREST APIは不要なのかと思っていましたが、実際はそうでもなくREST APIも大事なのがよくわかりました。gRPCを使う際はその点も注意しながら利用して下さい。

そして、今回GoのgRPCでAPIを作るための基本的な方法はまとめられたので、これから試したい方はぜひ参考にしてみて下さい!

 

この記事を書いた人
Tomoyuki

SE→ブロガーを経て、現在はWeb系エンジニアをしています!

Tomoyukiをフォローする
応用
スポンサーリンク
Tomoyukiをフォローする

コメント

タイトルとURLをコピーしました