REST mit Play 2 & ReactiveMongo

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.

Teilen Sie diesen Beitrag

Das könnte dich auch interessieren …

8 Antworten

  1. Sebastian Basner sagt:

    Was übrigens mit Play2 auch sehr gut geht ist CouchDB. Hier wird dann ausschließlich über REST mit der DB gesprochen und somit ist implizit alles asynchron. Habe da aus reinem Interesse selber mal eine kleine CRUD Applikation mit gebaut und das lief ziemlich straight forward.

    • Flavia sagt:

      Hast du die Demo Applikation noch und kann sie in unser Github pushen? Ein kurzer Artikel mit den Besonderheiten wäre natürlich noch schöner 🙂

  2. Sebastian Basner sagt:

    Lass mir ein paar Tage Zeit, dann räume ich den Code mal auf. Ich hatte eh überlegt dazu was für den eigenen Gebrauch zusammen zu schreiben.

  3. Mustafa sagt:

    Hallo Dennis,
    super Artikel – herzlichen Dank!

    In Deinem Companion Objekt erstellt Du durch implizite Reads & Writes eine Konvertierungsmöglichkeit von/zu Json Objekte.
    Ist das selbe in diesem Kontext auch für Request-Parameter möglich?

    • Dennis Fricke sagt:

      Hallo Mustafa,

      schön das dir mein Artikel gefallen hat. Ich hoffe ich habe deine Frage richtig verstanden. Grundsätzlich ist dies möglich, wenn du deine Requests ebenfalls mit Objekten abbildest. Ich würde dir allerdings empfehlen die Konvertierung in der Action zu machen, damit nachfolgende Entwickler, die später am Projekt arbeiten könnten nicht zu viel Magic vorfinden und sich einfach einarbeiten können.

      Ich habe dir mal ein Beispiel mit verschiedenen Möglichkeiten gebaut und in unser Github gepushed: https://github.com/flaviait/RequestCombinatorExample.

      Ich hoffe ich konnte dir damit helfen 🙂

  4. Mustafa sagt:

    Hey Dennis – Danke hat mir aufjedenfall weitergeholfen :)!
    Dann gleich die naechste Frage :). Und zwar komme ich aus der Spring-Welt und habe mir sofort ein DAO zusammengebaut.
    Dieses sieht in etwa so aus:

    object UserDao extends AbstractMongoDao {

    override val collectionName: String = "user"

    def registerUser(user: User) : Future[LastError] = {
    collection.insert(user)
    }
    }

    Mich stört/irritiert hier der Rückgabewert. Muss ich ReactiveMongo in Verbindung mit Controllern einsetzen? Wie würdest du dir ein DAO vorstellen. Ich würde am besten dieses DAO aus einer Service-Klasse heraus aufrufen, und die Service-Klasse wiederum in meinem Controller. Warum überspringt die Play-Welt die Service-Layer, sieht man da keinen Bedarf?

    Du siehst, ich bin ziemlich irritiert :D. Aufjedenfall bin ich auf deine Antwort gespannt und bedanke mich schon jetzt für diese :)!

    • Dennis Fricke sagt:

      Hallo Mustafa,

      dass hängt ganz davon ab mit welcher Datenbank du arbeitest. Nutzt du MongoDB in Kombination mit ReactiveMongo würde ich dir Empfehlen direkt in den Controllern mit der Collection zu arbeiten. Letztendlich ist die Collection ja nichts anderes als eine Liste von Objekten. Diese Liste bietet dir noch einige MongoDB spezifische Operationen. Das die Objekte erst Lazy geladen werden, spielt für dich ja im Endeffekt keine Rolle.

      Nutzt du eine relationale Datenbank wie etwa MySQL oder Postgres erstellst du deine Methoden direkt im Model. Play verfolgt dort einen anderen Ansatz wie etwa Spring. In Spring hast du die Entitäten + DAO/Repository. Die Entität selbst bildet nur das Objekt ab. Zugriffe und Aktionen erfolgen über das DAO/Repository. Play hingegen nutzt Models. Diese beinhalten sowohl die Struktur des Objekts, sowie Methoden zur Verwaltung (speichern, löschen, etc). Ich habe hier einen kurzen Artikel geschrieben zu Play 2 + Anorm: http://blog.flavia-it.de/play-framework-2-crud-model-mit-anorm/. Der könnte für dich eventuell interessant sein.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert