虎の穴ラボ技術ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

connect-goで生成しながら始めるAPI開発入門

この記事は虎の穴ラボ Advent Calendar 2024の14日目の記事です。

こんにちは、虎の穴ラボFantiaエンジニアのNSYです。
今回はconnect-goによるAPIの開発に入門してみました。

connectとは、gRPC(Google Remote Procedure Call)の構築をシンプルに行うことができるGo言語のライブラリです。 これを利用することでサーバーとクライアントを効率的に開発することができます。この記事では加えてGo言語のコード生成ができるライブラリを組み合わせ、TodoのCRUDを行うAPIを構築してみました。 connectrpc.com

目次

完成後の構成

.
├── backend
│   ├── Dockerfile.dev
│   ├── buf.gen.yaml
│   ├── buf.yaml
│   ├── cmd
│   │   └── server
│   │       └── main.go
│   ├── db
│   │   ├── migrations
│   │   │   ├── 000001_create_todos_table.down.sql
│   │   │   └── 000001_create_todos_table.up.sql
│   │   └── models
│   │       ├── boil_queries.go
│   │       ├── boil_table_names.go
│   │       ├── boil_types.go
│   │       ├── boil_view_names.go
│   │       ├── psql_upsert.go
│   │       └── todos.go
│   ├── gen
│   │   └── todo
│   │       └── v1
│   │           ├── todo.pb.go
│   │           └── todov1connect
│   │               └── todo.connect.go
│   ├── go.mod
│   ├── go.sum
│   ├── proto
│   │   └── todo
│   │       └── v1
│   │           └── todo.proto
│   ├── service
│   │   └── todo
│   │       └── v1
│   │           └── todo.go
│   └── sqlboiler.toml
└── docker-compose.yml

APIの構築

Dockerで環境構築

まずはDockerを用いて環境の構築をします。設定は以下の通りで、GoとDB用のコンテナを定義しています。 また、Dockerfileでは必要なライブラリのインストールを行っています。
ここから先の作業はGoコンテナ中で進めていきますのでコンテナをビルドして起動します。

# docker-compose.yml
services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    volumes:
      - ./backend:/app
    tty: true
    environment:
      - DB_USER=user
      - DB_PASSWORD=postgres
      - DB_HOST=db
      - DB_PORT=5432
      - DB_NAME=connect
    ports:
      - "8080:8080"
  db:
    image: postgres:13.18
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: connect
    volumes:
      - db-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  db-data:
# Dockerfile.dev
FROM golang:1.23

RUN go install github.com/bufbuild/buf/cmd/buf@latest
RUN go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
RUN go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest

RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1
RUN go install github.com/volatiletech/sqlboiler/v4@latest
RUN go install github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-psql@latest

Goの初期化

まずは、go mod init backendでGoのプロジェクトの初期化を行います。ここではコンテナ名とそろえてbackendにしたいと思います。

DBマイグレーションの生成

PostgreSQLを使用して、APIで利用するテーブルを作成します。 マイグレーションにはgolang-migrateを使用します。

データ保存をするTodosテーブルの定義ファイルを、migrate create -ext sql -dir db/migrations -seq create_todos_tableで生成します。 backend/db/migrations以下に生成されたファイルを元に、マイグレーションを行うSQLを作成します。

最後にmigrate -database postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable -path db/migrations upでマイグレーションを実行します。

-- db/migrations/000001_create_todos_table.up.sql
CREATE TABLE IF NOT EXISTS todos (
  id SERIAL PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  description TEXT,
  completed BOOLEAN NOT NULL DEFAULT FALSE
);
-- db/migrations/000001_create_todos_table.down.sql
DROP TABLE IF EXISTS todos;

github.com

ORMによるコード生成

GoのORM/クエリビルダにはgormやsqlx、goquなどがあります。
この記事ではDB上に定義されたテーブルを元にして、ORMを生成することができるsqlboilerを利用したいと思います。

DBへの接続情報をsqlboiler.tomlに設定します。その後sqlboiler psqlを実行します。backend/db/models以下にファイルが生成されているはずです。

# sqlboiler.toml
pkgname="models"
output="db/models"
wipe=true
add-global-variants=false
no-tests=true

[psql]
    dbname = "connect"
    host   = "db"
    port   = 5432
    user   = "user"
    pass   = "postgres"
    sslmode = "disable"
    schema = "public"
    blacklist = ["schema_migrations"]

github.com

could not find sqlboiler version in go.modのエラーが表示される場合は、go get -u github.com/volatiletech/sqlboiler/v4を実行後に試してみてください。

protoの定義

次はprotoファイルにAPIの関数や引数・戻り値の定義を行います。 この記事ではCRUDを行うため、以下の関数を定義しています。

  1. CreateTodo: Todoの作成.
  2. ListTodo: Todoの一覧取得.
  3. UpdateTodo: Todoの更新.
  4. DeleteTodo: Todoの削除.
// todo.proto
syntax = "proto3";

package proto.todo.v1;

option go_package = "backend/gen/todo/v1;todov1";

message Todo {
  int32 id = 1;
  string title = 2;
  string description = 3;
  bool completed = 4;
}
message TodoList {
  repeated Todo todos = 1;
}

message CreateTodoRequest {
  string title = 1;
  string description = 2;
}
message CreateTodoResponse {
  bool success = 1;
}

message ListTodoRequest {}
message ListTodoResponse {
  repeated Todo todos = 1;
}

message UpdateTodoRequest {
  int32 id = 1;
  string title = 2;
  string description = 3;
  bool completed = 4;
}
message UpdateTodoResponse {
  bool success = 1;
}

message DeleteTodoRequest {
  int32 id = 1;
}
message DeleteTodoResponse {
  bool success = 1;
}

service TodoService {
  rpc CreateTodo (CreateTodoRequest) returns (CreateTodoResponse);
  rpc ListTodo (ListTodoRequest) returns (ListTodoResponse);
  rpc UpdateTodo (UpdateTodoRequest) returns (UpdateTodoResponse);
  rpc DeleteTodo (DeleteTodoRequest) returns (DeleteTodoResponse);
}

connectによるコード生成

それではprotoの定義を元に、connectによるgRPCの生成を行っていきます。

はじめにbuf config initで初期化を行います。これによりbuf.yamlが生成されるので、modulesの項目に設定を追加してください。 次にbuf.gen.yamlを作成して、buf generateを実行してください。backend/gen以下にファイルが生成されているはずです。

# buf.yaml
version: v2
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
modules:
  - path: proto
# buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-go
    out: gen
    opt: paths=source_relative
  - local: protoc-gen-connect-go
    out: gen
    opt: paths=source_relative

APIの実装

ここまでに作成してきたコードを元に、APIサーバーの実装を行います。

はじめにconnectで生成された、backend/gen/todo/v1/todov1connect/todo.connect.goに定義されているTodoServiceHandlerを確認してください。このインタフェースにはprotoで定義された4つの関数が定義されています。サーバーの開発ではこの生成された関数を実装していくことになります。

まずはNewTodoServiceHandlerインタフェースの実装を行います。

// service/todo/v1/todo.go
package servicev1todo

import (
    "backend/db/models"
    v1 "backend/gen/todo/v1"
    "backend/gen/todo/v1/todov1connect"

    "context"
    "database/sql"
    "log"
    "net/http"

    "connectrpc.com/connect"
    "github.com/volatiletech/null/v8"
    "github.com/volatiletech/sqlboiler/v4/boil"
    "github.com/volatiletech/sqlboiler/v4/queries/qm"
)


type ServiceV1Todo struct {
    exec *sql.DB
}

func NewServiceV1Todo(db *sql.DB) (string, http.Handler) {
    srv := &ServiceV1Todo{exec: db}
    path, handler := todov1connect.NewTodoServiceHandler(srv)
    return path, handler
}

func (srv *ServiceV1Todo)ListTodo(ctx context.Context, req *connect.Request[v1.ListTodoRequest]) (*connect.Response[v1.ListTodoResponse], error){
    todoModels, err := models.Todos(qm.Select()).All(ctx, srv.exec)
    if err != nil {
        log.Println(err)
        return nil, err
    }

    todos := make([]*v1.Todo, len(todoModels))
    for index, todoModel := range todoModels {
        todos[index] = &v1.Todo{
            Id: int32(todoModel.ID),
            Title: todoModel.Title,
            Description: todoModel.Description.String,
            Completed: todoModel.Completed,
        }
    }

    res := connect.NewResponse(&v1.ListTodoResponse{Todos: todos})
    res.Header().Set("Todo-Version", "1")
    return res, nil
}

func (srv *ServiceV1Todo)CreateTodo(ctx context.Context, req *connect.Request[v1.CreateTodoRequest]) (*connect.Response[v1.CreateTodoResponse], error){
    todo := &models.Todo{
        Title: req.Msg.Title,
        Description: null.StringFrom(req.Msg.Description),
        Completed: false,
    }

    if err := todo.Insert(ctx, srv.exec, boil.Infer()); err != nil {
        log.Println(err)
        return nil, err
    }

    res := connect.NewResponse(&v1.CreateTodoResponse{Success: true})
    res.Header().Set("Todo-Version", "1")
    return res, nil
}

func (srv *ServiceV1Todo)UpdateTodo(ctx context.Context, req *connect.Request[v1.UpdateTodoRequest]) (*connect.Response[v1.UpdateTodoResponse], error){
    todo, err := models.Todos(qm.Select(),models.TodoWhere.ID.EQ(int(req.Msg.Id))).One(ctx, srv.exec)
    if err != nil {
        log.Println(err)
        return nil, err
    }

    todo.Title = req.Msg.Title
    todo.Description = null.StringFrom(req.Msg.Description)
    todo.Completed = req.Msg.Completed
    if _,err := todo.Update(ctx, srv.exec, boil.Infer()); err != nil {
        log.Println(err)
        return nil, err
    }

    res := connect.NewResponse(&v1.UpdateTodoResponse{Success: true})
    res.Header().Set("Todo-Version", "1")
    return res, nil
}

func (srv *ServiceV1Todo)DeleteTodo(ctx context.Context, req *connect.Request[v1.DeleteTodoRequest]) (*connect.Response[v1.DeleteTodoResponse], error){
    todo, err := models.Todos(qm.Select(),models.TodoWhere.ID.EQ(int(req.Msg.Id))).One(ctx, srv.exec)
    if err != nil {
        log.Println(err)
        return nil, err
    }

    if _,err := todo.Delete(ctx, srv.exec); err != nil {
        log.Println(err)
        return nil, err
    }

    res := connect.NewResponse(&v1.DeleteTodoResponse{Success: true})
    res.Header().Set("Todo-Version", "1")
    return res, nil
}

次にAPIサーバー本体のコードを実装します。
grpcreflectはGraphQLのイントロスペクションと同様にサーバーに定義されたAPIの仕様を取得するための設定になります。 これを利用することで、開発時にPostmanなどが補完を行ってくれるようになります。

// cmd/server/main.go
package main

import (
    servicev1todo "backend/service/todo/v1"
    "fmt"
    "os"

    "database/sql"
    "log"
    "net/http"

    connctcors "connectrpc.com/cors"
    "connectrpc.com/grpcreflect"
    _ "github.com/lib/pq"
    "github.com/rs/cors"
    "github.com/volatiletech/sqlboiler/v4/boil"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"
)

func withCORS(h http.Handler) http.Handler {
    middleware := cors.New(cors.Options{
        AllowedOrigins:   []string{"*"},
        AllowedMethods:   connctcors.AllowedMethods(),
        AllowedHeaders:   connctcors.AllowedHeaders(),
        ExposedHeaders:   connctcors.ExposedHeaders(),
    })
    return middleware.Handler(h)
}

func main() {
    mux := http.NewServeMux()

    db, err := sql.Open(
        "postgres", 
        fmt.Sprintf(
            "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
            os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    path, handler := servicev1todo.NewServiceV1Todo(db)
    mux.Handle(path, handler)

    reflector := grpcreflect.NewStaticReflector(
        "proto.todo.v1.TodoService",
    )
    mux.Handle(grpcreflect.NewHandlerV1(reflector))
    mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))

    log.Println("Listening on :8080")
    http.ListenAndServe(":8080", withCORS(h2c.NewHandler(mux, &http2.Server{})))
}

Postmanから実行する

はじめにgo run ./cmd/server/main.goでサーバーを起動してください。 次にPostmanなどのクライアントツールを立ち上げてください。ここでは例としてPostmanで説明を行います。

通信方法をgRPCに設定してください。その後にService definitionタブをからUsing server reflection.を実行してください。 これで定義されたAPIを取得できているはずです。

次にSelect a methodから選択ボックスを開き実行したい関数を選択してください。選択後に、Messageタブを開きUse Example Messageを選択してください。 試しに実行してみると、想定どおりのレスポンスが返り無事に実装できていることが確認できます。

所感とまとめ

この記事ではコードを生成してくれるライブラリを活用しながら、connectを使ったAPI開発に入門してみました。 実際に使用してみて雛形やコードを生成してくれるのは効率的だと感じました。

今回は基本的なUnary RPC(単方向リモートプロシージャコール)という通信方式だけを利用しました。この方式は、従来のAPIと同じようにリクエストとレスポンスの一対一の通信を行うものです。そのため、gRPCに慣れていなくても比較的簡単に実装することができました。しかし、gRPCの強みは単一通信だけでなく、双方向通信やストリーミングなど、様々な通信方法を選択できる点にあります。これにより複雑で高度な通信を実現することが可能です。

今後は、その他の通信方式を試したり、connect-webを使ってクライアントと通信してみたいと思います。

Fantia開発採用情報

虎の穴ラボでは現在、一緒にFantiaを開発していく仲間を積極募集中です!
多くのユーザーに使っていただけるtoCサービスの開発をやってみたい方は、ぜひ弊社の採用情報をご覧ください。
toranoana-lab.co.jp