A Better Way To Write Servers in Go
In Go, the standard way of writing request handlers leads to verbose and repetitive code. In this post, I would like to share my preferred way of writing servers. With this way I can keep my handlers more concise and handle the errors in a central place. Another benefit is to handle dependencies in a more elegant way.
As a disclaimer, this is a combination of ideas from Mat Ryer’s post How I write HTTP services after eight years and the Custom Handler Example from go-chi
library.
The Server Struct
The first concept is from Mat Ryer, which is having a server
struct that we can attach request handlers.
I also store an instance of http.Server
inside this struct, so that I can configure default timeouts.
For convenience, I also add a Start
method to the server to make main.go
cleaner.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
//...
type Server struct {
Db *sql.DB
Config config.Config
httpSrv *http.Server
}
// New Initiates a new server.
func New(conf config.Config) *Server {
s := &Server{
Config: conf,
httpSrv: &http.Server{
Addr: fmt.Sprintf(":%s", conf.Port),
ReadTimeout: conf.ReadTimeout,
WriteTimeout: conf.WriteTimeout,
IdleTimeout: conf.IdleTimeout,
MaxHeaderBytes: conf.MaxHeaderBytes,
},
}
s.SetupRoutes()
return s
}
// Start starts the server.
func (s *Server) Start() error {
log.Printf("Server starting at port %s\n", s.Config.Port)
if err := s.httpSrv.ListenAndServe(); err != nil {
return err
}
return nil
}
|
Request Handlers
To define request handlers, I first define a Handler
type as the go-chi
example suggests.
1
|
type Handler func(w http.ResponseWriter, r *http.Request) error
|
This handler type also implements http.Handler interface.
1
2
3
4
5
6
7
8
9
|
//...
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
// handle returned error here.
w.WriteHeader(503)
w.Write([]byte("bad"))
}
}
|
Here is the full example from one of my projects. Note: I usually use a custom error type to simplify error handling in the server which I’m planning to explain in another post. Without that I would have to check for every error to send a meaningful error message to the client.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// ...
type Handler func(w http.ResponseWriter, r *http.Request) error
// Error describes an error that will be sent to the user.
type Error interface {
Error() string
Code() int
Body() []byte
}
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
e, ok := err.(Error)
logger := logs.GetLogEntry(r)
if !ok {
logger.Errorf("An unexpected error occurred: %v\n", err)
w.WriteHeader(500)
w.Write([]byte(`{"message": "Something went wrong."}`))
return
}
logger.Error(e.Error())
w.WriteHeader(e.Code())
w.Write(e.Body())
}
}
|
Another concept from Mat Ryer is to attach the handlers to the server struct. Here is a request handler example using this method.
1
2
3
4
5
6
7
8
9
10
11
12
|
// ...
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) error {
if err := s.Db.Ping(); err != nil {
return err
}
// check other stuff
w.WriteHeader(200)
return nil
}
|
Combining both ideas, I get two benefits:
- I can just return an error from the handler without worrying about HTTP responses
- I can access the dependencies from the server struct which is an elegant way to manage dependencies
Final step is to register these endpoints. I usually use go-chi
as a router, but it can be done with others the same way. In the end, I assign the chi router to the Handler
property of http.Server
, which will be invoked as the default Mux
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
//...
// SetupRoutes sets up the routes and middlewares.
func (s *Server) SetupRoutes() {
r := chi.NewRouter()
r.Use(corsMiddleware(s.Config.ClientURLS)) // also register middlewares here
// ...
r.Method("GET", "/status", handler(s.handleStatus))
// Auth routes
r.Method("POST", "/register", handler(s.handleRegister))
r.Method("POST", "/login", handler(s.handleLogin))
r.Method("GET", "/me", handler(s.handleMe))
// ...
// Set the default handler to our router
s.httpSrv.Handler = r
}
|
Then I call this function inside the server’s constructor like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// ...
// New Initiates a new server.
func New(conf config.Config) *Server {
s := &Server{
Config: conf,
httpSrv: &http.Server{
Addr: fmt.Sprintf(":%s", conf.Port),
ReadTimeout: conf.ReadTimeout,
WriteTimeout: conf.WriteTimeout,
IdleTimeout: conf.IdleTimeout,
MaxHeaderBytes: conf.MaxHeaderBytes,
},
}
s.SetupRoutes() <--
return s
}
// ...
|
In conclusion, I find this approach much more readable than the traditional way of writing servers. The error handling is in a central location, request handlers are more concise, and I can see all the endpoints by looking at server/routes.go
.
So, there you have it. I hope this was useful to you. Have fun building software.
You can find the complete example here