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.
-
2. How to Model Microservices: Aggregate in Sam Newman, Building Microservices, page 54. ↩