GraphQL для C#. БД прилагается

от автора

Введение

(Статья являет собой желание немного обновить информацию на Хабре по данной теме, а так же сыскать несколько подсказок от более опытных коллег)

Приветствую, Хабр! Относительно недавно я решил влиться в С# и его технологию для создания веб-приложений ASP.NET. До этого писал в основном на С++ и Python с Django. Ну а так как я по жизни практик, то и чтоб чему-то научиться, надо что-то сделать, пусть и корявенькое (хотя пару книжек, конечно, прочитал). Выбор пал на стандартное приложение магазина книг, а точнее его бэк составляющую, ибо с дизайном и любыми, даже базовыми, проявлениями фронтовой части я не дружу от слова совсем)

Вначале сделал приложение с базовыми контролерами REST API по учебнику и т.д. Но после захотелось попробовать уже другой вариант, и я решил использовать GraphQL…

Выбор библиотеки

В начале был… выбор. Как оказалось, для начала предстояло определиться с библиотекой, которая позволит мне работать с GraphQL. Это либо HotChocolate, либо собственно GraphQL. И вроде выбор понятен, бери GraphQL и вперед. Но почему-то я решил погуглить и наткнулся на мнение, что библиотека GraphQL морально устарела, заброшена и вообще для дедов, то ли дело HotChocolate — всегда в тренде, новые обновления и вообще новый, модный и красивый. Уж не знаю, что там в итоге с GraphQL-библиотекой, но то, что HotChocolate обновляется достаточно часто, — это факт, но точно не плюс(

По итогу у меня были установлены следующие пакеты:

HotChocolate.AspNetCore Version="14.1.0"  HotChocolate.AspNetCore.Voyager Version="10.5.5"  HotChocolate.Data Version="14.1.0"  HotChocolate.Data.EntityFramework Version="14.1.0"  Microsoft.EntityFrameworkCore" Version="9.0.0-rc.2.24474.1"  Microsoft.EntityFrameworkCore.Tools" Version="9.0.0-rc.2.24474.1" Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0-rc.2"

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

Первичная настройка

Начнем с настройки проекта(Естественно он должен быть уже создан)). В моем случаи код конфигурации программы и код старта программы разделены на 2 файла, хотя вроде как это старый подход и сейчас все делают в одном.
Файл Program.cs:

namespace BooksStore {  public class Program {  public static void Main ( string[] args ) { CreateHostBuilder ( args ).Build ().Run (); }  public static IHostBuilder CreateHostBuilder ( string[] args ) => Host.CreateDefaultBuilder ( args ) .ConfigureWebHostDefaults ( webBuilder => { webBuilder.UseStartup<Startup> (); } ); } }

Файл Startup.cs

namespace BooksStore {  public class Startup {  public Startup ( IConfiguration configuration ) { Configuration = configuration; }  public IConfiguration Configuration { get; }  public void ConfigureServices ( IServiceCollection services ) { services.AddDbContextFactory<AppDbContext> ( options => options.UseNpgsql ( Configuration.GetConnectionString ( "DefaultConnection" ) ) ); services.AddEndpointsApiExplorer (); services.AddGraphQLServer () .RegisterDbContextFactory<AppDbContext> ()//Регистрация БД для графа ..AddQueryType<Query> () // Регистрация Query запросов .AddMutationType<Mutation> () .AddProjections ()                 .AddFiltering () .AddSorting (); }  public void Configure ( IApplicationBuilder app , IWebHostEnvironment env ) { if ( env.IsDevelopment () ) { app.UseSwagger (); app.UseSwaggerUI (); } app.UseHttpsRedirection (); app.UseStaticFiles (); app.UseRouting (); app.UseEndpoints ( endpoints => { endpoints.MapGraphQL ( "/api" ); } ); } } }

В первом файле нет ничего интересного, так что перейдем ко второму. В функции public void ConfigureServices ( IServiceCollection services ) мы добавляем сервисы которые будут использоваться во всем проекте. Первое это собственно БД, которую мы будем использовать. И вот тут важно добавить именно AddDbContextFactory (либо AddPooledDbContextFactory )потому что это позволит делать множественные запросы к БД с помощью GraphQL

После этого уже регистрируем сервер самого графа и для него же регистрируем БД. Дальнейшие строки мы рассмотрим позже и будем возвращаться к этому файлу.

Модели

Прежде чем перейти к работе с данными, нужно создать классы моделей этих данных. Тут достаточно всё просто, я использую те же модели, что и для работы с Entity Framework. Я думаю, тут не возникнет проблем, но приведу пример, как такой класс может выглядеть.

public class Book : BaseModel     {         [Column(TypeName = "varchar(200)")]         public string Name { get; set; }          public float Price { get; set; }          [DataType(DataType.Date)]         [DisplayFormat(DataFormatString = "{0:dd:MM:yyyy}", ApplyFormatInEditMode = true)]         public DateOnly DatePublication { get; set; }          public int AuthorId { get; set; }          public Author Author { get; set; }          public ICollection<Genre> Genre { get; set; }          [GraphQLIgnore]         public ICollection<Order> Orders { get; set; }     }

Отдельно хочется обратить внимание, что у GraphQL есть атрибуты для моделей данных, их немного, но самая полезная из них — это GraphQLIgnore. Данный атрибут говорит графу игнорировать данное поле. Это важно, так как по умолчанию все поля являются обязательными при выполнении запроса на изменение данных. И тут два выхода: либо помечать сам тип поля как Nullable, но тогда это скажется и на схеме БД, и в целом на коде. Либо просто пометить это поле данным атрибутом, но учтите, что это поле будет недоступно как для изменения с помощью графа, так и для получения с помощью него. Так что именно в модели им стоит помечать только поля-связи.

Запросы и Мутации

Первое, что бросается в глаза после перехода с REST, это то, что в GraphQL есть только 2 «команды», так сказать. Это Query и Mutation. Query берет на себя функции GET запроса, а Mutation — POST, PUT, PATCH и DELETE.

Также нужно отдельно сказать, что разработчики HotChocolate позволяют использовать один из трех подходов написания кода — это: «Implementation-first», «Code-first» и «Schema-first». В своих примерах я использую первый.

Query

Создадим отдельный файл с классом Query(имя может быть произвольным).

 public class Query     {         /// <summary>         /// Get Books         /// </summary>         [UseProjection]         [UseFiltering]         [UseSorting]         public IQueryable<Book> GetBooks([Service] AppDbContext context) => context.Books;          /// <summary>         /// Get Authors         /// </summary>         [UseFiltering]         [UseSorting]         public IQueryable<Author> GetAuthor([Service] AppDbContext context) => context.Author;

Чтобы взять данные из нашей БД, нам нужно прописать для этого метод. Все методы для взятия данных пишутся в одном классе, т. к. зарегистрировать для GraphQL мы можем только один класс. (Можно, конечно, разбить всё на основной класс и ExtendingTypes, но тут на ваш выбор.) Собственно, чтобы метод понимал, откуда ему брать данные, в параметрах нужно передать ему наш контекст БД и указать атрибутом, что это сервис и искать он должен это в сервисах. Возвращает такой метод нашу модель данных.

Теперь поговорим об атрибутах самих методов. UseProjecting позволяет делать Lazy Loading. UseFiltering позволяет использовать where в запросах, а UseSorting, собственно, сортировать данные на выходе. Дальше нам их так же нужно зарегистрировать для графа в файле Startup.cs. И вот тут нужно обратить внимание на последовательность их регистрации. У HotChocolate существует довольно строгая иерархия, и потому регистрировать их нужно именно в таком порядке. В случае если не соблюсти этот порядок, то сервер графа может не запуститься в принципе, хотя программа скомпилируется (я помню, потратил на это часа 4, пока не нашел в документации). Иерархия, кст, следующая: UsePaging > UseProjection > UseFiltering > UseSorting.

Так же незабываем зарегистрировать наш класс Query с помощью метода .AddQueryType<Query>() .

Mutation

Теперь займемся изменениями. Так же создадим отдельный класс Mutation (Имя так же может быть произвольным).

public partial class Mutation {  public async Task<Book> AddBookAsync ( BookIn input , [Service] AppDbContext context , ICollection<int> genres ) { var book = new Book { Name = input.Name , DatePublication = input.DatePublication , Price = input.Price , Author = context.Author.Find ( input.AuthorId ) , Genre = context.Genre.Where ( g => genres.Contains ( g.Id ) ).ToList () , }; if ( book.Author is null ) throw new ArgumentException ( "Wrong argument AuthorId" ); if ( book.Genre.Count == 0 ) throw new ArgumentException ( "Wrong argument Genre" );  context.Books.Add ( book ); await context.SaveChangesAsync (); return book; }  public async Task<Book> UpdateBookAsync ( [Service] AppDbContext context , UpdateBooks input ) { var book = context.Books.Find ( input.Id ); if ( book == null ) throw new ArgumentException ( "Wrong argument id book" );  book.Price = input.Price == default ? book.Price : input.Price; book.Name = input.Name == default ? book.Name : input.Name; book.DatePublication = input.DatePublication == default ? book.DatePublication : (DateOnly) input.DatePublication; if ( book.Author != default ) { var author = context.Author.Find ( input.AuthorId ); if ( author == null ) throw new ArgumentException ( "Wrong argument id author" ); book.Author = author; }  context.Books.Update ( book ); await context.SaveChangesAsync (); return book; }  public async Task<bool> DeleteBookAsync ( [Service] AppDbContext context , int id ) { var book = context.Books.Find ( id ); if ( book != null ) { context.Books.Remove ( book ); await context.SaveChangesAsync (); return true; } return false; }

Такой класс также может быть зарегистрирован только один, поэтому все методы хранятся в нем (и также можно применять ExtendingTypes). Но я не люблю хранить все в одном файле, поэтому сделал данный класс partial и разделил по файлам.

Собственно, как уже сказал, каждый метод — это EndPoint, а его параметры — это поля, которые должны быть переданы при его вызове. Кроме, конечно, контекста БД, который мы также помечаем атрибутом Service.

И вот тут мы и приходим к тому, что, а не все поля из нашей модели мы бы хотели передавать при вызове.

Для этого мы должны создать отдельные классы. Я покажу на примере все той же модели Book.

    /// <summary> /// класс для получение данных в MutationBook с игнорирование ненужных данных /// </summary> public class BookIn : Book {  [GraphQLIgnore] public new Author Author { get; set; }  [GraphQLIgnore] public new ICollection<Genre> Genre { get; set; } }

Данный класс мы используем при добавлении книги. Мы наследуемся от нашего класса-модели и переопределяем ненужные нам поля уже с атрибутом игнорирования.

Так же поступаем и при update.

В методе delete, как видите, можно уже не использовать модель, хватить и обычного id.

Остается лишь зарегистрировать наш класс в файле Startup.cs с помощью .AddMutationType<Mutation> ()

Запуск

Все что остается — это прописать в методу Configure в все том же файле Startup.cs путь к нашему серверу GraphQL с помощью метода:

app.UseEndpoints ( endpoints => { endpoints.MapGraphQL ( "/api" ); //Вместо /api может идти любой путь на ваш выбор } );

После чего останется только запустить проект и перейти по нужному пути.

То что вы увидите будет:

Вам нужно будет нажать Create Document и перед вами откроется нужная страница:

Здесь уже можно писать свои запросы и с помощью кнопки Run их выполнять.

Тестирование

Отдельно хочется немного уделить юнит тестам наших EndPoints. Для этого нужно создать проект тестирование XUnit.

В нем у меня находиться следующий класс:

public class BookStoreTests {     private IServiceCollection services;      /// <summary>     /// Регистрируем сервис БД и создаем начальные данные в ней     /// </summary>     /// <param name="output"></param>     public BookStoreTests()     {         services = new ServiceCollection();         services.AddDbContextFactory<AppDbContext>(option => option.UseInMemoryDatabase("TestDataBase"));          var provider = services.BuildServiceProvider();         var context = provider.GetRequiredService<AppDbContext>();         context.Database.EnsureDeleted();         context.Database.EnsureCreated();          context.AddRange(             new Author { Id = 1, Name = "Ilya", Birthday = new DateOnly(2000, 01, 23) },             new Book { Id = 1, Name = "Bulya", AuthorId = 1, DatePublication = new DateOnly(2023, 09, 14), Price = 123 },             new Genre { Id = 1, Name = "Scrim" });          context.SaveChanges();     }      /// <summary>     /// Имитируем работу Graphql server и посылаем query запрос     /// </summary>     /// <returns></returns>     [Fact]     public async Task QueryTest()     {         var result = await services.AddGraphQLServer()              .RegisterDbContextFactory<AppDbContext>()              .AddQueryType<Query>()              .AddProjections()              .AddSorting()              .AddFiltering()              .ExecuteRequestAsync("{books{name, datePublication, }}");         Assert.True(result.ToJson().Contains("Bulya"));     }      /// <summary>     /// Создаем экземпляры books и записываем в БД     /// </summary>     /// <returns></returns>     [Fact]     public async Task MutationTest()     {         var mutation = new Mutation();         var provider = services.BuildServiceProvider();         var context = provider.GetRequiredService<AppDbContext>();         var book = new BookIn()         {             AuthorId = 1,             Id = 2,             DatePublication = new DateOnly(2000, 05, 01),             Price = 432,             Name = "TestMutation"         };         var genre = new List<int>() { 1 };         var result = await mutation.AddBookAsync(book, context, genre);         Assert.Equal("TestMutation", context.Books.Find(2).Name);     } }

Давайте по порядку. Первое, о чем мы хотим подумать, это что, наверное, мы не хотим тестироваться на наших данных, которые в БД. Есть разные подходы к этому вопросу, о которых можно почитать в документации Entity Framework. Конечно, я выбрал самый не рекомендуемый)) То есть данные для тестов мы будем хранить в памяти, и потому нам нужно будет использовать тип БД InMemory. В конструкторе класса нам нужно создать экземпляр класса сервисов, ибо, как мы помним, наши методы GraphQL работают с БД через сервисы. После чего регистрируем в сервисах нашу БД, только уже с опциями БД в памяти. И заполняем ее данными и сохраняем изменения. Дальше идут методы для тестирования запроса и мутации. И тут есть отличия. Запросы мы тестируем именно как вызов конечной точки. Для этого мы также регистрируем сервис сервера GraphQL и регистрируем для него БД и класс запросов. После чего с помощью метода .ExecuteRequestAsync("{books{name, datePublication}}") мы делаем сам запрос. Для того чтобы проверить, что запрос выдал то, что нам нужно, конвертируем ответ в JSON и ищем там нужное имя.

С мутациями всё сложнее. Я не смог найти, каким образом отправить запрос мутации на сервер GraphQL, т. к. предыдущий метод выдавал ошибку, что он поддерживает только Query, что довольно странно, ведь в описании написано другое. Поэтому мутации я тестирую как просто вызов метода класса с передачей ему нужных параметров. После чего просто ищу созданную запись в БД.

Итог

По итогу получилось самое простенькое и базовое приложение для работы с книгами, которое поддерживает базовые запросы CRUD. А также 2 простеньких метода для тестирования.

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

Скорее всего, большее количество кода можно оптимизировать и написать более правильно, но я сделал так, как смог найти.

Основной проблемой, конечно, является заставить все библиотеки работать как одну. Ибо Entity Framework не знает о HotChocolate, как, собственно, и сам ASP.NET. А HC вроде должен о них знать, но не особо хочет, т. к. в большинстве статей они обходят тему EF, уделив лишь пару абзацев, и то больше как настроить, чем как работать. Про тесты могу сказать то же самое. Нашел лишь один видос от разработчика, но сделать полноценно, как он там показал, не смог, просто из-за того, что нужных методов уже нет (ну либо я что-то не установил, хотя сверял несколько раз все зависимости).


ссылка на оригинал статьи https://habr.com/ru/articles/870082/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *