In this project i try to create an HTTP server from scratch in Go. For this first release I focused on handling simple GET request without body. The full project is available on the GitHubt repository.
Let’s dive into every package.
Http
This package exposes a simple interface that defines an HttpResponse that every handler must implement. It also exposes a template that follows the standard of an HTTP response.
package http
type HttpResponse struct {
StatusCode uint16
Body string
}
var HttpResponseTemplate string = "HTTP/1.1 {statusCode} {message}\r\nContent-Type: text/plain\r\nContent-Length: {ContentLength}\r\n\r\n{body}"
Tree
This package is central for handling the routing of each request to the correct handler.
type Node struct {
Name string
Child []*Node
IsLeaf bool
Handler RouteHandlerFunction
}
The Node
struct allows building a routing tree where each node corresponds to a part of a URL. The
tree structure makes it efficient to match incoming requests with the correct handler based on the path
hierarchy.
It is composed of:
- Name: The name of the current node representing part of a URL path.
- Child []*Node: A slice of pointers to child nodes, enabling hierarchical routing (e.g., /user/profile).
- IsLeaf: Indicates whether this node is a leaf, i.e., a final node representing a complete URL path.
- Handler: A function (of type RouteHandlerFunction) that processes requests when the node is matched.
// Add a node in the tree.
func (tree *Node) AddNode(path []string, handler RouteHandlerFunction) {
if len(path) > 0 {
var i int
for i = 0; i < len(tree.Child); i++ {
if tree.Child[i].Name == path[0] {
break
}
}
if i == len(tree.Child) {
tree.Child = append(tree.Child, &Node{Name: path[0], Child: make([]*Node, 0), IsLeaf: false})
}
tree.Child[i].AddNode(path[1:], handler)
}
tree.Handler = (handler)
tree
}
This function is designed to add a new node (or set of nodes) to the Node tree, representing a URL path, and assign it a route handler.
The function checks if there are still elements left in the path slice. If there are, it tries to match the first part of the path (i.e., path[0]) with the current node’s children.
The loop goes through the existing children (tree.Child
), looking for a child with the same Name
as path[0]
.
If a match is found, the function proceeds to the next child node in the tree. If no match is found, it creates
a new node for path[0]
, and recursively adds the next segment of the path (path[1:]
)
to this new child node.
Once all the path segments are processed, the handler is assigned to the current node using
tree.Handler = handler
.
This method ensures that nodes are added efficiently and can handle paths of arbitrary depth, making it ideal for organizing route handlers in a tree structure.
Example:
Assume you want to route the URL /users/profile:
- The path would be split into
["users", "profile"]
. - The AddNode function would first look for a node named “users”. If it doesn’t exist, it creates it.
- It then recursively looks for “profile” under the “users” node. If not found, it adds it.
- The route handler is then assigned to the “profile” node.
func (tree *Node) Search(path []string) *Node {
if len(path) == 0 && len(tree.Child) == 0 {
return tree
}
var i int
for i = 0; i < len(tree.Child); i++ {
if tree.Child[i].Name == path[0] || tree.Child[i].Name == "*" {
break
}
}
if i == len(tree.Child) {
return nil
}
return tree.Child[i].Search(path[1:])
}
The Search
method breaks down the URL into steps to traverse the tree to find a match. If it
reaches the end with a leaf, it returns the handler function; otherwise, it returns nil.
In summary:
- It checks if the current node is a valid endpoint based on the provided path.
- It looks for a child node that matches the first segment of the path, allowing for wildcard matches.
- It handles the case where no matching child is found by returning nil.
- If a matching child is found, it continues searching recursively down that path.
Routing
This section is responsible for initializing the path tree and assigning the various handler functions defined in the code.
type RouteHandlerFunction func(request string) http.HttpResponse
type Router struct {
tree *Node
}
func New() *Router {
router := Router{}
router.tree = InitTree()
// add routes
router.tree.AddNode(strings.Split("", "/")[:1], func(req string) http.HttpResponse { return http.HttpResponse{StatusCode: 200, Body: ""} })
router.tree.AddNode(strings.Split("/hello", "/")[1:], func(req string) http.HttpResponse { return http.HttpResponse{StatusCode: 200, Body: ""} })
router.tree.AddNode(strings.Split("/echo/*", "/")[1:], func(req string) http.HttpResponse {
pattern := "/[a-zA-Z0-9]+$"
re, err := regexp.Compile(pattern)
if err != nil {
return http.HttpResponse{StatusCode: 400, Body: ""}
}
matches := re.FindStringSubmatch(strings.Split(req, " ")[1])
if len(matches) > 0 {
return http.HttpResponse{StatusCode: 200, Body: strings.ReplaceAll(matches[0], "/", "")}
}
return http.HttpResponse{StatusCode: 400, Body: ""}
})
router.tree.AddNode(strings.Split("/file/*", "/")[1:], func(req string) http.HttpResponse {
pattern := "/[a-zA-Z0-9]+$"
re, err := regexp.Compile(pattern)
if err != nil {
return http.HttpResponse{StatusCode: 404, Body: ""}
}
matches := re.FindStringSubmatch(strings.Split(req, " ")[1])
if len(matches) == 0 {
return http.HttpResponse{StatusCode: 404, Body: ""}
}
basePath, _ := os.Getwd()
basePath += "/assets/"
basePath += matches[0]
data, err := os.ReadFile(basePath)
if err != nil {
log.Printf("Failed reading file %s", err)
return http.HttpResponse{StatusCode: 404, Body: ""}
}
return http.HttpResponse{StatusCode: 200, Body: string(data)}
})
router.tree.PrintTree("")
return &router
}
RouteHandlerFunction
defines the structure of the handler function, and the Router struct defines
the root of the path tree.
The New method initializes the tree with the following routes:
- / : return 200 with no body
- /hello : return 200 with no body
- /echo/* : return 200 with the body being the next string following echo/
- /file/* : rereturn 200 with the body read from a file by the system
func (router *Router) Route(request string) (*RouteHandlerFunction, error) {
path := strings.Split(request, "/")
node := router.tree.Search(path[1:])
if node == nil || !node.IsLeaf {
return nil, errors.New("no route found")
}
return &node.Handler, nil
}
Route splits the URL into different segments and passes it to the tree’s search method, returning the handler if found or throwing an error.
Server
This is the main package that handles the TCP connection.
type Server struct {
listener net.Listener
router *routing.Router
}
func (server *Server) init(port uint16) {
l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
log.Printf("[Server] Server listing on %s\n", fmt.Sprintf("%d", port))
if err != nil {
os.Exit(1)
}
server.listener = l
server.router = routing.New()
}
The Server struct defines the properties of the server, which has a TCP listener and a router. The init method creates a TCP socket listening on the chosen port and initializes the router.
func (server Server) handleConnection(con net.Conn) {
defer con.Close()
req := make([]byte, 1024)
_, err := con.Read(req)
if err != nil {
con.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
}
response, err := server.handleRequestLine(string(req))
if err != nil {
con.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
}
con.Write([]byte(response))
}
The handleConnection accept a new connection from the listener, and takes care of reading the data coming from the TCP connection and translating it into a request-line that will then be passed to the processor. In this version there is no validator that checks if the request-line respects the standard.
The handleConnection
method accepts a new connection from the listener and handles reading the
data coming from the TCP connection. It translates this data into a request line that will then be passed to the
processor. In this version, there is no validation to check whether the request line adheres to the standard.
// A request-line begins with a method token
// followed by a single space (SP)
// the request-target, and another single space (SP)
// and ends with the protocol version
func (server Server) handleRequestLine(request string) (string, error) {
// split line by SP
requestParts := strings.Split(request, " ")
if requestParts[0] != "GET" {
return "HTTP/1.1 404 Not Found\r\n\r\n", nil
}
handler, err := server.router.Route(requestParts[1])
if err != nil {
return "HTTP/1.1 404 Not Found\r\n\r\n", nil
}
return execRouteHandler(handler, request), nil
}
The handleRequestLine
for the Server struct method is responsible for processing the initial line
of an HTTP request, determining how to respond based on the request method and target.
The request line is split into parts using a space (” ”) as the delimiter. This is necessary to separate the HTTP method, request target, and protocol version.After this operation, requestParts will contain:
- requestParts[0]: The HTTP method (e.g., “GET”).
- requestParts[1]: The request target (e.g., the URL path).
- requestParts[2]: The protocol version (e.g., “HTTP/1.1”).
The method calls Route on the server’s router, passing the request target requestParts[1]
. The
router is expected to return a handler function for the specified request target.
handler will contain the function to execute for the matched route.
If the Route
method encounters an error (for example, if no route matches the request target), err
will be non-nil.
If the request method is “GET” and a valid handler is found, the method calls
execRouteHandler(handler, request)
.
This function executes the route handler for the request and constructs the appropriate HTTP response string
based on the handler’s output.
Finally, it returns the generated response and a nil
error.
func main() {
args := os.Args
if len(args[1:]) < 1 {
log.Fatal("Please provide a port")
}
port, err := strconv.ParseInt(args[1], 10, 16)
if err != nil {
log.Fatal(err)
}
log.Printf("[Server] Logs from your program will appear here!\n")
var server Server
server.init(uint16(port))
defer func() {
server.listener.Close()
}()
for {
con, err := server.listener.Accept()
if err != nil {
log.Fatalln("[Server] Error accepting connection: ", err.Error())
os.Exit(1)
}
go server.handleConnection(con)
}
}
The main function initializes a server, listens for incoming connections, and handles each connection in a concurrent manner.
The handleConnection(con)
method of the server is called in a new goroutine
go server.handleConnection(con)
. This allows the server to handle multiple connections concurrently
without blocking.
Each connection is processed in its own goroutine, enabling the server to serve multiple clients at the same time.
Next steps
As a next step, consider extending the server to support additional HTTP methods, improve error handling, and implement request validation to conform to HTTP standards.
Conclusion
In this project, we successfully built a basic HTTP server from scratch using Go. By focusing on handling simple GET requests, we explored essential concepts such as routing, request handling, and server architecture.
We began by defining a simple HttpResponse structure to standardize our server’s responses. Next, we developed a tree-based routing mechanism using the Node struct, which allows for efficient URL path matching. This approach not only facilitates hierarchical routing but also ensures that our server can easily be extended to handle more complex routes in the future.
The Router struct plays a crucial role in initializing the routing tree and assigning handler functions to different URL paths. This separation of concerns enhances maintainability and readability in our code.
Our Server struct handles the TCP connection, reading incoming requests, and providing appropriate responses based on the request line. The use of goroutines allows for concurrent request processing, enabling our server to handle multiple clients simultaneously without blocking.
This foundational project demonstrates the power of Go in building efficient and scalable web servers and serves as a stepping stone for more advanced web development projects.
Thank you for following along with this journey of creating a basic HTTP server in Go. I hope it inspires you to explore further into Go’s capabilities !