It's time for some code!
We will start by building the Server
. It will be responsible for:
You can paste in the code below yourself, or see the finished result in your project by checking out the remote server
branch:
$ git fetch && git checkout --track golangdk/server
Open up the file server/server.go
and add the following below the package declaration:
server/server.go// Package server contains everything for setting up and running the HTTP server. package server import ( "context" "errors" "fmt" "net" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" ) type Server struct { address string mux chi.Router server *http.Server } type Options struct { Host string Port int } func New(opts Options) *Server { address := net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)) mux := chi.NewMux() return &Server{ address: address, mux: mux, server: &http.Server{ Addr: address, Handler: mux, ReadTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, IdleTimeout: 5 * time.Second, }, } } // …
Let's go through the code.
In Go, it's often nice to be able to create structs directly like this: s := &Server{}
. This makes sense when the struct can then be used directly, with good default values. But, sometimes we want to have more complex construction going on, and that's the case here. We're using a popular pattern to create a type using a constructor function. The function is New
, which takes some Options
as the only parameter.
New
turns the host and port pair into an address the server understands (in the form host:port
), and creates a http.Server
using that address and the mux.
Mux is short for multiplexer. The mux is what receives an HTTP request, looks at where it should go, and directs it to the code that should give a response. We are using the popular Chi multiplexer and HTTP router.
Because we're importing an external library, you'll have to go get
it as well before this code compiles. Run this in your project directory to get Chi version 5:
$ go get -u github.com/go-chi/chi/v5
The http.Server
from the standard library has timeouts for reading different parts of HTTP requests, and for writing the HTTP responses. If it takes longer than this, the request is aborted.
By default, no timeouts are set. When dealing with network requests on the internet, it is generally always a good idea to set timeouts, and this is no exception. In that way, we can avoid slow clients (or malicious bots) taking up server resources.
What do the timeouts do? Let's run go doc
and find out (output shortened):
$ go doc http.Server
type Server struct {
// ReadTimeout is the maximum duration for reading the entire
// request, including the body.
ReadTimeout time.Duration
// ReadHeaderTimeout is the amount of time allowed to read
// request headers.
ReadHeaderTimeout time.Duration
// WriteTimeout is the maximum duration before timing out
// writes of the response.
WriteTimeout time.Duration
// IdleTimeout is the maximum amount of time to wait for the
// next request when keep-alives are enabled.
IdleTimeout time.Duration
}
I like to set them all explicitly, because some of them take the values from others when they're not set, and I can never remember which ones. For now, we're hardcoding them to five seconds each. It doesn't matter too much if it's one, five, or ten seconds, the important thing is that there are timeouts at all.
The Server
is the main thing we start up when the app starts, and what we shut down when the app stops. It's the core part of the web app, which talks to all clients and sends them your beautiful web pages as responses.
When the Server
starts, it should set up your HTTP routes on the mux, and start listening on the given host and port for HTTP requests. If an error occurs, for example because something is already listening on that port, it should report the error back to the calling code.
When it stops, we should stop accepting new HTTP requests, but finish existing ones. That way, users of your web app will not see weird error messages when their connection is reset abruptly, but instead never notice that your app was down in the first place. The most typical reason for stopping the web app is when deploying a new version. The in front will switch over from the old app version to the new one, and no-one should notice. Like magic!
Let's add the following code below the New
function:
server/server.go// Package server contains everything for setting up and running the HTTP server. package server // … // Start the Server by setting up routes and listening for HTTP requests on the given address. func (s *Server) Start() error { s.setupRoutes() fmt.Println("Starting on", s.address) if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("error starting server: %w", err) } return nil } // Stop the Server gracefully within the timeout. func (s *Server) Stop() error { fmt.Println("Stopping") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := s.server.Shutdown(ctx); err != nil { return fmt.Errorf("error stopping server: %w", err) } return nil }
Also, add this stub to a new server/routes.go
file:
server/routes.gopackage server func (s *Server) setupRoutes() { }
Start
begins with setting up the routes. We don't have any routes yet, so it doesn't actually set up anything. I like to keep the setupRoutes
function in a separate file, because they change a lot depending on what kind of app you're building. It's nice to keep them separate from the rest of the server code that doesn't change very often.
Next, we print the address we're listening on, and start listening for requests. Note that http.Server.ListenAndServe
always returns a non-nil error, which is http.ErrServerClosed
if everything went well from start to shutdown. We don't want to report that as an error in our app, so we filter that error out using errors.Is
. Any other error is reported back to the calling code.
Stop
sets up a shutdown timeout using the context.WithTimeout
function, so that our graceful shutdown has a time limit. If the limit is reached, the server does a hard exit instead, so we are sure that we can still shut down our app successfully without the OS having to kill it. The 30 seconds here should be enough for that, even if we create long-running code later.
Sweet, let's see if our code works. "But we haven't written any code to start up the server!", you might say to yourself right now, and you would be right. But fear not, we'll reach for one of our most valuable tools to check that our code works: tests!
Open up the file server/server_test.go
and add the following:
server/server_test.gopackage server_test import ( "net/http" "testing" "github.com/matryer/is" "canvas/integrationtest" ) func TestServer_Start(t *testing.T) { integrationtest.SkipIfShort(t) t.Run("starts the server and listens for requests", func(t *testing.T) { is := is.New(t) cleanup := integrationtest.CreateServer() defer cleanup() resp, err := http.Get("http://localhost:8081/") is.NoErr(err) is.Equal(http.StatusNotFound, resp.StatusCode) }) }
Put this into integrationtest/server.go
:
integrationtest/server.gopackage integrationtest import ( "net/http" "testing" "time" "canvas/server" ) // CreateServer for testing on port 8081, returning a cleanup function that stops the server. // Usage: // cleanup := CreateServer() // defer cleanup() func CreateServer() func() { s := server.New(server.Options{ Host: "localhost", Port: 8081, }) go func() { if err := s.Start(); err != nil { panic(err) } }() for { _, err := http.Get("http://localhost:8081/") if err == nil { break } time.Sleep(5 * time.Millisecond) } return func() { if err := s.Stop(); err != nil { panic(err) } } } // SkipIfShort skips t if the "-short" flag is passed to "go test". func SkipIfShort(t *testing.T) { if testing.Short() { t.SkipNow() } }
This time, we're importing github.com/matryer/is
, a minimal testing helper library that makes tests a bit easier to read. We'll be using that throughout the project. As always, go get it with:
$ go get -u github.com/matryer/is
So what's going on? Let's look at the integrationtest
helper package first. The function CreateServer
starts a Server
for us, makes sure that it runs, and returns a function we can call in our tests to stop the server again. Note that because this code is meant for testing, we panic on errors instead of handling them properly. This works quite fine in tests, and makes the test code a bit more readable.
See how we're always starting the server up on the same port, 8081? We could make that an argument to the testing helper function, or perhaps try a random port and see if it works, because this will fail if we run multiple test suites in parallel. But in practice, you will rarely do that: on your machine you will mostly just run the tests one at a time, and in CI your tests will probably have their own virtual machine or container.
The actual testing code is now nice and short. First, we create our testing helper is
, and call the integrationtest.CreateServer
function, deferring the cleanup
call until the test function returns. Then we check that we can GET the root URL of the server, and that it returns a 404 (because we have no handlers yet).
Run your tests and check that they work:
$ make test-integration
go test -coverprofile=cover.out -p 1 ./...
? canvas/cmd/server [no test files]
? canvas/handlers [no test files]
? canvas/integrationtest [no test files]
ok canvas/server 0.118s coverage: 85.7% of statements
Looks like it, yay! Give yourself a pat on the shoulder. You've earned it.
Note that we're calling make test-integration
this time. That's because of the integrationtest.SkipIfShort
call in the test, which makes sure that the test is skipped if the -short
flag is passed to go test
, which is what the regular make test
does. (See the difference in the Makefile
.) This way, we can choose to run our fast tests or the slower integration tests.
Next up is creating your first HTTP handler.
Get help at support@golang.dk.