Play 2 ist ein großartiges Scala Framework mit dem man in sehr kurzer Zeit sehr gute Ergebnisse liefern kann, vorausgesetzt man beherrscht die Basics. Zum Evaluieren von Play 2 und ReactiveMongo habe ich mir die Zeit genommen und einen schmalen REST Service gebaut.
ReactiveMongo ist eine für Play 2 entwickelte Erweiterung die asynchrone, nicht blockierende Operationen mit MongoDB ermöglicht. Dies gibt uns die Möglichkeit unsere Anwendung sehr stark zu skalieren und passt demnach perfekt in den Play 2 Kontext.
Um ReactiveMongo als Plugin in Play 2 hinzuzufügen, müssen einige Schritte erledigt werden. Zu aller erst müssen die libraryDependencies in der build.sbt erweitert werden.
libraryDependencies ++= Seq( jdbc, anorm, cache, ws, "org.reactivemongo" %% "play2-reactivemongo" % "0.10.5.0.akka23" )
Ich habe den Eintrag von org.reactivemongo ergänzt. Alle anderen sollten bereits vorhanden sein. Nun müssen wir Play noch sagen, dass das Plugin auch geladen werden soll. Wir ergänzen in der play.plugins die Zeile
400:play.modules.reactivemongo.ReactiveMongoPlugin
Zu guter Letzt fehlt in der application.conf noch der Verweis auf unsere MongoDB Instanz.
mongodb.uri = ${?MONGOHQ_URL}
MONGOHQ_URL ist eine Umgebungsvariable. Man könnte auch einen String, anstellt der Umgebungsvariable eintragen.
mongodb.url = "mongodb://<user>:<Password>@localhost:27017/ReactiveMongoRestExample"
Da die Applikation aber sowohl Lokal als auch im Web laufen soll, habe ich mich für die erste Variante entschieden. Und schon haben wir eine lauffähige Applikation, die zumindest schon einmal in der Lage ist mit Hilfe von ReactiveMongo mit einer MongoDB zu sprechen.
Die Idee hinter der Beispiel-Applikation ist, einen sehr minimalistischen Shop zu bauen. Also müssen wir eine Schnittstelle anbieten, über die man alle vorhandenen Produkte auflisten, Details zu einzelnen Produkten abfragen, Produkte anlegen, editieren und löschen kann. Als kleines Extra kann man zu Produkten Kommentare erstellen. Dies soll den Unterschied des Datenmodells einer auf MongoDB basierenden Applikation im Gegensatz zu einer Applikation mit einer relationalen Datenbank veranschaulichen.
Die Routes
Damit wir den REST Service ansprechen können, müssen wir Play sagen welche Action eines Controllers bei welcher URL aufgerufen werden soll. Dies erfolgt in Play in conf/routes. Bei DELETE, PUT, GET wird in der URL ein variabler Parameter id mitgegeben. Dies erkennt Play an dem Doppelpunkt und gibt es dann als Parameter in die zugeordnete Methode.
POST /products controllers.Product.create GET /products controllers.Product.list DELETE /products/:id controllers.Product.delete(id: String) PUT /products/:id controllers.Product.update(id: String) GET /products/:id controllers.Product.find(id: String)
Somit haben wir alle für uns relevanten Routen zum Anlegen, Löschen, Editieren, Auflisten und detaillierten Anzeigen erstellt.
Die Models
Die Models sind sehr simpel, da wir durch die impliziten Reads & Writes von Play eine Konvertierung von Datentypen zu Json haben. Dies ist für einfache Datenmodelle wirklich sehr angenehm. Für Selbsterstellte Datentypen oder Datentypen aus einer Fremdbibliothek müssen entsprechende Reads und Writes geschrieben werden. Wie etwa hier für JodaDate.
case class Product(_id: Option[BSONObjectID],
description: String,
price: Double,
comments: Option[List[Option[Comment]]],
created: Option[DateTime])
object Product {
import play.api.libs.json._
import play.modules.reactivemongo.json.BSONFormats._
implicit val dateTimeReads = Reads.jodaDateReads("yyyy-MM-dd HH:mm:ss")
implicit val dateTimeWrites = Writes.jodaDateWrites("yyyy-MM-dd HH:mm:ss")
implicit val productReads = Json.reads[Product]
implicit val productWrites = Json.writes[Product]
}
Wenn wir davon ausgehen, dass die Modelle die entsprechende Entität in der Datenbank abbildet, sollte dem Kenner von relationalen Datenbanken bereits etwas auffallen. Die Kommentare hängen als Liste an der Entität und werden nicht in einer eigenen Tabelle gespeichert. Ein valider Datensatz würde also so aussehen:
{
"description": "Pink Panties",
"price": 129.99,
"comments": [
{
"username": "john.doe",
"text": "This is really pink!"
},
{
"username": "samantha.smith",
"text": "Really comfortable"
}
],
"created": "2014-01-01 01:00:00"
},
Unser Model für einen Kommentar sieht so aus:
case class Comment(username: String,
text: String)
object Comment {
import play.api.libs.json._
import play.modules.reactivemongo.json.BSONFormats._
implicit val commentReads = Json.reads[Comment]
implicit val commentWrites = Json.writes[Comment]
}
Damit haben wir schon den ersten Teil der Miete. Wir haben Entitäten angelegt, die Problemlos von Json nach Scala und zurück konvertiert werden können.
Der Controller
Als letzten Schritt müssen wir noch den Controller erstellen, der die „Business Logik“ abbildet. Dieser beinhaltet 5 Actions, die jeweils unseren 5 unterschiedlichen Routen zugeordnet sind.
Create
Create erstellt ein neues Produkt und erwartet valides Json. Im Fehlerfall wird ein Fehler als Json zurückgegeben. Andernfalls wird der Datensatz erstellt und mit ID an den Aufrufer zurückgegeben.
def create = Action.async(parse.json) { request =>
request.body.validate[Product].map {
case product => {
val futureResult = collection.save(product)
futureResult.map {
case t => t.inError match {
case true => InternalServerError("%s".format(t))
case false => Ok(Json.toJson(product))
}
}
}
}.recoverTotal {
e => Future {
BadRequest(JsError.toFlatJson(e))
}
}
}
Update
Update aktualisiert einen Datensatz. Es ist wichtig, dass die ID in er URL mitgegeben wird und nicht im Body.
def update(id: String) = Action.async(parse.json) { request =>
request.body.validate[Product].map {
case product => {
val futureResult = collection.update(Json.obj("_id" -> Json.obj("$oid" -> id)), product)
futureResult.map {
case t => t.inError match {
case true => InternalServerError("%s".format(t))
case false => Ok(Json.toJson(product))
}
}
}
}.recoverTotal {
e => Future {
BadRequest(JsError.toFlatJson(e))
}
}
}
Delete
Delete löscht den Datensatz zur ID, die in der URL übergeben wird.
def delete(id: String) = Action.async(parse.anyContent) { request =>
val futureResult = collection.remove(Json.obj("_id" -> Json.obj("$oid" -> id)), firstMatchOnly = true)
futureResult.map {
case t => t.inError match {
case true => InternalServerError("%s".format(t))
case false => Ok("success")
}
}
}
Find
Find gibt den Datensatz zur ID zurück, die in der URL übergeben wird.
def find(id: String) = Action.async(parse.anyContent) { request =>
val futureResults: Future[Option[Product]] = collection.find(Json.obj("_id" -> Json.obj("$oid" -> id))).one[Product]
futureResults.map {
case t => Ok(Json.toJson(t))
}
}
List
List listet alle im System vorhandenen Produkte auf und gibt sie als Json Array an den Aufrufer zurück. Bei großen Datenmengen, sollte man die Menge durch Pagination oder Filter reduzieren. In unserem einfachen Beispiel ist das nicht notwendig.
def list() = Action.async { request =>
val cursor = collection.find(Json.obj()).cursor[Product]
val futureResults = cursor.collect[List]()
futureResults.map {
case t => Ok(Json.toJson(t))
}
}
Same-Origin Policy (CORS Filter)
Da man Services ja in der Regel nicht von der gleichen URL aufrufen will, moderne Browser ihre Nutzer jedoch davor schützen wollen, dass Schadcode von anderen Seiten geladen wird muss man dem Service beibringen, dass er Cross-Origin Resource Sharing unterstützt. Dies wird in Play in sogenannten Filtern implementiert.
case class CORSFilter() extends Filter{
import scala.concurrent._
import ExecutionContext.Implicits.global
lazy val allowedDomain = play.api.Play.current.configuration.getString("cors.allowed.domain")
def isPreFlight(r: RequestHeader) =(
r.method.toLowerCase.equals("options")
&&
r.headers.get("Access-Control-Request-Method").nonEmpty
)
def apply(f: (RequestHeader) => Future[SimpleResult])(request: RequestHeader): Future[SimpleResult] = {
if (isPreFlight(request)) {
Future.successful(Default.Ok.withHeaders(
"Access-Control-Allow-Origin" -> allowedDomain.orElse(request.headers.get("Origin")).getOrElse(""),
"Access-Control-Allow-Methods" -> request.headers.get("Access-Control-Request-Method").getOrElse("*"),
"Access-Control-Allow-Headers" -> request.headers.get("Access-Control-Request-Headers").getOrElse(""),
"Access-Control-Allow-Credentials" -> "true"
))
} else {
f(request).map{_.withHeaders(
"Access-Control-Allow-Origin" -> allowedDomain.orElse(request.headers.get("Origin")).getOrElse(""),
"Access-Control-Allow-Methods" -> request.headers.get("Access-Control-Request-Method").getOrElse("*"),
"Access-Control-Allow-Headers" -> request.headers.get("Access-Control-Request-Headers").getOrElse(""),
"Access-Control-Allow-Credentials" -> "true"
)}
}
}
}
Und damit dieser Filter auch genutzt wird müssen wir ein Global Object erstellt und es mit dem Filter erweitern.
object Global extends WithFilters(CORSFilter()) with GlobalSettings {
}
Gratulation! Wir haben einen schmalen REST Service mit Play 2 entwickelt. Der Code ist in GitHub zu finden. Eine Demo Applikation auf Heroku. Da Heroku die Instanzen nach einer gewissen Inaktivität herunterfährt, könnte die Responsezeit beim ersten Aufruf etwas höher sein.