Streaming HTTP responses

标准响应和 Content-Length 报头

从 HTTP 1.1 开始,服务器为了保持一个连接的连通并服务多个 HTTP 请求和响应,必须在响应中写入合适的 Content-Length HTTP 报头。

一般情况下,当你发送一个简单结果时并不会指定 Content-Length 报头,比如:

  1. def index = Action {
  2. Ok("Hello World")
  3. }

当然,由于你所发送的内容显而易见,Play能够计算出内容长度并生成适当的报头。

需要注意的是,基于文本的内容长度计算并没有如你所见的这般简单,因为 Content-Length 报头的计算取决于将字符转换为字节码的字符编码。

事实上,我们之前看到的响应体都是由 play.api.libs.iteratee.Enumerator 所指定的:

  1. def index = Action {
  2. Result(
  3. header = ResponseHeader(200),
  4. body = Enumerator("Hello World")
  5. )
  6. }

也就是说,为了能够正确的计算出 Content-Length 报头,Play必须读取整个枚举器(enumerator),并将其加载入内存中。

发送大量数据

如果对于简单的枚举器(enumerator)来说,将所有数据加载入内存中并不是个问题,但大数据集呢?假设我们想要返回给 web 客户端一个很大的文件。

让我们先来看看怎么创建一个 Enumerator[Array[Byte]] 来枚举整个文件的内容:

  1. val file = new java.io.File("/tmp/fileToServe.pdf")
  2. val fileContent: Enumerator[Array[Byte]] = Enumerator.fromFile(file)

这看起来很简单对吧?让我们接着用这个枚举器(enumerator)来指定响应体:

  1. def index = Action {
  2. val file = new java.io.File("/tmp/fileToServe.pdf")
  3. val fileContent: Enumerator[Array[Byte]] = Enumerator.fromFile(file)
  4. Result(
  5. header = ResponseHeader(200),
  6. body = fileContent
  7. )
  8. }

事实上,这里有一个问题。因为我们并没有指定 Content-Length 报头,Play 需要自己来计算,唯一的方法就是读取整个枚举器(enumerator)并加载入内存,然后再计算响应的长度。

问题在于我们并不想将整个大文本加载入内存中。为了避免这种情况,我们必须自己来指定 Content-Length 报头。

  1. def index = Action {
  2. val file = new java.io.File("/tmp/fileToServe.pdf")
  3. val fileContent: Enumerator[Array[Byte]] = Enumerator.fromFile(file)
  4. Result(
  5. header = ResponseHeader(200, Map(CONTENT_LENGTH -> file.length.toString)),
  6. body = fileContent
  7. )
  8. }

Play 会以一种惰性方式来读取这个枚举器(enumerator),在数据块可用时才将其复制到 HTTP 响应中。

处理文件

当然,Play 提供了简单易用的 helper 来处理本地文件:

  1. def index = Action {
  2. Ok.sendFile(new java.io.File("/tmp/fileToServe.pdf"))
  3. }

这个 helper 能够根据文件名计算出 Content-Type 报头,并且添加 Content-Disposition 报头告诉 web 浏览器该如何处理这个响应。默认会让 web 浏览器下载该文件并在响应中添加 Content-Disposition: attachment; filename=fileToServe.pdf 报头。

你也可以指定文件名:

  1. def index = Action {
  2. Ok.sendFile(
  3. content = new java.io.File("/tmp/fileToServe.pdf"),
  4. fileName = _ => "termsOfService.pdf"
  5. )
  6. }

如果你想以 inline 的方式处理该文件:

  1. def index = Action {
  2. Ok.sendFile(
  3. content = new java.io.File("/tmp/fileToServe.pdf"),
  4. inline = true
  5. )
  6. }

这样就不用指定文件名了,因为 web 浏览器根本不会尝试下载,而是将所有文本显示在 web 浏览器窗口中。这对于那些浏览器本身已经支持了的文本类型非常有用,如文本,html 和图片。

分块响应

到现在为止,流处理文件内容工作的非常好,主要是因为能够在流处理之前算出文本长度。但是如果是动态计算,还没有得出长度的内容呢?

对于这样的响应,我们需要使用 分块传输编码 (Chunked transfer encoding)。

分块传输编码(Chunked transfer encoding)是一种定义在 Hypertext Transfer Protocol(HTTP)1.1 版本中的数据传输机制,其中 web 服务器会将文本分块处理。它使用了 Transfer-Encoding HTTP 响应报头而非 Content-Length 报头,如果你没有使用 Content-Length 的话,则必须使用这个报头。由于没有 Content-Length,服务器无需在开始传输响应到客户端(通常是 web 客户端)之前就知道内容的长度。 Web 服务器在知道内容总长前就能够传输动态生成的内容响应。

在发送每个数据块前,都会先发送块的大小,客户端能够依此判断是否已完整接收该数据块。数据传输会在收到一个长度为零的数据块后终结。

http://en.wikipedia.org/wiki/Chunked_transfer_encoding

这样做的好处是能够实时处理数据,一旦数据可用我们便会发送。缺点是由于浏览器不知道内容长度,无法显示正确的下载进度。

假设我们有个服务,提供了一个动态 InputStream 来计算一些数据。首先我们需要为这个流创建一个 Enumerator

  1. val data = getDataStream
  2. val dataContent: Enumerator[Array[Byte]] = Enumerator.fromStream(data)

这样就能使用 ChunkedResult 来流处理这些数据了:

  1. def index = Action {
  2. val data = getDataStream
  3. val dataContent: Enumerator[Array[Byte]] = Enumerator.fromStream(data)
  4. ChunkedResult(
  5. header = ResponseHeader(200),
  6. chunks = dataContent
  7. )
  8. }

Play 同样提供了一些 helper :

  1. def index = Action {
  2. val data = getDataStream
  3. val dataContent: Enumerator[Array[Byte]] = Enumerator.fromStream(data)
  4. Ok.chunked(dataContent)
  5. }

当然,我们可以使用任一 Enumerator 来指定数据块:

  1. def index = Action {
  2. Ok.chunked(
  3. Enumerator("kiki", "foo", "bar").andThen(Enumerator.eof)
  4. )
  5. }

我们可以检查服务器发回的 HTTP 响应:

  1. HTTP/1.1 200 OK
  2. Content-Type: text/plain; charset=utf-8
  3. Transfer-Encoding: chunked
  4. 4
  5. kiki
  6. 3
  7. foo
  8. 3
  9. bar
  10. 0

我们得到了三个数据块,最后还跟了一个空数据块用来结束响应。