Functionality decoupling into microservices - part 3
22 April 2023
In today's post we will focus on splitting the code from the monolith into an actual separate service. Previously we have already prepared the new database (Redis) and migrated the table from the old SQLite database.
Code for the monolith version in the previous post is tagged as
1.1
on GitHub.
The new microservice code can
be found here
tagged as 1.0
.
Previous parts:
Copying from the monolith
Let's init a new repository and Go module. We also need to import the needed packages to the new project - Redis and JWT.
$ mkdir fruits-microservice
$ cd fruits-microservice
$ git init
$ go mod init fruits_microservice
$ go get github.com/redis/go-redis/v9
$ go get github.com/golang-jwt/jwt/v5
Firstly, we can copy the whole fruits
directory to our new project. Then we
need to adapt the code, make it use only Redis and remove all references to
SQLite. All the changes can be found in the new fruits
package of the new
project here.
As said previously, we will also add a possibility to retrieve a fruit of a
single user. validateRecord
function from previous repository can be adapted
to become parseRecord
.
Next we want to also move the check if the fruit is special. Just copy and paste
isFruitSpecial
and rename it to IsFruitSpecial
to make it public. We will
use it later from another package.
Config will hold our Redis endpoint configuration and a public key for JWT
verification. It is based on the original config
package,
see it here.
Service endpoint
We will create a similar router to the one in the monolith in router/serve.go
.
Let's add validation of the request in this file as well. To make it leaner and
more readable, we will create a new function validateMethod
that will check
the request against the list of valid HTTP verbs. The three possible endpoints
are:
GET /fruit/:id
- that will return fruit of user withid
POST, PUT /fruit
- update the fruit of the userGET /
- return a list of all user-fruit pairs
We will also authenticate user in the fruit
endpoint, to not continue if there
is no JWT token in the headers.
func Serve(port int) error {
mux := http.NewServeMux()
mux.HandleFunc("/fruit/", user)
mux.HandleFunc("/fruit", fruit)
mux.HandleFunc("/", root)
return http.ListenAndServe(":"+strconv.Itoa(port), mux)
}
func validateMethod(w http.ResponseWriter, r *http.Request, allowed ...string) bool {
// Returns true if method is allowed, false otherwise
// And prints 405 Method Not Allowed in text/plain
}
func fruit(w http.ResponseWriter, r *http.Request) {
// handles URL: /fruit with POST and PUT - updates user's fruit
if !validateMethod(w, r, "POST", "PUT") {
return
}
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Failed to parse form"))
return
}
// TODO: implement in the next section
token := auth.Authenticate(w, r)
if token == nil {
return
}
err = updateFruit(r.Form, token)
if err != nil {
// ... Check the type of error and return appropriate status code
}
w.WriteHeader(http.StatusCreated)
}
func user(w http.ResponseWriter, r *http.Request) {
// handles URL: /fruit/:id - gets single user's fruit
if !validateMethod(w, r, "GET") {
return
}
getByUser(w, r)
}
func root(w http.ResponseWriter, r *http.Request) {
// handles URL: / - returns all fruits
if !validateMethod(w, r, "GET") {
return
}
getAllFruits(w)
}
Authentication
Authentication is just the process of verifying the identity of a user. What we will do here is to check if the signature of the token matches and if the token contains required fields. Public key is needed to verify the signature.
func Authenticate(w http.ResponseWriter, r *http.Request) *jwt.Token {
token_str := r.Header.Get("X-Auth-Token")
if token_str == "" {
// Error out
}
token, err := jwt.Parse(token_str, func(t *jwt.Token) (interface{}, error) {
return config.PublicKey, nil
}, jwt.WithAudience("service:fruits"), jwt.WithValidMethods([]string{jwt.SigningMethodES512.Name}))
f err != nil || !token.Valid {
// Error out with 401
}
if user, err := token.Claims.(jwt.MapClaims).GetSubject(); err != nil || user == "" {
// Error out with 401
}
return token
}
Authorization
Authorization happens inside updateFruit
function. We will check if the user
can set the requested fruit based on their super
status. Also check of the
formatting of the subject field can be done in this step. The snippet for this
procedure is very simple:
super := token.Claims.(jwt.MapClaims)["super"].(bool)
if fruits.IsFruitSpecial(form.Get("fruit")) && !super {
return fmt.Errorf("this user is not allowed to set special fruit")
}
...
subject, err := claims.GetSubject()
if err != nil || subject == "" || !strings.HasPrefix(subject, "user:") {
return fmt.Errorf("invalid subject, must refer to a user")
}
Both code for authorization and authentication with tests included can be viewed
here fruits-microservice/auth
.
Getters
Both getByUser
and getAllFruits
are similar. They both write JSON into the
ResponseWriter
or set the status to 500 in case of some failure. getByUser
can also, naturally, return 404 if the fruit for the user is not set. The JSON
body contains a set of two keys: username
and fruit
or an array of those in
case of getAllFruits
.
This file
contains the code for both of the functions.
Setters
updateFruit
function
is just as simple as the previous two. It just accepts a URL-encoded form and a
JWT token to know for which user to set the fruit for. Authentication is not
needed here because we already covered it inside the serve.go
file of the
router
.
Complete microservice
Lastly, the only file crucial to make our work work is main.go
that will
simply run the server. Put router.Serve(8081)
and we have the service ready to
accept connections. But it is not usable by the end user. All the UI remains in
the original monolith. This will be the topic of the next article.
To fully understand what we did in this post, refer to the image below. It shows all the main components of this microservice. Although the caller chain might be different, all the ideas are contained within those boundaries.
The complexity is somewhat similar to the monolith diagram, however, if we wanted to extend the service for another functions, like favorite vegetable and favorite bread management, mixed with user and account management, and a functionality to list which bakeries have the favorite bread, and update where users can specify if they like the vegetable raw or not... You get it, the graph will grow very quickly and become an untangled mess. With microservice approach, we can focus on one functionality and easily draw all the components on a single page and easily point which code to update. With the monolith, we would need to deploy it entirely and do database migrations.
Each of the microservices on the microservice graph look just as simple as the fruits microservice graph.
Finishing touches - tests
In order to verify that our microservice works, we can implement tests for it.
I implemented some integration tests
in the test
directory.
It uses dockertest
package to spin up a clean Redis container. Moreover, some
files contain unit tests but the most interesting one
is end-to-end test.
End-to-end is used loosely here - it does not involve the actual user behavior
but it covers the whole service outside of boundaries and several scenarios, one
after another. It is similar to the integration test but uses actual HTTP calls
to simulate external user or agent.