pabis.eu

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:

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.

Fruits microservice graph

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.

Monolith approach

Microservice approach

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.