О чем не пишут в документации, или тонкости рефакторинга на .Net Core

    Всем привет! Этим материалом мы открываем цикл из нескольких статей, посвященных длинной истории о том, как мы пришли с одной стороны к CD, а с другой — к high availability, основанной на избыточности.


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


    И первым шагом мы переводим его на .NET Core и делимся с вами тонкостями, которые встретились нам на этом пути.



    Несколько фактов о нашем web API:


    • 110 методов,
    • сервис push уведомлений,
    • сервис управлениями баннерам,
    • 35K запросов в день.

    Задача очень простая и понятная — построить конвейер CD, так как наше приложение быстро и динамично развивается, и нам нужно руководствоваться принципом “done значит released”.


    Как это сейчас развернуто на текущей продуктовой среде:


    К чему идем:


    Как мы будем это делать:


    • Разрабатываем методику автоматического тестирования, покрываем тестами и включаем в процесс сборки,
    • Переводим наш сервис на .NET Core,
    • Настраиваем сборку в Docker контейнер (под Linux — ну не зря же мы на Core замахнулись),
    • Разворачиваем кластер Kubernetes в продуктовой и тестовой средах,
    • Заезжаем в них.

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


    Начинаем с перехода с ASP.NET Web Api 2 на ASP.NET Core 2 для core-части мобильного сервиса (пока без пушей и баннеров. С ними разберемся чуть позже, если будут подводные камни — расскажем в отдельной статье).


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


    Наш план рефакторинга мобильного сервиса выглядит следующим образом:


    1. Создаем в Visual Studio 2017 новое решение


    • Проект ASP .NET Core Web Application с темплейтом Web Api для основного проекта.
    • Class Library (.NET Standard) для вспомогательных проектов.

    2. Подключаем внешние зависимости


    2.1 Подключаем WCF сервисы


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


    То, что раньше называлось Service References, теперь именуется Connected Services. Добавляем в проект WCF Web Service Reference через меню Add Connected Service.


    Поскольку в ASP.NET Core теперь нет Web.config-файлов, все настройки WCF клиента хранятся в сгенерированном коде Reference.cs.


    В классе клиента предусмотрен метод для ввода дополнительных настроек клиента:


    static partial void ConfigureEndpoint(ServiceEndpoint serviceEndpoint, ClientCredentials clientCredentials)


    Пишем реализацию partial метода. В нашем случае в этом методе прописаны Credentials для авторизации в сервисе.


    Помимо изменений в настройках клиента второе серьезное изменение — в клиенте больше нет синхронных методов. У нас async-вариант использовался сразу.


    2.2. Подключаем nuget-пакеты TimeZoneConverter, Swashbuckle, Mime, XmlSerializer.Generator

    С переносом пакетов TimeZoneConverter, Swashbuckle, Mime никаких проблем не возникло.


    Поговорим о находках. При использовании стандартного Xml-сериализатора есть важный момент: кодогенератор запускается в рантайме при первом использовании. Соответственно, это увеличивает время холодного старта. Такое поведение можно обнаружить, если в студии у вас отключен Just My Code. Беглый поиск по интернету вывел нас на nuget-пакет XmlSerializer.Generator, который пришел на замену sgen и поддерживает минимальные Net Core 2 и Net Standard 2. Его предназначение — генерация кода xml сериализатора в compile-time.


    С удовольствием добавляем его в проект. Пакет автоматически включается в последовательность сборки проекта.


    Из ограничений:


    а) Генератор не умеет резолвить названия классов с учетом namespace, поэтому придется избавиться от дублирования названий DTO-классов, если таковые имеются.


    b) Ленивые люди используют xmltocsharp.azurewebsites.net для генерации DTO-классов из XML описания. Онлайн-сервис грешит повсеместной расстановкой XmlRoot-атрибутов. Генератор обижается на такое поведение. Огнем и мечом выкашиваем из ненужных мест XmlRoot.


    3. Транслируем Global.asax в Startup.cs.


    • Используем стандартный IoC контейнер.
    • HttpHandler в .NET Core заменены на middleware, переписываем их. Фильтры остались фильтрами.
    • Поскольку мы отказались от IIS, настраиваем аутентификацию и Directory Browsing средствами ASP.NET.
    • Подключаем для логирования стандартный логгер через ILoggerFactory. Планируем выбросить NLog и использовать Serilog для ELK.

    4. Настраиваем конфигурации


    4.1. Настраиваем окружение через IHostingEnvironment.Environment


    Мы планируем на выходе билда получать единственный докер-образ и пропускать его в неизменном виде через все среды тестирования. Настройка окружения должна полностью управляться через переменную окружения ASPNETCORE_ENVIRONMENT. Поэтому по максимуму избавляемся от условной компиляции в коде и смотрим на значение IHostingEnvironment.Environment.


    4.2. Переносим блок AppSettings из в Web.{configuration}.config в AppSettings.{environment}.json.


    4.3. Прописываем для WCF сервисов конфигурацию для окружений Dev, Test, Stage, Release.


    5. Убираем зависимость от HttpContext


    HttpContext синглтон сыграл в ящик, на смену ему пришел IHttpContextAccessor.


    Вот 3 вещи, которые были затронуты таким изменением:


    • HttpContext.Current.AddErrors использовался для сквозного сбора различных ошибок в ходе обработки конкретного реквеста на тестовом сервере. Все собранные ошибки затем в специальном HttpHandler записывались в warning_message в теле респонса, что позволяло быстрее диагностировать проблемы.

    Вместо HttpContext.Current.AddErrors будем пользоваться IHttpContextAccessor.HttpContext.Items


    • HttpContext.Current.HttpContext.Timestamp использовался для
      a. замера времени реквеста,
      b. тегирования операций, связанных с определенным реквестом, в логах.

    С увеличением количества запросов стали часто сталкиваться с перекрытием запросов по Timestamp, т.е. один и тот же тег использовался для разных запросов. В NET Core доступно свойство IHttpContextAccessor.HttpContext.TraceIdentifier — действительно уникальный идентификатор.


    • Теперь для получения IP не нужно использовать никакой магии, все находится в одном месте — IHttpContextAccessor.HttpContext.Connection.RemoteIpAddress.

    6. Переносим код контроллеров

    6.1. ASP NET WebApi был поглощён в ASP NET MVC.


    Затронуты маршрутизация, биндинг, negotiation, исчезли многие классы — начиная с ApiController и далее.


    Для того, чтобы обойтись минимальной кровью при переносе кода контроллеров, есть workaround в виде nuget-пакета WebApiCompatShim, который эмулирует концепции Web Api на базе MVC.


    Мы же решили сразу отказаться от прослойки и пользоваться чистым MVC со своими костылями, чтобы почувствовать боль и явно понимать, какой объем работы предстоит сделать, для того, чтобы в скором светлом будущем привести все к надлежащему виду по последнему слову ASP NET Core. Как оказалось, все совсем не грустно.


    • Меняем ApiController на Controller.
    • Для expires и cache-control респонс-хедеров используем ResponseCache-атрибут из коробки.
    • Для кастомных реквест-хедеров ASP NET Core может нас порадовать уже реализованным FromHeader-атрибутом.
    • Биндинг FromUri заменяем на FromQuery.

    6.2. Пишем свой HttpResponseException


    В проекте по старинке в action возвращается результирующий объект вместо IActionResult. В .NET Core, о горе, убрали HttpResponseException, объясняя тем, что разработчики платформы заботятся о правильном использовании их детища и подсказывают нам не использовать исключения для логики запросов — bad request, unauthorized и т.д…


    Договорившись с совестью, откладываем рутину на потом и пилим свой HttpResponseException и ActionFilter для него, ибо в рамках быстрого перехода переписывать все на IActionResult слишком долго. Да и к тому же, придется в каждом методе указать ProducesResponseType атрибут, по которому сваггер будет понимать класс результата для action и генерировать документацию.


        public class HttpResponseException : Exception
        {
            public int StatusCode { get; private set; }
            public string ContentType { get; private set; } = "text/plain";
    
            public HttpResponseException(int statusCode)
            {
                StatusCode = statusCode;
            }
    
            public HttpResponseException(int statusCode, string message) : base(message)
            {
                StatusCode = statusCode;
            }        
        }
    
        public class HttpResponseExceptionFilter : IActionFilter
        {
            public void OnActionExecuting(ActionExecutingContext context)
            {
            }
    
            public void OnActionExecuted(ActionExecutedContext context)
            {
                if (context.Exception is HttpResponseException)
                {
                    var ex = (HttpResponseException)context.Exception;
                    context.Result = new ContentResult() {
                        StatusCode = ex.StatusCode,
                        Content = ex.Message,
                        ContentType = ex.ContentType
                    };
                    context.ExceptionHandled = true;
                }   
            }
        }

    Для методов загрузки и отправки файлов сделали исключение: здесь по-честному переписали с HttpRequestMessage и HttpContent на FileContentResult и IFormFile, иначе никак нельзя.


    7. Документация


    • Включаем сборку XML документации.
    • Обновляем фильтры и атрибуты сваггера Swashbuckle.

    P.S. Не скажем, что рефакторинг был долгим по времени, но все же потребовал немало усилий. Этот опыт теперь с нами (и с вами) и в следующий раз мы c вами сможем пройти этот путь быстрее.


    В следующей серии поделимся находками по настройке сборки в Docker-контейнер. Как говорится, «не переключайтесь».

    EastBanc Technologies 235,65
    Компания
    Поделиться публикацией
    Комментарии 15
    • 0

      Тоже недавно портировал api на Core…
      А Вы не используете механизм Delta< T > для update методов из Web API OData? Мне кажется без него жизнь-боль в условиях, когда клиенты шлют динамический набор полей входящего класса с обновленными значениями, и надо либо гемороиться с проверками какие поля таки надо обновлять, а какие null просто потому что их не прислали, либо использовать Delta< T >
      Но в core версии Odata он всё еще поломан, и я крайне огорчён сим фактом ((


      Eщё SignalR только в процессе портирования. Бета версия только есть… но минимум кое как работает.
      EF7 немного поменялся синтаксис, но не критично. Остальное скопировалось копипастой и работает. Ради интереса запустил на убунте в том числе в связке с линукс версией MSSQL. Огонь ))

      • +1

        Не пробовали https://habrahabr.ru/post/319996/ ?

        • 0

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

          • 0
            Как вы формируете patch запрос? Я использую Microsoft.OData.Client и он это не умеет.
            • 0

              при работе с классом Delta в запросе никакой необычности нет, обычный json


              {"id":1,"firstName":"новое имя","lastName":"новая фамилия"}

              дело только в принимающей стороне.


              Update([FromBody] Delta<UserClass> req);

              после этого в req будет спец объект, в котором будет помечено какие поля изменились, а какие не прислали. Он потом может сам пропатчить сущность


              req.Patch(originalEntity);
    • 0
      инетересен опыт «в бою», вы вышли в продакшен? делали нагрузочное? как проект справляется? и какой ORM используете? как он себя ведёт. тоже «пилим» сейчас переезд на .net core
      • 0

        Ждите продолжение, там обязательно расскажем.

      • 0
        А чем NLOG не угодил?
        • 0

          Всем угодил. NLog умеет в .NET Core 2, пока что живем с ним. Но, судя по опыту других наших проектов, интеграция Serilog с ELK происходит прозрачнее и уже успешно используется в production. А как у вас с этим дела?

          • 0
            Мы молимся на NLOG и я пока не вижу причины его заменять.

            А в отношении сервисов, у нас все хуже, т.к. инфраструктуру проектировали лет 5 назад и всё написано на .NET. Есть куча WCF SOAP микросервисов, каждый под свои задачи. Все работают standalone, без IIS, как обычный Windows Service.

            Вот, планируем отказываться от винды и перетащить всё на swagger с REST-ом в докере, поэтому и присматриваюсь к опыту переноса под NET Core. Но есть одна серьёзная проблема, есть сервис на который смотрят наши партнеры и там обязательно оставлять SOAP.
            • 0

              А зачем вам в связке Serilog + ELK нужен L?

              • 0

                Спасибо! Применительно к .NET ELK мы используем как условную аббревиатуру, Logstash там действительно нет.

          • 0
            В asp.net core так же используется web.config если приложение хостится под iis для настройки веб сервера и параметров запуска хоста — например environment.

            Упустили самую интересную часть — как настраивали AD аутентификацию из под Linux контейнера. Kestrel бекенд сервер, это по сути обертка над с++ библиотекой сетевого i\o, веб сервером его с натяжкой можно назвать. Для него вроде есть что-то что работает под виндой с NTLM, но обычно всегда берут серьезный front-end сервер для этих задач c готовыми решениями — например iis или nginx под Linux, а их уже использует kestrel как reverse proxy.

            Про kubrrnetes тоже интересно почитать, обычно в один контейнер запихивать все приложение и для этого брать оркестратор типа kubernetes вообще решение не много непонятное, к тому же если у вас on-premise решение, а не облако.
            • 0

              В проекте используется минимальное количество функционала IIS, поэтому по поводу расставания с ним у нас не возникло сомнений. При появлении необходимости будем брать фронтенд-сервер в соответствии с условиями. Какую то часть на себя возьмет Kubernetes (например, балансировщик).


              По поводу AD — аутентификация происходит во внешнем для нашего решения сервисе, настройкой аутентификации в данном проекте не занимаемся.


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

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

            Самое читаемое