Эволюция .NET-стека: что изменилось за последние несколько лет
Всем привет. Меня зовут Влад, я старший .NET-разработчик в компании DataArt и около восьми лет работаю с .NET-стеком. В прошлом году написал статью «Как учить .NET» для новичков. В ней были изложены первые и последующие шаги, некая дорожная карта изучения .NET-экосистемы c нуля, однако без упоминаний последних актуальных технологий.
Все более популярным становится .NET Core, новые проекты стартуют на кросс-платформенном ASP.NET Core, в использование входит C# 8. В языке C# остались все фичи предыдущих версий для обратной совместимости, но это не значит, что они рекомендуемые. Эта статья для людей, уже имеющих опыт в коммерческой разработке на .NET-стеке и желающих проапгрейдить знания в связи с последними релизами технологий от Microsoft. А также для тех, кто годами сидит на старых версиях ASP.NET/C# и хочет быть в курсе, что нового в мире .NET-технологий.
В статье я изложу большинство нововведений, ориентируясь на веб-стек.
Open Source .NET
Много лет .NET оставался закрытой системой, однако последние пять лет Microsoft держит курс на открытие исходных кодов своих фреймворков. Последнее время ощутимый вклад в развитие ASP.NET Core, F#, VS Code внесло именно комьюнити (судя по количеству пулл-реквестов):
Также некоторые крупные корпорации платят своим специалистам за контрибьютинг в экосистему .NET, например Samsung.
.NET Core
Предпосылками к созданию альтернативной реализации .NET фреймворка стало отсутствие гибкой модульности, портабельности и кросс-платформенности. Рост популярности контейнеризации и повсеместное использование Linux не могли обойти Microsoft стороной, так как одним из плюсов Java перед .NET была как раз возможность хостинга под Linux. Оригинальный .NET Framework был неконкурентоспособен на этом рынке. Также .NET Core — часть open-source-наследия с возможностью для комьюнити влиять на разработку. По-моему мнению, это было абсолютно верным шагом к адаптации технологий от Microsoft к современным тенденциям.
Основное архитектурное отличие .NET Core от .NET Framework — это модульность и портабельность.
Под модульностью я понимаю меньшую связанность компонент и дробление на большее количество пакетов, из которых состоят библиотеки классов .NET (Core FX).Теперь все части приложения являются nuget-пакетами, включая CLR и JIT.
Под портабельностью — возможность поставлять код вместе со средой исполнения нужной версии в одном приложении (Core CLR).
В .NET Core был представлен кросс-платформенный CLI (command line interface) для выполнения типичных задач, управления зависимостями, пакетами, проектом. Например, такой командой мы можем завернуть приложение в единый исполняемый файл, который будет содержать и приложение, и среду выполнения:
dotnet publish -r win-x64 -c Release /p:PublishSingleFile=true
Вот хорошая статья про архитектуру .NET Core и способы развертывания.
Предпосылкой для появления .NET Core была платформа DNX, кросс-платформенная реализация .NET Runtime, которая впоследствии стала .NET Core 1. Кстати, dotnet CLI ранее назывался DNX.
Начиная с Visual Studio 2017, уже есть поддержка контейнеризации приложения из коробки.Visual Studio за нас создаст Dockerfile: скачает необходимый образ, запустит приложение. Подробнее можно посмотреть в этом видео. Подробнее о контейнеризации — тут.
Чтобы вручную завернуть приложение в контейнер, необходимо установить Docker, скачать необходимый образ-основу, создать Dockerfile и описать процесс копирования файлов, проброс портов из контейнера и старта приложения. Как это сделать руками, можно почитать в этой статье.
Все приготовления, описанные выше, можно сделать парой кликов из коробки с помощью GUI Visual Studio.
Что такое .NET Standard
В экосистеме .NET существует восемь реализаций .NET-фреймворка под разные платформы:
- .NET Core;
- .NET Framework;
- Mono;
- Xamarin.iOS;
- Xamarin.Mac;
- Xamarin.Android;
- Universal Windows Platform;
- Unity.
.NET Standard — унифицированное версионирование набора поддерживаемых API.
Каждая реализация имеет свою собственную историю релизов и развития. Чтобы понимать, какой пакет может быть совместим и с какой версией, было решено стандартизировать наборы предоставляемых API в спецификации .NET Standard. Для лучшего понимания можно посмотреть интерактивную таблицу.
Можно провести хорошую аналогию со спецификацией HTML5, которую каждый браузер реализует по-своему.
Очень подробно о реальных историях работы и решениях проблем совместимости с .NET Standard вы можете почитать тут.
C# 6, 7, 8
За последние четыре года развития C# (C# 6, C# 7.1, 7.2, 7.3, C# 8) изменения больше всего коснулись механики работы довольно стандартных вещей:
- методов;
- ветвлений (любые условные операторы);
- переменных;
- классов.
Всего я насчитал 54 новые фичи:
Группировка по количеству новых фич в каждом релизе по сфере изменений
В новых релизах C# все более обрастает синтаксическим сахаром, более лаконичным синтаксисом и средствами функционального программирования.
Я не буду перечислять все нововведения, которых довольно много. Опишу наиболее важные с точки зрения личного опыта и простоты введения в личную практику.
Упрощенный синтаксис кортежей (C# 7)
Ранее проблема передачи набора значений из метода решалась с помощью паттерна DTO. Однако мы сталкиваемся с тем, что объявление нового типа создает необходимость объявления еще одного класса, что формирует избыточность лишних классов в приложении.
Далее, начиная с .NET 4.0, популярность приобрел обобщенный класс Tuple, позволяющий передавать наборы значений разных типов в одном экземпляре, однако проблема в том, что неудобно работать со свойствами. Для переноса значений в обобщенном классе Tuple элементы кортежа будут называться Item1, Item2 вместо реальных имен свойств.
Проблема была решена в C# 7, что позволило в лаконичной манере передавать наборы значений из методов (кортежи) и объявлять их в качестве переменных, например:
(int, int) GetMinMax(int a, int b) { return (Math.Min(a, b), Math.Max(a, b)); } (int min, int max) = GetMinMax(4, 5); Console.WriteLine(max); Console.WriteLine(min);
Null-conditional operators (C# 6)
Следующая конструкция позволяет получить имя, если класс person != null. Если же он == null, в переменной first окажется null:
var first = person?.FirstName;
Очень сильно уменьшает количество кода в случае частых проверок на null.
String interpolation (C# 6)
Синтаксис более удобной шаблонизации строк показан в сочетании со стрелочной (лябмда-синтаксис) функцией (C# 6):
public class UserInfo { public string FirstName { get; set; } public string LastName { get; set; } public string FullName => $"{FirstName} {LastName}"; }
The nameof expression (C# 6)
Преобразует имя переменной в строку. Решает проблему хранения лишних строковых констант и хардкода строк:
void NameOfDemo(string lastName) { if (string.IsNullOrEmpty(lastName)) throw new ArgumentException( message: "Cannot be blank", paramName: nameof(lastName)); }
Local functions (C# 7)
Локальные функции решают следующие две проблемы:
- В случае необходимости еще раз использовать кусок кода внутри метода, причем нигде больше в приложении он использоваться не будет.
- Ранее мы могли объявить такой кусок кода двумя способами: в виде приватного метода на том же уровне или с помощью анонимного метода внутри.
Анонимные методы применяют механику делегатов. Иногда такой подход уместен для размещения кода, переиспользуемого только внутри этого метода.
С появлением делегатов их синтаксис выглядел так:
public delegate int DelegateInt(int a, int b); DelegateInt func = delegate (int a, int b) { return a + b; }; var result = func(1, 1);
Затем синтаксис делегатов упростили с помощью лямбд и добавили стандартные делегаты Func & Action:
Func<int,int,int> func = (a, b) => a + b; var result = func(1, 1);
Сейчас же Visual Studio 2019 предложит вам отрефакторить это выражение до локальной функции с использованием lambda-body-синтаксиса:
int func(int a, int b) => a + b; var result = func(1, 1);
Причем механика работы локальных функций немного отличается от механики работы анонимных методов:
- Локальную функцию можно определить в конце метода, переменную делегата только перед использованием.
- В случае рекурсивного вызова для делегата придется заранее определить имя переменной и присвоить пустое (default) значение, чтобы иметь возможность вызывать анонимный метод из него же.
- Локальная функция не является делегатом и не преобразуется в делегат во время использования, следовательно, не будет потреблять ресурсы в управляемой куче. При работе замыкания локальных функций не будет выделена лишняя память в куче, а будут использоваться структуры. Это поможет снизить потребляемые ресурсы. Если вам важен перфоманс, про реверс-инжинеринг того, как работают анонимные методы в сравнении с локальными функциями, можно почитать тут.
- В локальных функциях можно использовать синтаксис yield return для создания перечислений, в отличие от лямбда-выражений.
В целом локальные функции и делегаты имеют разные сферы применения. Локальные функции более уместны для выделения заново используемого куска кода внутри конкретного метода.
Readonly members (C# 8)
Новый модификатор доступа readonly для методов позволяет ограничивать их возможность изменять значения переменных. В случае наличия такого кода он не дает скомпилировать проект. Это помогает следить за иммутабельностью структур и классов.
public class DistanceDemo { public double X { get; set; } public double Y { get; set; } public double Distance { get; set; } public readonly override string ToString() => $"({X}, {Y}) is {Distance} from the origin"; }
Null-coalescing assignment (C# 8)
Позволяет объединить присвоение значения с проверкой на null. В следующем примере переменной numbers будет присвоено значение, только если она содержит null:
List<int> numbers = null; numbers ??= new List<int>();
Опять-таки убираем условные конструкции и сокращаем количество кода.
Nullable reference types (C# 8)
Это достаточно значительное нововведение в C# 8. С каждой новой версией C# — разработчики пытаются сделать программирование более лаконичным, предсказуемым и безопасным, помогая с помощью языковых средств избегать возможных типичных ошибок. Зачем делать проверки на null по всему приложению, если можно запретить на уровне компилятора устанавливать null? :)
Новый режим работы можно включить и для проекта в целом, и для какого-то отдельного куска кода, используя специальные директивы. В зоне действия новой фичи все Reference-переменные могут по умолчанию запрещать присваивать себе null, и чтобы иметь возможность присвоить null, нужно будет объявить эту возможность явно, например:
string? Name;
Также был добавлен новый оператор предотвращения вывода warning’ов на потенциально небезопасную операцию.
Этот код создаст warning:
(null as Car).Number
Тут warning не будет:
(null as Car)!.Number
Более детальное описание этой довольной объемной фичи можно найти в этой статье. О том, как Entity Framework Core работает с этой фичей, можно прочитать тут.
Patterns, Patterns, Patterns (C# 7-8)
В последних релизах C# реализовали много фич, которые упрощают условные операторы и делают их более лаконичными.
Кроме pattern matching из C# 7, позволяющего включать логику, завязанную на определение типа аргумента в оператор switch/case:
public static int SumPositiveNumbers(IEnumerable<object> sequence) { int sum = 0; foreach (var i in sequence) { switch (i) { case 0: break; case IEnumerable<int> childSequence: { foreach(var item in childSequence) sum += (item > 0) ? item : 0; break; } case int n when n > 0: sum += n; break; case null: throw new NullReferenceException("Null found in sequence"); default: throw new InvalidOperationException("Unrecognized type"); } } return sum; }
В C# 8 также появилось много вариаций нового синтаксиса switch-case. Все они интуитивно понятны, так что не буду писать много комментариев:
1. Было:
public static RGBColor FromRainbowClassic(Rainbow colorBand) { switch (colorBand) { case Rainbow.Red: return new RGBColor(0xFF, 0x00, 0x00); case Rainbow.Green: return new RGBColor(0x00, 0xFF, 0x00); case Rainbow.Blue: return new RGBColor(0x00, 0x00, 0xFF); default: throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)); }; }
Стало:
public static RGBColor FromRainbow(Rainbow colorBand) => colorBand switch { Rainbow.Red => new RGBColor(0xFF, 0x00, 0x00), Rainbow.Green => new RGBColor(0x00, 0xFF, 0x00), Rainbow.Blue => new RGBColor(0x00, 0x00, 0xFF), _ => throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)), };
2. Проверка на совпадение отдельных свойств объекта:
public static decimal ComputeTax(Address location, decimal salePrice) => location switch { { State: "WA" } => salePrice * 0.06M, { State: "MN" } => salePrice * 0.75M, { State: "MI" } => salePrice * 0.05M, _ => 0M };
3. Совпадение нескольких скалярных значений:
public static string RockPaperScissors(string first, string second) => (first, second) switch { ("rock", "paper") => "rock is covered by paper. Paper wins.", ("rock", "scissors") => "rock breaks scissors. Rock wins.", (_, _) => "tie" };
4. Позиционный паттерн на примере кода, который определяет позицию точки в евклидовой системе координат, а именно в каком из 4 квадрантов находится точка:
static Quadrant GetQuadrant(Point point) => point switch { (0, 0) => Quadrant.Origin, var (x, y) when x > 0 && y > 0 => Quadrant.One, var (x, y) when x < 0 && y > 0 => Quadrant.Two, var (x, y) when x < 0 && y < 0 => Quadrant.Three, var (x, y) when x > 0 && y < 0 => Quadrant.Four, var (_, _) => Quadrant.OnBorder, _ => Quadrant.Unknown };
Согласитесь, что написание кода в такой манере куда более приятно и понятно, чем вложенные условные операторы.
Default interface implementation (C# 8)
Возможно, это самая спорная и обсуждаемая фича из всех новых. Более подробное описание вы можете найти тут. Теперь в C# можно добавлять реализацию методов в интерфейсы.
Самое важное — понимать, зачем она была введена, и использовать по назначению. Многие люди могут обрадоваться, что теперь можно писать код в интерфейсах, тем самым реализовывая множественное наследование, либо использовать их вместо абстрактных классов.
Причиной добавления возможности иметь имплементацию в интерфейсах стала парадигма об иммутабельности интерфейсов. К примеру, мы строим систему CMS для интернет-магазина, для которой разработчик получает базовые сборки и интерфейсы, реализация которых создает кастомизацию системы, к примеру модули свойств товара. Однако, желая добавить сигнатуру нового метода в интерфейс модуля расчета цены, который рассчитывает скидку, мы рискуем сломать обратную совместимость и код клиента, так как ему необходимо будет реализовывать еще один метод после нашего обновления базовых сборок приложения.
В описанном случае мы не можем расширять контракт, который уже существует в созданных интерфейсах, так как интерфейсы, предоставляемые наружу, — иммутабельные, для обеспечения целостности кода клиентов.
Таким образом, давая возможность реализовывать поведение новых членов в интерфейсах по умолчанию, мы выпускаем новые возможности для кастомизации без критических изменений, требующих от клиента делать правки в коде.
Пример такого решения вы можете найти тут, изучив вначале код из папки starter/customer-relationship, а затем реализацию члена в интерфейсе finished/customer-relationship.
В общем, злоупотреблять этой фичей в дизайне своих приложений не стоит: она была предназначена для создателей API, для решения конкретных проблем.
Почитать о C# 8 можно на MSDN. Вот ещё неплохая статья на русском с некоторыми тонкостями использования новых фич.
ASP.NET Core
Если вы работали с ASP.NET MVC/Web API, многие вещи в ASP.NET Core для вас будут в новинку. Фреймворк стал более гибким и более очевидным. Центральное место, которое изменилось, — конфигурирование хостинга и конвейера обработки запросов.
В ASP.NET Core, по аналогии с другими бэкенд-экосистемами, было введено понятие middleware. Его можно определить как:
Функции промежуточной обработки (middleware) — это функции, имеющие доступ к объекту запроса , объекту ответа и к следующей функции промежуточной обработки в цикле «запрос-ответ» приложения. Могут выполнять работу как до перехода на следующую функцию, так и после, в обратном порядке, также решать — нужно ли продоложать цепочку обработки запроса. Подробнее тут.
Концептуальное изображение конвейера обработки запросов
В ASP.NET приложение конфигурируется в основном с помощью Web.Config, и точкой входа в приложение является global.asax.
В ASP.NET Core точка входа в приложение — Program.cs, где конфигурируется, как приложение будет хоститься и с какими параметрами и на каком сервере. В ASP.NET Core хостинг приложения отвязан от самого веб-движка и нет жесткой привязки к IIS, что дает больше гибкости. Также, продолжая концепцию ASP.NET Core с self-contained приложениями, мы можем сделать приложение self-hosted. Также есть опциональный файл Startup.cs, в котором мы пишем код управления жизненным циклом запроса, подключением middleware и регистрацией сервисов во встроенном IoC контейнере. Он опциональный, потому что все это мы можем реализовать и в Program.cs. Еще есть AppSettings.json, который содержит наши параметры приложения в зависимости от выбранной билд-конфигурации.
Конфигурирование проекта стало гораздо более прозрачным. Теперь нужно не искать узлы в огромном XML, а, скорее, писать личный конвейер обработки запросов на более интуитивно-понятном API на C# через конфигурирование кодом.
Теперь MVC и Web API висят на одной шине обработки запроса. Также разработчики позиционируют ASP.NET Core как высокопроизводительный серверный фреймворк, более быструю альтернативу старому ASP.NET.
Кстати, в ASP.NET Core для обработки запросов теперь используется ThreadPool, и нет SynchronizationContext по умолчанию вместо потоков IIS. Такое изменение может привести к серьезным проблемам — дедлокам на продакшене при большой нагрузке, в случае миграции решения с ASP.NET на ASP.NET Core, если у вас имеются специфические варианты реализации работы с тасками и асинхронностью. Подробнее можно почитать тут.
Entity Framework Core
Полностью переписанный Entity Framework Core — альтернатива Entity Framework 6.x, так как имеет почти такое же API, однако есть некоторые возможности, недоступные в EF Core, и наоборот. Полное сравнение можно почитать тут.
Самые значимые отличия в EF и EF Core — отсутствие в EF Core подходов Model First & Database First, использующих файл маппинга концептуальной и реальной модели — EDMX. Опыт показал, что излишние визуальная и концептуальная составляющие делают большие приложения слишком неудобными и медленными для разработки. Нововведением в EF Core стала поддержка нереляционных хранилищ. Посмотреть пример использования можно тут.
Сейчас EF Core проходит путь стабилизации. Сравним бэклоги багов на текущий момент. Они меняются довольно быстро, так что ниже цифры для сравнения. В EF Core багов больше, но и работа над ними идет более интенсивно:
Я думаю, что, начиная с версии 2.1, в которой наконец-то добавили серверную группировку и еще кучу разных фич, EF Core станет вполне production-ready фреймворком.
Асинхронный паттерн разработки
Сейчас асинхронный паттерн построения приложений становится де-факто стандартом. Асинхронность != многопоточность. Асинхронность — это о том, как эти самые потоки использовать более выгодно, повышая загруженность каждого, тем самым делая наше приложение более экономным, а следовательно, более производительным. Сам паттерн асинхронной работы менялся с выходами новых версий .NET, и уже в .NET 4 принял современный вид.
Начиная с .NET 4, рекомендованный подход — TAP (Task-based asynchronous pattern). В .NET 4.5 / C# 5 он был расширен оператором async/await, позволяющим писать асинхронный код в синхронной манере.
История развития паттернов асинхронного написания кода в C#:
- .NET 1.0 — Asynchronous Programming Model (IAsyncResult паттерн) — не рекомендуемый.
- .NET 2.0 — Event-based Asynchronous Pattern (EAP) — не рекомендуемый.
- .NET 4.0 — Task-based Asynchronous Pattern (TAP) — рекомендуемый.
- .NET 4.5 — в TAP добавлены async/await (C# 5.0) , ValueTask<T>, Inprogress<T>
- .NET Standard 2.1 (.NET Core 3, Mono 6.4) — в TAP добавлены AsyncStreams, реализация паттерна asynchronous data pull, аналога async sequence из F#.
Асинхронные стримы в асинхронном приложении позволят еще более рационально балансировать нагрузку при операциях ввода-вывода, где каждой операцией может быть не целое чтение какого-то ресурса, а много небольших чтений в рамках буфера.
Blazor
Клиентский C# в браузере стал реальностью. Не так давно Blazor был включен в релиз ASP.NET Core 3, и это своего рода революция в разработке UI-части приложений.
Предпосылки:
- Asm.js появился в
2013-м как способ написания высокопроизводительного кода для веб-браузеров. Вскоре поддержка asm.js была включена в Chrome & Mozilla. - В конце
2017-го идея asm.js была развита в релиз Web Assembly (Wasm) года в браузере Chrome. В wasm компилируется код на языках более высокого уровня: C/C++/Rust. - Вскоре Microsoft попробовала собрать Mono под платформу wasm. Как результат, получаем реализацию .NET-фреймворка в браузере. Исполняя код, написанный на чистом C#, который работает с
IL-инструкциями Mono под wasm-интерфейсом.
Это позволило писать код на C# для виртуальной машины (CLR) в браузере, которая работает под wasm. Фреймворк реализован в паттерне MVVM и немного напоминает компоненты Vue.js, содержащие и разметку и код модели. Также есть возможность вызывать чистый нативный JS.
Так это работает на концептуальном уровне
Технология постепенно развивается. Недавно ко мне пришла вакансия, в описании которой одним из необходимых скиллов был Blazor. Люди делают что-то вроде CAD-системы.
Из интересных пакетов под Blazor можно отметить:
- мост между HTML5 Canvas и C# кодом;
- реализация Twitter Bootstrap;
- чтение файлов через FileStream в браузере.
Также рекомендую хорошую статью с «Хабра» про Blazor.
Туллинг
Все чаще люди переходят на средства разработки от JetBrains. Сам считаю их довольно качественным софтом с хорошим UX:
- Rider — альтернативная IDE для .NET-стека, имеет родной ReSharper как элемент среды. В Visual Studio при установке ReSharper получаем тормоза за счет того, что, кроме анализа кода в ReShaerper, сама Visual занимается анализом кода, и сам плагин работает через интерфейсы, выставляемые VS, что дает излишнюю работу и двойной расход ресурсов. Еще важный момент. ReSharper работает in process, сама же Visual Studio реализована как приложение x86 (32 bit), что накладывает ограничения на размер потребляемой оперативной памяти на поток, где находится студия.
- DataGrip — альтернатива MS SQL Management Studio + HuntingDog, имеет встроенный быстрый поиск по объектам баз данных, включая полнотекстовый поиск по хранимым процедурам/функциям, кучу удобств для работы с таблицами, удобный редактор кода с автокомплитом. Отлично подходит под цели Database Developers, однако для администрирования серверов придется использовать либо текстовые команды, либо GUI от нативного MSSMS. DataGrip содержит поддержку кучи драйверов для всех популярных баз данных.
- Дополнительные средства от JetBrains:
- dotPeek — альтернатива ILSpy & Reflector;
- dotMemory — средство для анализа потребляемой памяти, частично повторяет функциональность Performance Monitor и WinDbg, только в более user-friendly манере;
- dotTrace — перфоманс профайлер;
- dotCover — средство запуска unit-тестов, поиск проблем с покрытием кода.
Но также довольно неплохо развилась Visual Studio. Последняя версия на сегодняшний день — 2019, имеет более минималистичный интерфейс, были улучшены средства статического анализа кода, рефакторинга, навигации по коду.
Выводы
За последние
По всем вопросам, пожеланиям, предложениям пишите мне на Facebook.