DEV Community

Cover image for Building a Web Application Framework in Go
koderehan
koderehan

Posted on

Building a Web Application Framework in Go

In this article, I'll walk through the implementation of a simple web application framework in Go. I will go through the code step by step and understand how it works. By the end of this article, you'll have a basic understanding of how to build a web application framework using Go.

Note :- This article assumes that you have a knowledge about goroutines, sync package with ReadAndWrite mutex and channels, This part wont be discussed in this article. I will soon be writing an article explaining the use of context, goroutines, channels in the below code seperately.

Introduction

Go is a powerful and efficient language for building web applications. It provides a standard library package called net/http that makes it easy to create web servers. However, building a web application often involves more than just handling HTTP requests and responses. It usually includes routing, middleware, and more.
In this article, we'll create a basic web application framework that includes the following features:

1.Routing.
2.Middleware support.
3.Request and Response handling.
4.Graceful server shutdown.

The Code

We'll start by discussing the main components of our web application framework: app.go, context.go, handlers.go, and routes.go. These files make up the core of our framework.

app.go

package app

import (
    // Imports omitted for brevity
)

// App represents the web application.
type App struct {
    ServerType  string
    Address     string
    Port        int
    Routes      []Route
    middlewares []MiddlewareFunc
    Server      *http.Server
    context     context.Context
    cancel      context.CancelFunc
}

// NewApp creates a new instance of the App struct with the provided configuration.
func NewApp(serverType, address string, port int, appCtx context.Context, cnl context.CancelFunc) *App {
    return &App{
        ServerType: serverType,
        Address:    address,
        Port:       port,
        context:    appCtx,
        cancel:     cnl,
    }
}

// Use adds middleware functions to the application.
func (app *App) Use(middlewareFunc MiddlewareFunc) {
    app.middlewares = append(app.middlewares, middlewareFunc)
}

// Run starts the HTTP server, registers routes, and handles graceful shutdown.
func (app *App) Run() {
    // Server configuration
    app.Server = &http.Server{
        Addr:    fmt.Sprintf("%s:%d", app.Address, app.Port),
        Handler: app,
    }

    // Start listening for incoming connections
    listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", app.Address, app.Port))
    if err != nil {
        log.Fatalf("Failed to start tcp server %v", err)
    }
    defer listener.Close()

    log.Printf("Server running on port %d\n", app.Port)

    // Start the HTTP server in a goroutine
    go func() {
        if err := app.Server.Serve(listener); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server close %v", err)
        }
    }()

    // Register routes
    for _, route := range app.Routes {
        log.Printf("Registered routes with method: %s and path %s\n", route.Method, route.Path)
    }

    // Graceful shutdown handling
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
    <-stop
    app.Shutdown()
}

// Shutdown gracefully shuts down the server.
func (app *App) Shutdown() {
    log.Println("Shutdown gracefully...")
    app.cancel()

    if err := app.Server.Shutdown(app.context); err != nil {
        log.Fatalf("Error during shutdown %v", err)
    }
}

// ServeHTTP handles incoming HTTP requests, routing, and middleware execution.
func (app *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    for _, route := range app.Routes {
        if r.Method == route.Method && r.URL.Path == route.Path {
            if matches, vars := pathMatches(route.Path, r.URL.Path); matches {
                ctx := &Context{
                    Request:        r,
                    ResponseWriter: w,
                    Params:         vars,
                    Context:        app.context,
                }

                // Middleware execution
                finalHandler := route.Handler
                for _, middleware := range app.middlewares {
                    finalHandler = middleware(finalHandler)
                }

                // Additional built-in middleware
                finalHandler = LoggingMiddleware(finalHandler)
                finalHandler = RecoverMiddleware(finalHandler)
                finalHandler = ParseBodyMiddleware(finalHandler)
                finalHandler = DefaultResponseHeaders(finalHandler)
                finalHandler = CompressionMiddleware(finalHandler)

                // Execute the final handler
                finalHandler(ctx)
                return
            }
        }
    }

    // If no route matches, return a 404 response
    http.NotFound(w, r)
}
Enter fullscreen mode Exit fullscreen mode

context.go

package app

import (
    // Imports omitted for brevity
)

// Context represents the request context for each HTTP request.
type Context struct {
    *http.Request
    http.ResponseWriter
    context.Context
    mu          sync.RWMutex
    Key         map[string]interface{}
    Params      map[string]string
    RequestBody interface{}
}

// NewContext creates a new Context instance.
func NewContext() *Context {
    return &Context{}
}

// Set stores key-value pairs in the context.
func (context *Context) Set(key string, value interface{}) {
    context.mu.Lock()
    defer context.mu.Unlock()

    if context.Key == nil {
        context.Key = make(map[string]interface{})
    }

    context.Key[key] = value
}

// parseBody parses the request body based on the content type.
func (context *Context) parseBody() error {
    contentType := context.Request.Header.Get("Content-Type")

    var err error
    var body interface{}

    if context.Request.Method == http.MethodPost || context.Request.Method == http.MethodPut {
        switch {
        case strings.HasPrefix(contentType, "application/json"):
            body, err = parseJSON(context.Request)
        case strings.HasPrefix(contentType, "multipart/form-data"):
            body, err = parseForm(context.Request)
        default:
            return errors.New("unsupported media type")
        }

        if err != nil {
            return err
        }

        context.RequestBody = body
        return nil
    }

    return errors.New("method not supported")
}

// Get retrieves values from the context.
func (context *Context) Get(key string) (value interface{}, exists bool) {
    context.mu.RLock()
    defer context.mu.RUnlock()
    value, exists = context.Key[key]
    return
}

// ShouldGet retrieves values from the context or panics if the value doesn't exist.
func (context *Context) ShouldGet(key string) interface{} {
    if value, exists := context.Get(key); exists {
        return value
    }

    panic(fmt.Sprintf("Value for the %s does not exist\n", key))
}

// SendResponse sends an HTTP response with the given data and status code.
func (context *Context) SendResponse(token string, data interface{}, statusCode int) {
    w := context.ResponseWriter

    if token != "" {
        w.Header().Set("Authorization", "Bearer "+token)
    }

    w.WriteHeader(statusCode)

    responseBytes, err := json.Marshal(data)

    if err != nil {
        log.Fatalf("Error while converting to bytes %v", err)
    }

    if _, err := w.Write(responseBytes); err != nil {
        log.Fatalf("Error while writing response %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

handlers.go

package app

// Handler represents a function that can handle an HTTP request within a specific route.
type Handler func(context *Context)

// MiddlewareFunc represents a function that wraps and enhances the behavior of a Handler.
type MiddlewareFunc func(handler Handler) Handler
Enter fullscreen mode Exit fullscreen mode

routes.go

package app

// Route represents an HTTP route, including the HTTP method, path, and associated Handler.
type Route struct {
    Method  string
    Path    string
    Handler Handler
}

// AddRoutes adds routes to the application.
func (app *App) AddRoutes(method string, path string, handler Handler) {
    app.Routes = append(app.Routes, Route{
        Method:  method,
        Path:    path,
        Handler: handler,
    })
}

// Group allows grouping routes with a common prefix.
func (app *App) Group(prefix string) *RouteGroup {
    return &RouteGroup{
        prefix: prefix,
        app:    app,
    }
}

// RouteGroup represents a group of routes with a common prefix.
type RouteGroup struct {
    prefix string
    app    *App
}

// Group allows further grouping of routes within a RouteGroup.
func (group *RouteGroup) Group(prefix string) *RouteGroup {
    return &RouteGroup{
        prefix: group.prefix + prefix,
        app:    group.app,
    }
}

// AddRoutes adds routes to the RouteGroup.
func (group *RouteGroup) AddRoutes(method string, path string, handler Handler) {
    fullpath := group.prefix + path
    group.app.AddRoutes(method, fullpath, handler)
}
Enter fullscreen mode Exit fullscreen mode

Explaining the Code

Now that we've seen the code, let's dive deeper into each component and understand its role in building our web application framework.

app.go

In this file, we define the main structure of our web application, App. It includes fields for server configuration, such as ServerType, Address, Port, and more. The NewApp function is responsible for creating a new instance of App with the provided configuration. We also have methods for starting the server, registering routes, and handling graceful shutdown.
One important aspect of our App structure is the inclusion of middleware support. Middleware functions are essential for adding features like logging, error handling, and request/response modification. By allowing users to add middleware functions, we make our framework highly extensible and customizable.

context.go
The Context struct represents the request context for each HTTP request. It contains fields for the HTTP request, response writer, and additional data. The parseBody method is used to parse the request body based on the content type, and the SendResponse method sends HTTP responses.
The Context struct simplifies the handling of request-specific data and the parsing of request bodies. This makes it easier for developers to access and manipulate request data within their route handlers. The parseBody will automatically parse the incoming request wether form data on json data and automatically map it to a map[string ]interface{}.
On the other hand we have the context.Set and Get methods set and retrieve instances of some variables we want to set to the context's instance, so we can retrieve it when we require it in the application lifecycle.
I use the store based pattern for my business logics i.e. I mostly bind my strores to the context.
for example.

func (stores *Stores) BindStore(next app.Handler) app.Handler {
 return func(context *app.Context) {
  context.Set("notification_store", stores.NotificationStore)
  context.Set("email_store", stores.EmailStore)
  next(context)
 }
}

func GetStore(ctx *app.Context) *Stores {
 notificationStore, nOk := ctx.ShouldGet("notification_store").(*NotificationStore)

 if !nOk {
  ctx.SendResponse("", customerror.NewError(errors.New("notification store not bound")), http.StatusInternalServerError)
 }

 emailStore, eOk := ctx.ShouldGet("email_store").(*EmailStore)

 if !eOk {
  ctx.SendResponse("", customerror.NewError(errors.New("email store not bound")), http.StatusInternalServerError)
 }

 return &Stores{
  NotificationStore: notificationStore,
  EmailStore:        emailStore,
 }
}
Enter fullscreen mode Exit fullscreen mode

In the code above the BindStore method will act as a Middleware function for the app instance and GetStore with be called in the Endpoint handler to access the store.

handlers.go
This file defines the Handler and MiddlewareFunc types, which are fundamental to the middleware and routing mechanisms. A Handler is a function that handles an HTTP request, and MiddlewareFunc represents a function that enhances the behavior of a Handler.
These types are the building blocks of our middleware and routing system. Middleware functions can be used to perform tasks before or after request handling, such as authentication, logging, or data transformation.

routes.go
Here, we define the Route struct, which represents an HTTP route, including the HTTP method, path, and associated Handler. The AddRoutes method allows us to add routes to the application, and we can group routes with a common prefix using the Group method.
The ability to group routes is valuable for organizing the application's endpoints, especially when building larger web applications with many routes.

for example :-

func ProjectModules(a *app.App) {
 api := a.Group("/api")
 {
  notificationmodule.Routes(api)
  emailmodule.Routes(api)
 }
}
Enter fullscreen mode Exit fullscreen mode
func Routes(g *app.RouteGroup) {

 notifications := g.Group("/notifications")
 {
  notifications.AddRoutes("POST", "/send", SendNotifications)
  notifications.AddRoutes("POST", "/ack", AcknowledgeNotification)
 }
}
Enter fullscreen mode Exit fullscreen mode

In the above example you can see that I would have router file that will have the ProjectModules function, that will import all the routes from the required modules.
In the modules directory I would have all the directories of the modules I have as the business logic inside my application and use the api group logics as shown above in the code. This helps in versioning and seperately your business logic throughout your application.
The app instance also have the instance of router group predefined inside it.

Conclusion

In this detailed exploration of our web application framework's code, we've uncovered the key components and their roles in creating a functional framework. Each file - app.go, context.go, handlers.go, and routes.go-plays a vital role in handling HTTP requests, managing routes, and executing middleware.
Building a web application framework from scratch not only helps you understand the inner workings of web applications but also gives you the flexibility to tailor the framework to your specific project needs. Whether you choose to use an established framework or build your own, this knowledge of the underlying code and concepts is invaluable in your journey as a Go developer.
Now, armed with a deeper understanding of this code, you have the foundation to explore further and build more advanced features and enhancements for your web application framework in Go.

Top comments (0)