Back

/ 5 min read

A Simple and Lightweight HTTP Framework Implemented in Go

Forward

I had been using a lot Go HTTP frameworks like Hertz, Gin and Fiber, while reading through the source code of these frameworks I tried to implement a simple HTTP framework myself.

A few days later, PIANO was born. I referenced code from Hertz and Gin and implemented most of the features that an HTTP framework should have.

For example, PIANO supports parameter routing, wildcard routing, and static routing, supports route grouping and middleware, and multiple forms of parameter fetching and returning. I’ll introduce these features next.

Quick Start

Install

Terminal window
go get github.com/B1NARY-GR0UP/piano

Or you can clone it and read the code directly.

Hello PIANO

package main
import (
"context"
"net/http"
"github.com/B1NARY-GR0UP/piano/core"
"github.com/B1NARY-GR0UP/piano/core/bin"
)
func main() {
p := bin.Default()
p.GET("/hello", func(ctx context.Context, pk *core.PianoKey) {
pk.String(http.StatusOK, "piano")
})
p.Play()
}

As you can see, PIANO’s Hello World is very simple: we register a route with the GET function, and then all you have to do is visit localhost:7246/hello in your browser or some other tool to see the value returned (which is “piano”).

7246 is the default listening port I set for PIANO, you can change it to your preferred listening port with the WithHostAddr function.

Features

Route

  • Static Route

    Static route is the simplest and most common form of routing, and the one we just demonstrated in Quick Start is static route. We set up a handler to handle a fixed HTTP request URL.

    package main
    import (
    "context"
    "net/http"
    "github.com/B1NARY-GR0UP/piano/core"
    "github.com/B1NARY-GR0UP/piano/core/bin"
    )
    func main() {
    p := bin.Default()
    // static route or common route
    p.GET("/ping", func(ctx context.Context, pk *core.PianoKey) {
    pk.JSON(http.StatusOK, core.M{
    "ping": "pong",
    })
    })
    p.Play()
    }
  • Param Route

    We can set a route in the form of :yourparam to match the HTTP request URL, and PIANO will automatically parse the request URL and store the parameters as key / value pairs, which we can then retrieve using the Param method. For example, if we set a route like /param/:username, when we send a request with URL localhost:7246/param/lorain, the param username will be assigned with value lorain. And we can get username by Param function.

    package main
    import (
    "context"
    "net/http"
    "github.com/B1NARY-GR0UP/piano/core"
    "github.com/B1NARY-GR0UP/piano/core/bin"
    )
    func main() {
    p := bin.Default()
    // param route
    p.GET("/param/:username", func(ctx context.Context, pk *core.PianoKey) {
    pk.JSON(http.StatusOK, core.M{
    "username": pk.Param("username"),
    })
    })
    p.Play()
    }
  • Wildcard Route

    The wildcard route matches all routes and can be set in the form *foobar.

    package main
    import (
    "context"
    "net/http"
    "github.com/B1NARY-GR0UP/piano/core"
    "github.com/B1NARY-GR0UP/piano/core/bin"
    )
    func main() {
    p := bin.Default()
    // wildcard route
    p.GET("/wild/*", func(ctx context.Context, pk *core.PianoKey) {
    pk.JSON(http.StatusOK, core.M{
    "route": "wildcard route",
    })
    })
    p.Play()
    }

Route Group

PIANO also implements route group, where we can group routes with the same prefix to simplify our code.

package main
import (
"context"
"net/http"
"github.com/B1NARY-GR0UP/piano/core"
"github.com/B1NARY-GR0UP/piano/core/bin"
)
func main() {
p := bin.Default()
auth := p.GROUP("/auth")
auth.GET("/ping", func(ctx context.Context, pk *core.PianoKey) {
pk.String(http.StatusOK, "pong")
})
auth.GET("/binary", func(ctx context.Context, pk *core.PianoKey) {
pk.String(http.StatusOK, "lorain")
})
p.Play()
}

Parameter Acquisition

The HTTP request URL or parameters in the request body can be retrieved using methods Query, PostForm.

  • Query
package main
import (
"context"
"net/http"
"github.com/B1NARY-GR0UP/piano/core"
"github.com/B1NARY-GR0UP/piano/core/bin"
)
func main() {
p := bin.Default()
p.GET("/query", func(ctx context.Context, pk *core.PianoKey) {
pk.JSON(http.StatusOK, core.M{
"username": pk.Query("username"),
})
})
p.Play()
}
  • PostForm
package main
import (
"context"
"net/http"
"github.com/B1NARY-GR0UP/piano/core"
"github.com/B1NARY-GR0UP/piano/core/bin"
)
func main() {
p := bin.Default()
p.POST("/form", func(ctx context.Context, pk *core.PianoKey) {
pk.JSON(http.StatusOK, core.M{
"username": pk.PostForm("username"),
"password": pk.PostForm("password"),
})
})
p.Play()
}

Other

PIANO has many other small features such as storing information in the request context, returning a response as JSON or string, middleware support, so I won’t cover them all here.

Design

Here we will introduce some of the core design of PIANO. PIANO is based entirely on the Golang standard library, with only one dependency for a simple log named inquisitor that I implemented myself.

  • go.mod
module github.com/B1NARY-GR0UP/piano
go 1.18
require github.com/B1NARY-GR0UP/inquisitor v0.1.0

Context

The design of PIANO context is as follows:

// PianoKey play the piano with PianoKeys
type PianoKey struct {
Request *http.Request
Writer http.ResponseWriter
index int // initialize with -1
Params Params
handlers HandlersChain
rwMutex sync.RWMutex
KVs M
}

And after referring to the context design of several frameworks, I decided to adopt Hertz’s scheme, which separates the request context from the context.Context, this can be well used in tracing and other scenarios through the correct management of the two different lifecycle contexts.

// HandlerFunc is the core type of PIANO
type HandlerFunc func(ctx context.Context, pk *PianoKey)

The current context only provides some simple functionality, such as storing key-value pairs in KVs, but more features will be supported later.

Route Tree

The design of the route tree uses the trie tree data structure, which inserts and searches in the form of iteration.

  • Insert
// insert into trie tree
func (t *tree) insert(path string, handlers HandlersChain) {
if t.root == nil {
t.root = &node{
kind: root,
fragment: strSlash,
}
}
currNode := t.root
fragments := splitPath(path)
for i, fragment := range fragments {
child := currNode.matchChild(fragment)
if child == nil {
child = &node{
kind: matchKind(fragment),
fragment: fragment,
parent: currNode,
}
currNode.children = append(currNode.children, child)
}
if i == len(fragments)-1 {
child.handlers = handlers
}
currNode = child
}
}
  • Search
// search matched node in trie tree, return nil when no matched
func (t *tree) search(path string, params *Params) *node {
fragments := splitPath(path)
var matchedNode *node
currNode := t.root
for i, fragment := range fragments {
child := currNode.matchChildWithParam(fragment, params)
if child == nil {
return nil
}
if i == len(fragments)-1 {
matchedNode = child
}
currNode = child
}
return matchedNode
}

It is worth mentioning that the different routing support is also implemented here.

Future Work

  • Encapsulation protocol layer
  • Add more middleware support
  • Improve engine and context functionality

Summary

That’s all for this article. Hopefully, this has given you an idea of the PIANO framework and how you can implement a simple version of HTTP framework yourself. I would be appreciate if you could give PIANO a star.

If you have any questions, please leave them in the comments or as issues. Thanks for reading.

Reference List