最近の盆栽ですけれど、tanukirpcというGoのWebフレームワークを書いています。ある程度やりたいことができはじめてきたので、どんなフレームワークかを紹介します。
github.com
TL;DR
- Webアプリケーションでよくやるようなことを、最短手順で自然に書けるように設計したフレームワーク
- リクエストをパースして構造体にマッピングする
- リクエストの内容をバリデーションする
- レスポンスの構造体をエンコードしてレスポンスとして書き込む
- グローバルスコープもしくはリクエストスコープでの構造体のコントローラーへの依存性注入
- DBコネクションやAPIクライアントの保持などに使う
- 現在の責務範囲はWebアプリケーションのコントローラーだが、Webアプリを作る時によくやるようなことはできるだけやれるようにしてく
tanukiup
開発サーバー起動用コマンド。ファイル更新を監視してビルドおよびサーバープロセスの再起動を行う
gentypescript
クライアントコードの生成コマンド。GoでもtRPCのような開発体験を得るのを目指している
ではそれぞれどういうことなのか見ていきましょう。
router
の作成
tanukirpc
は*tanukirpc.Router
を作成し、このrouterのGet
やPost
などのメソッドを使ってAPIエンドポイントを設定していくことでコントローラー全体を定義していきます。なのでまずは、router
を作るところから始めます。
package main
import (
"github.com/mackee/tanukirpc"
)
func main() {
router := tanukirpc.NewRouter(struct{}{})
}
空struct struct{}{}
を渡しているのが気になりますが、こちらは後述するRegistryの項目でお話しします。
APIエンドポイントの定義
まずは基本的なエンドポイントを定義していきます。次のコードではシンプルな GET /hello
エンドポイントを定義しています。
package main
import (
"fmt"
"net/http"
"github.com/mackee/tanukirpc"
)
func main() {
router := tanukirpc.NewRouter(struct{}{})
type helloRequest struct {
Name string `query:"name"`
}
type helloResponse struct {
Message string `json:"name"`
}
router.Get("/hello", tanukirpc.NewHandler(
func(ctx tanukirpc.Context[struct{}], req helloRequest) (*helloResponse, error) {
return &helloResponse{Message: fmt.Sprintf("hello, %s", req.Name)}, nil
},
))
http.ListenAndServe("127.0.0.1:8080", router)
}
これで完成しました。起動してみます。
$ go run main.go
ログを出していないので何も出力されませんが、HTTPサーバーとして起動しています。別のターミナルを立ち上げて、curlを用いてリクエストを行ってみます。
$ curl 'http://localhost:8080/hello?name=tanukirpc'
{"name":"hello, tanukirpc"}
JSONでレスポンスが帰ってきました。この通り、query parameterのname
として渡したtanukirpc
がhelloRequest.Name
にバインドされてハンドラー内で利用できます。
また、ハンドラーで返した*helloResponse
はJSONとしてクライアントに返却されます。
便利な開発用コマンド tanukiup
先ほどはgo run main.go
で起動しましたが、ファイルを変更するたびにCtrl+c
で終了させて立ち上げ直さなければなりません。そこでtanukirpcでは開発用の便利なコマンドとしてtanukiup
を用意しています。
tanukiup
は以下のように使います。
$ go get github.com/mackee/tanukirpc/cmd/tanukiup
$ go run github.com/mackee/tanukirpc/cmd/tanukiup -dir ./
-dir ./
は ./
以下を監視対象に入れます。デフォルトでは監視対象のディレクトリのうち、.go
で終わるファイルに更新があった場合にビルドとコマンドの再起動が行われます。
こういったWebアプリケーションサーバー開発でよくやるようなことのツール化もtanukirpcの守備範囲です。
リクエストのパース
tanukirpcは以下のリクエスト形式のstructへのバインドに対応しています。
- クエリパラメーター
- パスパラメーター
- 例:
/hello/{name}
の {name}
に任意の文字列が入る形式
- Form
- 現在は
application/x-www-form-urlencoded
のみ
- JSON
上記の例ではクエリパラメーターのやり方について解説しましたが、他の形式のケースを紹介します。
パスパラメーター
router
に渡すパスを/hello/{name}
とし、request structのfieldのタグをurlparam:"name"
とすると、{name}
に入る任意の文字列をstructにバインドします。
type helloRequest struct {
Name string `urlparam:"name"`
}
type helloResponse struct {
Message string `json:"name"`
}
router.Get("/hello/{name}", tanukirpc.NewHandler(
func(ctx tanukirpc.Context[struct{}], req helloRequest) (*helloResponse, error) {
return &helloResponse{Message: fmt.Sprintf("hello, %s", req.Name)}, nil
},
))
$ curl 'http://localhost:8080/hello/tanukirpc'
{"name":"hello, tanukirpc"}
POST Form
structのfieldのタグにform:"name"
のように入れると、リクエストボディに入れられたapplication/x-www-form-urlencoded
形式のリクエストの内容をバインドします。リクエストのContent-Type
ヘッダーがapplication/x-www-form-urlencoded
である必要があります。
type helloRequest struct {
Name string `form:"name"`
}
type helloResponse struct {
Message string `json:"name"`
}
router.Post("/hello", tanukirpc.NewHandler(
func(ctx tanukirpc.Context[struct{}], req helloRequest) (*helloResponse, error) {
return &helloResponse{Message: fmt.Sprintf("hello, %s", req.Name)}, nil
},
))
$ curl -XPOST -d 'name=tanukirpc' -H 'Content-Type: application/x-www-form-urlencoded' http://localhost:8080/hello
{"name":"hello, tanukirpc"}
structのタグにjson:"name"
のようにすると、リクエストボディに入れられたJSON形式をパースし、フィールドにバインドします。リクエストのContent-Type
ヘッダーがapplication/json
である必要があります。
type helloRequest struct {
Name string `json:"name"`
}
type helloResponse struct {
Message string `json:"name"`
}
router.Post("/hello", tanukirpc.NewHandler(
func(ctx tanukirpc.Context[struct{}], req helloRequest) (*helloResponse, error) {
return &helloResponse{Message: fmt.Sprintf("hello, %s", req.Name)}, nil
},
))
$ curl -XPOST -d '{"name":"tanukirpc"}' -H 'Content-Type: application/json' http://localhost:8080/hello
{"name":"hello, tanukirpc"}
上記のタグは複数の種別を指定可能であり、例えばform:"name" json:"name"
のように指定すれば、FormとJSON形式の両方を対応するようなエンドポイントを作成できます。
また、上記以外の形式もCodec
というインターフェイスを実装した上で、router
作成時にオプションとして渡すことで対応可能になっています。
リクエストバリデーション
tanukirpcではgithub.com/go-playground/validatorによるバリデーションが最初から組み込まれています。go-playground/validator
によるリクエストバリデーションを有効にするには、リクエストstructにvalidate
タグを追加します。
以下はクエリパラメーターのname
が必須であることを表すハンドラーです。
type helloRequest struct {
Name string `query:"name" validate:"required"`
}
type helloResponse struct {
Message string `json:"name"`
}
router.Get("/hello", tanukirpc.NewHandler(
func(ctx tanukirpc.Context[struct{}], req helloRequest) (*helloResponse, error) {
return &helloResponse{Message: fmt.Sprintf("hello, %s", req.Name)}, nil
},
))
$ curl http://localhost:8080/hello
{"error":{"message":"Key: 'helloRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag"}}
$ curl 'http://localhost:8080/hello?name=tanukirpc'
{"name":"hello, tanukirpc"}
なお、このエラー出力に関しては、tanukirpc.ErrorHooker
interfaceを実装し、router
の作成時に渡すことで、カスタマイズが可能です。
レスポンス
レスポンス形式は現在はJSON形式のみ対応しています。Codec
interfaceを実装し、router
作成時に渡すことで、その他の形式もサポート可能です。
Registry
Registryはこれまで紹介した型付きハンドラーと並ぶ、tanukirpcのユニークな機能です。
上記の例ではrouter
の作成時に、空structを渡していますが、ここには任意の値を渡すことができます。ここで渡した値はハンドラー内で利用可能です。
一例として、DBコネクションを組み込んだstructを渡したアプリケーションを示します。以下のアプリケーションに対してPOST /users
にリクエストすると、Registryに組み込まれたdbコネクションを用いて、ユーザーを作成します。
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"github.com/mackee/tanukirpc"
_ "github.com/mattn/go-sqlite3"
)
type registry struct {
db *sql.DB
}
func main() {
db, err := sql.Open("sqlite3", "./users.db")
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
reg := ®istry{db: db}
router := tanukirpc.NewRouter(reg)
router.Post("/users", tanukirpc.NewHandler(postUsersHandler))
http.ListenAndServe("127.0.0.1:8080", router)
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
type usersRequest struct {
Name string `form:"name" validate:"required"`
}
type usersResponse struct {
User User `json:"user"`
}
func postUsersHandler(ctx tanukirpc.Context[*registry], req usersRequest) (*usersResponse, error) {
reg := ctx.Registry()
row := reg.db.QueryRowContext(ctx, "INSERT INTO users (name) VALUES (?) RETURNING id", req.Name)
var id int
if err := row.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to insert user: %w", err)
}
return &usersResponse{User: User{ID: id, Name: req.Name}}, nil
}
$ curl -XPOST -d "name=tanukirpc" http://localhost:8080/users
{"user":{"id":1,"name":"tanukirpc"}}
この例ではグローバル変数とあまり変わりませんが、tanukirpc.WithContextFactory
を用いてrouter
にオプションを渡すと、リクエストのたびにRegistryを生成することも可能です。DBの都度接続が必要なケースでは有用です。
tanukirpc
ではエンドポイントをネストして定義することにより、上流から渡されたRegistryとは別の型のRegistryに変換ができます。この機能がどういう場合に有用なのか例を交えて紹介します。
上記のPOST /users
の例では、作成を行いましたが、次は参照と更新のエンドポイントを作成してみます。以下の例ではtanukirpc.RouteWithTransformer
を用いて/users/{id}
以下のパスを定義し、またこのパスに対応するuser
テーブルの行をRegistryに組み込んでハンドラーで利用できるようにします。
type registryWithUser struct {
*registry
user *User
}
var usersTransformer = tanukirpc.NewTransformer(
func(ctx tanukirpc.Context[*registry]) (*registryWithUser, error) {
reg := ctx.Registry()
id := chi.URLParam(ctx.Request(), "id")
row := reg.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = ?", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, tanukirpc.WrapErrorWithStatus(http.StatusNotFound, errors.New("user not found"))
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
return ®istryWithUser{registry: reg, user: &user}, nil
},
)
func getUsersHandler(ctx tanukirpc.Context[*registryWithUser], _ struct{}) (*usersResponse, error) {
return &usersResponse{User: *ctx.Registry().user}, nil
}
func putUsersHandler(ctx tanukirpc.Context[*registryWithUser], req usersRequest) (*usersResponse, error) {
reg := ctx.Registry()
if _, err := reg.db.ExecContext(ctx, "UPDATE users SET name = ? WHERE id = ?", req.Name, reg.user.ID); err != nil {
return nil, fmt.Errorf("failed to update user: %w", err)
}
return &usersResponse{User: User{ID: reg.user.ID, Name: req.Name}}, nil
}
func main() {
tanukirpc.RouteWithTransformer(router, usersTransformer, "/users/{id}", func(r *tanukirpc.Router[*registryWithUser]) {
r.Get("/", tanukirpc.NewHandler(getUsersHandler))
r.Put("/", tanukirpc.NewHandler(putUsersHandler))
})
}
usersTransformer
内でパスに対応したuserを問い合わせ、ない場合は404を返しています。これにより、ハンドラー内ではuserの存在が前提のコードを書けます。以下は上記コードを書いたアプリケーションの挙動を示したものです。
$ curl http://localhost:8080/users/1
{"user":{"id":1,"name":"tanukirpc"}}
$ curl http://localhost:8080/users/2
{"error":{"message":"user not found"}}
$ curl -XPUT -d "name=go-chi" http://localhost:8080/users/1
{"user":{"id":1,"name":"go-chi"}}
$ curl http://localhost:8080/users/1
{"user":{"id":1,"name":"go-chi"}}
TypeScriptクライアント生成
tanukirpcを作る際に、最初のマイルストーンとして設定した機能です。tanukirpcを作成した動機として、もし私が一人でフロントエンドからバックエンドまで全てこなすとしたら、どんなフレームワークが効率的か、それが世の中にない場合にどういったものを作れば良いかを考えた、というのが始まりです。一人で作成する場合に、gRPCやGraphQL、OpenAPIといったスキーマ駆動の開発はむしろ足枷となります。TypeScriptでサーバーアプリケーションを作成する際の、tRPCのような開発体験を得る手段として、TypeScriptのコード生成をしてみてはどうかと考えました。
クライアントを生成する準備として、以下のgo:generate
行を足します。
//go:generate github.com/mackee/tanukirpc/cmd/gentypescript -out ./frontend/src/client.ts ./
そして、生成対象のパスを含んだrouter
をgenclient.AnalyzeTarget
に渡します。
まとめると以下のようになります。
func main() {
db, err := sql.Open("sqlite3", "./users.db")
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
reg := ®istry{db: db}
router := tanukirpc.NewRouter(reg)
router.Post("/users", tanukirpc.NewHandler(postUsersHandler))
tanukirpc.RouteWithTransformer(router, usersTransformer, "/users/{id}", func(r *tanukirpc.Router[*registryWithUser]) {
r.Get("/", tanukirpc.NewHandler(getUsersHandler))
r.Put("/", tanukirpc.NewHandler(putUsersHandler))
})
genclient.AnalyzeTarget(router)
http.ListenAndServe("127.0.0.1:8080", router)
}
go generate ./
で生成しても良いのですが、上記で説明したtanukiup
コマンドはgentypescript
コマンドにも対応しています。tanukiup
コマンド起動時にファイル内にgentypescript
が含まれているかをみているので、tanukiup
コマンドを再起動します。すると、指定したパスにclient.ts
が出力されます。
その様子が以下です。

また、この生成したクライアントは、パスに対応したリクエストの型がマッピングされています。使用中の様子を以下に示します。

まとめ
tanukirpcはまだできたばかりでよちよち歩きのフレームワークですが、私個人が使う分には「これから使っていこう」という気になるような機能がすでに揃っています。私自身はこれから仕事も含めて責任を取れる範囲では使ってみて、足りない機能を見つけては足していこうと思います。
もしこれをご覧の中に、こういったフレームワークが欲しかったけれどあれが足りない、これが足りない、もしくはこういうケースはどう書けばいいかという方がいれば、GitHubのissueなどで言っていただければ何らかの反応はさせていただこうかと思います。フレームワークは実戦で鍛えてみてなんぼかと思います。あなたの実戦に組み込めるような提案ができるとベリベリハッピーです。
また、このフレームワークに関する話をAsakusa.go #3でさせていただきます。今のところ作った動機について話そうかと思いますが、それだとテクっぽくないので、今考えている機能や組み込もうとしている自作ライブラリについての話をするかもしれません。その場合は動機の話は懇親会でしようかなと思います。
そんな感じです。それでは良いDB読み書きJSON返しライフを。