This is the third instalment of the Go Crash Course. In the first instalment, you learned how to create your first Go application. In the second instalment, you learned how to create a Go application that looked similar to one you'd encounter in the professional world. Today, we'll refactor and improve the code we wrote last time.
Separating the Application Logic
As a reminder, here's what our main.go
file looked like:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
var (
lightbulbs = make(map[string]bool)
)
func main() {
lightbulbs["livingroom"] = false
lightbulbs["kitchen"] = false
http.HandleFunc("/healthcheck", func(responseWriter http.ResponseWriter, request *http.Request) {
responseWriter.Header().Set("Content-Type", "application-json")
responseWriter.WriteHeader(http.StatusOK)
responseWriter.Write([]byte(`{"message":"service is up and running"}`))
})
http.HandleFunc("/lightbulbs", func(responseWriter http.ResponseWriter, request *http.Request) {
responseWriter.Header().Set("Content-Type", "application-json")
responseWriter.WriteHeader(http.StatusOK)
json.NewEncoder(responseWriter).Encode(lightbulbs)
})
http.HandleFunc("/lightbulbs/switch", func(responseWriter http.ResponseWriter, request *http.Request) {
responseWriter.Header().Set("Content-Type", "application-json")
name := request.URL.Query().Get("name")
if name == "" {
responseWriter.WriteHeader(http.StatusBadRequest)
responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
return
}
if _, keyExists := lightbulbs[name]; !keyExists {
responseWriter.WriteHeader(http.StatusNotFound)
responseWriter.Write([]byte(`{"message":"a light bulb with the provided name doesn't exist"}`))
return
}
lightbulbs[name] = !lightbulbs[name]
responseWriter.WriteHeader(http.StatusOK)
json.NewEncoder(responseWriter).Encode(lightbulbs)
})
http.HandleFunc("/lightbulbs/create", func(responseWriter http.ResponseWriter, request *http.Request) {
responseWriter.Header().Set("Content-Type", "application-json")
name := request.URL.Query().Get("name")
if name == "" {
responseWriter.WriteHeader(http.StatusBadRequest)
responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
return
}
if _, keyExists := lightbulbs[name]; keyExists {
responseWriter.WriteHeader(http.StatusBadRequest)
responseWriter.Write([]byte(`{"message":"a lightbulb with the provided name already exists"}`))
return
}
lightbulbs[name] = false
responseWriter.WriteHeader(http.StatusOK)
json.NewEncoder(responseWriter).Encode(lightbulbs)
})
http.HandleFunc("/lightbulbs/delete", func(responseWriter http.ResponseWriter, request *http.Request) {
responseWriter.Header().Set("Content-Type", "application-json")
name := request.URL.Query().Get("name")
if name == "" {
responseWriter.WriteHeader(http.StatusBadRequest)
responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
return
}
if _, keyExists := lightbulbs[name]; !keyExists {
responseWriter.WriteHeader(http.StatusNotFound)
responseWriter.Write([]byte(`{"message":"a lightbulb with the provided name doesn't exist"}`))
return
}
delete(lightbulbs, name)
responseWriter.WriteHeader(http.StatusOK)
json.NewEncoder(responseWriter).Encode(lightbulbs)
})
fmt.Println("http server listening on localhost:8080")
http.ListenAndServe(":8080", nil)
}
While this code works, it's far from great. First off, we need to separate the application logic. As mentioned previously, we can group common functionalities under packages.
So let's create a folder called house
(because we're automating our house). This will be our package for anything house-related. Inside this package, create a file called in_memory_storage.go
. Add this code to the file:
package house
import "errors"
type InMemoryStorage struct {
data map[string]bool
}
func (db *InMemoryStorage) GetAll() ([]Lightbulb, error) {
lbs := []Lightbulb{}
for k, v := range db.data {
lbs = append(lbs, Lightbulb{Name: k, On: v})
}
return lbs, nil
}
func (db *InMemoryStorage) Get(name string) (Lightbulb, error) {
if val, exists := db.data[name]; exists {
return Lightbulb{Name: name, On: val}, nil
}
return Lightbulb{}, errors.New("key not found")
}
func (db *InMemoryStorage) Create(lb Lightbulb) error {
db.data[lb.Name] = lb.On
return nil
}
func (db *InMemoryStorage) Update(lb Lightbulb) error {
db.data[lb.Name] = lb.On
return nil
}
func (db *InMemoryStorage) Delete(name string) error {
delete(db.data, name)
return nil
}
func NewInMemoryStorage() *InMemoryStorage {
return &InMemoryStorage{
data: map[string]bool{},
}
}
Remember the first few lines of the main.go
file? It kind of acted like an in-memory database. We formalized that into our code here. To do so, we created a struct called InMemoryStorage
, which will act as a database (that's why we added some common database operations to it) as long as the process is running.
Let's write a unit test for this code that can serve as an example for other tests. Inside the house
folder, create a file named in_memory_storage_test.go
and add this:
package house
import "testing"
func TestInMemoryStorage_Create(t *testing.T) {
type fields struct {
data map[string]bool
}
type args struct {
lb Lightbulb
}
tests := []struct {
name string
fields fields
args args
wantErr bool
wantCount int
}{
{
name: "empty_data_should_have_one_entry_after_create",
fields: fields{
data: map[string]bool{},
},
args: args{
lb: Lightbulb{
Name: "livingroom",
},
},
wantErr: false,
wantCount: 1,
},
{
name: "if_one_item_exists_data_should_have_two_entries_after_create",
fields: fields{
data: map[string]bool{
"bedroom": false,
},
},
args: args{
lb: Lightbulb{
Name: "living-room",
},
},
wantErr: false,
wantCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := &InMemoryStorage{
data: tt.fields.data,
}
if err := db.Create(tt.args.lb); (err != nil) != tt.wantErr {
t.Errorf("InMemoryStorage.Create() error = %v, wantErr %v", err, tt.wantErr)
}
items, _ := db.GetAll()
itemCount := len(items)
if itemCount != tt.wantCount {
t.Errorf("itemCount %d, wantCount %d", itemCount, tt.wantCount)
}
})
}
}
Creating HTTP handlers
Now that we separated the application logic and created a unit test for it, let's move on to HTTP handlers. Inside the house
folder, create a filed called lightbulb.go
with the following content:
package house
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
var badRequestResponse = []byte(`{"message":"bad request"}`)
var methodNotAllowedResponse = []byte(`{"message":"method not allowed"}`)
var notFoundResponse = []byte(`{"message":"lightbulb not found"}`)
type Storage interface {
GetAll() ([]Lightbulb, error)
Get(name string) (Lightbulb, error)
Create(lb Lightbulb) error
Update(lb Lightbulb) error
Delete(name string) error
}
type Lightbulb struct {
Name string `json:"name"`
On bool `json:"on"`
}
func GetLightbulb(storage Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(methodNotAllowedResponse)
return
}
name := r.URL.Query().Get("name")
if name == "" {
lightbulbs, err := storage.GetAll()
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to getall lightbulbs: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
response := map[string]bool{}
for _, lightbulb := range lightbulbs {
response[lightbulb.Name] = lightbulb.On
}
json.NewEncoder(w).Encode(response)
return
}
lightbulb, err := storage.Get(name)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to get lightbulb: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
json.NewEncoder(w).Encode(lightbulb)
}
}
func CreateLightbulb(storage Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(methodNotAllowedResponse)
return
}
if r.Body == nil {
log.Println("create requires a request body")
w.WriteHeader(http.StatusBadRequest)
w.Write(badRequestResponse)
return
}
var lightbulb Lightbulb
err := json.NewDecoder(r.Body).Decode(&lightbulb)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to create lightbulb: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusBadRequest)
w.Write(badRequestResponse)
return
}
err = storage.Create(lightbulb)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to create lightbulbs: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
json.NewEncoder(w).Encode(lightbulb)
}
}
func SwitchLightbulb(storage Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(methodNotAllowedResponse)
return
}
name := r.URL.Query().Get("name")
lightbulb, err := storage.Get(name)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to get lightbulb: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
lightbulb.On = !lightbulb.On
err = storage.Update(lightbulb)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to update lightbulbs: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
lightbulbs, err := storage.GetAll()
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to getall lightbulbs: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
json.NewEncoder(w).Encode(lightbulbs)
}
}
func DeleteLightbulb(storage Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(methodNotAllowedResponse)
return
}
name := r.URL.Query().Get("name")
err := storage.Delete(name)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to delete lightbulbs: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
}
}
Whew. There's a lot going on here. Let's dissect this step by step.
var badRequestResponse = []byte(`{"message":"bad request"}`)
var methodNotAllowedResponse = []byte(`{"message":"method not allowed"}`)
var notFoundResponse = []byte(`{"message":"lightbulb not found"}`)
type Storage interface {
GetAll() ([]Lightbulb, error)
Get(name string) (Lightbulb, error)
Create(lb Lightbulb) error
Update(lb Lightbulb) error
Delete(name string) error
}
type Lightbulb struct {
Name string `json:"name"`
On bool `json:"on"`
}
This is where we created all the things we'll need. Firstly, we created some variables with fixed response values. We need to allocate these once, after which we can reuse them whenever we need.
Secondly, we created an interface called Storage
that represents the storage our service will depend on. Testing becomes easier when our handlers depend on an interface, because we can mock our dependency.
This also means we can create another storage implementation in the future. We can replace the InMemoryStorage
with whatever we want without much impact on our code at all, because nothing will change for our handlers.
Then we created the Lightbulb
struct to represent our domain entity and, after that, we created our HTTP handlers. We wrapped our handler generation with a function that accepts our dependency (anything that implements the Storage
interface). Let's have a look at this Create
handler:
func CreateLightbulb(storage Storage) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write(methodNotAllowedResponse)
return
}
if r.Body == nil {
log.Println("create requires a request body")
w.WriteHeader(http.StatusBadRequest)
w.Write(badRequestResponse)
return
}
var lightbulb Lightbulb
err := json.NewDecoder(r.Body).Decode(&lightbulb)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to create lightbulb: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusBadRequest)
w.Write(badRequestResponse)
return
}
err = storage.Create(lightbulb)
if err != nil {
msg := fmt.Sprintf("an error occurred while trying to create lightbulbs: %v\n", err)
log.Println(msg)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
return
}
It's pretty straightforward code. The first thing the handler does is set up the response content-type
and check the request method
. After that, it checks if the request body was empty before it decodes the request body to our domain struct, checks for errors, and logs if it finds anything. Then, if all goes well, it encodes its response to the ResponseWriter
.
Let's create a test for this handler to show how you can mock the Storage
. Inside the house
folder, create a file called lightbulb_test.go
and add the following:
package house
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
type MockStorage struct {
err error
lb Lightbulb
lbs []Lightbulb
}
func (m *MockStorage) GetAll() ([]Lightbulb, error) {
return m.lbs, m.err
}
func (m *MockStorage) Get(name string) (Lightbulb, error) {
return m.lb, m.err
}
func (m *MockStorage) Create(lb Lightbulb) error {
return m.err
}
func (m *MockStorage) Update(lb Lightbulb) error {
return m.err
}
func (m *MockStorage) Delete(name string) error {
return m.err
}
func TestCreateLightbulb(t *testing.T) {
type args struct {
storage Storage
r func() *http.Request
}
tests := []struct {
name string
args args
wantStatusCode int
}{
{
name: "create_returns_200_when_all_good",
args: args{
storage: &MockStorage{},
r: func() *http.Request {
req, _ := http.NewRequest(http.MethodPost, "/create", bytes.NewReader([]byte(`{"name":"livingroom"}`)))
return req
},
},
wantStatusCode: http.StatusOK,
},
{
name: "create_returns_500_when_storage_misbehaves",
args: args{
storage: &MockStorage{
err: errors.New("something's wrong"),
},
r: func() *http.Request {
req, _ := http.NewRequest(http.MethodPost, "/create", bytes.NewReader([]byte(`{"name":"livingroom"}`)))
return req
},
},
wantStatusCode: http.StatusInternalServerError,
},
{
name: "create_returns_400_when_request_body_is_invalid",
args: args{
storage: &MockStorage{},
r: func() *http.Request {
req, _ := http.NewRequest(http.MethodPost, "/create", nil)
return req
},
},
wantStatusCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := CreateLightbulb(tt.args.storage)
w := httptest.NewRecorder()
handler(w, tt.args.r())
result := w.Result()
if result.St
First off, we created the MockStorage
struct. As mentioned in the previous Go Crash Course articles, anything that satisfies an interface can be used in its place. In order to turn MockStorage
into a valid Storage
, we need to create the same functions that the interface requires.
But there's a catch. We designed the struct to have properties that represent all the possible return types for the functions. The functions just return those properties. This allows us to create a MockStorage
that returns exactly what we want it to, no matter what.
In the first testcase of this snippet of code, where we're testing for a response status code of 200 if everything goes to plan, we created a MockStorage
that has no error.
In the second testcase, where we're testing for a response status code of 500 if something bad happens while trying to access the storage, we created a MockStorage
that returns error
for all its operations.
In the third testcase, where we're testing for a response status code of 400 if we get a request with an invalid body, we created a MockStorage
that's just fine, but a request
that has an empty body.
A Refactored File
The composition root is the part that glues all this together. The main.go
file should have its environment, dependencies, and wiring set up. Someone reading your codebase should be able to tell what your services depend on and how those dependencies are configured. So here's our refactored main.go
file:
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/julianosouza/go-crash-course/house"
)
func main() {
s := house.NewInMemoryStorage()
router := http.NewServeMux()
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application-json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message":"service is up and running"}`))
return
})
router.HandleFunc("/lightbulbs", house.GetLightbulb(s))
router.HandleFunc("/lightbulbs/create", house.CreateLightbulb(s))
router.HandleFunc("/lightbulbs/switch", house.SwitchLightbulb(s))
router.HandleFunc("/lightbulbs/delete", house.DeleteLightbulb(s))
srv := http.Server{
Addr: ":8080",
WriteTimeout: 1 * time.Second,
ReadTimeout: 1 * time.Second,
Handler: router,
}
fmt.Println("http server listening on localhost:8080")
log.Fatal(srv.ListenAndServe())
}
First off, we create our storage. In this case, we're using the InMemoryStorage
implementation. Then, we create a new router and set up the HTTP handlers.
router.HandleFunc("/lightbulbs", house.GetLightbulb(s))
Notice how our handler bindings are much more readable. At a glance, we're able to see that whenever someone hits /lightbulbs
path, we're going to use the GetLightbulbs
handler for the request that depends on a Storage
implementation.
The final bit is where set up our HTTP server and let it run.
Exercise: play around with the code for a while. Implement some unit tests for the storage and the handler. Try to create a new implementation for the Storage
interface that allows you to handle a real database. Redis
is a good, simple option for this. Try to create a RedisStorage
if you're up for a challenge.
This was the third instalment of our Go Crash Course. In the fourth instalment, we'll learn about logging, monitoring, and building containers. See you there!
TABLE OF CONTENTS