Напиши мне GraphQL сервер на C#

в 7:33, , рубрики: .net, C#, graphql-server graphql-dotnet, Блог компании ДоксВижн, Программирование, Разработка веб-сайтов

Как-то выдалась у меня пара выходных, и я набросал 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();

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

  1. Описать его тип — создать объект ObjectGraphType, StringGraphType, BooleanGraphType, IdGraphType, IntGraphType, DateGraphType или FloatGraphType.
  2. Описать само поле (имя, обработчик) — создать объект 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 без написания серверных расширений — пишите. Чем выше будет интерес от партнеров — тем больше внимания мы этому уделим.

Автор: PFight77

Источник

* - обязательные к заполнению поля