Статьи

Расширенная маршрутизация в Play Framework

Мы часто получаем вопросы о том, как удовлетворить все виды потребностей маршрутизации в Play Framework. Хотя встроенного маршрутизатора достаточно для большинства пользователей, иногда вам могут встретиться случаи, когда этого недостаточно. Или, может быть, вы хотите более удобный способ реализации некоторого шаблона маршрутизации. Что бы это ни было, Play позволит вам делать практически все, что угодно. Этот пост в блоге будет описывать некоторые распространенные случаи использования.

Подключение к механизму маршрутизации Plays

Если по какой-то причине вам не нравится роутер Plays или если вы хотите использовать модифицированный роутер, то Play позволяет вам это легко сделать. Global.onRouteRequest — это метод, который вызывается для маршрутизации. По умолчанию это делегирует маршрутизатору Play, но вы можете переопределить его, чтобы сделать все, что вы хотите. Например:

override def onRouteRequest(req: RequestHeader): Option[Handler] = {
  (req.method, req.path) match {
    case ("GET", "/") => Some(controllers.Application.index)
    case ("POST", "/submit") => Some(controllers.Application.submit)
    case _ => None
  }
}

Как вы можете видеть, я практически реализовал свой собственный маленький DSL маршрутизации. Я также мог бы делегировать обратно маршрутизатору по умолчанию, используя invokingsuper.onRouteRequest (req).

Интересная вещь, которую также можно сделать, — делегировать разные маршрутизаторы на основе чего-то в запросе. Маршрутизатор воспроизведения компилируется в экземпляр Router.Routes, и это будет сам объект с именем Routes. По умолчанию любой файл с расширением .routes в каталоге будет скомпилирован и помещен в пакет с тем же именем, что и имя файла, за исключением .routes. Поэтому, если бы у меня было два маршрутизатора, foo.routes и bar.routes, я мог бы реализовать грубую форму виртуального хостинга следующим образом:

override def onRouteRequest(req: RequestHeader): Option[Handler] = {
  if (req.host == "foo.example.com") {
    foo.Routes.routes.lift(req)
  } else if (req.host == "bar.example.com") {
    bar.Routes.routes.lift(req)
  } else {
    super.onRouteRequest(req)
  }
}

S о вот некоторые случаи использования , которые перекрывая onRouteRequest могут быть полезны для:

  • Изменение запроса каким-либо образом перед выполнением маршрутизации
  • Подключите совершенно другой маршрутизатор (например, Jaxrs)
  • Делегирование в разные файлы маршрутов в зависимости от некоторых аспектов запроса

Реализация собственного роутера

В предыдущем примере мы видели, как использовать интерфейс Plays Router.Routes, другой вариант — реализовать его. Теперь нет никакой реальной причины для его реализации, если вы просто собираетесь делегировать ему напрямую из onRouteRequest. Тем не менее, реализовав этот интерфейс, вы можете включить его в другой файл маршрутов, используя синтаксис для дополнительных маршрутов, который в случае, если вы раньше не сталкивались с этим, обычно выглядит следующим образом:

->    /foo         foo.Routes

Теперь то, за что люди часто критикуют Play, это то, что он не поддерживает маршрутизацию ресурсов в стиле rails, где используется соглашение для маршрутизации обычно необходимых конечных точек REST к определенным методам на контроллере. Хотя в Play нет ничего стандартного, что делает это, сегодня нетрудно реализовать это для своего проекта, в Play 2.1 есть все, что вам нужно для его поддержки, с использованием синтаксиса маршрутов и реализации собственного маршрутизатора. И у меня также есть хорошие новости, мы скоро представим такую ​​функцию в Play. Но до тех пор, а также, если у вас есть собственные соглашения, которые вы хотите реализовать, вы, вероятно, найдете эти инструкции очень полезными.

Итак, давайте начнем с интерфейса, который могут реализовать наши контроллеры:

trait ResourceController[T] extends Controller {
  def index: EssentialAction
  def newScreen: EssentialAction
  def create: EssentialAction
  def show(id: T): EssentialAction
  def edit(id: T): EssentialAction
  def update(id: T): EssentialAction
  def destroy(id: T): EssentialAction
}

Я мог бы предоставить реализации по умолчанию, которые возвращают не реализованные, но затем для его реализации потребуется использовать ключевые слова переопределения. Я думаю, что это вопрос предпочтений здесь.

Теперь я собираюсь написать роутер. Интерфейс маршрутизатора выглядит следующим образом:

trait Routes {
  def routes: PartialFunction[RequestHeader, Handler]
  def documentation: Seq[(String, String, String)]
  def setPrefix(prefix: String)
  def prefix: String
}

The routes method is pretty self explanatory, it is the function that looks up the handler for a request. documentation is used to document the router, it is not mandatory, but it used by at least one REST API documenting tool to discover what routes are available and what they look like. For brevity in this post, we won’t worry about implementing it. The prefix and setPrefix methods are used by Play to inject the path of the router. In the routes includes syntax that I showed above, you could see that we declared the router to be on the path/foo. This path is injected using this mechanism.

So we’ll write an abstract class that implements the routes interface and the ResourceController interface:

abstract class ResourceRouter[T](implicit idBindable: PathBindable[T]) 
    extends Router.Routes with ResourceController[T] {
  private var path: String = ""
  def setPrefix(prefix: String) {
    path = prefix
  }
  def prefix = path
  def documentation = Nil
  def routes = ...
}

I’ve given it a PathBindable, this is so that we have a way to convert the id from a String extracted from the path to the type accepted by the methods. PathBindable is the same interface that’s used under the covers when in a normal routes file to convert types.

Now for the implementation of routes. First I’m going to create some regular expressions for matching the different paths:

private val MaybeSlash = "/?".r
private val NewScreen = "/new/?".r
private val Id = "/([^/]+)/?".r
private val Edit = "/([^/]+)/edit/?".r

I’m also going to create a helper function for the routes that require the id to be bound:

def withId(id: String, action: T => EssentialAction) =
  idBindable.bind("id", id).fold(badRequest, action)

badRequest is actually a method on Router.Routes that takes the error message and turns it into an action that returns that as a result. Now I’m ready to implement the partial function:

def routes = new AbstractPartialFunction[RequestHeader, Handler] {
  override def applyOrElse[A <: RequestHeader, B >: Handler](rh: A, default: A => B) = {
    if (rh.path.startsWith(path)) {
      (rh.method, rh.path.drop(path.length)) match {
        case ("GET", MaybeSlash()) => index
        case ("GET", NewScreen()) => newScreen
        case ("POST", MaybeSlash()) => create
        case ("GET", Id(id)) => withId(id, show)
        case ("GET", Edit(id)) => withId(id, edit)
        case ("PUT", Id(id)) => withId(id, update)
        case ("DELETE", Id(id)) => withId(id, destroy)
        case _ => default(rh)
      }
    } else {
      default(rh)
    }
  }
 
  def isDefinedAt(rh: RequestHeader) = ...
}

I’ve implemented AbstractPartialFunction, and the main method to implement then is applyOrElse. The match statement doesn’t look much unlike the mini DSL I showed in the first code sample. I’m using regular expressions as extractor objects to extract the ids out of the path. Note that I haven’t shown the implementation of isDefinedAt. Play actually won’t call this, but it’s good to implement it anyway, it’s basically the same implementation as applyOrElse, except instead of invoking the corresponding methods, it returns true, or for when nothing matches, it returns false.

And now we’re done. So what does using this look like? My controller looks like this:

package controllers
 
object MyResource extends ResourceRouter[Long] {
  def index = Action {...}
  def create(id: Long) = Action {...}
  ...
  def custom(id: Long) = Action {...}
}

And in my routes file I have this:

->     /myresource              controllers.MyResource
POST   /myresource/:id/custom   controllers.MyResource.custom(id: Long)

You can see I’ve also shown an example of adding a custom action to the controller, obviously the standard crud actions are not going to be enough, and the nice thing about this is that you can add as many arbitrary routes as you want.

But what if we want to have a managed controller, that is, one whose instantiation is managed by a DI framework? Well let’s created another router that does this:

class ManagedResourceRouter[T, R >: ResourceController[T]]
    (implicit idBindable: PathBindable[T], ct: ClassTag[R]) 
    extends ResourceRouter[T] {
 
  private def invoke(action: R => EssentialAction) = {
    Play.maybeApplication.map { app =>
      action(app.global.getControllerInstance(ct.runtimeClass.asInstanceOf[Class[R]]))
    } getOrElse {
      Action(Results.InternalServerError("No application"))
    }
  }
 
  def index = invoke(_.index)
  def newScreen = invoke(_.newScreen)
  def create = invoke(_.create)
  def show(id: T) = invoke(_.show(id))
  def edit(id: T) = invoke(_.edit(id))
  def update(id: T) = invoke(_.update(id))
  def destroy(id: T) = invoke(_.destroy(id))
}

This uses the same Global.getControllerInstance method that managed controllers in the regular router use. Now to use this is very simple:

package controllers
 
class MyResource(dbService: DbService) extends ResourceController[Long] {
  def index = Action {...}
  def create(id: Long) = Action {...}
  ...
  def custom(id: Long) = Action {...}
}
object MyResource extends ManagedResourceRouter[Long, MyResource]

And in the routes file:

->     /myresource              controllers.MyResource
POST   /myresource/:id/custom   @controllers.MyResource.custom(id: Long)

The final thing we need to consider is reverse routing and the Javascript router. Again this is very simple, but I’m not going to go into any details here. Instead, you can check out the final product, which has a few more features, here.