Posted in scala, play framework
new to Play Framework 2
Comment
Play Framework 를 배우기로 마음먹었다. 새로운 무언가를 마주쳤을때, 어떻게 해결할까를 위주로 서술했다.
Installation
우선 설치를 해야했다. Play 를 배포하는 Typesafe 의 Getting Started 에 들어가서, 확인해보니 activator 라는 제품으로 Play 를 감싸 편하게 개발할 수 있도록 해주는 플랫폼을 만들어 놨다.
Installing Play 를 참조해서 activator 설치 후에, PATH 에 추가했다. Creating New Application 와 Activator Console Usage 을 참조해서 새 프로젝트를 생성하고, activator 의 기본적인 사용법을 익혔다.
$ activator new example-app play-scala
$ cd example-app
$ ./activator run
activator 는 sbt 위에서 돌아가는 또 다른 콘솔이기 때문에, 이렇게도 쓸 수 있다.
$ sbt
[example-app] $ compile
[example-app] $ test
[example-app] $ run
IDE intergration 을 하려고 https://www.playframework.com/documentation/2.3.x/PlayConsole 를 따라했는데 잘 안됀다. 나는 emacs 를 쓰는데 ensime
과 activator
가 디펜던시 충돌이 있는 것 같다. ensime
을 쓰지 않기로 결정했다. 구글링 해보니 ensime-sbt
0.17 에서 해결한단다. 지금은 0.15-SNAPSHOT 인데 1-2 달 걸린다고 하고 자세한건 https://github.com/sbt/sbt/issues/1592 여기 참조.
Directory Structure
이제 뭘 하려면 파일을 수정해야 하는데, activator 가 생성해주는 파일이 생각보다 많아서 무엇을 수정해야하는지 좀 난감했다. Anatomy of a Play Application 을 참조해서 디렉토리 구조를 살펴봤다.
app → Application sources
└ assets → Compiled asset sources
└ stylesheets → Typically LESS CSS sources
└ javascripts → Typically CoffeeScript sources
└ controllers → Application controllers
└ models → Application business layer
└ views → Templates
build.sbt → Application build script
conf → Configurations files and other non-compiled resources (on classpath)
└ application.conf → Main configuration file
└ routes → Routes definition
public → Public assets
└ stylesheets → CSS files
└ javascripts → Javascript files
└ images → Image files
project → sbt configuration files
└ build.properties → Marker for sbt project
└ plugins.sbt → sbt plugins including the declaration for Play itself
lib → Unmanaged libraries dependencies
logs → Standard logs folder
└ application.log → Default log file
target → Generated stuff
└ scala-2.10.0
└ cache
└ classes → Compiled class files
└ classes_managed → Managed class files (templates, ...)
└ resource_managed → Managed resources (less, ...)
└ src_managed → Generated sources (templates, ...)
test → source folder for unit or functional tests
build.sbt
에 빌드 스크립트가, project/plugins.sbt
에 디펜던시가 나열되어 있었고 lib
폴더 내에 unmanaged 디펜던시를 넣게끔 되어 있었다. build.properties
는 sbt 버전이 기록되어있다.
로그같은 경우는 logs
폴더가 따로 있고, 여기 내에 application.log
파일에 디폴트로 로그가 쌓인다.
conf
밑에는 application.conf
에 데이터베이스 커넥션이나, 로거 세팅등 Play 에서 사용하는 세팅이 적게끔 되어있다. routes
는 URL 세팅이 담겨있다. 새로운 API를 추가하면, 아마 여기에도 추가해야 할 것 같다.
app
은 다른 웹 프레임워크처럼 views
, controllers
, models
와 같은 디렉터리가 있다. views
밑에 템플릿 파일들을 보면 파일 이름이 main.scala.html
, index.scala.html
과 같은데, 이건 조금 더 살펴봐야겠다.
Play 는 LESS 나 Stylus, Coffee 처럼 pre-processor 의 소스코드를 app/assets
하위에 놓고, 빌드 스크립트를 이용해서 public
으로 컴파일 되도록 해 놓았다.
Hello World
이제 준비는 다 되었으니, Hello Wolrd 를 찍을 차례다. 다 됐고, views
폴더 밑에 있는 파일을 수정해야겠다. localhost:9000
를 입력했을때 나오는 컨트롤러와 뷰를 찾기 위해서 conf/routes
를 확인하니, 아래와 같았다.
# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~
# Home page
GET / controllers.Application.index
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.at(path="/public", file)
GET /
했을때, controllers/Application.scala
파일로 간다.
// controllers/Application.scala
package controllers
import play.api._
import play.api.mvc._
object Application extends Controller {
def index = Action {
Ok(views.html.index("Your new application is ready."))
}
}
views/index.scala.html
에 "Your new application is ready."
를 전달한다. Action
과 Ok
은 무엇인지 몰라서 구글링 해보니 What is Action 이라는 문서가 있다. 이 문서에 의하면 Action
은 play.api.mvc.Request
를 받아 play.api.mvc.Response
를 만드는 함수다.
Action {
Ok("Hello world")
}
이 경우 Ok
는 HTTP Status 200 과 text/plain
컨텐츠를 담고있는 play.api.mvc.Response
를 만들어 낸다. 그리고 play.api.mvc.Action
의 컴패니온 오브젝트는 다양한 헬퍼를 제공하는데, 아래가 그 예다.
Action { request =>
Ok("Got request [" + request + "]")
}
Action(parse.json) { implicit request =>
Ok("Got request [" + request + "]")
}
근데 그 전에, implicit
에 대해서 이해가 안됀다. Understanding implicit in Scala 라는 SO(Stackoverflow) 질문에서 Scala Implicit Conversion 이라는 블로그도 찾아냈다.
Implicit conversion 은, 쉽게 말해서 A
가 필요할때 B
가 있고, 타입이 맞지 않는다면, A -> B
를 해줄 수 있는 implicit function value
을 찾는다. (물론 A
와 B
에 대한 컴패니온 오브젝트도 찾아서, 변환할 수 있으면 변환도 할거고). function
대신 def
를 사용해도 eta-expanded 될 것이므로 문제 없다. SO 의 원문도 첨부하면
When the compiler finds an expression of the wrong type for the context, it will look for an implicit Function value of a type that will allow it to typecheck. So if an A is required and it finds a B, it will look for an implicit value of type B => A in scope (it also checks some other places like in the B and A companion objects, if they exist). Since defs can be "eta-expanded" into Function objects, an implicit def xyz(arg: B): A will do as well.
이제, 컨트롤러를 일단 해결 했으니 views/index.scala.html
로 넘어가자.
@(message: String)
@main("Welcome to Play") {
@play20.welcome(message)
}
controlers/Application.scala
컨트롤러에서 넘어온 문자열 값 "Your new application is ready"
가 message
변수에 들어가고 @
가 Play 템플릿 엔진에서 사용하는 문법 인것 같다. @main
은 아마 views/main.scala.html
을 include 하는 문법 같다. main.scala.html
의 내용은 아래와 같다.
@(title: String)(content: Html)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
<link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
<script src="@routes.Assets.at("javascripts/hello.js")" type="text/javascript"></script>
</head>
<body>
@content
</body>
</html>
조금 난해하긴 한데, @main
이 main.scala.html
를 부르는거라 생각하면, 두개의 인자를 main.scala.html
에서 받으므로 index.scala.html
의 @main
부분 에서도 두개를 넘겨줘야 한다. 하나는 컨트롤러에서 받은 message
고 두번째 인자는 main(message)
가 리턴하는 함수에 넘겨줄 content
변수를 play20.welcome
를 통해서 만들어 낸다. 아직 왜 커링을 이용하는진 모르겠다.
찾아보니 target/scala-2.11/twirl/main/views/html/index.template.scala
에서 play20.welcome
을 호출한다. 아마 빌트인 라이브러리인가 보다.
템플릿 엔진에 대해 이해하기 전에, 일단 welcome
API 부터 만들어 보자. 구글에서 검색하니 Play for Scala, Sample Application 라는 프로젝트가 있어서 참고했다.
결국 내가 GET /welcome
를 만들려면, controllers/Application.scala
에 메소드를 아래와 같이 추가한 뒤
def welcome(name: String) = Action {
Ok("welcome " + name)
}
해당 API 를 conf/routes
에 추가한다.
GET /welcome controllers.Application.welcome(name: String)
만약 HTML 을 렌더링하고 싶다면 welcome.scala.html
을 아래처럼 만들고
@(name: String)
<!DOCTYPE html>
<html>
<title>Welcome Page</title>
<body>
<h1>Welcome, <em>name</em></h1>
</body>
</html>
컨트롤러의 welcome
메소드를 다음과 같이 수정한다.
// Application.scala
def welcome(name: String) = Action {
Ok(views.html.welcome(name))
}
Template Engine
Scala Tempaltes 링크에서 Play 템플릿 엔진에 대한 기본적인 이해를 할 수 있었다. 다른 언어와 다른점은, 템플릿 엔진 문법을 열고 닫고 할 필요 없이 스칼라는 @
한번만 이용해도 if
블럭같은 멀티라인 코드를 처리할 수 있다는 것.
@if(items.isEmpty) {
<h1>Nothing to display</h1>
} else {
<h1>@items.size items!</h1>
}
REST API : GET
이제 기본적인 무언가를 만들 준비가 됐다. 구글에서 scala play example github 을 검색해서 나온 예제 프로젝트를 참고해서 만들어 보자.
conf/routes
에 API 를 정의하고,
GET /phones/:model.json controllers.Phones.show(model String)
Model 을 정의하자.
// app/models/Phone.scala
case class Phone(model: String, brand: String, price: Int)
이제 app/controllers/Phones.scala
컨트롤러를 만들면 된다.
package controllers
import play.api._
import play.api.mvc._
import models.Phone
object Phones extends Controller {
def show(model: String) = Action {
Ok(s"{ Model : ${model} }")
}
}
근데, 잠깐! 어떻게 Model 을 Json 으로 변환하는지 모른다. scala play json 이라고 검색해보니 Scala Json 이라는 문서가 나오긴 한다.
play.api.lib.json
패키지에 있는 JsValue
를 만들면, Ok
를 이용해서 날릴 수 있다. JsValue
를 만들기 위해선 Json.toJson
을 사용하면 되는데, Primitive Type 이나 Collection 은 디폴트로 지원해 준다. 따라서 Json.toJson("Fiver")
혹은 Json.toJson(Seq(1, 2, 3, 4))
와 같이 사용할 수 있다.
그런데 문제는 우리가 만든 클래스는 디폴트로 JsValue
로 바꿀수 없다. Json.toJson
은 인자로 받은 것을 JsValue
로 변환하기 위해 Json.toJson[T](T)(implicit writes: Writes[T])
를 사용하는데, 우리가 만든 Phone
을 위한 Writes[Phone]
은 없기 때문에 만들어 줘야 한다.
// controllers.Phones.scala
...
def show(model: String) = Action {
Ok(Json.toJson(Phone(model, "Samsung", 4900)))
}
implicit val phoneWrites = new Writes[Phone] {
def writes(phone: Phone) = Json.obj(
"model" -> phone.model,
"brand" -> phone.brand,
"price" -> phone.price
)
}
...
Phones
컨트롤러 내부에 위와 같이 작성하면, /phones/samsung.json
과 입력했을때 {"model":"samsung","brand":"Samsung","price":4900}
와 같은 application/json
response 가 돌아온다. 코드는 간단하다, Writes[Phone]
을 만드는데, 여기 내부에 Phone
을 받아 JsValue
를 돌려줄수 있는 writes
함수를 Json.obj
를 이용해서 만들면 된다.
컨트롤러에 Writes[Phone]
이 있어야되는지 의문이다 모델에 있어야만 할 것 같다. 그리고 구글링 해서 나온 Gist: Play 2.0 Marshalling를 따라가면 더 나은 버전이 있는데, 일단은 이걸로 족하다. 나중에 더 고치자.
이제 단순히 컨트롤러에서 직접 생성하는 대신, Repository 역할을 해줄 Phone
의 Companion Object 를 models/Phone.scala
에 만들자.
object Phone {
var phones = Set(
Phone("Nexus5", "Google", 459000),
Phone("Galaxy Note4", "SamSung", 996000),
Phone("G3 Pro", "LG", 681000)
)
}
이제 컨트롤러에서 호출할 메소드를 만들면 되는데, 이름을 짓는법이 걱정이다. getAll
과 같은 이름도 나쁘진 않을텐데, 정해진 스탠다드가 있지 않을까? DAO method name convention 을 검색해 보았다. SO 답변 을 보니, Spring Data JPA 의 메소드 컨벤션을 따르는것도 괜찮다고 해서 그러기로 했다.
find*
는 Select 를 수행하는 메소드의 이름이다. get
은 보통 getter 와 혼동할 여지가 있기 때문에 find
가 더 나은것 같다. 나머지는 직관적인 create
, update
, delete
를 사용하기로 했다.
이제 models/Phone.scala
을 다시 작성해 보면
package models
case class Phone(model: String, brand: String, price: Int) {
override def toString = "[%s : ], - %s".format(model, brand, price)
}
object Phone {
var phones = Set(
Phone("nexus5", "Google", 459000),
Phone("note4", "SamSung", 996000),
Phone("g3", "LG", 681000)
def findByModel(model: String) = phones.find(_.model == model)
}
컨트롤러는,
def get(model: String) = Action {
Ok(Json.toJson(Phone.findByModel(model)))
}
잘 동작한다. 있으면 뿌려주고, 없으면 null
을 Response 에 담아 보낸다. 난 null
대신 HTTP status 404 가 왔으면 좋겠다. 컨트롤러의 get
메소드를 다음처럼 고친다.
def get(model: String) = Action {
Phone.findByModel(model).map { model =>
Ok(Json.toJson(model))
}.getOrElse(NotFound)
}
이제 /phones.json
API 를 만들어 보자. 컨트롤러 내에 list
메소드를, Phone 컴패니언 오브젝트 내에 findAll
메소드를 추가한다.
// models/Phone.scala
object Phone {
var phones = Set[Phone]()
def findByModel(model: String) = phones.find(_.model == model)
def findAll = phones.toList
}
// controllers/Phones.scala
def list = Action {
Ok(Json.toJson(Phone.findAll))
}
갑자기 궁금해진게 있다. 내가 var phones = Set[Phone]()
처럼 empty Repository 를 만들면, GET /phones.json
을 요청했을때 200 OK
와 []
, 즉 빈 배열이 돌아오는데 이게 REST API 에서 적절한 응답일까? REST get all resources empty 라고 검색하니 SO 에서 Proper response for empty table? 이라는 질문이 있다.
204 (No Content) 와 404 (Not Found) 중 어떤걸 응답 코드로 사용해야 하냐는 질문에 200 (OK) 가 더 적절하다고 말한다. 왜냐하면, 요청된 리소스인 Collection 은 존재하나, 그 내부가 비었기 때문이다.
유저 몇명을 삭제해서 Collection 이 비었을때 404 를 보낸다면 /users 라는 API 가 삭제된 것으로 오인할 수 있다.
204 같은 경우, request 는 처리 되었으나 응답할 필요가 없는 delete 같은 요청의 응답코드로 사용되는 것이 적절하다. 원문을 첨부하면,
Why not 404 (Not Found)?
The 404 status code should be reserved for situations, in which a resource is not found. In this case, your resource is a collection of users. This collection exists but it's currently empty. Personally, I'd be very confused as an author of a client for your application if I got a 200 one day and a 404 the next day just because someone happened to remove a couple of users. What am I supposed to do? Is my URL wrong? Did someone change the API and neglect to leave a redirection.
Why not 202 (No Content)?
A 204 is supposed to indicate that some operation was executed successfully and no data needs to be returned. This is perfect as a response to a DELETE request or perhaps firing some script that does not need to return data. In case of api/users, you usually expect to receive a representation of your collection of users. Sending a response body one time and not sending it the other time is inconsistent and potentially misleading.
REST API : POST
이제 POST 요청을 처리하기 위해 컨트롤러에 add
메소드를, 레포지터리에 create
메소드를 추가해 보자.
일단 conf/routes
에 라우팅을, 컨트롤러에 메소드를 추가한다.
// conf/routes
`POST /phones/:model.json controllers.Phones.add(model: String)
// controllers/Phones.scala
def add(model: String) = Action { request =>
}
이 POST 요청 핸들러를 작성하려면 내가 모르는것은 2가지다.
(1) Request body 에서 파라미터 추출
(2) 추출한 파라미터를 암시적으로 Phone
인스턴스로 변경
일단, 검색을 해보니 여러 문서를 찾았다.
(1) ScalaJson
(2) ScalaJsonHttp
(3) ScalaJsonCombinators
놀랍게도 이걸 다 이해해야 한다. 스칼라의 JSON 처리는, 아니 Play 의 JSON 처리는 직관적이지 못한 것 같다. 일단 예제 코드를 보면
def add() = Action(BodyParsers.parse.json) { request =>
val phoneRes = request.body.validate[Phone]
phoneRes.fold(
errors => {
BadReques
},
phone => {
Phone.create(phone)
}
)
}
implicit val phoneReads: Reads[Phone] = (
(JsPath \ "model").read[String] and
(JsPath \ "brand").read[String] and
(JsPath \ "price").read[Int]
)(Phone.apply _)
요약하자면, Action(BodyParsers.parse.json
을 이용하면, Content-Type 으로 application/json
혹은 text/json
을 받아들이고, Request Body 에 있는 값들을 파싱해서 JsValue
를 만들어 낸다.
그리고 validate[Phone]
를 이용해서 implicit Reads[Phone]
을 호출하여, validation 한 결과값을 얻는다.
그리고 이 값을 처리하기 위해 fold
메소드를 사용하는데, 첫 인자는 validation 에 실패했을 경우, 후자는 성공했을 경우의 로직을 적으면 된다.
정정하겠다. Play 의 JSON 처리는 처음보면 난해하지만, Static Typing 에서 필요한 instance converting 과 validation 을 잘 섞은 깔끔한 방법 이다.
phoneReads
를 만드는 과정이 좀 난해하긴 한데, 여기 를 좀더 참고하면 JsPath
와 and
를 이용해서 만드는건 사실 FunctionalBuilder[Reads]#CanBuild3[String, String, Int]
다. 이것 자체가 중요한것은 아니고, 이건 사실 바디파서로부터 뽑혀 나온 것들을 스칼라 타입으로 바꾼 것들을 담고 있는 통이고, 여기에 Phone.apply
를 호출해서 Phone
을 하나 만들 수 있다.
Writes[Phone]
도 JsPath
를 이용해서 만들 수 있다.
implicit val phoneWirtes: Writes[Phone] = (
(JsPath \ "model").write[String] and
(JsPath \ "brand").write[String] and
(JsPath \ "price").write[Int]
)(unlift(Phone.unapply))
이 두개의 Reads[T]
와 Writes[T]
를 mixin 하면 Format[T]
가 된다.
val phoneWirtes: Writes[Phone] = (
(JsPath \ "model").write[String] and
(JsPath \ "brand").write[String] and
(JsPath \ "price").write[Int]
)(unlift(Phone.unapply))
val phoneReads: Reads[Phone] = (
(JsPath \ "model").read[String] and
(JsPath \ "brand").read[String] and
(JsPath \ "price").read[Int]
)(Phone.apply _)
implicit val phoneFormat: Format[Phone] =
Format(phoneReads, phoneWirtes)
더 나아가서, 우리의 조그만 어플리케이션의 경우에는 Reads
와 Writes
가 다르지 않으므로 Combinator
를 이용해서 바로 Format
을 만들 수 있다.
implicit val phoneFormat: Format[Phone] = (
(JsPath \ "model").format[String] and
(JsPath \ "brand").format[String] and
(JsPath \ "price").format[Int]
) (Phone.apply, unlift(Phone.unapply))
Writes[Phone]
이 Phone
을 이용해서 Js.Value
를 만들어준다는건 알겠는데, unlift
가 무엇인지 궁금하다. Scala Doc 에서 unlifting 과 lifting 에 관한 링크를 찾아냈다.
쉽게 말해서 lifting 은 함수가 리턴해주는 타입에 대해 Option
을 씌워주는 것이고, unlifting 은 벗겨주는 것이다. Phone.unapply
는 Phone => Option[(String, String, Int)]
이므로, unlift
를 적용하면 Phone => (String, String, Int)
가 됀다.
lifting 과 partial function 에 관한건 SO: What is lifting in Scala 으로, Play 의 JSON API 에 관한건 Unveiling Play2 JSON API 로
이제 구현을 요약하자면, models/Phone.scala
의 create
메소드는
def create(phone: Phone) = {
phones = phones + phone
}
controllers/Phones.scala
의 add
메소드는
def add() = Action(BodyParsers.parse.json) { request =>
val phoneRes = request.body.validate[Phone]
phoneRes.fold(
errors => {
BadRequest(Json.obj("status" ->"404", "message" -> JsError.toFlatJson(errors)))
},
phone => {
Phone.create(phone)
Created
}
)
}
Summary
Play 에 대해 아무것도 모르는 상태에서, 간단한 엔티티를 하나 만들고 POST, GET API 를 만들어 보았다. 에러 핸들링도 없고, 부족한게 많지만 차차 붙여 나가면 될테다.