Goで自作Webフレームワーク【その①】

2023/08/26に公開

はじめに

皆さんは普段GoでAPIサーバを作成するとき、どのフレームワークを使っていますか?
私はGinFiberといったフレームワークをよく使いますが、goは標準パッケージが充実しているので、net/httpパッケージで十分という方もいるでしょう。
そもそも、なぜGoでWebFWを作ろうと思ったのかというと、最近ずっとGoを学習しており、GoでOSS開発をやりたいという意欲が高まってきたので、その一歩目として、WebFWを作ってみようと思ったわけです。そのため、きれいな実装やGinなどのFWと比べても機能的な面で落ちてしまうので、その点は悪しからず。

https://github.com/gin-gonic/gin

https://github.com/gofiber/fiber

実装したい機能

今回実装してみたい機能としては以下のようなものが挙げられます。

  • 静的なルーティング (ex. /users)
  • 動的なルーティング (ex. /users/:id)
  • HTTPメソッド(Get, Post, Put, Delete等)
  • HTMLなどの静的ファイルの描画
  • コンテキストを扱った処理 (gin.Contextのような振る舞いをするやつ)
  • ミドルウェアを扱った処理 (panicから回復するミドルウェア等)
  • ロガー

このくらいでしょうか、かなり盛り込みました。実装できる自信はありませんが、頑張ります。

以下、このフレームワークのリポジトリです。

https://github.com/yamato0211/goad

簡単なフレームワーク

前述したようにGoは非常に優秀なnet/httpパッケージがあるので、これを使用してまずは簡単なフレームワークを作ってみます。

goad.go
package goad

import (
	"errors"
	"net/http"
)

type Engine struct {
	Router *Router
}

type Router struct {
	routingTable map[string]func(w http.ResponseWriter, r *http.Request)
}

func (r *Router) Get(path string, handler func(w http.ResponseWriter, r *http.Request)) error {
	if r.routingTable[path] != nil {
		return errors.New("existed")
	}

	r.routingTable[path] = handler
	return nil
}

func (h *Engine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		handler := h.Router.routingTable[r.URL.Path]
		if handler == nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		handler(w, r)
		return
	}
}

func New() *Engine {
	return &Engine{
		Router: &Router{
			routingTable: make(map[string]func(w http.ResponseWriter, r *http.Request)),
		},
	}
}

func (e *Engine) Run(addr string) {
	http.ListenAndServe(addr, e)
}

httpサーバーはnet/httpパッケージのListenAndServe(addr string, handler http.Handler) を使用します。第一引数にはlistenするアドレスを第二引数にはhttp.Handlerなるものを受け取っています。この型を見てみると

server.go
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

http.Handlerは ServeHTTP(ResponseWriter, *Request) をもつinterfaceでした。
そのためフレームワークの要であるEngineというstructを定義して、Engine structに対して、ServeHTTPメソッドを定義します。これによりEngineがhttp.Handlerのinterfaceを満たすようになります。

goad.go
...
type Engine struct {
	Router *Router
}
...
func (h *Engine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		handler := h.Router.routingTable[r.URL.Path]
		if handler == nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		handler(w, r)
		return
	}
}

Engine structには、Routerを持たせています。

type Router struct {
	routingTable map[string]func(w http.ResponseWriter, r *http.Request)
}

func (r *Router) Get(path string, handler func(w http.ResponseWriter, r *http.Request)) error {
	if r.routingTable[path] != nil {
		return errors.New("this path is already used")
	}

	r.routingTable[path] = handler
	return nil
}

Routerは上のようなstructです。routingTableはapiのパスをmapのkeyとしてhandlerをvalueに持ちます。つまり、パスとhandlerを一対一対応させます。今回、RouterのメソッドとしてGetしか定義していません。Getメソッドはpathとhandlerを引数として受け取り、routingTableにhandlerを格納します。すでにpathに対してhandlerが割り当ててある場合はエラーを返します。

func New() *Engine {
	return &Engine{
		Router: &Router{
			routingTable: make(map[string]func(w http.ResponseWriter, r *http.Request)),
		},
	}
}

func (e *Engine) Run(addr string) {
	http.ListenAndServe(addr, e)
}

残りはNewとRunです。New()はEngineの初期化を行います。Run()で実際にListenAndServeを呼び出します。

実際に使ってみる

$ go mod init goad-test
$ go get github.com/yamato0211/goad
main.go
package main

import (
	"fmt"
	"net/http"

	"github.com/yamato0211/goad"
)

func main() {
	e := goad.New()
	e.Router.Get("/users", UserHandler)
	e.Run(":8080")
}

func UserHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "users")
}

以下のコマンドで起動して、http://localhost:8080/users にアクセスすると

$ go run main.go

usersが表示された!!うまく動いてますね。

現状の問題点

いまroutingTableというmapにhandlerを格納していますが、これには問題があります。静的なルーティングの場合このような仕様でも良いかもしれませんが、動的ルーティングを行いたい場合、つまりusers/123だったりusers/abcだったりと、users/以降に続く可変の文字列に対応したいとなるとroutingTableだと厳しいと思われます。

動的ルーティングをどのように実現するか

ここが一番の悩みどころでした。色々検索してみましたが、実装方法がなかなか出てこなかったので、chatgpt大先生に聞いてみることにしました。

トライ(Trie)を使用した方法:
トライは、ルーティングのための非常に効率的なデータ構造として知られています。特に、大量のルートを持つ大規模なWebアプリケーションでのルーティングに適しています。トライを使用してルーティングを実装する場合、各ノードはURLの一部を表し、特定のパスの末尾に到達したときに実行されるハンドラを持ちます。

トライ木と呼ばれるデータ構造があるとのこと、これを使って動的ルーティングを実現したい。

参考

https://bmf-tech.com/posts/Golangでトライ木を実装する

https://kotaroooo0-dev.hatenablog.com/entry/trie

次回: 実装頑張る

Discussion