How I write Go (HTTP) Services (Part 1)

Note on Opinions

Just a heads up – these opinions are all mine!

Source

The source code used throughout this series can be found here: https://github.com/anthoturc/token-service/.

Yet Another Go HTTP Servers Article

Yep, you guessed it! This is “yet another” but hey, I thought I’d share my approach anyway. I find that some important pieces get left out of out blogs that talk about programming HTTP servers in Go. For instance, this Grafana blog is thorough and covers similar content but it doesn’t talk about how to deploy or tracing. Oh, and there’s another one by Cloudflare that hits on the same general idea and is missing some extra information. If you are reading this, definitely check out those articles. They are great! I was hoping to cover similar topics and go all the way to deployment.

Now, articles like these dive deep into the nitty-gritty of writing an HTTP server, and that’s totally fine! After all, entire books have been dedicated to programming networked services. But there are some aspects, like configuration management, graceful shutdown, and wiring up dependencies before kicking off the service, that often don’t get the spotlight. So, I’m here to shed some light on these crucial elements of writing HTTP services.

Table Of Contents

Open Table Of Contents

The Entrypoint

Let’s kick things off with the entry point to my application – main.go. Ideally, everything the application does can be traced back to a line in the main function of this file. Now, the filename doesn’t necessarily have to be main.go. What’s more important is that it contains the package main declaration and a func main() {...}.

I did use “Entrypoint” on purpose. The reason is that the generated binary will also be the entry point of a Docker container.

Here is a sample main.go file for your reference:

package main

func main() {
  //TODO: All the things!!
}

HTTP Scaffolding

Let’s start with defining our HTTP server. So far we are only using the standard library!

package main

import (
  "net/http"
  "log"
)

func main() {
  log.SetFlags(log.LstdFlags | log.Lshortfile)

  server := http.Server{
    Addr: ":8080",
    Handler: http.DefaultServeMux,
  }

  log.Fatal(server.ListenAndServe())
}

If you run this in a terminal, you should see your process run successfully! There are a few problems with this setup that the linked articles address.

More Server Config 💾

So.. we don’t have any read timeouts configured. That might not be a big deal on your computer’s network or your home network, but the internet is a wild place and not all clients behave! By that I mean, your server is too generous with its time. For example, if a client takes forever to finish sending the headers in a given HTTP request, then the server will just sit there waiting. On the flip side, the server might take forever to actually process the request and the connection will remain intact. In this case, forever is on the order of minutes.

Let’s fix that by setting up some timeouts!

package main

import (
  "net/http"
  "log"
  "time"
)

func main() {
  log.SetFlags(log.LstdFlags | log.Lshortfile)

  server := http.Server{
    Addr: ":8080",
    Handler: http.DefaultServeMux,
    ReadTimeout: 15 * time.Second,
    WriteTimeout: 30 * time.Second,
  }

  log.Fatal(server.ListenAndServe())
}

Phew - that is better! The ReadTimeout is related to an incoming connection and the WriteTimeout is related to the time after the request’s headers and body have been completely read. If you think that 15 and 20 seconds are generous, you are totally right! But that is the absolute worst case timing. You’d need to configure those values based on your application’s need.

Graceful shutdown

It would be helpful if the server was able the application was able to shutdown the server more gracefully. Right now you would need to stop the process via a signal (e.g. an interrupt) to actually stop it. That isn’t great though and can definitely lead to your callers seeing 5xx more often than not.

A common pattern for having graceful shutdown is to handle the signal and have the application take some steps to clean itself up. Here is an example of that:

package main

import (
  "context"
  "net/http"
  "log"
  "time"
  "os"
  "os/signal"
  "syscall"
)

func main() {
  log.SetFlags(log.LstdFlags | log.Lshortfile)

  server := http.Server{
    Addr: ":8080",
    Handler: http.DefaultServeMux,
    ReadTimeout: 15 * time.Second,
    WriteTimeout: 30 * time.Second,
  }

  shutdown = make(chan os.Signal, 1)
  signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)

  go func() {
    if err := server.ListenAndServe(); err != http.ErrServerClosed {
		  log.Fatal(err)
    }
  }()

  log.Println("starting service and waiting for shutdown signal")
  <-shutdown
  log.Println("shutting down now")
  if err := server.Shutdown(context.Background()); err != nil {
    log.Fatalf("failed to shutdown server: %v", err)
  }
  log.Println("shutdown complete")
}

And just like that, we can handle cleaning up the server. This doesn’t mean we are completely done with the cleanup work. However, this implementation is a pretty good place to be. server.Shutdown will gracefully stop the server from accepting connections while handling existing ones. You would still need to clean up any other resources. Database connections, files, etc.

Configuration

This is a pretty small application, but a big theme in programming is to try and reduce magic values and constants. E.g. the ReadTimeout on the server is 15 * time.Second. Why 15? Imagine if the server setup was cast to some package other than main (pretty common today). In that case 15 sticks out like a sore thumb. Another good example is the address that the server is listening on. In this case ":8080". In general, you would want to keep static configuration separate from you application logic or at least grouped together in a place that makes sense. We only laucnhed one server, but a given machine has way more ports than 8080, you could write a program that spins up 10 or 15 or 30 servers on the same machine. There are more than enough ports to support it.

Enter AppConfig

This has been a long post so if you made it this far, thank you!

So there are lots of ways to manage configuration and you can introduce third party libraries that handle all of it for you. I know Viper is fairly popular but I haven’t used it yet and for most of my applications I don’t want to introduce the dependency when ym configuration needs are pretty simple. Another option is to pass all your coniguration via command line arguments and populate the values with environment variables.

Personally, I prefer an approach where the coniguration is loaded from a file at the start of the program. I am going to break this next portion into pieces.

I am going to start off with what I want my config file to look like. I am storing this file in the coniguration/ directory within the root of the module (i.e. the same level as go.mod).

# configuration/dev.yml
---
server:
  address: "localhost"
  port: 8000
  timeout:
    read: 15
    write: 30
db:
  host: "localhost"
  port: 5432
  user: "user"
  password: "admin"
  name: "my_db_name"
  sslMode: "disabled"

I am opting into the convention that the name of the file will correspond to the environment the application will run in. I didn’t talk too much about configuration a database but the one bove under db corresponds to the inputs you need to connect to Postgres.

If I wanted to translate this to Go I will need to define a few structs and pick a library to work with the yaml — I wouldn’t want to write that YAML parsing logic myself. Neither should you because someone already did it! Check out https://pkg.go.dev/gopkg.in/yaml.v3.

Note: You don’t need to use YAML. You could use toml, Json, .env or whatever floats your boat. I have to work with YAML a lot in my day job so I use it is my default. Although I do really like when I can use a library to transpile the YAML. Imagine being able to write that definition in code and having the code generate the YAML file as output! A good example is the AWS CDK.

Here is the mapping of my coniguration file to Go structs. It is tedious to define each field but once it’s done this kind of static coniguration shouldn’t change drastically and it is easy to update this because there is a 1:1 mapping between each field in the YAML file and the structs here.

// config.go
package main

type TimeoutConf struct {
  Read uint `yaml:"read"`
  Write uint `yaml:"write"`
}

type ServerConf struct {
  Port uint16 `yaml:"port"`
  Address string `yaml:"address"`
  TimeoutConf `yaml:"timeout"`
}

type DbConf struct {
  Host     string `yaml:"host"`
  Port     uint16 `yaml:"port"`
  User     string `yaml:"user"`
  Password string `yaml:"password"`
  Name     string `yaml:"name"`
  SslMode  string `yaml:"sslMode"`
}

type AppConf struct {
  ServerConf `yaml:"server"`
  DbConf `yaml:"db"`
}

So far so good. Let’s write a little bit of code to parse these structres. In this case, we just need a function — NewAppConf — to return a valid instance of AppConf (or an error if something went wrong). That should be simple enough. If the caller provides the path to the configuration file, then we read the entire file and unmarshal the data using the package I linked above. Easy.

// config.go
package main

import (
  "path"
  "fmt"
  "os"

	"gopkg.in/yaml.v3"
)

// ... Snip ...

func NewAppConf(pathToConf string) (AppConf, error) {
  data, err := os.ReadFile(pathToConf)
  if err != nil {
    return AppConf{}, fmt.Errorf("unmarshal source - failed to read %s: %v", pathToConf, err)
  }

  var conf AppConf
  err = yaml.Unmarshal(data, &conf)
  if err != nil {
    return AppConf{}, fmt.Errorf("unmarshal source - failed to unmarshal data in %s: %v", pathToConf, err)
  }
  return conf, nil
}

At this point we just need to call this function in our main.go and we will have our coniguration loaded!

package main

import (
  "context"
  "net/http"
  "log"
  "time"
  "os"
  "os/signal"
  "syscall"
)

func main() {
  log.SetFlags(log.LstdFlags | log.Lshortfile)

  conf, err := NewAppConf(path.Join(".", "configuration", "dev.yml"))
  if err != nil {
    log.Fatalf("failed to retrieve app config: %v", err)
  }

  address := fmt.Sprintf("%s:%d", conf.ServerConf.Address, conf.ServerConf.Port)

  server := http.Server{
    Addr: address,
    Handler: http.DefaultServeMux,
    ReadTimeout: time.Duration(conf.ServerConf.TimeoutConf.Read) * time.Second,
    WriteTimeout: time.Duration(conf.ServerConf.TimeoutConf.Write) * time.Second,
  }

  // ... Snip ...
}

I didn’t include anything related to the database configuration but you could do that!

Conclusion

In this post we covered:

  1. Setting up HTTP servers and some basic configuration for them
  2. The pitfalls with default HTTP server configuration
  3. Setting up static configuration management

The next few posts in this series will cover how to go further. E.g. setting up logging, tracing, Docker-izing the application and deploying it!