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

в 14:16, , рубрики: ASP.NET, entityframework, graphql, hotchocolate, xunit
GraphQL для C#. БД прилагается - 1

Введение

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

Приветствую! Относительно недавно я решил влиться в С# и его технологию для создания веб-приложений 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 может идти любой путь на ваш выбор
			} );

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

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

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

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

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

Здесь уже можно писать свои запросы и с помощью кнопки 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

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js