Web pages can get very complex and slow. In this talk, I share how we solve some of these problems at LinkedIn by leveraging composition and streaming in the Play Framework. This was my keynote for Ping Conference 2014 ( http://www.ping-conf.com/ ): the video is on ustream ( http://www.ustream.tv/recorded/42801129 ) and the sample code is on github ( https://github.com/brikis98/ping-play ).
34. Abstraction: structure your app so you
can focus on one part while safely
ignoring the rest
Composition: structure your app so
you can easily combine the simpler
parts into more complicated parts
39. For this talk, we’re going to simulate service
calls by calling a mock endpoint
40. object Mock extends Controller {
def mock(serviceName: String) = Action.async {
serviceName match {
case "wvyp" => respond(data = "56", delay = 10)
case "search" => respond(data = "10", delay = 5)
case "likes" => respond(data = "150", delay = 40)
case "comments" => respond(data = "14", delay = 20)
}
}
private def respond(data: String, delay: Long) = Promise.timeout(Ok(data), delay)
}
app/mock/Mock.scala
For each “service”, this endpoint returns mock data after a
fixed delay. In the real world, the data might be JSON.
42. object ServiceClient {
def makeServiceCall(serviceName: String): Future[String] = {
WS.url(s"http://localhost:9000/mock/$serviceName").get().map(_.body)
}
}
app/data/ServiceClient.scala
A simple client to make a remote call to the mock endpoint
and return a Future with the body of the HTTP response
43. @(wvypCount: Int, searchCount: Int)
<html>
<head>
<link rel="stylesheet" href="/assets/stylesheets/wvyp.css"/>
</head>
<body>
<div class="wvyp">
<h2>Who's Viewed Your Profile</h2>
@views.html.wvyp.wvypCount(wvypCount)
@views.html.wvyp.searchCount(searchCount)
</div>
</body>
</html>
app/views/wvyp/wvyp.scala.html
For the template, we use Play’s Scala templates (.scala.
html). This template uses two partials for the body.
44. @(wvypCount: Int)
<p class="wvyp-count">
<span class="large-number">@wvypCount</span>
<span>Your profile has been viewed by <b>@wvypCount</b> people in the past 3 days</span>
</p>
app/views/wvyp/wvypCount.scala.html
@(searchCount: Int)
<p class="search-count">
<span class="large-number">@searchCount</span>
<span>Your have shown up in search results <b>@searchCount</b> times in the past 3 days</span>
</p>
app/views/wvyp/searchCount.scala.html
The markup for the two partials that show the counts
45. object WVYP extends Controller {
def index = Action.async {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
}
}
app/controllers/WVYP.scala
Next, the WVYP controller. First, we make two service calls in
parallel to fetch the WVYP count and search count.
46. object WVYP extends Controller {
def index = Action.async {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
for {
wvypCount <- wvypCountFuture
searchCount <- searchCountFuture
} yield {
Ok(views.html.wvyp.wvyp(wvypCount.toInt, searchCount.toInt))
}
}
}
app/controllers/WVYP.scala
Next, we compose the two Futures into a single Future and
render the WVYP template.
53. trait Action[A] extends EssentialAction {
def apply(request: Request[A]): Future[SimpleResult]
}
play/api/mvc/Action.scala
In Play, an Action is a function
55. @(wvypCount: Int, searchCount: Int)
<html>
<head>
<link rel="stylesheet" href="/assets/stylesheets/wvyp.css"/>
</head>
<body>
@views.html.wvyp.wvypBody(wvypCount, searchCount)
</body>
</html>
app/views/wvyp/wvyp.scala.html
We need one change to each module: move the body into a
partial. Scala templates are functions, so they also compose!
56. def index(embed: Boolean) = Action.async {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
for {
wvypCount <- wvypCountFuture
searchCount <- searchCountFuture
} yield {
if (embed) Ok(views.html.wvyp.wvypBody(wvypCount.toInt, searchCount.toInt))
else Ok(views.html.wvyp.wvyp(wvypCount.toInt, searchCount.toInt))
}
}
app/controllers/WVYP.scala
Update each module’s controller to accept an “embed”
parameter: if it’s set to true, render only the body partial.
58. object Pagelet {
def readBody(result: SimpleResult)(implicit codec: Codec): Future[Html] = {
result.body.run(Iteratee.consume()).map(bytes => Html(new String(bytes, codec.charset)))
}
}
app/ui/Pagelet.scala
Add a helper that can take a SimpleResult and return its body
as Future[Html]. We’ll talk more about Iteratees later.
59. object Aggregator extends Controller {
def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
val wvyuFuture = Wvyu.index(embed = true)(request)
}
}
app/controllers/Aggregator.scala
Now for the aggregator controller. First, we call the two
submodules. Each returns a Future[SimpleResult].
60. object Aggregator extends Controller {
def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
val wvyuFuture = Wvyu.index(embed = true)(request)
for {
wvyp <- wvypFuture
wvyu <- wvyuFuture
wvypBody <- Pagelet.readBody(wvyp)
wvyuBody <- Pagelet.readBody(wvyu)
} yield {
}
}
}
app/controllers/Aggregator.scala
Read the body of each Future[SimpleResult] as Html using
the Pagelet helper we just added
61. object Aggregator extends Controller {
def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
val wvyuFuture = Wvyu.index(embed = true)(request)
for {
wvyp <- wvypFuture
wvyu <- wvyuFuture
wvypBody <- Pagelet.readBody(wvyp)
wvyuBody <- Pagelet.readBody(wvyu)
} yield {
Ok(views.html.aggregator.aggregator(wvypBody, wvyuBody))
}
}
}
app/controllers/Aggregator.scala
Pass the Html to the aggregator template
62. GET
@(wvypBody:/mock/:serviceName controllers.Mock.mock(serviceName: String)
Html, wvyuBody: Html)
GET
/wvyp
controllers.WVYP.index(embed: Boolean ?= false)
GET
<html>
/wvyu
controllers.WVYU.index(embed: Boolean ?= false)
GET
<head>
/aggregate
controllers.Aggregator.index
<link rel="stylesheet" href="/assets/stylesheets/wvyp.css"/>
<link rel="stylesheet" href="/assets/stylesheets/wvyu.css"/>
</head>
<body>
@wvypBody
@wvyuBody
</body>
</html>
app/views/aggregator/aggregator.scala
Add the aggregator to the routes file
64. Wins
1.
Abstraction: we can focus on just one small part of the page while safely
ignoring all the rest.
2.
Composition: we can build complicated pages by putting together simpler
parts. We can get lots of reuse from common pieces.
3.
Testing and iterating on a small, standalone unit is much easier and faster.
65. Caveats
1.
Standalone modules may be inefficient in terms of duplicated service calls.
However, de-duping is straightforward.
2.
Have to merge and dedupe static content.
68. Some questions:
1. How do we set cookies?
2. How do we handle errors?
3. How do we aggregate static content?
69. It turns out HTTP is an elegant way to answer
these questions.
70. object WVYP extends Controller {
def index(embed: Boolean) = Action {
// [snip]
Ok(views.html.wvyp.wvyp(wvypCount.toInt, searchCount.toInt)).withCookies(Cookie(“foo”, “bar”))
}
}
app/controllers/WVYP.scala
For example, imagine a standalone module sets a Cookie
71. object Aggregator extends Controller {
def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
for {
wvyp <- wvypFuture
wvypBody <- Pagelet.readBody(wvyp)
wvypHeaders = wvyp.header.headers // The Cookie header here will contain the “foo” cookie!
} yield {
Ok(views.html.aggregator.aggregator(wvypBody))
}
}
}
app/controllers/Aggregator.scala
The aggregator will see that as a Cookie header!
72. object Pagelet {
def mergeCookies(results: SimpleResult*): Seq[Cookie] = {
results.flatMap { result =>
result.header.headers.get(HeaderNames.SET_COOKIE).map(Cookies.decode).getOrElse(Seq.empty)
}
}
}
app/ui/Pagelet.scala
We can add a helper to merge the cookies from multiple
SimpleResult objects
73. def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
val wvyuFuture = Wvyu.index(embed = true)(request)
for {
wvyp <- wvypFuture
wvyu <- wvyuFuture
wvypBody <- Pagelet.readBody(wvyp)
wvyuBody <- Pagelet.readBody(wvyu)
} yield {
Ok(views.html.aggregator.aggregator(wvypBody, wvyuBody))
.withCookies(Pagelet.mergeCookies(wvyp, wvyu):_*)
}
}
app/controllers/Aggregator.scala
Update the aggregator to write out the merged cookies
74. We can see the aggregate endpoint is now setting cookies
81. def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
for {
wvyp <- wvypFuture
wvypBody <- Pagelet.readBody(wvyp)
} yield {
if (wvyp.status == 200) {
Ok(views.html.aggregator.aggregator(wvypBody))
} else {
// Handle errors
}
}
}
app/controllers/Aggregator.scala
The aggregator can read the status codes and react
accordingly
82. CSS and JS dependencies can be set as a
header and aggregated too.
83. object StaticContent {
val cssHeaderName = "X-CSS"
val jsHeaderName = "X-JS"
def asHeaders(css: Seq[String], js: Seq[String]): Seq[(String, String)] = {
Seq(cssHeaderName -> css.mkString(","), jsHeaderName -> js.mkString(","))
}
}
app/ui/StaticContent.scala
Add a helper to create the headers
84. def index(embed: Boolean) = Action {
// [...] all other code is the same as before [...]
val css = Vector("/assets/stylesheets/wvyp.css")
val js = Vector.empty[String]
if (embed) {
Ok(views.html.wvyp.wvypBody(wvypCount.toInt, searchCount.toInt))
.withHeaders(StaticContent.asHeaders(css, js):_*)
} else {
Ok(views.html.wvyp.wvyp(wvypCount.toInt, searchCount.toInt, css, js))
}
}
app/controllers/WVYP.scala
In embed mode, return CSS/JS dependencies as headers. In
standalone mode, render them in the template.
86. def index = Action.async { request =>
val wvypFuture = Wvyp.index(embed = true)(request)
val wvyuFuture = Wvyu.index(embed = true)(request)
for {
wvyp <- wvypFuture
wvyu <- wvyuFuture
wvypBody <- Pagelet.readBody(wvyp)
wvyuBody <- Pagelet.readBody(wvyu)
} yield {
val css = StaticContent.mergeCssHeaders(wvyp, wvyu)
val js = StaticContent.mergeCssHeaders(wvyp, wvyu)
Ok(views.html.aggregator.aggregator(wvypBody, wvyuBody, css, js))
}
}
app/controllers/Aggregator.scala
The aggregator can merge and de-dupe the static content
and pass it to its template for rendering
87. Wins
1.
Modules are truly standalone
2.
Can dynamically compose modules using Play’s router
3.
Can compose modules from remote endpoints
4.
Can reuse endpoints from the browser via AJAX
5.
Static content dependencies explicitly defined
101. Quick Review
1.
An Enumerator is a Producer. It pumps out chunks of data.
2.
An Iteratee is a Consumer. It reactively consumes chunks of data.
3.
An Enumeratee is an Adapter. You can attach them in front of Iteratees
and Enumerators to filter the chunks of data.
105. object EnumeratorExample extends Controller {
def index = Action {
Ok.chunked(/* We need an Enumerator here */)
}
}
app/controllers/EnumeratorExamples.scala
Play has an Ok.chunked method which can stream out the
contents of an Enumerator
106. object EnumeratorExample extends Controller {
def index = Action {
Ok.chunked(Enumerator("Created", " using", " Enumerator", ".apply()nn"))
}
}
app/controllers/EnumeratorExamples.scala
The Enumerator object has several factory methods. For
example, Enumerator.apply creates one from a fixed list.
108. object EnumeratorExample extends Controller {
def index = Action {
Ok.chunked(Enumerator.repeatM(Promise.timeout("Hellon", 500)))
}
}
app/controllers/EnumeratorExamples.scala
You can also create an Enumerator that repeats a value
generated from a Future
109. The word “Hello” is pumped out every 500ms. Note: when
testing streaming, use “curl -N” so curl doesn’t buffer.
110. def index = Action {
val helloEnumerator = Enumerator("hello ")
val goodbyeEnumerator = Enumerator("goodbyenn")
val helloGoodbyeEnumerator = helloEnumerator.andThen(goodbyeEnumerator)
Ok.chunked(helloGoodbyeEnumerator)
}
app/controllers/EnumeratorExamples.scala
Most importantly, you can combine Enumerators. Here is an
example using the andThen method.
111. With andThen, we see all the data from the first enumerator
and then all the data from the second one
112. def index = Action {
val helloEnumerator = Enumerator.repeatM(Promise.timeout("Hellon", 500))
val goodbyeEnumerator = Enumerator.repeatM(Promise.timeout("Goodbyen", 1000))
val helloGoodbyeEnumerator = Enumerator.interleave(helloEnumerator, goodbyeEnumerator)
Ok.chunked(helloGoodbyeEnumerator)
}
app/controllers/EnumeratorExamples.scala
We can also combine Enumerators using Enumerator.
interleave
113. With interleave, data can come in any order. Above, we see
“Hello” every 500ms and “Goodbye” every 1000ms.
115. object WVYPEnumerator extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
}
}
app/controllers/WVYPEnumerator.scala
Create the new WVYP controller. Again, we start with two
service calls.
116. object WVYPEnumerator extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountEnum = Enumerator.flatten(wvypCountFuture.map(str => Enumerator(str + "n")))
val searchCountEnum = Enumerator.flatten(searchCountFuture.map(str => Enumerator(str + "n")))
}
}
app/controllers/WVYPEnumerator.scala
Next, convert each Future[String] into an Enumerator[String]
117. object WVYPEnumerator extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountEnum = Enumerator.flatten(wvypCountFuture.map(str => Enumerator(str + "n")))
val searchCountEnum = Enumerator.flatten(searchCountFuture.map(str => Enumerator(str + "n")))
val body = wvypCountEnum.andThen(searchCountEnum)
Ok.chunked(body)
}
}
app/controllers/WVYPEnumerator.scala
Finally, compose the two Enumerators and use Ok.chunked
to stream them out
118. GET
@(wvypBody:/mock/:serviceName controllers.Mock.mock(serviceName: String)
Html, wvyuBody: Html)
GET
/wvyp
controllers.WVYP.index(embed: Boolean ?= false)
GET
<html>
/wvyu
controllers.WVYU.index(embed: Boolean ?= false)
GET
<head>
/aggregate
controllers.Aggregator.index
GET <link rel="stylesheet" href="/assets/stylesheets/wvyp.css"/>
/wvyp/enumerator
controllers.WVYPEnumerator.index
<link rel="stylesheet" href="/assets/stylesheets/wvyu.css"/>
</head>
<body>
@wvypBody
@wvyuBody
</body>
</html>
app/views/aggregator/aggregator.scala
Add a routes entry for the enumerator-based WVYP controller
124. play.Keys.templatesTypes ++= Map("stream" -> "ui.HtmlStreamFormat")
play.Keys.templatesImport ++= Vector("ui.HtmlStream", "ui.HtmlStream._", "ui.StaticContent")
build.sbt
Play allows you to define a new template type. This will allow
us to create .scala.stream files instead of .scala.html.
125. The new template type must define an
“Appendable” and a “Format” for it
126. trait Appendable[T] {
def +=(other: T): T
}
play/api/templates/Templates.scala
The Appendable trait is simple: it’s anything with an append
(+=) operator.
127. class Html(buffer: StringBuilder) extends Appendable[Html](buffer) {
def +=(other: Html) = {
buffer.append(other.buffer)
this
}
}
play/api/templates/Templates.scala
The default Appendable built into Play is Html, which just
wraps a StringBuilder.
128. case class HtmlStream(enumerator: Enumerator[Html]) extends Appendable[HtmlStream] {
def +=(other: HtmlStream): HtmlStream = andThen(other)
def andThen(other: HtmlStream): HtmlStream = HtmlStream(enumerator.andThen(other.enumerator))
}
app/ui/HtmlStream.scala
For streaming templates, we create an HtmlStream
Appendable that wraps an Enumerator[Html].
129. object HtmlStream {
def interleave(streams: HtmlStream*): HtmlStream = {
HtmlStream(Enumerator.interleave(streams.map(_.enumerator)))
}
def flatten(eventuallyStream: Future[HtmlStream]): HtmlStream = {
HtmlStream(Enumerator.flatten(eventuallyStream.map(_.enumerator)))
}
}
app/ui/HtmlStream.scala
We add an HtmlStream companion object with helper
methods to combine HtmlStreams just like Enumerators
130. object HtmlStream {
def apply(text: String): HtmlStream = {
apply(Html(text))
}
def apply(html: Html): HtmlStream = {
HtmlStream(Enumerator(html))
}
def apply(eventuallyHtml: Future[Html]): HtmlStream = {
flatten(eventuallyHtml.map(apply))
}
}
app/ui/HtmlStream.scala
Also, we add several methods to the HtmlStream companion
object to help create HtmlStream instances.
131. trait Format[T <: Appendable[T]] {
type Appendable = T
def raw(text: String): T
def escape(text: String): T
}
play/api/templates/Templates.scala
The Format trait is what Play will use to create your
Appendable from a String
133. object HtmlStreamImplicits {
// Implicit conversion so HtmlStream can be passed directly to Ok.feed and Ok.chunked
implicit def toEnumerator(stream: HtmlStream): Enumerator[Html] = {
// Skip empty chunks, as these mean EOF in chunked encoding
stream.enumerator.through(Enumeratee.filter(!_.body.isEmpty))
}
}
app/ui/HtmlStream.scala
We also include an implicit conversion so HtmlStream can be
passed directly to Ok.chunked
135. object WVYPStream extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
}
}
app/controllers/WVYPStream.scala
Now for the streaming controller. As usual, start with the
service calls.
136. object WVYPStream extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountHtmlFuture = wvypCountFuture.map(str => views.html.wvyp.wvypCount(str.toInt))
val searchCountHtmlFuture = searchCountFuture.map(str => views.html.wvyp.searchCount(str.toInt))
}
}
app/controllers/WVYPStream.scala
Next, render the data in each Future as Html
137. object WVYPStream extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountHtmlFuture = wvypCountFuture.map(str => views.html.wvyp.wvypCount(str.toInt))
val searchCountHtmlFuture = searchCountFuture.map(str => views.html.wvyp.searchCount(str.toInt))
val wvypCountStream = HtmlStream(wvypCountHtmlFuture)
val searchCountStream = HtmlStream(searchCountHtmlFuture)
}
}
app/controllers/WVYPStream.scala
Convert each Future[Html] to an HtmlStream using the factory
methods we created earlier
138. object WVYPStream extends Controller {
def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountHtmlFuture = wvypCountFuture.map(str => views.html.wvyp.wvypCount(str.toInt))
val searchCountHtmlFuture = searchCountFuture.map(str => views.html.wvyp.searchCount(str.toInt))
val wvypCountStream = HtmlStream(wvypCountHtmlFuture)
val searchCountStream = HtmlStream(searchCountHtmlFuture)
val body = wvypCountStream.andThen(searchCountStream)
Ok.chunked(views.stream.wvypStreaming(body))
}
}
app/controllers/WVYPStream.scala
Finally, combine the streams using andThen and render the
streaming template
139. GET
@(wvypBody:/mock/:serviceName controllers.Mock.mock(serviceName: String)
Html, wvyuBody: Html)
GET
/wvyp
controllers.WVYP.index(embed: Boolean ?= false)
GET
<html>
/wvyu
controllers.WVYU.index(embed: Boolean ?= false)
GET
<head>
/aggregate
controllers.Aggregator.index
GET <link rel="stylesheet" href="/assets/stylesheets/wvyp.css"/>
/wvyp/enumerator
controllers.WVYPEnumerator.index
GET <link rel="stylesheet" href="/assets/stylesheets/wvyu.css"/>
/wvyp/stream
controllers.WVYPStream.index
</head>
<body>
@wvypBody
@wvyuBody
</body>
</html>
app/views/aggregator/aggregator.scala
Add a routes entry for the stream-based WVYP controller
140. The page loads almost immediately and we see the WVYP
count right away.
151. @(contents: Html, id: String)
<script type="text/html-stream" id="@id-contents">
@contents
</script>
<script type="text/javascript">
document.getElementById("@id").innerHTML = document.getElementById("@id-contents").innerHTML;
</script>
app/views/ui/pagelet.scala.html
Create a “pagelet” template that will take a snippet of HTML
and use JS to inject it into the proper spot on the page.
152. object Pagelet {
def render(html: Html, id: String): Html = {
views.html.ui.pagelet(html, id)
}
def renderStream(html: Html, id: String): HtmlStream = {
HtmlStream(render(html, id))
}
def renderStream(htmlFuture: Future[Html], id: String): HtmlStream = {
HtmlStream.flatten(htmlFuture.map(html => renderStream(html, id)))
}
}
app/ui/Pagelet.scala
Add a Pagelet class with helper methods to wrap Html and
Future[Html] in the pagelet template.
153. @(body: ui.HtmlStream)
<html>
<head>
<link rel="stylesheet" href="/assets/stylesheets/wvyp.css"/>
</head>
<body>
<div class="wvyp">
<h2>Who's Viewed Your Profile</h2>
<div id="wvypCount"></div>
<div id="searchCount"></div>
</div>
@body
</body>
</html>
app/views/wvyp/wvyp.scala.stream
Update the WVYP streaming template to include placholder
divs for the WVYP and search counts
154. def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountHtmlFuture = wvypCountFuture.map(str => views.html.wvyp.wvypCount(str.toInt))
val searchCountHtmlFuture = searchCountFuture.map(str => views.html.wvyp.searchCount(str.toInt))
}
app/controllers/WVYPStream.scala
Finally, update the WVYPStream controller. We still make two
service calls and render each as HTML.
155. def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountHtmlFuture = wvypCountFuture.map(str => views.html.wvyp.wvypCount(str.toInt))
val searchCountHtmlFuture = searchCountFuture.map(str => views.html.wvyp.searchCount(str.toInt))
val wvypCountStream = Pagelet.renderStream(wvypCountHtmlFuture, "wvypCount")
val searchCountStream = Pagelet.renderStream(searchCountHtmlFuture, "searchCount")
}
app/controllers/WVYPStream.scala
This time, we convert the Future[Html] into an HtmlStream
using the Pagelet.renderStream helper method.
156. def index = Action {
val wvypCountFuture = ServiceClient.makeServiceCall("wvyp")
val searchCountFuture = ServiceClient.makeServiceCall("search")
val wvypCountHtmlFuture = wvypCountFuture.map(str => views.html.wvyp.wvypCount(str.toInt))
val searchCountHtmlFuture = searchCountFuture.map(str => views.html.wvyp.searchCount(str.toInt))
val wvypCountStream = Pagelet.renderStream(wvypCountHtmlFuture, "wvypCount")
val searchCountStream = Pagelet.renderStream(searchCountHtmlFuture, "searchCount")
val body = HtmlStream.interleave(wvypCountStream, searchCountStream)
Ok.chunked(views.stream.wvypStreaming(body))
}
app/controllers/WVYPStream.scala
Instead of using andThen, we compose the streams using
interleave. This way, the Html will stream as soon as it’s ready.
157. The page loads almost immediately, but now we see the
search count first
159. We now have the basics for out-of-order,
BigPipe style rendering!
160. Wins
1.
Much faster time to first byte
2.
Static content can start loading much earlier
3.
User can see and start interacting with the page almost immediately
4.
Out of order rendering with JavaScript allows even deeper optimizations
161. Caveats
1.
Once you’ve streamed out the HTTP headers, you can’t change them. This
means cookies and error handling may have to be done client-side.
2.
Have to be careful with “pop in” and redraws. Client side code should
intelligently choose when to insert items into the DOM.
3.
Need to handle CSS and JS dependencies differently
4.
Testing is harder
5.
May need to disable JavaScript rendering for SEO
162. You can find the code from this presentation at:
https://github.com/brikis98/ping-play