Tuesday, January 15, 2019

More DI: Adding a Client-Side Cache with the Decorator Pattern

The Decorator Pattern lets us add functionality without changing existing classes. Previously, we looked at adding retry functionality and exception logging to a data reader by using decorators. This time, we'll add a client-side cache to our data reader. (For more details on how the Decorator Pattern works, see the prior article More DI: Adding Retry with the Decorator Pattern.)

Network calls, whether to a web service or a database, are often the most slowest parts of our application due latency and bandwidth limitations. To minimize these types of calls, we want to add a client-side cache to the application. This cache will provide data for a specified amount of time and then hit the real data reader when it's time to refresh the cache. This will keep the network calls to a minimum.
The Decorator Pattern lets us add functionality without changing existing classes.
This article is one of a series about dependency injection. The articles can be found here: More DI and the code is available on GitHub: https://github.com/jeremybytes/di-decorators.

Behavior Without a Cache
Before adding the cache, let's see how the application behaves. For this, we'll remove the previous decorators and go back to the base state.

Composing the Objects
Here's the composition root (from the "PeopleViewer" project, App.xaml.cs file):

The code here is much different from what is in the GitHub project. The GitHub project uses all of the decorators, and we'll look at that in a later article. To follow along with this article, you can replace the code in the "ComposeObjects" method with what we have here.

As a reminder, the "ComposeObjects" method has 3 steps. (1) It creates a ServiceReader; this is a data reader that gets data from a web service. (2) It uses the ServiceReader as a parameter to create a PeopleReaderViewModel; this view model has the presentation logic for the form. (3) It uses the view model as a parameter to create the MainWindow; this is the view that has the UI elements for the form.

Running the Application (Success)
We'll start by running the application. Since we are using the ServiceReader, we also need to start the web service. This is done from the command line with "dotnet run". (See this article for more information on how I quickly open a command prompt to the correct folder.)

Once the service is running, we'll start the application and click the "Refresh People" button.

This gives us the data from the service.

Running the Application (Failure)
Now let's stop the service and try again. To stop the service, just press "Ctrl-C" in the command window.

Then in the application, click "Clear Data" then "Refresh People". The call will fail since the service is no longer running. When the debugger stops in the code, just click "Continue" or "F5" to keep running. The application will give us the generic error message.

This shows that each time we click the "Refresh People" button, the application will make a call to the web service. This is the behavior that we'd like to change with the client-side cache.

A Client-Side Cache
To add the cache, we will decorate the data reader with caching functionality. To do this, we wrap a current data reader, add the cache, and then expose the same interface to the outside world. (For more details on using the Decorator pattern, see More DI: Adding Retry with the Decorator Pattern.)

We need to wrap and implement the IPersonReader interface (from the "Common" project, IPersonReader.cs file):

Here is the code for the CachingReader (from the "PersonReader.Decorators" project, CachingReader.cs file):

The first thing to note is that the CachingReader implements the "IPersonReader" interface, so it has the 2 methods "GetPeople" and "GetPerson".

At the top of the class, we have a field for the data reader that will be wrapped ("_wrappedReader"). This is set from an incoming constructor parameter. In addition, the constructor has a TimeSpan parameter for the duration of our client-side cache. This is also stored in a private field ("_cacheDuration").

The other 2 fields manage the cache. The "_cachedItems" field holds the data. The "_dataDateTime" is the time the data was last refreshed. This is used to see if the cache has expired.

The "GetPeople" method validates the cache and then returns the cached items.

Validating the Cache
The "ValidateCache" method ensures that we have current data. Here's the code for that:

The "if" statement checks to see if the cache is current. (We'll look at "IsCacheValid" in just a moment.) If the cache is populated and valid, then the method returns immediately, and the current cache is used.

If the cache is not populated or if it is expired, then the "GetPeople" method is called on the wrapped data reader. The items returned are put into the cache and the data date/time is updated.

If the call to "GetPeople" fails for some reason, then the CachingReader will return a default record that simply states "No Data Available". This will give us a value that we can display in the UI (and this works for this specific application). Another option would be to let the exception bubble up.

After setting the default record, a call to "InvalidateCache" ensures that the data will be refreshed with the next call. (We'll see "InvalidateCache" in just a moment.)

Checking for an Expired Cache
The "IsCacheValid" property will check to see if the cache is populated and not expired. Here's the code for that:

First, this checks to see if the cached items field is populated. If it is null, then it returns "false" immediately.

If the cache is populated, then we calculate the age of the cache by getting the difference between the data date/time and the current time. Then we check to see if the difference is less than the cache duration value.

This returns "true" if the cache is still valid.

Invalidating the Cache
We may want to invalidate the cache manually. For this, we have the "InvalidateCache" method:

This sets the data date/time to the minimum value for DateTime. This will make sure that the cache will be refreshed on the next call (unless the cache duration value is set to a *very* large value).

The implementation of this cache is fairly naive. This assumes that the data coming back from the "GetPeople" method is small enough to be stored in memory. I've used this implementation for small data sets for a while now. But it would need to be beefed up for larger data sets.

Using the Caching Decorator
Just as we did with the retry decorator and the exception logging decorator, to use the caching decorator, we snap the pieces together in a different order.

Here is the code with the CachingReader added, from the "PeopleViewer" project, App.xaml.cs file. (Reminder, this code is different from what is in the final GitHub project.)

This adds the CachingReader to the object composition. Let's walk the method from the bottom up. (1) We create a MainWindow instance. This needs an "IPeopleViewModel" as a parameter. (2) So above that, we create a PeopleReaderViewModel to pass to the MainWindow. The PeopleReaderViewModel needs an "IPersonReader". (3) So above that we create the CachingReader to pass to the view model. The CachingReader has 2 parameters: an "IPersonReader" to wrap and a TimeSpan for the duration. (4) So above that we create a ServiceReader to get data from a web service and also create a TimeSpan of 10 seconds.

Running the Application (Success)
So let's see the cache in action. First, start the web service like we did above:

Then run the application and click "Refresh People". This gives us the expected data:

Now we'll check the cache. Keep the application running for the next steps.

Using the Cache
With the application still running, stop the web service using "Ctrl-C" as we did above.

Now click the "Clear Data" and "Refresh People" buttons in the application.

We still have data!

Now if we continue to click "Clear Data" and "Refresh Data", the cache will eventually expire. After the cache is expired, the CachingReader tries to get data from the service (which is no longer running). That call fails, so the CachingReader returns the default record:

This shows the client-side cache in action. Even though the web service is no longer running, we can still get data from the client-side cache until the data gets too old.

Note: if the cache is expiring too quickly to see these results, just set the "duration" to a larger value in the "ComposeObjects" method above.

This code works with the CSVReader and SQLReader as well. I use the ServiceReader here since it is the easiest to demonstrate by stopping the service.

Unit Testing the Caching Decorator
Just like with the other decorators, we want to unit test the caching decorator. The process is very similar to the previous tests, so we'll go pretty quickly through this. For more details, see More DI: Unit Testing Async Methods which shows the tests for the retry decorator.

These tests are in the "PersonReader.Decorator.Tests" project, CachingReaderTests.cs file.

Testing That the Cache is *Not* Used
Our first test will check to make sure that the cache is not used on the first call to the "GetPeople" method. Here's the code for that:

Just like with our other decorators, we want to use a fake data reader for testing. In this case, we have a "CountedReader" that keeps track of how many times the "GetPeople" method is called.

Let's look at the fake reader and then come back to this test.

Counting Method Calls
The fake data reader is in the same testing project, CountedReader.cs file:

The CountedReader implements "IPersonReader" and so has the "GetPeople" and "GetPerson" methods. The class has a public property to keep track of how many times the methods are called. This property is public, so it is available to the tests.

The "GetPeople" method increments the call count and then returns an empty collection of Person objects. The "GetPerson" method does something similar. We don't need any fake data here, so the empty results are fine.

Back to the Test
Let's go back to the first test.

Here, we create a CountedReader and a duration (a TimeSpan of 1 second). Then we use these values to create the CachingReader.

Next, we call the "GetPeople" method.

Finally, we assert that the CallCount on the CountedReader is 1. This lets us know that the wrapped data reader was called one time for the test. And this is exactly what we expect.

Testing the Cache
The next test will see if the cache is working. Here's the code:

The name of the test tells how we're going to verify that the cache is being used. When the "GetPeople" method is called 2 times, we expect that the wrapped reader is only called 1 time.

The setup for this test is the same as the prior one. We create a caching reader with a duration of 1 second.

For the action, we call "GetPeople" twice. We expect that the second call should use the cache.

Then we check to see how many times the CountedReader is called. We expect that it will be called the first time but not the second time. So, the CallCount should be 1.

Verifying an Expired Cache
For the last test, we want to make sure that the reader still works after the cache has expired.

This test has the same setup: we create a CachingReader with a 1 second cache.

In the action section, we call the "GetPeople" method. This should populate the cache.

Then we wait for 2 seconds. (For more information on how "Task.Delay" works, take a look at the article for the Retry Decorator.)

After waiting 2 seconds, we call the "GetPeople" method again. The cache should be expired, so we expect that the wrapped data reader will be called to refresh the cache.

The result is that we expect the CountedReader to be called twice. And that is what we check with the assertion.

Note: This test takes at least 2 seconds to complete. This is a slooow test, so I've marked it with the "Slow" category. It is possible to create shorter durations for the cache and for the delay, but if we cut things too fine, then we may end up with timing issues in the test.

Test Results
When we run the tests, they all pass:

And we can see that the third tests did take 2 seconds to complete.

Dependency Injection and the Decorator Pattern
Dependency Injection and the Decorator Pattern work really well together. We can easily add retry functionality, exception logging, or a client-side cache to our application. We just need to take the loosely coupled pieces and snap them together in a different order.

We did *not* need to change the view (MainWindow). We did *not* need to change the view model (PeopleReaderViewModel). We did *not* need to change the existing data reader (ServiceReader). We just snap our pieces together in a different order.

It seems like I'm repeating this quite a bit. And that's because it is so important. Decorators let us add functionality in pieces. These atomic units are easier to test, and we can compose them in whatever order we like.

Since our decorators wrap "IPersonReader" objects, and our decorators are "IPersonReader" objects, we can have our decorators wrap other decorators. This will stack the functionality so that we get all of the features that we want. That's what we'll look at next time. So be sure to check back.

Happy Coding!

No comments:

Post a Comment