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)
}
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)
}
}
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
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)
}
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,
}
}
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)
}
}
func Routes(g *app.RouteGroup) {
notifications := g.Group("/notifications")
{
notifications.AddRoutes("POST", "/send", SendNotifications)
notifications.AddRoutes("POST", "/ack", AcknowledgeNotification)
}
}
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)