Статьи

Разработка современных приложений с использованием Scala: веб-API с использованием Akka HTTP

Эта статья является частью нашего академического курса под названием « Разработка современных приложений с помощью Scala» .

В этом курсе мы предоставляем среду и набор инструментов, чтобы вы могли разрабатывать современные приложения Scala. Мы охватываем широкий спектр тем: от сборки SBT и реактивных приложений до тестирования и доступа к базе данных. С нашими простыми учебными пособиями вы сможете запустить и запустить собственные проекты за минимальное время. Проверьте это здесь !

1. Введение

Как часто в наши дни вы слышите фразы типа «Веб-API пожирают мир» ? Действительно, спасибо Марку Андреессену за хорошее обобщение этого, но API становятся все более и более важными для поддержки разговоров между предприятиями или между потребителями , особенно в веб-вселенной.

Для многих предприятий наличие веб-API является обязательным конкурентным преимуществом и часто является вопросом выживания.

2. Быть ОТДЫХОМ (фул)

В сегодняшней сети HTTP — король. За прошедшие годы было предпринято много разных попыток придумать стандартный, унифицированный протокол высокого уровня для предоставления услуг над ним. SOAP был, вероятно, первым, который получил широкую популярность (особенно в корпоративном мире) и на протяжении ряда лет служил де-факто спецификацией для разработки веб-сервисов и API. Тем не менее, его многословие и формализм часто служили источником дополнительной сложности, и это является одной из причин возникновения передачи представительского состояния (или просто REST ).

В наши дни большинство веб-сервисов и API-интерфейсов разрабатываются в соответствии с архитектурными стилями REST и поэтому называются REST (ful) . Для создания веб-сервисов и API, соответствующих принципам и ограничениям REST (ful), существует множество отличных инфраструктур, но в мире Scala есть несомненный лидер: Akka HTTP .

3. От спрея до Акки Http

Многие из вас могут быть знакомы с Akka HTTP с помощью его потрясающего предшественника, очень популярного Spray Framework . Это все еще довольно широко используется в дикой природе, но с прошлого года или около того, Spray Framework активно мигрирует под зонтиком Akka HTTP и будет в конечном итоге прекращен. На момент написания статьи последняя стабильная версия Akka HTTP (распространяемая как часть любимого Akka Toolkit ) была 2.4.11 .

Akka HTTP стоит на плечах Actor Model (предоставленной ядром Akka ) и Akka Streams и, таким образом, полностью охватывает парадигму реактивного программирования . Однако, пожалуйста, обратите внимание, что некоторые части Akka HTTP все еще носят экспериментальный ярлык, так как миграция Spray Framework в новый дом продолжается, и некоторые контракты с большой вероятностью изменятся.

4. Оставаться на сервере

В общем, когда мы говорим о веб-сервисах и API, в нем участвуют как минимум две стороны: сервер (поставщик) и клиент (потребитель). Неудивительно, что Akka HTTP имеет поддержку обоих, поэтому давайте начнем с самой интересной части, со стороны сервера.

Akka HTTP предлагает довольно много уровней API, от довольно низкоуровневой обработки запросов / ответов до красивых DSL . В этой части руководства мы собираемся использовать только DSL-сервер Akka HTTP , поскольку это самый быстрый (и самый красивый) способ начать создание веб-сервисов REST (ful) и API в Scala .

4.1. Маршруты и директивы

В основе HTTP- серверов Akka лежит маршрутизация, которая в контексте HTTP- коммуникаций может быть описана как процесс выбора наилучших путей для обработки входящих запросов. Маршруты составляются и описываются с использованием директив : строительные блоки серверного DSL -сервера Akka HTTP .

Например, давайте разработаем простой веб-API REST (ful) для управления пользователями, несколько функционально похожий на тот, который мы сделали в разделе «Веб-приложения с Play Framework» . Напомню, что вот как выглядит наш класс User дел (неудивительно, что он используется везде):

1
2
case class User(id: Option[Int], email: String,
  firstName: Option[String], lastName: Option[String])

Вероятно, первый маршрут, который может понадобиться нашему веб-API для обработки, — это вернуть список всех пользователей, поэтому пусть это будет нашей отправной точкой:

1
2
3
4
5
6
7
val route: Route = path("users") {
  pathEndOrSingleSlash {
    get {
      complete(repository.findAll)
    }
  }
}

Так просто! Каждый раз, когда клиент отправляет запрос GET на конечную точку /users (согласно path("users") и get директивы), мы собираемся извлечь всех пользователей из основного хранилища данных и вернуть их обратно (согласно complete директиве). В действительности маршруты могут быть довольно сложными, требующими извлечения и проверки различных параметров, но, к счастью, в DSL маршрутизации есть все, что встроено, например:

1
2
3
4
5
6
7
8
9
val route: Route = pathPrefix("users") {
    path(IntNumber) { id =>
      get {
        rejectEmptyResponse {
          complete(repository.find(id))
        }
      }
    }
  }

Давайте внимательнее посмотрим на этот фрагмент кода. Как вы уже могли догадаться, мы предоставляем веб-API для извлечения пользователя по его целочисленному идентификатору, который предоставляется в качестве параметра пути URI (с использованием директивы path(IntNumber) ), например /users/101 . Однако пользователь с таким идентификатором может существовать или не существовать (вообще говоря, repository.find(id) возвращает Option[User] ). В этом случае наличие директивы rejectEmptyResponse указывает нашей конечной точке возвращать код состояния HTTP 404 (а не пустой ответ) в случае, если пользователь не был найден в хранилище данных.

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

4.2. Маршаллинг и демаршаллинг

Akka HTTP использует процесс маршалинга и демаршаллинга для преобразования объектов в представление, передаваемое по проводам. Но практически, в большинстве случаев, когда речь шла о веб-API REST (ful) , мы говорим о формате JSON , и Akka HTTP имеет превосходную поддержку для этого.

1
2
3
4
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
import spray.json.DefaultJsonProtocol._
 
implicit val userFormat = jsonFormat4(User)

Использование предопределенной функции jsonFormat4 вводит неявную поддержку для маршаллинга User класса case в представление JSON и отмены маршалинга его обратно User из JSON , например:

1
2
3
4
5
6
7
8
9
val route: Route = pathPrefix("users") {
  pathEndOrSingleSlash {
    post {
      entity(as[User]) { user =>
        complete(Created, repository.insert(user))
      }
    }
  }
}

Выглядит неплохо, но практики веб-API REST (ful) могут утверждать, что семантика действия POST должна включать заголовок Location указывающий на вновь созданный ресурс. Давайте исправим это, потребуется немного реструктурировать реализацию, введя директивы onSuccess и respondWithHeader .

01
02
03
04
05
06
07
08
09
10
11
12
13
val route: Route = pathPrefix("users") {
  pathEndOrSingleSlash {
    post {
      entity(as[User]) { user =>
        onSuccess(repository.insert(user)) { u =>
          respondWithHeader(Location(uri.withPath(uri.path / u.id.mkString))) {
            complete(Created, u)
          }
        }
      }
    }
  }
}

Отлично, что не менее важно, есть специальная поддержка потоковой передачи ответов HTTP в формате JSON , особенно когда задействованы потоки Akka . В разделе «Доступ к базе данных с помощью Slick» мы уже узнали, как транслировать результаты из хранилища данных с использованием потрясающей библиотеки Slick , поэтому этот фрагмент кода должен выглядеть очень знакомым.

1
2
3
4
5
6
7
class UsersRepository(val config: DatabaseConfig[JdbcProfile]) extends Db with UsersTable {
  ...
  def stream(implicit materializer: Materializer) = Source
    .fromPublisher(db.stream(
       users.result.withStatementParameters(fetchSize = 10)))
  ...
}

Source [T, _] может быть напрямую возвращен из директивы complete , при условии, что T (в нашем случае это User ) имеет неявную поддержку маршалинга JSON , например:

1
2
3
4
5
6
7
8
9
implicit val jsonStreamingSupport = EntityStreamingSupport.json()
 
val route: Route = pathPrefix("users") {
  path("stream") {
    get {
      complete(repository.stream)
    }
  }
}

Хотя это может отличаться от других фрагментов кода, которые мы видели до сих пор, в этом случае Akka HTTP будет использовать кодирование передачи по частям для доставки ответа.

Также обратите внимание, что поддержка JSON в настоящее время находится на переходном этапе и все еще находится в пакетах Spray Framework . Надеемся, что возможные абстракции HTTP Akka будут очень похожи, и миграция будет зависеть от изменения пакета (скрестив пальцы).

4,3. Директивы в глубине

Директивы в Akka HTTP способны выполнять практически все с запросом или ответом, и существует действительно впечатляющий список предопределенных . Чтобы лучше понять механику, мы рассмотрим еще два примера: ведение журнала и безопасность.

Директива logRequestResult становится очень logRequestResult если вам необходимо устранить неполадки веб-API HTTP Akka путем проверки полных снимков запросов и ответов.

1
2
3
val route: Route = logRequestResult("user-routes") {
  ...
}

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

1
2
3
[akka.actor.ActorSystemImpl(akka-http-webapi)] user-routes: Response for
  Request : HttpRequest(HttpMethod(POST),http://localhost:58080/users,List(Host: localhost:58080, User-Agent: curl/7.47.1, Accept: */*, Timeout-Access: ),HttpEntity.Strict(application/json,{"email": "[email protected]"}),HttpProtocol(HTTP/1.1))
  Response: Complete(HttpResponse(200 OK,List(),HttpEntity.Strict(application/json,{"id":1,"email":"[email protected]"}),HttpProtocol(HTTP/1.1)))

Обратите внимание, что все заголовки запросов и ответов вместе с полезной нагрузкой включены. В случае передачи конфиденциальной информации (например, паролей или учетных данных) было бы неплохо внедрить какую-либо фильтрацию или маскировку сверху.

Другой интересный пример связан с защитой веб-API Akka HTTP с использованием семейства authenticateXxx . На данный момент поддерживаются только два потока аутентификации: HTTP Basic Auth и OAuth2 . В качестве примера, давайте введем еще одну конечную точку в наш веб-API, чтобы разрешить пользовательскую модификацию (обычно это делается с помощью запроса PUT ).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
val route: Route = pathPrefix("users") {
  path(IntNumber) { id =>
    put {
      authenticateBasicAsync(realm = "Users", authenticator) { user =>
        rejectEmptyResponse {
          entity(as[UserUpdate]) { user =>
            complete(
              repository.update(id, user.firstName, user.lastName) map {
                case true => repository.find(id)
                case _=> Future.successful(None)
              }
            )
          }
        }
      }
    }
  }
}

Эта конечная точка защищена с помощью HTTP Basic Auth и будет проверять, существует ли уже электронная почта пользователя в хранилище данных. Для простоты пароль всегда жестко запрограммирован на "password" (пожалуйста, никогда не делайте этого в реальных приложениях).

1
2
3
4
5
6
7
def authenticator(credentials: Credentials): Future[Option[User]] = {
  credentials match {
    case p @ Credentials.Provided(email) if p.verify("password") =>
      repository.findByEmail(email)
    case _ => Future.successful(None)
  }
}

Действительно приятно, но, вероятно, лучшая часть дизайна Akka HTTP — это расширяемость, встроенная прямо в ядро: очень легко ввести свои собственные директивы, если нет ничего предопределенного для удовлетворения ваших потребностей.

4.4. Когда дела идут плохо

Конечно, большую часть времени ваши веб-API будут работать отлично, обслуживая счастливых клиентов. Однако время от времени случаются плохие вещи, и лучше быть готовыми к ним. По сути, причин сбоя может быть много: нарушение бизнес-ограничений, подключение к базе данных, недоступность внешних зависимостей, сборка мусора и т. Д. Под капотом мы можем классифицировать их по двум различным сегментам: исключения и продолжительность выполнения.

В свете разрабатываемого нами веб-API типичным примером исключительной ситуации может быть создание пользователя с дублирующимся адресом электронной почты. На уровне хранения данных это приведет к уникальному нарушению ограничений и требует особой обработки. Как и следовало ожидать, для этого есть специальная директива handleExceptions , например:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
val route: Route = pathPrefix("users") {
  pathEndOrSingleSlash {
    post {
      handleExceptions(exceptionHandler) {
        extractUri { uri =>
          entity(as[User]) { user =>
            onSuccess(repository.insert(user)) { u =>
              complete(Created, u)
            }
          }
        }
      }   
    }
  }
}

Директива handleExceptions принимает ExceptionHandler в качестве аргумента, который отображает конкретное исключение в ответ. В нашем случае это будет SQLException, сопоставленное с HTTP- кодом ответа 409 , что указывает на конфликт.

1
2
3
val exceptionHandler = ExceptionHandler {
  case ex: SQLException => complete(Conflict, ex.getMessage)
}

Это здорово, но как насчет времени выполнения? К счастью, Akka HTTP поддерживает различные таймауты для защиты ваших веб-API. Тайм-аут запроса — один из тех, который позволяет ограничить максимальное количество времени, которое может занять маршрут для возврата ответа. Если это время превышено, будет возвращен код ответа HTTP 503 , сигнализирующий о том, что веб-API в данный момент недоступен. Все эти тайм-ауты могут быть настроены с использованием глобальных настроек в application.conf или с использованием директив timeout , например:

1
2
3
4
5
6
7
8
9
val route: Route = pathPrefix("users") {
  pathEndOrSingleSlash {
    post {
      withRequestTimeout(5 seconds) {
         ...
      }   
    }
  }
}

Возможность использовать такой детализированный элемент управления является действительно мощным вариантом, поскольку не все веб-API одинаково важны и ожидания выполнения могут отличаться.

4,5. Запуск / выключение

Akka HTTP использует механизмы расширения Akka и обеспечивает реализацию расширения для поддержки Http . Существует довольно много способов его инициализации и использования, но самый простой из них — использование функции bindAndHandle .

1
2
3
4
5
implicit val system = ActorSystem("akka-http-webapi")
implicit val materializer = ActorMaterializer()
 
val repository = new UsersRepository(config)
Http().bindAndHandle(new UserApi(repository).route, "0.0.0.0", 58080)

Процедура выключения несколько сложна, но в двух словах состоит из двух этапов: открепление расширения Http и выключение базовой системы акторов, например:

1
2
3
4
val f = Http().bindAndHandle(new UserApi(repository).route, "0.0.0.0", 58080)
f.flatMap(_.unbind) onComplete {
  _ => system.terminate
}

Вероятно, вы редко сталкивались бы с необходимостью использовать программное завершение, но, тем не менее, полезно знать механизм этого.

4,6. Безопасный HTTP (HTTPS)

Наряду с обычным HTTP , Akka HTTP готов к производственным развертываниям и поддерживает безопасную связь через HTTPS . Как и в том, что мы узнали в разделе учебника «Веб-приложения с Play Framework» , нам понадобится хранилище ключей Java с импортированными сертификатами. Для целей разработки создание самозаверяющих сертификатов является достаточно хорошим вариантом для начала:

01
02
03
04
05
06
07
08
09
10
11
keytool -genkeypair -v
  -alias localhost
  -dname "CN=localhost"
  -keystore src/main/resources/akka-http-webapi.jks
  -keypass changeme
  -storepass changeme
  -keyalg RSA
  -keysize 4096
  -ext KeyUsage:critical="keyCertSign"
  -ext BasicConstraints:critical="ca:true"
  -validity 365

Однако для настройки поддержки HTTPS на стороне сервера требуется написать немало кода, но, к счастью, он хорошо документирован . Черта SslSupport служит примером такой конфигурации.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
trait SslSupport {
  val configuration = ConfigFactory.load()
  val password = configuration.getString("keystore.password").toCharArray()
 
  val https: HttpsConnectionContext =
    managed(getClass.getResourceAsStream("/akka-http-webapi.jks")).map { in =>
      val keyStore = KeyStore.getInstance("JKS")
      keyStore.load(in, password)
   
      val keyManagerFactory = KeyManagerFactory.getInstance("SunX509")
      keyManagerFactory.init(keyStore, password)
   
      val tmf = TrustManagerFactory.getInstance("SunX509")
      tmf.init(keyStore)
   
      val sslContext = SSLContext.getInstance("TLS")
      sslContext.init(keyManagerFactory.getKeyManagers, tmf.getTrustManagers,
        new SecureRandom)
     
      ConnectionContext.https(sslContext)
    }.opt.get
}

Теперь мы можем использовать переменную контекста соединения https для создания привязки HTTPS , передав ее функции bindAndHandle в качестве аргумента, например:

1
2
Http().bindAndHandle(new UserApi(repository).route, "0.0.0.0", 58083,
  connectionContext = https)

Обратите внимание, что у вас может быть поддержка HTTP, поддержка HTTPS или и то и другое одновременно, Akka HTTP дает вам полную свободу в выборе такого рода.

4,7. тестирование

Существует множество различных подходов к тестированию веб-API. Неудивительно, что, как и любой другой модуль Akka , Akka HTTP имеет специальные тестовые леса, полностью интегрированные со структурой ScalaTest (к сожалению, specs2 пока не поддерживается «из коробки»).

Давайте начнем с очень простого примера, чтобы убедиться, что конечная точка /users будет возвращать пустой список пользователей при отправке запроса GET .

1
2
3
4
5
6
7
"Users Web API" should {
  "return all users" in {
    Get("/users") ~> route ~> check {
      responseAs[Seq[User]] shouldEqual Seq()
    }
  }
}

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
"Users Web API" should {
  "create and update user" in {
    Post("/users", User(None, "[email protected]", None, None)) ~> route ~> check {
      status shouldEqual Created
      header[Location] map { location =>
        val credentials = BasicHttpCredentials("[email protected]", "password")
        Put(location.uri, UserUpdate(Some("John"), Some("Smith"))) ~>
            addCredentials(credentials) ~> route ~> check {
          status shouldEqual OK
          responseAs[User] should have {
            'firstName (Some("John"))
            'lastName (Some("Smith"))
          }
        }
      }
    }
  }
}

Конечно, если вы практикуете TDD или производные методологии (и я искренне верю, что все должны), вы бы оценили надежность и краткость лесов тестирования Akka HTTP . Кажется, все сделано правильно.

5. Жизнь как клиент

Я надеюсь, что в этот момент мы по-настоящему оценили мощную серверную поддержку, которую Akka HTTP предоставляет для разработки REST (ful) веб-сервисов и API. Но часто веб-API, которые мы сами создали, становятся клиентами для других внешних веб-сервисов и API.

Как мы уже упоминали ранее, Akka HTTP имеет отличную клиентскую поддержку для взаимодействия с внешними веб-сервисами и API-интерфейсами на основе HTTP . Как и на стороне сервера, доступно несколько уровней клиентских API , но, вероятно, самый простой в использовании — это API уровня запросов .

1
2
3
4
val response: Future[Seq[User]] =
  Http()
    .singleRequest(HttpRequest(uri = "http://localhost:58080/users"))
    .flatMap { response => Unmarshal(response).to[Seq[User]] }

В этом простом примере есть только один запрос к нашей конечной точке /users и результаты передаются из JSON в Seq[User] . Однако в большинстве реальных приложений стоит постоянно платить цену за установление HTTP- соединений, поэтому использование пулов соединений является предпочтительным и эффективным решением. Полезно знать, что в Akka HTTP также есть все необходимые строительные блоки для таких сценариев, например:

01
02
03
04
05
06
07
08
09
10
val pool = Http().superPool[String]()
   
val response: Future[Seq[User]] =
    Source.single(HttpRequest(uri = "http://localhost:58080/users") -> uuid)
      .via(pool)
      .runWith(Sink.head)
      .flatMap {
        case (Success(response), _) => Unmarshal(response).to[Seq[User]]
        case _ => Future.successful(Seq[User]())
      }

Конечные результаты этих двух фрагментов кода абсолютно одинаковы. Но последний использует пул и еще раз показывает, что Akka Streams постоянно поддерживается Akka HTTP и весьма удобен при обработке HTTP- сообщений на стороне клиента. Однако есть одна важная деталь: после завершения пулы соединений должны быть отключены вручную, например:

1
Http().shutdownAllConnectionPools()

Если связь между клиентом и сервером основывается на протоколе HTTPS , API-интерфейс клиента Akka HTTP поддерживает шифрование TLS из коробки.

6. Выводы

Если вы создаете или думаете о создании REST (ful) веб-сервисов и API в Scala, обязательно попробуйте Akka HTTP . Его DSL маршрутизации — прекрасный и элегантный способ описания семантики ваших ресурсов REST (ful) и включения реализации для их обслуживания. По сравнению с Play Framework , на самом деле есть несколько совпадений, но для чистых проектов веб-API Akka HTTP определенно победитель. Присутствие некоторых экспериментальных модулей может наложить некоторые риски на принятие Akka HTTP сразу, однако с каждым выпуском все ближе и ближе, чтобы обеспечить стабильные и сжатые контракты.

7. Что дальше

Это действительно грустно признать, но наш учебник подходит к концу. Но я искренне верю, что для большинства из нас это станет лишь началом увлекательного путешествия в мир языка программирования и экосистемы Scala . Пусть радость и успех будут с вами по пути!

Полный исходный код доступен для скачивания .