Как стать автором
Обновить
0
Райффайзен Банк
Развеиваем мифы об IT в банках

RAML-роутинг в Play Framework

Время на прочтение 8 мин
Количество просмотров 4.5K
image

Play framework — очень гибкий инструмент, но информации о том, как изменить формат route-файла, на просторах интернета мало. Я расскажу о том, как можно заменить стандартный язык описания маршрутов на основе route-файла на описание в формате RAML. А для этого нам придется создать свой SBT-плагин.

В двух словах о RAML (RESTful API Modeling Language). Как совершенно справедливо сказано на главной странице проекта, этот язык существенно упрощает работу с API приложения на протяжении всего его жизненного цикла. Он лаконичен, легко переиспользуется, и что самое ценное — в равной степени легко читается машиной и человеком. То есть можно воплотить подход documentation as a code, когда один артефакт (RAML-скрипт) становится точкой входа для всех участников процесса разработки — аналитиков, тестировщиков и программистов.

Постановка задачи


Наша команда работает над большой и сложной системой онлайн-банкинга. Особое внимание уделяется документированию интерфейсов и тестам. Однажды я задался вопросом, можно ли объединить тесты, документацию и кодогенерацию. Оказалось, что до определенной степени это возможно. Но для начала пара слов о том, зачем нам это понадобилось.

В большом проекте особое значение имеет документирование интерфейсов. Аналитики, разработчики сервисов и клиентских приложений должны иметь описание API системы, над которой они работают. Аналитикам важна документация в удобном для чтения виде, программисты в конечном счете описывают интерфейс в коде, причем разные команды пишут код на разных языках. Информация многократно дублируется, что затрудняет её синхронизацию. Когда команд много, почти невозможно вручную гарантировать соответствие друг другу различных форматов API. Поэтому к решению этой проблемы мы подошли основательно.

Для начала мы выбрали единый для всех команд формат описания API. Им стал RAML. Далее нам нужно было бы гарантировать (насколько это возможно), что наши сервисы соответствуют описанию, а клиентские приложения работают с описанными сервисами. Для этого мы используем инструменты тестирования, о которых я расскажу в другой статье. И последним шагом стало внедрение кодогенерации, которая создает за нас код на основе данных из RAML. Сегодня речь пойдет об инструментах кодогенерации, используемых в проекте сервера.

Обычно в документации содержится информация о конечных точках REST, описание параметров и тела запроса, HTTP-коды и описание ответов. При этом для всех вышеперечисленных элементов часто указываются примеры. Этой информации вполне достаточно для тестирования работоспособности конечной точки — просто нужно взять пример запроса для неё и отослать на сервер. Если у конечной точки есть параметры, то их значения нужно также взять из примеров. Пришедший ответ сравнить с примером ответа или провалидировать его JSON-схему на основании документации. Чтобы примеры ответов соответствовали ответам сервера, тот должен работать с правильными данными в БД. Таким образом, при наличии БД с тестовыми данными и документации API сервиса с описанием ответов и примерами запросов, мы можем обеспечить простое тестирование работоспособности нашего сервиса. О нашей документации, системе тестирования и БД сейчас я упомянул для полноты картины, и мы о них обязательно поговорим в другой раз. Здесь же я расскажу о том, как на основании такой документации генерировать как можно больше полезного серверного кода.

Наш сервер написан на Play 2.5 и предоставляет REST API своим клиентам. Формат обмена данными — JSON. Стандартное описание API в Play framework находится в файле conf/route. Синтаксис этого описания прост и ограничивается описанием имен конечных точек и их параметров, а также привязкой конечных точек к методам контроллера в файле routes. Нашей целью будет замена стандартного синтаксиса на описание в формате RAML. Для этого нам нужно:

  1. Разобраться, как в Play устроена маршрутизация и как обрабатываются route-файлы.
  2. Заменить стандартный механизм маршрутизации на наш механизм, использующий RAML.
  3. Посмотреть на результат и сделать выводы :)

Итак, давайте по порядку.

Роутинг в Play framework


Play framework рассчитан на использование с двумя языками — Scala и Java. Поэтому для описания маршрутов авторы фреймворка не стали использовать DSL на базе какого-то конкретного языка, а написали свой язык и компилятор к нему. Далее я буду говорить про Scala, но всё сказанное справедливо и для Java. Play-приложение собирается с помощью SBT. Во время сборки проекта route-файлы компилируются в файлы на Scala или Java, и далее результат компиляции используется при сборке. За обработку route-файла отвечает SBT-плагин com.typesafe.play.sbt-plugin. Давайте посмотрим, как он работает. Но для начала пару слов об SBT.

Основным понятием SBT является ключ. Ключи бывают двух типов: TaskKey и SettingsKey. Первый тип используется для хранения функций. Каждое обращение к этому ключу приводит к вызову этой функции. Второй тип ключа хранит константу и вычисляется один раз. Compile — это TaskKey, в процессе выполнения он вызывает другой TaskKey, sourceGenerators, для кодогенерации и создания исходных файлов. Собственно SBT-plugin добавляет функцию обработки route-файла к sourceGenerators.

Обычно на основе route создается два основных артефакта — файл target/scala-2.11/routes/main/router/Routes.scala и target/scala-2.11/routes/main/controllers/ReverseRoutes.scala. Класс Routes используется для маршрутизации входящих запросов. ReverseRoutes используется для вызова конечных точек из кода контроллеров и view по имени конечной точки. Давайте проиллюстрируем вышесказанное примером.

conf/routes
GET /test/:strParam   @controllers.HomeController.index(strParam)

Тут мы объявляем параметризованную конечную точку и мапим её на метод HomeController.index. В результате компиляции этого файла получается следующий код на Scala:

target/scala-2.11/routes/main/router/Routes.scala
class Routes(
   override val errorHandler: play.api.http.HttpErrorHandler,
   HomeController_0: javax.inject.Provider[controllers.HomeController],
   val prefix: String
) extends GeneratedRouter {
   ...
   private[this] lazy val controllers_HomeController_index0_route = Route("GET",
       PathPattern(List(
           StaticPart(this.prefix),
           StaticPart(this.defaultPrefix),
           StaticPart("test/"),
           DynamicPart("strParam", """[^/]+""",true)
       ))
       )
 
   private[this] lazy val controllers_HomeController_index0_invoker = createInvoker(
       HomeController_0.get.index(fakeValue[String]),HandlerDef(
           this.getClass.getClassLoader,
           "router","controllers.HomeController","index",
           Seq(classOf[String]),"GET","""""",this.prefix + """test/""" + "$" + """strParam<[^/]+>""")
       )
 
   def routes: PartialFunction[RequestHeader, Handler] = {
       case controllers_HomeController_index0_route(params) =>
           call(params.fromPath[String]("strParam", None)) { (strParam) =>
               controllers_HomeController_index0_invoker.call(HomeController_0.get.index(strParam))
           }
       }
}

Этот класс занимается маршрутизацией входящих запросов. В качестве аргументов ему передаются ссылка на контроллер (точнее, инжектор, но это не существенно) и префикс URL пути, который настраивается в конфигурационном файле. Далее в классе объявлена «маска» маршрутизации controllers_HomeController_index0_route. Маска состоит из HTTP-глагола и паттерна маршрута. Последний состоит из частей, каждая соответствует элементу URL пути. StaticPart определяет маску для неизменной части пути, DynamicPart задает шаблон для URL параметра. Каждый входящий запрос попадает в функцию routes, где сопоставляется с доступными масками (в нашем случае она одна). Если совпадений не найдено — клиент получит 404 ошибку, в противном случае будет вызван соответствующий обработчик. В нашем примере обработчик один — это controllers_HomeController_index0_invoker. В обязанности обработчика входит вызов метода контроллера с нужным набором параметров и трансформация результатов этого вызова.

target/scala-2.11/routes/main/controllers/ReverseRoutes.scala
package controllers {
   class ReverseHomeController(_prefix: => String) {
       ...
        def index(strParam:String): Call = {
           import ReverseRouteContext.empty
           Call("GET", _prefix + { _defaultPrefix } +
               "test/" +
               implicitly[PathBindable[String]].unbind("strParam", dynamicString(strParam)))
       }
   }
}

Этот код позволяет нам обращаться к конечной точке через соответствующую функцию, например, во view.

Итак, чтобы сменить формат описания маршрутов нам достаточно написать свой генератор файла Routes. ReverseRoutes нам не нужен, так как наш сервис отдает JSON и view у него нет. Чтобы наш генератор сработал, нужно включить его. Можно копировать исходники генератора в каждый проект, где он нужен, а далее подключать его в build.sbt. Но правильнее будет оформить генератор в виде плагина к SBT.

Плагин SBT


О плагинах SBT исчерпывающе написано в документации. Тут я упомяну об основных, на мой взгляд, моментах. Плагин — это набор дополнительной функциональности. Обычно плагины добавляют в проект новые ключи и расширяют существующие. Нам, например, нужно будет расширить ключ sourceGenerators. Одни плагины могут зависеть от других, например, мы могли бы использовать в качестве основы плагин com.typesafe.play.sbt-plugin и изменить в нем только то, что нам нужно. Другими словами наш плагин зависит от com.typesafe.play.sbt-plugin. Чтобы SBT автоматически подключал все зависимости для нашего плагина, тот должен быть AutoPlugin'ом. Ну и последнее: из-за вопросов совместимости плагины пишутся на Scala 2.10.

Итак, нам нужно генерировать Routes.scala на основе файла RAML. Пусть этот файл называется conf/api.raml. Чтобы документацию в RAML-формате можно было использовать для маршрутизации, необходимо каким-то способом указать в нем для каждой конечной точки метод контроллера, который необходимо вызвать при получении запроса. RAML 0.8, который мы будем использовать, не имеет средств для указания такой информации, поэтому придется делать грязный хак (RAML 1.0 решает эту проблему с помощью аннотаций, но на момент написания статьи эта версия стандарта еще сыра). Добавим информацию о вызываемом методе контроллера в первую строку description для каждой конечной точки. Наш пример в RAML-формате из предыдущей главы будет выглядеть так:

/test/{strParam}:
   uriParameters:
       strParam:
       description: simple parameter
       type: string
       required: true
       example: "some value"
   get:
       description: |
           @controllers.HomeController.index(strParam)
       responses:
           200:
               body:
                   application/json:
                       schema: !include ./schemas/statements/operations.json
                       example: !include ./examples/statements/operations.json


На деталях парсинга RAML останавливаться не буду, скажу лишь, что можно использовать парсер от raml.org. В результате парсинга мы получаем список правил — по одному на каждую конечную точку. Правило задается следующим классом:

case class Rule(verb: HttpVerb, path: PathPattern, call: HandlerCall, comments: List[Comment] = List())

Названия и типы полей говорят сами за себя. Теперь для каждого правила мы можем в файле Routes.scala создать свою маску, обработчик и элемент case в функции route. Для решения этой задачи можно вручную генерировать строку с кодом Routes.scala на основе списка правил, или применить макросы. Но лучше выбрать промежуточный вариант, который предпочли и разработчики Play — использовать шаблонизатор. PВ Play применяется шаблонизатор twirl, и мы тоже его используем. Вот шаблон из нашего плагина, генерирующий функцию route:

def routes: PartialFunction[RequestHeader, Handler] = @ob
   @if(rules.isEmpty) {
   Map.empty
   } else {@for((dep, index) <- rules.zipWithIndex){@dep.rule match {
   case route @ Rule(_, _, _, _) => {
       case @(routeIdentifier(route, index))(params) =>
           call@(routeBinding(route)) @ob @tupleNames(route)
               @paramsChecker(route) @(invokerIdentifier(route, index))
                   .call(@injectedControllerMethodCall(route, dep.ident, x => safeKeyword(x.name)))@cb
       }
   }}}@cb

Выглядит несколько запутанно, но если присмотреться, то всё становится ясно. Выражения, начинающиеся с @ — это директивы и переменные шаблонизатора. Переменные @ob и @cb будут раскрыты в { и } соответственно. А, например, @(routeIdentifier(route, index)) развернется по следующему правилу:

def routeIdentifier(route: Rule, index: Int): String = route.call.packageName.replace(".", "_") +
   "_" + route.call.controller.replace(".", "_") +
   "_" + route.call.method + index + "_route"

Теперь ясно, как написать код, создающий Routes.scala на основе RAML, и понятно, как подключить его к сборке. Исходники готового плагина лежат на Github.

Планы на будущее


Плагин позволил нам использовать документацию в качестве исходного кода для сервера. Но кодогенерация не использует всей доступной информации из RAML-файла. А именно, мы никак не используем информацию о типе запроса и ответа. В Play парсинг запроса и генерация ответа происходит в методах контроллера, но мы хотим генерировать этот код автоматически. Кроме того, у нас в планах использовать версию RAML 1.0.

На сегодня всё, спасибо за внимание!
Теги:
Хабы:
+11
Комментарии 5
Комментарии Комментарии 5

Публикации

Информация

Сайт
www.raiffeisen.ru
Дата регистрации
Дата основания
1996
Численность
5 001–10 000 человек
Местоположение
Россия
Представитель

Истории