Upgrading From Play Framework 2.3 to Play 2.5

Gregg Hernandez

Reading time: about 22 min

Topics:

  • Behind The Scenes

Recently I had the opportunity to upgrade the Play Framework from 2.3 to 2.5 on all of Lucid's JVM-based services. A lot of the upgrade involved pretty routine things like changing implicit parameters from Lang to Messages or updating uses of play-json to match the new API. However, over the course of the upgrade, I came across a number of problems with less obvious solutions. If you've struggled with compile time dependency injection, Akka streams, or properly supported deprecated Play features, then hopefully you'll find some answers here. As a quick prerequisite, if you're upgrading from Play 2.3, I'm assuming you've read the migration guides found here and here. I'm also going to mostly gloss over the details of getting your application to compile for the first time after upgrading, as this process is straightforward and pretty well documented.

Transitioning to the new Play APIs

While it is perfectly fine to allow deprecated code to exist in your application, it should be considered a short-term solution. In future upgrades, many of the deprecated features will be removed, and you will either be unable to upgrade or you will be required to update your code all at once. At Lucid we have decided to make it an ongoing effort to gradually transition away from deprecated code over time. Hopefully, doing so will put us in a good position to move to newer versions of Play when they become available.

Akka Streams

Play's migration guide covers migrating to Akka Streams at a high level. The two most dramatic changes for Lucid were (1) the introduction of dependency injection and (2) the change to using Akka Streams instead of the Iteratee/Enumerator APIs for body parsers and in chunked responses. A BodyParser used to expect you to return an Iteratee, but now it expects an Accumulator. A call to Ok.chunked() used to take an Enumerator, but now it takes a Source. These two APIs will provide a good foundation for working with Akka Streams in other contexts as well. Play does provide some helpers in the Streams object to make the transition quite a bit easier.

BodyParser

For a BodyParser you can promote an Iteratee to an Accumulator using Streams.iterateeToAccumulator. However, if you prefer to work with Akka Streams directly, a bit more work is involved. We'll start out with a BodyParser that examines some header and determines if the value found in the header matches a hash of the body of the request using the deprecated BodyParser.iteratee method and some helpers from the Streams object:

def parser[A](otherParser: BodyParser[A]) = BodyParser.iteratee { request =>
  Iteratee.consume[ByteString]().mapM { bytes =>
    val enumerator = Enumerator(bytes)
    enumerator.run(Streams.accumulatorToIteratee(parse.tolerantText(request))).flatMap {
      case Left(result) => Future.successful(Left(result))
      case Right(string) =>
        if (request.headers.get("Some-Header").contains(string.hashCode.toString)) {
          enumerator.run(Streams.accumulatorToIteratee(otherParser(request)))
        } else {
          Future.successful(Left(BadRequest))
        }
    }
  }
}

This is an extremely dense block of code that I'm not going to explain in detail. The high level idea is that it is creating a stream that expects input in the form of ByteStrings. It then adds a processing step (mapM) that consumes all the bytes, verifies that the hash of the bytes matches what is in "Some-Header", and returns a result. Unfortunately, when transitioning this code to Akka Streams, we end up with quite a bit more code. I believe the code is a little easier to understand, and I like that Akka Streams uses a slightly less abstract vocabulary for core types like Source, Flow, and Sink. The following explanation of how to transition to Akka Streams borrows heavily from this Stack Overflow question (asked and answered by me).

BodyParser.apply now expects you to return an Accumulator[ByteString, Either[Result, A]]. This is effectively an alias for a Sink[ByteString, Future[Either[Result, A]]. So our parser function is given a BodyParser[A]. We can use that body parser as our Sink, or last step in processing the stream.

val parserSink: Sink[ByteString, Future[Either[Result, A]]] = otherParser(request).toSink

parserSink expects input in the form of ByteString, so we need to create a Flow (a processing step) that reads all the data in the stream and passes it through to the sink unchanged; however, it also needs to somehow tell Play to return a 400 if the hash doesn't match. A Flow has three main components: its input, its output, and a materialized value. The type of flow we need is Flow[ByteString, ByteString, Future[Boolean]]. This is a flow that takes ByteStrings as input, produces ByteStrings as output, and materializes a Future[Boolean] (representing whether or not the hash matches). So for now, let's assume we have the flow that we need. We'll represent it here with Flow.fromGraph(new BodyValidator(request)) and define BodyValidator later.

val flow: Flow[ByteString, ByteString, Future[Boolean]] = Flow.fromGraph(new BodyValidator(request))

The next step is to combine flow with parserSink into a Sink[ByteString, Future[Either[Result, A]]]. We can use flow.toMat=>Mat3):akka.stream.scaladsl.Sink[In,Mat3]) to accomplish this. toMat expects you to pass in a Sink and a function to combine the materialized value from the Flow and Sink. flow materializes into a Future[Boolean], and parserSink materializes into a Future[Either[Result, A]], so we need a (Future[Boolean], Future[Either[Result, A]]) => Future[Either[Result, A]]:

def combine(success: Future[Boolean], result: Future[Either[Result, A]]): Future[Either[Result, A]] = {
  success.flatMap { hashValid =>
    if (hashValid) {
      result
    } else {
      Future.successful(Left(BadRequest))
    }
  }
}

val newSink = flow.toMat(parserSink)(combine)

Finally, we just wrap newSink in an Accumulator and we end up with the following for the full definition of parser:

def parser[A](otherParser: BodyParser[A]): BodyParser[A] = BodyParser { request =>
  val parserSink: Sink[ByteString, Future[Either[Result, A]]] = otherParser(request).toSink

  val flow: Flow[ByteString, ByteString, Future[Boolean]] = Flow.fromGraph(new BodyValidator(request))

  def combine(success: Future[Boolean], result: Future[Either[Result, A]]): Future[Either[Result, A]] = {
    success.flatMap { hashValid =>
      if (hashValid) {
        result
      } else {
        Future.successful(Left(BadRequest))
      }
    }
  }

  val newSink = flow.toMat(parserSink)(combine)
  Accumulator(newSink)
}

Great. Now let's look at BodyValidator. The end goal is to create a Flow[ByteString, ByteString, Future[Boolean]] or a Flow that takes in ByteString and outputs ByteString unchanged and materializes into a Future[Boolean] representing whether or not the body of the request matches "Some-Header" in the request. We do this by creating a GraphStageWithMaterializedValue. Here's what the final code looks like:

class BodyValidator(request: RequestHeader) extends GraphStageWithMaterializedValue[FlowShape[ByteString, ByteString], Future[Boolean]] {
  val in = Inlet[ByteString]("BodyValidator.in")
  val out = Outlet[ByteString]("BodyValidator.out")

  override def shape: FlowShape[ByteString, ByteString] = FlowShape.of(in, out)

  override def createLogicAndMaterializedValue(inheritedAttributes: Attributes): (GraphStageLogic, Future[Boolean]) = {
    val status = Promise[Boolean]()
    val bodyBuffer = new ByteStringBuilder()

    val logic = new GraphStageLogic(shape) {
      setHandler(out, new OutHandler {
        override def onPull(): Unit = pull(in)
      })

      setHandler(in, new InHandler {
        def onPush(): Unit = {
          val chunk = grab(in)
          bodyBuffer.append(chunk)
          push(out, chunk)
        }

        override def onUpstreamFinish(): Unit = {
          val fullBody = bodyBuffer.result()
          if (request.headers.get("Some-Header").map(ByteString(_)).contains(fullBody)) {
            status.success(true)
          } else {
            status.success(false)
          }
          completeStage()
        }

        override def onUpstreamFailure(e: Throwable): Unit = {
          status.failure(e)
          failStage(e)
        }
      })
    }

    (logic, status.future)
  }
}

I'm going to ignore basically everything except the InHandler in createLogicAndMaterializedValue section. You can reference the Stack Overflow question for more details. You can also assume that the code here is more or less the bare minimum. Removing the OutHandler especially will cause problems. In createLogicAndMaterializedValue we first create a Promise[Boolean] to ultimately be converted to the materialized Future[Boolean] we're after and a ByteStringBuilder used to buffer the bytes we've seen while processing this stream. Logically we want to read each chunk of bytes, save it in bodyBuffer, and then pass those bytes downstream unchanged. Once upstream has no more bytes to send, we'll compare what we've received with the "Some-Header" header to make sure they match. All of this happens in onPush and onUpstreamFinish:

def onPush(): Unit = {
  val chunk = grab(in)     // grab next chunk
  bodyBuffer.append(chunk) // write chunk to bodyBuffer
  push(out, chunk)         // pass chunk unchanged downstream
}

override def onUpstreamFinish(): Unit = {
  val fullBody = bodyBuffer.result()
  if (request.headers.get("Some-Header").map(ByteString(_)).contains(fullBody)) {
    status.success(true)
  } else {
    status.success(false)
  }
  completeStage()
}

It's worth noting that the semantics of the iteratee version and the Akka Streams version are slightly different. In the iteratee version, otherParser is not invoked until after the body has been verified. In the Akka Streams version, these two stages happen concurrently. In my (very non-scientific) benchmarks, the Akka Streams version was able to process requests about 20% faster than the iteratee version. Some of this improvement is due to the concurrent nature of the Akka Streams solution; however, it is still around 10% faster when dealing with requests that don't pass the first stage.

Ok.chunked

There are currently two versions of Ok.chunked in Play. The deprecated version takes an Enumerator[C], while the new version takes a Source[C, _]. Lucid is still working on transitioning many chunked responses to the new API, but in most cases it has been a dramatic improvement in code readability. Often we find ourselves with an Iterator that we want to send to the client chunked. With the old enumerator API, we had code that looked like this:

var hasSentOne = false
val enumerator = Enumerator("[") >>> Enumerator.enumerate(iterator).map { data =>
  val comma = if (hasSentOne) {
    ","
  } else {
    hasSentOne = true
    ""
  }

  comma + Json.asciiStringify(data)
} >>> Enumerator("]") >>> Enumerator.eof

Ok.chunked(enumerator).as("application/json")

The Akka Streams version is a bit cleaner:

val source = Source.fromIterator(() => iterator).map { data =>
  Json.asciiStringify(data)
}.intersperse("[", ",", "]")

Ok.chunked(source).as("application/json")

We get to dispense with all of the boiler plate around commas and wrapping the output in square brackets. Using intersperse is simpler than chaining one off enumerators with >>>.

Dependency Injection

Play has really great documentation for doing runtime dependency injection with Guice. At Lucid we decided that we would prefer the guarantees of compile-time dependency injection. In practice, this means that we "inject" a dependency by passing it as a constructor parameter to a controller, or model, or to anything else that might have a dependency. Using MacWire, we are able to abstract away some of the tedium of manually injecting all of our dependencies. First we need a class that implements BuiltInComponents to make sure Play has everything it needs to run smoothly. The easiest way to get a hold of one of these is to extend BuiltInComponentsFromContext. When extending this class, you need to provide a definition for def router: Router:

class CustomComponents(context: Context) extends BuiltInComponentsFromContext(context) {
  lazy val router: Router = {
    val prefix: String = "/"
    wire[Routes]
  }
}

wire is a pretty simple macro. I'll illustrate what it's doing, but first we need a little background. Let's assume you have a conf/routes file that looks like this:

GET /     com.lucidchart.controllers.Home.main()
GET /blog com.lucidchart.controllers.Blog.main()

When a Play project is compiled, the routes file is parsed, and a Routes class is generated. Given the above routes file, your Routes class will look something like this:

class Routes(errorHandler: HttpErrorHandler, home: Home, blog: Blog, prefix: String) extends GeneratedRouter { ... }

The first parameter is always an HttpErrorHandler, and the last parameter is a String representing the route prefix ("/" here). Then one parameter is generated for each controller class referenced in your routes file. Given this constructor, when wire[Routes] is encountered, wire will attempt to find a single value in the current scope that matches the type of each parameter. BuiltInComponentsFromContext provides the HttpErrorHandler, and we explicitly defined prefix in the scope of wire[Routes] so we have those two parameters covered. If we try to compile this now, we get this error:

[error] Cannot find a value of type: [com.lucidchart.controller.Home]
[error]     wire[Routes]
[error]         ^

This is wire letting you know that there is no com.lucidchart.controller.Home in scope. Okay, so let's get one in scope (and a com.lucidchart.controller.Blog while we're at it).

class CustomComponents(context: Context) extends BuiltInComponentsFromContext(context) {
  lazy val home: Home = wire[Home]
  lazy val blog: Blog = wire[Blog]

  lazy val router: Router = {
    val prefix: String = "/"
    wire[Routes]
  }
}

Once you have everything that Routes expects at compile time, it will transform wire[Routes] into:

new Routes(httpErrorHandler, home, blog, prefix)

The gains here aren't amazing, but when you have a routes file that is tens or hundreds of entries long, you start to really reap the rewards of using wire.

Now we just need to create an ApplicationLoader and configure Play to use it:

class CustomLoader extends ApplicationLoader {
  def load(context: Context): Application = {
    LoggerCongfigurator(context.environment.classLoader).foreach {
      _.configure(context.environment)
    }
    new CustomComponents(context).application
  }
}

And then in application.conf:

play.application.loader = "CustomLoader"

Since we have wire[Home] above we can (more or less) arbitrarily add dependencies to Home, and wire will inject them automatically. Home could be defined as class Home() or class Home(actorSystem: ActorSystem, configuration: Configuration, environment: Environment)(implicit mat: Materializer) and everything will just work without the need to make changes in CustomComponents. If you add a dependency that isn't already in scope at wire[Home] you can just add another lazy val that wires this new dependency. For example, let's say that Home is defined as class Home(homeModel: HomeModel). HomeModel is not provided by BuiltInComponentsFromContext, so you need to provide your own:

class CustomComponents(context: Context) extends BuiltInComponentsFromContext(context) {
  lazy val homeModel: HomeModel = wire[HomeModel]

  lazy val home: Home = wire[Home]

  ...
}

If HomeModel has dependencies that are provided by BuiltInComponentsFromContext then we're good to go. If it has dependencies that are not provided, then we need to repeat this process for HomeModel's dependencies until all dependencies have everything they need.

Since BuiltInComponentsFromContext provides a lot of default Play features, such as filters and error handlers, we can simply override them to use things beyond the defaults. However, if you override the default httpErrorhandler or httpFilters you also need to override httpRequestHandler since it takes the other two as parameters.

override lazy val httpErrorHandler: HttpErrorHandler = wire[CustomErrorHandler] // you'll need to provide this class
override lazy val httpFilters: Seq[EssentialFilter] = Seq(new GzipFilter())
override lazy val httpRequestHandler: HttpRequestHandler = wire[HttpRequestHandler]

 

Maintaining a deprecated Play application

As of Play 2.5, the old approach (GlobalSettings, Play, etc.) has been marked as deprecated. Lucid Software (as of this writing) has 18 services that depend on the Play Framework. Attempting to upgrade to Play 2.5 while removing all deprecated code would have been an exercise in futility. For now, the majority of services use Play 2.5 with lots and lots of deprecated features in use. Unfortunately the steps that need to be taken to maintain legacy Play application support like this aren't well documented. The good news is that it's not overly difficult. So let's talk about what Lucid needed to do to make sure we were able to maintain roughly Play 2.3 behavior.

Configuration

We'll start with config changes. Here's all the changes/additions we had to make in application.conf:

# Keeps your GlobalSettings object working correctly
play.http.requestHandler=play.api.http.GlobalSettingsHttpRequestHandler

# replaced trustxForwarded = true
play.http.forwarded.trustedProxies=["0.0.0.0/0", "::/0"]

# the next two were needed to replace parsers.text.maxLength=104857600
play.http.parser.maxMemoryBuffer=104857600
play.http.parser.maxDiskBuffer=104857600

play.http.cookies.strict = false

The play.http.requestHandler setting is needed simply to make sure that all your callbacks in your GlobalSettings object get executed correctly. Without this option things like doFilter and onRouteRequest won't be called.

The play.http.forwarded.trustedProxies setting only matters if your incoming requests are proxied through something like nginx or haproxy and SSL is terminated at the proxy. The request.secure value won't be set correctly (it will always be false) if you don't configure your trusted proxies correctly. The value above will whitelist all proxies. This is fine at Lucid since only the proxies are exposed to the outside world, and no requests can be made directly to the Play servers.

Replacing parsers.text.maxLength required setting both play.http.parser.maxMemoryBuffer and play.http.parser.maxDiskBuffer. We have a service for importing Visio, Gliffy, and OmniGraffle documents. When a user imports one of these documents, they are POSTed to a Play service. Sometimes these documents can get somewhat large. These two settings make sure that Play allows these large documents to actually be processed. Play 2.3 used to apply parsers.text.maxLength to both the in-memory buffer and the on-disk buffer. That's not the case anymore.

play.http.cookies.strict is a setting that determines whether or not Play will strictly follow the RCF for cookie. Play 2.3 did not, and setting this to false preserves that behavior. Without this setting, it's possible that existing clients will have invalid cookies according to Play even though the web browsers themselves will be fine with the cookies.

Logger

Configuring the logger in Play used to mean creating conf/logger.xml. In Play 2.5, you have to rename that file to conf/logback.xml. This is partially documented here, but this particular change isn't explicitly highlighted. You need to follow a link to the logback manual and infer that the file should be named logback.xml because there is no mention of logger.xml.

JSON

Upgrading to the new JSON API is fairly well documented; however, there are some gotchas that caused us issues, so I feel like it will be valuable to highlight those problems here. The new JSON API was introduced in Play 2.4. The biggest change is a restructuring of the class hierarchy. JsUndefined used to be a subclass of JsValue. That's not the case anymore. JsUndefined is now one of two subclasses of JsLookupResult (the other sub-class is JsDefined). A JsLookupResult is roughly equivalent to an Either designed specifically for expressing whether a particular JSON key is defined. An expression like (json \ "key") will return a JsLookupResult. It will be a JsDefined if "key" exists in json and JsUndefined otherwise. Most of the time, the compiler will catch errors in usage and you can happily move through your application fixing compiler errors. However, in some pattern matches, the compiler is unable to stop you from doing bad things. The following pattern appears quite a bit in our code base:

val x = (json \ "key") match {
  case origin: JsObject => // do things
  case _ =>
}

This pattern compiles in both the new and old JSON API. However, in the new API, the pattern match will always fall through to the default case. The fix is to change case origin: JsObject to case JsDefined(origin: JsObject). The errors caused by this code were easy to miss. Our QA team found a number of bugs during testing of the upgrade that were caused by this. It ultimately led to a full audit of all JSON code to make sure we didn't miss any more.

Headers and Type Safety

At Lucid we use the GzipFilter to gzip any responses that match some Content-Type and are larger than some threshold (based on the Content-Length header). In our Play 2.3 application, we would instantiate a GzipFilter like this:

val gzip = new GzipFilter(shouldGzip = (request, response) => {
  !response.header.headers.get("Content-Length").exists(_.toLong < minContentLength) &&
    response.header.headers.get("Content-Type").exists(contentType => acceptContentTypes.exists(contentType.startsWith(_)))
})

As you can see, we simply check the appropriate headers to determine if the response should be gzip'd. Play is generally moving towards a more type-safe HTTP environment. This means that many headers are being represented as explicit members rather than as entries in the response.header.headers Map. Conceptually Content-Type and Content-Length are properties of the response body. As such, Play has moved access to this data into response.body.contentLength and response.body.contentType. In Play 2.5 we noticed that our response times got worse. After some debugging, we discovered that no responses were being gzip'd. Fixing this problem required changing the above code to the following:

val gzip = new GzipFilter(shouldGzip = (request, response) => {
  !response.body.contentLength.exists(_ < minContentLength) &&
    response.body.contentType.exists(contentType => acceptContentTypes.exists(contentType.startsWith(_)))
})

Play 2.5 also generates a warning if you explicitly set the Content-Type on an object that also has an implicit Writeable available:

Ok(Json.obj("hello" -> "play")).withHeaders("Content-Type" -> "application/json")

This generates the following warning:

[warn] p.c.s.n.NettyModelConversion - Content-Type set both in header (application/json) and attached to entity (application/json), ignoring content type from entity. To remove this warning, use Result.as(...) to set the content type, rather than setting the header manually.

Helpfully, the warning tells you how to fix it.

Ok(Json.obj("hello" -> "play")).as("application/json")

Since a JsValue has an implicit Writeable[JsValue] available, you can also leave off the Content-Type altogether, and Play will still do the right thing.

Ok(Json.obj("hello" -> "play"))

 

Final Thoughts

Overall it was a fun experience to upgrade Play. It was not without risk; however, being able to migrate a legacy application using deprecated features minimized the risk of possible bugs that would have resulted from a large-scale refactoring. We are still exploring the full implications of this upgrade particularly in terms of testability and performance. So far we haven't seen any negative performance impacts, and I believe that some of the BodyParser API changes improved performance (more testing is needed). As we move more and more services to full compile-time dependency injection, the testability of the application should improve. At a minimum, we'll be able to run more tests in parallel and share jvm instance across tests since Play.current will no longer be shared between tests.

About Lucid

Lucid Software is a pioneer and leader in visual collaboration dedicated to helping teams build the future. With its products—Lucidchart, Lucidspark, and Lucidscale—teams are supported from ideation to execution and are empowered to align around a shared vision, clarify complexity, and collaborate visually, no matter where they are. Lucid is proud to serve top businesses around the world, including customers such as Google, GE, and NBC Universal, and 99% of the Fortune 500. Lucid partners with industry leaders, including Google, Atlassian, and Microsoft. Since its founding, Lucid has received numerous awards for its products, business, and workplace culture. For more information, visit lucid.co.

Solutions

  • Digital transformation
  • Cloud migration
  • New product development
  • Efficiency through AI
  • View more

Resources

  • Customers
  • Developers
  • Security
  • Support
  • Training labs
  • User community
  • Partners
  • Newsletter
PrivacyLegalCookie privacy choicesCookie policy
  • linkedin
  • twitter
  • instagram
  • facebook
  • youtube
  • glassdoor
  • tiktok

© 2024 Lucid Software Inc.