ぱいぱいにっき

Pythonが好きすぎるけれど、今からPerlを好きになりますにっき

tanukirpcというWebフレームワークを作っています

最近の盆栽ですけれど、tanukirpcというGoのWebフレームワークを書いています。ある程度やりたいことができはじめてきたので、どんなフレームワークかを紹介します。

github.com

TL;DR

  • Webアプリケーションでよくやるようなことを、最短手順で自然に書けるように設計したフレームワーク
    • リクエストをパースして構造体にマッピングする
    • リクエストの内容をバリデーションする
    • レスポンスの構造体をエンコードしてレスポンスとして書き込む
    • グローバルスコープもしくはリクエストスコープでの構造体のコントローラーへの依存性注入
      • DBコネクションやAPIクライアントの保持などに使う
  • 現在の責務範囲はWebアプリケーションのコントローラーだが、Webアプリを作る時によくやるようなことはできるだけやれるようにしてく
    • tanukiup 開発サーバー起動用コマンド。ファイル更新を監視してビルドおよびサーバープロセスの再起動を行う
    • gentypescript クライアントコードの生成コマンド。GoでもtRPCのような開発体験を得るのを目指している

ではそれぞれどういうことなのか見ていきましょう。

routerの作成

tanukirpc*tanukirpc.Routerを作成し、このrouterのGetPostなどのメソッドを使ってAPIエンドポイントを設定していくことでコントローラー全体を定義していきます。なのでまずは、routerを作るところから始めます。

package main

import (
    "github.com/mackee/tanukirpc"
)

func main() {
    router := tanukirpc.NewRouter(struct{}{})
    // do something
}

空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として渡したtanukirpchelloRequest.Nameにバインドされてハンドラー内で利用できます。

また、ハンドラーで返した*helloResponseJSONとしてクライアントに返却されます。

便利な開発用コマンド 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=tanukirpc
  • パスパラメーター
    • 例: /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"}

JSON

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 := &registry{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の都度接続が必要なケースでは有用です。

Registry transformer

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 &registryWithUser{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 ./

そして、生成対象のパスを含んだroutergenclient.AnalyzeTargetに渡します。

まとめると以下のようになります。

//go:generate go run github.com/mackee/tanukirpc/cmd/gentypescript -out ./frontend/src/client.ts ./

func main() {
    db, err := sql.Open("sqlite3", "./users.db")
    if err != nil {
        log.Fatalf("failed to open database: %v", err)
    }

    reg := &registry{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が出力されます。

その様子が以下です。

https://i.imgur.com/78CWrhW.gif

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

https://i.imgur.com/1rAJmcJ.gif

まとめ

tanukirpcはまだできたばかりでよちよち歩きのフレームワークですが、私個人が使う分には「これから使っていこう」という気になるような機能がすでに揃っています。私自身はこれから仕事も含めて責任を取れる範囲では使ってみて、足りない機能を見つけては足していこうと思います。

もしこれをご覧の中に、こういったフレームワークが欲しかったけれどあれが足りない、これが足りない、もしくはこういうケースはどう書けばいいかという方がいれば、GitHubのissueなどで言っていただければ何らかの反応はさせていただこうかと思います。フレームワークは実戦で鍛えてみてなんぼかと思います。あなたの実戦に組み込めるような提案ができるとベリベリハッピーです。

また、このフレームワークに関する話をAsakusa.go #3でさせていただきます。今のところ作った動機について話そうかと思いますが、それだとテクっぽくないので、今考えている機能や組み込もうとしている自作ライブラリについての話をするかもしれません。その場合は動機の話は懇親会でしようかなと思います。

そんな感じです。それでは良いDB読み書きJSON返しライフを。