Как-то выдалась у меня пара выходных, и я набросал GraphQL сервер к нашей Docsvision платформе. Ниже расскажу, как все прошло.
Что за платформа Docsvision
Платформа Docsvision включает в себя множество различных средств для построения систем документооборота, но ключевым ее компонентом является что-то вроде ORM. Есть редактор метаданных, в которых можно описать структуру полей карточек. Там могут быть структурные, коллекционные и древовидные секции, которые, к тому же, могут быть вложенными, в общем, все сложно. По метаданным генерируется БД, и потом можно работать с ней через некоторый C# API. Словом — идеальный вариант для построения GraphQL сервера.
Какие есть варианты
Честно сказать, вариантов не много и они так себе. Мне удалось найти только две библиотеки:
UPD: в комментариях подсказали что есть еще Hotchocolate.
По README мне поначалу приглянулась вторая, и я даже начал с ее помощью что-то делать. Но вскоре выяснил, что у нее слишком бедный API, и с задачей генерации схемы по метаданным она не справится. Впрочем, ее, кажется, уже забросили (последний коммит год назад).
У graphql-dotnet
API достаточно гибкий, но и в то же время он ужасно документирован, запутан и неинтуитивен. Чтобы понять, как с ним работать, мне приходилось смотреть исходники… Правда, я работал с версией 0.16
, тогда как сейчас последняя 0.17.3
, и уже выпущено 7 beta-версий 2.0
. Так что прошу прощения, если материал немного устарел.
Должен еще упомянуть, библиотеки поставляются с неподписанными сборками. Мне пришлось пересобирать их из исходников вручную, чтобы использовать в нашем ASP.NET приложении с подписанными сборками.
Структура GraphQL сервера
Если Вы не знакомы с GraphQL, можете попробовать github explorer. Небольшой секрет — можно жмать Ctrl+пробел, чтобы получить автодополнение. Клиентская часть там есть ничто иное как GraphiQL, которую без труда можно прикрутить к своему серверу. Просто берете index.html, добавляете скрипты из npm-пакета, и меняете url в функции graphQLFetcher на адрес своего сервера — все, можно играться.
Рассмотрим простой запрос:
query {
viewer {
login,
company
}
}
Мы здесь видим набор полей — viewer, в нем login, company. Наша задача, как GraphQL бэкенда, построить на сервере некоторую "схему", в которой все эти поля будут обрабатываться. По сути, нам просто нужно создать соответствующую структуру служебных объектов с описанием полей, и задать callback-функции для вычисления значений.
Схему можно сгенерировать автоматически на основе C# классов, но мы пойдем по хардкору — будем все делать руками. Но это не потому что я лихой парень, просто генерация схемы на основе метаданных — это нестандартный сценарий в graphql-dotnet, который не поддерживается официальной документацией. Так что, мы копнем немного в ее нутро, в недокументированную область.
Создав схему, нам останется любым удобным нам образом доставить строку запроса (и параметры) с клиента на сервер (совершенно неважно, как — GET, POST, SignalR, TCP...), и скормить его движку вместе со схемой. Движок выплюнет нам объект с результатом, который превратим в JSON и вернем клиенту. У меня это выглядело так:
// Мой сервис, в котором генерируется схема на основе метаданных
var schema = GraphQlService.GetCardsSchema(sessionContext);
// Создаем экземпляр движка (объект можно переиспользовать)
var executer = new DocumentExecuter();
// Скармливаем ему схему, запрос
var dict = await executer.ExecuteAsync(schema, sessionContext, request.Query, request.MethodName).ConfigureAwait(false);
// По-простецки обработаем ошибки :)
if (dict.Errors != null && dict.Errors.Count > 0)
{
throw new InvalidOperationException(dict.Errors.First().Message);
}
// Возвращаем клиенту результат
return Json(dict.Data);
Обратить внимание можно на sessionContext
. Это наш специфичный для Docsvision объект, через который осуществляется доступ к платформе. При создании схемы мы все время работаем с тем или иным контекстом, но об этом чуть позже.
Генерация схемы
Начинается все умилительно просто:
Schema schema = new Schema();
К сожалению, на этом простой код заканчивается. Для того чтобы добавить в схему какое-либо поле, нам нужно:
- Описать его тип — создать объект ObjectGraphType, StringGraphType, BooleanGraphType, IdGraphType, IntGraphType, DateGraphType или FloatGraphType.
- Описать само поле (имя, обработчик) — создать объект GraphQL.Types.FieldType
Давайте попробуем описать тот простой запрос, что я приводил выше. В запросе у нас есть одно поле — viewer. Чтобы добавить его в запрос, нужно сначала описать его тип. Тип у него простой — объект, с двумя строковыми полями — login и company. Опишем поле login:
var loginField = new GraphQL.Types.FieldType();
loginField.Name = "login";
loginField.ResolvedType = new StringGraphType();
loginField.Type = typeof(string);
loginField.Resolver = new MyViewerLoginResolver();
// ...
class MyViewerLoginResolver : GraphQL.Resolvers.IFieldResolver
{
public object Resolve(ResolveFieldContext context)
{
// Предполагаем, что у нас в контексте будет какой-то наш объект UserInfo
// который нам передаст родительский обработчик viewer
return (context.Source as UserInfo).AccountName;
}
}
Аналогично создаем объект companyField — отлично, мы готовы описать тип поля viewer.
ObjectGraphType<UserInfo> viewerType = new ObjectGraphType<UserInfo>();
viewerType.Name = "Viewer";
viewerType.AddField(loginField);
viewerType.AddField(companyField);
Тип есть, теперь можно описать и само поле viewer:
var viewerField = new GraphQL.Types.FieldType();
viewerField.Name = "viewer";
viewerField.ResolvedType = viewerType;
viewerField.Type = typeof(UserInfo);
viewerField.Resolver = new MyViewerResolver();
// ...
class MyViewerResolver : GraphQL.Resolvers.IFieldResolver
{
public object Resolve(ResolveFieldContext context)
{
// Помните мы передавали свой sessionContext при выполнении запроса?
// То, что мы вернем здесь будет передано дочерним резолверам (login и company)
return (context.Source as SessionContext).UserInfo;
}
}
Ну и последний штрих, добавляем наше поле в тип query:
var queryType = new ObjectGraphType();
queryType.AddField(viewerField);
schema.Query = queryType;
Вот и все, наша схема готова.
Коллекции, пейджинация, обработка параметров
Если поле возвращает не один объект, а коллекцию, то нужно явно это указать. Для этого достаточно обернуть тип свойства в экземпляр класса ListGraphType. Допустим, если бы viewer возвращал коллекцию, мы просто написали бы так:
// Было (один объект)
viewerField.ResolvedType = viewerType;
// Стало (коллекция)
viewerField.ResolvedType = new ListGraphType(viewerType);
Соответственно, в резолвере MyViewerResolver тогда нужно было бы возвращать список.
При появлении коллекционных полей важно сразу позаботиться о пейджинации. Какого-то готового механизма тут нет, делается все через параметры. Пример использования параметра Вы могли заметить в примере выше (у cardDocument есть параметр id). Давайте добавим такой параметр к viewer:
var idArgument = new QueryArgument(typeof(IdGraphType));
idArgument.Name = "id";
idArgument.ResolvedType = new IdGraphType();
idArgument.DefaultValue = Guid.Empty;
viewerField.Arguments = new QueryArguments(idArgument);
Получить потом значение параметра в резолвере можно так:
public object Resolve(ResolveFieldContext context)
{
var idArgStr = context.Arguments?["id"].ToString() ?? Guid.Empty.ToString();
var idArg = Guid.Parse(idArgStr);
GraphQL такой типизированный, что распарсить сам Guid, конечно, не смог. Ну да ладно, нам не трудно.
Запрос карточек Docsvision
В реализации GrapqhQL для платформы Docsvision я соответственно просто кодом прохожу по метаданным (sessionContext.Session.CardManager.CardTypes
), и для всех карточек и их секций автоматически создаю такие вот объекты с соответствующими резолверами. В итоге получилось что-то такое:
query {
cardDocument(id: "{AF652E55-7BCF-E711-8308-54A05079B7BF}") {
mainInfo {
name
instanceID
}
}
}
Здесь cardDocument — это тип карточки, mainInfo — имя секции в ней, name и instanceID — поля в секции. Соответствующие резолверы для карточки, секции и поля используют API CardManager следующим образом:
class CardDataResolver : GraphQL.Resolvers.IFieldResolver
{
public object Resolve(ResolveFieldContext context)
{
var sessionContext = (context.Source as SessionContext);
var idArg = Guid.Parse(context.Arguments?["id"].ToString() ?? Guid.Empty.ToString());
return sessionContext.Session.CardManager.GetCardData(idArg);
}
}
class SectionResolver : GraphQL.Resolvers.IFieldResolver
{
CardSection section;
public SectionFieldResolver(CardSection section)
{
this.section = section;
}
public object Resolve(ResolveFieldContext context)
{
var idArg = Guid.Parse(context.Arguments?["id"].ToString() ?? Guid.Empty.ToString());
var skipArg = (int?)context.Arguments?["skip"] ?? 0;
var takeArg = (int?)context.Arguments?["take"] ?? 15;
var sectionData = (context.Source as CardData).Sections[section.Id];
return idArg == Guid.Empty ?
sectionData.GetAllRows().Skip(skipArg).Take(takeArg)
:
new List<RowData> { sectionData.GetRow(idArg) };
}
}
class RowFieldResolver : GraphQL.Resolvers.IFieldResolver
{
Field field;
public RowFieldResolver(Field field)
{
this.field = field;
}
public object Resolve(ResolveFieldContext context)
{
return (context.Source as RowData)[field.Alias];
}
}
Конечно, здесь можно только запрашивать карточки по id, но нетрудно таким же образом сгенерировать схему для доступа к расширенным отчетам, сервисам и всему, что угодно. С таким API можно получать любые данные из базы Docsvision, просто написав соответствующий JavaScript — очень удобно для написания своих скриптов и расширений.
Заключение
С GrapqhQL в .NET пока все непросто. Есть одна сколько-то живая библиотека, без надежного вендора и с непонятным будущим, неустоявшимся и странным API, неизвестностью в том, как она себя поведет под нагрузкой и насколько она стабильная. Но имеем что имеем, вроде работает, а недостатки в документации и остальном компенсируются открытостью исходного кода.
То, что я описывал в этой статье — это все большей частью недокументированный API, который я исследовал методом тыка и изучением исходников. Просто авторы библиотеки не задумывались, что кому-то понадобится генерировать схему автоматически — ну да что поделаешь, это опен-сорс.
Написано это все было за несколько выходных, и само собой, пока не более чем прототип. В стандартной поставке Docsvision это, скорее всего, появится, но когда — пока трудно сказать. Впрочем, если Вам нравится идея обращаться к базе Docsvision прямо из JavaScrpit без написания серверных расширений — пишите. Чем выше будет интерес от партнеров — тем больше внимания мы этому уделим.
Комментарии (9)
SergeyRodyushkin
23.07.2018 14:12Простите, а зачем так жёстко-то, с ручной сборкой resolvers? Вроде бы, примеров на graphql-dotnet.github.io/getting-started хватает, чтобы делать классы-наследники ObjectGraphType и определять поля в их конструкторе.
Я не то, чтобы гуру GraphQL, но тоже копаюсь с ним сейчас, и мне интересно.PFight77 Автор
23.07.2018 18:46Когда делаешь классы наследники ObjectGraphType подразумевается, что вся схема известна на этапе компиляции. Здесь я генерирую схему на основе динамических метаданных Docsvision.
SergeyRodyushkin
23.07.2018 18:57Тогда вы правы. Возможно, тогда Conventions помогли бы что-то упростить. Документация там тоже неважная, конечно.
kemsky
23.07.2018 16:38Хочется в ближайшем будущем попробовать GraphQL, а именно идею его использования как api-gateway, чтобы на клиенте любые данные извлекать одним запросом. Интересно как сделать маппинг с наименьшими усилиями.
PFight77 Автор
23.07.2018 18:48+1Главное, чтобы в Вашем API были нормальные сигнатуры функций. То есть, возвращаемое значение — модель, а не ActionResult какой-нибудь. Тогда можно рефлекшеном пробежаться и сгенерировать схему как в статье.
Dolfik
Мне понравилась библиотека HotChocolate, достаточно удобное API, но скудная документация, зато разрабатывается достаточно активно.
PFight77 Автор
Ух ты, спасибо, как-то не нашел. Там правда в январе этого года первый коммит, молодая библиотека.
gnaeus
А как у HotChocolate с DataLoader и проблемой N + 1? Я вот о чем:
В ресолвере поля
posts
мы достаем список постов. А в ресолвере поляauthor
мы полезем в базу N раз.В референсной реализации GraphQL используется подход DataLoader. Он основан на том, что если у нас есть операция получения списка объектов :
То мы можем группировать вызовы, загружающие единичный объект
Item
:Task
-и на получение единичного элемента накапливаются в очередиTask
из очередиПри всей уродливости
graphql-dotnet
, DataLoader там есть.Dolfik
Пока настолько глубоко не погружался в эту тему, но похоже поддержка DataLoader там тоже есть, как раз недавно была добавлена.