pabis.eu

Functionality decoupling into microservices - part 2

12 April 2023

In the previous post we discussed and designed how do we want to split our monolith service into an independent accounts and fruits services. There are multiple approaches to do it. Let's go with the data first approach. Using this technique, we migrate the data first and during code implementation, we can test it on production by making calls to both services and silently logging the comparison of results. However, as a warm-up, let's create an endpoint for generating JWT tokens.

Current version of the code is tagged as 1.1 at GitHub. Code for the version in the previous post is tagged as 1.0.

Creating the JWT endpoint

First, we need to generate a private and public key pair for the server. To do this we will use the following commands. Keep in mind that the length of the key has to be equal or longer than the signing method in JWT. We will use ES512, thus our key has to be at least 512 bits long, so secp521r1 curve fits.

$ openssl ecparam -name secp521r1 -genkey -noout -out server.pem
$ openssl ec -in server.pem -pubout > server.pem.pub

Next it is time to build a function for building the signed tokens. In the new package token, let's make two files config.go that will hold our private key in memory and create.go that will contain the function for building the token.

package token
import "crypto/ecdsa"
var PrivateKey *ecdsa.PrivateKey = nil

The token creation function is very straightforward. We create a map of claims that we first fill with custom parameters and then put all the standard ones, overwriting them if they were already present. Next we just sign the token and return the string (or error).

package token
import (
    "fmt"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

func CreateToken(user int, service string, params map[string]interface{}) (string, error) {
    claims := jwt.MapClaims{}
    for k, v := range params {
        claims[k] = v
    }
    // Overwrite arguments that are standard
    claims["sub"] = fmt.Sprintf("user:%d", user)
    claims["aud"] = fmt.Sprintf("service:%s", service)
    claims["exp"] = time.Now().Add(time.Second * 30).Unix() // expiration: 30 seconds

    token := jwt.NewWithClaims(jwt.SigningMethodES512, claims)
    return token.SignedString(PrivateKey)
}

Creating the Redis cluster and migrating data

Because fruits service, as discussed, will not contain too much data, and a single key, we can use a different database for it. A simple key-value database like Redis would be sufficient. Spinning up a Docker image with Redis is a good and quick option. In AWS there also possibility of ElastiCache as a managed Redis service but it is not the best as permanent storage.

$ docker run --name redis -p 6379:6379 -d redis:latest

So, how do we approach data migration? We need to somehow transform the SQLite queries and output into Redis SET. It is most convenient to use a language SDKs for that. As our service is already implemented in Go, we will create a new function that will be called from the CLI parameter, just like user creation and starting the server.

Let's first define a struct and a function for fetching data from SQLite. We will base it on what is in GetFruits in fruits/read.go. Then we need to adapt the SQL query and the fetching of records. For that a helper function like getUserAndFruit below will be helpful.

// [...]
type userAndFruit struct {
    username string
    fruit    string
}

func getUserAndFruit() (map[int]userAndFruit, error) {
    db, err := sql.Open("sqlite3", config.DbFile)
    [...]
    rows, err := db.Query("SELECT u.id, f.fruit, u.username FROM fruits f JOIN users u ON f.user = u.id")
    [...]
    userFruitMap := make(map[int]userAndFruit)
    for rows.Next() {
        var id int
        var fruit string
        var name string
        rows.Scan(&id, &fruit, &name)
        userFruitMap[id] = userAndFruit{name, fruit}
    }
    return userFruitMap, nil
}

So now after we have a map of users and their favorite fruits mapped by their ID, we can import it to Redis. To keep things simple, each record will be a string separated by :, where the first part is username in base64 encoding (to prevent bugs when user has : in username) and the part after the colon is user's selected fruit. For the key, we will prepend it with user: so that it aligns with the URI scheme 1.

    [...]
    client := redis.NewClient(&redis.Options{
        Addr:     config.RedisEndpoint,
    })
    [...]
    for id, userFruit := range userFruitMap {
        key := fmt.Sprintf("user:%d", id) // id -> "user:id"
        user_b64 := base64.StdEncoding.EncodeToString([]byte(userFruit.username))
        value := fmt.Sprintf("%s:%s", user_b64, userFruit.fruit) // "base64(username):fruit"

        err := client.Set(ctx, key, value, 0).Err()
        if err != nil {
            fmt.Printf("failed to set key %s: %v\n", key, err)
        } else {
            counter++
        }
    }

We can then run the migration by creating a new flag in main.go, browse the database and see that the keys and values are there.

$ docker run --rm -it redis:latest redis-cli -h 172.17.0.2
172.17.0.2:6379> GET user:1
"bWljaGFlbA==:orange"

Dark launch of the new database

To make sure that out databases are equal at all times, we can write to both of them on each UpdateFruit. Fortunately both of them are idempotent so even if our migration from SQLite to Redis is somewhat outdated, we can migrate again, and users changing the data during migration should not overwrite anything.

We will modify UpdateFruit function to write to both databases at once. Ideally, we want our Redis to run on an already prepared separate instance that will hold our microservice or separated from both of the services. Also for GetFruits we want to know if the contents produced by both are the same, so we will be prepared and know how to implement the new microservice in the future.

func UpdateFruit(id int, name string) error {
    // Updates a fruit
    db, err := sql.Open("sqlite3", config.DbFile)
    [...]
    username, err := users.GetUsername(db, id)
    // If we can't get username, don't set the fruit in any database
    if err != nil {
        return err
    }

    if do_update {
        _, err = db.Exec("UPDATE fruits SET fruit = ? WHERE user = ?", name, id)
    } else {
        _, err = db.Exec("INSERT INTO fruits (user, fruit) VALUES (?, ?)", id, name)
    }
    // Only update in Redis when there's no error from SQLite
    // The other way round it's not necessary as we have idempotent migration
    if err == nil {
        // Implementation of this function is similar to the loop in migration part
        err = setInRedis(id, username, name)
    }

    return err
}

In GetFruits we need some way to verify if the data is consistent across the two databases. This functionality will not face the user - it is only for our information. For each row we read from SQLite, we will get the record from Redis and compare. Firstly, we modify the query to also get the ID of the user. Next we will add a function that will silently log to our own standard output information on failures about the data inconsistency. Also for the sake of performance, we will reuse the same client for Redis.

func GetFruits() (map[string]string, error) {
    [...]
    rows, err := db.Query("SELECT u.id, f.fruit, u.username FROM fruits f JOIN users u ON f.user = u.id")
    [...]
    client := redis.NewClient(&redis.Options{
        Addr: config.RedisEndpoint,
    })
    ctx := context.TODO() // Configure Redis variables
    for rows.Next() {
        var id int
        var fruit string
        var name string
        rows.Scan(&id, &fruit, &name)
        fruits[name] = fruit
        validateRecord(client, ctx, id, name, fruit) // Check if the record is consistent
    }
}

func validateRecord(client *redis.Client, ctx context.Context, id int, username string, fruit string) {
    record, err := client.Get(ctx, fmt.Sprintf("user:%d", id)).Result()
    if err != nil {
        log.Printf("ERROR: failed to get record for user %d: %v", id, err)
        return
    }

    splitRecord := strings.Split(record, ":")
    if len(splitRecord) != 2 {
        log.Printf("ERROR: invalid record: %s", record)
        return
    }

    decodedUsername, err := base64.StdEncoding.DecodeString(splitRecord[0])
    if err != nil {
        log.Printf("ERROR: failed to decode username: %v", err)
        return
    }

    if string(decodedUsername) != username || splitRecord[1] != fruit {
        log.Printf("ERROR: mismatched record for user %d: %s", id, record)
        return
    }
}

Now, let's try migrating the database, starting the server, refreshing the page and verifying that there are no errors. Next, we can change the record in Redis and see that error is happening. Migrating again or setting the record again should fix the inconsistency.

$ ./monolith -migrate
# Now corrupt a record in Redis
$ docker run --rm -it redis:latest redis-cli -h 172.17.0.2
172.17.0.2:6379> GET user:3
"bWFyaWFu:pineapple"
172.17.0.2:6379> SET user:3 "bWFya%!@#$WFuxy:pineapple"
OK
172.17.0.2:6379> exit
# Run the server and refresh the page
$ ./monolith -serve
2023/04/10 15:12:30 ERROR: failed to decode username: illegal base64 data at input byte 5

We finally have a draft for data decoupling. So far we still have a single service and still keep a copy in the legacy storage. In the next part, we will be implementing a new microservice that will be responsible only for the management of fruits. Currently, we have all the necessary parts for it: the new database that is decoupled from the rest of the system and a way to connect and authenticate the two services using JWT tokens.


  1. 2. How to Model Microservices: Aggregate in Sam Newman, Building Microservices, page 54.