관리 메뉴

HAMA 블로그

[Play2] Body parsers 이해하기 본문

PlayFramework2

[Play2] Body parsers 이해하기

[하마] 이승현 (wowlsh93@gmail.com) 2017. 3. 21. 10:56



Play2 는 뼛속부터 비동기로 이루어져 있기 때문에 최강성능&부드러운 서버라는 장점을 가지고 있지만, 비동기라는 그리 직관적이지 않은 기술을 내부에 포함하고 있기 때문에 때론 굉장히 헥깔리게 만들기도 합니다. 하지만 Scala 언어및 다양한 동시성 라이브러리의 지원으로 추상층을 끌어올려 아주 간단한 코드로 그런 강력한 능력을 얻게 해주니깐 걱정마세요 ^^

우리가 SQL 문을 작성할때, 그 짧은 코딩으로 매우 많은 일들이 물밑에서 이루어지는 것처럼 즉 모든것을 알지 않아도 편하게 소기의 성과를 이루는 것처럼, Play2 내부에서 이루어지는 모든 것들을 상세하게 이해하지 않아도 됩니다. "해결" 을 하는게 응용개발자의 목적이니까요.  

이제 Play2 에서 HTTP 안의 body 를 어떻게 다루는지 살펴보겠습니다.


선행 참고) 

스프링에서 컨트롤러 함수의 예가 다음과 같은 모습을 취하는 반면 

@RequestMapping(method = RequestMethod.GET)
public String printHello(ModelMap model) {
model.addAttribute("message", "Hello Spring MVC Framework!");
return "hello";
}

Play2 웹개발에서는 아래와 같습니다. (위의 자바 예와 동일한 내용의 예는 아닙니다.) 

def doSomething = Action{
Ok.apply(views.html.index("Hi there"))
}


Body parsers


바디 파서란 무엇인가요? 

HTTP 요청은 보통 헤더와 함께 body가 따라옵니다. 헤더는 일반적으로 매우 작기 때문에  메모리에 안전하게 버퍼링 될 수 있기 때문에 Play에서는 RequestHeader 클래스를 사용하여 그 내용을 모델링하죠. body는 어떤 경우에는 매우 길 수도 있어서 메모리에 버퍼링 되지 않고 스트림으로 모델링 됩니다.그러나 대부분 request body 페이로드는 작고 메모리에서 모델링 될 수 있으므로 body 스트림을 메모리의 객체에 매핑하기 위해 Play는 BodyParser 를 제공합니다.

Play는 비동기향 프레임워크 이므로 전통적인 InputStream은 요청 본문을 읽는 데 사용할 수 없습니다. 입력 스트림이 블로킹하는 경우 읽기를 호출하면 호출하는 스레드가 데이터를 사용할 수있을 때까지 기다려야합니다. 대신 PlayAkka Streams라는 비동기 스트리밍 라이브러리를 사용하는데요. Akka Streams는 많은 비동기식 스트리밍 API가 원활하게 작동 할 수 있도록 해주는 Reactive Streams의 구현입니다. 따라서 전통적인 InputStream 기반 기술은 Play와 함께 사용하기에 적합하지 않으며 대신  Akka Streams 및 Reactive Streams 주변의 비동기 라이브러리의 생태계는 당신이 필요한 모든 것을 가지고 있습니다.


액션(Actions) 에 대하여

이전에 Action이 Request => Result 함수라고 했었던 적이 있는데요. 이것은 사실이 아닙니다. Action 트레잇을 보다 자세하게 살펴 보죠. 

* 보통 우리는 이렇게 사용했었죠?

Action { request =>
  Ok("Got request [" + request + "]")
}

* trait 은 이렇습니다. (역주: 사실 이것을 정확히 이해하려면  Request[A] => Result 라는 함수리터럴이 클래스라는 것과 내부에 apply 메소드를 가진다는 것을 알아야한다. 즉 스칼라언어에 대한 기초가 전제됨.) 

trait Action[A] extends (Request[A] => Result) {
  def parser: BodyParser[A]
}

제너릭 타입 A가 있으며, 액션에서 BodyParser [A]를 정의해야 한다는 것을 알 수 있습니다.
이어서 Request[A]는 다음과 같이 정의됩니다.

trait Request[+A] extends RequestHeader {
  def body: A
}

A 타입은 리퀘스트 바디의 타입이구요. 그런 리퀘스트 바디 타입은 스칼라 타입으로 (예 : String, NodeSeq, Array [Byte], JsonValue 또는 java.io.File) 사용할 수 있습니다.

요약하면 Action [A]는 BodyParser [A]를 사용하여 HTTP 요청에서 A 타입의 값을 검색&뽑아내고 Action apply 코드로 전달합니다.

기본으로 제공하는 바디 파서들 

일반적인 웹앱들 경우 굳이 커스텀 파서를 만들어서 사용할 필요가 없구요. Play의 내장형 파서로 간단하게 작업 할 수 있습니다. 여기에는 JSON, XML, Forms 용 파서가 포함되며 일반 텍스트 본문을 String으로 처리하고 ByteString으로 바이트 body를 처리합니다.


기본 파디 파서 

명시적으로 바디 파서를 선택하지 않은 경우에 적용되는 기본 본문 파서의 경우,  
들어오는 Content-Type 헤더를 살펴보고 이에 따라 본문을 파싱 합니다.
예를 들어, application / json 유형의 Content-Type은 JsValue로 구문 분석되지만
Application / x-www-form-urlencoded의 Content-Type은 Map [String, Seq [String]]으로 구문 분석됩니다.

기본 바디 파서는 AnyContent 타입의 바디를 만들어내는데요.  AnyContent에서 지원하는 다양한 타입은 asJson과 같은 as 메소드로 액세스 할 수 있습니다. 이것은 body 에서 Option 타입을 반환 하구요.

코드를 보고 이해를 해봅시다.

def save = Action { request =>
  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")
  }
}

다음은 기본 바디 파서가 지원하는 타입들의 매핑입니다.

text/plain: String, accessible via asText.

application/json: JsValue, accessible via asJson.

application/xml, text/xml or application/XXX+xml: scala.xml.NodeSeq, accessible via asXml.

application/x-www-form-urlencoded: Map[String, Seq[String]], accessible via asFormUrlEncoded.

multipart/form-data: MultipartFormData, accessible via asMultipartFormData.

Any other content type: RawBuffer, accessible via asRaw.


성능상의 이유로 기본 body Parser는 HTTP spec에 정의 된 데로 request 메서드가 의미있는 본문으로 정의되지 않았다면 본문을 구문 분석하지 않습니다. 이는 POST, PUT 및 PATCH 요청의 바디만 구문 분석하지만 GET, HEAD 또는 DELETE는 분석하지 않는다는 것을 의미합니다. 이 메소드에 대한 요청 본문을 구문 분석하려면 아래에 설명 된 anyContent 본문 파서를 사용할 수 있습니다.


명시적 바디 파서 선택하기 

바디 파서를 명시적으로 선택하려면 바디 파서를 Action apply 또는 async 메서드에 직접 전달하여 수행 할 수 있습니다. Play는 여러 개의 바디 파서를 기본적으로 제공하는데 이는 BodyParsers.parse 객체를 통해 사용할 수 있습니다. 이 객체는 Controller 트레잇으로 편리하게 가져옵니다.

예를 들어, json 본문을 기대하는 동작을 정의하려면 (앞의 예와 같이) :

def save = Action(parse.json) { request =>
  Ok("Got: " + (request.body \ "name").as[String])
}

바디의 타입이 첨부터 JsValue 이므로 더 이상 Option 타입이 아니기 때문에 바디로 작업하기가 더 쉬워졌습니다. 이것이 옵션이 아닌 이유는 json body 파서가 request 에 있는 Content-Type이 application / json임을 먼저 확인하고 실제 요청이 그것과 다르면 415 Unsupported Media Type 응답을 되돌려 보내기 때문입니다. 따라서 우리의 Action 코드에서 다시 확인할 필요가 없는거죠.

이것은 물론 클라이언트가 요청시 올바른 Content-Type 헤더를 보내고 모든것이 제대로 작동되는 경우이며, 조금 더 편하게하고 싶다면, Content-Type을 무시하고 body를 json으로 구문 분석하려고 시도하는 tolerantJson을 대신 사용할 수 있습니다. 아래와 같은 : 

def save = Action(parse.tolerantJson) { request =>
  Ok("Got: " + (request.body \ "name").as[String])
}

다음은 request 바디를 파일에 저장하는  예제입니다.

def save = Action(parse.file(to = new File("/tmp/upload"))) { request =>
  Ok("Saved the request content to " + request.body)
}


바디 파서를 서로 엮기 (Combining) 

이전 예에서 모든 request 바디는 동일한 파일에 저장되는데 , 사실 이렇게 사용하지는 않지 않을까요? 요청 세션에서 사용자 이름을 추출하여 각 사용자에 대해 고유 한 파일을 제공하는 다른 방식의 사용자 정의 바디 파서를 작성해 보겠겠습니다.

val storeInUserFile = parse.using { request =>
  request.session.get("username").map { user =>
    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)
}

Note: 여기서는 실제로 우리 자신의 BodyParser 를 작성하는 것이 아니라, 기존의 BodyParser 들을 결합하는 것입니다. BodyParser 를 바닥부터 다루는 방법은 고급 항목 섹션에서 다룹니다.


최대 내용 길이(Max content length)

text, json, xml 또는 formUrlEncoded와 같은 텍스트 기반 바디 파서는 모든 내용을 메모리에 로드 해야하기 때문에 최대 내용 길이를 사용합니다. 기본적으로 구문 분석 할 최대 콘텐츠 길이는 100KB 이며  application.confplay.http.parser.maxMemoryBuffer 속성을 지정하면 재정의 할 수 있습니다.

play.http.parser.maxMemoryBuffer=128K

raw 파서나 multipart / form-data와 같이 디스크의 내용을 버퍼링하는 파서의 경우 최대 내용 길이는 play.http.parser.maxDiskBuffer 속성을 사용하여 지정됩니다. 기본값은 10MB 이며  multipart / form-data 파서는 데이터 필드의 집계에 text max length 속성을 적용합니다.

주어진 동작에 대한 기본 최대 길이를 재정의 할 수도 있습니다.

// Accept only 10KB of data.
def save = Action(parse.text(maxLength = 1024 * 10)) { request =>
  Ok("Got: " + text)
}

maxLength를 사용하여 바디 파서를 래핑 할 수도 있구요.

// Accept only 10KB of data.
def save = Action(parse.maxLength(1024 * 10, storeInUserFile)) { request =>
  Ok("Saved the request content to " + request.body)
}


사용자 정의 바디 파서 작성

BodyParser 트레잇을 구현하여 사용자 정의 파서를 만들 수 있습니다. 이 트레잇은 그저 단순한 함수입니다.

trait BodyParser[+A] extends (RequestHeader => Accumulator[ByteString, Either[Result, A]])

이 함수의 시그니처는 처음에는 조금 어려울 수 있는데요.

이 함수는 RequestHeader를 사용합니다. 이것은 요청에 대한 정보를 확인하는 데 사용할 수 있으며 가장 일반적으로 Content-Type을 가져와서 바디를 올바르게 파싱 할 수 있습니다.

함수의 반환 형식은 Accumulator(누적기) 이며 Akka Streams Sink 주변의 얇은 레이어입니다. 이 누적기는 비동기적으로 요소 스트림을 결과로 누적하며 Akka 스트림 소스를 전달하여 실행할 수 있으며  완료시 사용할 Future 를 반환합니다. 그것은 Sink [E, Future [A]]와 본질적으로 동일하며 타입을 감싸는 래퍼 일뿐입니다.

그러나 Sink 와의 큰 차이점은 Accumulator가 map, mapFuture, recover 등과 같은 편리한 메소드를 제공한다는 것입니다. Sink의 경우는 이 모든 작업을 promise과 같은 결과로 작업 할 경우  mapMaterializedValue 호출로 래핑해야 한다는 점이죠. 

 apply 메소드가 반환하는 누적기는 ByteString 유형의 요소를 사용합니다. 이는 본질적으로 바이트 배열이지만 ByteString은 변경 불가능하고 바이트 및 슬라이스와 같은 많은 작업이 일정 (constant) 시간 내에 발생한다는 점에서 바이트 []와는 다릅니다. 

누적기의 반환 타입은 Either[Result, A] 입니다. Result를 반환하거나 A 타입의 본문을 반환한다는 것이죠. 일반적으로 오류가 발생한 경우 Result가 반환됩니다. 오류는 Content-Type이 바디 파서가 수락하는 타입과 일치하지 않거나 메모리 버퍼가 초과 된 경우 발생합니다. 


body 를 다른 어딘가로 직접 전송하기

바디 파서를 작성하는 일반적인 사용 사례는 실제로 바디를 파싱하고 싶지 않을 때를 위한 것 입니다. 무슨 얘기냐 하면 다른 곳으로 스트리밍 하려고 할 때를 말하는데요. 다음 코드를 보시죠.

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)(implicit ec: ExecutionContext) {

  def forward(request: WSRequest): BodyParser[WSResponse] = BodyParser { req =>
    Accumulator.source[ByteString].mapFuture { source =>
      request
        // TODO: stream body when support is implemented
        // .withBody(source)
        .execute()
        .map(Right.apply)
    }
  }

  def myAction = Action(forward(ws.url("https://example.com"))) { req =>
    Ok("Uploaded")
  }
}


Akka Streams 을 사용하여 파싱하기 

드문 경우지만 Akka Streams를 사용하여 사용자 지정 파서를 작성해야 할 수도 있습니다. 대부분의 경우, 대부분의 케이스에는 ByteString에 바디를 버퍼링하는 것으로 충분합니다.이 메서드는 명령형 메서드와 임의 액세스를 바디에 사용할 수 있기 때문에 일반적으로 훨씬 간단한 구문 분석 방법을 제공합니다.

그러나, 가능하지 않은 경우, 예를 들어 파싱해야 하는 본문이 너무 길어서 메모리에 저장되지 않는 경우와 같이 사용자 정의 파서를 작성해야 할 수도 있습니다.

Akka Streams 사용법에 대한 자세한 설명은 이 설명서의 범위를 벗어납니다. 시작하는 가장 좋은 방법은  Akka Streams documentation 설명서를 읽는 것이지만 아래 코드에서 Akka Streams 쿡북의 Parsing lines from a stream of ByteStrings 을 기반으로 하는 CSV 파서를 보여드리죠.

import play.api.mvc._
import play.api.libs.streams._
import play.api.libs.concurrent.Execution.Implicits.defaultContext
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)
}



Comments