Testing your app is good and everything, but now it's time to get it running! 😎
The first part we'll handle is setting up the logger, because every step after will use the logger to print out progress and error messages. We'll be using the Zap logger, which you can get with:
$ go get -u go.uber.org/zap
The logging setup code goes into cmd/server/main.go
and looks like this:
cmd/server/main.go// Package main is the entry point to the server. It reads configuration, sets up logging and error handling, // handles signals from the OS, and starts and stops the server. package main import ( "fmt" "os" "go.uber.org/zap" ) func main() { os.Exit(start()) } func start() int { logEnv := getStringOrDefault("LOG_ENV", "development") log, err := createLogger(logEnv) if err != nil { fmt.Println("Error setting up the logger:", err) return 1 } defer func() { // If we cannot sync, there's probably something wrong with outputting logs, // so we probably cannot write using fmt.Println either. So just ignore the error. _ = log.Sync() }() return 0 } func createLogger(env string) (*zap.Logger, error) { switch env { case "production": return zap.NewProduction() case "development": return zap.NewDevelopment() default: return zap.NewNop(), nil } } func getStringOrDefault(name, defaultV string) string { v, ok := os.LookupEnv(name) if !ok { return defaultV } return v }
As always, if you don't feel like assembling the parts yourself, get the remote branch:
$ git fetch && git checkout --track golangdk/start
So what's happening here?
First, you'll notice that the main
function just calls another function. start
returns an integer, which is passed to os.Exit
. Why is that?
os.Exit
exits the program. The integer you pass to it is returned to the operating system. 0
means success, everything else means error. In our app, we'll just use 1
for error states. That's all there is to it.
The first thing we do in start
is call a helper function, getStringOrDefault
, that looks up and returns an environment variable by name, or returns a default value if the variable isn't set. This makes it easy to set sane defaults for configuration values.
The Zap logger has some preconfigured defaults for development and production environments, which we use in the createLogger
helper function.
If the logger errors for some reason, we print an error message to the console using fmt.Println
, and return 1
for the error code. And then, we defer a call to log.Sync
, to make sure that all log messages are output before the program exits.
Remember that we created the Start
and Stop
methods on the Server
? It's time to use them. We need to set up some code that waits for a signal (like pressing a stop button in your IDE, or ctrl+c in the terminal), call Start
, and then wait for the signal before we call Stop
on the server.
First, we need another dependency:
$ go get -u golang.org/x/sync
Then, add this:
cmd/server/main.go// Package main is the entry point to the server. It reads configuration, sets up logging and error handling, // handles signals from the OS, and starts and stops the server. package main import ( "context" "fmt" "os" "os/signal" "strconv" "syscall" "go.uber.org/zap" "golang.org/x/sync/errgroup" "canvas/server" ) func main() { os.Exit(start()) } func start() int { logEnv := getStringOrDefault("LOG_ENV", "development") log, err := createLogger(logEnv) if err != nil { fmt.Println("Error setting up the logger:", err) return 1 } defer func() { // If we cannot sync, there's probably something wrong with outputting logs, // so we probably cannot write using fmt.Println either. So just ignore the error. _ = log.Sync() }() host := getStringOrDefault("HOST", "localhost") port := getIntOrDefault("PORT", 8080) s := server.New(server.Options{ Host: host, Port: port, }) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() eg, ctx := errgroup.WithContext(ctx) eg.Go(func() error { if err := s.Start(); err != nil { log.Info("Error starting server", zap.Error(err)) return err } return nil }) <-ctx.Done() eg.Go(func() error { if err := s.Stop(); err != nil { log.Info("Error stopping server", zap.Error(err)) return err } return nil }) if err := eg.Wait(); err != nil { return 1 } return 0 } // … func getIntOrDefault(name string, defaultV int) int { v, ok := os.LookupEnv(name) if !ok { return defaultV } vAsInt, err := strconv.Atoi(v) if err != nil { return defaultV } return vAsInt }
There's a lot going on here, so let's go through the parts one by one.
When you press ctrl+c in your terminal, or the cloud runtime shuts down the app container, what really happens is that your terminal sends a so-called signal to your app. There are various different signals, but we'll just use the ones called SIGTERM (signal terminate) and SIGINT (signal interrupt). The difference between the two isn't important for our app, and we'll listen for both to stop the app in the same way.
First, we create a new ctx
with signal.NotifyContext
. That function makes sure to cancel the returned context if it receives one of the signals we have asked for.
Then, we create an error group with errgroup.WithContext
. An error group is something that can run functions in goroutines, wait for all the functions to finish, and return any errors. The Context
returned by the errgroup.WithContext
function is cancelled the first time any of the functions return an error, which enables us to shut down immediately if there's an error starting up.
We then call eg.Go
with a small function which starts the server and returns any error. After that, we block until the ctx
from before is cancelled, by reading from the channel returned by ctx.Done
. When it is cancelled (either by a signal or a function passed to eg.Go
returning an error), we call Stop
on our server in another goroutine passed to the error group.
Finally, we wait for all functions passed to the error group to finish. The first of any errors is returned by Wait
, and because we have already logged the errors we want, we simply return 1
in that case.
So basically, an error group is a convenient way to wait for different goroutines to finish. Once Stop
is called on the server, Start
returns immediately. But we don't want to exit the program right away, because we want the current HTTP requests to finish before we do that. Remember that Stop
has a timeout, to enable a graceful shutdown? This means that Stop
only returns after the server has been shut down gracefully, or the timeout has been reached. After Stop
has returned, we tell the error group that we're done, and only then does eg.Wait()
allow the code to proceed to the appropriate return statement.
I know that was quite a complex paragraph. That's because parallel code is complex. If you don't get it, don't worry. Read the code again and try to understand the flow, but if you get stuck, maybe skip this step and come back later. Just remember that this enables your app to do a graceful shutdown.
Did you notice the zap.Error
calls in the log statements? Zap uses something called structured logging, where log messages are not just free-form text, but also have structure. In this case, the log output is a JSON message, where one of the keys is error
with a value of the error message. This makes it easier to machine-parse log messages and, for example, filter out error messages for analysis.
Now that we have set up the logger, we want to pass it along to the Server
struct, so we can use the same logger in the Server
without having to configure it again, or use global variables, which is generally bad practice.
This is called dependency injection (often abbreviated DI), which is a fancy term for taking a dependency (here, the logger) and passing it along to something (here, the server), so the server doesn't have to create the dependency itself.
Let's add the logger to the Server
and Options
structs, and to the New
function. Then we can replace the calls to fmt.Println
in the server code with proper logging:
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" "go.uber.org/zap" ) type Server struct { address string log *zap.Logger mux chi.Router server *http.Server } type Options struct { Host string Log *zap.Logger Port int } func New(opts Options) *Server { if opts.Log == nil { opts.Log = zap.NewNop() } address := net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)) mux := chi.NewMux() return &Server{ address: address, log: opts.Log, 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, }, } } // Start the Server by setting up routes and listening for HTTP requests on the given address. func (s *Server) Start() error { s.setupRoutes() s.log.Info("Starting", zap.String("address", 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 { s.log.Info("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 }
Finally, remember to actually pass the logger in the start
function:
cmd/server/main.go// Package main is the entry point to the server. It reads configuration, sets up logging and error handling, // handles signals from the OS, and starts and stops the server. package main import ( "context" "fmt" "os" "os/signal" "strconv" "syscall" "go.uber.org/zap" "golang.org/x/sync/errgroup" "canvas/server" ) func main() { os.Exit(start()) } func start() int { logEnv := getStringOrDefault("LOG_ENV", "development") log, err := createLogger(logEnv) if err != nil { fmt.Println("Error setting up the logger:", err) return 1 } defer func() { // If we cannot sync, there's probably something wrong with outputting logs, // so we probably cannot write using fmt.Println either. So just ignore the error. _ = log.Sync() }() host := getStringOrDefault("HOST", "localhost") port := getIntOrDefault("PORT", 8080) s := server.New(server.Options{ Host: host, Log: log, Port: port, }) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() eg, ctx := errgroup.WithContext(ctx) eg.Go(func() error { if err := s.Start(); err != nil { log.Info("Error starting server", zap.Error(err)) return err } return nil }) <-ctx.Done() eg.Go(func() error { if err := s.Stop(); err != nil { log.Info("Error stopping server", zap.Error(err)) return err } return nil }) if err := eg.Wait(); err != nil { return 1 } return 0 } // …
Note that there's a nil check in New
for the Options.Log
field, so that we can add a no-op logger if none is passed. That way, we don't have to check if there's a logger every time we want to use it.
The last thing we'll do in this section is prepare for setting a release version automatically. This is going to look a bit weird, because we won't be setting a value in the code itself. It's something that will be done for us in the code compilation in the next section of the course, but we'll prepare for it now.
We will add a global variable before the main
function, and add the release version permanently to our logger. This makes it easy to see which version of the app is outputting what log messages, which is useful for example if you want to check that a bug has been fixed in a particular version.
cmd/server/main.go// Package main is the entry point to the server. It reads configuration, sets up logging and error handling, // handles signals from the OS, and starts and stops the server. package main import ( "context" "fmt" "os" "os/signal" "strconv" "syscall" "go.uber.org/zap" "golang.org/x/sync/errgroup" "canvas/server" ) // release is set through the linker at build time, generally from a git sha. // Used for logging and error reporting. var release string func main() { os.Exit(start()) } func start() int { logEnv := getStringOrDefault("LOG_ENV", "development") log, err := createLogger(logEnv) if err != nil { fmt.Println("Error setting up the logger:", err) return 1 } log = log.With(zap.String("release", release)) defer func() { // If we cannot sync, there's probably something wrong with outputting logs, // so we probably cannot write using fmt.Println either. So just ignore the error. _ = log.Sync() }() // … } // …
So, did it work? Run make start
, and you should see something like this:
$ make start
go run cmd/server/*.go
2024-12-11T04:10:06Z INFO server/server.go:52 Starting {"release": "", "address": "localhost:8080"}
If that's what you're seeing as well, excellent. If not, go back and hunt for bugs, or checkout the finished branch and compare to your changes. In any case, I think you've deserved a coffee now! ☕️
Get help at support@golang.dk.