🐶

GolangでWebフレームワークはじめ

2024/10/15に公開

はじめに

WebAPIを開発するときにWebフレームワークを使うというのが当たり前になってきたと思います(当たり前になる前を知らない)。私は最近Go言語のEchoというフレームワークが気に入っています。とてもシンプルな作りに心を掴まれました。そこである日思ったんですよね、Webフレームワークってどういう中身になってるんだろうと。気になってEchoを真似してWebフレームワークの開発を始めました。

https://github.com/poteto0/poteto

  • Echo

https://github.com/labstack/echo/tree/master

  • こちらにも影響を受けました

https://zenn.dev/yamato0211/articles/f5ab65ba6e95d9

実現したい機能

まずは最低限のWebAPIを目指していきます・・・

  • ルーティング
  • Jsonのレスポンス
  • CORS

ルーティング

今回初めて知ったんですが、ルーティングってトライ木で実装されてるんですね。競プロでしか聞かないので、驚きました。トライ木は簡単に言うと、文字ごとにノードに格納して、いくつかの文字列を木構造にしたデータ構造です。それによって、特に同じような文字列が多いときに最もいい結果を産みます(多分)。このようにしてルーティングのパフォーマンスを上げてたんですね~。

  • トライ木については

https://algo-logic.info/trie-tree/

実装

WebAPIではある程度エンドポイントが共通してきますよね、

users/create
users/update
users/:id
...

なので、大きな効果があるわけです。そして通常のトライ木とは違い、パスの階層でノードを作っていきます。

example → users → create
                ↳ update
        ↳ hello

ということで各ルートのメンバはこんな形になります。handlerはそれぞれのエンドポイントに紐づいた関数です。childrenが次のrouteを参照し、木構造を形成します。

type route struct {
	key      string
	method   string
	children map[string]Route
	handler  HandlerFunc
}

検索

リクエストがあったエンドポイントを検索します。最も大きい階層から、順々に階層をたどっていきます

func (r *route) Search(path string) *route {
	currentRoute := r
	params := strings.Split(path, "/")

	for _, param := range params {
		if param == "" {
			continue
		}

        // 子供を辿っていく
		if nextRoute, ok := currentRoute.children[param]; ok {
			currentRoute = nextRoute.(*route)
		} else {
			return nil
		}
	}
	return currentRoute
}

挿入

ほぼ検索とおなじですが、子を辿っていき、無くなったらそこに新たなRouteを挿入します

func (r *route) Insert(method, path string, handler HandlerFunc) {
	currentRoute := r
	params := strings.Split(path, "/")

	for _, param := range params {
		if param == "" {
			continue
		}

		if nextRoute := currentRoute.children[param]; nextRoute == nil {
            // 無くなったら新しく作る
			currentRoute.children[param] = &route{
				key:      param,
				method:   method,
				children: make(map[string]Route),
			}
		}
        // 子を辿っていく
		currentRoute = currentRoute.children[param].(*route)
	}
	currentRoute.handler = handler
}

これでルーティングの機能を実装することができました。次に、リクエストを送ったときに、Jsonのレスポンスを返したいですよね?

Json型レスポンス

まずはコンテキストが必要になってきます。理解はまだまだ不十分ですが、今の私の認識ではリクエストとレスポンスを持ったもの、といった感じです。つまり、リクエストを追記する必要があるわけですね。Goには非常に使いやすいnet/httpパッケージがあるので、それを活用しましょう。

type context struct {
	response Response
	request  *http.Request
	path     string
}

ここでJson型のレスポンスを書きたければ、こうすればいいのです。便利ですね。

func (ctx *context) JSON(code int, value any) error {
	ctx.writeContentType(constant.APPLICATION_JSON)
	ctx.response.SetStatus(code)
	return ctx.JsonSerialize(value)
}

func (ctx *context) writeContentType(value string) {
	header := ctx.response.Header()

	if header.Get(constant.HEADER_CONTENT_TYPE) == "" {
		header.Set(constant.HEADER_CONTENT_TYPE, value)
	}
}

func (ctx *context) JsonSerialize(value any) error {
	encoder := json.NewEncoder(ctx.GetResponse())
	return encoder.Encode(value)
}

簡単な実装ですが、コンテキストを受け取って、リレーして返す様子っていうのが今一度実感できたのがよかったです。さあこれでWebAPIが出来ました、と見せかけて、クライアントアプリから呼ぼうとするとCORSポリシーに引っ掛かってしまいます。みな最初に思う、これ何やねんです。こうしないとどこもかしこもレスポンスを返してしまうので、必要というかWebフレームワークとしてはこれに対応しないとほぼ何もできません(余談ですが、CORSポリシーってあくまでリクエストは通ってるらしいですね。そのため防御になっているわけではないとか)。

https://qiita.com/netebakari/items/41baa7e1d0b8d89f9d12

CORS対応

CORSに対応するには、Access-Control-Allow-Originヘッダにクライアントを登録する必要があります。初めに言いますが、ここの実装はほぼEchoの写経です。でもいい勉強にはなりました。

type CORSConfig struct {
    // Originを登録します ex) http://localhost:* 
	AllowOrigins []string `yaml:"allow_origins
    // HTTPメソッドを登録します
	AllowMethods []string `yaml:"allow_methods"`
}

func CORSWithConfig(config CORSConfig) poteto.MiddlewareFunc {
	...
    // 正規表現に対応します。よくあるAccess-Control-Origin: * とかですね
	allowOriginPatterns := []string{}
	for _, origin := range config.AllowOrigins {
		pattern := wrapRegExp(origin)
		allowOriginPatterns = append(allowOriginPatterns, pattern)
	}

	return func(next poteto.HandlerFunc) poteto.HandlerFunc {
		return func(ctx poteto.Context) error {
			...
			origin := req.Header.Get(constant.HEADER_ORIGIN)

			res.Header().Add(constant.HEADER_VARY, constant.HEADER_ORIGIN)
			preflight := req.Method == http.MethodOptions

        	...

            // 許可されたOriginと照らし合わせます
			allowSubDomain := getAllowSubDomain(origin, config.AllowOrigins)
			allowOrigin := getAllowOrigin(allowSubDomain, allowOriginPatterns)

			// Origin not allowed
			if allowOrigin == "" {
				...
				return ctx.NoContent()
			}

			// allowed method
			if matchMethod(req.Method, config.AllowMethods) {
                // レスポンスを返す前に、CORSヘッダーを追加して終了です
				res.Header().Set(constant.HEADER_ACCESS_CONTROL_ORIGIN, allowOrigin)
				return next(ctx)
			}

			return ctx.NoContent()
		}
	}
}

これでほんとに最低限の機能が整いました。あとはサーブしておしまい。

func (p *poteto) Run(addr string) {
	if err := http.ListenAndServe(addr, p); err != nil {
		panic(err)
	}
}

にしても便利ですねnet/httpは。

今後やりたいこと

  • JWTミドルウェア
  • パラメータを扱う

今後も気長に開発していきます。

最後に

普段使っている道具の中身を見ることができるので、Webフレームワーク開発おすすめです。Goって人が書いたコードも結構すんなり読めるので、私はGolangでやってみるのが良いんじゃないかなーと激推ししてます。

Discussion