Open20

Connect で todo アプリを作るぞ

島袋恵島袋恵

作業場所を作る

$ mkdir connect-go-todo-example
$ cd connect-go-todo-example
$ go mod init github.com/shimabukuromeg/connect-go-todo-example
島袋恵島袋恵

getstarted と同じように必要なパッケージをインストール

$ 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
島袋恵島袋恵

Protocol Bufferes で todo を定義する

$ mkdir -p proto/todo/v1
$ touch proto/todo/v1/todo.proto
  • todoの追加、todo のステータスの更新、todoの削除をまずは実装する
  • proto/todo/v1/todo.proto
    • option go_package の指定間違えると buf generate した後のコードでパッケージの import のパスが間違っててエラーになるから注意
syntax = "proto3";

package proto.todo.v1;

option go_package = "github.com/shimabukuromeg/connect-go-todo-example/gen/proto/todo/v1;todov1";

enum Status {
  STATUS_UNKNOWN_UNSPECIFIED = 0;
  STATUS_TODO = 1;
  STATUS_DOING = 2;
  STATUS_DONE = 3;
}

message TodoItem {
  uint64 id = 1;
  string name = 2;
  Status status = 3;
}

message TodoItems {
  repeated TodoItem items = 1;
}

message CreateTaskRequest {
  string name = 1;
  Status status = 2;
}

message CreateTaskResponse {
  uint64 id = 1;
  string name = 2;
  Status status = 3;
}

message UpdateTaskStatusRequest {
  uint64 id = 1;
  Status status = 2;
}

message UpdateTaskStatusResponse {
  uint64 id = 1;
  Status status = 2;
}

message DeleteTaskRequest {
  uint64 id = 1;
}

message DeleteTaskResponse {
  uint64 id = 1;
}

service ToDoService {
  rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse) {}
  rpc UpdateTaskStatus(UpdateTaskStatusRequest) returns (UpdateTaskStatusResponse) {}
  rpc DeleteTask(DeleteTaskRequest) returns (DeleteTaskResponse) {}
}
島袋恵島袋恵

buf.gen.yaml を作る

version: v1
plugins:
  - plugin: go
    out: gen
    opt: paths=source_relative
  - plugin: connect-go
    out: gen
    opt: paths=source_relative
    # for scenarigo test
  - plugin: go-grpc
    out: gen
    opt: paths=source_relative
島袋恵島袋恵

gen ディレクトリ配下で以下のファイルが生成される

$ tree gen                                                                                                       (git)-[main]
gen
└── proto
    └── todo
        └── v1
            ├── todo.pb.go
            ├── todo_grpc.pb.go
            └── todov1connect
                └── todo.connect.go
島袋恵島袋恵

cmd/server/main.go を作ってサーバーを動かせるようにする。CreateTask, UpdateTaskStatus, DeleteTask の各 rpc のロジックは未実装のままで関数だけ定義して、後で実装する。

cmd/server/main.go

package main

import (
	"context"
	"log"
	"net/http"
	"sync"

	"connectrpc.com/connect"
	"github.com/shimabukuromeg/connect-go-todo-example/gen/proto/todo/v1/todov1connect"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"

	todov1 "github.com/shimabukuromeg/connect-go-todo-example/gen/proto/todo/v1" // generated by protoc-gen-go
)

type TodoServer struct {
	todos  sync.Map
	nextID int
}

func (s *TodoServer) CreateTask(
	ctx context.Context,
	req *connect.Request[todov1.CreateTaskRequest],
) (*connect.Response[todov1.CreateTaskResponse], error) {
    log.Println("Request headers: ", req.Header())
	// TODO: Implement this method.
	res := connect.NewResponse(&todov1.CreateTaskResponse{})
    res.Header().Set("CreateTask-Version", "v1")
    return res, nil
}

func (s *TodoServer) UpdateTaskStatus(
	ctx context.Context,
	req *connect.Request[todov1.UpdateTaskStatusRequest],
) (*connect.Response[todov1.UpdateTaskStatusResponse], error) {
    log.Println("Request headers: ", req.Header())
	// TODO: Implement this method.
	res := connect.NewResponse(&todov1.UpdateTaskStatusResponse{})
    res.Header().Set("UpdateTaskStatus-Version", "v1")
    return res, nil
}

func (s *TodoServer) DeleteTask(
	ctx context.Context,
	req *connect.Request[todov1.DeleteTaskRequest],
) (*connect.Response[todov1.DeleteTaskResponse], error) {
    log.Println("Request headers: ", req.Header())
	// TODO: Implement this method.
res := connect.NewResponse(&todov1.DeleteTaskResponse{})
    res.Header().Set("DeleteTask-Version", "v1")
    return res, nil
}


func main() {
    todoServer := &TodoServer{}
    mux := http.NewServeMux()
    path, handler := todov1connect.NewToDoServiceHandler(todoServer)
    mux.Handle(path, handler)
    http.ListenAndServe(
        "localhost:8080",
        // Use h2c so we can serve HTTP/2 without TLS.
        h2c.NewHandler(mux, &http2.Server{}),
    )
}
島袋恵島袋恵

サーバーを立ち上げて動作確認してみる。

サーバーを立ち上げる

$ go run ./cmd/server/main.go
島袋恵島袋恵

ちゃんと動いてるか確認するため、gRPC のクライアントツール evans を導入する。REPLが使えて補完も効いていい感じにrpcを叩けるツール

https://github.com/ktr0731/evans

インストール

$ brew tap ktr0731/evans
$ brew install evans
島袋恵島袋恵

プロジェクトの一番上の階層で以下のコマンドを実行する

$ evans --proto proto/todo/v1/todo.proto repl --port 8080

起動した

$ evans --proto proto/todo/v1/todo.proto repl --port 8080

  ______
 |  ____|
 | |__    __   __   __ _   _ __    ___
 |  __|   \ \ / /  / _. | | '_ \  / __|
 | |____   \ V /  | (_| | | | | | \__ \
 |______|   \_/    \__,_| |_| |_| |___/

 more expressive universal gRPC client


proto.todo.v1.ToDoService@127.0.0.1:8080>

CreateTaskのrpcをcallする。まだロジックは未実装なので値ちゃんと返ってきてないけど、疎通できてることは確認できた。

proto.todo.v1.ToDoService@127.0.0.1:8080> call CreateTask
name (TYPE_STRING) => tarou
✔ STATUS_TODO
{}
島袋恵島袋恵

続いて、シナリオテストを作る。シナリオテストは、scenarigo を使う

https://github.com/zoncoen/scenarigo

インストール

$ go install github.com/zoncoen/scenarigo/cmd/scenarigo@v0.15.1

シナリオテストのディレクトリを作成する

$ mkdir scenariotest
$ cd scenariotest

scenarigo のコンフィグを作成。以下のコマンドを実行すると scenarigo.yaml が生成される

$ scenarigo config init
島袋恵島袋恵

buf generate したコードを使って シナリオを実行できるように plugin を作成する。READMEに書かれてる pluginの作り方を参考にする

https://github.com/zoncoen/scenarigo#how-to-write-plugins

scenariotest 配下に pluginを実装するディレクトリを作る

$ mkdir -p plugins/todo
$ touch plugins/todo/main.go

main.go の実装

package main

import (
	todov1 "github.com/shimabukuromeg/connect-go-todo-example/gen/proto/todo/v1" // generated by protoc-gen-go

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

var conn *grpc.ClientConn

func newConn(target string) *grpc.ClientConn {
	if conn != nil {
		return conn
	}

	conn, err := grpc.Dial(
		target,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithBlock(),
	)
	if err != nil {
		panic(err)
	}

	return conn
}

func NewTodoServiceClient() todov1.ToDoServiceClient {
	conn := newConn("localhost:8080")
	return todov1.NewToDoServiceClient(conn)
}

scenarigo.yaml を編集して plugin の記述を追加する

~ 抜粋
plugins: # Specify configurations to build plugins.
  todo.so: # Map keys specify plugin output file path from the root directory of plugins.
    src: ./plugins/todo # Specify the source file, directory, or "go gettable" module path of the plugin.
~ 抜粋

プラグインをビルドする。ビルドするときは以下のコマンドを実行する。このコマンドは、scenariotestディレクトリで実行する。

$ scenarigo plugin build

実行したら、genディレクトリ配下に todo.so が作られてる。

scenariotest のディレクトリ構成

scenariotest $ tree .
.
├── gen
│   └── todo.so
├── plugins
│   └── todo
│       ├── go.mod
│       ├── go.sum
│       └── main.go
├── scenarigo.yaml
島袋恵島袋恵

シナリオを作成する

$ mkdir scenarios
$ touch scenarios/todo_test.yaml

scenarios/todo_test.yaml にCreateTaskをcallしたときのシナリオを書く

title: シナリオテスト
plugins:
  grpc: todo.so
vars:
  client: "{{plugins.grpc.NewTodoServiceClient()}}"
steps:
  - title: Create task
    protocol: gRPC
    request:
      client: "{{vars.client}}"
      method: CreateTask
      body:
        name: "太郎"
        status: "STATUS_TODO"
    expect:
      code: 0
      body:
        id: "{{response.id}}"
        name: "太郎"
        status: "STATUS_TODO"
    bind:
      vars:
        id: "{{response.id}}"

scenarigo.yaml を編集して作ったシナリオを指定する

~抜粋
scenarios: ["scenarios"] # Specify test scenario files and directories.
~抜粋

シナリオを実行する。サーバーが起動してなかったら起動させて実行する。($ go run ./cmd/server/main.go

$ scenarigo run

実行した結果。まだrpcのロジックが未完成なので失敗する。

$ scenarigo run
--- FAIL: scenarios/todo_test.yaml (0.00s)
    --- FAIL: scenarios/todo_test.yaml/シナリオテスト (0.00s)
        --- FAIL: scenarios/todo_test.yaml/シナリオテスト/Create_task (0.00s)
                request:
                  method: CreateTask
                  metadata: {}
                  message:
                    name: 太郎
                    status: 1
                response:
                  status:
                    code: OK
                  header:
                    content-type:
                    - application/grpc
                    createtask-version:
                    - v1
                    date:
                    - Sat, 23 Sep 2023 12:19:18 GMT
                    grpc-accept-encoding:
                    - gzip
                  message: {}
                elapsed time: 0.000739 sec
                2 errors occurred: .steps[0].expect.message.name: expected 太郎 but got
                .steps[0].expect.message.status: expected string (STATUS_TODO) but got todov1.Status (STATUS_UNKNOWN_UNSPECIFIED)
FAIL
FAIL	scenarios/todo_test.yaml	0.004s
FAIL
島袋恵島袋恵

シナリオの準備ができたので CreateTask の未完成だったロジックの部分を実装する。※ちゃんと登録できてるか確認したかったので、いろいろログに出力させたりしてる。

func (s *TodoServer) CreateTask(
	ctx context.Context,
	req *connect.Request[todov1.CreateTaskRequest],
) (*connect.Response[todov1.CreateTaskResponse], error) {
	s.nextID++
	id := s.nextID

	newTodo := &todov1.TodoItem{
		Id:     uint64(id),
		Name:   req.Msg.Name,
		Status: req.Msg.Status,
	}

	// TODOを追加
	s.todos.Store(newTodo.Id, newTodo)
	log.Println("TODOを追加")

	log.Println("Request headers: ", req.Header())
	res := connect.NewResponse(&todov1.CreateTaskResponse{
		Id:     newTodo.Id,
		Name:   newTodo.Name,
		Status: newTodo.Status,
	})

	// TODO一覧
	log.Println("TODO一覧")
	s.todos.Range(func(key, value interface{}) bool {
		log.Printf("key is %d, value is %v", key, value)
		return true
	})

	res.Header().Set("CreateTask-Version", "v1")
	return res, nil
}

サーバーを起動し直して、シナリオテストを実行すると成功した

$ scenarigo run
ok  	scenarios/todo_test.yaml	0.006s
島袋恵島袋恵

同じように、更新と削除のシナリオテストを書いて、その後ロジック修正して、シナリオテストが通ることを確認する。

シナリオテスト更新

scenariotest/scenarios/todo_test.yaml

title: シナリオテスト
plugins:
  grpc: todo.so
vars:
  client: "{{plugins.grpc.NewTodoServiceClient()}}"
steps:
  - title: Create task
    protocol: gRPC
    request:
      client: "{{vars.client}}"
      method: CreateTask
      body:
        name: "太郎"
        status: "STATUS_TODO"
    expect:
      code: 0
      body:
        id: "{{response.id}}"
        name: "太郎"
        status: "STATUS_TODO"
    bind:
      vars:
        id: "{{response.id}}"

  - title: Create task 2
    protocol: gRPC
    request:
      client: "{{vars.client}}"
      method: CreateTask
      body:
        name: "次郎"
        status: "STATUS_TODO"
    expect:
      code: 0
      body:
        id: "{{response.id}}"
        name: "次郎"
        status: "STATUS_TODO"

  - title: Update task status
    protocol: gRPC
    request:
      client: "{{vars.client}}"
      method: UpdateTaskStatus
      body:
        id: "{{vars.id}}"
        status: "STATUS_DONE"
    expect:
      code: 0
      body:
        id: "{{vars.id}}"
        status: "STATUS_DONE"

  - title: Delete task
    protocol: gRPC
    request:
      client: "{{vars.client}}"
      method: DeleteTask
      body:
        id: "{{vars.id}}"
    expect:
      code: 0
      body:
        id: "{{response.id}}"

最終的なロジック

cmd/server/main.go

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"sync"

	"connectrpc.com/connect"
	"connectrpc.com/grpcreflect"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"

	todov1 "github.com/shimabukuromeg/connect-go-todo-example/gen/proto/todo/v1" // generated by protoc-gen-go

	"github.com/shimabukuromeg/connect-go-todo-example/gen/proto/todo/v1/todov1connect"
)

type TodoServer struct {
	todos  sync.Map
	nextID int
}

func isEmpty(m *sync.Map) bool {
	isEmpty := true
	m.Range(func(k, v interface{}) bool {
		isEmpty = false
		return false // break after the first item
	})
	return isEmpty
}


func (s *TodoServer) CreateTask(
	ctx context.Context,
	req *connect.Request[todov1.CreateTaskRequest],
) (*connect.Response[todov1.CreateTaskResponse], error) {
	s.nextID++
	id := s.nextID

	newTodo := &todov1.TodoItem{
		Id:     uint64(id),
		Name:   req.Msg.Name,
		Status: req.Msg.Status,
	}

	// TODOを追加
	s.todos.Store(newTodo.Id, newTodo)
	log.Println("TODOを追加")

	log.Println("Request headers: ", req.Header())
	res := connect.NewResponse(&todov1.CreateTaskResponse{
		Id:     newTodo.Id,
		Name:   newTodo.Name,
		Status: newTodo.Status,
	})

	// TODO一覧
	log.Println("TODO一覧")
	s.todos.Range(func(key, value interface{}) bool {
		log.Printf("key is %d, value is %v", key, value)
		return true
	})

	res.Header().Set("CreateTask-Version", "v1")
	return res, nil
}

func (s *TodoServer) UpdateTaskStatus(
	ctx context.Context,
	req *connect.Request[todov1.UpdateTaskStatusRequest],
) (*connect.Response[todov1.UpdateTaskStatusResponse], error) {
	id := req.Msg.Id
	log.Println("Request headers: ", req.Header())

	// 更新対象のtodoを取得
	t, ok := s.todos.Load(id)
	if !ok {
		fmt.Printf("id %d のtodoはありません", id)
	}

	todo := t.(*todov1.TodoItem)

	updateTodo := &todov1.TodoItem{
		Id:     id,
		Name:   todo.Name,
		Status: req.Msg.Status,
	}

	// TODOを更新
	s.todos.Store(id, updateTodo)
	log.Println("TODOを更新")

	res := connect.NewResponse(&todov1.UpdateTaskStatusResponse{
		Id:     updateTodo.Id,
		Status: todov1.Status_STATUS_DONE,
	})

	// TODO一覧
	log.Println("TODO一覧")
	if isEmpty(&s.todos) {
		log.Println("todoはありません")
	} else {
		s.todos.Range(func(key, value interface{}) bool {
			log.Printf("key is %d, value is %v", key, value)
			return true
		})
	}

	res.Header().Set("UpdateTaskStatus-Version", "v1")
	return res, nil
}

func (s *TodoServer) DeleteTask(
	ctx context.Context,
	req *connect.Request[todov1.DeleteTaskRequest],
) (*connect.Response[todov1.DeleteTaskResponse], error) {
	id := req.Msg.Id
	log.Println("Request headers: ", req.Header())

	// 削除対象のtodoを取得
	_, ok := s.todos.Load(id)
	if !ok {
		fmt.Printf("id %d のtodoはありません", id)
	}

	// TODOを削除
	s.todos.Delete(id)
	log.Println("TODOを更新")

	res := connect.NewResponse(&todov1.DeleteTaskResponse{
		Id: id,
	})

	// TODO一覧
	log.Println("TODO一覧")
	s.todos.Range(func(key, value interface{}) bool {
		log.Printf("key is %d, value is %v", key, value)
		return true
	})

	res.Header().Set("Greet-Version", "v1")
	return res, nil
}


func main() {
    todoServer := &TodoServer{}	
	mux := http.NewServeMux()
	reflector := grpcreflect.NewStaticReflector(
		"greet.v1.TodoService",
	)
	mux.Handle(grpcreflect.NewHandlerV1(reflector))
	mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))
	path, handler := todov1connect.NewToDoServiceHandler(todoServer)
	mux.Handle(path, handler)
	log.Fatal(http.ListenAndServe(
		"localhost:8080",
		// Use h2c so we can serve HTTP/2 without TLS.
		h2c.NewHandler(mux, &http2.Server{}),
	))
}

シナリオテストを実行する。成功

$ scenarigo run
ok  	scenarios/todo_test.yaml	0.010s
島袋恵島袋恵

最後にevansを使ってクライアントからtodo追加してみる

$ evans --proto proto/todo/v1/todo.proto repl --port 8080

  ______
 |  ____|
 | |__    __   __   __ _   _ __    ___
 |  __|   \ \ / /  / _. | | '_ \  / __|
 | |____   \ V /  | (_| | | | | | \__ \
 |______|   \_/    \__,_| |_| |_| |___/

 more expressive universal gRPC client


proto.todo.v1.ToDoService@127.0.0.1:8080> call CreateTask
name (TYPE_STRING) => タスク1
✔ STATUS_TODO
{
  "id": "3",
  "name": "タスク1",
  "status": "STATUS_TODO"
}

ちゃんと追加された

島袋恵島袋恵

感想

  • evans がいい感じに補完きいて便利だった
  • protoファイルで定義して、generateしてシナリオテスト書いて、実装してって流れが体験良かった