Microservices in Golang, pt2 - REST architecture
We’ve learned how to create the very simple service with Golang and we’ve also restructured the service by using patterns that will use when we move forward.
In this article we’ll look into Restful service, or REpresentational State Transfer that makes it easier for systems to communicate with each other; RESTful systems are characterized by being stateless and separate the concerns of client and server; RESTful service are the most common service architecure in use. A good read regarding the subject (designing REST APIs for HTTP) can be found here .
Briefly, REST defines 6 architectural constraints which make any web service a RESTful API.
Uniform interface - along with the data, the server responses should also announce available actions and resources (what was queried, how the data is structured, how to retrieve related objects, if there is more related data based in the response lenght or pagination). So, REST is defined by four interface constraints: identification of resources; manipulation of resources through representations; self-descriptive messages; and, hypermedia as the engine of application state
Client–server - the implementation of the client and the implementation of the server can be done independently without each knowing about the other
Stateless - the server does not need to know anything about what state the client is in and vice versa
Cacheable - caching is the act of storing the server response in the client, so that a client does not need to make a server request for the same resource repeatedly. A server response should have information about how caching is to be done, so that a client caches the response for a time-period or never caches the server response
Layered system - every layer should have a single high-level purpose and deal only with that, it is about separation of concerns. One of the most common architectures is the so-called 3-tier architecture, where you have data access, business logic and presentation. Services could be added as a separate layer, most common between business logic and presentation.
Code on demand (optional) - this can be ignored for now, since in the old days we had Java applets, etc.
Terminologies#
Resource is an object or representation of an entity, which has some associated data with it and there can be set of methods to operate on. For example Products
, Shops
and Cities
are resources.
Collections are set of resources, for example Products
is the collection of Product
resource.
URL (Uniform Resource Locator) is a path through which a resource can be located and some actions can be performed on it.
Actions is the intended operation to be performed in the resource, such as add, update or delete.
Making requests#
REST requires that a client make a request to the server in order to retrieve or modify data on the server, generaly consiting of:
- an HTTP verb, which defines what kind of operation to perform
- a header, which allows the client to pass along information about the request
- a path to a resource
- an optional message body containing data
HTTP verbs#
GET - the GET method requests a specific resource (by id) or a collection of resources. Requests using GET should only retrieve data
POST - the POST method is used to create a new resource. More specifically submit an entity to the specified resource, often causing a change in state or side effects on the server
PUT - the PUT method updates a specific resource (by id); replacing all current representations of the target resource with the request payload
DELETE - the DELETE request method deletes the specified resource, removes a specific resource by id
HEAD - the HEAD method asks for a response identical to that of a GET request, but without the response body
OPTIONS - the OPTIONS method is used to describe the communication options for the target resource
PATCH - the PATCH method is used to apply partial modifications to a resource
TRACE - the TRACE method performs a message loop-back test along the path to the target resource
When working in systems, you might find that these verbs are not always followed strictly. But this should provide you a guideline to what expect from each of these HTTP requests against a service endpoint.
Headers and parameters#
The HTTP headers are an important part of an service API request and response as they represent the meta-data associated with the service API request and response. Generally, the headers carry information for request or response body, request authorization, response caching, response cookies, and more.
We have to set the request headers when we are sending requests to a service API, and assert against the response to ensure the correct headers are being returned.
For example, a client accessing a resource with id 10 in an Products
resource on a server might send a GET request like this:
GET /products/10
Accept: application/json
The Accept header field in this case says that the client accepts the content in application/json
.
Path#
Requests must contain a path to a resource that the operation should be performed on. A Path comprises an HTTP verb and a URL path that, when exposed, is combined with the base path of the API. For example in the url https://jsonplaceholder.typicode.com/posts/1/comments
, we have the path:
/posts/1/comments
Paths should only contain the information required to locate a resource with the degree of specificity needed. When looking for a list or collection of a resource, the id is not always necessary.
HTTP Status code#
HTTP response status codes indicate whether a specific HTTP request has been successfully completed. Responses are grouped in five classes:
- Informational responses (100–199)
- Successful responses (200–299)
- Redirects (300–399),
- Client errors (400–499)
- Server errors (500–599)
You can find more about it by reading the HTTP response status codes doc.
Enough with theory and let’s get started writing our first REST API service in Golang.
Building a simple REST API#
Hopefully you’ve follow the part 1 , as I’ll be copying the structure, as the base for our REST service.
If your project is already in version control, you can simply run:
go mod init
Or you can supply the module path manually:
go mod init example.com/go-intro-microservices-pt2
This command will create go.mod file which both defines projects requirements and locks dependencies to their correct versions (if you’ve worked with nodejs, it’s similar to package.json and the package-lock.json put together).
It’s recommended to use a module path that corresponds to a repository you plan or will publish your module to, so when you do, go get will be able to automatically fetch, build and install your module. For example you may choose a module path github.com/punkbit/myPackage
, so when you publish your module, everyone can get it by simply using import github.com/punkbit/myPackage
in their app. More on modules can be found in the wiki
.
So, let’s get started by creating a new file main.go
and copy the content from our previously built microservice and keeping the Hello
Handler
only.
If you’re not familiar with the structure provided here as a base, it’s strongly recommended to check the part 1 .
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
"example.com/go-intro-microservices/handlers"
)
func main() {
l := log.New(os.Stdout, "rest-api", log.LstdFlags)
hh := handlers.NewHello(l)
sm := http.NewServeMux()
sm.Handle("/", hh)
s := &http.Server{
Addr: ":9000",
Handler: sm,
IdleTimeout: 120 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
}
go func() {
err := s.ListenAndServe()
if err != nil {
l.Fatal(err)
}
}()
sigChan := make(chan os.Signal)
signal.Notify(sigChan, os.Interrupt)
signal.Notify(sigChan, os.Kill)
sig := <-sigChan
l.Println("Terminate received, gracefully shuttingdown...", sig)
tc, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.Shutdown(tc)
}
Create the directory handlers
from your project root directory where we’ll keep the Handlers
; and the hello.go
file.
mkdir ./handlers && touch ./handlers/hello.go
The content for the hello.go
should be along the lines of:
package handlers
import (
"log"
"net/http"
)
type Hello struct {
l *log.Logger
}
func NewHello(l *log.Logger) *Hello {
return &Hello{l}
}
func (h *Hello) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
h.l.Println("Hello world!")
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message": "hello world"}`))
}
Now, if you run the program:
go run main.go
You can run curl:
curl localhost:9000
And it should output:
{"message": "hello world"}
In the ServeHTTP
method we’ve set the http status code 200
that means success. We se the content type to application/json
, so that the client can understand it’s a particular payload type and compute it correctly.
All good but in case you haven’t realised, but when we sent the request to the service it was sent as HTTP GET
; If we send a HTTP DELETE
request or HTTP PUT
, we’ll have the same json hello world
response. You can test that yourself by running:
curl -X DELETE localhost:9000
Output:
{"message": "hello world"}
We need a type of selection control mechanism to control the flow of the program execution. In the example below we use a switch
statement, but we could use if/else
statements too.
func (h *Hello) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
h.l.Println("Hello world!")
rw.Header().Set("Content-Type", "application/json")
switch r.Method {
case "GET":
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message": "This is a HTTP GET response!"}`))
case "POST":
rw.WriteHeader(http.StatusCreated)
rw.Write([]byte(`{"message": "This is a HTTP POST response!"}`))
case "PUT":
rw.WriteHeader(http.StatusAccepted)
rw.Write([]byte(`{"message": "This is a HTTP PUT response1"}`))
case "DELETE":
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message": "This is a HTTP DELETE response!"}`))
default:
rw.WriteHeader(http.StatusNotFound)
rw.Write([]byte(`{"message": "Not found!"}`))
}
}
If we stop the golang program (our HTTP server) with CTRL+C
, re-run it and execute a curl with -X POST
or any other switch
statement match we get a different json response.
{"message": "This is a HTTP POST response!"}
This far we’ve used net/http
built in methods, but its quite limited. If you want to specify RESTful resources with proper HTTP methods, it is hard to work with the standard http.ServeMux
. Also, for pretty URL paths (not fixed URL), that use variables, you’ll need to implement a custom mux (multiplexer). There are plenty of mux packages available but for the example bellow we’ll use Gorilla Mux
- A powerful HTTP router and URL matcher for building Go web servers.
Add the package
go get -u github.com/gorilla/mux
And modify the code to include it (you’ll notice that its compatible with our net/http
). To keep it short I’ve omited some code.
import (
...
"github.com/gorilla/mux"
)
...
func main() {
...
hh := handlers.NewHello(l)
sm := mux.NewRouter()
sm.Handle("/", hh)
...
}
If you re-run the server and do a GET request with curl you should see the same results as before.
We can now be a bit more specific regarding the HTTP method and assign specific function to each. We also need to use the HandleFunc
, that has a different signature.
In the hello.go
:
package handlers
import (
"log"
"net/http"
)
type Hello struct {
l *log.Logger
}
func NewHello(l *log.Logger) *Hello {
return &Hello{l}
}
func (h *Hello) Get(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message": "This is a HTTP GET response!"}`))
}
func (h *Hello) Post(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusCreated)
rw.Write([]byte(`{"message": "This is a HTTP POST response!"}`))
}
func (h *Hello) Put(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusAccepted)
rw.Write([]byte(`{"message": "This is a HTTP PUT response1"}`))
}
func (h *Hello) Delete(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"message": "This is a HTTP DELETE response!"}`))
}
func (h *Hello) NotFound(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusNotFound)
rw.Write([]byte(`{"message": "Not found!"}`))
}
And finally the main.go
that now has a handler and respective HTTP method assigned to a path.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
"example.com/go-intro-microservices-pt2/handlers"
"github.com/gorilla/mux"
)
func main() {
l := log.New(os.Stdout, "rest-api", log.LstdFlags)
hh := handlers.NewHello(l)
sm := mux.NewRouter()
sm.HandleFunc("/", hh.Get).Methods(http.MethodGet)
sm.HandleFunc("/", hh.Post).Methods(http.MethodPost)
sm.HandleFunc("/", hh.Put).Methods(http.MethodPut)
sm.HandleFunc("/", hh.Delete).Methods(http.MethodDelete)
sm.HandleFunc("/", hh.NotFound)
s := &http.Server{
Addr: ":9000",
Handler: sm,
IdleTimeout: 120 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
}
go func() {
err := s.ListenAndServe()
if err != nil {
l.Fatal(err)
}
}()
sigChan := make(chan os.Signal)
signal.Notify(sigChan, os.Interrupt)
signal.Notify(sigChan, os.Kill)
sig := <-sigChan
l.Println("Terminate received, gracefully shuttingdown...", sig)
tc, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.Shutdown(tc)
}
At this point you must agree that the code is cleaner and easier to read.
If you re-run the server program and do a GET, POST, PUT, etc request you should see the correct response.
Subrouters are also supported and quite easy to do. You should spend some time checking the Gorilla Mux documentation to get to know a bit more.
func main() {
...
sm := mux.NewRouter()
api := sm.PathPrefix("/api/v1").Subrouter()
api.HandleFunc("/", hh.Get).Methods(http.MethodGet)
api.HandleFunc("/", hh.Post).Methods(http.MethodPost)
api.HandleFunc("/", hh.Put).Methods(http.MethodPut)
api.HandleFunc("/", hh.Delete).Methods(http.MethodDelete)
api.HandleFunc("/", hh.NotFound)
...
}
You’d make requests to the same endpoint prefixed with /api/v1
, which would result in the following, for example:
curl -v -X POST localhost:9000/api/v1/
Notice the /
? A bit weird right? You can omit the /
by modifying the path in your HandleFunc
definition as follows:
func main() {
...
sm := mux.NewRouter()
api := sm.PathPrefix("/api/v1").Subrouter()
api.HandleFunc("", hh.Get).Methods(http.MethodGet)
api.HandleFunc("", hh.Post).Methods(http.MethodPost)
api.HandleFunc("", hh.Put).Methods(http.MethodPut)
api.HandleFunc("", hh.Delete).Methods(http.MethodDelete)
api.HandleFunc("", hh.NotFound)
...
}
With the pattern above we could have a /api/v2
at some point in the future, while keeping the v1 alive.
We can also handle path and query parameters, please check the documentation in the Gorilla Mux repository .
A very basic example of that, picked from the Gorilla Mux repository is:
r.HandleFunc("/articles/{category}/", ArticlesCategoryHandler)
...
func ArticlesCategoryHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Category: %v\n", vars["category"])
}
And this is all you need to know about the basic usage. For more advanced options you’ll have to check the documentation.
In the next part, we’ll find out how to Serialize data with encoding/json
.