Connect で todo アプリを作るぞ
Connect で todo アプリを作るぞ
getstartd はこの前やった
作業場所を作る
$ 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 のパスが間違っててエラーになるから注意
- option go_package の指定間違えると
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.yaml
を作る
$ buf mod init
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
準備ができたらコード生成する
$ buf lint
$ buf generate
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を叩けるツール
インストール
$ 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 を使う
インストール
$ 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の作り方を参考にする
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してシナリオテスト書いて、実装してって流れが体験良かった