Злое обезьянничанье в C# с помощью генераторов исходных текстов Rosyln

Недавно я работал над проектом OSS, где мне нужно было беспрепятственно перенаправить вызов, который, по мнению разработчика, используется для выполнения некоторых дополнительных битов. Я не смог найти никакой реальной документации по этому вопросу, поэтому я решил изучить некоторые способы сделать это. Отдельное спасибо Марку Рендлу за то, что помог мне быть злым.

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

Марк Рендл

Что такое Monkeypatching?

Если вы не знакомы с термином Monkey patching, то это процесс изменения некоторого кода «на лету», когда автор кода не обязательно хотел такого поведения. Однако это не то, что вы можете сделать в C# (или .NET) в целом. Существуют некоторые библиотеки, такие как Harmony, которые могут делать это частично, но они основаны на запуске вашего кода и выполнении исправлений библиотек. Общая предпосылка заключается в том, что вы хотите перенаправить вызов между методом, который, по мнению кода, он собирался вызвать, и каким-то другим методом. Это может быть невероятно полезно, если вы хотите изменить/исправить функциональность чего-то, что вы не контролируете. Халид (@buhakmeh) написал статью о некоторых из этих подходов здесь, а мы рассмотрим альтернативный вариант, который имеет очень узкое применение.

Что такое генераторы исходников Roslyn?

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

Код

Здесь есть пример кода, и каждый коммит показывает различные фазы. В этом базовом примере мы перенаправим наш собственный код, использующий System.Console, на наш новый PrefixConsole. Наш новый класс добавляет «WithPrefix: » перед всеми строками записи нашей консоли.

Фаза 1 — Мы ненавидим себя

Очевидно, что это не реальный пример того, что сделал бы кто-то в реальном мире (если только он не НАСТОЛЬКО злой и не ненавидит себя). Мы доказываем, что можем перенаправить вызов в нашем коде от одного типа (в данном случае класса Console из Framework) к другому типу (PrefixConsole). Вскоре мы перейдем к чему-то более интересному.

Сначала мы создадим новое чистое консольное приложение.

dotnet new console && dotnet run

Hello World!
Вход в полноэкранный режим Выход из полноэкранного режима

Далее наш новый класс Console Prefixer, вы увидите, что это обычный класс C#, ничего особенного. Затем мы будем внутренне вызывать класс System.Console. Здесь важно полное пространство имен, иначе вы окажетесь в бесконечном цикле.


public static class PrefixConsole
{
    public static void WriteLine(string text)
    {
        System.Console.WriteLine("WithPrefix: " + text);
    }
}

Вход в полноэкранный режим Выход из полноэкранного режима

Затем мы добавим в наш класс функцию using, которая будет переопределять использование.


using Console = monkeypatch_test.PrefixConsole;

Войти в полноэкранный режим Выйти из полноэкранного режима

Итак, это неплохо. Мы направляем на это наш собственный класс, поэтому мы можем видеть, где это происходит, и довольно очевидно, куда мы посылаем вызовы. Когда мы используем IDE для перехода к определению, оно приведет нас к нашему классу PrefixConsole, так что здесь нет ничего сомнительного, просто небольшая непрямая связь, которую, вероятно, не нужно было делать…

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

Фаза 2 — Мы ненавидим своих товарищей по команде (Глобальное использование)

Итак, теперь давайте уберем это выражение using, так как оно слишком очевидно, что мы делаем. Кроме того, мы хотим, чтобы ВСЕ использования Console были снабжены префиксами, а нам лень заходить в каждый файл класса и делать это. Поэтому давайте воспользуемся глобальным using.

Глобальные операторы using появились в C# 10. Они позволяют действительно сократить объем наших файлов. Если вы уже использовали шаблоны Razor, вы делали нечто подобное, добавляя все состояния using в View_start.cshtml.

Мы добавим этот Globals.cs, но имя не имеет значения. Затем удалите наш оператор using из файла.


global using Console = monkeypatch_test.PrefixConsole;

Вход в полноэкранный режим Выйти из полноэкранного режима

Это направит все ссылки на Console (которые не являются полностью квалифицированными) на нашу новую PrefixConsole.

Итак, теперь все становится плохо. Из кода вашего класса не очевидно, что вас перенаправляют. Однако, по крайней мере, IDE направит вас к классу для записи.

Итак, теперь давайте сделаем все еще хуже.

Фаза 3 — Мы ненавидим всех (генераторы исходных текстов)

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

Однако если он находится в пакете NuGet, людям приходится добавлять в свое решение досадную строчку кода, чтобы сделать это перенаправление. Это довольно плохо, почему мы хотим, чтобы люди, использующие нашу библиотеку, писали БОЛЬШЕ строк кода?

Так что давайте воспользуемся генератором исходников, чтобы сделать это перенаправление, которое РЕАЛЬНО запутает людей и сделает нас популярными повсюду.

Во-первых, давайте перенесем этот класс в нашу новую консольную библиотеку под названием EvilConsolePrefixer (потому что именно здесь мы становимся немного злыми). Мы также можем удалить наш Globals.cs.

Затем мы можем добавить пакеты SourceGenerator в нашу новую библиотеку.

dotnet add package Microsoft.CodeAnalysis.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp.Workspaces
Вход в полноэкранный режим Выход из полноэкранного режима

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


[Generator]
public class EvilConsolePrefixerGenerator : ISourceGenerator
{
    public void Execute(GeneratorExecutionContext context)
    {
        context.AddSource("Globals", "global using Console = EvilConsolePrefixer.PrefixConsole;");
    }
    public void Initialize(GeneratorInitializationContext context)
    {
    }
}

Вход в полноэкранный режим Выйти из полноэкранного режима

Это добавит исходный файл в конвейер компиляции с одной строкой, которая сделает наше перенаправление. Почему именно так, а не просто добавить глобальный using в класс EvilConsolePrefixer? Глобальные usings (и usings вообще) привязаны к проекту, поэтому они не перейдут в вызывающий проект. Использование SourceGenerator таким образом означает, что наше глобальное использование будет добавлено в основной проект, как если бы это был код, написанный разработчиком.

Остается только добавить проект EvilConsolePrefixer в качестве ссылки на наш основной проект. Поскольку мы делаем это локально (т.е. не через NuGet), нам потребуется добавить дополнительный атрибут к импорту. Это не требуется, если мы используем пакет NuGet.


  <ItemGroup>
    <ProjectReference Include="..EvilConsolePrefixerEvilConsolePrefixer.csproj" OutputItemType="Analyzer"/>
  </ItemGroup>

Вход в полноэкранный режим Выйти из полноэкранного режима

Дополнительный атрибут — OutputItemType="Analyzer".

Что делает это зло, спросите вы?

  1. Марк Рендл сказал, что это зло и я не должен этого делать.
  2. Мы не должны перенаправлять вызовы, это делает код трудным для рассуждений, поскольку ваш контекст не верен.
  3. Подобное перенаправление во время компиляции может нарушить компиляцию вашего пользователя, поскольку ваш новый код может не иметь всех методов и свойств, которые были у исходного класса.
  4. IDE направляет пользователя к исходному классу, а не к тому, который вводится во время компиляции.
  5. Декомпиляция решения будет выглядеть так, как будто разработчики напрямую ссылались на ваш код, что на самом деле неверно.

Зачем же вам это делать, Мартин?

Я столкнулся с этим, когда пытался найти способ добавить shim в запечатанный класс из Microsoft BCL. Целью было создать пакет, который позволил бы людям, использующим этот класс, легко получить обертку без необходимости изменять свой код.

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

Кроме того, то, что я хотел использовать в своем новом коде, это [CallerLineNumber] и [CallerFilePath], которые должны быть сделаны во время сборки, поскольку это единственное время, когда они доступны (без PDB). Вы не можете, например, использовать существующий интерфейс без этих свойств и просто добавить их в класс, реализующий интерфейс.

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

Оцените статью
Procodings.ru
Добавить комментарий