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.