Goで自作Webフレームワーク【その①】
はじめに
皆さんは普段GoでAPIサーバを作成するとき、どのフレームワークを使っていますか?
私はGinやFiberといったフレームワークをよく使いますが、goは標準パッケージが充実しているので、net/httpパッケージで十分という方もいるでしょう。
そもそも、なぜGoでWebFWを作ろうと思ったのかというと、最近ずっとGoを学習しており、GoでOSS開発をやりたいという意欲が高まってきたので、その一歩目として、WebFWを作ってみようと思ったわけです。そのため、きれいな実装やGinなどのFWと比べても機能的な面で落ちてしまうので、その点は悪しからず。
実装したい機能
今回実装してみたい機能としては以下のようなものが挙げられます。
- 静的なルーティング (ex. /users)
- 動的なルーティング (ex. /users/:id)
- HTTPメソッド(Get, Post, Put, Delete等)
- HTMLなどの静的ファイルの描画
- コンテキストを扱った処理 (gin.Contextのような振る舞いをするやつ)
- ミドルウェアを扱った処理 (panicから回復するミドルウェア等)
- ロガー
このくらいでしょうか、かなり盛り込みました。実装できる自信はありませんが、頑張ります。
以下、このフレームワークのリポジトリです。
簡単なフレームワーク
前述したようにGoは非常に優秀なnet/httpパッケージがあるので、これを使用してまずは簡単なフレームワークを作ってみます。
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なるものを受け取っています。この型を見てみると
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
http.Handlerは ServeHTTP(ResponseWriter, *Request) をもつinterfaceでした。
そのためフレームワークの要であるEngineというstructを定義して、Engine structに対して、ServeHTTPメソッドを定義します。これによりEngineがhttp.Handlerのinterfaceを満たすようになります。
...
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
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の一部を表し、特定のパスの末尾に到達したときに実行されるハンドラを持ちます。
トライ木と呼ばれるデータ構造があるとのこと、これを使って動的ルーティングを実現したい。
参考
Discussion