Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
July 18, 2022 11:53 am GMT

On interfaces and composition

On interfaces and composition

Abstractions are great, when done in moderation, with the goal in mind, and using the language features designed to take advantage of it. Were doing some work around storage and Ive been thinking about how we might go about implementing it in a way that makes working with code easy to understand and contained to small units that we can swap in and out as needed.

The need to store things

In most applications you will need a way to store things so they can be retrieved at a later date. Whether thats compiled build artefacts, metrics, logs, comments, blog posts, images, it doesnt matter. You have a piece of data, and you want to put it somewhere where you can then find it and do something with it 4 weeks later.

The very first iteration of an app is most probably going to have a database, like MySQL, coded into it, as thats the one that you decided to use, and its easy, and theres no reason to use indirections and abstractions.

The issues come when you need to replace MySQL with something else for whatever reason. Now you need to rip out half of your codebase because MySQL was hardcoded.

Enter interfaces

Lets say your app only really cares about two things: users and products. The only thing your app needs to do with regards to those are

  • storing a user
  • retrieving a user by email address or ID
  • storing a product
  • retrieving a product by its ID

Thats it.

So given the following pseudocode, the app only cares about the 4 operations above, but it doesnt really need to know how those are achieved.

package apptype User struct {    ID int    Name string,    Email string,    Password string,    Confirmed bool}type Product struct {    ID int    Title string,    Author User,    Description string,    Price int}func (a *App) ProductsForUser(user User) ([]Product, error) {    // Get products for the user}

Hence interfaces. They describe a set of behaviours that implementations would need to do. This hypothetical use case would have this interface.

package apptype Storage interface {    StoreUser(ctx context.Context, user User) (User, error)    GetUser(ctx context.Context, id int) (User, error)    ListUsers(ctx context.Context, ids []int) ([]User, error)    StoreProduct(ctx context.Context, product Product) (Product, error)    GetProduct(ctx context.Context, id int) (Product, error)    ListProducts(ctx context.Context, ids []int) ([]Product, error)    ListProductsForUser(ctx context.Context, user User) ([]Product, error)}

Tying this up into the app would look like this, without an implementation or constructor yet.

package apptype App struct {    storage Storage // the type is the Storage interface from above}func New(storage Storage) App {    return App{        storage: storage,    }}func (a *App) ProductsForUser(user User) ([]Product, error) {    products, err := a.storage.ListProductsForUser(user)    if err != nil {        return nil, errors.Wrap(err, "storage.ListProductsForUser for user %d", user.ID)    }    return products, nil}

The great thing is is that it doesnt really matter what the implementation of Storage is. Lets plug in our MySQL implementation here:

package mysqlimport (    "database/sql"    "app"    _ "github.com/go-sql-driver/mysql")// This is the mysql.DB, the implementation of the Storage interfacetype DB struct {    db dbConnection}func New(username, password, host, port, databasename) (DB, error) {    db, err := sql.Open("mysql", fmt.Sprintf(        "%s:%s@%s:%s/%s",        username, password, host, port, databasename,    ))    if err != nil {        return Storage{}, errors.Wrapf(err, "sql.Open: %s:***@%s:%s/%s",        username, host, port, databasename)    }    db.SetConnMaxLifetime(time.Minute * 3)    db.SetMaxOpenConns(10)    db.SetMaxIdleConns(10)    return DB{        db: db,    }, nil}func (d *DB) ProductsForUser(ctx context.Context, user app.User) ([]app.Product, error) {    d.db.Select(query, user.ID)}func (d *DB) StoreUser(ctx context.Context, user app.User) (User, error) { ... }// rest of the interface implementation here

This will get us direct access to the database, theres nothing inherently clever or complicated here.

However for performance reasons we might want to cache some of this data, especially if the data tends to be static. Details of certain products would not change often, nor details for the users, and its usually faster to talk to a Redis backend than an actual database.

That said we also need to think about how to handle situations where some data is not cached. One of the usual ways to deal with this is to check for the data, and if its not there, check the database. Pseudo-code wise it would look like this:

cachedData, found, err := redis.GetSomeData("key")if err != nil {    // there was an error, handle it}if found {    // superb, cache had the data    return cachedData }// cache didn't have the data, let's ask MySQLdata, err := database.GetSomeData("key")if err != nil {    // found the data in the database, cache it on Redis    // and return it    redis.SetSomeData("key", data)    return data}// even database didn't have it, so 404?

This works, but not really extensible, kind of hard, and looks a lot like spaghetti. Theres a better way.

Wraps!

Turns out I can wrap the mysql implementation into the Redis implementation. Also pseudocode:

package redisimport "github.com/go-redis/redis/v8"type Cache struct {    fallback Storage // the interface    client   *redis.Client}func New(host, port, password string, db int, fallback Storage) Cache {    rdb := redis.NewClient(&redis.Options{        Addr:     fmt.Sprintf("%s:%s", host, port),        Password: password,        DB:       db,    })    return Cache{        fallback: fallback,        client:   rdb,    }}func (c Cache) ProductsForUser(user app.User) ([]app.Product, error) {    data, err := c.client.Get(ctx, user.ID).Result()    if err == nil {        // Redis had the data        return data, nil    }    if err == redis.Nil {        // Redis did not have the data, ask fallback        products, err := c.fallback.ProductsForUser(user)        if err != nil {            return nil, errors.Wrap(err, "redis fallback sent back an error")        }        err = c.client.Set(ctx, user.ID, products, 0).Err()        if err != nil {            // setting the value to Redis failed, but we do have the data            // log and move on            log.Warn("could not set data to redis", err)        }        return products, nil    }    // err is not nil, but also not not found from Redis    return nil, errors.Wrap(err, "something went wrong in redis") }

The cool thing about this is that the calling code, our App, does not need to know anything about the inner workings of this. The setup code will look like this:

package mainfunc main() {    // create mysql connection    mysqlStorage, err := mysql.New("username", "password", "host", "port", "databasename")    if err != nil {        log.Fatal("could not get mysql")        os.Exit(1)    }    // create Redis connection and add the mysql connection as a fallback    // for all the data that's not cached yet    redisStorage := redis.New("host", "port", "password", 0, mysqlStorage)    // spin up our app with the Redis storage    service := app.New(redisStorage)    // service will first check Redis, then mysql    products, err := service.ProductsForUser(43)    // rest of the owl}

This way the services API remains flat, simple, easy to understand. Moreover each layer of storage remains simple because there are at most two layers they need to concern themselves with: their own (Redis does Redis things), and if that didnt have any effect, ask the fallback.

The fallbacks are interfaces though, so they dont need to care what the fallback is, just that asking for the same data looks the same. Let that package worry about it.

This makes it super easy to add additional layers. Do you want an inmemory cache in front of the Redis one? Sure:

package mainfunc main() {    // create MySQL connection    mysqlStorage, err := mysql.New("username", "password", "host", "port", "databasename")    if err != nil {        log.Fatal("could not get mysql")        os.Exit(1)    }    // create Redis connection and add the mysql connection as a fallback    // for all the data that's not cached yet    redisStorage := redis.New("host", "port", "password", 0, mysqlStorage)    // wrap the Redis into the inmemory    inmemStorage := inmemory.New(redisStorage)    // spin up our app with the inmemory storage    service := app.New(inmemStorage)    // service will first check inmemory, then Redis, then MySQL    products, err := service.ProductsForUser(43)    // rest of the owl}

Testing / mocking

More importantly than the above, unit testing the code becomes easy because we no longer need to spin up an actual Redis server, or a mysql database, or use the inmemory cache, because what were testing isnt those storage solutions, but rather what the service does with data / errors returned from the storage.

Mockery is a great tool to generate mocks based on interfaces, that way our unit tests can look like this (pseudocode):

func TestGetProduct(t *testing.T) {    // new mock storage with expecter on it.    storage := new(mocks.Storage)    // tell the mock that if the argument for the ProductsForUser method is 43,    // then return that list of products, nil err,    // and expect it to be called 1 times.    storage.EXPECT().ProductsForUser(43).Return([]Product{p1, p2}, nil).Times(1)    // use the mocked storage implementation with expectations on it    service := app.New(storage)    // query the code    products, err := service.ProductsForUser(43)    // make the assertions    storage.AssertExpectations(t)}

Recap

Using interfaces makes it easy to compose different parts of the services were building. That way each individual layer can be isolated, tested, validated, and the entire functionality can be mocked out so we can test the overarching application logic given known returns.

Ultimately this results in easier testing requirements, more confidence in the application code, better test coverage, faster test runs, and easier reasoning about code.


Original Link: https://dev.to/javorszky/on-interfaces-and-composition-4ppm

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To