Статьи

Когда следование принципам REST (ful) может показаться непрактичным, GraphQL может прийти на смену

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

Итак … что такое GraphQL ? По большому счету, это еще один вид языка запросов. Но что делает его интересным, он предназначен для того, чтобы дать клиентам (например, веб-интерфейсам) возможность выражать свои потребности (например, бэкэндам) с точки зрения ожидаемых данных. Откровенно говоря, GraphQL идет гораздо дальше, но это одна из его наиболее привлекательных функций.

GraphQL — это просто спецификация, без каких-либо особых требований к языку программирования или стеку технологий, но, что неудивительно, она получила широкое признание в современной веб-разработке как на стороне клиента ( Apollo , Relay ), так и на стороне сервера (класс API часто называют BFFs в наши дни). В сегодняшнем посте мы расскажем о GraphQL , обсудим, где он может подойти и почему вы можете подумать о его принятии. Несмотря на то, что на столе немало вариантов, Sangria , потрясающая основанная на Scala реализация спецификации GraphQL , станет основой, которую мы собираемся построить.

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

1
2
3
case class User(id: Long, email: String, created: Option[LocalDateTime],
  firstName: Option[String], lastName: Option[String], roles: Option[Seq[Role.Value]],
    active: Option[Boolean], address: Option[Address])

Роль — это жестко перечисленное перечисление:

1
2
3
object Role extends Enumeration {
  val USER, ADMIN = Value
}

В то время как Адрес является еще одним небольшим классом в модели данных:

1
2
case class Address(street: String, city: String, state: String,
zip: String, country: String)

По сути, GraphQL строго типизирован. Это означает, что конкретные модели приложения должны быть как-то представлены в GraphQL . Чтобы говорить естественно, нам нужно определить схему . В Sangria определения схемы довольно просты и состоят из трех основных категорий, заимствованных из спецификации GraphQL : типы объектов, типы запросов и типы мутаций . Все это мы затронем, но определения типов объектов звучат как логическая точка для начала.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
val UserType = ObjectType(
  "User",
  "User Type",
  interfaces[Unit, User](),
  fields[Unit, User](
    Field("id", LongType, Some("User id"), tags = ProjectionName("id") :: Nil,
      resolve = _.value.id),
    Field("email", StringType, Some("User email address"),
      resolve = _.value.email),
    Field("created", OptionType(LocalDateTimeType), Some("User creation date"),
      resolve = _.value.created),
    Field("address", OptionType(AddressType), Some("User address"),
      resolve = _.value.address),
    Field("active", OptionType(BooleanType), Some("User is active or not"),
      resolve = _.value.active),
    Field("firstName", OptionType(StringType), Some("User first name"),
      resolve = _.value.firstName),
    Field("lastName", OptionType(StringType), Some("User last name"),
      resolve = _.value.lastName),
    Field("roles", OptionType(ListType(RoleEnumType)), Some("User roles"),
      resolve = _.value.roles)
  ))

Во многих отношениях это просто прямое сопоставление пользователя с типом пользователя . Sangria может помочь вам в этом, предоставив поддержку макросов, чтобы вы могли получить схему, сгенерированную во время компиляции. Определение AddressType очень похоже, давайте пропустим его и посмотрим, как работать с перечислением, например, с ролями .

1
2
3
4
5
6
7
val RoleEnumType = EnumType(
  "Role",
  Some("List of roles"),
  List(
    EnumValue("USER", value = Role.USER, description = Some("User")),
    EnumValue("ADMIN", value = Role.ADMIN, description = Some("Administrator")
  ))

Легко, просто и компактно… В традиционных веб-сервисах REST (ful) метаданные о ресурсах обычно не доступны сразу. Однако несколько дополнительных спецификаций, таких как JSON Schema , могут заполнить этот пробел с небольшим количеством работы.

Хорошо, так что типы есть, но что это за запросы и мутации ? Запрос — это особый тип в спецификации GraphQL, который в основном описывает, как вы хотите получить данные и их форму. Например, часто бывает необходимо получить информацию о пользователе по его идентификатору, который можно выразить следующим запросом GraphQL :

01
02
03
04
05
06
07
08
09
10
query {
  user(id: 1) {
    email
    firstName
    lastName
    address {
      country
    }
  }
}

Вы можете буквально прочитать его как есть: найдите пользователя с идентификатором 1 и верните только его адрес электронной почты , имя и фамилию , а также адрес только со страной . Удивительно, не только запросы GraphQL являются исключительно мощными, но они дают контроль над тем, что возвращать заинтересованным сторонам. Бесценная возможность, если вам нужно поддерживать множество разных клиентов, не увеличивая количество конечных точек API. Определение типов запросов в Сангрии также не составляет труда, например:

1
2
3
4
5
6
7
val Query = ObjectType(
  "Query", fields[Repository, Unit](
    Field("user", OptionType(UserType), arguments = ID :: Nil,
      resolve = (ctx) => ctx.ctx.findById(ctx.arg(ID))),
    Field("users", ListType(UserType),
      resolve = Projector((ctx, f) => ctx.ctx.findAll(f.map(_.name))))
  ))

На самом деле есть два запроса, которые описан во фрагменте кода выше. Тот, который мы видели раньше, выбирает пользователя по идентификатору, а другой — всех пользователей. Вот быстрый пример последнего:

1
2
3
4
5
6
query {
  users {
    id
    email
  }
}

Надеюсь, вы согласитесь, что объяснений не требуется, цель ясна. Запросы, возможно, являются самым сильным аргументом в пользу принятия GraphQL , ценностное предложение действительно огромно . С помощью Sangria у вас есть доступ к полям, которые клиенты хотят вернуть, поэтому хранилище данных может быть приказано возвращать только эти подмножества, используя проекции, выборки или аналогичные понятия. Чтобы быть ближе к реальности, наше примерное приложение хранит данные в MongoDB, поэтому мы можем попросить его вернуть только те поля, которые интересуют клиента.

1
2
3
4
5
6
7
8
9
def findAll(fields: Seq[String]): Future[Seq[User]] = collection.flatMap(
   _.find(document())
    .projection(
      fields
       .foldLeft(document())((doc, field) => doc.merge(field -> BSONInteger(1)))
    )
    .cursor[User]()
    .collect(Int.MaxValue, Cursor.FailOnError[Seq[User]]())
  )

Если мы вернемся к типичным веб-API REST (ful) , в наши дни наиболее широко используемый подход для определения формы желаемого ответа заключается в передаче параметра строки запроса, например / api / users? Fields = email, firstName, фамилия,… Однако, с точки зрения реализации, не так много фреймворков изначально поддерживают такие функции, поэтому каждый должен придумывать свой путь. Что касается возможностей запросов, в случае, если вы являетесь пользователем потрясающей платформы Apache CXF , вы можете воспользоваться ее довольно мощным поисковым расширением , о котором мы говорили некоторое время назад.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
mutation {
  add(email: "[email protected]", firstName: "John", lastName: "Smith", roles: [ADMIN]) {
    id
    active
    email
    firstName
    lastName
    address {
      street
      country
    }
  }
}

В этой мутации в систему будет добавлен новый пользователь Джон Смит с электронной почтой [email protected] и назначенной ролью ADMIN . Как и в случае запросов , клиент всегда контролирует, какая форма данных ему нужна от сервера после завершения мутации . Мутации могут рассматриваться как призывы к действию и напоминают вызовы многих методов, например, активация пользователя может быть выполнена следующим образом:

1
2
3
4
5
mutation {
  activate(id: 1) {
    active
  }
}

В Сангрии мутации описываются точно так же, как запросы , например, те, на которые мы смотрели ранее, имеют следующее определение типа:

01
02
03
04
05
06
07
08
09
10
val Mutation = ObjectType(
  "Mutation", fields[Repository, Unit](
    Field("activate", OptionType(UserType),
      arguments = ID :: Nil,
      resolve = (ctx) => ctx.ctx.activateById(ctx.arg(ID))),
    Field("add", OptionType(UserType),
      arguments = EmailArg :: FirstNameArg :: LastNameArg :: RolesArg :: Nil,
      resolve = (ctx) => ctx.ctx.addUser(ctx.arg(EmailArg), ctx.arg(FirstNameArg),
        ctx.arg(LastNameArg), ctx.arg(RolesArg)))
  ))

На этом наша схема GraphQL завершена:

1
val UserSchema = Schema(Query, Some(Mutation))

Это здорово, однако … что мы можем с этим сделать? Как раз вовремя, пожалуйста, попробуйте сервер GraphQL . Как мы помним, нет привязанности к конкретной технологии или стеку, но во вселенной веб-API вы можете рассматривать сервер GraphQL как единую конечную точку, связанную с глаголом POST HTTP . И как только мы начали говорить о HTTP и Scala , которые могли бы работать лучше, чем удивительный Akka HTTP , к счастью, Sangria полностью интегрировалась с ним.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
val route: Route = path("users") {
  post {
    entity(as[String]) { document =>
      QueryParser.parse(document) match {
        case Success(queryAst) =>
          complete(Executor.execute(SchemaDefinition.UserSchema, queryAst, repository)
            .map(OK -> _)
            .recover {
              case error: QueryAnalysisError => BadRequest -> error.resolveError
              case error: ErrorWithResolver => InternalServerError -> error.resolveError
            })
  
        case Failure(error) => complete(BadRequest -> Error(error.getMessage))
      }
    }
  } ~ get {
    complete(SchemaRenderer.renderSchema(SchemaDefinition.UserSchema))
  }
}

Вы можете заметить, что мы также выставляем нашу схему под конечной точкой GET , для чего она здесь? Что ж, если вы знакомы с Swagger, о котором мы здесь много говорили, это очень похожая концепция. Схема содержит все необходимые части, достаточные для того, чтобы внешние инструменты автоматически обнаруживали соответствующие запросы и мутации GraphQL , а также типы, на которые они ссылаются. GraphiQL , IDE в браузере для изучения GraphQL , является одним из них (подумайте о Swagger UI в мире REST (ful) сервисов).

Мы в основном там, наш сервер GraphQL готов, давайте запустим его и отправим пару запросов и мутаций, чтобы почувствовать это:

1
2
3
4
5
[info] Running com.example.graphql.Boot
INFO  akka.event.slf4j.Slf4jLogger  - Slf4jLogger started
INFO  reactivemongo.api.MongoDriver  - No mongo-async-driver configuration found
INFO  reactivemongo.api.MongoDriver  - [Supervisor-1] Creating connection: Connection-2
INFO  r.core.actors.MongoDBSystem  - [Supervisor-1/Connection-2] Starting the MongoDBSystem akka://reactivemongo/user/Connection-2

Весьма вероятно, что в нашем хранилище данных (мы используем MongoDB в качестве контейнера Docker ) в данный момент нет пользователей, поэтому неплохо добавить его сразу:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ curl -vi -X POST http://localhost:48080/users -H "Content-Type: application/json" -d " \
   mutation { \
     add(email: \"a@b.com\", firstName: \"John\", lastName: \"Smith\", roles: [ADMIN]) { \
       id \
       active \
       email \
       firstName \
       lastName \
       address { \
       street \
         country \
       } \
     } \
   }"
 
HTTP/1.1 200 OK
Server: akka-http/10.0.5
Date: Tue, 25 Apr 2017 01:01:25 GMT
Content-Type: application/json
Content-Length: 123
 
{
  "data":{
    "add":{
      "email":"[email protected]",
      "lastName":"Smith",
      "firstName":"John",
      "id":1493082085000,
      "address":null,
      "active":false
    }
  }
}

Кажется, работает отлично. Детали ответа всегда будут заключены в конверт данных , независимо от того, какой запрос или мутацию вы выполняете, например:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ curl -vi -X POST http://localhost:48080/users -H "Content-Type: application/json" -d " \                                               
   query { \                                                                                                                          
     users { \                                                                                                                        
       id \                                                                                                                           
       email \                                                                                                                        
     } \                                                                                                                              
   }"                                                                                                                                 
 
HTTP/1.1 200 OK                                                                                                                                  
Server: akka-http/10.0.5                                                                                                                         
Date: Tue, 25 Apr 2017 01:09:21 GMT                                                                                                              
Content-Type: application/json                                                                                                                   
Content-Length: 98                                                                                                                               
                                                                                                                                                   
{
  "data":{
    "users":[
      {
        "id":1493082085000,
        "email":"[email protected]"
      }
    ]
  }
}

Точно так же, как мы и заказали … Честно говоря, работа с GraphQL кажется естественной, особенно когда требуется запрос данных. И мы даже не говорили о фрагментах , переменных , директивах и многом другом.

Теперь возникает вопрос: должны ли мы отказаться от всех наших практик, JAX-RS , Spring MVC , … и перейти на GraphQL ? Я искренне верю, что это не так, GraphQL хорошо подходит для решения определенных проблем, но в целом традиционные Web-сервисы REST (ful) в сочетании с Swagger или любой другой установленной платформой спецификации API остаются здесь. ,

И, пожалуйста, имейте в виду , что наряду с преимуществами GraphQL имеет свою цену. Например, HTTP-кеширование и управление кешем больше не будут применяться, HATEOAS также не имеет особого смысла, унифицированные ответы, независимо от того, что вы называете, надежность, поскольку все находится за одним фасадом… С учетом этого GraphQL действительно отличный инструмент, пожалуйста, используйте его с умом!

Полный исходный код проекта доступен на Github .