何爲Body parser?
一個HTTP請求由請求頭和請求體組成。header部分通常很小 —— 因此可以在內存中被安全的緩存,在Play中對應着RequestHeader模型。相對而言,body部分可能會非常大,這時它不會直接緩存在內存中,而是以流的形式來處理。但是許多請求的請求體也會很小, 可以直接映射到內存,爲了將請求體的流看做一個內存對象,Play提供了BodyParser抽象。
Play作爲一個異步框架,無法使用傳統的InputStream來讀取請求體的流——因爲InputStream是阻塞的,當你調用read方法時,調用此方法的的線程必須等待數據到達並可用。作爲替代,Play提供了一個異步的流處理庫——Akka Streams。Akka流是Reactive Stream的實現,一個允許多個異步API無縫集成的SPI。記住基於InputStream的技術在Play中是不適用的,Akka Stream異步庫及其完整的生態環境將提供你需要的全部。
關於Actions
前面我們說過Action是一個 Request => Result 函數。這個說法並不完全正確,我們先來看看Action:
trait Action[A] extends (Request[A] => Result) {
def parser: Parser[A]
}
首先能看到類定義中的泛型A,然後一個action必須定義一個BodyParser[A]。相應的Request[A]定義如下:
trait Request[+A] extends RequestHeader {
def body: A
}
A類型即請求體的類型。我們可以使用任意Scala類型作爲請求體,如: String,NodeSeq,Array[Byte],JsonValue,或者java.io.File,只要有相應的body parser來處理它。
總結一下,Action[A]使用了一個BodyParser[A]來從HTTP請求中獲取類型A,然後創建一個Request[A]對象並將它傳給action代碼。
使用內置的body parsers
大多數的常見的web apps不需要自定義新的body parsers,只需要使用Play內置的body parser就可以工作的很好。包括 JSON,XML,forms及普通文本格式的body體(String)、二進制的body體(ByteString)。
默認的body parser
如果沒有顯式的指定一個body parser,Play將會根據 Content-Type 選擇一個對應的body parser。如,一個Context-Type爲application/json將會當做JsValue處理,而application/x-www-from-unlencoded 將會被處理爲 Map[String, Seq[String]]
默認的parser將會創建一個類型爲 AnyContent 的body,AnyContext中的可變可變由 as 方法指定,如asJson將會返回一個Option類型的body:
def save = Action {request: Request[AnyContent] =>
val body: AnyContent = request.body
val jsonBody: Option[JsValue] = body.asJson
// expecting json body
jsonBody.map { json =>
Ok("Got: " + (json \ "name").as[String])
}.getOrElse {
BadRequest("Expecting application/json request body")
}
}
下面是一張默認body parser的映射列表:
- text/plain:String,對應asText
- application/json:JsValue,對應 asJson
- application/xml,text/xml 或 application/XXX+xml:scala.xml.NodeSeq,對應asXml
- application/x-www-form-urlencoded:Map[String, Seq[String]],對應 asFormUrlEncoded
- multipart/form-data:MultipartFormData,對應asMultipartFormData。
- 任意其他類型:RawBuffer,對應 asRaw
默認的body parser在解析前會判斷request是否包含了body。HTTP規範規定了 Content-Length / Transfer-Encoding 示意了請求中會帶body,因此parser僅在請求提供了這些頭時纔會解析,還有一種情況就是在 FakeRequest 中明確設置了非空body。
如果你希望在任意情況下都解析body,你可以嘗試使用下文中提到的 anyContent body parser。
指定body parser
如果你希望顯式指定一個body parser,可以通過調用Action 的 apply 或 async 方法,向其傳遞一個 body parser。
Play提供了一系列開箱即用的body parser,他們都繼承了PlayBodyParsers特質,可以直接注入到controller。
一個處理json body的action示例如下:
def save = Action(parse.json) { request: Request[JsValue] =>
Ok("Got: " + (request.body \ "name").as[String])
}
注意這裏的body類型爲JsValue而非Option,因此變得更加容易處理。內部的機制是json body parser將校驗請求是否有application/json的Content-Type,如果沒有,將直接返回415 Unsupported Media Type。所以我們的代碼中無需再次檢測。
這意味着提高了對客戶端代碼的規範要求,必須保證他們的Content-Type被正確設置。如果你想放鬆要求,可以使用 tolerantJson方法,它會忽略Content-Type,並努力嘗試解析body。
def save = Action(parse.tolerantJson) { request: Request[JsValue] =>
Ok("Got: " + (request.body \ "name").as[String])
}
下面是一個將request body寫入文件的例子:
def save = Action(parse.file(to = new File("/tmp/upload"))) { request: Request[File]
Ok("Save the request content to " + request.body)
}
組合body parsers
前面的例子中,所有request bodies全部存儲在同一個文件中。下面我們來從request中解析用戶名,來爲每個用戶創建單獨的文件:
val storeInUserFile = parse.using { request =>
request.session.get("username").map { user =>
parse.file(to = new File("/tmp/" + user + ".upload"))
}.getOrElse {
sys.error("You don't have the right to upload here")
}
}
def save = Action(storeInUserFile) { request =>
Ok("Saved the request content to " + request.body)
}
注意:這並不是寫一個新的parser,而是組合了已有parser。這種方式已足以應付大多數情況。關於如何從零開始自定義一個BodyParser將在高級主題中講述。
最大內容長度
基於文本的body parsers(包括 text,json,xml 或者 formUrlEncoded)使用了 最大內容長度 (max content length),因爲它們需要將整個content載入內存。默認情況下,最大的content length是100kb。這個值可以通過設置application.conf中的play.http.parser.maxMemoryBuffer來重新定義:
play.http.parser.maxMemoryBuffer=128K
有些parser會將內容緩存在硬盤上,如 raw parser 或者 multipart/form-data,最大content length由 play.http.parser.maxDiskBuffer定義,默認值是10MB。multipart/form-data parser還會數據字段的聚合強制使用 text max length 屬性。
你可以在指定action中覆蓋這個設置:
// Accept only 10KB of data.
def save = Action(parse.text(maxLength = 1024 * 10)) { request: Request[String] =>
Ok("Got: " + text)
}
你還可以爲任意的body parser指定maxLength:
// Accept only 10KB of data.
def save = Action(parse.maxLength(1024 * 10, storeInUserFile)){ request =>
Ok("Saved the request content to " + request.body)
}
自定義body parser
你可以通過實現BodyParser特質來自定義一個body parser。BodyParser是一個簡單地函數:
trait BodyParser[+A] extends (RequestHeader => Accumulator[ByteString, Either[Result, A]])
這個函數的簽名看起來有點嚇人,所以下面一起來分解。
函數接受一個RequestHeader。它將被用來檢查request信息 —— 大多數情況下它將檢查 Content-Type,來保證body以正確的格式被解析。
函數的返回值類型是 Accumulator。累加器(accumulator)是對Akka Streams Sink的簡單封裝。累加器異步的將元素流累積到result中,它可以通過傳入Akka Streams Source 來運行,並返回一個Future指示累加器的完成狀態。本質上它和Sink[E, Future[A]]是一樣的,事實上也的確如此,它就是在其上的一層封裝。不同之處在於Accumulator提供了一系列有用的方法,如map,mapFuture,recover等等,將result視爲一個promise來操作。Sink要求所有這些操作都包含在mapMaterializedValue調用中。
累加器的apply方法返回的是 ByteString 類型 —— 其實就是bytes數組,不同之處是ByteString是不可變的,並且以固定時間耗費提供了 slicing、appending 等操作。
累加器的返回值是 Either[Result, A] —— 即返回一個Result,或者一個A類型的body。result一般是在發生錯誤時返回,如者 body parser 不接受此Content-Type類型導致解析失敗,或者超出了內存中的緩存大小限制。當body parser返回一個result時,它將此action短路 —— body parser立即返回,action將不會被調用。
重定向body
一個寫body parser的常見例子是你不想處理此body,而是想將它引到其它地方。你可以這樣定義你的parser:
import javax.inject._
import play.api.mvc._
import play.api.libs.streams._
import play.api.libs.ws._
import scala.concurrent.ExecutionContext
import akka.util.ByteString
class MyController @Inject() (ws: WSClient, val controllerComponents: ControllerComponents)
(implicit ec: ExecutionContext) extends BaseController {
def forward(request: WSRequest): BodyParser[WSResponse] = BodyParser { req =>
Accumulator.source[ByteString].mapFuture { source =>
request
.withBody(source)
.execute()
.map(Right.apply)
}
}
def myAction = Action(forward(ws.url("https://example.com"))) { req =>
Ok("Uploaded")
}
}
使用Akka Streams來自定義解析
在極少數情況下,你可能需要使用到Akka Streams。大多數情況下你可以將body緩存到一個ByteString中,這樣會使操作簡單很多,而且提供了對body的隨機訪問。
但是,當你需要處理很長的body時你就無法將它整個放入內存。
如何使用Akka Streams已經超出了本文檔的講述範圍。你可以移步這裏查看 Akka Streams 的細節。我們下面提供了一個CSV解析器的簡單例子,它基於Akka Streams cookbook 的 Parsing lines from a stream of ByteStrings 部分:
import play.api.mvc.BodyParser
import play.api.libs.streams._
import akka.util.ByteString
import akka.stream.scaladsl._
val csv: BodyParser[Seq[Seq[String]]] = BodyParser { req =>
// A flow that splits the stream into CSV lines
val sink: Sink[ByteString, Future[Seq[Seq[String]]]] = Flow[ByteString]
// We split by the new line character, allowing a maximum of 1000 characters per line
.via(Framing.delimiter(ByteString("\n"), 1000, allowTruncation = true))
// Turn each line to a String and split it by commas
.map(_.utf8String.trim.split(",").toSeq)
// Now we fold it into a list
.toMat(Sink.fold(Seq.empty[Seq[String]])(_ :+ _))(Keep.right)
// Convert the body to a Right either
Accumulator(sink).map(Right.apply)
}