Connectを使ってgPRCとHTTP/1.1のリクエストを受け取れるサーバーを作ってみよう

この記事は[Go Advent Calendar 2024](https://qiita.com/advent-calendar/2024/go)の16日目の記事です！

https://qiita.com/advent-calendar/2024/go

こんにちは！株式会社Schoo 新卒1年目の [@hiroto_0411](https://x.com/hiroto_0411)です！
研修が終わり、初めて弊社のgRPC実装を見た際に「これはREST APIを実装してる？」と疑問に思いました。そこで調べてみると[Connect](https://connectrpc.com/docs/introduction)を使ってgRPCとREST APIの両方を実装していると分かりました。そこで、今回はConnectって何？をまとめてみました！

## この記事でわかること
- Connectってなに？
- Connectを使ってAPIサーバーを作る流れ

gRPCについて理解が曖昧な方は以下の記事にgRPCについてまとめたのでこちらを先に見ていただくと、この記事が理解しやすくなると思います。

https://qiita.com/hiroto_0411/items/e87b626878d288460d62


## Connectってなに？

https://connectrpc.com/docs/introduction

ConnectはgRPCのプロトコルをベースに、gRPCとREST APIを構築できるライブラリです。

Connectには以下のような特徴があります

- Connect独自のプロトコル（HTTP/1.1、HTTP/2、HTTP/3対応）に加え、gRPCとgRPC-Webの3つのプロトコルをサポートしています。このため、**grpc-gatewayなどの追加ツールを使用せずに、1つのサーバーでgRPCとHTTP/1.1ベースのAPI（REST APIなど）の両方の形式のリクエストを受ける事ができます**

記事執筆時点 (2024/12)ではGo、TypeScript、JavaScript、Swift、Kotlinに対応しています。

Connectを使うことで、簡単にgRPCとREST APIの実装を同時にできるので、実際に実装してみましょう！


## Connectを使ってサーバーを実装してみる

https://connectrpc.com/docs/go/getting-started

上記のチュートリアルを参考にしながらConnectを使ってサーバーを実装してみました。

作成したコードは以下で見られます。

https://github.com/hiroto1220/go-playground/tree/main/connect

ディレクトリ構成はこんな感じになる予定です。

```
.
├── gen
│   ├── greetconnect
│   │   └── helloworld.connect.go
│   └── helloworld.pb.go
├── handler
│   └── helloworld.go
├── proto
│   └── helloworld.proto
├── buf.gen.yaml
├── buf.yaml
├── go.mod
├── go.sum
└── main.go
```


### 1. 準備
**モジュールの作成**
```sh
go mod init github.com/user-name/project-name   
```

**必要なツールのインストール**
```sh
go install github.com/bufbuild/buf/cmd/buf@latest
go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
```
**PATHを更新 (pathが通っていない場合のみ)**
```sh
export PATH="$PATH:$(go env GOPATH)/bin"
```

### 2 .protoファイルの作成
Protocol Buffersというフォーマットを使用してgRPCのサービス名やメソッド名、リクエストやレスポンスを定義します。
```proto: ./proto/helloworld.proto
syntax = "proto3";

option go_package = "github.com/user-name/project-name/gen;greet";

package hello;

service Greeter {
    rpc SayHello(HelloRequest) returns (HelloResponse){ }
}

message HelloRequest {
    string name = 1;
}

message HelloResponse {
    string message = 1;
}
```
- この後、.protoファイルを元にコードを自動生成します。その時に出力したいpackage名をgo_packageで指定する事ができます。今回はgen。
- `;greet`をつけることで、`greetconnect`というpackage名で`helloworld.connect.go`が作成される。`;greet`を指定しないと、`go_package`で指定しているpackage名+connect(今回の場合は`genconnect`)というpackage名で作成されます。

```
├── gen
│   ├── greetconnect
│   │   └── helloworld.connect.go
│   └── helloworld.pb.go
```

### 3.コードの自動生成
**buf.yamlをルートディレクトリに作成**
```sh
buf config init
```

**buf.gen.yamlをルートディレクトリに作成**
```yaml: ./buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-go
    out: gen
    opt:
      - module=github.com/user-name/project-name/gen
  - local: protoc-gen-connect-go
    out: gen
    opt: 
      - module=github.com/user-name/project-name/gen
```


**コードを自動生成**
以下のコマンドを実行することで、.protoファイルとbuf.gen.yamlを元に2種類のファイルが自動生成されます。
```sh
buf generate
```
```
├── gen
│   ├── greetconnect
│   │   └── helloworld.connect.go
│   └── helloworld.pb.go
```


**モジュールの依存関係を整理**
```sh
go mod tidy
```

### 4. 自動生成されたinterfaceを使い、サーバーを実装する
自動生成されたinterface
```go:./gen/greetconnect/helloworld.connect.go
type GreeterHandler interface {
	SayHello(context.Context, *connect.Request[gen.HelloRequest]) (*connect.Response[gen.HelloResponse], error)
}

func NewGreeterHandler(svc GreeterHandler, opts ...connect.HandlerOption) (string, http.Handler) {
	greeterSayHelloHandler := connect.NewUnaryHandler(
		GreeterSayHelloProcedure,
		svc.SayHello,
		connect.WithSchema(greeterSayHelloMethodDescriptor),
		connect.WithHandlerOptions(opts...),
	)
	return "/hello.Greeter/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case GreeterSayHelloProcedure:
			greeterSayHelloHandler.ServeHTTP(w, r)
		default:
			http.NotFound(w, r)
		}
	})
}

type UnimplementedGreeterHandler struct{}

func (UnimplementedGreeterHandler) SayHello(context.Context, *connect.Request[gen.HelloRequest]) (*connect.Response[gen.HelloResponse], error) {
	return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hello.Greeter.SayHello is not implemented"))
}
```


[./gen/greetconnect/helloworld.connect.go](https://github.com/hiroto1220/go-playground/blob/main/connect/gen/greetconnect/helloworld.connect.go)に定義されているinterfaceを実装し、main.goから使います。
```go:./handler/helloworld.go
package handler

import (
	"context"
	"fmt"
	"log"

	"connectrpc.com/connect"
	greet "github.com/user-name/project-name/gen"
)

type GreetHandler struct{}

func NewGreetHandler() *GreetHandler {
	return &GreetHandler{}
}

func (s *GreetHandler) SayHello(ctx context.Context, req *connect.Request[greet.HelloRequest]) (*connect.Response[greet.HelloResponse], error) {
	log.Println("Request headers: ", req.Header())
	// 本来ならここから、別の層のメソッドを呼び出すなどする

	res := connect.NewResponse(&greet.HelloResponse{
		Message: fmt.Sprintf("Hello, %s!", req.Msg.Name),
	})
	res.Header().Set("Greet-Version", "v1")
	return res, nil
}
```

```go:./main.go
package main

import (
	"net/http"

	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"

	"github.com/user-name/project-name/gen/greetconnect"
	"github.com/user-name/project-name/handler"
)

func main() {
	greeter := handler.NewGreetHandler()
	path, handler := greetconnect.NewGreeterHandler(greeter)

	mux := http.NewServeMux()
	mux.Handle(path, handler)

	http.ListenAndServe(
		"localhost:8080",
		// Use h2c so we can serve HTTP/2 without TLS.
		h2c.NewHandler(mux, &http2.Server{}),
	)
}
```

Connect使って書くことで、net/httpを使ってサーバーを作成することができREST APIを実装する時と似たような感じのコードで書く事ができます。Connectを使わない場合はgrpc-goを使ったgRPC特有の書き方になります。

<details><summary>Connectを使わないでgRPCを実装する場合のmain.go</summary>

```go
package main

import (
	"flag"
	"fmt"
	"log"
	"net"

	pb "github.com/user-name/project-name/gen"
	"github.com/user-name/project-name/server"

	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

var (
	port = flag.Int("port", 50051, "The server port")
)

func main() {
	flag.Parse()
	lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server.GreeterServer{})
	log.Printf("server listening at %v", lis.Addr())

	reflection.Register(s)

	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}
```

</details>


### 5.サーバーにリクエストを送ってみる
**サーバーの起動**
```sh
go run main.go
```

**REST APIとgRPCそれぞれの方法でリクエストを送ってみる**
curl
```sh
curl \
    --header "Content-Type: application/json" \
    --data '{"name": "パブりん"}' \
    http://localhost:8080/hello.Greeter/SayHello
```

```json
{
  "message": "Hello, パブりん!"
}
```

grpcurl
```sh
grpcurl -protoset <(buf build -o -) -plaintext -d '{"name": "パブりん"}' localhost:8080 hello.Greeter/SayHello
```
```json
{
  "message": "Hello, パブりん!"
}
```

## 参考

https://connectrpc.com/docs/introduction

https://connectrpc.com/docs/go/getting-started


---
**Schooでは一緒に働く仲間を募集しています！**

https://corp.schoo.jp/careers/graduates

https://corp.schoo.jp/careers