На прошлой неделе на Хабре было целых две статьи о реализации 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