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();
    }


Дисклеймер

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


Всё очень просто, понятно, не требует особых магий вроде наследования специальных классов или навешивания атрибутов. Переменные и имена методов выводятся автоматически. В общем, красота. Тем более, что пункт 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 к сгенерированной сборке и никаких хвостов в обратную сторону.

Комментарии (28)


  1. mayorovp
    27.11.2017 11:58

    А что мешало использовать обычный ServiceContractGenerator раз уж у вас как клиент так и сервис WCF используют?


    1. PsyHaSTe Автор
      27.11.2017 12:17

      Потому что у нас использовался REST-api. Описание REST-api возможно только в WADL или WSDL 2.0, но WCF их не поддерживает.


      1. mayorovp
        27.11.2017 12:25

        Так ведь для генерации вроде же WSDL не нужен, достаточно ServiceDescription? WSDL для WCF — лишь формат передачи ServiceDescription, но у нас-то все классы рядом…


        1. PsyHaSTe Автор
          27.11.2017 12:53

          Хм, не знал. Но после беглого прочтения документации похоже, что этим можно было бы заменить мою реализацию генерации клиента. Однако, тут у нас возникает проблема получения метаданных. Если посмотреть пример, то нам нужно заполнить класс ContractDescription, на основании которого он сгенерирует там клиент. В принципе, это может сработать, но тогда возникает проблема зависимости от System.ServiceModel.dll на стороне клиента. Либо билд одного проекта будет класть сгенерированные *.cs-файлики в другой проект, который и будет "настоящей" сборкой с клиентами. Но, с этой библиотекой это невозможно, я с автором обсуждал, его позиция заключается в том, что это поведение "by design": сборка должна быть самодостаточной, и не должна модифицироваться при билде каких-либо других проектов.


          Таким образом, принципиально это возможно, но могут возникнуть сложности не меньше, чем те, что мы пытались избежать, воспользовавшись функционалом "из коробки".


          1. mayorovp
            27.11.2017 13:22

            Хм, а вы на клиенте все-таки не WCF используете? Ну тогда понятно почему пришлось так много генерировать…

            Хотя я все еще не понимаю чем ситуация с кодогенерацией отличается для ServiceContractGenerator и для того что получилось у вас. И в том и в другом случае генерируются какие-то .cs-файлы в другом проекте.


            1. PsyHaSTe Автор
              27.11.2017 14:56

              Ну, генерировать немного, результат выглядит примерно так:


              namespace Clients
              {
                  using System;
                  using System.Collections.Immutable;
                  using System.Threading.Tasks;
              
                  public sealed class MyCoolRestServiceClient : DisposableBase, IDisposable
                  {
                      private readonly IRemoteRequestProcessor processor;
                      public MyCoolRestServiceClient(IRemoteRequestProcessor processor)
                      {
                          this.processor = processor ?? throw new ArgumentNullException("processor");
                      }
              
                      protected override void Dispose(bool disposing)
                      {
                          processor.Dispose();
                      }
              
                      public Task<string> GetMessage(string hello, string world)
                      {
                          if (IsDisposed)
                              throw new ObjectDisposedException(this.GetType().FullName);
                          var queryStringParamters = ImmutableDictionary.CreateBuilder<string, object>();
                          var bodyParamters = ImmutableDictionary.CreateBuilder<string, object>();
                          queryStringParamters.Add("hello", hello);
                          bodyParamters.Add("world", world);
                          var descriptor = new RemoteOperationDescriptor("GET", "/{hello}", OperationWebMessageFormat.Xml, OperationWebMessageFormat.Xml);
                          var request = new RemoteRequest(descriptor, queryStringParamters.ToImmutable(), bodyParamters.ToImmutable());
                          return processor.GetResultAsync<string>(request);
                      }
                  }
              }

              Собственно тут уже всё есть. Нужен только HttpClient или любой другой хэндлер, хоть HttpWebRequest, хоть что. Никакого WCF со стороны клиента.


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


              1. mayorovp
                27.11.2017 15:29

                Так, а какое отношение ServiceContractGenerator имеет к референсам проекта?

                Разумеется, после кодогенерации должна получиться отдельная клиентская библиотека. Почему вы думаете что это невозможно без референса на WCF?


                1. PsyHaSTe Автор
                  27.11.2017 16:33

                  Если генерируется отдельная библиотека, то это нехорошо тем, что у нас на выходе Service.dll будет что-то вроде Service.Client.dll, мы либо хардкодим имя результирующей сборки, либо должны как-то в атрибутах говорить "ты компилируйся вон туда, а ты — вон туда". У нас также должны будут быть вот эти пустые сборки, куда будут копироваться файлы, и на которые нужно будет повесить красную табличку "НЕ УДАЛЯТЬ. НУЖНО!".


                  В противовес этому сборки, в которых интерфейс и клиент под них лежат вместе. Может, это не всегда нужно, и мы распространяем клиенты в обязательном порядке, но мне кажется это меньшим злом. Сломать тут очень трудно что-то. Есть атрибут — генерируем, нет — нет. В случае выше, например, тут и вопрос сборок (удалили/неудалили), неймингов, неправильно прописанного пути (опечатались, и вместо AbcbdSas.dll написали AbcbbSas.dll и всё) ...


                  В итоге, это возможный способ, но как мне кажется, менее удачный.


                  Ну и как бонус, этот код очень легко заставить работать, например, с ASP.Net, достаточно научить вместо WebInvoke использовать атрибут Route.


                  1. mayorovp
                    27.11.2017 16:44

                    Я вас не понимаю. Только что вы говорили что у клиентской сборки не должно быть зависимости от ServiceModel — а тут вдруг говорите что интерфейс должен быть в той же самой сборке… Или что вы понимаете под интерфейсом?


                    Вообще, включить автогенерированный код в ту же самую сборку очень просто. Надо сначала сгенерировать отдельную сборку — а потом воспользоваться ILMerge и все.


                    Ничуть не сложнее сделать нормальную отдельную клиентскую сборку: делается отдельный проект, и в нем переопределяется цель BeforeCompile, где и вызывается генератор. И тут же удаляется референс на серверную сборку если он был (есть вариант и без него обойтись, но тогда поломаются "смешанные" конфигурации в решении). Ну да, есть возможность что клиентский проект окажется "пустым" (т.е. не будет содержать никаких файлов кроме автогенерированных) — но кто его будет удалять если он прописан в референсах у кучи других проектов?


                    Что же до хардкода и опечаток — тут я совсем не понимаю в чем же, собственно, проблема? Почему <AssemblyName>Service</AssemblyName> в файле проекта вас устраивает, а <AssemblyName>Service.Client</AssemblyName> — уже хардкод и нехорошо?


                    1. PsyHaSTe Автор
                      27.11.2017 17:34

                      Я вас не понимаю. Только что вы говорили что у клиентской сборки не должно быть зависимости от ServiceModel — а тут вдруг говорите что интерфейс должен быть в той же самой сборке… Или что вы понимаете под интерфейсом?

                      Мне желательно, чтобы вся генерация проекте Х ограничивалась проектом Х, и обеспечить при этом отсутствие зависимостей на внешние сборки-генераторы. У нугета для этого есть удобная опция developmentDependency.


                      Вообще, включить автогенерированный код в ту же самую сборку очень просто. Надо сначала сгенерировать отдельную сборку — а потом воспользоваться ILMerge и все.

                      Ничуть не сложнее сделать нормальную отдельную клиентскую сборку: делается отдельный проект, и в нем переопределяется цель BeforeCompile, где и вызывается генератор. И тут же удаляется референс на серверную сборку если он был (есть вариант и без него обойтись, но тогда поломаются "смешанные" конфигурации в решении). Ну да, есть возможность что клиентский проект окажется "пустым" (т.е. не будет содержать никаких файлов кроме автогенерированных) — но кто его будет удалять если он прописан в референсах у кучи других проектов?

                      Можно. Но мне кажется, это сложнее.


                      Что же до хардкода и опечаток — тут я совсем не понимаю в чем же, собственно, проблема? Почему Service в файле проекта вас устраивает, а Service.Client — уже хардкод и нехорошо?

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


                      Под хардкодом я предположил, что для того, чтобы положить классы в разные сборки мы будем писать имя сборки в атрибуте, типа [RemoteClient("InternalApi.dll")] и [RemoteClient("PublicApi.dll")]. В моем случае мы пишем 2 сборки — InternalApi и PublicApi и там пишем необходимые интерфейсы. В вашем случае генерируются три, в случае, если мы пишем имя итоговой сборки в атрибуте, либо четыре: InternalApi, PublicApi, InternalApi.Clients, PublicApi.Clients.


                      То есть если с точки зрения реализации смотреть, то может это удобнее. С точки зрения использования это приводит к удвоению количества сборок. Да, мы можем ILMerge'ом помержить в одну сборку если захотим, но меня больше беспокоят пустые проекты в солюшене, которые ни в коем случае нельзя удалять.


                      1. mayorovp
                        27.11.2017 17:46

                        Мне желательно, чтобы вся генерация проекте Х ограничивалась проектом Х, и обеспечить при этом отсутствие зависимостей на внешние сборки-генераторы.

                        Я понял что вам нужно, но я не понимаю с чем вы спорите.


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

                        А я что, предлагаю подкладывать файлы в чужие проекты?


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

                        Ну блин, что вам все-таки нужно? Вот три варианта:


                        1. все помержили в одну сборку, проект тоже один — этот вариант вам не нравится потому что зависимость от WCF;
                        2. две сборки, которые генерирует один проект — этот вариант вам не нравится потому что хардкод и, наверное, потому что студия такое плохо понимает;
                        3. две сборки и два проекта — этот вариант вам не нравится потому что второй проект — "пустой".

                        Но больше-то вариантов нет в принципе! Я не понимаю какой из вариантов у вас сделан (в статье не написано никакой конкретики) — но явно один из приведенных выше. Почему возражение, которое вы мне приводите, не применимо к вашему коду?


                        1. PsyHaSTe Автор
                          27.11.2017 18:01

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


                          1. mayorovp
                            28.11.2017 07:01

                            Все еще не понимаю что именно вы сделали.


  1. Pilat
    27.11.2017 20:35

    В который раз у меня в голове один вопрос: REST придумали как замену SOAP. И к чему всё свелось в итоге? Недо-SOAP построен. Одна проблема — работает так себе.


    1. PsyHaSTe Автор
      27.11.2017 20:50

      REST работает отлично, проблема в том, что инструмент (в данном случае WCF) не поддерживает стандарт, который позволяет обмениваться метаданными не только для SOAP. Думаю, если бы WSDL 1.0 поддерживал REST и не поддерживал SOAP это утверждение спокойно можно было развернуть обратно.

      Но, как сказано в статье, не все так плохо — берете сваггер и он вам генерирует что угодно, быстро и без проблем. Ну или берете такой вот генератор и у вас все из коробки. Ведь все, что есть у SOAP — это количество инструментов, которые его поддерживают. Логично, что у (сравнительно) новой технологии инструментов будет поменьше. Но это не беда технологии, инструменты появятся, как только она докажет свою жизнеспособность. Пример выше — это как раз такая тулза. Просто ставите нугет-пакет и все заводится из коробки. Что там под капотом — да какая разница? Главное, что работает, и работает хорошо.


  1. PsyHaSTe Автор
    27.11.2017 20:50

    del


  1. host13
    27.11.2017 23:17

    По-моему проще просто прогонять тесты от предыдущей версии. Если упали — значит сломана обратная совместимость


    1. PsyHaSTe Автор
      27.11.2017 23:18

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


  1. smind
    28.11.2017 06:38

    не понимаю почему вы свой исходный код так небрежно форматируете. Это некрасиво.


    1. ad1Dima
      28.11.2017 11:12

      А что конкретно вас смущает?


  1. trickytoots
    28.11.2017 12:39

    Сорри, но выглядит как какой-то адский оверкил.
    Разве того же самого нельзя добиться простым проксированием в рантайме?


    1. PsyHaSTe Автор
      28.11.2017 12:43

      При проксировании в рантайме нельзя добиться ошибки компиляции, если сервис обновился.


      1. trickytoots
        28.11.2017 23:49

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


        1. PsyHaSTe Автор
          29.11.2017 12:09

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


          1. trickytoots
            29.11.2017 22:59

            Сории за жаву, последний раз на сишарпе очень давно писал, вот пример:

            tpcg.io/Aw5FAU

            Service это чистый клиентский интерфейс.
            ServiceImpl это реализация сервиса с хттп маппингом.
            Фабрика генерит проксю удовлетворяющую интерфейсу Service и занимающуюся конвертацией параметров в пригодный для реализации вид, будь это хттп или еще какой-то протокол, главное чтобы оно конфигурилось атрибутами.

            Клиент зависит от Service интерфейса, когда интерфейс меняется, код перестает компилиться.

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


            1. PsyHaSTe Автор
              30.11.2017 17:49

              Ну, вы написали эквивалент примера №2. Недостатки у него, соответственно, те же:


              1. клиент обязан реализовывать тот же интерфейс, что и сервис
              2. нельзя понять, что что-то не так, пока вы не запустите программу. Можно конечно в начале проверять все методы всех клиентов всех сервисов, но это не очень удобно, и увеличивает время билда/запуска с каждым новым сервисом.


              1. trickytoots
                30.11.2017 22:36

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

                1. Это да, ок. С другой стороны, мы же не знаем, какой апи хочет конкретный клиент. По идее, если клиенту надо, он докрутит там где надо, и тут уже нет принципиально разницы, докрутит ли он правила кодогенерации чтобы получить особенную версию клиента, либо же завраппит существующий клиент нужным ему образом непосредственно в коде.
                2. Я не совсем понимаю, что именно может сломаться? Эта реализация исключительно рантаймовая, просто общим образом реализуется уже существующий интерфейс.
                Добавляется новый метод в интерфейс — оно автоматом подхватывает новый метод и продолжает работать. Меняется апи — ошибка компиляции во всех местах где использовалось старое апи.

                Единственное что, рефлексия, почему-то некоторые ее не любят. =)


                1. PsyHaSTe Автор
                  01.12.2017 12:08

                  1. Ну у меня ломалось, например, когда у метода класса не было атрибута, в вашем случае если у интерфейса нет @Method("GET"). В таком случае генерация не знает, что с этим делать, и падает. В вашем случае — в рантайме. В общем, когда меняется что-нибудь, что не является частью сигнатуры метода, но тоже необходимо. Атрибут это отличный пример этого "нечта".