Blogg

Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn

Callista medarbetare Erik Lupander

Go Microservices, part 3 - embedded database and JSON

// Erik Lupander

In part 3, we’ll make our Accountservice do something useful.

  • Declare an ‘Account’ struct
  • Embed a simple key-value store that we can store Account structs in.
  • Serialize a struct to JSON and serve over our /accounts/{accountId} HTTP service.

Source code

As in all upcoming parts of this blog series, you can get the complete source code of this part by cloning the source (see part 2) and switching to the P3 branch, i.e:

git checkout P3

Note: Most of the Go source code for the blog series was rewritten in July 2019 to better reflect contemporary idiomatic Go coding guidelines and design patterns. However, the corresponding git branch for each part of the series remains unchanged in order to stay aligned with the content of each installment. For the latest and greatest code, look at the master branch in github.

Declaring an Account struct

For a more elaborate introduction to Go structs, please check this guide.

In our project, create a folder named model under the /accountservice folder.

mkdir model

Now, create a file named account.go in the model folder with the following content:

package model

type Account struct {
        Id string `json:"id"`
        Name string  `json:"name"`
}

This declares our Account abstraction that basically is an id and a name. The case of the first letter denotes scoping (Upper-case == public, lower-case package-scoped). We also use the built-in support for declaring how each field should be serialized by the json.Marshal function in Go.

Embedding a key-value store

For this, we’ll use the BoltDB key-value store. It’s simple, fast and easy to work with. We can actually preempt go get to retrieve the dependency before we’ve declared use of it:

go get github.com/boltdb/bolt

Next, in the /goblog/accountservice folder, create a new folder named “dbclient” and a file named boltclient.go. To make mocking easier later on, we’ll start by declaring an interface that defines the contract we need implementors to fulfill:

package dbclient

import (
        "github.com/callistaenterprise/goblog/accountservice/model"
)

type IBoltClient interface {
        OpenBoltDb()
        QueryAccount(accountId string) (model.Account, error)
        Seed()
}

In the same file, we’ll provide an implementation of this interface. Start by declaring a struct that encapsulates a pointer to a bolt.DB instance.

// Real implementation
type BoltClient struct {
        boltDB *bolt.DB
}

Here is the implementation of OpenBoltDb(). We’ll add the two remaining functions a bit further down.

func (bc *BoltClient) OpenBoltDb() {
        var err error
        bc.boltDB, err = bolt.Open("accounts.db", 0600, nil)
        if err != nil {
                log.Fatal(err)
        }
}

This part of Go syntax can feel a bit weird at first, where we bind a function to a struct. Our struct now implicitly implements one of the three methods.

We’ll need an instance of this “bolt client” somewhere. Let’s put it where it’s going to be used, in /goblog/accountservice/service/handlers.go. Create that file and add the instance of our struct:

  package service
  
  import (
          "github.com/callistaenterprise/goblog/accountservice/dbclient"
  )
  
  var DBClient dbclient.IBoltClient

Update main.go so it’ll open the DB on start:

func main() {
        fmt.Printf("Starting %v\n", appName)
        initializeBoltClient()                 // NEW
        service.StartWebServer("6767")
}

// Creates instance and calls the OpenBoltDb and Seed funcs
func initializeBoltClient() {
        service.DBClient = &dbclient.BoltClient{}
        service.DBClient.OpenBoltDb()
        service.DBClient.Seed()
}

Our microservice should now create a database on start. However, before running we’ll add a piece of code that’ll bootstrap some accounts for us on startup.

Seed some Accounts on startup

Open boltclient.go again and add the following functions:

// Start seeding accounts
func (bc *BoltClient) Seed() {
        initializeBucket()
        seedAccounts()
}

// Creates an "AccountBucket" in our BoltDB. It will overwrite any existing bucket of the same name.
func (bc *BoltClient) initializeBucket() {
        bc.boltDB.Update(func(tx *bolt.Tx) error {
                _, err := tx.CreateBucket([]byte("AccountBucket"))
                if err != nil {
                        return fmt.Errorf("create bucket failed: %s", err)
                }
                return nil
        })
}


// Seed (n) make-believe account objects into the AcountBucket bucket.
func (bc *BoltClient) seedAccounts() {

        total := 100
        for i := 0; i < total; i++ {

                // Generate a key 10000 or larger
                key := strconv.Itoa(10000 + i)

                // Create an instance of our Account struct
                acc := model.Account{
                        Id: key,
                        Name: "Person_" + strconv.Itoa(i),
                }

                // Serialize the struct to JSON
                jsonBytes, _ := json.Marshal(acc)

                // Write the data to the AccountBucket
                bc.boltDB.Update(func(tx *bolt.Tx) error {
                        b := tx.Bucket([]byte("AccountBucket"))
                        err := b.Put([]byte(key), jsonBytes)
                        return err
                })
        }
        fmt.Printf("Seeded %v fake accounts...\n", total)
}

For more details on the Bolt API and how the Update method accepts a func that does the work for us, see the BoltDB documentation.

We’re done with the BoltDB part for now. Let’s build and run again:

> go run *.go
Starting accountservice
Seeded 100 fake accounts...
2017/01/31 16:30:59 Starting HTTP service at 6767

Lovely! Stop it using Ctrl+C.

Adding a Query method

Now we finish our little DB API by adding a Query method to the boltclient.go:

func (bc *BoltClient) QueryAccount(accountId string) (model.Account, error) {
        // Allocate an empty Account instance we'll let json.Unmarhal populate for us in a bit.
        account := model.Account{}

        // Read an object from the bucket using boltDB.View
        err := bc.boltDB.View(func(tx *bolt.Tx) error {
                // Read the bucket from the DB
                b := tx.Bucket([]byte("AccountBucket"))

                // Read the value identified by our accountId supplied as []byte
                accountBytes := b.Get([]byte(accountId))
                if accountBytes == nil {
                        return fmt.Errorf("No account found for " + accountId)
                }
                // Unmarshal the returned bytes into the account struct we created at
                // the top of the function
                json.Unmarshal(accountBytes, &account)

                // Return nil to indicate nothing went wrong, e.g no error
                return nil
        })
        // If there were an error, return the error
        if err != nil {
                return model.Account{}, err
        }
        // Return the Account struct and nil as error.
        return account, nil
}

Follow the comments if the code doesn’t make sense. The function will query the BoltDB using a supplied accountId parameter and will return an Account struct or an error.

Serving the Account over HTTP

Let’s fix the /accounts/{accountId} route we declared in /service/routes.go so it actually returns one of the seeded Account structs. Open routes.go and replace the inlined func(w http.ResponseWriter, r *http.Request) { with a reference to a function GetAccount we’ll create in a moment:

Route{
        "GetAccount",             // Name
        "GET",                    // HTTP method
        "/accounts/{accountId}",  // Route pattern
        GetAccount,
},

Next, update /service/handlers.go with a GetAccount func that fulfills the HTTP handler func signature:

var DBClient dbclient.IBoltClient

func GetAccount(w http.ResponseWriter, r *http.Request) {

	// Read the 'accountId' path parameter from the mux map
	var accountId = mux.Vars(r)["accountId"]

        // Read the account struct BoltDB
	account, err := DBClient.QueryAccount(accountId)

        // If err, return a 404
	if err != nil {
		w.WriteHeader(http.StatusNotFound)
		return
	}

        // If found, marshal into JSON, write headers and content
	data, _ := json.Marshal(account)
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Length", strconv.Itoa(len(data)))
	w.WriteHeader(http.StatusOK)
	w.Write(data)
}

The GetAccount func fulfills the handler func signature so when Gorilla detects a call to /accounts/{accountId} it will route the request into the GetAccount function. Let’s run it!

> go run *.go
Starting accountservice
Seeded 100 fake accounts...
2017/01/31 16:30:59 Starting HTTP service at 6767

Call the API using curl. Remember, we seeded 100 accounts starting with an Id of 10000.

> curl http://localhost:6767/accounts/10000
{"id":"10000","name":"Person_0"}

Nice! Our microservice is now actually serving JSON data from an underlying store over HTTP.

Footprint and performance

Let’s check the same memory and CPU usage metrics as in part 2: Before, during and after our simple Gatling-based load test.

Memory usage after startup

mem use

2.1 mb, still not bad! Adding the embedded BoltDB and some more code to handle routing etc. added 300kb to our initial footprint. Let’s start the Gatling test running 1K req/s. Now we’re actually returning a real Account object fetched from the BoltDB which also is serialized to JSON:

Memory usage after load test

mem use2 31.2 mb of RAM. The extra overhead of serving 1K req/s using an embedded DB was really small compared to the naive service from Part 2.

Performance and CPU usage

cpu use Serving 1K req/s uses about 10% of a single Core. The overhead of the BoltDB and JSON serialization is not very significant, good! By the way - the java process at the top is our Gatling test which actually uses ~3x the CPU resources as the software it is testing.

performance Mean response time is still less than one millisecond.

Perhaps we should test with a heavier load, shall we say 4K req/s? (Note that one may need to increase the number of available file handles on the OS level):

Memory use at 4K req/s

mem use Approx 120 mb. Almost exactly an increase by 4x. This memory scaling with n/o concurrent requests is almost certainly due to the Golang runtime or possibly Gorilla increasing the number of internal goroutines used to serve requests concurrently as load goes up.

Performance at 4K req/s

cpu use CPU use stays just below 30% at 4K req/s. At this point, i.e. running on a 16 GB RAM / Core i7 equipped laptop, I’d say that IO or file handles would bottleneck sooner than available CPU cycles.

performance Mean latency now finally rises above 1 ms with 95% of requests staying below 3ms. We do see latency starting to take a hit at 4K req/s, though I’d personally say that the little Accountservice with its embedded BoltDB performs really well.

Comparison to other platforms

One could probably write an interesting blog post about benchmarking this “accountservice” against an functionally equivalent microservice implemented on the JVM, NodeJS, CLR and others.

I did some naive inconclusive benchmarking (using a Gatling test) on this myself late 2015 comparing a HTTP/JSON service + MongoDB access implemented in Go 1.5 vs Spring Boot@Java 8 and NodeJS. In that particular case the JVM and Go-based solutions scaled equally well with a slight edge to the JVM-based solution regarding latencies. The NodeJS server performed quite similarly to the others up to the point where the CPU utilization reached 100% on a single core and things started going south regarding latencies.

Please don’t take the benchmarking mentioned above as some kind of fact as it was just a quick and dirty thing I did for my own pleasure.

So while the numbers I’ve shown regarding performance at 4K req/s using Go 1.7 for the “accountservice” may seem very impressive, they can probably be matched by other platforms as well, though I doubt their memory use will be as pleasant. I guess your milage may vary.

Final words

In the next part of this blog series we’ll take a look at unit testing our service using GoConvey and mocking the BoltDB client.

Tack för att du läser Callistas blogg.
Hjälp oss att nå ut med information genom att dela nyheter och artiklar i ditt nätverk.

Kommentarer