Реализуем RESTful Web Service на Scala

в 6:45, , рубрики: scala, scalaz, Веб-разработка, Программирование, метки: ,

На прошлой неделе на Хабре было целых две статьи о реализации RESTful web-сервисов на Java. Что ж, не будем отставать и напишем свой вариант на Scala, с монадами и аппликативными функторами. Матёрые разработчики на Scala вряд ли найдут в этой статье что-то новое, а любители Django вообще скажут что у них эта функциональность есть «из-коробки», но я надеюсь что Java-разработчикам и просто любопытствующим будет интересно почитать.

Подготовка

За основу возьмём задачу из предыдущей статьи, но постараемся решить её так, что бы код решения умещался на экран. Хотя бы на 40-дюймовый и пятым шрифтом. В конце концов, в XXI веке должна быть возможность решать простые задачи без мегабайтов xml-конфигов и десятков абстрактных фабрик.

Для тех, кто не хочет ходить по ссылкам уточню: мы собираемся реализовать простейший RESTful сервис для доступа к базе данных клиентов. Из необходимой функциональности — создание и удаление объектов в базе, а также постраничная выдача списка всех клиентов с возможностью сортировки по разным полям.

В качестве кирпичиков, из которых мы будем строить дом, возьмём:

  • Scala — даже не кирпичик, а скорее фундамент,
  • Unfiltered — отличная библиотека для обработки HTTP-запросов,
  • Squeryl — библиотека для запросов к базе данных,
  • Jackson — библиотека для работы с JSON, изначально написанная для Java, но на ура справляющаяся и со Scala-типами,
  • Scalaz — библиотека, позволяющая писать в коде разные забавные символы типа ⊛, ↦ или ∃, а заодно реализующая такие полезные абстракции, как аппликативные функторы, моноиды, полугруппы и стрелки Клейсли. Последние, правда, мне пока не приходилось использовать, но скорее всего это объясняется тем, что я ещё не достиг нужной степени функционального просветления.

По ходу статьи я постараюсь давать достаточно пояснений, что бы код был понятен людям не знакомым со Scala, но не обещаю что у меня получится.

В бой

Модель данных

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

@JsonIgnoreProperties(Array("_isPersisted"))
case class Customer(id:        String,
                    firstName: String,
                    lastName:  String,
                    email:     Option[String],
                    birthday:  Option[Date]) extends KeyedEntity[String]

Поля, имеющие тип Option[_], соответствуют nullable-колонкам базы данных. Такие поля могут принимать два вида значений: Some(value), если значение есть, и None, если его нет. Использование Option позволяет свести к минимуму шансы на появление NullPointerException и является обычной практикой в функциональных языках программирования (особенно в тех, в которых понятия null вообще нет).

Аннотация @JsonIgnoreProperties исключает определённые поля из JSON-сериализации. В данном случае пришлось исключить поле _isPersisted, которое добавил Squeryl.

Инициализация схемы базы данных

Те, кто работал с JDBC знают, что первым делом приходится инициализировать класс драйвера базы данных. Не будем отклоняться от этой практики:

Class.forName("org.h2.Driver")

SessionFactory.concreteFactory =
  Some(() => Session.create(DriverManager.getConnection("jdbc:h2:test", "sa", ""), new H2Adapter))

В первой строке мы подгружаем JDBC-драйвер, а во второй указываем библиотеке Squeryl, какую фабрику соединений использовать. В качестве базы данных используем лёгкую и быструю H2.

Теперь пришёл черёд схемы:

object DB extends Schema {
  val customer = table[Customer]
}

transaction { allCatch opt DB.create }

Сначала мы указываем, что наша база содержит одну таблицу, соответствующую классу Customer, а затем выполняем DDL-команды по созданию этой таблицы. В реальной жизни использовать автоматическое создание таблиц обычно оказывается проблематично, но для быстрой демонстрации это очень удобно. Если таблицы в базе данных уже существуют, DB.create выбросит исключение, которое мы, благодаря allCatch opt, успешно проигнорируем.

JSON-сериализация и десериализация

Для начала, проинициализируем JSON-парсер, что бы он мог работать с типами данных, принятыми в Scala:

val mapper = new ObjectMapper().withModule(DefaultScalaModule)

Теперь определим две функции для превращения JSON-строк в объекты:

def parseCustomerJson(json: String): Option[Customer] =
  allCatch opt mapper.readValue(json, classOf[Customer])

def readCustomer(req: HttpRequest[_], id: => String): Option[Customer] =
  parseCustomerJson(Body.string(req)) map (_.copy(id = id))

Функция parseCustomerJson занимается собственно разбором JSON. Благодаря использованию allCatch opt исключения, возникшие в процессе разбора, будут перехвачены и в качестве результата мы получим None. Вторая функция, readCustomer, имеет непосредственное отношение к обработке HTTP-запроса — она читает тело запроса, превращает его в объект типа Customer и устанавливает поле id в заданное значение.

Стоит отметить, что указывать тип возвращаемого значения в обеих функциях было необязательно: у компилятора достаточно данных, что бы вывести тип и без подсказок программиста, но явно указанный тип иногда облегчает понимание кода человеком.

Обратный процесс — превращение объекта Customer (или списка List[Customer]) в тело HTTP ответа — тоже не представляет сложности:

case class ResponseJson(o: Any) extends ComposeResponse(
  ContentType("application/json") ~> ResponseString(mapper.writeValueAsString(o)))

В дальнейшем, мы просто будем возвращать объекты типа ResponseJson, а фреймворк Unfiltered позаботится о том, что бы превратить его в правильный HTTP-ответ.

Ещё один маленький штрих, это генерация новых идентификаторов клиентов. Самый простой, хотя и не всегда самый удобный способ — использовать UUID:

def nextId = UUID.randomUUID().toString

Обработка HTTP-запросов

Теперь, когда большая часть подготовительной работы сделана, мы можем приступить непосредсвенно к реализации web-сервиса. Не буду вдаваться в подробности устройства библиотеки Unfiltered, скажу лишь что простейший способ её использования выглядит так:

val service = cycle.Planify {
  case /* шаблон запроса */ => /* код, генерирующий ответ */
}

У нашего сервиса будет две точки входа: /customer и /customer/[id]. Начнём со второй:

case req@Path(Seg("customer" :: id :: Nil)) => req match {
  case GET(_) => transaction { DB.customer.lookup(id) cata(ResponseJson, NotFound) }
  case PUT(_) => transaction { readCustomer(req, id) ∘ DB.customer.update cata(_ => Ok, BadRequest) }
  case DELETE(_) => transaction { DB.customer.delete(id); NoContent }
  case _ => Pass
}

В первой строке мы указываем, что этот код хочет обрабатывать только URL вида /customer/[id] и привязывает переданный идентификатор к переменной id (если неизменяемую переменную вообще можно так называть). В следующих строках мы уточняем поведение в зависимости от типа запроса. Разберём, к примеру, обработку метода PUT по шагам:

  • transaction { ... }: мы указываем, что на время работы тела обработчика следует открыть транзакцию,
  • readCustomer(req, id): используем заранее написанный метод, читающий тело запроса и возвращающий Option[Customer]
  • : этот символ заслуживает отдельного внимания, по сути он является синонимом операции map и позволяет применить какую-нибудь функцию к содержимому Option, если это содержимое есть,
  • DB.customer.update: та самая функция, которую мы хотим применить — обновление сущности в базе,
  • cata(_ => Ok, BadRequest): возвращает Ok, если в Option есть значение или BadRequest, если запрос не удалось разобрать и мы имеем None вместо клиента.

Обработка GET и DELETE запросов выполняется аналогично.

Во второй половине обработчика, обслуживающей запросы к /customer, нам понадобятся две вспомогательные функции:

  val field: PartialFunction[String, Customer => TypedExpressionNode[_]] = {
    case "id" => _.id
    case "firstName" => _.firstName
    case "lastName" => _.lastName
    case "email" => _.email
    case "birthday" => _.birthday
  }

  val ordering: PartialFunction[String, TypedExpressionNode[_] => OrderByExpression] = {
    case "asc" => _.asc
    case "desc" => _.desc
  }

Эти функции будут использоваться для создания order by части запроса и, скорее всего, покопавшись в недрах Squeryl, их можно было написать проще, но и такой вариант меня устроил. Сам код обработчика:

case req@Path(Seg("customer" :: Nil)) => req match {
  case POST(_) =>
    transaction {
      readCustomer(req, nextId) ∘ DB.customer.insert ∘ ResponseJson cata(_ ~> Created, BadRequest)
    }
  case GET(_) & Params(params) =>
    transaction {
      import Params._
      val orderBy = (params.get("orderby") ∗ first orElse Some("id")) ∗ field.lift
      val order = (params.get("order") ∗ first orElse Some("asc")) ∗ ordering.lift
      val pageNum = params.get("pagenum") ∗ (first ~> int)
      val pageSize = params.get("pagesize") ∗ (first ~> int)
      val offset = ^(pageNum, pageSize)(_ * _)
      val query = from(DB.customer) {
        q => select(q) orderBy ^(orderBy, order)(_ andThen _ apply q).toList
      }
      val pagedQuery = ^(offset, pageSize)(query.page) getOrElse query
      ResponseJson(pagedQuery.toList)
    }
  case _ => Pass
}

Часть, относящаяся к POST-запросу не несёт в себе ничего нового, а вот дальше нам приходится обрабатывать параметры запроса и появляется два непонятных символа: и ^. Первый (аккуратно, не спутайте его c обычной звёздочкой *) является синонимом к flatMap и отличается от map тем, что применяемая функция тоже должна возвращать Option. Таким образом мы можем последовательно выполнить несколько операций, каждая из которых либо успешно возвращает значение, либо возвращает None в случае ошибки. Второй оператор чуть сложнее и позволяет выполнить какую-то операцию только если все используемые переменные не равны None. Это позволяет нам выполнять сортировку только если указаны и колонка и направление, а разбивать результат на страницы только если заданы и номер страницы и её размер.

Вот собственно и всё, осталось только запустить сервер

Http(8080).plan(service).run()

и можно брать в руки curl, что бы проверить что всё работает.

Заключение

На мой взгляд, получившийся код web-сервиса компактен и довольно легко читается, а это очень важное свойство. Естественно, он не идеален: например, для обработки ошибок наверное стоило использовать scala.Either или scalaz.Validation, а кому-то может не понравиться использование юникодных операторов. Кроме того, за внешней простотой иногда могут скрываться достаточно сложные операции, и что бы понять как всё устроено «под капотом» придётся поднапрячь извилины. Тем не менее, я надеюсь что эта статья побудит кого-нибудь присмотреться к Scala повнимательнее: даже если у вас не получится применить этот язык в работе, вы наверняка узнаете что-то новое.

Код, как и положено, выложен на GitHub, и от приведённого в статье он отличается только наличием import-ов и sbt-скрипта для сборки.

Чуть не забыл — я в самом начале статьи обещал, что в вёб сервисе будут монады и прочая нечисть. Так вот, flatMap (он же ) это монадический bind, а оператор ^ имеет непосредственное отношение к аппликативным функторам.

Ну и напоследок, если вы находитесь в Харькове или Саратове и хотите разрабатывать интересные вещи, используя Scala и Akka, пишите — мы ищем грамотных специалистов.

Автор: romik

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js