Русский
Русский
English
Статистика
Реклама

Dotnet

Перевод Что же такого особенного в IAsyncEnumerable в .NET Core 3.0?

10.08.2020 18:20:12 | Автор: admin
Перевод статьи подготовлен в преддверии старта курса Разработчик C#.





Одной из наиболее важных функций .NET Core 3.0 и C# 8.0 стал новый IAsyncEnumerable<T> (он же асинхронный поток). Но что в нем такого особенного? Что же мы можем сделать теперь, что было невозможно раньше?

В этой статье мы рассмотрим, какие задачи IAsyncEnumerable<T> предназначен решать, как реализовать его в наших собственных приложениях и почему IAsyncEnumerable<T> заменит Task<IEnumerable<T>> во многих ситуациях.

Ознакомьтесь со всеми новыми функциями .NET Core 3

Жизнь до IAsyncEnumerable<T>


Возможно, лучший способ объяснить, почему IAsyncEnumerable<T> так полезен это рассмотреть проблемы, с которыми мы сталкивались до него.

Представьте, что мы создаем библиотеку для взаимодействия с данным, и нам нужен метод, который запрашивает некоторые данные из хранилища или API. Обычно этот метод возвращает Task<IEnumerable<T>>, как здесь:

public async Task<IEnumerable<Product>> GetAllProducts()

Чтобы реализовать этот метод, мы обычно запрашиваем данные асинхронно и возвращаем их, когда он завершается. Проблема с этим становится более очевидной, когда для получения данных нам нужно сделать несколько асинхронных вызовов. Например, наша база данных или API могут возвращать данные целыми страницами, как, например, эта реализация, использующая Azure Cosmos DB:

public async Task<IEnumerable<Product>> GetAllProducts(){    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");    var products = new List<Product>();    while (iterator.HasMoreResults)    {        foreach (var product in await iterator.ReadNextAsync())        {            products.Add(product);        }    }    return products;}

Обратите внимание, что мы пролистываем все результаты в цикле while, создаем экземпляры объектов product, помещаем их в List, и, наконец, возвращаем все целиком. Это довольно неэффективно, особенно на больших наборах данных.

Возможно, мы сможем создать более эффективную реализацию, изменив наш метод так, чтобы он возвращал результаты по целой странице за раз:

public IEnumerable<Task<IEnumerable<Product>>> GetAllProducts(){    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");    while (iterator.HasMoreResults)    {        yield return iterator.ReadNextAsync().ContinueWith(t =>         {            return (IEnumerable<Product>)t.Result;        });    }}

Вызывающий объект будет использовать метод следующим образом:

foreach (var productsTask in productsRepository.GetAllProducts()){    foreach (var product in await productsTask)    {        Console.WriteLine(product.Name);    }}

Эта реализация более эффективна, но метод теперь возвращает IEnumerable<Task<IEnumerable<Product>>>. Как мы видим из вызывающего кода, вызов метода и обработка данных не интуитивны. Что еще более важно, подкачка страниц это деталь реализации метода доступа к данным, о которой вызывающая сторона не должна ничего знать.

IAsyncEnumerable<T> спешит на помощь


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

В синхронном коде метод, который возвращает IEnumerable, может использовать оператор yield return для возврата каждой части данных вызывающей стороне, когда она приходит из базы данных.

public IEnumerable<Product> GetAllProducts(){    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");    while (iterator.HasMoreResults)    {        foreach (var product in iterator.ReadNextAsync().Result)        {            yield return product;        }    }}

Однако, НИКОГДА ТАК НЕ ДЕЛАЙТЕ! Приведенный выше код превращает асинхронный вызов базы данных в блокирующий и не масштабируется.

Если только мы могли бы использовать yield return с асинхронными методами! Это было невозможно до сих пор.

IAsyncEnumerable<T> был представлен в .NET Core 3 (.NET Standard 2.1). Он предоставляет энумератор, у которого есть метод MoveNextAsync(), который может быть ожидаемым. Это означает, что инициатор может совершать асинхронные вызовы во время (посреди) получения результатов.

Вместо возврата Task<IEnumerable<T>> наш метод теперь может возвращать IAsyncEnumerable<T> и использовать yield return для передачи данных.

public async IAsyncEnumerable<Product> GetAllProducts(){    Container container = cosmosClient.GetContainer(DatabaseId, ContainerId);    var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");    while (iterator.HasMoreResults)    {        foreach (var product in await iterator.ReadNextAsync())        {            yield return product;        }    }}

Чтобы использовать результаты, нам нужно использовать новый синтаксис await foreach(), доступный в C# 8:

await foreach (var product in productsRepository.GetAllProducts()){    Console.WriteLine(product);}

Это намного приятнее. Метод производит данные по мере их поступления. Код вызова использует данные в своем темпе.

IAsyncEnumerable<T> и ASP.NET Core


Начиная с .NET Core 3 Preview 7, ASP.NET может возвращать IAsyncEnumerable из экшена контроллера API. Это означает, что мы можем возвращать результаты нашего метода напрямую эффективно передавая данные из базы данных в HTTP ответ.

[HttpGet]public IAsyncEnumerable<Product> Get()    => productsRepository.GetAllProducts();

Замена Task<IEnumerable<T>> на IAsyncEnumerable<T>


С течением времени по ходу освоения .NET Core 3 и .NET Standard 2.1, ожидается, что IAsyncEnumerable<T> будет использоваться в местах, где мы обычно использовали Task<IEnumerable>.

Я с нетерпением жду возможности увидеть поддержку IAsyncEnumerable<T> в библиотеках. В этой статье мы видели подобный код для запроса данных с помощью SDK Azure Cosmos DB 3.0:

var iterator = container.GetItemQueryIterator<Product>("SELECT * FROM c");while (iterator.HasMoreResults){    foreach (var product in await iterator.ReadNextAsync())    {        Console.WriteLine(product.Name);    }}

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

Чтобы посмотреть, как это могло бы выглядеть, если бы GetItemQueryIterator<Product>() вместо этого возвращал IAsyncEnumerable<T>, мы можем создать метод-расширение в FeedIterator:

public static class FeedIteratorExtensions{    public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this FeedIterator<T> iterator)    {        while (iterator.HasMoreResults)        {            foreach(var item in await iterator.ReadNextAsync())            {                yield return item;            }        }    }}

Теперь мы можем обрабатывать результаты наших запросов намного более приятным способом:

var products = container    .GetItemQueryIterator<Product>("SELECT * FROM c")    .ToAsyncEnumerable();await foreach (var product in products){    Console.WriteLine(product.Name);}

Резюме


IAsyncEnumerable<T> является долгожданным дополнением к .NET и во многих случаях сделает код более приятным и эффективным. Узнать об этом больше вы можете на этих ресурсах:




Шаблон проектирования Состояние (state)



Читать ещё:


Подробнее..

Генерация типизированных ссылок на элементы управления AvaloniaUI с атрибутом xName с помощью C Source Generators API

29.11.2020 22:13:53 | Автор: admin


В апреле 2020-го года разработчиками платформы .NET 5 был анонсирован новый способ генерации исходного кода на языке программирования C# с помощью реализации интерфейса ISourceGenerator. Данный способ позволяет разработчикам анализировать пользовательский код и создавать новые исходные файлы на этапе компиляции. При этом, API новых генераторов исходного кода схож с API анализаторов Roslyn. Генерировать код можно как с помощью Roslyn Compiler API, так и методом конкатенации обычных строк.


Постановка задачи


С помощью новых генераторов исходного кода может получиться элегантно решить широкий спектр задач, включая генерацию шаблонного кода, который было бы не очень интересно и совсем не продуктивно писать вручную. Например, в приложениях, использующих AvaloniaUI фреймворк для разработки кроссплатформенных приложений с графическим интерфейсом, о котором недавно вышла статья на Хабре нередко приходится писать следующий код для получения ссылки на элемент управления, объявленный в XAML:


TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");

Элемент типа TextBox с именем PasswordTextBox при этом объявлен в XAML следующим образом:


<TextBox x:Name="PasswordTextBox"         Watermark="Please, enter your password..."         UseFloatingWatermark="True"         PasswordChar="*" />

Получать ссылку на элемент управления в XAML может понадобиться в случае необходимости применения анимаций, программного изменения стилей и свойств элемента управления, или использования кроссплатформенных типизированных привязок данных ReactiveUI, таких, как Bind, BindCommand, BindValidation, позволяющих связывать компоненты View и ViewModel без использования синтаксиса {Binding} в XAML-разметке.


public class SignUpView : ReactiveWindow<SignUpViewModel>{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        // Привязки данных ReactiveUI и ReactiveUI.Validation.        // Можно было бы схожим образом использовать расширение разметки Binding,        // но некоторые разработчики предпочитают описывать биндинги в C#.        // Почему бы не облегчить им (и многим другим) жизнь?        //        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);    }    // Шаблонный код для типизированного доступа к именованным    // элементам управления, объявленным в XAML.    TextBox UserNameTextBox => this.FindControl<TextBox>("UserNameTextBox");    TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");    TextBlock CompoundValidation => this.FindControl<TextBlock>("CompoundValidation");}

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


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


Пример входных и выходных данных


Мы ожидаем, что на вход наш генератор исходного кода будет получать два файла. Для компонента представления с именем SignUpView, данными файлами будут являться XAML-разметка SignUpView.xaml, и code-behind файл SignUpView.xaml.cs, содержащий логику пользовательского интерфейса. Например, для файла разметки пользовательского интерфейса SignUpView.xaml:


<Window xmlns="http://personeltest.ru/aways/github.com/avaloniaui"        xmlns:x="http://personeltest.ru/away/schemas.microsoft.com/winfx/2006/xaml"        x:Class="Avalonia.NameGenerator.Sandbox.Views.SignUpView">    <StackPanel>        <TextBox x:Name="UserNameTextBox"                 Watermark="Please, enter user name..."                 UseFloatingWatermark="True" />        <TextBlock Name="UserNameValidation"                   Foreground="Red"                   FontSize="12" />    </StackPanel></Window>

Содержимое файла SignUpView.xaml.cs будет выглядеть следующим образом:


public partial class SignUpView : Window{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        // Мы хотим иметь доступ к типизированным элементам управления вот здесь,        // чтобы, например, писать код наподобие вот такого:        // UserNameTextBox.Text = "Violet Evergarden";        // UserNameValidation.Text = "An optional validation error message";    }}

А сгенерированное содержимое SignUpView.xaml.cs должно будет выглядеть следующим образом:


partial class SignUpView{    internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindControl<global::Avalonia.Controls.TextBox>("UserNameTextBox");    internal global::Avalonia.Controls.TextBlock UserNameValidation => this.FindControl<global::Avalonia.Controls.TextBlock>("UserNameValidation");}

Префиксы global:: здесь нужны для избежания коллизий пространств имён. Дополнительно, необходимо полностью указывать имена типов также для избежания коллизий. По аналогии с WPF, мы маркируем генерируемые свойства как internal. В случае использования partial-классов базовый класс можно указывать только в одной из частей partial-класса, поэтому в сгенерированном коде мы опускаем указание базового класса таким образом пользователи нашего генератора смогут наследоваться от какого угодно наследника Window, будь то ReactiveWindow<TViewModel>, или другой тип окна.


Следует заметить, что при вызове метода FindControl обход дерева элементов производиться не будет Avalonia хранит именованные ссылки на элементы управления в словарях, называемых INameScope в терминологии Avalonia. При желании, Вы можете изучить исходный код методов FindControl и FindNameScope на GitHub.


Реализуем интерфейс ISourceGenerator


Простейший генератор исходного кода, который ничего не делает, выглядит следующим образом:


[Generator]public class EmptyGenerator : ISourceGenerator{    public void Initialize(GeneratorInitializationContext context) { }    public void Execute(GeneratorExecutionContext context) { }}

В методе Initialize предлагается проинициализировать новый генератор исходного кода, а в методе Execute выполнить все важные вычисления, и при необходимости добавить сгенерированные файлы исходного кода в контекст выполнения с помощью вызова метода context.AddSource(fileName, sourceText). При этом, файл проекта генератора исходного кода выглядит следующим образом:


<Project Sdk="Microsoft.NET.Sdk">    <PropertyGroup>        <TargetFramework>netstandard2.0</TargetFramework>        <LangVersion>preview</LangVersion>        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>        <IncludeBuildOutput>false</IncludeBuildOutput>    </PropertyGroup>    <ItemGroup>        <PackageReference            Include="Microsoft.CodeAnalysis.CSharp"            Version="3.8.0-5.final"            PrivateAssets="all" />        <PackageReference            Include="Microsoft.CodeAnalysis.Analyzers"            Version="3.3.1"            PrivateAssets="all" />    </ItemGroup>    <ItemGroup>        <None Include="$(OutputPath)\$(AssemblyName).dll"              Pack="true"              PackagePath="analyzers/dotnet/cs"              Visible="false" />    </ItemGroup></Project>

Давайте, для начала, добавим в сборку проекта, ссылающегося на генератор, некоторый атрибут, с помощью которого пользователи нашего генератора будут помечать классы, для которых необходимо генерировать типизированные ссылки на элементы управления Avalonia, объявленные в XAML. Изменим код нашего генератора следующим образом:


[Generator]public class NameReferenceGenerator : ISourceGenerator{    private const string AttributeName = "GenerateTypedNameReferencesAttribute";    private const string AttributeFile = "GenerateTypedNameReferencesAttribute";    private const string AttributeCode = @"// <auto-generated />using System;[AttributeUsage(AttributeTargets.Class, Inherited=false, AllowMultiple=false)]internal sealed class GenerateTypedNameReferencesAttribute : Attribute { }";    public void Initialize(GeneratorInitializationContext context) { }    public void Execute(GeneratorExecutionContext context)    {        // Добавим код атрибута в файл 'GenerateTypedNameReferencesAttribute.cs'         // проекта разработчика, который решит воспользоваться нашим генератором.        context.AddSource(AttributeFile,            SourceText.From(                AttributeCode, Encoding.UTF8));    }}

Пока ничего сложного мы объявили исходный код атрибута, имя файла, и имя атрибута как константы, с помощью вызова SourceText.From(code) обернули строку в исходный текст, и затем добавили новый исходный файл в проект с помощью вызова context.AddSource(fileName, sourceText). Теперь в проекте, который ссылается на наш генератор, мы можем помечать интересующие нас классы с помощью атрибута [GenerateTypedNameReferences]. Для классов, помеченных данным атрибутом, мы будем генерировать типизированные ссылки на именованные элементы управления, объявленные в XAML. В случае рассматриваемого примера с SignUpView.xaml, code-behind данного файла разметки должен будет выглядеть вот так:


[GenerateTypedNameReferences]public partial class SignUpView : Window{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        // Мы пока только собираемся генерировать именованные ссылки.        // Если раскомментировать код ниже, проект не скомпилируется (пока).        // UserNameTextBox.Text = "Violet Evergarden";        // UserNameValidation.Text = "An optional validation error message";    }}

Нам необходимо научить наш ISourceGenerator следующим вещам:


  1. Находить все классы, помеченные атрибутом [GenerateTypedNameReferences];
  2. Находить соответствующие классам XAML-файлы;
  3. Извлекать полные имена типов элементов интерфейса, объявленных в XAML-файлах;
  4. Вытаскивать из XAML-файлов имена (значения Name или x:Name) элементов управления;
  5. Генерировать partial-класс и заполнять его типизированными ссылками.

Находим классы, маркированные атрибутом


Для реализации такой функциональности API генераторов исходного кода предлагает реализовать и зарегистрировать интерфейс ISyntaxReceiver, который позволит собрать все ссылки на интересующий синтаксис в одном месте. Реализуем ISyntaxReceiver, который будет собирать все ссылки на объявления классов сборки пользователя нашего генератора:


internal class NameReferenceSyntaxReceiver : ISyntaxReceiver{    public List<ClassDeclarationSyntax> CandidateClasses { get; } =        new List<ClassDeclarationSyntax>();    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)    {        if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&            classDeclarationSyntax.AttributeLists.Count > 0)            CandidateClasses.Add(classDeclarationSyntax);    }}

Зарегистрируем данный класс в методе ISourceGenerator.Initialize(GeneratorInitializationContext context):


context.RegisterForSyntaxNotifications(() => new NameReferenceSyntaxReceiver());

Теперь, во время выполнения нашего генератора, мы можем получить доступ ко всем ClassDeclarationSyntax в сборке, и в цикле найти все классы, маркированные необходимым образом:


// Добавим в CSharpCompilation исходник нашего атрибута.var options = (CSharpParseOptions)existingCompilation.SyntaxTrees[0].Options;var compilation = existingCompilation.AddSyntaxTrees(CSharpSyntaxTree    .ParseText(SourceText.From(AttributeCode, Encoding.UTF8), options));var attributeSymbol = compilation.GetTypeByMetadataName(AttributeName);var symbols = new List<INamedTypeSymbol>();foreach (var candidateClass in nameReferenceSyntaxReceiver.CandidateClasses){    // Извлечём INamedTypeSymbol из нашего класса-кандидата.    var model = compilation.GetSemanticModel(candidateClass.SyntaxTree);    var typeSymbol = (INamedTypeSymbol) model.GetDeclaredSymbol(candidateClass);    // Проверим, маркирован ли класс с помощью нашего атрибута.    var relevantAttribute = typeSymbol!        .GetAttributes()        .FirstOrDefault(attr => attr.AttributeClass!.Equals(            attributeSymbol, SymbolEqualityComparer.Default));    if (relevantAttribute == null) {        continue;    }    // Проверим, маркирован ли класс как 'partial'.    var isPartial = candidateClass        .Modifiers        .Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword));    // Таким образом, список 'symbols' будет содержать только те    // классы, которые маркированы с помощью ключевого слова 'partial'    // и атрибута 'GenerateTypedNameReferences'.    if (isPartial) {        symbols.Add(typeSymbol);    }}

Находим подходящие XAML-файлы


В Avalonia действуют соглашения именования XAML-файлов и code-behind файлов для них. Для файла с разметкой с именем SignUpView.xaml файл code-behind будет называться SignUpView.xaml.cs, а класс внутри него, как правило, называется SignUpView. В нашей реализации генератора типизированных ссылок будем полагаться на данную схему именования. Файлы разметки Avalonia на момент реализации генератора и написания данного материала могли иметь расширения .xaml или .axaml, поэтому код, определяющий имя XAML-файла на основании имени типа будет иметь следующий вид:


var xamlFileName = $"{typeSymbol.Name}.xaml";var aXamlFileName = $"{typeSymbol.Name}.axaml";var relevantXamlFile = context    .AdditionalFiles    .FirstOrDefault(text =>         text.Path.EndsWith(xamlFileName) ||         text.Path.EndsWith(aXamlFileName));

Здесь, typeSymbol имеет тип INamedTypeSymbol и может быть получен в результате обхода списка symbols, который мы сформировали на предыдущем этапе. А ещё здесь есть один нюанс. Чтобы файлы разметки были доступны как AdditionalFiles, пользователю генератора необходимо их дополнительно включить в проект с использованием директивы MSBuild <AdditionalFiles />. Таким образом, пользователь генератора должен отредактировать файл проекта .csproj, и добавить туда вот такой <ItemGroup />:


<ItemGroup>    <!-- Очень важная директива, без которой генераторы исходного         кода не смогут выпотрошить файлы разметки! -->    <AdditionalFiles Include="**\*.xaml" /></ItemGroup>

Подробное описание <AdditionalFiles /> можно найти в материале New C# Source Generator Samples.


Извлекаем полные имена типов из XAML


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


Хорошая новость заключается в том, что фреймворк AvaloniaUI использует новый компилятор XamlX, целиком написанный @kekekeks. Этот компилятор мало того, что не имеет рантайм-зависимостей, умеет находить ошибки в XAML на этапе компиляции, работает намного быстрее загрузчиков XAML из WPF, UWP, XF и других технологий, так ещё и предоставляет нам удобный API для парсинга XAML и разрешения типов. Таким образом, мы можем позволить себе подключить XamlX в проект исходниками (git submodule add ://repo ./path), и написать свой собственный MiniCompiler, который наш генератор исходного кода будет вызывать для компиляции XAML и получения полной информации о типах, даже если они лежат в каких-нибудь сторонних сборках. Реализация XamlX.XamlCompiler в виде нашего маленького MiniCompiler, который мы собираемся натравливать на XAML-файлы, имеет вид:


internal sealed class MiniCompiler : XamlCompiler<object, IXamlEmitResult>{    public static MiniCompiler CreateDefault(        RoslynTypeSystem typeSystem,        params string[] additionalTypes)    {        var mappings = new XamlLanguageTypeMappings(typeSystem);        foreach (var additionalType in additionalTypes)            mappings.XmlnsAttributes.Add(typeSystem.GetType(additionalType));        var configuration = new TransformerConfiguration(            typeSystem,            typeSystem.Assemblies[0],            mappings);        return new MiniCompiler(configuration);    }    private MiniCompiler(TransformerConfiguration configuration)        : base(configuration,               new XamlLanguageEmitMappings<object, IXamlEmitResult>(),               false)    {        Transformers.Add(new NameDirectiveTransformer());        Transformers.Add(new DataTemplateTransformer());        Transformers.Add(new KnownDirectivesTransformer());        Transformers.Add(new XamlIntrinsicsTransformer());        Transformers.Add(new XArgumentsTransformer());        Transformers.Add(new TypeReferenceResolver());    }    protected override XamlEmitContext<object, IXamlEmitResult> InitCodeGen(        IFileSource file,        Func<string, IXamlType, IXamlTypeBuilder<object>> createSubType,        object codeGen, XamlRuntimeContext<object, IXamlEmitResult> context,        bool needContextLocal) =>        throw new NotSupportedException();}

В нашем MiniCompiler мы используем дефолтные трансформеры XamlX и один особенный NameDirectiveTransformer, тоже написанный @kekekeks, который умеет преобразовывать XAML-атрибут x:Name в XAML-атрибут Name для того, чтобы впоследствии обходить полученное AST и вытаскивать имена элементов управления было проще. Такой NameDirectiveTransformer выглядит следующим образом:


internal class NameDirectiveTransformer : IXamlAstTransformer{    public IXamlAstNode Transform(        AstTransformationContext context,        IXamlAstNode node)    {        // Нас интересуют только объекты.        if (node is XamlAstObjectNode objectNode)        {            for (var index = 0; index < objectNode.Children.Count; index++)            {                // Если мы встретили x:Name, заменяем его на Name и                 // продолжаем обходить потомков XamlAstObjectNode дальше.                var child = objectNode.Children[index];                if (child is XamlAstXmlDirective directive &&                    directive.Namespace == XamlNamespaces.Xaml2006 &&                    directive.Name == "Name")                    objectNode.Children[index] =                        new XamlAstXamlPropertyValueNode(                            directive,                            new XamlAstNamePropertyReference(                                directive, objectNode.Type, "Name", objectNode.Type),                            directive.Values);            }        }        return node;    }}

Фабрика MiniCompiler.CreateDefault принимает первым аргументом любопытный тип RoslynTypeSystem, который вы не найдёте в исходниках XamlX. Данный тип реализует интерфейс IXamlTypeSystem, а это значит, что всё самое сложное только начинается. Чтобы наш маленький компилятор заработал внутри нашего генератора исходного кода, нам необходимо реализовать систему типов XamlX поверх API семантической модели компилятора Roslyn. Для реализации новой IXamlTypeSystem пришлось реализовывать много-много интерфейсов (IXamlType для классов, IXamlAssembly для сборок, IXamlMethod для методов, IXamlProperty для свойств и др). Реализация IXamlAssembly, например, выглядит вот так:


public class RoslynAssembly : IXamlAssembly{    private readonly IAssemblySymbol _symbol;    public RoslynAssembly(IAssemblySymbol symbol) => _symbol = symbol;    public bool Equals(IXamlAssembly other) =>        other is RoslynAssembly roslynAssembly &&        SymbolEqualityComparer.Default.Equals(_symbol, roslynAssembly._symbol);    public string Name => _symbol.Name;    public IReadOnlyList<IXamlCustomAttribute> CustomAttributes =>        _symbol.GetAttributes()            .Select(data => new RoslynAttribute(data, this))            .ToList();    public IXamlType FindType(string fullName)    {        var type = _symbol.GetTypeByMetadataName(fullName);        return type is null ? null : new RoslynType(type, this);    }}

После реализации всех необходимых интерфейсов мы наконец сможем распарсить XAML инструментами XamlX, создать инстанс нашей реализации RoslynTypeSystem, передав ей в конструктор CSharpCompilation, которую мы уже извлекли из контекста генерации на предыдущем этапе, и трансформировать полученное в результате парсинга AST в AST с включённой информацией о пространствах имён и типах:


var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());MiniCompiler.CreateDefault(    // 'compilation' имеет тип 'CSharpCompilation'    new RoslynTypeSystem(compilation),    "Avalonia.Metadata.XmlnsDefinitionAttribute")    .Transform(parsed);

Готово! Осталось извлечь все именованные объекты из дерева и дело в шляпе.


Находим именованные объекты XAML


На предыдущем этапе мы уже рассмотрели трансформер AST XamlX, реализующий IXamlAstTransformer, а теперь давайте рассмотрим и напишем посетителя узлов этого AST, реализующий интерфейс IXamlAstVisitor. Наш посетитель будет выглядеть следующим образом:


internal sealed class NameReceiver : IXamlAstVisitor{    private readonly List<(string TypeName, string Name)> _items =        new List<(string TypeName, string Name)>();    public IReadOnlyList<(string TypeName, string Name)> Controls => _items;    public IXamlAstNode Visit(IXamlAstNode node)    {        if (node is XamlAstObjectNode objectNode)        {            // Извлекаем тип AST-узла. Данный тип нам вывел XamlX в            // процессе взаимодействия с нашей RoslynTypeSystem.            //            var clrType = objectNode.Type.GetClrType();            foreach (var child in objectNode.Children)            {                // Если мы в результате обхода потомков встретили свойство,                // которое называется 'Name', и при этом внутри 'Name' лежит строка,                // то добавляем в список элементов '_items' имя и CLR-тип элемента AST.                //                if (child is XamlAstXamlPropertyValueNode propertyValueNode &&                    propertyValueNode.Property is XamlAstNamePropertyReference named &&                    named.Name == "Name" &&                    propertyValueNode.Values.Count > 0 &&                    propertyValueNode.Values[0] is XamlAstTextNode text)                {                    var nsType = $@"{clrType.Namespace}.{clrType.Name}";                    var typeNamePair = (nsType, text.Text);                    if (!_items.Contains(typeNamePair))                        _items.Add(typeNamePair);                }            }            return node;        }        return node;    }    public void Push(IXamlAstNode node) { }    public void Pop() { }}

Процесс парсинга XAML и извлечения типов и имён XAML-элементов теперь выглядит так:


var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());MiniCompiler.CreateDefault(    // 'compilation' имеет тип 'CSharpCompilation'    new RoslynTypeSystem(compilation),    "Avalonia.Metadata.XmlnsDefinitionAttribute")    .Transform(parsed);var visitor = new NameReceiver();parsed.Root.Visit(visitor);parsed.Root.VisitChildren(visitor);// Теперь у нас есть и типы, и имена элементов.var controls = visitor.Controls;

Генерируем типизированные ссылки


Наконец, можно перейти к заключительному этапу разработки нашего генератора исходного кода. У нас есть всё, что было нужно для полного счастья и типы, и пространства имён, и имена элементов. А это значит, что нам необходимо сгенерировать partial-класс, сложив туда ссылки на все найденные именованные элементы пользовательского интерфейса, объявленные в XAML. Метод, генерирующий такой partial-класс, будет иметь вид:


private static string GenerateSourceCode(    List<(string TypeName, string Name)> controls,    INamedTypeSymbol classSymbol,    AdditionalText xamlFile){    var className = classSymbol.Name;    var nameSpace = classSymbol.ContainingNamespace        .ToDisplayString(SymbolDisplayFormat);    var namedControls = controls        .Select(info => "        " +                       $"internal global::{info.TypeName} {info.Name} => " +                       $"this.FindControl<global::{info.TypeName}>(\"{info.Name}\");");    return $@"// <auto-generated />using Avalonia.Controls;namespace {nameSpace}{{    partial class {className}    {{{string.Join("\n", namedControls)}       }}}}";}

Добавим полученный код в контекст выполнения GeneratorExecutionContext:


var sourceCode = GenerateSourceCode(controls, symbol, relevantXamlFile);context.AddSource($"{symbol.Name}.g.cs", SourceText.From(sourceCode, Encoding.UTF8));

Готово!


Результат


Инструментарий Visual Studio понимает, что при изменении XAML-файла, включённого в проект как <AdditionalFile />, необходимо вызвать генератор исходного кода ещё раз, и обновить сгенерированные исходники. Таким образом, при редактировании XAML-файлов, ссылки на новые элементы управления, добавляемые в XAML в процессе разработки, будут автоматически становиться доступными из C#-файла с расширением .xaml.cs.


ezgif-1-f52e7303c26f


Исходный код генератора доступен на GitHub.


Интеграция генераторов исходного кода с JetBrains Rider и ReSharper доступна в последних EAP, что позволяет утверждать, что реализованная технология является кроссплатформенной, и будет работать на Windows, Linux, и macOS. В дальнейшем мы собираемся заинтегрировать получившийся генератор в Avalonia, чтобы в новых версиях фреймворка генерация типизированных ссылок стала доступна из коробки.


А вот так выглядит обновлённый пример кода из самого начала статьи, с биндингами и ReactiveUI.Validation:


[GenerateTypedNameReferences]public class SignUpView : ReactiveWindow<SignUpViewModel>{    public SignUpView()    {        AvaloniaXamlLoader.Load(this);        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);    }}

Ссылки


Подробнее..

Как найти ошибки в C проекте, работая под Linux и macOS

17.06.2020 18:07:34 | Автор: admin
Picture 8

PVS-Studio известный статический анализатор кода, позволяющий найти множество каверзных ошибок, скрытых в исходниках. Недавно завершился бета-тест новой версии, в которой появилась возможность анализа C# проектов под Linux и macOS. Кроме того, теперь анализатор можно интегрировать с кроссплатформенной IDE от JetBrains Rider. Данная статья позволит познакомиться с этими возможностями на примере проверки open source проекта RavenDB.


Введение


Некоторое время назад мой коллега Сергей Васильев написал заметку об открытии бета-теста новой версии разрабатываемого нами статического анализатора PVS-Studio. Сейчас бета-тест завершён и новую версию можно загрузить, перейдя по ссылке. В этой же статье мы рассмотрим, как проводится анализ C# проектов под Linux/macOS с использованием консольного интерфейса и Rider. После этого проведём традиционный разбор некоторых интересных срабатываний анализатора.

RavenDB


Picture 5


Для проверки был выбран открытый проект RavenDB, репозиторий которого насчитывает почти 5 тысяч файлов исходного кода. Он представляет собой достаточно популярную NoSQL-базу данных. Подробности можно узнать на сайте. Нетрудно догадаться, что меня привлёк его размер, ведь в столь большом и, вне всяких сомнений, серьёзном проекте наверняка найдётся что-нибудь интересное.

Command Line Interface


Для начала рассмотрим, как производится анализ через консоль. Этот раздел, на мой взгляд, будет особенно интересен тем, кто желает интегрировать анализатор в CI-систему. У команды, запускающей анализ, есть ряд интересных опций, однако в общем случае всё достаточно тривиально. Чтобы провести анализ RavenDB, я перехожу в папку с проектом и ввожу в консоли следующее:
pvs-studio-dotnet -t ./RavenDB.sln 

Флаг -t (сокращение от target) служит для указания файла решения или проекта для проверки. Представленная выше строка запускает анализ и в результате работы формирует файл, содержащий найденные ошибки. Всё просто, не правда ли?

Rider


Работа с анализатором в Rider представляет собой примерно то же самое, что и в Visual Studio. Простой и понятный интерфейс плагина позволит проверить проект буквально в пару кликов. Это не преувеличение для проведения анализа RavenDB мне было достаточно кликнуть в верхнем меню Tools, навести на PVS-Studio и кликнуть Check Current Solution/Project.

Picture 2


Результаты анализа будут отображены в нижней части окна на вкладке PVS-Studio (ну а на какой же ещё? :) )

Picture 3


Как и в случае с плагином для Visual Studio, двойной клик по предупреждению отобразит место, с которым оно связано. Всё удобно и понятно.

Что ещё более важно, инструмент PVS-Studio не просто указывает на ошибки, а имеет инфраструктуру, позволяющую легко внедрить методологию статического анализа даже в большой старый проект.

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

Можно указать PVS-Studio считать эти предупреждения пока неактуальными (отложить технический долг на потом), и больше их не показывать. Анализатор создаёт специальный файл, где сохраняет информацию о пока неинтересных ошибках. И теперь PVS-Studio будет выдавать предупреждения только на новый или измененный код. Причем, всё это реализовано умно. Если, например, в начало некоего файла будет добавлена пустая строка, то анализатор понимает, что, по сути, ничего не изменилось, и по-прежнему будет молчать. Этот файл разметки можно заложить в систему контроля версий. Файл большой, но это не страшно, так как часто его закладывать смысла нет.

Теперь все программисты будут видеть предупреждения, относящиеся только к новому или изменённому коду. Таким образом, анализатор можно начать использовать, что называется, со следующего дня. А к техническому долгу можно будет вернуться позднее, и постепенно исправлять ошибки и настраивать анализатор.

Для подавления предупреждений на существующий код в Rider необходимо просто перейти в верхнем меню в Tools->PVS-Studio и кликнуть Suppress All Messages.

Picture 1


В появившемся окне, предупреждающем о том, что в suppress-лист попадут все текущие срабатывания, кликаем на Ok. В результате в папке с проектом будет создан suppress-файл, который будет учитываться анализатором при дальнейшей работе.

Надо отметить, что в Rider уже есть анализатор, который успешно подсвечивает некоторые ошибки. Таким образом, ряд предупреждений PVS-Studio указывает на код, который выглядит подозрительно и с точки зрения редактора. Тем не менее, PVS-Studio довольно часто находит ошибки, которые смогли уйти от чуткого взора анализатора от JetBrains. Именно поэтому наиболее эффективным решением будет позволить им работать в команде.

Picture 10


А на десерт


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

В результате проверки было выведено около тысячи предупреждений:

Picture 11


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

Конечно, не все срабатывания указывают на супер-страшные ошибки. Если бы это было так, вряд ли бы в проекте хоть что-то работало :). Но важно понимать раз анализатор ругается, значит код выглядит странно, и его стоит внимательно изучить.

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

Всего лишь дополнительная проверка?


public static void EnsurePathExists(string file){  var dirpath = Path.GetDirectoryName(file);  List<string> dirsToCreate = new List<string>();  while (Directory.Exists(dirpath) == false)  {    dirsToCreate.Add(dirpath);    dirpath = Directory.GetParent(dirpath).ToString();    if (dirpath == null)                                  // <=      break;  }  dirsToCreate.ForEach(x => Directory.CreateDirectory(x));}

Предупреждение анализатора: V3022 Expression 'dirpath == null' is always false. PosixHelper.cs(124) Voron

Данное срабатывание можно рассмотреть по-разному. С одной стороны, лишняя проверка это, конечно, не очень хорошо, но сама по себе она ошибкой не является. С другой стоит задуматься: действительно ли этот код работает так, как задумывал программист?

Возможно, разработчик и правда не знал, что ToString никогда не вернёт null. Если же это не так, то можно сделать предположение о том, чего он хотел добиться.

Быть может, break должен вызываться, когда не удаётся получить родителя для рассматриваемой директории. Если это так, то проверка на null обретает смысл. Однако рассматривать нужно не результат работы ToString, а значение, возвращаемое методом GetParent:
dirsToCreate.Add(dirpath);var dir = Directory.GetParent(dirpath);    if (dir == null)  break;dirpath = dir.ToString();

В противном случае, возвращение null методом GetParent приводит к исключению при вызове ToString.

Типичный null


public long ScanOldest(){  ....  for (int i = 0; i < copy.Length; i++)  {    var item = copy[i].Value;    if (item != null || item == InvalidLowLevelTransaction) // <=    {      if (val > item.Id)                                    // <=        val = item.Id;    }  }  ....}

Предупреждение анализатора: V3125 The 'item' object was used after it was verified against null. Check lines: 249, 247. ActiveTransactions.cs(249), ActiveTransactions.cs(247) Voron

Код выглядит странно из-за происходящего в том случае, когда item действительно null. Действительно, если InvalidLowLevelTransaction тоже окажется null, условие окажется истинным и попытка получения item.Id приведёт к выбрасыванию исключения. А если InvalidLowLevelTransaction не может оказаться null, то условие "item == InvalidLowLevelTransaction" попросту лишнее. Всё потому, что оно проверяется лишь в случае, когда item == null. Ну а если вдруг item не может быть null, то вообще всё условие теряет смысл и лишь добавляет ненужную вложенность.

Я думаю, что здесь, возможно, выбран неверный логический оператор. Если заменить в условии "||" на "&&", то код сразу начинает выглядеть логично. Ну и никаких проблем в таком случае возникнуть не может.

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

Снова лишняя проверка?


public void WriteObjectEnd(){  ....  if (_continuationState.Count > 1)  {    var outerState =       _continuationState.Count > 0 ? _continuationState.Pop() : currentState    ;    if (outerState.FirstWrite == -1)      outerState.FirstWrite = start;    _continuationState.Push(outerState);  }     ....}

Предупреждение анализатора: V3022 Expression '_continuationState.Count > 0' is always true. ManualBlittableJsonDocumentBuilder.cs(152) Sparrow

Сначала во внешнем условии проверяется, что количество элементов в коллекции больше 1, а затем на следующей же строке тернарный оператор проверяет, что их число больше 0. Создаётся впечатление, что одна из проверок должна выглядеть иначе. Так или иначе, этот код весьма подозрительно выглядит и должен быть внимательно изучен и переработан при необходимости.

Возможный NRE


protected override Expression VisitIndex(IndexExpression node){  if (node.Object != null)  {    Visit(node.Object);  }  else  {    Out(node.Indexer.DeclaringType.Name); // <=  }  if (node.Indexer != null)               // <=  {    Out(".");    Out(node.Indexer.Name);  }  VisitExpressions('[', node.Arguments, ']');  return node;}

Предупреждение анализатора: V3095 The 'node.Indexer' object was used before it was verified against null. Check lines: 1180, 1182. ExpressionStringBuilder.cs(1180), ExpressionStringBuilder.cs(1182) Raven.Client

На самом деле, это ещё одно место, которое считает подозрительным и PVS-Studio, и Rider. Правда, формулировки несколько отличаются: анализатор от JetBrains просто подсвечивает node.Indexer.DeclaringType с комментарием Possible NullReferenceException.

Оба проверяющих утверждают, что в этом фрагменте действительно может быть выброшено исключение. Должен заметить, что предупреждение от PVS-Studio не только говорит, что тут возможна ошибка, но и объясняет, почему у него сложилось такое мнение. Вроде мелочь, а приятно.

На самом деле, не факт, что тут и правда допущена ошибка. Вполне возможно, что если node.Object == null, то node.Indexer точно задан. При этом допустима ситуация, когда node.Object и node.Indexer оба не равны null. Только в таком случае можно считать данное срабатывание обоих анализаторов ложным. Поэтому стоит внимательно проанализировать все возможные варианты.

А если копнуть глубже?


private async Task LoadStartingWithInternal(....){  ....  var command = operation.CreateRequest();  if (command != null)                       // <=  {    await RequestExecutor      .ExecuteAsync(command, Context, SessionInfo, token)      .ConfigureAwait(false)    ;    if (stream != null)      Context.Write(stream, command.Result.Results.Parent);    else      operation.SetResult(command.Result);  }  ....}

Предупреждение анализатора: V3022 Expression 'command != null' is always true. AsyncDocumentSession.Load.cs(175) Raven.Client

Предупреждение выдаётся из-за того, что метод CreateRequest никогда не возвращает null. В самом деле, достаточно взглянуть на его код, чтобы убедиться в этом:
public GetDocumentsCommand CreateRequest(){  _session.IncrementRequestCount();  if (Logger.IsInfoEnabled)    Logger.Info(....);  return new GetDocumentsCommand(....);}

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

У вас может возникнуть вполне разумный вопрос: а где тут бросается исключение-то? Если их нет, то тогда перед нами всё-таки просто лишняя проверка, и никакой ошибки тут быть не может.

Но увы, при выполнении метода исключение действительно может быть выброшено, причём дважды. Сначала в методе IncrementRequestCount:
public void IncrementRequestCount(){  if (++NumberOfRequests > MaxNumberOfRequestsPerSession)    throw new InvalidOperationException(....);}

Затем в конструкторе GetDocumentsCommand:
public GetDocumentsCommand(string startWith, ....){  _startWith = startWith ?? throw new ArgumentNullException(nameof(startWith));  ....}

Традиционный copy-paste


public override void WriteTo(StringBuilder writer){  ....  if (SqlConnectionStringsUpdated)    json[nameof(SqlConnectionStringsUpdated)] = SqlConnectionStringsUpdated;  if (ClientConfigurationUpdated)    json[nameof(ClientConfigurationUpdated)] = ClientConfigurationUpdated;  if (ConflictSolverConfigUpdated)    json[nameof(ConflictSolverConfigUpdated)] = ClientConfigurationUpdated;  if (PeriodicBackupsUpdated)    json[nameof(PeriodicBackupsUpdated)] = PeriodicBackupsUpdated;  if (ExternalReplicationsUpdated)    json[nameof(ExternalReplicationsUpdated)] = ExternalReplicationsUpdated;  ....}

Предупреждение анализатора: V3127 Two similar code fragments were found. Perhaps, this is a typo. SmugglerResult.cs(256), SmugglerResult.cs(253) Raven.Client

Сильно сомневаюсь, что хоть кто-нибудь увидел бы тут странность, взглянув на код. Функция состоит из 14 подобных условий и все переменные заканчиваются на Updated. Даже когда тут приведена малая её часть, ошибку видно не сразу.

Человеческий мозг буквально отказывается искать в таком коде что-либо. В то же время, PVS-Studio с легкостью обнаружил, что присваивается, скорее всего, совершенно не то, должно:
if (ClientConfigurationUpdated)    json[nameof(ClientConfigurationUpdated)] = ClientConfigurationUpdated;if (ConflictSolverConfigUpdated)    json[nameof(ConflictSolverConfigUpdated)] = ClientConfigurationUpdated;

Логично, что в нижней строке справа от оператора присваивания должно быть ConflictSolverConfigUpdated. Я уверен, что без статического анализа данная странность была бы найдена только в случае, если бы из-за неё сломалось что-то достаточно серьёзное. Программист сможет заметить, что в этой функции скрыта проблема, разве что только если будет знать об этом заранее.

Ох уж этот "??"


public int Count =>   _documentsByEntity.Count + _onBeforeStoreDocumentsByEntity?.Count ?? 0;

Предупреждение анализатора: V3123 Perhaps the '??' operator works in a different way than it was expected. Its priority is lower than priority of other operators in its left part. InMemoryDocumentSessionOperations.cs(1952) Raven.Client

Конечно, сохраняется возможность, что это вовсе не ошибка, что так всё и задумано. И всё же это место выглядит очень подозрительно. Ведь логично предположить, что идея функции состоит вовсе не в том, чтобы возвращать 0 при _onBeforeStoreDocumentsByEntity == null.

Я думаю, что здесь действительно присутствует ошибка, связанная с приоритетами операторов. В этом случае, необходимо поставить скобки:
_documentsByEntity.Count + (_onBeforeStoreDocumentsByEntity?.Count ?? 0)

Ну а если вдруг и правда всё так и задумано, то, возможно, стоит указать на это явно тогда у анализатора и программистов, читающих код, вопросов не возникнет:
(_documentsByEntity.Count + _onBeforeStoreDocumentsByEntity?.Count) ?? 0

Но это уже дело вкуса, конечно.

Передача параметров


private static void UpdateEnvironmentVariableLicenseString(....){  ....  if (ValidateLicense(newLicense, rsaParameters, oldLicense) == false)    return;  ....}

Предупреждение анализатора: V3066 Possible incorrect order of arguments passed to 'ValidateLicense' method: 'newLicense' and 'oldLicense'. LicenseHelper.cs(177) Raven.Server

Аргументы переданы в метод в подозрительном порядке. В самом деле, взглянем на объявление:
private static bool ValidateLicense(  License oldLicense,   RSAParameters rsaParameters,   License newLicense)

Весьма приятно, что PVS-Studio способен находить даже такие ошибки. Это отличный пример к вопросу о преимуществах статического анализа перед динамическим.

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

Never continue


private List<CounterOperation> GetCounterOperationsFor(RavenEtlItem item){  ....  for (var i = 0; i < counters.Count; i++)  {    counters.GetPropertyByIndex(i, ref prop);    if (      GetCounterValueAndCheckIfShouldSkip(        item.DocumentId,         null,         prop,         out long value,         out bool delete      )    ) continue;    ....  }  ....}

Предупреждение анализатора: V3022 Expression 'GetCounterValueAndCheckIfShouldSkip(item.DocumentId, null, prop, out long value, out bool delete)' is always false. RavenEtlDocumentTransformer.cs(362) Raven.Server

Полностью данный метод можно рассмотреть, перейдя по ссылке.

Предупреждение говорит о том, что вызов continue в этом цикле недостижим. И если это так, то фрагмент действительно странный. Но может это просто ложное срабатывание? Всё же Rider по этому поводу не ругается.

Взглянем на метод GetCounterValueAndCheckIfShouldSkip:
private bool GetCounterValueAndCheckIfShouldSkip(  LazyStringValue docId,   string function,   BlittableJsonReaderObject.PropertyDetails prop,   out long value,   out bool delete){  value = 0;  if (prop.Value is LazyStringValue)  {    delete = true;  }  else  {    delete = false;    value = CountersStorage.InternalGetCounterValue(      prop.Value as BlittableJsonReaderObject.RawBlob,       docId,       prop.Name    );    if (function != null)    {      using (var result = BehaviorsScript.Run(        Context,         Context,         function,         new object[] { docId, prop.Name }      ))      {        if (result.BooleanValue != true)          return true;      }    }  }  return false;}

Очевидно, этот метод может вернуть true только в том случае, если function != null. В коде же, рассмотренном ранее, на место этого параметра передаётся именно нулевой указатель. Значит, вызов continue действительно недостижим.

Этот момент может быть как безобидным упущением, так и проблемой, связанной с какой-нибудь ошибкой в условии. Так или иначе, стоит обратить на него внимание.

Доверяй, но проверяй


public LicenseType Type{  get  {    if (ErrorMessage != null)      return LicenseType.Invalid;    if (Attributes == null)      return LicenseType.None;    if (Attributes != null &&                             // <=        Attributes.TryGetValue("type", out object type) &&        type is int    )    {      var typeAsInt = (int)type;      if (Enum.IsDefined(typeof(LicenseType), typeAsInt))        return (LicenseType)typeAsInt;    }    return LicenseType.Community;  }}

Предупреждение анализатора: V3063 A part of conditional expression is always true if it is evaluated: Attributes != null. LicenseStatus.cs(28) Raven.Server

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

Nullable, который никогда не null


public Task SuspendObserver(){  if (ServerStore.IsLeader())  {    var suspend = GetBoolValueQueryString("value");    if (suspend.HasValue)                                  // <=    {      Server.ServerStore.Observer.Suspended = suspend.Value;    }    NoContentStatus();    return Task.CompletedTask;  }  RedirectToLeader();  return Task.CompletedTask;}

Предупреждение анализатора: V3022 Expression 'suspend.HasValue' is always true. RachisAdminHandler.cs(116) Raven.Server

Очередная с виду безобидная лишняя проверка. Хотя пока не ясно, с чего анализатор считает её таковой.

Обратимся к GetBoolValueQueryString:
protected bool? GetBoolValueQueryString(string name, bool required = true){  var boolAsString = GetStringQueryString(name, required);  if (boolAsString == null)    return null;  if (bool.TryParse(boolAsString, out bool result) == false)    ThrowInvalidBoolean(name, boolAsString);  return result;}

Действительно, иногда эта функция возвращает null. Да и Rider не считал ту проверку лишней. Неужели Единорог подвёл?

Picture 15


А что, если взглянуть на метод GetStringQueryString?
protected string GetStringQueryString(string name, bool required = true){  var val = HttpContext.Request.Query[name];  if (val.Count == 0 || string.IsNullOrWhiteSpace(val[0]))  {    if (required)      ThrowRequiredMember(name);    return null;  }  return val[0];}

Хм, если параметр required == true, то будет вызван метод ThrowRequiredMember. Интересно, что же он делает? :) Ну, чтобы уж точно не осталось сомнений:
private static void ThrowRequiredMember(string name){  throw new ArgumentException(    $"Query string {name} is mandatory, but wasn't specified."  );}

Итак, резюмируем. Разработчик вызывает метод GetBoolValueQueryString. Вероятно, он считает, что потенциально метод не получит необходимое значение. Ну и как итог вернёт null. Внутри вызывается GetStringQueryString. В случае если возникают проблемы, он либо вернёт null, либо выбросит исключение. Второе происходит в том случае, если параметр required установлен как true. При этом это его значение по умолчанию. В то же время, при вызове GetBoolValueQueryString он, если посмотреть на код выше, как раз не передаётся.

Давайте рассмотрим ещё раз код метода SuspendObserver, на фрагмент которого и ругался анализатор:
public Task SuspendObserver(){  if (ServerStore.IsLeader())  {    var suspend = GetBoolValueQueryString("value");    if (suspend.HasValue)    {      Server.ServerStore.Observer.Suspended = suspend.Value;    }    NoContentStatus();    return Task.CompletedTask;  }  RedirectToLeader();  return Task.CompletedTask;}

Создаётся впечатление, что по задумке поток выполнения тут не должен прерываться, если GetBoolValueQueryString не смог получить значение. В самом деле, после блока с проверкой на null производятся различные действия и возвращается значение. Полагаю, по задумке эти действия производятся независимо отуспешности работы метода GetBoolValueQueryString. Что же произойдёт на самом деле? Поток выполнения будет прерван исключением.

Чтобы этот момент поправить, нужно при вызове GetBoolValueQueryString передать в качестве второго параметра required значение false. Тогда всё действительно будет работать так, как ожидается.

Как я и говорил ранее, иногда кажется, что анализатор ошибается (чего греха таить, бывает и такое). Также достаточно часто предупреждение выглядит незначительным. Казалось бы, есть тут лишняя проверка, да и ладно. Ну или ещё лучше уберём её и никаких проблем предупреждение ведь пропадёт!

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

Странности


private async Task<int> WriteDocumentsJsonAsync(...., int numberOfResults) // <={  using (    var writer = new AsyncBlittableJsonTextWriter(      context,       ResponseBodyStream(),       Database.DatabaseShutdown    )  )  {    writer.WriteStartObject();    writer.WritePropertyName(nameof(GetDocumentsResult.Results));    numberOfResults = await writer.WriteDocumentsAsync(                    // <=      context,       documentsToWrite,       metadataOnly    );    ....  }  return numberOfResults;}

Предупреждение анализатора: V3061 Parameter 'numberOfResults' is always rewritten in method body before being used. DocumentHandler.cs(273), DocumentHandler.cs(267) Raven.Server

Параметр, передаваемый в функцию, не используется, а сразу перезаписывается. Зачем он тут нужен? Его хотели передавать через ref?

Мне стало любопытно взглянуть на то, как используется этот метод в существующем коде. Я понадеялся, что раз он приватный, их не должно быть особо много. Спасибо Rider, я с лёгкостью нашёл, где же производится вызов. Это было единственное место:
private async Task GetDocumentsByIdAsync(....){  ....              int numberOfResults = 0;  numberOfResults = await WriteDocumentsJsonAsync(    context,     metadataOnly,     documents,     includes,     includeCounters?.Results,     numberOfResults  );  ....}

Переменной присваивается 0, затем она передаётся в метод, результат работы которого присваивается ей же. И внутри метода этот параметр никак не используется. Эм. Зачем всё это?

Picture 6


Не тот логический оператор


private OrderByField ExtractOrderByFromMethod(....){  ....  if (me.Arguments.Count < 2 && me.Arguments.Count > 3)    throw new InvalidQueryException(....);  ....}

Предупреждение анализатора: V3022 Expression 'me.Arguments.Count < 2 && me.Arguments.Count > 3' is always false. Probably the '||' operator should be used here. QueryMetadata.cs(861) Raven.Server

Полностью данный метод можно посмотреть здесь.

В этот раз перед нами очевидная ошибка, состоящая в использовании неправильного логического оператора. В текущем виде проверка количества аргументов попросту не работает, ведь нет такого значения, которое одновременно и меньше 2, и больше 3. Истинные же намерения разработчика легко раскрываются первым аргументом, передаваемым в конструктор исключения:
"Invalid ORDER BY 'spatial.distance(from, to, roundFactor)' call, expected 2-3 arguments, got " + me.Arguments.CountЧтобы проверка работала корректно, нужно просто заменить "&&" на "||".

Странный try-метод


private bool Operator(OperatorField fieldOption, out QueryExpression op){   ....  switch (match)  {    ....    case "(":      var isMethod = Method(field, out var method); // <=      op = method;      if (isMethod && Operator(OperatorField.Optional, out var methodOperator))      {        ....      }      return isMethod;    ....  }}

Предупреждение анализатора: V3063 A part of conditional expression is always true if it is evaluated: isMethod. QueryParser.cs(1797) Raven.Server

Полностью данный метод можно посмотреть здесь.

Конструкция var isMethod = Method(field, out var method) напомнила мне о стандартных методах вроде Int.TryParse. Такие методы производят попытку получить результат и записать её в out-переменную, а флаг успешности проведения операции является возвращаемым значением. Код, использующий такие функции, обычно производит проверку возвращаемого значения, а затем на её основе выполняет те или иные операции.

На мой взгляд, функция Method используется здесь именно таким образом. Результат работы Method, кроме того, является возвращаемым значением метода Operator, вызывающего её.

Анализатор же указывает, что переменная isMethod всегда будет иметь значение true и её проверка в условии бессмысленна. Это означает, что функция Method никогда не возвращает false. Какой же тогда смысл в использовании такой конструкции?

Для начала, убедимся в том, что анализатор не ошибся:
private bool Method(FieldExpression field, out MethodExpression op){  var args = ReadMethodArguments();  op = new MethodExpression(field.FieldValue, args);  return true;}

Действительно, возвращаемое значение этого метода всегда true. И если всё так и задумывалось, то это странно, но не беда, в принципе. Но что, если нет?

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

Создаётся впечатление, что код, вызывающий функцию Method, не рассчитан на выбрасывание исключений. Скорее всего предполагается, что когда корректно выполнить получение значения out-переменной не удаётся, функция Method вернёт false. Однако при текущей реализации вместо этого будет выброшено исключение.

Как бы то ни было, стоит обратить внимание на данный фрагмент.

null != null?


private Address GetNextEdge(){  if (m_curEdgeBlock == null || m_curEdgeBlock.Count <= m_curEdgeIdx)  {    m_curEdgeBlock = null;    if (m_edgeBlocks.Count == 0)    {      throw new ApplicationException(        "Error not enough edge data.  Giving up on heap dump."      );    }    var nextEdgeBlock = m_edgeBlocks.Dequeue();    if (      m_curEdgeBlock != null &&                       // <=      nextEdgeBlock.Index != m_curEdgeBlock.Index + 1    )    {      throw new ApplicationException(        "Error expected Node Index " + (m_curEdgeBlock.Index + 1) +         " Got " + nextEdgeBlock.Index + " Giving up on heap dump."      );    }    m_curEdgeBlock = nextEdgeBlock;    m_curEdgeIdx = 0;  }  return m_curEdgeBlock.Values(m_curEdgeIdx++).Target;}

Предупреждение анализатора: V3063 A part of conditional expression is always false if it is evaluated: m_curEdgeBlock != null. DotNetHeapDumpGraphReader.cs(803) Raven.Debug

Переменной присваивается нулевой указатель, а затем через несколько строчек производится проверка на её неравенство null. При этом бессмысленным становится код, производящий проверку nextEdgeBlock.Index != m_curEdgeBlock.Index + 1. Кроме того, никогда не произойдёт выбрасывание исключения.

Логично предположить, что тут что-то работает не так, как должно, ведь фрагмент выглядит очень странно. Либо проверка тут вообще не нужна, либо она реализована как-то неправильно.

Можно рассмотреть срабатывание и с другой стороны пойти от обратного. Попробуем представить ситуацию, при которой это срабатывание является ложным. Я думаю, что это возможно, только если значение переменной может быть изменено при вызове Deque. Однако m_curEdgeBlock является приватным полем, а m_edgeBlocks это стандартная очередь, которая инициализируется в этом же классе. Таким образом, весьма сомнительно, что вызов Dequeue как-то может повлиять на значение m_curEdgeBlock. Следовательно, срабатывание, скорее всего, не является ложным.

First or null


public HashSet<string> FindSpecialColumns(string tableSchema, string tableName){  var mainSchema = GetTable(tableSchema, tableName);  var result = new HashSet<string>();  mainSchema.PrimaryKeyColumns.ForEach(x => result.Add(x)); // <=  foreach (var fkCandidate in Tables)    foreach (var tableReference in fkCandidate.References.Where(        x => x.Table == tableName && x.Schema == tableSchema      )    )    {      tableReference.Columns.ForEach(x => result.Add(x));    }  return result;}

Предупреждение анализатора: V3146 Possible null dereference of 'mainSchema'. The 'Tables.FirstOrDefault' can return default null value. DatabaseSchema.cs(31) Raven.Server

Срабатывание, на первый взгляд, может показаться непонятным. Действительно, при чём тут вообще FirstOrDefault? Чтобы стало ясно, отчего же анализатор ругается, необходимо взглянуть на функцию GetTable:
public TableSchema GetTable(string schema, string tableName){  return Tables.FirstOrDefault(    x => x.Schema == schema && x.TableName == tableName  );}

Вызов метода FirstOrDefault вместо First может быть обусловлен тем, что в коллекции может не быть элементов, соответствующих заданному условию. В таком случае FirstOrDefault, а, следовательно, и GetTable вернёт null, так как TableSchema это ссылочный тип. Именно поэтому PVS-Studio и говорит о том, что в данном коде может произойти попытка разыменования нулевого указателя.

Возможно, всё же стоит проверять такой случай, чтобы выполнение не прерывалось с NullReferenceException. Если же вариант, при котором Tables.FirstOrDefault вернёт null невозможен, то тогда нет смысла в использовании FirstOrDefault вместо First.

Always true


public override void VerifyCanExecuteCommand(  ServerStore store, TransactionOperationContext context, bool isClusterAdmin){  using (context.OpenReadTransaction())  {    var read = store.Cluster.GetCertificateByThumbprint(context, Name);    if (read == null)      return;    var definition = JsonDeserializationServer.CertificateDefinition(read);    if (      definition.SecurityClearance != SecurityClearance.ClusterAdmin || // <=      definition.SecurityClearance != SecurityClearance.ClusterNode     // <=    )      return;  }  AssertClusterAdmin(isClusterAdmin);}

Предупреждение анализатора: V3022 Expression is always true. Probably the '&&' operator should be used here. DeleteCertificateFromClusterCommand.cs(21) Raven.Server

Ещё один пример ситуации, когда скорее всего выбран неправильный логический оператор. В данном случае условие всегда истинно, ведь переменная точно не равна хотя бы одному из значений, с которыми сравнивается.

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

Заключение


В первую очередь хочу поблагодарить всех, кто добрался до этого места. Статья вышла достаточно объёмная, но, надеюсь, вам было интересно вместе со мной разбираться с новой версией анализатора PVS-Studio и изучать найденные ошибки.

Важно помнить, что разработчик должен основной своей целью ставить вовсе не уменьшение количества срабатываний. Не ради достижения пустого лога ошибок нужно использовать PVS-Studio. Бороться со срабатываниями всё равно что бороться с симптомами болезни, от которой страдает исходный код.

Рассматривая сообщения анализатора, всегда нужно стараться понять, почему выдаётся то или иное предупреждение. Лишь осознав логику, по которой анализатор выдал предупреждение, можно сделать вывод о том, указывает оно на ошибку или нет. Именно в этом случае вы будете бороться не с симптомом, а с болезнью. И именно таким образом ваш код станет чище и здоровее. И конечно же, проблем с таким прекрасным исходником будет намного меньше. Хотя лучше я пожелаю вам, чтобы их не было вообще :)

Picture 16




Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikita Lipilin. How to find errors in a C# project working under Linux and macOS.
Подробнее..

Как я сделал Discord бота для игровой гильдии с помощью .NET Core

05.06.2021 18:10:05 | Автор: admin
Батрак предупреждает о том что к гильдии присоединился игрокБатрак предупреждает о том что к гильдии присоединился игрок

Вступление

Всем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.

В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.

От вас не потребуется никаких знаний об игре. Я написал материал так чтобы можно было абстрагироваться от игры и сделал заглушку для данных об игроках. Но если у вас есть учетная запись в Battle.net, то вы сможете получать реальные данные.

Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.

План

На каждом шаге будем постепенно наращивать функционал.

  1. Создадим новый web api проект с одним контроллером /check. При обращении к этому адресу будем отправлять строку Hello! в Discord чат.

  2. Научимся получать данные о составе гильдии с помощью готовой библиотеки или заглушки.

  3. Научимся сохранять в кэш полученный список игроков чтобы при следующих проверках находить различия с предыдущей версией списка. Обо всех изменениях будем писать в Discord.

  4. Напишем Dockerfile для нашего проекта и разместим проект на хостинге Heroku.

  5. Посмотрим на несколько способов сделать периодическое выполнение кода.

  6. Реализуем автоматическую сборку, запуск тестов и публикацию проекта после каждого коммита в master

Шаг 1. Отправляем сообщение в Discord

Нам потребуется создать новый ASP.NET Core Web API проект.

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

Добавим к проекту новый контроллер

[ApiController]public class GuildController : ControllerBase{    [HttpGet("/check")]    public async Task<IActionResult> Check(CancellationToken ct)    {        return Ok();    }}

Затем нам понадобится webhook от вашего Discord сервера. Webhook - это механизм отправки событий. В данном случае, то это адрес к которому можно слать простые http запросы с сообщениями внутри.

Получить его можно в пункте integrations в настройках любого текстового канала вашего Discord сервера.

Создание webhookСоздание webhook

Добавим webhook в appsettings.json нашего проекта. Позже мы унесем его в переменные окружения Heroku. Если вы не знакомы с тем как работать с конфигурацией в ASP Core проектах предварительно изучите эту тему.

{"DiscordWebhook":"https://discord.com/api/webhooks/****/***"}

Теперь создадим новый сервис DiscordBroker, который умеет отправлять сообщения в Discord. Создайте папку Services и поместите туда новый класс, эта папка нам еще пригодится.

По сути этот новый сервис делает post запрос по адресу из webhook и содержит сообщение в теле запроса.

public class DiscordBroker : IDiscordBroker{    private readonly string _webhook;    private readonly HttpClient _client;    public DiscordBroker(IHttpClientFactory clientFactory, IConfiguration configuration)    {        _client = clientFactory.CreateClient();        _webhook = configuration["DiscordWebhook"];    }    public async Task SendMessage(string message, CancellationToken ct)    {        var request = new HttpRequestMessage        {            Method = HttpMethod.Post,            RequestUri = new Uri(_webhook),            Content = new FormUrlEncodedContent(new[] {new KeyValuePair<string, string>("content", message)})        };        await _client.SendAsync(request, ct);    }}

Как видите, мы используем внедрение зависимостей. IConfiguration позволит нам достать webhook из конфигов, а IHttpClientFactory создать новый HttpClient.

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

Не забудьте что новый класс нужно будет зарегистрировать в Startup.

services.AddScoped<IDiscordBroker, DiscordBroker>();

А также нужно будет зарегистрировать HttpClient, для работы IHttpClientFactory.

services.AddHttpClient();

Теперь можно воспользоваться новым классом в контроллере.

private readonly IDiscordBroker _discordBroker;public GuildController(IDiscordBroker discordBroker){  _discordBroker = discordBroker;}[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){  await _discordBroker.SendMessage("Hello", ct);  return Ok();}

Запустите проект, зайдите по адресу /check в браузере и убедитесь что в Discord пришло новое сообщение.

Шаг 2. Получаем данные из Battle.net

У нас есть два варианта: получать данные из настоящих серверов battle.net или из моей заглушки. Если у вас нет аккаунта в battle.net, то пропустите следующий кусок статьи до момента где приводится реализация заглушки.

Получаем реальные данные

Вам понадобится зайти на https://develop.battle.net/ и получить там две персональных строки BattleNetId и BattleNetSecret. Они будут нужны нам чтобы авторизоваться в api перед отправкой запросов. Поместите их в appsettings.

Подключим к проекту библиотеку ArgentPonyWarcraftClient.

Создадим новый класс BattleNetApiClient в папке Services.

public class BattleNetApiClient{   private readonly string _guildName;   private readonly string _realmName;   private readonly IWarcraftClient _warcraftClient;   public BattleNetApiClient(IHttpClientFactory clientFactory, IConfiguration configuration)   {       _warcraftClient = new WarcraftClient(           configuration["BattleNetId"],           configuration["BattleNetSecret"],           Region.Europe,           Locale.ru_RU,           clientFactory.CreateClient()       );       _realmName = configuration["RealmName"];       _guildName = configuration["GuildName"];   }}

В конструкторе мы создаем новый экземпляр класса WarcraftClient.
Этот класс относится к библиотеке, которую мы установили ранее. С его помощью можно получать данные об игроках.

Кроме этого, нужно создать в appsettings проекта две новых записи RealmName и GuildName. RealmName это название игрового мира, а GuildName это название гильдии. Их будем использовать как параметры при запросе.

Сделаем метод GetGuildMembers чтобы получать состав гильдии и создадим модель WowCharacterToken которая будет представлять собой информацию об игроке.

public async Task<WowCharacterToken[]> GetGuildMembers(){   var roster = await _warcraftClient.GetGuildRosterAsync(_realmName, _guildName, "profile-eu");   if (!roster.Success) throw new ApplicationException("get roster failed");   return roster.Value.Members.Select(x => new WowCharacterToken   {       WowId = x.Character.Id,       Name = x.Character.Name   }).ToArray();}
public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Класс WowCharacterToken следует поместить в папку Models.

Не забудьте подключить BattleNetApiClient в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Берем данные из заглушки

Для начала создадим модель WowCharacterToken и поместим ее в папку Models. Она представляет собой информацию об игроке.

public class WowCharacterToken{  public int WowId { get; set; }  public string Name { get; set; }}

Дальше сделаем вот такой класс

public class BattleNetApiClient{    private bool _firstTime = true;    public Task<WowCharacterToken[]> GetGuildMembers()    {        if (_firstTime)        {            _firstTime = false;            return Task.FromResult(new[]            {                new WowCharacterToken                {                    WowId = 1,                    Name = "Артас"                },                new WowCharacterToken                {                    WowId = 2,                    Name = "Сильвана"                }            });        }        return Task.FromResult(new[]        {            new WowCharacterToken            {                WowId = 1,                Name = "Артас"            },            new WowCharacterToken            {                WowId = 3,                Name = "Непобедимый"            }        });    }}

Он возвращает зашитый в него список игроков. При первом вызове метода мы вернем один список, при последующих другой. Это нужно нам что смоделировать изменчивое поведение api. Этой заглушки хватит чтобы продолжить делать проект.

Сделайте интерфейс и подключите все что мы создали в Startup.

services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();

Выведем результаты в Discord

После того как мы сделали BattleNetApiClient, им можно воспользоваться в контроллере чтобы вывести кол-во игроков в Discord.

[ApiController]public class GuildController : ControllerBase{  private readonly IDiscordBroker _discordBroker;  private readonly IBattleNetApiClient _battleNetApiClient;  public GuildController(IDiscordBroker discordBroker, IBattleNetApiClient battleNetApiClient)  {     _discordBroker = discordBroker;     _battleNetApiClient = battleNetApiClient;  }  [HttpGet("/check")]  public async Task<IActionResult> Check(CancellationToken ct)  {     var members = await _battleNetApiClient.GetGuildMembers();     await _discordBroker.SendMessage($"Members count: {members.Length}", ct);     return Ok();  }}

Шаг 3. Находим новых и ушедших игроков

Нужно научиться определять какие игроки появились или пропали из списка при последующих запросах к api. Для этого мы можем закэшировать список в InMemory кэше (в оперативной памяти) или во внешнем хранилище.

Если закэшировать список в InMemory кэше, то мы потеряем его при перезапуске приложения. Поэтому позже мы подключим базу данных Redis как аддон в Heroku и будем кешировать туда.

А пока что подключим InMemory кэш в Startup.

services.AddMemoryCache(); 

Теперь в нашем распоряжении есть IDistributedCache, который можно подключить через конструктор. Я предпочел не использовать его напрямую , а написать для него обертку. Создайте класс GuildRepository и поместите его в новую папку Repositories.

public class GuildRepository : IGuildRepository{    private readonly IDistributedCache _cache;    private const string Key = "wowcharacters";    public GuildRepository(IDistributedCache cache)    {        _cache = cache;    }    public async Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        var value = await _cache.GetAsync(Key, ct);        if (value == null) return Array.Empty<WowCharacterToken>();        return await Deserialize(value);    }    public async Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        var value = await Serialize(characters);        await _cache.SetAsync(Key, value, ct);    }        private static async Task<byte[]> Serialize(WowCharacterToken[] tokens)    {        var binaryFormatter = new BinaryFormatter();        await using var memoryStream = new MemoryStream();        binaryFormatter.Serialize(memoryStream, tokens);        return memoryStream.ToArray();    }    private static async Task<WowCharacterToken[]> Deserialize(byte[] bytes)    {        await using var memoryStream = new MemoryStream();        var binaryFormatter = new BinaryFormatter();        memoryStream.Write(bytes, 0, bytes.Length);        memoryStream.Seek(0, SeekOrigin.Begin);        return (WowCharacterToken[]) binaryFormatter.Deserialize(memoryStream);    }}

Теперь можно написать сервис который будет сравнивать новый список игроков с сохраненным.

public class GuildService{    private readonly IBattleNetApiClient _battleNetApiClient;    private readonly IGuildRepository _repository;    public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)    {        _battleNetApiClient = battleNetApiClient;        _repository = repository;    }    public async Task<Report> Check(CancellationToken ct)    {        var newCharacters = await _battleNetApiClient.GetGuildMembers();        var savedCharacters = await _repository.GetCharacters(ct);        await _repository.SaveCharacters(newCharacters, ct);        if (!savedCharacters.Any())            return new Report            {                JoinedMembers = Array.Empty<WowCharacterToken>(),                DepartedMembers = Array.Empty<WowCharacterToken>(),                TotalCount = newCharacters.Length            };        var joined = newCharacters.Where(x => savedCharacters.All(y => y.WowId != x.WowId)).ToArray();        var departed = savedCharacters.Where(x => newCharacters.All(y => y.Name != x.Name)).ToArray();        return new Report        {            JoinedMembers = joined,            DepartedMembers = departed,            TotalCount = newCharacters.Length        };    }}

В качестве возвращаемого результата используется модель Report. Ее нужно создать и поместить в папку Models.

public class Report{   public WowCharacterToken[] JoinedMembers { get; set; }   public WowCharacterToken[] DepartedMembers { get; set; }   public int TotalCount { get; set; }}

Применим GuildService в контроллере.

[HttpGet("/check")]public async Task<IActionResult> Check(CancellationToken ct){   var report = await _guildService.Check(ct);   return new JsonResult(report, new JsonSerializerOptions   {      Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Cyrillic)   });}

Теперь отправим в Discord какие игроки присоединились или покинули гильдию.

if (joined.Any() || departed.Any()){   foreach (var c in joined)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** присоединился к гильдии",         ct);   foreach (var c in departed)      await _discordBroker.SendMessage(         $":smile: **{c.Name}** покинул гильдию",         ct);}

Эту логику я добавил в GuildService в конец метода Check. Писать бизнес логику в контроллере не стоит, у него другое назначение. В самом начале мы делали там отправку сообщения в Discord потому что еще не существовало GuildService.

На первом скриншоте в статье вы видели что я вывел больше информации об игроке. Ее можно получить если воспользоваться библиотекой ArgentPonyWarcraftClient

await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);

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

Unit тесты

У нас появился класс GuildService с нетривиальной логикой, который будет изменяться и расширяться в будущем. Стоит написать на него тесты. Для этого нужно будет сделать заглушки для BattleNetApiClient, GuildRepository и DiscordBroker. Я специально просил создавать интерфейсы для этих классов чтобы можно было сделать их фейки.

Создайте новый проект для Unit тестов. Заведите в нем папку Fakes и сделайте три фейка.

public class DiscordBrokerFake : IDiscordBroker{   public List<string> SentMessages { get; } = new();   public Task SendMessage(string message, CancellationToken ct)   {      SentMessages.Add(message);      return Task.CompletedTask;   }}
public class GuildRepositoryFake : IGuildRepository{    public List<WowCharacterToken> Characters { get; } = new();    public Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)    {        return Task.FromResult(Characters.ToArray());    }    public Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)    {        Characters.Clear();        Characters.AddRange(characters);        return Task.CompletedTask;    }}
public class BattleNetApiClientFake : IBattleNetApiClient{   public List<WowCharacterToken> GuildMembers { get; } = new();   public List<WowCharacter> Characters { get; } = new();   public Task<WowCharacterToken[]> GetGuildMembers()   {      return Task.FromResult(GuildMembers.ToArray());   }}

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

Первый тест на GuildService будет выглядеть так:

[Test]public async Task SaveNewMembers_WhenCacheIsEmpty(){   var wowCharacterToken = new WowCharacterToken   {      WowId = 100,      Name = "Sam"   };      var battleNetApiClient = new BattleNetApiApiClientFake();   battleNetApiClient.GuildMembers.Add(wowCharacterToken);   var guildRepositoryFake = new GuildRepositoryFake();   var guildService = new GuildService(battleNetApiClient, null, guildRepositoryFake);   var changes = await guildService.Check(CancellationToken.None);   changes.JoinedMembers.Length.Should().Be(0);   changes.DepartedMembers.Length.Should().Be(0);   changes.TotalCount.Should().Be(1);   guildRepositoryFake.Characters.Should().BeEquivalentTo(wowCharacterToken);}

Как видно из названия, тест позволяет проверить что мы сохраним список игроков, если кэш пуст. Заметьте, в конце теста используется специальный набор методов Should, Be... Это методы из библиотеки FluentAssertions, которые помогают нам сделать Assertion более читабельным.

Теперь у нас есть база для написания тестов. Я показал вам основную идею, дальнейшее написание тестов оставляю вам.

Главный функционал проекта готов. Теперь можно подумать о его публикации.

Шаг 4. Привет Docker и Heroku!

Мы будем размещать проект на платформе Heroku. Heroku не позволяет запускать .NET проекты из коробки, но она позволяет запускать Docker образы.

Чтобы упаковать проект в Docker нам понадобится создать в корне репозитория Dockerfile со следующим содержимым

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS builderWORKDIR /sourcesCOPY *.sln .COPY ./src/peon.csproj ./src/COPY ./tests/tests.csproj ./tests/RUN dotnet restoreCOPY . .RUN dotnet publish --output /app/ --configuration ReleaseFROM mcr.microsoft.com/dotnet/core/aspnet:3.1WORKDIR /appCOPY --from=builder /app .CMD ["dotnet", "peon.dll"]

peon.dll это название моего Solution. Peon переводится как батрак.

О том как работать с Docker и Heroku можно прочитать здесь. Но я все же опишу последовательность действий.

Вам понадобится создать аккаунт в Heroku, установить Heroku CLI.

Создайте новый проект в heroku и свяжите его с вашим репозиторием.

heroku git:remote -a project_name

Теперь нам необходимо создать файл heroku.yml в папке с проектом. У него будет такое содержимое:

build:  docker:    web: Dockerfile

Дальше выполним небольшую череду команд:

# Залогинимся в heroku registryheroku container:login# Соберем и запушим образ в registryheroku container:push web# Зарелизим приложение из образаheroku container:release web

Можете открыть приложение в браузере с помощью команды:

heroku open

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

Установите для нашего Heroku приложения бесплатный аддон RedisCloud.

Строку подключения для Redis можно будет получить через переменную окружения REDISCLOUD_URL. Она будет доступна, когда приложение будет запущено в экосистеме Heroku.

Нам нужно получить эту переменную в коде приложения.

Установите библиотеку Microsoft.Extensions.Caching.StackExchangeRedis.

С помощью нее можно зарегистрировать Redis реализацию для IDistributedCache в Startup.

services.AddStackExchangeRedisCache(o =>{   o.InstanceName = "PeonCache";   var redisCloudUrl = Environment.GetEnvironmentVariable("REDISCLOUD_URL");   if (string.IsNullOrEmpty(redisCloudUrl))   {      throw new ApplicationException("redis connection string was not found");   }   var (endpoint, password) = RedisUtils.ParseConnectionString(redisCloudUrl);   o.ConfigurationOptions = new ConfigurationOptions   {      EndPoints = {endpoint},      Password = password   };});

В этом коде мы получили переменную REDISCLOUD_URL из переменных окружения системы. После этого мы извлекли адрес и пароль базы данных с помощью класса RedisUtils. Его написал я сам:

public static class RedisUtils{   public static (string endpoint, string password) ParseConnectionString(string connectionString)   {      var bodyPart = connectionString.Split("://")[1];      var authPart = bodyPart.Split("@")[0];      var password = authPart.Split(":")[1];      var endpoint = bodyPart.Split("@")[1];      return (endpoint, password);   }}

На этот класс можно сделать простой Unit тест.

[Test]public void ParseConnectionString(){   const string example = "redis://user:password@url:port";   var (endpoint, password) = RedisUtils.ParseConnectionString(example);   endpoint.Should().Be("url:port");   password.Should().Be("password");}

После того что мы сделали, GuildRepository будет сохранять кэш не в оперативную память, а в Redis. Нам даже не нужно ничего менять в коде приложения.

Опубликуйте новую версию приложения.

Шаг 5. Реализуем циклическое выполнение

Нам нужно сделать так чтобы проверка состава гильдии происходила регулярно, например каждые 15 минут.

Есть несколько способов это реализовать:

Самый простой способ - это сделать задание на сайте https://cron-job.org. Этот сервис будет слать get запрос на /check вашего приложения каждые N минут.

Второй способ - это использовать Hosted Services. В этой статье подробно описано как создать повторяющееся задание в ASP.NET Core проекте. Учтите, бесплатный тариф в Heroku подразумевает что ваше приложение будет засыпать после того как к нему некоторое время не делали запросов. Hosted Service перестанет работать после того как приложение заснет. В этом варианте вам следует перейти на платный тариф. Кстати, так сейчас работает мой бот.

Третий способ - это подключить к проекту специальные Cron аддоны. Например Heroku Scheduler. Можете пойти этим путем и разобраться как создать cron job в Heroku.

Шаг 6. Автоматическая сборка, прогон тестов и публикация

Во-первых, зайдите в настройки приложения в Heroku.

Там есть пункт Deploy. Подключите там свой Github аккаунт и включите Automatic deploys после каждого коммита в master.

Поставьте галочку у пункта Wait for CI to pass before deploy. Нам нужно чтобы Heroku дожидался сборки и прогонки тестов. Если тесты покраснеют, то публикация не случится.

Сделаем сборку и прогонку тестов в Github Actions.

Зайдите в репозиторий и перейдите в пункт Actions. Теперь создайте новый workflow на основе шаблона .NET

В репозитории появится новый файл dotnet.yml. Он описывает процесс сборки.

Как видите по его содержимому, задание build будет запускаться после пуша в ветку master.

on:  push:    branches: [ master ]  pull_request:    branches: [ master ]

Содержимое самого задания нас полностью устраивает. Если вы вчитаетесь в то что там происходит, то увидите что там происходит запуск команд dotnet build и dotnet test.

    steps:    - uses: actions/checkout@v2    - name: Setup .NET      uses: actions/setup-dotnet@v1      with:        dotnet-version: 5.0.x    - name: Restore dependencies      run: dotnet restore    - name: Build      run: dotnet build --no-restore    - name: Test      run: dotnet test --no-build --verbosity normal

Менять в этом файле ничего не нужно, все уже будет работать из коробки.

Запуште что-нибудь в master и посмотрите что задание запускается. Кстати, оно уже должно было запуститься после создания нового workflow.

Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.

Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах!

Подробнее..
Категории: C , Net , Api , Docker , Dotnet , Discord , Bot , Бот , Heroku , Микросервис , Wow

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru