Как-то выдалась у меня пара выходных, и я набросал 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 без написания серверных расширений — пишите. Чем выше будет интерес от партнеров — тем больше внимания мы этому уделим.

Комментарии (9)


  1. Dolfik
    23.07.2018 11:34

    Мне понравилась библиотека HotChocolate, достаточно удобное API, но скудная документация, зато разрабатывается достаточно активно.


    1. PFight77 Автор
      23.07.2018 11:36

      Ух ты, спасибо, как-то не нашел. Там правда в январе этого года первый коммит, молодая библиотека.


    1. gnaeus
      23.07.2018 14:54

      А как у HotChocolate с DataLoader и проблемой N + 1? Я вот о чем:


      query {
        posts {
          author {
            name
          }
        }
      }

      В ресолвере поля posts мы достаем список постов. А в ресолвере поля author мы полезем в базу N раз.


      В референсной реализации GraphQL используется подход DataLoader. Он основан на том, что если у нас есть операция получения списка объектов :


      Task<Item[]> GetItems(int id[]);

      То мы можем группировать вызовы, загружающие единичный объект Item:


      • сначала Task-и на получение единичного элемента накапливаются в очереди
      • затем происходит обращение к БД для загрузки списка элементов
      • далее, разультаты запроса распределяются по ожидающим Task из очереди
      • и наконец, выполнение переходит к более глубокому уровню GraphQL-ресолверов.

      При всей уродливости graphql-dotnet, DataLoader там есть.


      1. Dolfik
        23.07.2018 16:46

        Пока настолько глубоко не погружался в эту тему, но похоже поддержка DataLoader там тоже есть, как раз недавно была добавлена.


  1. SergeyRodyushkin
    23.07.2018 14:12

    Простите, а зачем так жёстко-то, с ручной сборкой resolvers? Вроде бы, примеров на graphql-dotnet.github.io/getting-started хватает, чтобы делать классы-наследники ObjectGraphType и определять поля в их конструкторе.
    Я не то, чтобы гуру GraphQL, но тоже копаюсь с ним сейчас, и мне интересно.


    1. PFight77 Автор
      23.07.2018 18:46

      Когда делаешь классы наследники ObjectGraphType подразумевается, что вся схема известна на этапе компиляции. Здесь я генерирую схему на основе динамических метаданных Docsvision.


      1. SergeyRodyushkin
        23.07.2018 18:57

        Тогда вы правы. Возможно, тогда Conventions помогли бы что-то упростить. Документация там тоже неважная, конечно.


  1. kemsky
    23.07.2018 16:38

    Хочется в ближайшем будущем попробовать GraphQL, а именно идею его использования как api-gateway, чтобы на клиенте любые данные извлекать одним запросом. Интересно как сделать маппинг с наименьшими усилиями.


    1. PFight77 Автор
      23.07.2018 18:48
      +1

      Главное, чтобы в Вашем API были нормальные сигнатуры функций. То есть, возвращаемое значение — модель, а не ActionResult какой-нибудь. Тогда можно рефлекшеном пробежаться и сгенерировать схему как в статье.