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