Angelos Orfanakos

Web server from scratch in Go

I recently started learning Go for the 3rd or 4th time. My previous attempts did not succeed because, without any projects to use it for, I quickly forgot it and lost motivation.

To make this time work, I’ve decided to write code while reading documentation, books, and watching courses.

The first project I decided to implement is a GET-only web/HTTP server. Coming from Ruby, I based my implementation on Marc-André Cournoyer’s Rebuilding a Web Server in Ruby class.

Here is server/server.go:

package server

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"net"
	"net/http"
	"strings"
)

type Env map[string]string

// Part of Rack web server interface
type Response struct {
	Status  int
	Headers map[string]string
	Body    io.Reader
}

// Part of Rack web server interface
// Applications are functions that accept Env and return Response
type App func(Env) *Response

type Server struct {
	Port int
	App
}

func NewServer(port int, app App) *Server {
	return &Server{Port: port, App: app}
}

func (s *Server) Start() error {
	ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.Port))
	if err != nil {
		return err
	}

	for {
		conn, err := ln.Accept()
		if err != nil {
			return err
		}

		c := newConnection(conn, s.App)

		// Process request concurrently
		go c.process()
	}
}

type connection struct {
	net.Conn
	App
}

func newConnection(conn net.Conn, app App) *connection {
	return &connection{Conn: conn, App: app}
}

func (c *connection) process() {
	reader := bufio.NewReader(*c)
	lines := bytes.NewBuffer(nil)

	for {
		data, err := reader.ReadBytes('\n')
		if err != nil {
			panic(err)
		}

		_, err = lines.Write(data)
		if err != nil {
			panic(err)
		}

		// Headers-Body separator
		if string(data) == "\r\n" {
			break
		}
	}

	// Parse HTTP request (part of Go's stdlib)
	req, err := http.ReadRequest(bufio.NewReader(lines))
	if err != nil {
		panic(err)
	}

	// Part of Rack web server interface
	env := make(Env)
	for name, value := range req.Header {
		envName := name
		envName = strings.ToUpper(envName)
		envName = strings.Replace(envName, "-", "_", -1)
		envName = "HTTP_" + envName
		env[envName] = value[0]
	}
	env["PATH_INFO"] = req.URL.Path
	env["REQUEST_METHOD"] = req.Method

	c.respond(env)
	c.close()
}

func (c *connection) respond(env Env) {
	resp := c.App(env) // Run app and store Response
	reason := reasonFromStatus(resp.Status)

	c.Conn.Write([]byte(fmt.Sprintf("HTTP/1.1 %d %s\r\n", resp.Status, reason)))

	for name, value := range resp.Headers {
		c.Conn.Write([]byte(fmt.Sprintf("%s: %s\r\n", name, value)))
	}

	// Headers-Body separator
	c.Conn.Write([]byte("\r\n"))

	scanner := bufio.NewScanner(resp.Body)

	for scanner.Scan() {
		c.Conn.Write([]byte(scanner.Text() + "\n"))
	}
}

func reasonFromStatus(status int) string {
	// A couple of codes for demonstration
	return map[int]string{
		200: "OK",
		404: "Not Found",
	}[status]
}

func (c *connection) close() {
	c.Conn.Close()
}

And main.go that uses it:

package main

import (
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/agorf/fs-webserver/server"
)

const port = 3000

func app(env server.Env) *server.Response {
	if env["PATH_INFO"] == "/sleep" {
		time.Sleep(5 * time.Second)
	}

	message := fmt.Sprintf(
		"Your user agent is %s and you asked for path %s\n",
		env["HTTP_USER_AGENT"],
		env["PATH_INFO"],
	)

	return &server.Response{
		Status: 200,
		Headers: map[string]string{
			"Content-Type":   "text/plain",
			"Content-Length": strconv.Itoa(len(message)),
		},
		Body: strings.NewReader(message),
	}
}

func main() {
	fmt.Printf("Listening on port %d...\n", port)

	s := server.NewServer(port, app)

	err := s.Start()
	if err != nil {
		panic(err)
	}
}

The app we’re serving is very simple, responding with the user agent header and the requested path.

The cool thing about this is that it serves requests concurrently. To verify that, we can run the server in a terminal window:

$ go run main.go
Listening on port 3000...

Open a new terminal window and make a long-lived (5 seconds) request:

$ curl http://localhost:3000/sleep

Open another terminal window and make many short-lived requests, verifying they’re immediately processed while the long-lived request is blocked:

$ curl http://localhost:3000
Your user agent is curl/7.64.0 and you asked for path /
$ curl http://localhost:3000
Your user agent is curl/7.64.0 and you asked for path /
$ curl http://localhost:3000
Your user agent is curl/7.64.0 and you asked for path /

Since I’m a Go newbie, I’d love to have your feedback if you’re more experienced.