.NET Разработчик
6.51K subscribers
427 photos
2 videos
14 files
2.04K links
Дневник сертифицированного .NET разработчика.

Для связи: @SBenzenko

Поддержать канал:
- https://boosty.to/netdeveloperdiary
- https://patreon.com/user?u=52551826
- https://pay.cloudtips.ru/p/70df3b3b
Download Telegram
День 1358. #Testing #Benchmark
Руководство по Бенчмарку в .NET. Начало
В этой серии постов рассмотрим, как создать проект .NET для тестирования производительности.

Начнём с чистого листа и создадим проект. Мы протестируем два метода, которые разными способами объединяют строки. Для этого будем использовать пакет BenchmarkDotNet.

Если вы, как я, не можете запомнить структуру проекта с бенчмарком и постоянно её гуглите, можно просто установить шаблоны:
dotnet new install BenchmarkDotNet.Templates

Теперь создадим проект, используя шаблон:
dotnet new benchmark --console-app -f net6.0 -o StringBenchmarks

Здесь мы используем несколько флагов:
--console-app – консольное приложение,
-f net6.0 – целевой фреймворк .NET 6.0,
-o StringBenchmarks – название проекта и папки для него.

Шаблон создаёт 2 класса:
- Benchmarks, в который добавляет 2 метода для тестовых сценариев: Scenario1 и Scenario2, помеченные атрибутом [Benchmark],
- Program, который собственно запускает бенчмарк.

Назовём методы удобными именами и добавим простые методы объединения строк: через конкатенацию и через StringBuilder.
namespace StringBenchmarks;

public class Benchmarks
{
[Benchmark]
public string StringJoin()
{
return string.Join(", ",
Enumerable.Range(0, 10)
.Select(i => i.ToString()));
}

[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
for (int i = 0; i < 10; i++)
{
sb.Append(i);
sb.Append(", ");
}

return sb.ToString();
}
}
Да, метод со StringBuilder выведет лишнюю запятую. Мы к этому ещё вернёмся. Запускаем проект:
dotnet run -c Release

Здесь стоит отметить, что должна использоваться конфигурация Release, чтобы максимально оптимизировать сборку. Кроме того, если вы хотите, чтобы тесты были максимально точными, закройте все работающие приложения и запустите проект из терминала.

Бенчмарк выдаст подробный отчёт. В начале выдаётся информация о системе, в которой проходили тесты, а затем собственно результаты с разными статистическими подробностями. Мы обратим внимание на колонку Mean (Среднее):
|        Method |     Mean |
|-------------- |---------:|
| StringJoin | 69.71 ns |
| StringBuilder | 41.19 ns |

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

Продолжение следует…

Источник:
https://blog.nimblepros.com/blogs/benchmarking-in-dotnet/
👍13
Иллюзия идеального выбора lock(_sync), троттлинг запросов, различия в мышлении инженера и архитектора — обо всем этом поговорим на DotNext 2022 Autumn.

Конференция пройдет 3–4 ноября в онлайне и 20 ноября в офлайне.

В программе уже есть первые доклады. Среди них:
✔️ Станислав Сидристый (ЦРТ) — «lock(_sync): иллюзия идеального выбора».
✔️ Евгений Пешков (Тинькофф) — «Алгоритмы троттлинга запросов».
✔️ Дмитрий Сошников (МАИ / НИУ ВШЭ) — «Как научить вашего ребенка программировать (и не только)».
✔️ Дмитрий Таболич (ИТ1) — «Думай как архитектор: майндшифт инженера».

Участников ждут тематические дискуссии с другими участниками, интервью с экспертами, доклады от партнеров и много других активностей.

Подробности и билеты — dotnext.ru

Если вам хочется на несколько часов отвлечься и побыть среди единомышленников, то приходите на DotNext. А промокод netdeveloper2022JRGpc даст скидку от 20% на билеты из категории «Для частных лиц».
👍5👎1
День 1359. #Testing #Benchmark
Руководство по Бенчмарку в .NET. Продолжение
Начало

Мы можем выводить дополнительную информацию в отчёте с помощью атрибутов.

Память
Наверное, самым популярным является MemoryDiagnoser, позволяющий посмотреть информацию о потребляемой памяти и количестве сборок мусора.

Базовый случай
Иногда полезно обозначить базовый метод, относительно которого мы будем считать, насколько быстрее (или медленнее) работают остальные. Это можно сделать, добавив в атрибут Benchmark параметр Baseline=true.

Параметры
Можно посмотреть, как себя ведёт метод в зависимости от объёма данных. В этом поможет атрибут Params(…), в который надо передать массив размеров входных данных.

Среда исполнения
Мы можем сравнить быстродействие кода в разных средах исполнения. Например, .NET 6.0 с .NET 7.0. Для этого используется атрибут SimpleJob(RuntimeMoniker.…). Убедитесь, что у вас установлена версия BetnchmarkDotNet 0.13 или выше, чтобы тестировать в средах .NET 5.0 и выше. Также убедитесь, что все эти среды исполнения установлены на вашей машине.

Собираем всё вместе:
namespace StringBenchmarks {
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net50)]
[SimpleJob(RuntimeMoniker.Net60, baseline: true)]
[SimpleJob(RuntimeMoniker.Net70)]
public class Benchmarks
{
[Params(5, 50, 500)]
public int N { get; set; }

[Benchmark(Baseline = true)]
public string StringJoin()
{
return string.Join(", ",
Enumerable.Range(0, N)
.Select(i => i.ToString()));
}

[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
for (int i = 0; i < N; i++)
{
sb.Append(i);
sb.Append(", ");
}

return sb.ToString();
}
}
}

Мы добавили параметр N, которому бенчмарк задаст значения 5, 50 и 500 соответственно в разных тестах. Также мы запустим тесты в 3х средах исполнения: .NET 5.0, .NET 6.0 (базовая среда) и .NET 7.0. Кроме того, добавлена диагностика памяти и за базовый случай взят метод StringJoin.

Результаты приведены на картинке ниже. Из результатов, например, заметно, что метод, использующий StringBuilder, с каждой новой версией .NET работает всё быстрее.

Не обязательно просматривать результаты в консоли. BenchmarkDotNet выводит результаты в папку BenchmarkDotNet.Artifacts. Там будут файлы отчетов в форматах html, csv и markdown. Это может быть очень полезно для добавления в PR или комментарий к релизу на Github или других подобных платформах.

Окончание следует…

Источник:
https://blog.nimblepros.com/blogs/benchmarking-in-dotnet/
👍13
День 1360. #Testing #Benchmark
Руководство по Бенчмарку в .NET. Окончание
Начало
Продолжение

Настройка и очистка
Иногда нужно написать какую-то логику, которая должна выполняться до или после бенчмарка, но мы не хотим, чтобы она участвовала в бенчмарке.
- Метод, помеченный атрибутом [GlobalSetup], будет выполняться только один раз для тестируемого метода после инициализации параметров бенчмарка и до всех вызовов тестового метода.
- Метод, помеченный атрибутом [GlobalCleanup], будет выполняться только один раз для тестируемого метода после всех вызовов тестового метода.
- Метод, помеченный атрибутом [IterationSetup], будет выполняться ровно один раз перед каждым вызовом теста. Не рекомендуется использовать его в микробенчмарках, так как это может испортить результаты. Однако, он может быть полезен, если тест занимает не менее 100 мс, и вы хотите подготовить некоторые данные перед каждым вызовом.
- Метод, отмеченный атрибутом [IterationCleanup], будет выполняться ровно один раз после каждого вызова. Этот атрибут также не рекомендуется использовать в микробенчмарках.

Валидация
Итак, бенчмарк позволяет сравнить между собой производительность методов, которые, по идее, должны делать одно и то же. Однако, если внимательно присмотреться к коду из первой части, можно заметить, что это не так. Метод StringBuilder() выводит лишнюю запятую в конце, в отличие от метода StringJoin(). Эта конкретная ошибка вряд ли сильно повлияла на результаты бенчмарка, но нам может и не повезти с этим в следующий раз.

Хотелось бы иметь какой-то автоматизированный тест, подтверждающий, что результат работы методов одинаковый.

BenchmarkDotNet умеет и это. Всё, что нам нужно сделать, это добавить ReturnValueValidator в наш тестовый класс, и все готово.

[ReturnValueValidator(failOnError: true)]
public class Benchmarks
{
// остальной код скрыт для краткости
// см. предыдущий пост
}

Теперь при попытке запуска нашего бенчмарка, если методы не возвращают одинаковый результат, мы получим ошибку:
// Validating benchmarks (Проверка бенчмарков):
Inconsistent benchmark return values in Benchmarks (Несогласованные возвращаемые значения в бенчмарках): StringJoin: 0, 1, 2, 3, 4, StringBuilder: 0, 1, 2, 3, 4,

* Здесь значения кажутся одинаковыми, но на самом деле это из-за того, что BenchmarkDotNet ставит запятую в сообщении об ошибке после результата первого метода. На самом деле это следует читать как:
StringJoin: "0, 1, 2, 3, 4", StringBuilder: "0, 1, 2, 3, 4,"

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

Также существует BaselineValidator, который проверяет, что параметр Baseline=true атрибута Benchmark добавлен только в одном методе. Этот валидатор обязательный.

JitOptimizationsValidator проверяет, все ли зависимости проекта оптимизированы. По умолчанию отключен.

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

Источники:
-
https://blog.nimblepros.com/blogs/validating-benchmarks/
-
https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html
-
https://benchmarkdotnet.org/articles/configs/validators.html
👍16
День 1361. #TipsAndTricks
Управление Директивами Using в Visual Studio
В последней версии Visual Studio вам не нужно управлять директивами using вручную. VS может добавлять/удалять их автоматически. Сегодня рассмотрим, как настроить Visual Studio для автоматической обработки директив using.

Все настройки будут выполняться в окне Options (Tools > Options…).

1. Управление директивами
Для начала посмотрим на опции, которые есть уже давно. Перейдите в раздел Text Editor > C# > Advanced (Текстовый редактор > C# > Дополнительно). Прокрутите вниз до блока Using Directives (Директивы Using). Здесь вы можете настроить:
- сортировку (располагать директивы System вверху),
- разделение групп директив,
- автоматическое добавление using при вставке кода,
- включить или выключить предложения добавления директив using для пространств имён из .NET Framework или из NuGet пакетов.

2. Автоматическое сворачивание
Перейдите в раздел Text Editor > C# > Advanced (Текстовый редактор > C# > Дополнительно). Прокрутите вниз до блока Outlining (Обрисовка), отметьте пункт Collapse usings on file open (Сворачивать директивы using при открытии файла).

3. Добавление в IntelliSense
Visual Studio может отображать типы из неимпортированных пространств имён в IntelliSense. Перейдите в раздел Text Editor > C# > IntelliSense (Текстовый редактор > C# > IntelliSense). Прокрутите в самый низ и отметьте пункт Show items from unimported namespaces (Показывать элементы из неимпортированных пространств имён). Теперь при наборе кода во всплывающей подсказке будут показываться все доступные типы. Для неимпортированных пространств имён справа будет показываться его имя. При выборе элемента Visual Studio автоматически добавит директиву using, если это необходимо.

4. Очистка неиспользуемых директив
Если вы хотите очищать неиспользуемые директивы using, вы можете настроить Visual Studio для запуска очистки кода при сохранении изменений. Перейдите в раздел Text Editor > Code Cleanup (Текстовый редактор > Очистка Кода). Отметьте пункт Run Code Cleanup on Save (Выполнять Очистку Кода при Сохранении). Затем выберите профиль очистки и нажмите на ссылку Configure Code Cleanup (Настроить Очистку Кода). В новом окне из нижнего списка Available fixers (Доступные фиксеры) выберите Remove unnecessary Imports or usings (Убрать ненужные директивы Import или using), а также любые другие, которые вам могут понадобиться. Сохраните изменения. Теперь при каждом сохранении файла будет производиться очистка кода и в том числе удаление ненужных директив.

Источник: https://www.meziantou.net/configuring-visual-studio-to-handle-using-directives-automatically.htm
👍14
День 1362. #ЗаметкиНаПолях #AsyncTips
Асинхронное освобождение

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

Решение
Есть два распространённых варианта действий.

1. Запрос на отмену всех текущих операций.
Такие типы, как файловые потоки и сокеты, отменяют все существующие операции чтения и записи при закрытии. Определив собственный CancellationTokenSource и передавая этот маркер внутренним операциям, можно сделать нечто похожее. В этом случае Dispose отменит операции, не ожидая их завершения:
class MyClass : IDisposable
{
private readonly CancellationTokenSource _сts =
new CancellationTokenSource();

public async Task<int> CalcAsync()
{
await Task.Delay(
TimeSpan.FromSeconds(2),
_сts.Token);

return 42;
}

public void Dispose()
{
_сts.Cancel();
}
}

Выше приведён упрощённый код. В реальном паттерне Dispose не всё так просто. А также стоит предоставить пользователю возможность передать собственный маркер CancellationToken (используя приём, описанный в этом посте).

При вызове Dispose будут отменены все существующие операции в вызывающем коде:
async Task UseMyClassAsync()
{
Task<int> task;
using (var resource = new MyClass())
{
task = resource.CalcAsync(default);
}
// Выдает OperationCanceledException.
var result = await task;
}
Для некоторых типов (например, HttpClient) такая реализация работает вполне нормально. Однако иногда необходимо убедиться, что будут завершены все операции.

2. Асинхронное освобождение впервые появилось в C# 8.0. Появились интерфейс IAsyncDisposable и команда await using. Таким образом, типы, которые собирались выполнить асинхронную работу при освобождении, теперь получили такую возможность:
class MyClass : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(TimeSpan.FromSeconds(2));
}
}

Использование:
await using (var myClass = new MyClass())
{

} // <--
// Здесь вызывается DisposeAsync (с ожиданием)

Также можно использовать ConfigureAwait(false):
var myClass = new MyClass();
await using (myClass.ConfigureAwait(false))
{

} // <--
// Здесь вызывается DisposeAsync (с ожиданием)
// с ConfigureAwait(false).

Асинхронное освобождение определенно проще, а первый подход должен использоваться только в том случае, если это действительно необходимо. Также при желании можно использовать оба подхода одновременно. Это наделит ваш тип семантикой «безопасного завершения работы», если в клиентском коде используется await using, и семантикой «жёсткой отмены», если клиентский код использует Dispose.

Источник: Стивен Клири “Конкурентность в C#”. 2-е межд. изд. — СПб.: Питер, 2020. Глава 11.
👍11
День 1363. #ЗаметкиНаПолях
CQRS. Факты и Мифы. Начало
Технические паттерны полны мифов и неверных толкований. Довольно часто это происходит с CQS и CQRS. Часто про CQ(R)S можно услышать, что:
- нужны две базы данных,
- нужно использовать очередь сообщений (например, RabbitMQ или Kafka),
- это сложно применить и усложняет архитектуру,
- возникнут проблемы окончательной согласованности (Eventual Consistency),
- нужно реализовывать паттерн Источников Событий (Event Sourcing).

CQS означает Разделение Команд и Запросов (Command Query Separation).
CQRS — Разделение Ответственности Команд и Запросов (Command Query Responsibility Segregation).

Ничто в названии не говорит о двух базах данных, разных таблицах или вообще о том, как информация хранится. Зато и в CQS, и в CQRS в названии есть Команды и Запросы:
Команда (Command) — это запрос на изменение.
Запрос (Query) — это запрос на возврат данных.

Команда для добавления события может выглядеть так:
public class CreateEvent
{
public string Name { get; }
public string Where { get; }
public DateTime When { get; }
}

Запрос на получение данных события может выглядеть так:
public class GetEvent
{
public Guid Id { get; }
}

Как видите, это простые DTO.

Шаблон CQS был создан Бертраном Мейером во время его работы над языком Eiffel. Он утверждал, что: «Задавание вопроса не должно менять ответ» и определил, что: «Команда (процедура) что-то делает, но не возвращает результат. Запрос (функция) возвращает результат, но не изменяет состояние». Благодаря такому различию обработку можно сделать более простой и предсказуемой. Запрос не создаст никаких побочных эффектов. Команда не будет использоваться для получения данных.

CQRS является расширением CQS. Грег Янг определил его так: «Разделение ответственности команд и запросов использует то же определение команд и запросов, что и у Мейера, и придерживается точки зрения, что они должны быть чистыми. Принципиальное отличие состоит в том, что в CQRS объекты делятся на два типа, один из которых содержит команды, а другой — запросы». Таким образом, CQS определяет общий принцип поведения. CQRS более конкретно говорит о реализации.

При использовании CQRS должно быть строгое разделение между моделью записи и моделью чтения. Они должны обрабатываться отдельными объектами (обработчиками) и не быть концептуально связанными друг с другом. Обработчики же не являются структурами хранения информации и не связаны с тем, где и как будут храниться данные. Обработчики команд исполняют команды, которые изменяют состояние или выполняют другие операции с побочными эффектами. Обработчики запросов отвечают за возврат результата запроса.

Ничто не мешает модели записи и модели чтения иметь одинаковую структуру или использовать одни и те же таблицы БД. Более того, в CQRS не обязательно использовать базу данных. Под капотом может быть Excel, текстовый файл или внешний API. Самое главное — концептуально разделять модели записи и чтения данных.

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

Окончание следует…

Источник:
https://event-driven.io/en/cqrs_facts_and_myths_explained/
👍21
День 1364. #ЗаметкиНаПолях
CQRS. Факты и Мифы. Окончание
Начало

Теперь перейдём к разбору мифов.

1. Усложняет ли CQRS архитектуру?
В архитектуре CQRS вы делите свою модель и API на вертикальные слои. Каждый обработчик команд/запросов представляет собой отдельный кусок, отдельную единицу кода (новый обработчик можно создавать даже через копирование/вставку). Благодаря этому вы можете настроить конкретный метод, не следуя общим соглашениям (например, использовать чистый SQL-запрос или даже другое хранилище). В традиционной многоуровневой архитектуре изменение базового универсального механизма на одном уровне может повлиять на все методы. Но мир не идеален: иногда исключений больше, чем случаев, подпадающих под общее правило. Вертикальное разделение помогает разработчику сосредоточиться на конкретной бизнес-функции, не отвлекаясь на остальную логику приложения.

2. Почему считается, что нужно иметь разные таблицы или базы данных?
CQRS позволяет настроить модель запроса в соответствии с потребностями клиентов. Типичным случаем является наличие отдельной модели чтения для каждого представления. Вам не нужны все детали объекта, если вы просто отображаете краткую сводку. Такие модели чтения могут представлять собой слегка отличающиеся SQL-запросы, выбирающие другой диапазон столбцов из таблицы. Но они также могут быть материализованными представлениями или отдельными таблицами. Можно сделать это для оптимизации производительности, чтобы записи и запросы не влияли друг на друга. Если у вас такой случай, то одно из возможных решений — использовать разные таблицы или базы данных и синхронизировать их после записи. Однако это не общее правило. Нужно выбрать стратегию, которая соответствует потребностям.

3. Откуда возникла необходимость в очередях обмена сообщениями?
CQRS позволяет иметь разные хранилища для разных бизнес-кейсов. Например, реляционную базу данных в модели записи и базу данных документов в модели чтения (например, Elastic Search для полнотекстового поиска) или любую другую комбинацию. Создание модели чтения в этом случае должно иметь логику преобразования. У вас могут быть отдельные сервисы, отвечающие за бизнес-логику по изменению состояния и за постройку моделей чтения. Очереди обмена сообщениями (например, RabbitMQ, Kafka) могут помочь синхронизировать их, уведомляя процессоры модели чтения о новом изменении в модели записи. Но можно начать с малого, используя таблицы и представления базы данных или очереди в памяти.

4. Что насчёт окончательной согласованности?
Если вы используете очереди сообщений или несколько баз данных, данные из хранилища для записи копируются (и преобразуются) в хранилище для чтения асинхронно. В результате хранилище чтения отстает от хранилища записи и имеет место Окончательная Согласованность (Eventual Consistency). Даже материализованные представления или репликация в базе данных могут иметь окончательную согласованность.
Когда изменение, внесённое в модель записи, влияет на несколько моделей чтения в одной и той же транзакции, стоит подумать о том, является ли влияние на производительность значительным. Один из возможных подходов — выгрузить обновление в фоновый асинхронный процесс. Зная, какие изменения были внесены, вы можете обрабатывать их по одному или, например, пакетами в процессе ETL.

5. Нужен ли Event Sourcing?
Нет. Event Sourcing довольно часто отождествляют с CQRS. Он по определению имеет модели Write и Read. События сохраняются в журнале только для добавления. Они являются источником истины. Модели чтения создаются и обновляются на основе событий. Event Sourcing и CQRS подходят друг другу, однако это разные парадигмы. Event Sourcing — это «всего лишь» один из вариантов, который вы можете выбрать в качестве реализации хранилища.

Источник: https://event-driven.io/en/cqrs_facts_and_myths_explained/
👍6
День 1365. #Книги
Закончил читать книгу «Проект “Феникс”. Как DevOps устраняет хаос и ускоряет развитие компании» (Джин Ким, Кевин Бер, Джордж Спаффорд — М.: Эксмо, 2022).

Это первая нетехническая книга, прочитанная мной… лет так за 15. И то, только потому, что как-то обнаружил её в этой подборке https://t.iss.one/NetDeveloperDiary/1558. Вообще, жаль, что про ИТ практически нет художественной литературы. Или может я просто не в курсе, накидайте в комментариях, что знаете.

Книга, конечно, не про программирование, а скорее про управление командой и DevOps. Сферы, в которых я не очень разбираюсь. Но всё равно история довольно увлекательная. На все 380+ страниц у меня ушла неделя. Мне понравилось.
👍12
День 1366. #TypesAndLanguages
Пару Слов об Оценках и Стори-Пойнтах. Начало
Стори-Пойнты (Story Points - SP) представляют собой сложность, риск, трудоёмкость и объём пользовательской истории. Истории оцениваются в баллах, чтобы получить относительный размер каждой из них, чтобы затем планировать будущее. Однако при планировании спринта нужно разбить историю на задачи.

Допустим, вы делите истории на задачи, которые длятся не более одного дня. Однако в этом случае вы фактически уберёте трудоёмкость из SP, поскольку каждая задача основана на ровно одном трудодне.

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

Как разделить истории на задачи? Если вы можете распараллелить какую-то работу, то её следует разделить на несколько задач. Если нет, и в задаче нет логического «чекпойнта», то это должна быть одиночная задача. Технически задача (и история) должна быть выполнена за спринт, это единственное требование. Однако, если ожидается, что задача займёт больше половины спринта, то стоит попытаться её поделить.

Мы разбили истории на задачи. Какие истории включить в предстоящий спринт? Можно рассчитать производительность команды, особенно если команда стабильна. Это может дать представление о том, сколько времени займёт работа, исходя из предыдущих спринтов. Однако, использовать производительность команды в стори-пойнтах для планирования спринта значит не понимать их смысл.

Допустим, в команде 5 человек, команда стабильна, и в последнем спринте закончили 25 историй в 1 стори-пойнт каждая. В следующем спринте задачи в 3 SP каждая. Сколько задач взять? Если вы думаете, что 8, это означает, что вы используете SP как ещё одну единицу времени и что ваши оценки растут линейно. Это неверно. 3 задачи по 1 SP не равны 1 задаче в 3 SP (поскольку есть и другие факторы, учитываемые при оценке). Но есть ещё проблема: в предыдущем спринте каждый член команды работал над 5 задачами в 1 SP, т.е. производительность каждого 5 SP за спринт. Поэтому, если взять больше 5 задач в 3 SP, кому-то придётся взять на себя 2 задачи, а это уже 6 SP.

Можно решить только приступить к задаче и перенести её на следующий спринт, но это противоречит идее планирования и обязательности. Agile-подход направлен на устранение неопределённости и рисков, а не на избавление от планирования и обязательств. Программисты утверждают, что плохо предсказывают будущее, поэтому хотят планировать небольшие шаги. Тем не менее, всё равно необходимо завершать работу, за которую вы взялись в спринте. Если вы берёте задачу с мыслью «я не закончу её, но хотя бы начну», то делаете неправильно. Вы должны разделить задачу на части и взять одну часть, которую сможете выполнить в спринте. Agile-подход и стори-пойнты нужны для того, чтобы полностью избежать оценок по времени, а для того, чтобы сделать их более реалистичными и надёжными. Вам всё ещё нужно закончить работу за спринт, т.е временные рамки фиксированы размером спринта.

Вместо 8-ми задач по 3 SP стоит взять 5. Это большая разница: 15 SP против 25. И по этой причине команда может выполнять разное количество SP в каждом спринте. Если вы заранее знаете, сколько SP вы выделите при планировании спринта, то это полностью противоречит идее стори-пойнтов.

Можно рассчитывать производительность и скорость. Но не нужно использовать эти значения для планирования работы внутри спринта. SP — не единицы измерения времени.

Как же перейти от SP ко времени? Никак. Мы можем зафиксировать только работу, выполненную в текущем спринте. SP могут быть соотнесены с оценками времени, но не могут диктовать их, а оценки времени не могут быть выведены из SP.

Продолжение следует…

Источник
https://blog.adamfurmanek.pl/2022/06/11/types-and-programming-languages-part-12/
👍6
День 1367. #TypesAndLanguages
Пару Слов об Оценках и Стори-Пойнтах. Продолжение
Начало

Нам нужны оценки по времени
Нужно ведь планировать работу наперёд.

Да. Поэтому оценивать задачи в стори-пойнтах — плохо. Программисты разные: у них разный опыт, разные навыки, им нравятся разные части разработки ПО. Нельзя просто придумать коэффициент для перевода SP в часы, так как каждый спринт индивидуален и каждый программист индивидуален, поэтому придётся знать эти коэффициенты для каждого человека и уметь предсказывать будущее (поскольку они меняются с каждым спринтом). Из-за этого программисту очень просто объяснить, почему он работал над задачей «слишком долго»: потому, что SP не передают никакой оценки времени (кроме того, что должны укладываться в один спринт).

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

Поскольку программистов редко заботят нетехнические вещи, они хотят избавиться от ответственности. И именно поэтому им нравится оценивать задачи в стори-пойнтах. Не нужно думать о часах, и у них есть очень хорошее объяснение, что дела будут сделаны «когда они будут сделаны». Очевидно, что это мало полезно всем остальным, и хорошие инженеры это понимают. Если вы считаете, что нормально оценивать задачи в SP, то представьте, что сантехник говорит вам, что починка вашей раковины «займет 8 SP». Очевидно, вас это не устроит, вместо этого вы ожидаете оценки по времени. Как ни странно, программисты часто утверждают, что разработка ПО отличается от «реальной жизни», и поэтому они не могут оценивать по времени надолго. Это неправда.

Как это должно выглядеть?
Как только вы разбиваете истории на задачи, вы оцениваете каждую задачу в часах (или днях, или любой другой единице времени). Затем просто выполняете расчёты, чтобы проверить, какие задачи вы можете включить в спринт. И на основе этого определяете, какие пользовательские истории вы обработаете.

Производительность и инкременты
Прежде чем перейти к фактическим единицам времени, мы рассмотрим производительность. Вы не можете использовать производительность для планирования спринта: если вы заранее знаете, сколько SP вам нужно сделать за спринт, то вы делаете это неправильно. Спринты предназначены для фиксации того, какие пользовательские истории будут выполнены полностью, и фактическое количество SP будет отличаться от спринта к спринту.

Однако, когда команда стабильна, вы можете рассчитать производительность и использовать её, чтобы оценить, достаточно ли у вас запланировано работы на какой-то период времени (до следующего релиза). Стратегический анализ бизнеса стоит дорого, да и планы со временем меняются. Нет смысла планировать что-то на 3 года вперед, потому что, скорее всего, планы устареют. В то же время хорошо бы иметь некоторое представление о том, сколько работы вам предстоит и над чем вы будете работать в течение следующих 6 месяцев.

Если вы подсчитаете, что в среднем команда набирает X стори-пойнтов за спринт, то вы можете посмотреть сколько работы вам предстоит сделать, сколько из неё оценено в SP, и достаточно ли вам работы до следующего релиза. Просто умножаете производительность на количество спринтов до релиза. Дело не в том, чтобы быть суперточным, а в том, чтобы понимать, сколько времени это займёт. Если вы видите, что у вас достаточно стори-поинтов, чтобы заполнить работой следующий год, то нет необходимости делать бизнес-анализ с вашим клиентом сейчас, это может подождать до следующего месяца. Может быть и наоборот. Вы думаете, что вам хватит работы на ближайшие 6 месяцев, а по оценке получается только на 5. Делайте выводы.

Окончание следует…

Источники:
-
https://blog.adamfurmanek.pl/2022/06/11/types-and-programming-languages-part-12/
-
https://blog.adamfurmanek.pl/2022/06/18/types-and-programming-languages-part-13/
👍6
День 1368. #TypesAndLanguages
Пару Слов об Оценках и Стори-Пойнтах. Окончание
Начало
Продолжение

Цикл обратной связи
Стори-пойнты используются не только для планирования релизов, но и для относительной оценки вашей работы. Вам нужно сравнивать вещи друг с другом и уметь сказать, какие вещи «большие», а какие «маленькие». Это должны знать все стороны.

Сначала клиент указывает, что нужно сделать. Затем нам нужно провести бизнес-анализ с клиентом, чтобы получить некоторые детали, разбить работу на пользовательские истории. Далее инженеры-программисты должны собраться вместе и оценить работу в стори-пойнтах. Они не должны оценивать точную продолжительность в часах/днях/месяцах. Им нужно только показать, какие задачи «большие», а какие «маленькие».

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

Наконец, клиент составляет список. Затем команды инженеров собираются вместе и планируют спринт. На этом этапе отбрасывается понятие стори-пойнтов. Пользовательские истории разбиваются на задачи и фиксируются конкретные пользовательские истории, которые должны быть реализованы в спринте.

Планирование в единицах времени
Если вы используете производительность, чтобы рассчитать, достаточно ли у вас работы на следующие 6 месяцев, то вы эффективно используете стори-пойнты как единицу времени. И в данном случае это нормально, потому что это всего лишь прогноз, а не обязательство, какие пользовательские истории следует выполнить. Может показаться, что мы из Agile вернулись к модели Waterfall. Нет, потому что мы корректируем процесс по ходу дела. В Waterfall мы сначала анализируем работу, потом делаем реализацию, потом тестируем, а потом уже разворачиваем. И всё. При спиральном (инкрементном, agile) подходе мы работаем в терминах релизов: планируем гораздо меньшую часть работы, и поставляем её. Вот в чём разработка ПО немного отличается от строительства домов. Вы переезжаете в новый дом только после завершения строительства. Однако при разработке ПО мы начинаем использовать продукт, даже если он ещё не готов, и делаем это гораздо раньше. В этом суть поэтапной работы: мы не ждём, пока дом будет «готов», мы въезжаем, как только у нас есть одна комната, без окон, без потолка и без входных дверей.

Однако мы не можем быть точны. Годовую работу мы не оценим идеально, мы всегда пропустим дедлайн. Но мы рискуем, потому что можем исправить траекторию на ходу. Все меняется: мы можем потерять техническое преимущество, потерять инвестора, люди могут уйти из компании или попасть под автобус. Есть много вещей, которые могут пойти не так, поэтому просто нужно признать, что мы не будем точны. Но дело не в том, чтобы через год понять, что мы пропустили дедлайн, а в том, чтобы выяснить это заранее и быть «достаточно точными».

Как быть достаточно точным?
История показывает, что оценки экспертов не бывают точны. Мы можем ограничить незавершённую работу, уменьшить скрытую работу, автоматизировать рутинную работу, чтобы улучшить процесс и получить более надёжные оценки. Можем использовать метод Монте-Карло, находить критические пути и моделировать различные сценарии. Но мы также можем просто переоценить наши планы и предположить, что 30% из них не будут реализованы в течение года. Всё это приемлемо, если вы знаете, насколько вы неточны.

Источник: https://blog.adamfurmanek.pl/2022/06/18/types-and-programming-languages-part-13/
День 1369.
Поговорили с Игорем Лабутиным о переводе книги «Entity Framework Core в действии». Как шёл процесс перевода, сложно ли это, зачем всё это надо и нужны ли вообще в наше время книги по технологиям.

Разговор вошёл в 59й выпуск подкаста RadioDotNet от 28 октября 2022 года.
Конкретно наш разговор на 01:26:00.
👍15
День 1370. #ЗаметкиНаПолях #AsyncTips
Блокировки и команда lock

Задача
Имеются общие данные. Требуется обеспечить безопасное чтение и запись этих данных из нескольких потоков.

Решение
Лучшее решение в такой ситуации — использование команды блокировки lock. Если поток входит в блок lock, то все остальные потоки не смогут войти в этот блок, пока блокировка не будет снята:
class MyClass
{
// Блокировка защищает поле _value.
private readonly object _mutex = new object();
private int _value;
public void Increment()
{
lock (_mutex)
{
_value = _value + 1;
}
}
}

В .NET существует несколько механизмов блокировки: Monitor, Spin, Lock и ReaderWriterLockSlim. В большинстве приложений эти типы блокировок практически никогда не должны использоваться напрямую. В частности, разработчики часто используют ReaderWriterLockSlim, даже когда такая сложность не является необходимой. Базовая команда lock нормально справляется с 99% случаев.

При использовании блокировок следует руководствоваться четырьмя важными рекомендациями:

1. Ограничьте видимость блокировки.
Объект, используемый в команде lock, должен быть приватным полем, которое никогда не должно быть доступным извне класса. Обычно есть не более одного поля блокировки на тип; если у вас их несколько, рассмотрите возможность рефакторинга этого типа на несколько типов. Блокировка может устанавливаться по любому ссылочному типу, но рекомендуется создавать отдельное поле специально для команды lock, как в примере выше. Если вы устанавливаете блокировку по другому экземпляру, убедитесь в том, что он является приватным для вашего класса; он не должен передаваться в конструкторе или возвращаться из get-метода свойства. Никогда не используйте lock(this) или lock с любым экземпляром Type или string; это может привести к взаимоблокировкам, доступным из другого кода.

2. Оставьте комментарий, что именно защищает блокировка.
Об этом шаге легко забыть во время первоначального написания кода, но когда кодовая база вырастет, это может оказаться важным.

3. Сократите до минимума объем кода, защищённого блокировкой.
Один из аспектов, на которые следует обращать внимание, — блокирующие вызовы при удержании блокировок. В идеале их быть вообще не должно.

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

Источник: Стивен Клири “Конкурентность в C#”. 2-е межд. изд. — СПб.: Питер, 2020. Глава 12.
👍17
Программа конференции DotNext 2022 Autumn полностью готова!

Конференция пройдет:
🌐 3–4 ноября онлайн
👥 20 ноября офлайн, в Москве

Вас ждут 26 докладов от спикеров из Альфа-Банка, Dodo Engineering, Тинькофф, Контура и других компаний.

Вот несколько примеров докладов:
✔️ Андрей Акиньшин, «Поговорим об описательной статистике перформанс-распределений»
✔️ Дмитрий Сошников, «Как научить вашего ребенка программировать (и не только)»
✔️ Вагиф Абилов, «Распределенный трейсинг OpenTelemetry вместо логирования всего подряд»

Участников ждут тематические дискуссии с другими участниками, интервью с экспертами, доклады от партнеров и много других активностей.

Промокод netdeveloper2022JRGpc дает скидку от 20% на билеты из категории «Для частных лиц».

Подробности и билеты на сайте — dotnext.ru
День 1371. #ЗаметкиНаПолях
Записи с Выражениями и Вычисляемыми Свойствами
Учитывая краткость записей и даже игнорируя другие их характеристики, когда нужен неизменяемый тип, проще всего использовать запись. Однако стоит помнить об их характеристиках, чтобы не получить неожиданные результаты.

Рассмотрим следующую запись:
public record SomeRecord(string Val)
{
public string CalcVal { get; } =
Val + " *calculated*";
}

Мы используем первичный конструктор для объявления и инициализации свойства Val, но у нас также есть другое свойство CalcVal, которое вычисляется с использованием Val. Вроде просто. Но что если мы используем with для создания другой записи:
var x = new SomeRecord("42");
var y = x with {Val = "69"};
Console.WriteLine(x);
Console.WriteLine(y);

Вот что получилось:
SomeRecord { Val = 42, CalcVal = 42 *calculated* }
SomeRecord { Val = 69, CalcVal = 42 *calculated* }


CalcVal во второй записи по-прежнему имеет то же значение, что и в первой. Это имеет смысл, так как при использовании выражения with запись клонируется, а новые значения получают только свойства, указанные в скобках.

Простой способ исправить это — использовать вычисляемое свойство:
public record SomeRecord(string Val)
{
public string CalcVal => Val + " *calculated*";
}

Это решает проблему и может быть допустимым решением в целом. Однако каждый раз, когда мы вызываем CalcVal значение будет вычисляться заново. В зависимости от того, как используется свойство, это может быть, а может и не быть проблемой, поскольку мы всегда повторяем одну и ту же логику и создаем новые объекты для возврата значения.

Источник: https://blog.codingmilitia.com/2022/09/01/beware-of-records-with-expressions-and-calculated-properties/
👍10
День 1372. #DevOps
Принципы, Реализации и Культура DevOps. Начало
Когда организации начинают использовать DevOps для разработки приложений, многие понимают DevOps только как часть инфраструктуры развёртывания и выбирают подмножество DevOps, вместо того чтобы понять культуру и технологические изменения, необходимые для успеха. Организации должны сначала определить, какой подход «Ops» лучше всего им подходит и интегрировать его в свои рабочие процессы. При этом они будут лучше координировать группы разработки и поддержки относительно общих целей и протоколов.

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

Вариации DevOps
1. DevOps: улучшение технических процессов и культуры
DevOps — это общий термин для операций, которые сочетают концепции Agile-разработки с ИТ-операциями и ускоряют разработку и развёртывание приложений в облачных средах. Хотя существуют разные поджанры DevOps, каждый поджанр представляет собой отдельное приложение методологии DevOps, направленное на то, чтобы сделать разработку более гибкой, стабильной и эффективной. Поскольку DevOps значительно сокращает жизненный цикл разработки, этот оптимизированный подход к разработке приложений довольно популярен.

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

3. NoOps: сокращение внутренних команд для операций
NoOps — это возвышенный взгляд на DevOps. Ощущение, что он настолько автоматизирован, что не требует отдельных специалистов внутри компании, а это означает, что ручное вмешательство в процессы не требуется. NoOps отличается от DevOps тем, что меньше внимания уделяется Agile и управлению процессами. Этот процесс предполагает, что разработчикам не нужно знать, как использовать автоматизированные инструменты, и использует методы непрерывной интеграции, позволяющие разработчикам сосредоточиться только на разработке приложений.

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

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

Метод GitOps также может похвастаться тем, что он поддерживается одними из самых инновационных облачных сервисов на рынке, что позволяет автоматизировать процесс создания обновления, тестирования на наличие ошибок, проверки кода и отправки обновления в производство. Поскольку Git — единственный источник достоверной информации о GitOps, крайне важно эффективно защитить репозиторий Git, чтобы избежать ненужных коммитов или пулл-реквестов.

Окончание следует…

Источник:
https://devops.com/devops-principles-implementations-and-culture/
👍13
День 1373. #DevOps
Принципы, Реализации и Культура DevOps. Окончание
Начало

5. CloudOps: реализация облачных функций
CloudOps пытается получить больше преимуществ от облачных функций, предлагаемых современными провайдерами. Поскольку CloudOps фокусируется на распределённой разработке и развёртывании, отсутствует единая точка отказа, что делает всю облачную среду более надёжной. Кроме того, некоторые части рабочего процесса в этом типе операций могут проходить без сохранения состояния, что делает его экономически эффективным, поскольку организации платят только за те ресурсы, которые они фактически используют. Несмотря на то, что CloudOps может быть эффективным с точки зрения времени и затрат, чрезвычайно важна правильная настройка конфигурации, чтобы этот подход был рабочим, что может означать дополнительные сложности при настройке конвейеров CI/CD.

6. CIOps: операции непрерывной интеграции
Эта последняя итерация DevOps, требующая настройки инфраструктуры, необходимой для поддержки нового кода, перед развёртыванием. Ручной ввод, необходимый для обеспечения правильной конфигурации заданий CI и места развёртывания, имеет как преимущества, так и недостатки. Контроль над инфраструктурой позволяет двум развёртываниям иметь разные конфигурации инфраструктуры. Основные недостатки заключаются в том, что требуемое ручное вмешательство оставляет место для ошибки, а необходимость привязки инструмента CI может представлять угрозу безопасности.

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

7. ITOps: автоматизация конвейеров управления ИТ
ИТ в общих чертах определяется как управление системами, обслуживающими конечных пользователей или сотрудников. Эти системы необходимы для ведения основного бизнеса. Принципиальная разница в том, что разработки тут практически нет. Если вашему бизнесу не требуется команда инженеров-программистов, и вы можете работать исключительно с готовыми приложениями, тогда ITOps для вас.

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

Выбор лучшей операции для организации
Выбор между вариантами DevOps — это вопрос поиска варианта, который лучше всего подходит для приложений, которые разрабатывает организация, и используемой ими облачной инфраструктуры. Хотя все вышеперечисленные стратегии Ops основаны на уникальных идеях, они, как правило, сосредоточены на технической стороне. DevOps же выделяется своей способностью всесторонне улучшать рабочие процессы и оптимизировать технические процессы, одновременно способствуя развитию культуры инноваций и сотрудничества в организации.

Источник: https://devops.com/devops-principles-implementations-and-culture/
👍5
День 1374.
Нерабочий день в РФ, пятница. Самое время повысить квалификацию. И команда конференции DotNext дарит вам эту возможность. Community Day - бесплатный билет на второй день online-конференции DotNext 2022 Autumn.

Послушать и пообщаться будет с кем. В программе (везде время московское):

15:00 - 16:00. Доклад Олега Сафонова (Тинькофф) «Пишем код, когда пишем код: source generator'ы»

15:00 - 16:00. Доклад Андрея Цветцих (Тинькофф) «Zero-downtime deployment и базы данных»

15:55 - 16:35. Интервью с Вадимом Мартыновым (Контур)

16:30 - 17:30. Доклад Романа Просина (Райффайзенбанк) «SkillsFlow: разработка системы управления навыками и компетенциями»

16:30 - 17:30. Дискуссия на тему «Объединяем .NET-разработчиков: как запустить и развить гильдии в компании» с участием Дмитрия Радченко (Почтатех), Дениса Фомина (Контур), Максима Смирнова (Альфа-Банк), Анны Морозовой (Dodo Engineering) и Алексея Мерсона (Тинькофф)

16:30 - 17:30. Доклад Гурия Самарина (Инжиниринговый дивизион Госкорпорации «Росатом») «Тестируем код, взаимодействующий с базой данных»

17:25 - 18:05. Интервью с Дмитрием Таболичем (IT_ONE)

18:00 - 19:00. Дискуссия на тему «Best practices для разработки Application Layer» с участием Андрея Парамонова (Dodo Engineering),
Андрея и Дениса Цветцих (Тинькофф, DevBrothers), Максима Аршинова (Хайтек Груп)

18:00 - 19:00. Доклад Романа Неволина (Sytac) «Пишем приложения, которые не ломаются в продакшене»

18:00 - 19:00. Доклад Андрея Акиньшина «Поговорим об описательной статистике перформанс-распределений»

Регистрируйтесь на Community Day по ссылке https://dotnext.ru/registration/personal/
👍4
День 1375. #ЗаметкиНаПолях #Юмор
Творим Дичь в C#. Часть 1
В этой короткой серии постов рассмотрим разные странные и зачастую бесполезные вещи, которые можно делать в C#.

Начнём с foreach для всего
Мы хотим выполнить foreach по целому числу, чтобы получить все его цифры:
foreach (var i in 435972)
{
Console.WriteLine(i);
}

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

Видите ли, foreach не требует от типа реализации интерфейса вроде IEnumerable или IEnumerator. Компилятору нужен всего лишь метод GetEnumerator, возвращающий объект итератора (подробнее про паттерн Итератор), в котором есть свойство Current и метод MoveNext. Давайте добавим его:
public static class Extensions
{
public static IntEnumerator
GetEnumerator(this int i) => new(i);
}

public struct IntEnumerator
{
private readonly List<int> list;
private int idx = -1;

public IntEnumerator(int num)
{
list = new List<int>();
while(num > 0)
{
list.Add(num % 10);
num /= 10;
}
list.Reverse();
}

public int Current => list[idx];

public bool MoveNext()
{
idx++;
return list.Count > idx;
}
}

Ничего сложного. Мы «добавляем» в тип int метод GetEnumerator, который возвращает IntEnumerator. IntEnumerator выполняет требования для цикла foreach. Этот итератор просто помещает отдельные цифры целого числа в список и возвращает их одну за другой по требованию.

В результате наш код скомпилируется и выведет:
4
3
5
9
7
2

Источник: https://steven-giesel.com/blogPost/5360d1c3-89f6-4a08-9ee3-6ddbe1b44236
👍28