Введение
(Статья являет собой желание немного обновить информацию на Хабре по данной теме, а так же сыскать несколько подсказок от более опытных коллег)
Приветствую, Хабр! Относительно недавно я решил влиться в С# и его технологию для создания веб-приложений 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/
Добавить комментарий