StepOne! The all-mighty CSharp-Man!
StepOne! The all-mighty CSharp-Man!

Меня зовут Степан, я C# профессионал уже более 7 лет на рынке и рассказываю об этом в Telegram каналe StepOne. В этой статье я покажу вам личную подборку 9ти underground NuGet пакетов. Вы наверняка не встречали их на работе, потому что они либо решают конкретную специальную задачу, либо решают известные задачи нестандартным подходом, либо ещё недостаточно известны на рынке РФ. Мне же удалось затащить их на прод и пощупать в бою!

Geo

Однажды у меня была задача - вытащить данные по гео-запросу из MongoDb, а потом сделать по ним гео-поиск в SQL Server. Так странно, потому что "микросервисы"

Код был написан, локально на винде работал - а на стейдже обосрамс. Оказалось, Linux не умеет работать с Microsoft.SqlServer.Types.SqlGeometry. Не помогало ничего, даже мок-пакет от dotMorten - dotMorten.Microsoft.SqlServer.Types. Проблема слишком низкоуровневая и зашита в DLL биндинги клиентской библиотеки.

Но по сути, мне надо было сериализовать гео объект в SQL запрос, чтобы СУБД собрала объект геометрии сама. Выглядит это примерно следующим образом:

DECLARE @g geometry;
DECLARE @h geometry;
SET @g = geometry::STGeomFromText('POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))', 0);
SET @h = geometry::STGeomFromText('POINT(1 1)', 0);

На уровне запроса СУБД работает с так называемыми WKT строками.
Well Known Text (WKT) - это что-то вроде JSON для гео-данных. Текстовый формат представления векторной геометрии и описания систем координат. Есть такая красивая картинка с примерами:

Примеры сериализации объектов в WKT строку
Примеры сериализации объектов в WKT строку

Решил проблему через ООП, когда нашёл библиотеку Geo. Geo - это библиотека для работы с пространственными объектами, которая моделирует географическую предметную область. Она позволяет собирать и сериализовывать гео объекты следующим образом:

var settings = new WktWriterSettings
{
    LinearRing = false;
    Triangle = false;
    DimensionFlag = true;
    NullOrdinate = Coordinate.NullOrdinate.ToString(CultureInfo.InvariantCulture);
    MaxDimesions = 4;
};

var writer = new WktWriter(settings);
var pointString = writer.Write(new Point(68.389, 73.89));

Ссылка на NuGet
Ссылка на GitHub

CoordinateSharp

Как программист, который редко работал с координатами, ч всю жизнь думал, что точки бывают двух типов: в 2D - [x, y], в 3D - [x, y, z] И ВСЁ. Тестовое в геймдев галеру дало понять, что я никогда так не ошибался...

По заданию для работы с любым объектом в игровом мире нужно уметь обрабатывать координаты. Оказывается, что они бывают разных форматов, между которыми существует конвертация. Как минимум (ширина, долгота) <-> декартовы (x, y)

Находка библиотеки CoordinateSharp спасла меня от очередного изобретения велосипедов. Добрый человек по имени Джастин написал простую .NET библиотеку для помощи в с преобразованиями географических координат, парсингом, форматированием и другими задачами. Например, координаты Сиэттла в 10 часов 10 минут 5 июня 2018 года можно собрать так:

//Seattle coordinates on 5 Jun 2018 @ 10:10 AM (UTC)
//Signed-Decimal Degree    47.6062, -122.3321
//Degrees Minutes Seconds  N 47º 36' 22.32" W 122º 19' 55.56"

/***********************************************************/

var c = new Coordinate(47.6062, -122.3321, new DateTime(2018, 6, 5, 10, 10, 0));

Условный сервис конвертации для тестового в геймдев галеру выглядел так, и его приняли:

internal sealed class CoordinatesConverter : ICoordinatesConverter
{
    public double[] ToGeo(int x, int y)
    {
        var coordinate = Cartesian.CartesianToLatLong(x, y, 0);
        return [coordinate.Longitude.ToDouble(), coordinate.Latitude.ToDouble()];
    }

    public int[] ToCartesian(double lat, double lng)
    {
        var cartesian = new Coordinate(lat, lng).Cartesian;
        return [double.ConvertToInteger<int>(cartesian.X), double.ConvertToInteger<int>(cartesian.Y)];
    }
}

Ссылка на NuGet
Ссылка на GitHub

TestableIO.System.IO.Abstractions

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

Тогда обратите внимание на пакет System.IO.Abstractions. И тогда проверка операций ввода/ввода станет проще!

Его суть довольна проста: статические методы из System.IO по типу File.WriteAllText теперь доступны через ряд специальных абстракций. Код под капотом тот же самый, только теперь он внедряемый и тестируемый.

Например, я использую этот пакет в своём языке программирования hydrascript, чтобы мокать файловую систему и изолировать логику для дампа дебаг файлов:

services.AddSingleton<IFileSystem, FileSystem>();

// ...

internal sealed class DumpingService(
    IFileSystem fileSystem,
    IOptions<FileInfo> fileInfo) : IDumpingService
{
    public void Dump(string? contents, string fileExtension)
    {
        var fileNameWithExtension = fileInfo.Value.Name;
        var originalFileExtension = fileInfo.Value.Extension;
        var fileName = fileNameWithExtension.Replace(originalFileExtension, string.Empty);
        var path = Path.Combine(
            fileInfo.Value.DirectoryName ?? string.Empty,
            ZString.Concat(fileName, '.', fileExtension));
        fileSystem.File.WriteAllText(path, contents);
    }
}

Больше про hydrascript можно узнать в моём Telegram канале StepOne.

Ссылка на NuGet
Ссылка на GitHub

EnvironmentAbstractions

Мокировать и внедрять в DI можно не только файловую систему, но и API для работы с переменными среды. Пакет EnvironmentAbstractions предоставляет абстракции над статическим классом System.Environment , чтобы иметь возможность потреблять эту логику в .NET приложениях через интерфейсы.

Доступно два интерфейса: IEnvironmentProvider и IEnvironmentVariableProvider. IEnvironmentProvider копирует содержимое System.Environment , в то время как IEnvironmentVariableProvider предоставляет API только для работы с переменными среды.

Я использую этот пакет в своём языке программирования hydrascript для реализации обращения к переменным среды в рантайме:

services.AddSingleton(SystemEnvironmentVariableProvider.Instance);

// ...

public sealed class EnvFrame(IEnvironmentVariableProvider provider) : IFrame
{
    public object? this[string id]
    {
        get => provider.GetEnvironmentVariable(id) ?? string.Empty;
        set => provider.SetEnvironmentVariable(id, value?.ToString());
    }
}

Ссылка на NuGet
Ссылка на GitHub

Fare

Fare означает Finite Automata and Regular Expressions.

Про этот NuGet пакет вы точно вряд ли слышали! Fare - это порт Java библиотек dk.brics.automaton и xeger , который позволяет генерировать текст по заданному регулярному выражению. Пример, использования:

using Fare;

var regex = "[a-zA-Z]+";
var xeger = new Xeger(regex);

var text = xeger.Generate(); 

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

Например, я использую этот пакет в своём языке программирования hydrascript для генерации тестового потока токенов для лексера:

public record LexerInput([property:MinLength(10), MaxLength(25)] TokenInput[] TokenInputs) : IReadOnlyList<string>
{
    public IEnumerator<string> GetEnumerator() =>
        TokenInputs.Select(x => x.Value).GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Count => TokenInputs.Length;

    public string this[int index] => TokenInputs[index].Value;

    public override string ToString() =>
        TokenInputs.Aggregate(
            TokenInput.AdditiveIdentity,
            (x, y) => x + y).Value;
}

public record TokenInput(
    [property: RegularExpression(TokenInput.Pattern)]
    string Value) :
    IAdditiveIdentity<TokenInput, TokenInput>,
    IAdditionOperators<TokenInput, TokenInput, TokenInput>
{
    [StringSyntax(StringSyntaxAttribute.Regex)]
    public const string Pattern = "[a-zA-Z]+|[0-9]+|[+]{2}";

    public static TokenInput operator +(TokenInput left, TokenInput right) =>
        new(left.Value + " " + right.Value);

    public static TokenInput AdditiveIdentity { get; } = new(string.Empty);
}

// ...

var fixture = new AutoFixture.Fixture();
var lexerInput = fixture.Create<LexerInput>().ToString();

Ссылка на NuGet
Ссылка на GitHub

NUT - Number To Text

В мире финтеха часто возникают задачи, когда сумму в числовом виде надо перевести в строку. Например, мы хотим некую функцию Translate вида:

Translate(100m).Should().Be("Сто рублей ноль копеек");

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

using Nut;

Console.WriteLine(100m.ToText("rub", "ru", new Options { MainUnitFirstCharUpper = true });
// Сто рублей ноль копеек

Ссылка на NuGet
Ссылка на GitHub

DbMocker

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

Но в��т незадача, несмотря на наличие абстракций ADO NET по типу DbConnection илиDbCommand и других, не понятно, как такой код тестировать.

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

Библиотека DbMocker решает эту проблему и позволяет писать настоящие юнит тесты на ADO NET based DAL. Например, есть сервис, который сравнивает кол-во строк в двух таблицах. Тогда замокировать можно следующим образом:

var mockDbConnection = new MockDbConnection();
mockDbConnection.Mocks
    .When(cmd => cmd.CommandText.Contains("count(*) from t1"))
    .ReturnsTable(
        MockTable.WithColumns("Count")
            .AddRow(1));
mockDbConnection.Mocks
    .When(cmd => cmd.CommandText.Contains("count(*) from t2"))
    .ReturnsTable(
        MockTable.WithColumns("Count")
            .AddRow(2));

Ссылка на NuGet
Ссылка на GitHub

DatabaseSchemaReader

В 2024 году я решал задачу автоматизации проверки переливки данных между базами данных. При заданных условиях и требованиях было решено выполнять кумулятивное хеширование на стороне СУБД и сравнивать результат в клиенте на C#.

Нужно было генерировать SQL скрипты на базе схемы данных для хеширования конкретной таблицы. Чтобы быстро прототипироваться и защитить proof of concept перед руководством, я решил использовать готовое решение и нашёл пакет DatabaseSchemaReader.

Библиотека предоставляет .NET Standard фасад для чтения метаданных БД через ADO NET соединение. Согласно документации поддерживаются многие серверы:

  • SqlServer

  • Oracle

  • MySql

  • PostgreSql

  • SQLite

  • SqlServerCe 4

  • DB2

  • Firebird

  • Intersystems Cache

  • Ingres

  • Sybase AnyWhere (ASA)

  • Sybase UltraLite

  • Sybase ASE

  • Access 97 and Access 2007

  • VistaDB

Начать использовать и прочитать схему очень просто:

using (var connection = new SqlConnection("cnn string"))
{
    var dbReader = new DatabaseReader(connection);
    var schema = dbReader.ReadAll();
    foreach (var table in schema.Tables)
    {
      //do something with your model
    }
}

API имеет удобную ООПшную объектную модель, для которой писать юнит тесты приносит удовольствие вместо боли:

var table = new DatabaseTable
{
    Name = "t",
    Columns =
    {
        new DatabaseColumn { Name = "a" }
    }
};

При тестировании в продакшне напоролся на неприятный подводный камень - скорость чтения схемы в разных СУБД отличается. Например, PostgreSql жутко тормозил. Выход нашёлся - кеширование в XML файле на диске, поскольку библиотека предусмотрела возможность сериализации и десериализации:

using (var stream = File.Open("cache.xml", FileMode.Create))
{
    var serializer = new XmlSerializer(typeof(DatabaseSchema));
    serializer.Serialize(stream, dbSchema);
}

// ...

using (var stream = File.Open("cache.xml", FileMode.Open))
{
    var serializer = new XmlSerializer(typeof(DatabaseSchema));
    var dbSchema = serializer.Deserialize(stream) as DatabaseSchema;
    DatabaseSchemaFixer.UpdateReferences(dbSchema);
    DatabaseSchemaFixer.UpdateReferences(dbSchema);
}

Ссылка на NuGet
Ссылка на GitHub

Visitor.NET

«Посетитель» (visitor) является одним из самых сложных паттернов Банды Четырёх.

На языке C# для него можно создать множество реализаций, однако все они так или иначе имеют ограничения из-за возникающего динамического приведения типов.

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

Описал подробно историю создания и принцип работы в статье на Хабре "Такого «Посетителя» вы ещё не видели — Visitor.NET".

Пакет активно используется в моём языке программирования hydrascript для реализации статического анализа и кодогенерации промежуточного представления при обходе узлов абстрактного синтаксического дерева. Например, так выглядит проверка тела функции на наличие return во всех ветках кода:

internal class ReturnAnalyzer : VisitorBase<IAbstractSyntaxTreeNode, bool>,
    IVisitor<FunctionDeclaration, ReturnAnalyzerResult>,
    IVisitor<IfStatement, bool>,
    IVisitor<ReturnStatement, bool>
{
    private readonly List<ReturnStatement> _returnStatements = [];

    public ReturnAnalyzerResult Visit(FunctionDeclaration visitable)
    {
        IAbstractSyntaxTreeNode astNode = visitable;
        var codePathEndedWithReturn= Visit(astNode);
        var returnStatements = new List<ReturnStatement>(_returnStatements);
        ReturnAnalyzerResult result = new(codePathEndedWithReturn, returnStatements);
        _returnStatements.Clear();
        return result;
    }

    public override bool Visit(IAbstractSyntaxTreeNode visitable)
    {
        for (var i = 0; i < visitable.Count; i++)
        {
            var visitableResult = visitable[i].Accept(This);
            if (visitableResult)
                return true;
        }

        return false;
    }

    public bool Visit(IfStatement visitable)
    {
        var thenReturns = visitable.Then.Accept(This);

        if (visitable.Else is null)
            return false;
        var elseReturns = visitable.Else.Accept(This);

        return thenReturns && elseReturns;
    }

    public bool Visit(ReturnStatement visitable)
    {
        _returnStatements.Add(visitable);
        return true;
    }
}

public sealed record ReturnAnalyzerResult(
    bool CodePathEndedWithReturn,
    IReadOnlyList<ReturnStatement> ReturnStatements);

Ссылка на NuGet
Ссылка на GitHub

Итоги

В этой статье я показал 9 малоизвестных C# open source проектов, которые могут помочь при решении боевых задач коммерческой разработки по следующим направлениям:

  • Гео данные

  • Файлы и ENV

  • Базы данных

  • Регулярные выражения

  • Конвертация числа в текст

  • Разработка посетителей (visitors) в целях обработки данных

Ещё я веду Telegram канал StepOne, куда выкладываю много интересного контента о программировании на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT-индустрии!