Всем привет! Этим материалом мы открываем цикл из нескольких статей, посвященных длинной истории о том, как мы пришли с одной стороны к 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-контейнер. Как говорится, «не переключайтесь».