GraphQL — это не серебряная пуля
20 октября 2022 г.Слишком сильно влюбился в GraphQL
Мне очень нравится работать с GraphQL. Вероятно, это лучший язык для «запросов» предопределенных деревьев объектов. Это не совсем язык запросов к базе данных Graph, как Cypher, но нам он все равно нравится.
На самом деле я бы назвал GraphQL скорее языком запросов JSON. В спецификации на самом деле не упоминается JSON, но большинство из нас использует GraphQL для запроса «деревьев» JSON.
Когда вы работаете с API, которые создают JSON, GraphQL действительно отлично подходит для получения подмножеств нужных вам данных. В других инструментах API, таких как gRPC или REST, отсутствуют «наборы выбора», что делает не таким интуитивно понятным получение подмножества всего дерева данных. И даже если ваши API не создают JSON или используют более громоздкие протоколы, такие как Kafka или gRPC, вы все равно можете использовать GraphQL поверх них с помощью такого инструмента, как WunderGraph.
В любом случае, GraphQL отлично подходит для запросов к JSON API. А как насчет конфигурации?
Прошло почти 4 года с тех пор, как я начал "внедрять" GraphQL в Go. Когда вы экспериментируете с новым инструментом, очевидно, что вы хотите использовать его везде, что я и сделал.
Через год после того, как я начал реализовывать сам язык, я создал GraphQL Gateway, который использует GraphQL SDL (определение схемы язык) в качестве основного языка конфигурации. Как видите, проект заархивирован и больше не поддерживается. Это был отличный эксперимент, но язык слишком ограничен для использования в качестве языка конфигурации.
Далее я рассмотрю несколько примеров, чтобы проиллюстрировать проблемы.
В GraphQL отсутствует импорт
Если вы посмотрите на мой пример конфигурации, вы заметите, что первый 102 строки — это просто объявления директив и типов. Вы должны разместить их где-нибудь, потому что иначе ваша IDE не сможет обеспечить вам интенсивность.
GraphQL по умолчанию не поддерживает импорт. Это приводит к подобным взломам:
# import '../fragments/UserData.graphql'
query GetUser {
user(id: 1) {
# ...UserData
email
}
}
Если вы выполните поиск по ключевому слову «импорт» в спецификации GraphQL, вы обнаружите, что оно нигде не упоминается.
Взгляните на этот пример из Apollo Federation 2. Где реальная схема? На первый взгляд, как выглядит этот GraphQL API? Это совсем не очевидно.
Еще одна проблема, с которой вы столкнетесь, заключается в том, что вам нужно скопировать и вставить «объявления». Как вы проверяете, действительно ли объявления соответствуют вашей среде выполнения? Ожидаете ли вы, что после изменения среды выполнения разработчик обновит объявления вручную?
Злоупотребление директивами GraphQL
По моему мнению, директивы отлично подходят для операций GraphQL, но быстро превращают схему GraphQL в беспорядок при использовании для настройки.
Вот хороший пример:
mutation ($email: String!) @rbac(requireMatchAll: [superadmin]) {
deleteManyMessages(where: { users: { is: { email: { equals: $email } } } }) {
count
}
}
Чтобы иметь возможность выполнить эту мутацию, вы должны быть суперадминистратором, вот и все. Сразу видно, что это мутация, и речь идет об удалении сообщений. Директива не мешает пониманию мутации.
Далее, давайте рассмотрим ужасный пример использования директив из моих экспериментов с GraphQL Gateway:
type Query {
post(id: Int!): JSONPlaceholderPost
@HttpJsonDataSource(
host: "jsonplaceholder.typicode.com"
url: "/posts/{{ .arguments.id }}"
)
@mapping(mode: NONE)
country(code: String!): Country
@GraphQLDataSource(
host: "countries.trevorblades.com"
url: "/"
field: "country"
params: [
{
name: "code"
sourceKind: FIELD_ARGUMENTS
sourceName: "code"
variableType: "String!"
}
]
)
person(id: String!): Person
@WasmDataSource(
wasmFile: "./person.wasm"
input: "{"id":"{{ .arguments.id }}"}"
)
@mapping(mode: NONE)
httpBinPipeline: String
@PipelineDataSource(
configFilePath: "./httpbin_pipeline.json"
inputJSON: """
{
"url": "https://httpbin.org/get",
"method": "GET"
}
"""
)
@mapping(mode: NONE)
}
Сколько корневых полей содержит эта схема? 4. Но это не очевидно. Четыре корневых поля должны состоять из 4 строк кода, а не из 37. Имейте в виду, что это простой пример. Мы даже не начали добавлять директивы для аутентификации, авторизации, ограничения скорости и т.д...
В настоящее время соотношение между корневыми полями и директивами конфигурации составляет почти 1:10. Добавление дополнительной логики в конфигурацию только усугубит ситуацию.
Вот еще один пример из Apollo Federation 2:
type Product implements ProductItf & SkuItf
@join__implements(graph: INVENTORY, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "SkuItf")
@join__implements(graph: REVIEWS, interface: "ProductItf")
@join__type(graph: INVENTORY, key: "id")
@join__type(graph: PRODUCTS, key: "id")
@join__type(graph: PRODUCTS, key: "sku package")
@join__type(graph: PRODUCTS, key: "sku variation { id }")
@join__type(graph: REVIEWS, key: "id")
{
id: ID! @tag(name: "hi-from-products")
dimensions: ProductDimension @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS)
delivery(zip: String): DeliveryEstimates @join__field(graph: INVENTORY, requires: "dimensions { size weight }")
sku: String @join__field(graph: PRODUCTS)
name: String @join__field(graph: PRODUCTS)
package: String @join__field(graph: PRODUCTS)
variation: ProductVariation @join__field(graph: PRODUCTS)
createdBy: User @join__field(graph: PRODUCTS)
hidden: String @join__field(graph: PRODUCTS)
reviewsScore: Float! @join__field(graph: REVIEWS, override: "products")
oldField: String @join__field(graph: PRODUCTS)
reviewsCount: Int! @join__field(graph: REVIEWS)
reviews: [Review!]! @join__field(graph: REVIEWS)
}
Интересно, как вы отлаживаете эту схему, если одна из директив @join__field
или @join__type
неверна. Но даже если мы проигнорируем часть отладки, интенсивное использование директив делает невозможным понимание схемы с первого взгляда.
Вот пример из Федерации 1:
type Product @key(fields: "id") {
id: ID!
name: String
price: Int
weight: Int
inStock: Boolean
shippingEstimate: Int @external
warehouse: Warehouse @requires(fields: "inStock")
}
Директивы @key
, @external
и @requires
могут быть немного странными, но, по крайней мере, их можно прочитать.
Директивы GraphQL заставляют вас повторяться
Вот еще один пример, в котором я хотел применить правило "режим сопоставления NONE" ко всем корневым полям:
type Query {
hello: String!
@StaticDataSource(
data: "World!"
)
@mapping(mode: NONE)
staticBoolean: Boolean!
@StaticDataSource(
data: "true"
)
@mapping(mode: NONE)
nonNullInt: Int!
@StaticDataSource(
data: "1"
)
@mapping(mode: NONE)
nullableInt: Int
@StaticDataSource(
data: null
)
@mapping(mode: NONE)
foo: Foo!
@StaticDataSource(
data: "{"bar": "baz"}"
)
@mapping(mode: NONE)
}
Было бы неплохо, если бы я мог применять конфигурации по умолчанию ко всем корневым полям? С таким языком, как TypeScript, у нас могла бы быть функция карты, которая применяет конфигурации по умолчанию ко всем корневым полям. В GraphQL нет такого простого решения для борьбы с повторениями.
Директивы GraphQL нельзя компоновать
Как видно из приведенного выше примера, было бы очень удобно, если бы мы могли "скомпоновать" директиву @HttpJsonDataSource
с директивой @mapping
, потому что в большинстве случаев случаи, они нужны нам вместе.
В таком языке, как Typescript, мы могли бы обернуть функцию HttpJsonDataSource
функцией mapping
:
const HttpJsonDataSourceWithMapping = (config: HttpJsonDataSourceConfig) => {
return mapping(HttpJsonDataSource(config));
};
С GraphQL нам всегда приходится применять все директивы отдельно. Он повторяется и затрудняет чтение схемы.
Директивы GraphQL не безопасны для типов
Многие люди хвалят GraphQL за то, что он безопасен для типов. При использовании директив GraphQL для настройки вы теряете эту безопасность типов, как только она становится более сложной.
Давайте рассмотрим несколько примеров. Я не хочу разглагольствовать о других, поэтому начну со своего примера:
type Query {
post(id: Int): JSONPlaceholderPost
@HttpJsonDataSource(
host: "jsonplaceholder.typicode.com"
url: "/posts/{{ .arguments.id }}"
)
}
В URL-адресе мы используем какой-то странный синтаксис шаблонов для вставки аргумента id
в URL-адрес. Откуда мы знаем, как правильно написать этот шаблон, если это просто строка? Что делать, если идентификатор равен нулю?
Добавим еще немного логики для повышения читабельности (сарказм):
type Query {
post(id: Int): JSONPlaceholderPost
@HttpJsonDataSource(
host: "jsonplaceholder.typicode.com"
path: "/posts/{{ default 0 .arguments.id }}"
)
}
Этот пример был странным, но все же в чем-то приемлемым. Если вам нужно настроить источник данных GraphQL, это становится более сложным.
type Query {
country(code: String!): Country
@GraphQLDataSource(
host: "countries.trevorblades.com"
url: "/"
field: "country"
params: [
{
name: "code"
sourceKind: FIELD_ARGUMENTS
sourceName: "code"
variableType: "String!"
}
]
)
}
В этом примере мы должны «применить» аргумент code
к источнику данных GraphQL. Мы должны ссылаться на него по имени (name: "code"
), но это также просто строка без какой-либо безопасности типов. Поскольку нам нужно иметь возможность применить некоторую логику «сопоставления», мы также должны указать sourceName
и variableType
, которые также являются просто строками.
Все это работает, но очень хрупко и трудно отлаживается. Но глядя на Федерацию, может быть и хуже:
type Product implements ProductItf & SkuItf
@join__implements(graph: INVENTORY, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "ProductItf")
@join__implements(graph: PRODUCTS, interface: "SkuItf")
@join__implements(graph: REVIEWS, interface: "ProductItf")
@join__type(graph: INVENTORY, key: "id")
@join__type(graph: PRODUCTS, key: "id")
@join__type(graph: PRODUCTS, key: "sku package")
@join__type(graph: PRODUCTS, key: "sku variation { id }")
@join__type(graph: REVIEWS, key: "id")
{
id: ID! @tag(name: "hi-from-products")
dimensions: ProductDimension @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS)
delivery(zip: String): DeliveryEstimates @join__field(graph: INVENTORY, requires: "dimensions { size weight }")
sku: String @join__field(graph: PRODUCTS)
name: String @join__field(graph: PRODUCTS)
package: String @join__field(graph: PRODUCTS)
variation: ProductVariation @join__field(graph: PRODUCTS)
createdBy: User @join__field(graph: PRODUCTS)
hidden: String @join__field(graph: PRODUCTS)
reviewsScore: Float! @join__field(graph: REVIEWS, override: "products")
oldField: String @join__field(graph: PRODUCTS)
reviewsCount: Int! @join__field(graph: REVIEWS)
reviews: [Review!]! @join__field(graph: REVIEWS)
}
Взгляните на строки 8, 9 и 14. Мы должны указать аргументы key
для определения соединений между подграфами. Если вы внимательно присмотритесь, то увидите, что значением строкового аргумента является SelectionSet.
С технической точки зрения возможность выбирать поля подграфа для объединения с помощью SelectionSets — отличное решение. С точки зрения разработчика, я не думаю, что это отличный опыт для разработчиков.
Если вы начнете с решения (GraphQL), а затем вернетесь к проблеме (конфигурации), ваши возможности будут ограничены. Лучше начать с проблемы, а затем оценить различные решения.
Я думаю, очевидно, что это можно сделать лучше, но мы должны учитывать, что существует порог того, насколько далеко вы можете заставить людей перейти на новое решение. Федерация 2 должна быть достаточно похожа на федерацию 1, чтобы упростить миграцию.
Давайте продолжим с еще несколькими примерами:
extend type Product @key(fields: "id") {
id: ID! @external
inStock: Boolean!
}
Это просто. Мы определяем ключ для типа продукта. Опять же, значение — это SelectionSet, хотя это всего лишь одно поле, но все же не автозаполнение или безопасность типов. В идеале среда IDE могла бы сообщить нам, что разрешены входные данные для аргумента fields
, что приводит к основной причине проблемы.
GraphQL не поддерживает обобщения
Если бы мы использовали язык с надлежащей поддержкой обобщений, например TypeScript (сюрприз), директива @key
могла бы наследовать метаинформацию от типа Product
, к которому она прикреплена. . Таким образом, мы могли бы сделать аргумент fields
выше типобезопасным.
Вот еще один пример, показывающий отсутствие дженериков:
type Review @model {
id: ID!
rating: Int! @default(value: 5)
published: Boolean @default(value: false)
status: Status @default(value: PENDING_REVIEW)
}
enum Status {
PENDING_REVIEW
APPROVED
}
Мы видим, что директива @default
используется несколько раз для установки значений по умолчанию для разных полей. Проблема в том, что на самом деле это недопустимый GraphQL. Аргумент value
не может одновременно иметь тип Int
, Boolean
и Status
.
К этой проблеме приводят многочисленные проблемы. Во-первых, расположение директив очень ограничено. Вы можете определить, что директива разрешена для местоположения FIELD_DEFINITION
, но вы не можете указать, что она должна быть разрешена только для полей Int
.
Но даже если бы мы могли это сделать, это все равно было бы неоднозначно, потому что нам пришлось бы определять несколько директив @default
для разных типов. Таким образом, в идеале мы могли бы использовать какой-то полиморфизм для определения одной директивы @default
, которая работает для всех типов. К сожалению, GraphQL не поддерживает этот вариант использования и никогда не сможет.
Вот альтернатива тому, как вы могли бы справиться с этим:
type Review @model {
id: ID!
rating: Int! @defaultInt(value: 5)
published: Boolean @defaultBoolean(value: false)
status: Status @defaultStatus(value: PENDING_REVIEW)
}
Теперь это может быть допустимой схемой GraphQL, но есть еще одна проблема, связанная с этим подходом. Мы не можем заставить пользователя поместить @defaultInt
в поле Int
. В языке схемы GraphQL нет ограничений для принудительного применения этого. TypeScript может легко сделать это.
Хотя некоторые обходные пути приемлемы, не используйте GraphQL способами, которые создают недопустимые схемы. Он ломает все ваши инструменты, IDE, линтеры и т. д.
Не помещайте Ecmascript в многострочные строки GraphQL
Вот еще одна мерзость:
type Query {
scriptExample(message: String!): JSON
@rest(
endpoint: "https://httpbin.org/anything"
method: POST
ecmascript: """
function bodyPOST(s) {
let body = JSON.parse(s);
body.ExtraMessage = get("message");
return JSON.stringify(body);
}
function transformREST(s) {
let out = JSON.parse(s);
out.CustomMessage = get("message");
return JSON.stringify(out);
}
"""
)
}
Я понимаю, почему это сделано, но это все еще плохой опыт разработчика. На дворе 2022 год, а вы вставляете экмаскрипт в строку?
Какую проблему мы здесь решаем? Обычно некоторая промежуточная логика выполняется до или после запроса либо для преобразования запроса, либо для ответа.
В WunderGraph у нас есть mutatingPreResolveHook, а также mutatingPostResolveHook именно для этого варианта использования. Разница в том, что вы можете написать логику на TypeScript, все хуки типобезопасны, вы получаете автодополнение, вы можете импортировать любой пакет npm, который вы хотите, вы можете использовать async-await, и вы даже можете тестировать и отлаживать свои хуки.< /p>
Вышеприведенное решение является взломом. Это лайфхак, который необходим, потому что вы не можете использовать GraphQL для написания промежуточного программного обеспечения.
GraphQL — это не XSLT
Давайте посмотрим на следующий пример:
type Query {
anonymous: [Customer]
@rest (endpoint: "https://api.com/customers",
transforms: [
{pathpattern: ["[]","name"], editor: "drop"}])
known: [Customer]
@rest (endpoint: "https://api.com/customers")
}
Это очень напоминает мне XSLT. Если вы не знакомы с XSLT, это язык для преобразования XML-документов. Проблема с этим примером заключается в том, что мы пытаемся использовать GraphQL в качестве языка преобразования. Если вы хотите что-то отфильтровать, вы можете сделать это очень легко с помощью map
или filter
в TypeScript/Javascript. GraphQL никогда не предназначался для использования в качестве языка для преобразования данных.
Я не уверен, насколько это хороший опыт для разработчиков. Я, вероятно, хотел бы безопасность типов и автозаполнение для аргумента transforms
. Тогда как мне отладить этот «код»? Как написать для этого тесты?
Если бы мы сгенерировали модели TypeScript для типов, мы могли бы не только сделать функцию преобразования типобезопасной, но и легко писать и запускать для нее модульные тесты.
Когда дело доходит до создания отличного интерфейса для разработчиков, очень важно немедленно предоставить разработчикам обратную связь. Если мой единственный вариант — протестировать мой код методом черного ящика, отправив запрос на сервер, я не смогу выполнять итерации так быстро, как хотелось бы.
<цитата>Весь этот подход напоминает повторное изобретение ESB с помощью GraphQL.
Вот пример XSLT для справки:
<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html>
<body>
<h2>My CD Collection</h2>
<table border="1">
<tr bgcolor="#9acd32">
<th>Title</th>
<th>Artist</th>
</tr>
<xsl:for-each select="catalog/cd">
<tr>
<td><xsl:value-of select="title"/></td>
<td><xsl:value-of select="artist"/></td>
</tr>
</xsl:for-each>
</table>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
GraphQL не является ORM
Другой пример показывает, как GraphQL можно использовать как ORM:
type Mutation {
addCustomerById(id: ID!, name: String!, email: String!): Customer
@dbquery(
type: "mysql"
table: "customer"
dml: INSERT
configuration: "mysql_config"
schema: "schema_name"
)
}
Какие здесь проблемы? Это не только занимает много места, но и гораздо менее удобочитаемо, чем простой SQL-запрос или использование правильного ORM. Если мы проигнорируем тот факт, что все поля не являются типобезопасными, в этом примере возникает еще одна проблема — аутентификация и авторизация.
Мы не можем просто сделать этот API общедоступным, мы должны защитить его.
Вот предлагаемое решение:
access:
policies:
- type: Query
rules:
- condition: PREDICATE
name: name of rule
fields: [ fieldname ... ]
ЯМЛ!? Теперь вам придется иметь дело не только с GraphQL, но и с YAML. С каждым из них достаточно сложно справиться, но вместе это определенно не будет хорошо. Чтобы обеспечить безопасность API, необходимо синхронизировать схему GraphQL и этот файл YAML.
Директивы GraphQL нелегко обнаружить, и их использование неочевидно
Позвольте мне показать вам очевидный код TypeScript, который преобразует строку в нижний регистр:
const helloWorld = "Hello World";
const lower = helloWorld.toLowerCase(); // "hello world"
Теперь давайте сделаем то же самое с GraphQL:
type Query { helloWorld: String}
Давайте представим, что наш сервер GraphQL позволяет использовать директиву @lowerCase
. Теперь мы можем использовать его так:
type Query { helloWorld: String @lowerCase}
В чем разница между двумя примерами? Объект helloWorld
в TypeScript распознается как строка, поэтому мы можем вызвать для него toLowerCase
. Совершенно очевидно, что мы можем вызывать этот метод, потому что он связан со строковым типом.
В GraphQL нет «методов», которые мы можем вызывать для поля. Мы можем прикреплять директивы к полям, но это не очевидно. Кроме того, директива @lowerCase
имеет смысл только для строкового поля, но GraphQL не позволяет нам ограничивать использование директивы определенным типом. То, что кажется простым, когда есть только одна директива, может стать довольно сложным, когда у вас есть 10, 20 или даже больше директив.
В заключение этого раздела, реализация директивы обычно содержит ряд правил, которые не могут быть выражены в схеме GraphQL. Например. Спецификация GraphQL не позволяет нам ограничивать директиву @lowerCase
строковыми полями. Это означает, что линтеры не будут работать, автодополнение не будет работать должным образом, и проверка также не сможет обнаружить эти ошибки. Вместо этого обнаружение неправильного использования директивы будет отложено до времени выполнения. С TypeScript мы обнаруживаем эти ошибки во время компиляции. С помощью tsc --watch --noEmit
мы можем отловить эти ошибки даже во время написания кода.
Итак, как правильно использовать GraphQL?
Pulumi доказала, что TypeScript — отличный язык для настройки, и мы черпали в нем много вдохновения.
AWS CDK использует аналогичный подход, когда вы можете использовать TypeScript (наряду с другими языками) для определения своей инфраструктуры.
Я думаю, будет лучше, если мы будем использовать GraphQL для того, в чем он хорош, в качестве языка запросов API.
Существует два основных лагеря, когда речь заходит об использовании GraphQL: подход, основанный на схеме, и подход, основанный на коде. Я думаю, что схема-сначала отлично подходит, когда вы хотите определить чистую схему GraphQL, тогда как код-сначала может быть намного более мощным, когда вы хотите моделировать сложные варианты использования.
В последнем случае схема GraphQL будет артефактом того, что вы определили в коде. Это позволяет вам использовать «настоящий» язык программирования, такой как TypeScript или C#, для определения вашего API, например. с мощным DSL, в то время как полученный контракт по-прежнему является схемой GraphQL.
Я не вижу большого будущего для API-интерфейсов GraphQL, предназначенных только для схемы, но рад оказаться неправым.
Заключение
Я думаю, что мы увидим еще несколько людей, которые поддержат идею «все делать с помощью GraphQL», и в конце концов эти компании смирятся с тем фактом, что GraphQL — не панацея.
GraphQL — отличный инструмент для набора инструментов API, но он определенно не является ни языком конфигурации, ни языком трансформации, ни заменой SQL, ни уж точно не Terraform. Здоровья!
В WunderGraph наше понимание GraphQL немного отличается от того, что вы могли видеть до сих пор. Мы используем GraphQL в качестве серверного языка для управления и запроса зависимостей API, сохраняя уровень GraphQL скрытым за JSON-RPC/REST API.
Также опубликовано здесь.
Оригинал