1. Overview

In this tutorial, we’ll learn how to use Play’s caching API with Scala. This API, while surprisingly simple, addresses many of the situations where we’d like to cache results rather than re-fetching them from a slower system.

2. Enabling Caching

To use the caching APIs in our Play application, there is some set-up to be done at the project level.

First, we need to amend our project’s build.sbt with an additional dependency:

libraryDependencies += caffeine

Play also supports using Ehcache, but they recommend Caffeine as of this writing.

Having added that dependency to our project, we’ll need to restart sbt or reload the project in our IDE. Once we’ve done that, we should be ready to start using the caching APIs in our code.

3. The Play SyncCacheApi and AsyncCacheApi

Play provides two different types of caching: synchronous and asynchronous. For our purposes, we’ll focus on the async version since it’s a better fit for a web service.

The AsyncCacheApi supports several operations:

  • set(key: String, value: Any, expiry: Duration = Duration.Inf): Future[Done] – Set a value in the cache that expires after the specified time (default is never)
  • remove(key: String): Future[Done] – Remove the specified value from the cache
  • get[T](key: String): Future[Option[T]] – Retrieve the value from the cache if it exists
  • getOrElseUpdate[T](key: String, expiry: Duration)(orElse: => Future[T]): Future[T] – Retrieve the specified value if present, otherwise invoke the orElse function to set a value for that key
  • removeAll(): Future[Done] – Remove all of the entries from the cache

Note that the synchronous version of the API, SyncCacheApi, does not provide the removeAll() method.

Next, we’ll see how to use the getOrElseUpdate() method.

4. Using the Caching API

By way of example, we’ll use the caching API for a simple application that uses Twitter’s recent search API. This API returns tweets from the last seven days for a specified user.

Using caching here makes sense for a couple of reasons:

  1. Twitter limits how frequently we can call their API
  2. Most Twitter users do not tweet that often

In our sample code, we’ll use the Twitter user name as the key into our cache. We’ll also set the entries to expire after five minutes. This is configurable via the twitterCache.expiry setting in the application.conf file.

4.1. Web Controller

Our top-level class is an instance of a Play BaseController called TwitterController:

@Singleton
class TwitterController @Inject()(
  twitterSearchService: TwitterSearchService,
  override val controllerComponents: ControllerComponents,
  implicit val executionContext: ExecutionContext
) extends BaseController {

  def recentSearch(twitterAccount: String): Action[AnyContent] = Action.async {
    twitterSearchService.recentSearch(twitterAccount).map { response =>
      Ok(Json.toJson(response))
    }
  }
}

We’ll make our controller available to an external caller by configuring it in the Play routes file:

GET     /api/twitter/recentSearch/:twitterAccount   controllers.TwitterController.recentSearch(twitterAccount)

Once we’ve launched our Play application, we can interact with the above interface using curl:

curl http://localhost:9000/api/twitter/recentSearch/TwitterDev | jq

Next, let’s look at how we’ve implemented the service layer, as this is where we use caching.

4.2. Search Service

Our service layer code is also very simple, and this is where we’ll use the caching API provided by Play:

class TwitterSearchService @Inject()(twitterWebApi: TwitterWebApi,
                                     cache: AsyncCacheApi,
                                     configuration: Configuration,
                                     implicit val executionContext: ExecutionContext) {

  val cacheExpiry: Duration = configuration.get[Duration]("twitterCache.expiry")

  def recentSearch(twitterUser: String): Future[Map[String, JsValue]] = {
    cache.getOrElseUpdate[JsValue](twitterUser, cacheExpiry) {
      twitterWebApi.recentSearch(twitterUser)
    }.map(_.as[Map[String, JsValue]])
  }
}

As discussed above, the getOrElseUpdate() method retrieves the specified tweets from the cache if they exist, otherwise, it calls our wrapper for the Twitter API to retrieve the latest values. This is a very common usage pattern for any cache. This one API call will probably suffice for the great majority of applications.

4.3. API Client

The client code to fetch tweets uses the Play WSClient class and does no caching of its own:

def recentSearch(fromTwitterUser: String): Future[JsValue] = {
  val url = String.format(recentSearchUrl, fromTwitterUser)
  wsClient
    .url(url)
    .withHttpHeaders(
      HeaderNames.ACCEPT -> MimeTypes.JSON,
      HeaderNames.AUTHORIZATION -> s"Bearer $bearerToken"
    ).get()
    .map { response =>
      if (response.status == OK) {
        response.json
      } else {
        throw ApiError(response.status, Some(response.statusText))
      }
    }
}

Note that the Twitter API requires us to use a bearer token to authenticate. This will be issued once we’ve created a Twitter developer account. Also, note that we have to set the header directly because the Play WSRequest.withAuth() method only works for authentication methods that have a username and password such as Basic Auth.

4.4. Final Notes on the Play Caching APIs

It’s worth noting that neither the SyncCacheApi nor the AsyncCacheApi provides any inter-thread synchronization. This means that for a given key, the API we have used could result in multiple calls to Twitter.

Additionally, the Caffeine provider we’ve used here is not a distributed cache. Therefore, if we run multiple instances of our Play app, each will have its own cache. It is possible to configure Ehcache to provide this.

5. Conclusion

In this tutorial, we introduced and discussed using Play’s caching API. As we can see, Play makes it very easy to add this capability to our application.

As always, the full source code is available over on GitHub.