Как написать свой сваггер и не пожалеть об этом

в 8:20, , рубрики: .net, C#, roslyn, кодогенерация, компилятор, Компиляторы, Проектирование и рефакторинг, рефакторинг, Совершенный код

image

Как-то раз моему коллеге в беклог упала задача «хотим организовать взаимодействие с внутренним REST-api так, чтобы любое изменение контракта сразу приводило к ошибке компиляции». Что может быть проще? – подумал я, однако работа с получившимся кактусом вынудила заняться многочасовым курениям документации, спуску от привычных концепций оверинжинеринга «налепим побольше интерфейсов, добавим максимум косвенности, и приправим всё это DI» до переезда на .Net Core, ручной кодогенерации промежуточного ассемблера и изучения нового компилятора C#. Лично я для себя открыл много интересного как в рантайме, так и в структуре самого компилятора. Думаю, некоторые вещие уже знают, а некоторые станут полезной пищей для размышления.

Акт первый: копипаста

Так как это была обычная типовая задача, а мой товарищ был не склонен думать долго над тем, что и так очевидно, то результат появился довольно быстро. REST-сервис был наш, на WCF, соответственно была введена общая сборка MyProj.Abstracitons, куда перекочевали интерфейсы сервисов. В ней же нам требовалось писать классы, которые реализовывали интерфейс сервиса и занимались проксированием запросов к нему и десериализацией результата. Идея была простая: на каждый сервис мы пишем по клиенту, который реализует тот же интерфейс, соответственно как только мы меняем любой метод в сервисе, у нас выскакивает ошибка компиляции. И мы предполагаем, что человек, меняя аргумент у функции проследит, чтобы она правильно сериализовывалась. Выглядело это примерно так:

public class FooClient : BaseClient<IFooService>
{
    private static readonly Uri _baseSubUri

    public FooClient() : base(BaseUri, _baseSubUri, LogManager.GetCurrentClassLogger()) {}

    [MethodImpl(MethodImplOptions.NoInlining)]
    public Task<Foo> GetFoo(int a, DateTime b, double c)
    {
        return GetFoo<Foo>(new Dictionary<string, object>{ {“a”, a}, {“b”, b.ToString(SerializationConstant.DateTimeFormat)}}, new Dictionary<string, object>{ {“c”, c.ToString(SerializationConstant.FloatFormat)}});
    }
}

Где BaseClient<TService> — это такая тоненькая обертка над HttpClient, которая определяет, какой метод мы пытаемся вызвать (GetFoo в данном случае), вычисляет его URL, посылает запрос, забирает ответ, десериализовывает результат (если надо) и отдает его.

То есть:

  • Наследуем BaseClient<TService>
  • Реализовываем все методы
  • Прописываем везде словари для всех аргументов, стараясь не ошибаться

В принципе, не сложно, оно даже работало, но после написания 20 метода у 30го класса, которые были абсолютно однотипными, люди постоянно забывали написать NoInlining, из-за чего все ломалось (Little quiz #1: как думаете, почему?), я себе задал вопрос «а нельзя ли как-то по-человечески к этому подойти?». Но, задача была уже вмержена в мастер, и сверху мне было сказано «иди фичи пили, а не фигней страдай». Однако, идея тратить по 3 часа в день на написание всяких врапперов мне совсем не нравилась. Не говоря про кучу атрибутов, то, что люди периодически забывали синхронизировать сериализацию со своими изменениями и всю подобную боль. Поэтому дожив до ближайших выходных и задавшись целью как-то улучшить ситуацию, за пару дней набросал альтернативное решение.

Акт второй: рефлексия

Идея тут была еще проще: что нам мешает делать всё то же самое, но не руками, а генерировать динамически? У нас совершенно однотипные задачи: взять входные аргументы, преобразовать их в два словаря, один для аргментов queryString, остальные как аргументы тела запроса, и просто вызвать какой-нибудь типовой HttpClient с этими параметрами. В итоге, все проблемы с теми же SerializationConstant решались тем, что они писались только один раз в этом обработчике, что позволяло их реализовать корректно единожды, и всегда радоваться правильному результату. После не очень продолжительного курения документации и stackoverflow, MVP был готов.

Теперь, для использования сервиса, просто:

  1. Создаем интерфейс
    public interface ISampleClient : ISampleService, IDisposable
    {
    }
  2. Пишем небольшую обёртку (исключительно для удобства дальнейшего использования):
    public static ISampleClient New(Uri baseUri, TimeSpan? timeout = null)
    {
        return BaseUriClient<ISampleClient>.New(baseUri, Constant.ServiceSampleUri, timeout);
    }
  3. Используем:

    [Fact]
    public async Task TestHelloAsync()
    {
        var manager = new ServiceManager();
        manager.RunAll(BaseAddress);
    
        using (var client = SampleClient.New(BaseAddress))
        {
            var hello = await client.GetHello();
    
            Assert.Equal(hello, "Hello");
        }
        manager.CloseAll();
    }

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

Как оно работает? На самом деле, на достаточно чёрной магии. Вот основной кусок, ответственный за генерацию прокси методов:

private static void ImplementMethod(TypeBuilder tb, MethodInfo interfaceMethod)
{
    var wcfOperationDescriptor = ReflectionHelper.GetUriTemplate(interfaceMethod);
    var parameters = GetLamdaParameters(interfaceMethod);
    var newDict = Expression.New(typeof(Dictionary<string, object>));
    var uriDict = Expression.Variable(newDict.Type); // словарь с аргументами queryString
    var bodyDict = Expression.Variable(newDict.Type); // словарь с аргументами в теле запроса
    var wcfRequest = Expression.Variable(typeof(IWcfRequest));
    var dictionaryAdd = newDict.Type.GetMethod("Add");

    var body = new List<Expression>(parameters.Length) // для обоих словарей генерируем выражения var dict = new Dictionary<...>
    {
        Expression.Assign(uriDict, newDict),
        Expression.Assign(bodyDict, newDict)
    };

    for (int i = 1; i < parameters.Length; i++)
    {
        var dictToAdd = wcfOperationDescriptor.UriTemplate.Contains("{" + parameters[i].Name + "}") ? uriDict : bodyDict; // в зависимости от того, идет параметр в uri считаем, что он параметр тела запроса либо урловый
        body.Add(Expression.Call(dictToAdd, dictionaryAdd, Expression.Constant(parameters[i].Name, typeof(string)),
            Expression.Convert(parameters[i], typeof(object)))); // добавляем в выбранный словарь аргумент
    }

    var wcfRequestType = ReflectionHelper.GetPropertyInterfaceImplementation<IWcfRequest>(); // в рантайме генерируем класс, реализующий все свойства интерфейса T, но добавляет к ним сеттер
    var wcfProps = wcfRequestType.GetProperties();
    var memberInit = Expression.MemberInit(Expression.New(wcfRequestType), 
        Expression.Bind(Array.Find(wcfProps, info => info.Name == "Descriptor"), GetCreateDesriptorExpression(wcfOperationDescriptor)),
        Expression.Bind(Array.Find(wcfProps, info => info.Name == "QueryStringParameters"), Expression.Convert(uriDict, typeof(IReadOnlyDictionary<string, object>))),
        Expression.Bind(Array.Find(wcfProps, info => info.Name == "BodyPrameters"), Expression.Convert(bodyDict, typeof(IReadOnlyDictionary<string, object>))));

    body.Add(Expression.Assign(wcfRequest, Expression.Convert(memberInit, wcfRequest.Type)));

    var requestMethod = GetRequestMethod(interfaceMethod); // определяем метод (GetResult или Execute), который нужно вызвать у процессора
    body.Add(Expression.Call(Expression.Field(parameters[0], "Processor"), requestMethod, wcfRequest));

    var bodyExpression = Expression.Lambda
        (
            Expression.Block(new[] { uriDict, bodyDict, wcfRequest }, body.ToArray()),
            parameters
        );

    var implementation = bodyExpression.CompileToInstanceMethod(tb, interfaceMethod.Name, MethodAttributes.Public | MethodAttributes.Virtual); // превращаем экпрешн в метод класса
    tb.DefineMethodOverride(implementation, interfaceMethod);
}

Little quiz #2

обратите внимание на строчку c ReflectionHelper.GetPropertyInterfaceImplementation<IWcfRequest>(). Как думаете, зачем она понадобилась? Рефлексия ради рефлексии, человеку интереснее писать код, который генерирует то, что он хочет, вместо того, чтобы просто его написать?

Основная суть тут в том, что мы с помощью Expression’ов генерируем тело метода, в котором мы все аргументы кладем либо в тело, либо в queryString, а потом используя расширение CompileToInstanceMethod компилируем его не в делегат, а сразу в метод класса. Делается это не очень сложно, хотя до получения рабочего варианта было проведено несколько десятков итераций, пока выкристализовался правильный:

internal static class XLambdaExpression
{
    public static MethodInfo CompileToInstanceMethod(this LambdaExpression expression, TypeBuilder tb, string methodName)
    {
        var paramTypes = expression.Parameters.Select(x => x.Type).ToArray();
        var proxyParamTypes = new Type[paramTypes.Length - 1];
        Array.Copy(paramTypes, 1, proxyParamTypes, 0, proxyParamTypes.Length);
        var proxy = tb.DefineMethod(methodName, MethodAttributes.Public | MethodAttributes.Virtual, expression.ReturnType, proxyParamTypes);
        var method = tb.DefineMethod($"<{proxy.Name}>__Implementation", MethodAttributes.Private | MethodAttributes.Static, proxy.ReturnType, paramTypes);
        expression.CompileToMethod(method);

        proxy.GetILGenerator().EmitCallWithParams(method, paramTypes.Length);
        return proxy;
    }
}

Самое печальное, что это еще относительно читаемый вариант, от которого пришлось отказаться после переезда на Core, потому что там убрали апишку CompileToMethod. Как следствие, можно генерировать анонимный делегат, но нельзя генерировать метод класса. А это-то нам и нужно было. Поэтому в коровской версии всё это заменяется на старый добрый ILGenerator. Типичный трюк, который я делаю в таком случае – просто пишу C# код, разбираю его ildasm’ом и смотрю, как он работает, в каких местах нужно подправить, чтобы покрыть общий случай. Если пытаться же писать IL самому, то в 99% случаев можно получить ошибку Common Language Runtime detected an invalid program :). Но в этом случае итоговый код понять намного тяжелее, чем относительно читаемые экспрешны.
Вопрос выпиливания этой апишки из кора обсуждается здесь (нас интересует первый пункт в списке), хотя реквест выглядит довольно мертвым. Но не все так плохо, ведь было найдено еще более хорошее решение!

Акт третий: под покровом компилятора

image
После переписывания и отлаживания всего этого дела в сотый раз я задался вопросом, почему нельзя всё это делать на этапе компиляции? Да, с помощью кэширования сгенерированных типов оверхед на использование клиентов ничтожный, мы платим всего лишь за вызов Activator.CreateInstance, что в контексте совершения целого HTTP-запроса мелочь, тем более, что их можно использовать как синглтон, т.к. никакого состояния кроме URL сервиса в нем нет. Но всё же, у нас тут прилично ограничений:

  1. Мы не можем посмотреть на сгенерированный код и подебажиться. В принципе, это и не нужно, т.к. он примитивный, но пока я не написал итоговый рабочий код приходилось о многом догадываться, почему работает не так, как задумано. В итоге: отлаживать динамические сборки в то еще удовольствие
  2. Клиент всегда обязан иметь тот же интерфейс, что и клиент. Когда это не удобно? Ну, например, когда сервер имеет синхронную апишку, но на клиенте она обязана быть асинхронной, ибо HTTP-запрос. И поэтому либо приходится блокировать поток и ждать ответа, либо делать все методы сервера асинхронными, заставлять сервис расставлять Task.FromResult где попало, даже если ему это не нужно.
  3. Избавиться от рефлексии в рантайме всегда приятно

Как раз в это время я слышал много интересного про Roslyn – новый модульный компилятор от Microsoft, который позволяет неплохо покопаться в процессе. Изначально я очень надеялся, что в нем как в LLVM можно просто написать middleware для нужной трансформации, однако после прочтения документации возникло впечатление, что на Roslyn полноценной кодогенерации без лишних телодвижений со стороны пользователя сделать нельзя: тут уж либо свой кастомный компилятор поверх (как например это сделано в проекте замены LINQ на циклы, но по понятным причинам это не очень удобно), либо анализатор в стиле «вы тут забыли запятую, давайте я вам её вставлю». И тут я наткнулся на интересный фич реквест в гитхаб репозитории языка на эту тему (тыц), но тут быстро обнаружилось две проблемы: во-первых до релиза этой фичи еще очень долго, а во-вторых мне достаточно быстро сказали, что она даже в рабочем виде мне никак не поможет. Хотя все было не так плохо, ибо в комментариях мне дали ссылку на интересный проект, который вроде бы должен был делать, что мне нужно.

Поковырявшись несколько дней и, освоив базовый проект, я понял – оно работает! И работает так, как нужно. Просто какая-то магия. В отличие от написания собственного компилятора поверх обычного, тут мы пишем обычный nuget-пакет, который можем просто подключить в решение, и он во время билда сделает своё черное дело, в нашем случае – сгенерирует код клиента для сервиса. Полная интеграция со студией, делать ничего не надо – лепота. Правда, подсветка после первой установки решения работать не будет, но после ребилда и переоткрытия солюшена будет и подсветка, и IntelliSense! Правда, работает не всё: например, как заставить показывать расширеную документацию от интерфейса при помощи <inheritdoc /> я так и не понял, студия почему-то просто не хочет этого делать. Ну да ладно, основное дело сделано – классы сгенерированны, они работают, и результат генерации всегда можно подсмотреть и поправить, устанавливается одним кликом через нугет. Всё, как мы хотели.

Для пользователя использование выглядит вот так:

image

Просто пишем интерфейс, вешаем пару атрибутов, компилируем, и можем пользовать сгенерированным классом. PostSharp не нужен! (шутка).

Итак, как же оно всё работает?

Акт четвертый: заключительный

Изначально я не собирался лезть глубоко, т.к. уже была готовая библиотечка, которая полностью отвечала моим требованиям, оставалось только написать анализатор и сделать пакет. Однако, реальность оказалась более жестокой, и ловя ошибки, то из-за моего неправильного использования предоставленного АПИ, то из-за ошибок или недоработок в самой библиотеки неизбежная расплата все же меня настигла. Пришлось и разбираться, и контрибьютить, чтобы в итоге все завелось как на картинке выше.

Практически вся соль, на самом деле, заключается в новом тулчейне .Net Core:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <PackageType>DotnetCliTool</PackageType>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp1.0</TargetFramework>
    <AssemblyName>dotnet-codegen</AssemblyName>
  </PropertyGroup>
</Project>

По сути это способ определять middleware при построении проекта. После этого компилятор понимает, что такое dotnet-codegen и умеет его вызывать. При сборке же проекта вы можете увидеть что-то подобное:
image

Как всё это работает, когда вы нажимаете билд (или даже просто сохраняете файл!):

  1. Есть GenerateCodeFromAttributes из сборки CodeGeneration.Roslyn.Tasks, который наследует Microsoft.Build.Utilities.ToolTask и определяет запуск всего этого добра во время сборки проекта. Собственно, работу этой таски мы и видели в output-окне чуть выше.
  2. Генерируется текстовый файл CodeGeneration.Roslyn.InputAssemblies.txt, куда пишется полный путь к сборке, которую мы собираем в текущий момент
  3. Вызывается CodeGeneration.Roslyn.Tool, который получает список файлов для анализа, входные сборки и т.п. в общем всё, что нужно для работы.
  4. Ну а дальше все просто, находим всех наследников интерфейса ICodeGenerator в проекте и вызываем единственный метод GenerateAsync, который генерируют нам код.
  5. Компилятор автоматически подхватит новые сгенерированные файлы из obj-директории и добавить их в результирующую сборку

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

Акт дополнительный: подведение итогов

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

image

И что немаловажно, я получил море удовольствия, реализуя всё это дело, а также неплохо, как мне кажется, прокачался в знании языка и компилятора. Поэтому и решил написать статью: может, новый сваггер миру и не нужен, но зато если вам нужна кодогенерация, Т4 вы презираете или он вам не подходит, а рефлексия не наш вариант, то вот – отличный инструмент, который просто выполняет свою работу, замечательно интегрируется в текущий пайплайн и в итоге распространяется просто как нугет-пакет. Да еще и подсветка от студии в комплекте! (но только после первой генерации и переоткрытии солюшена).
Сразу скажу, что я не пробовал этот процесс с не-core проектами, со взрослым фреймворком, возможно там будут какие-то сложности. Но учитывая, что таргеты этого пакета включают в себя portable-net45+win8+wpa81, portable-net4+win8+wpa81 и даже net20, то особых сложностей быть не должно. А даже если что-то не нравится, лишние зависимости или там NIH – всегда можно сделать свою, более кошерную, реализацию, благо кода так уж и много. Другой подводный камень — отладка, как дебажить всё это добро я не разобрался, код писался вслепую. Но автор родной библиотеки CodeGeneration.Roslyn определенно обладает нужными знаниями, достаточно посмотреть на структуру проекта, просто в итоге я обошелся без них.

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

Ссылки:

Все мои проекты под MIT-лицензией, форкайте-изучайте-ломайте как хотите, никаких претензий не имею :)

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

Ну и ответы на вопросы, конечно же:

  1. MethodImplOptions.NoInlining используется для того, чтобы определить имя метода, который мы должны вызывать. Т.к. большинство методов достаточно простые, многие – буквально однострочные, то компилятор любит их инлайнить. Как известно, компилятор инлайнит методы с телом меньше 32 байт (есть еще куча условий, но не будем на этом заострять внимание, тут они все выполнялись), поэтому можно было видеть забавный баг, что методы с большим количеством аргументов успешно вызываются, а с малым – бросают ошибку в рантайме, т.к. мы доходим до самого верха коллстека, не находя нужного метода:
        MethodBase method = null;
        for (var i = 0; i < MAX_STACKFRAME_NESTING; i++)
        {
            var tempMethod = new StackFrame(i).GetMethod();
            if (typeof(TService).IsAssignableFrom(tempMethod.DeclaringType))
            {
                method = tempMethod;
                break;
            }
        }
  2. Дело в том, что когда я писал метод с рефлекшном, я не особо задумывался, что мы добавляем классы не в текущую сборку RemoteClient.Core, а в динамически создаваемую. А это очень важно. В итоге, после тестирования всего функционала и получения уверенности, что всё это работает, я увидел, что мой класс WcfRequest является публичным. «Непорядок» — подумал я – «Имплементация должна быть приватной, а видимым должен быть только интерфейс». И поставил атрибут internal. И всё сломалось. Ну и достаточно просто понять, почему, мы генерируем сборку A.Dynamicalygenerated.dll, которая пытается инстанцировать internal класс в родительской сборке A.dll и закономерно падает с ошибкой доступа. Ну, и это не считая того, что у нас получается неприятная циклическая зависимость между сборками. В итоге, динамическая генерация «класса-пустышки», который просто добавляет сеттеры ко всем свойствам, оказалась и достаточно простым решением, и одновременно удобным в том плане, что теперь у нас есть прямая зависимость от A.dll к сгенерированной сборке и никаких хвостов в обратную сторону.

Автор: PsyHaSTe

Источник

Поделиться

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