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

    На прошлой неделе на Хабре было целых две статьи о реализации 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, пишите — мы ищем грамотных специалистов.
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 10

      0
      Для информации; знакомый программист давно работает над проектом под названием Xitrum.
      Xitrum is an async and clustered Scala web framework and HTTP(S) server on top of Netty and Hazelcast
      ngocdaothanh.github.com/xitrum/
        0
        Да, я видел этот проект. Тоже хороший вариант, но на мой взгляд он больше похож именно на web framework, а для простого rest мне unfiltered нравится больше. К тому же, в unfiltered вроде типизация построже.
        –1
        Имхо, scalaz есть истинное зло в мире Scala (особенно для новичков). Возможность скалы именовать метод любым символом в этой библиотеке возведен до полнейшего абсурда.
          0
          В ветке scalaz7 они отделили основную функциональность от операторов, поэтому если вам категорически не нравится юникод то вместо него можно использовать длинные текстовые имена методов. Признаться честно, я и сам в реальном коде предпочитаю не использовать юникодные операторы.
          Считать же scalaz истинным злом я бы не стал — реализуемые этой библиотекой абстракции действительно очень полезны.
            +1
            А по-моему, смотрится это обалденно. Не в смысле понимания кода — из-за них исходники превращаются почти в книжку «1000 и 1 головоломка», а в философском смысле. :)
            +1
            Про Scala, только слышал, теперь буду представлять, что это такое. Да реализация, конечно, короче, но честно, с первого взгляда как-то чтобы легко читалась, не сказал бы.
            Но, в любом случае, очень радует, что моя статья побудила уже 2-ух человек предложить альтернативные варианты реализации.
            Спасибо, как раз хотел посмотреть с разных сторон, как можно это сделать!
              0
              Спасибо. То, что нужно. :)
                0
                Отличный топик, но все-таки ИМХО надо было обойтись без scalaz.
                  0
                  Отличный топик, но все-таки ИМХО на Java привычнее.
                  ЗЫ. Передаю привет Али.
                    0
                    Спасибо.
                    ЗЫ. Вы меня с кем-то путаете :-)

                  Only users with full accounts can post comments. Log in, please.