CI/CD. Fail-fast підхід у Nuke

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всім привіт, мене звуть Владислав, я .Net Developer у Plarium. У цій статті я розповім, що таке fail-fast design, яким чином він реалізований у Nuke і як я бачу розвиток інструментів fail-fast у Nuke.

Я працюю в департаменті Game Platforms, який займається колоігровим розробленням. Ми розробляємо платформу Plarium Play, ігровий портал plarium.com, форум для взаємодії гравців, корисні штуки для внутрішнього використання, купу лендингів та різних мікросервісів.

Така велика кількість проєктів потребує надійної та масштабованої системи безперервної інтеграції та безперервного доставлення (CI/CD). Ми використовуємо зв’язку Jenkins та Nuke. Більшість логіки для збирання/доставлення конкретного проєкту міститься у Nuke. А Jenkins бере на себе функції UI-відображення всіх планів, тригери, пов’язані з репозиторіями, та налагодження взаємозв’язків між планами.

Для повторного застосування коду, що використовується в CI/CD, ми маємо внутрішню бібліотеку на базі Nuke. Вона трохи розширює стандартну функціональність фреймворку та містить Targets (у Nuke — одиничний крок збирання/доставлення). Оскільки наша бібліотека лише розширює Nuke, то спроєктована вона в межах підходів, які використовуються у фреймворку. Один із таких підходів — fail-fast design. Він дозволяє не витрачати час на свідомо не успішний процес та запускати процеси збирання/доставлення коду надійніше.

Що таке fail-fast design

Коли в програмі виникає помилка, програмісту важливо якнайшвидше знайти її в коді й виправити. Але знайти причину бага не завжди просто.

Один з інструментів, який прискорює пошук помилок, — fail-fast design. Jim Shore визначає його як підхід, у якому виклик винятків відбувається максимально близько до місця появи проблеми.

Розглянемо приклад коду на C# для наочності:

internal class Program
{
  static void Main(string[] args)
  {
    var instance = new SomeClass(null);
    instance.SomeMethod();
  }

  public class SomeClass
  {
    private readonly string _importantField;

    public SomeClass(string importantField)
    {
      _importantField = importantField;
    }

    public void SomeMethod()
    {
      Console.WriteLine(_importantField.Length);
    }
  }
}

У прикладі помилка NullReferenceException виникне всередині методу SomeMethod(), тому що _importantField має значення null. У реальному коді між ініціалізацією класу SomeClass() і викликом методу SomeMethod() кількість проміжних дій може бути набагато більшою, що зробить пошук причин бага складнішим.

Використання fail-fast підходу для цього прикладу:

public SomeClass(string importantField)
{
  _importantField = importantField ?? throw new  ArgumentNullException(nameof(importantField));
}

Відповідно _importantField перевіряється раніше.

Таким чином fail-fast design можна описати як принцип проєктування складних систем, за якого система на максимально ранньому етапі повідомляє про будь-які умови, які можуть вказувати на її відмову. Це робиться для того, щоб не допустити продовження потенційно хибного процесу.

Використання Fail-fast design у системах автоматизації збирання

Fail-fast підхід справедливий під час написання будь-якого коду, зокрема й для систем автоматизації збирання. Але код для CI/CD має кілька особливостей:

  • коротший цикл життя у порівнянні з іншими програмами. Він запускається, щоб зробити певну кількість дій та завершитися;
  • відсутність взаємодії з користувачем у процесі роботи — всі необхідні параметри код отримує на старті.

Як ці особливості впливають на fail-fast design? По-перше, для коду CI/CD «крихкість» не така критична як для інших застосунків. Швидше навпаки, це той випадок, коли важливо якомога раніше зупинити потенційно хибний процес (в ідеалі навіть не запускати).

По-друге, оскільки всі параметри програма отримує на старті, є можливість відразу зрозуміти, чи достатньо їх для роботи чи ні. Отже, можна навіть не починати потенційно помилковий процес. Так виконання перевірок (і в разі потреби зупинка процесу) зміщується на старт програми.

Плюси виявлення помилки на старті

Очевидний плюс — економія часу. Збирання чи доставлення коду — частина загального робочого процесу. Залежно від конкретного проєкту, вона може займати різну кількість часу, впливаючи на тривалість робочого процесу в цілому. У разі виконання об’ємних тестів цей час може суттєво збільшитися.

Окрім переваги в зменшенні часу, є ще одна вигода від використання такого підходу для коду CI/CD. Розберемо знайому кожному послідовність дій:

Якщо помилка відбувається на етапі «Подивитись», двері холодильника залишаються відчиненими, що явно негативно позначиться на роботі холодильника.

Ту ж проблему можна зустріти у планах доставлення коду. Розглянемо приклад плану доставлення вебзастосунку:

Що буде, якщо щось піде не так на кроці Deploy? Логічно, що план впаде і наш код не буде доставлений на потрібне оточення. Але крім цього варто врахувати, що вже було виконано кроки Build, Migration, StopIisPool. А значить у нас ще й вимкнений пул і застосунок не працює, хоча міг би.

Виконання якихось кроків під час доставлення коду може призвести до втрати працездатності застосунку, а fail-fast підхід навіть не дасть почати таке доставлення коду.

Спойлер

У прикладі вище фігурує міграція. Можливо, у когось виникло питання «Навіщо?», адже її можна виконати у момент старту застосунку.

Виконання міграції як етапу публікації застосунку — також реалізація принципу fail-fast. Припустімо, міграція містить якісь помилки й виконатися не може. Якщо ми будемо використовувати таку міграцію на старті застосунку, то отримаємо неробочий застосунок. А за підходу, вказаного на схемі, ми побачимо проблеми з міграцією до публікації.

Можна справедливо зауважити, що це проблеми пайплайну. Що це потрібно було б урахувати й, наприклад, зробити включення пулу обов’язковою дією незалежно від решти. Але fail-fast design спонукає до думок на кшталт: «Навіщо запускати те, що все одно впаде?» і «Чи можна було б зрозуміти, що збірка впаде, до її запуску?».

Недоречний жарт

Краще не замислюватися «Навіщо запускати те, що все одно впаде?», тому що тоді взагалі можна перестати писати код.

Що таке Nuke

Nuke — це open-source фреймворк для збирання та доставлення застосунків. Це консольний застосунок на C#, а це означає, що під час створення коду в ньому можна використовувати будь-які NuGet-пакети, всі принади C# і можливості IDE. Це й відрізняє Nuke від інших фреймворків. Наприклад, у Cake нічого з перерахованого вище використовувати не можна.

«З коробки» Nuke пропонує зручні способи:

  • передавання параметрів через аргументи командного рядка;
  • побудови взаємозв’язків між етапами збирання;
  • реалізації fail-fast design для параметрів;
  • візуалізації етапів збирання.

Також Nuke надає доступ до великої кількості CLI tools з описом згідно з офіційною документацією та механізму додавання власних CLI tools.

Більше про Nuke можна дізнатися на сайті, а вихідний код проєкту подивитися на GitHub.

Перевірка параметрів

У Nuke реалізований такий механізм: для кожного кроку можна задати необхідний параметр. Перед запуском процесу збирання/доставлення виконується перевірка, чи всі необхідні параметри передані. Якщо хоч одного параметра не вистачає, процес запущений не буде, а користувач отримає повідомлення із зазначенням причини.

Розгляньмо приклад:

private Target Deploy => _ => _
  .Requires(() => IisPoolName!= null)
  .Requires(() => IisPoolName) // спрощена перевірка на null
  .DependsOn(StopIisPool)
  .Executes(() => { });

Розшифровка

Target у Nuke — це делегат, через це і виходить така «сумнівна» конструкція у вигляді смайлика => _ =>

.DependsOn(StopIisPool) вказує на те, що етап збирання Deploy залежить від етапу StopIisPool, а значить перед виконанням Deploy повинен спочатку виконатись .

.Executes() — сама суть таргета, той код, який у ньому виконується.

Метод .Requires() використовується у прикладі двічі, щоб показати різні варіанти його виклику.

Метод показує, що є якась обов’язкова умова, яку необхідно перевірити перед виконанням усіх таргетів.

Виноска

Можливо, у вас виникне питання, чому параметр передається через лямбду?

Nuke має своєрідний життєвий цикл. Створення екземпляра класу та ініціалізація параметрів відбуваються не одночасно. Отже, якщо передати просто параметр, то в ньому буде непроініціалізоване значення (за замовчуванням). Передача лямбди дозволяє отримати значення параметра в момент виконання методу Requires.

Якщо ми тепер запустимо Nuke з доданим методом Requires, то отримаємо:

Як видно, жоден із таргетів не був виконаний.

Метод Requires приймає Func<bool>. Це означає, що можна перевіряти не лише параметри, а все, на що вистачить фантазії.

Додаткова інформація

Під час запуску Nuke необхідно передати кроки, які фреймворк має виконати. При цьому у файлах проєкту задаються зв’язки між кроками.

Наприклад, крок Deploy залежить від кроку Build. А для кроку Build необхідний параметр SolutionPath.

Тоді під час запуску збирання з параметрами Target Deploy ми побачимо повідомлення, що збирання не було розпочато через відсутність параметра SolutionPath.

Тобто Nuke спочатку вибудовує граф виконання кроків, а потім для кожного з кроків у графі здійснює перевірку наявності параметрів.

Перевірка послідовності збирання

Окрім методу Requires у Nuke є перевірка послідовності збирання. Послідовність задається за допомогою методів DependsOn, DependsFor, After, Before та їхніх Try версій.

Наприклад:

public Target SomeTarget => _ => _
  .DependsOn(NextTarget)
  .Executes(() => Console.WriteLine("SomeTarget"));

public Target NextTarget => _ => _
  .DependsOn(SomeTarget)
  .Executes(() => Console.WriteLine("NextTarget"));

Тут SomeTarget залежить від NextTarget, а NextTarget — від SomeTarget. Виходить кругова залежність. Якщо запустити цей приклад, то отримаємо:

Ця перевірка також виконується на старті застосунку, до початку виконання етапів збирання.

Перевірка CLI tools

У Nuke є досить зручний спосіб роботи з CLI tools, детальніше про нього можна почитати тут.

Розгляньмо приклад, у якому спробуємо використати docker CLI на пристрої, де його немає:

[PathExecutable]
public Tool Docker;
 
public Target SomeTarget => _ => _.Executes(() => Docker.Invoke("info"));

Тут ми оголошуємо CLI tool через поле Docker і використовуємо на етапі збирання SomeTarget. У результаті отримуємо:

Зеленим виділено частину, яка була виконана до початку виконання етапів збирання. Червоним — виконання кроків збирання.

Тобто Nuke до початку збирання виконав перевірку, чи є такий тип CLI чи ні, але обмежився повідомленням про можливу проблему. Далі вже безпосередньо під час спроби використовувати CLI tool отримуємо помилку і збирання закінчується невдало.

Використовувати fail-fast підхід для CLI tool можна так само як і для параметрів, додавши метод Requires:

[PathExecutable]
public Tool Docker;
 
public Target SomeTarget => _ => _
  .Requires(() => Docker)
  .Executes(() => Docker.Invoke("info"));

Тоді під час спроби запустити збирання отримаємо:

Єдиний мінус, на який я хочу звернути увагу, — перевірка відсутності CLI tool та повідомлення про це трапляється завжди, навіть якщо ви викликаєте ті етапи збирання, які не використовують CLI tool. Це може трохи збивати з пантелику під час переглядання логів, але критичною проблемою не є.

Можливості розширення fail-fast інструментів

Щоб розширити наявні fail-fast інструменти, потрібно розібратися в життєвому циклі проєкту Nuke. Точкою входу в проєкт Nuke є єдиний клас, успадкований від NukeBuild:

static int Main(string[] args) => Execute<Program>(x => x.DefaultTarget);

У методі Execute<T> через generic-аргумент передається Nuke-клас. Як видно з прикладу, ініціалізація класу відбувається під капотом Nuke і передати додаткові аргументи в нього, щоб налаштувати клас, не можна. Ця інформація стане нам у пригоді далі.

Раніше ми визначили, що суть fail-fast підходу — якомога раніше, в ідеалі до початку виконання кроків збирання, зрозуміти, що збирання завершиться невдало, і не запускати його. Також ми розібралися, що вже реалізовано в Nuke, а саме:

Нагадаю, що всі три вищезгадані типи fail-fast перевірок проходять до початку виконання етапів збирання. Відповідно, за розширення fail-fast інструментів слід запускати їх перевірки до початку виконання етапів збирання.

Розділімо помилки, які можуть виникати в коді CI/CD, на групи:

  • всередині коду;
  • пов’язані з зовнішніми сервісами: доступ до БД, доступ до зовнішніх сервісів за допомогою API, використання CLI tools;
  • пов’язані з середовищем: відсутність змінних середовища, прав адміністратора, доступу до певних папок;
  • пов’язані з параметрами: відсутність або не валідне значення параметра.

Запобігти помилкам всередині коду не вдасться, але до інших трьох пунктів цього списку можна застосувати fail-fast підхід.

Підготовка до розширення fail-fast. Перенесення логіки до класів-сервісів

У невеликих проєктах досить просто розміщувати всю логіку всередині методу .Execute(). Але зі збільшенням кількості Target`ів та логіки в них виникають проблеми з перевикористанням коду та з тим, що Nuke-клас починає ставати дуже великим. У такому разі правильніше винести всю логіку в окремі класи й просто перевикористовувати їх усередині Target`ів.

Щоб перевірити умовний сервіс, який використовується в третьому етапі збирання, необхідно гарантувати, що й на етапі перевірки, й на етапі кроку збирання ми будемо використовувати один і той самий екземпляр сервісу.

Найпростіше рішення — зробити такі класи-сервіси статичними. Як уже говорилося раніше, екземпляр Nuke-класу створюється «під капотом» фреймворку, а отже передати до нього екземпляри класів-сервісів неможливо. Альтернативним варіантом використання статичних класів-сервісів є dependency injection (DI). Я віддаю перевагу саме цьому підходу.

Nuke як фреймворк не має готової реалізації DI, тому я використовую надбудову на базі NuGet-пакету Microsoft.Extensions.DependencyInjection. Але, якщо замінити іншу реалізацію DI, нічого принципово не зміниться. Я використовую DI виключно для того, щоб створити всі необхідні класи до початку збирання. І щоб бути впевненим, що в етапах збирання використовуватиму той самий екземпляр класу, що й перевіряв.

Приклад додавання dependency injection у Nuke

Ще одна особливість проєктів Nuke — це те, що кроки збирання та параметри можуть бути описані або всередині класу, який успадковується від NukeBuild, або в інтерфейсах як реалізація за замовчуванням. А отже доступ до DI-контейнера має бути й у класі, який успадковується від NukeBuild, і в інтерфейсів. Логічно зробити цей клас статичним.

Ось приблизний код такого класу:

public static class NukeDependencies
{
  private static bool _isAlreadyInitialize;
  private static IServiceProvider Container { get; private set; }
 
  public static T Get<T>() => Container .GetRequiredService<T>();
 
  public static object Get(Type type) => Container .GetRequiredService(type);
 
  internal static void RegisterDependencies(Action<IServiceCollection> registrations)
  {
    if (_isAlreadyInitialize)
      throw new Exception("Can't register dependencies twice");
 
    var services = new ServiceCollection();
    registrations.Invoke(services);
    services.AddSingleton<IServiceCollection>(services);
    Container = services.BuildServiceProvider();
    _isAlreadyInitialize = true;
  }
}

І Nuke-клас:

public abstract class NukeBuildWithDependencyInjection()
{
  protected NukeBuildWithDependencyInjection()
  {
NukeDependencies.RegisterDependencies(AddOrOverrideDependencies);
  }
protected virtual void AddOrOverrideDependencies(IServiceCollection   services)
  {
    services.AddSingleton<INukeBuild>(this);
  }
}

Реєстрація в DI-контейнері відбувається через метод AddOrOverrideDependencies, який можна перевизначити у класах-спадкоємцях, додаючи нові залежності.

Отримувати екземпляри класів із DI-контейнера можна через виклик NukeDependencies.Get<T>() всередині методу .Execute().

Таким чином вийде щось на кшталт:

//Інтерфейс, у якому описано етап збирання та його параметри 
public interface ICanDoSomething: INukeBuild
{
 [Parameter]
 public string Test => TryGetValue(() => Test);
 
 public Target DefaultTarget => _ => _
   .Requires(() => NukeDependencies.Get<SomeService>())
   .Requires(() => NukeDependencies.Get<SomeOtherService>())
   .Executes(() => 
   {
     var someService = NukeDependencies.Get<SomeService>();
     var someOtherService = NukeDependencies.Get<SomeOtherService>();
     someService.DoWork(Test);
     someOtherService.DoOtherWork();
   });
}
 
//Nuke-клас використовує інтерфейс вище та dependency injection
class Program: NukeBuildWithDependencyInjection, ICanDoSomething
{
  static int Main(string[] args) =>
     Execute<Program>(x => ((ICanDoSomething)x).DefaultTarget);
 
  protected override void AddOrOverrideDependencies(IServiceCollection services)
 {
   base.AddOrOverrideDependencies(services);
   //Реєструємо послуги, які плануємо отримувати з контейнера
   services.AddSingleton<SomeService>();
   services.AddSingleton<SomeOtherService>();
 }
}

На перший погляд, реалізація такого підходу (винесення логіки в класи-сервіси та реєстрація їх у DI-контейнері) принципово для fail-fast нічого не змінює. Але насправді відбувається наступне:

  • Автоматично перевіряються всі залежності для класів-сервісів, зареєстрованих у DI-контейнері.
  • Завдяки .Requires(() => NukeDependencies.Get<Т>()) з’являється можливість указувати, які класи-сервіси необхідні кожному з етапів збирання, і перевіряти до початку всіх етапів.
  • Опис перевірки переноситься всередину класу-сервісу, а саме до його конструктора. Завдяки цьому опис етапів збирання стає простішим та наочнішим — можна не дублювати методи Requires у різних кроках збирання.
  • Нема потреби виконувати перевірку параметрів через метод Requires. Параметри можна отримати всередині класів-сервісів через інтерфейс INukeBuild, перетворивши його до необхідного інтерфейсу з параметром. Набагато логічніше виконувати перевірки параметрів усередині класів-сервісів, тому що там наявно видно, де і як вони використовуються.

Але отримана система також має низку недоліків, а саме:

  • Отримання екземплярів класів із контейнерів усередині методу Execute виглядає досить громіздко і не дуже звично.
  • Необхідно для кожного етапу збирання явно вказувати (через метод Requires), які класи-сервіси будуть у ньому використовуватись (у методі Execute). Якщо не вказати таким чином один із класів-сервісів, то fail-fast для нього не працюватиме.
  • Якщо всередині методу Execute буде використовуватися, наприклад, 20 класів з DI-контейнера, опис етапу збирання (Target) буде громіздким і погано читатиметься через 20ь однотипних Requires.

Для усунення вищезгаданих недоробок застосовується підхід «тонкого Execute», який доповнює описану вище систему.

Приклад реалізації «тонкого Execute»

«Тонкий Еxecute» (за аналогією з «тонким клієнтом» або «тонким контролером») — підхід, за якого метод Execute повинен містити мінімум логіки та за фактом делегувати роботу потрібному класу. Щоб використовувати «тонкий Execute», визначимо інтерфейс для класів, які будуть містити логіку етапів збирання:

public interface INukeTargetAction
{
  void Invoke();
}

І метод розширення для зручнішого використання:

public static ITargetDefinition Executes<T>(this ITargetDefinition targetDefinition) where T : INukeTargetAction
{
  targetDefinition.Requires(() => NukeDependencies.Get<T>() != null);
  targetDefinition.Executes(() => NukeDependencies.Get<T>().Invoke());
  return targetDefinition;
}

Тепер крок збирання виглядатиме так:

public interface ICanDoSomething: INukeBuild
{
  [Parameter]
  public string Test => TryGetValue(() => Test);
 
  public Target DefaultTarget => _ => _.Executes<TargetAction>();
 
  public class TargetAction: INukeTargetAction
  {
    private readonly string _someField;
 
    public TargetAction(INukeBuild nuke)
    {
      //Зверніть увагу, перевірка параметра відбувається всередині TargetAction, а не через метод Requires
      var parameterContainer = (ICanDoSomething)nuke;
      _someField = parameterContainer .Test ?? throw new  ArgumentException(nameof(interfaceWithParameter.Test));
    }
 
    public void Invoke()
    {
      Console.WriteLine($"I do some work now, _someField - {_someField}");
    }
  }
}

І Nuke-клас, який використовує цей крок збирання через імплементацію інтерфейсу:

class Program: NukeBuildWithDependencyInjection, ICanDoSomething
{
  static int Main(string[] args) =>
      Execute<Program>(x => ((ICanDoSomething)x).DefaultTarget);
 
 protected override void AddOrOverrideDependencies(IServiceCollection services)
 {
   base.AddOrOverrideDependencies(services);
   services.AddSingleton<ICanDoSomething.TargetAction>();
 }
}

Я хотів би звернути увагу, що все, що стосується логіки етапу збирання, міститься в класі TargetAction, а в Nuke-класі — лише реєстрація залежностей у контейнер.

Цей підхід дає такі переваги:

  • У будь-якому місці коду використовуватиметься один і той самий екземпляр класу, раніше зареєстрований у контейнері (крім випадків, коли під час реєстрації не вказано інше).
  • Завдяки методу розширення Executes<T>; перевірка класу відбувається до початку виконання кроків складання. Автоматично перевіряються всі залежності зазначеного класу. Необхідності вказувати вручну всі класи, які потрібно перевірити, немає.
  • Інтуїтивно зрозумілий спосіб отримання залежностей із DI-контейнера — просто через конструктор класу, без «милиць».
  • Перевірка класу відбувається у конструкторі і її формат може бути досить гнучким. Можна, як у прикладі, легко валідувати характеристики. А в разі складної логіки перевірки можна винести її в окремий метод та викликати його в конструкторі.
  • Можливість тестування коду всередині методу Execute. За замовчуванням код у метод Execute передається у вигляді лямбди, що ускладнює його тестування.
  • Зміщення логіки перевірки з опису кроку збирання (поля Target) у конструктор кожного класу-сервісу. Це дуже органічно, що, проєктуючи клас, ми описуємо в ньому логіку його перевірки.

Приклад використання розширеного fail-fast підходу

У попередніх двох розділах я описав, як створити систему, в якій реалізується розширений підхід. Під час створення логіки вашого особистого Nuke-проєкту в межах цієї системи слід описувати логіку перевірки класів-сервісів усередині конструктора.

Перевірки всередині конструктора можуть бути абсолютно різними — все залежить від функціонального призначення цього класу. Наприклад, можна зробити пробний запит до зовнішніх ресурсів, спробувати відкрити з’єднання з базою даних або створити файл у потрібній директорії.

Застосовуючи описаний вище підхід, можна отримати зручну та наочну систему, яка доповнює та використовує вбудований у Nuke fail-fast design. Не скажеш, що це підхід реалізує автоматичну перевірку повною мірою, оскільки всередині кожного класу-сервісу необхідно описати логіку перевірки. Але автоматичності системі додає використання IServiceProvider, під час формування якого для кожного сервісу будуть будуватися (а значить і перевірятися) всі залежності, і нема потреби прописувати які, саме сервіси необхідно перевірити.

Насамкінець пропоную розглянути приклад реалізації абстрактного http-клієнта для Nuke.

Припустімо, що в Nuke-проекті необхідно використовувати багато зовнішніх сервісів. Тоді логічно зробити абстрактний клієнт, який на старті виконуватиме перевірку доступності по хосту, а заразом і правильність самого хоста.

Такий клас міг би виглядати так:

public abstract class AbstractClient: IDisposable
{
  protected readonly Uri BaseUri;
  protected readonly Ping PingSender;
  protected readonly HttpClient HttpClient;
 
  protected AbstractClient(string baseUrl, Ping pingSender)
  {
    BaseUri = new Uri(baseUrl);
    PingSender = pingSender;
 
    HttpClient = new HttpClient
    {
      BaseAddress = BaseUri,
    };
 
    PingHost();
  }
 
  private void PingHost()
  {
    PingReply response;
    var host = BaseUri.Host;
 
    try
    {
      response = PingSender.Send(host);
    }
    catch (Exception exception)
    {
//Винятково для того, щоб зробити помилку більш зрозумілою та легкою до прочитання
      throw new Exception($"Some problem while create service [{GetType().Name}], ping [{host}] exception - {exception.Message}");
    }
    if (response == null)
      throw new Exception($"Some problem while create service [{GetType().Name}], ping [{host}] response is null");
 
    if (response.Status != IPStatus.Success)
      throw new Exception($"Some problem while create service [{GetType().Name}], ping [{host}] response status is {response.Status}");
 
    Console.WriteLine($"External service [{host}] pinged successed");
  }
  public void Dispose()
  {
    PingSender?.Dispose();
    HttpClient?.Dispose();
  }
 }
}

Реалізуємо абстрактний клас в умовному тестовому клієнті:

public class TestClient : AbstractClient
{
  //Звертаю увагу, що BaseUrl для клієнта передається
  //як параметр. Це може бути корисно у випадках, коли url може
  //змінюватися в залежності від оточення
  public TestClient(INukeBuild nuke, Ping pingSender) : base(((ICanTestClient)nuke).TestExternalServiceUrl, pingSender)
  {
    //Місце для логіки перевірки або налаштування конкретно цього клієнта
    //Використовується за необхідності
  }
  //Місце для методів клієнта
}

На цьому етапі ми маємо абстрактний клієнт та його реалізацію. При цьому вони дуже слабко пов’язані з Nuke. Єдиний зв’язок — це отримання URL через інтерфейс INukeBuild.

Додаткова інформація

У своїх прикладах я в конструктори класів-сервісів у разі необхідності отримання параметрів прокидаю інтерфейс INukeBuild. А вже потім перетворюю у необхідний для отримання параметра інтерфейс.

Можливе інше рішення: відразу отримувати через конструктор необхідний для отримання параметра інтерфейс. Але тоді доведеться реєструвати в DI-контейнері всі інтерфейси, що імплементуються nuke-класом.

Є й інші варіанти, але в будь-якому випадку це справа смаку та звички.

Створімо інтерфейс з реалізацією за замовчуванням для описування етапу збирання, який буде використовувати клієнт, створений вище:

public interface ICanTestClient: INukeBuild
{
  //Опис параметра, який передаватиметься як аргумент
  //командного рядка
  [Parameter] public string TestExternalServiceUrl => TryGetValue(() => TestExternalServiceUrl);
 
  //Створення кроку збирання в рамках фреймворку nuke
  public Target DefaultTarget => _ => _.Executes<TargetAction>();
 
  //Клас, що містить логіку етапу збирання
  public class TargetAction : INukeTargetAction
  {
    private readonly TestClient _testClient;
 
    //Отримуємо залежності із DI-контейнера
    public TargetAction(TestClient testClient)
    {
      _testClient = testClient;
    }
 
    public void Invoke()
    {
      //Опис етапу збирання в такому випадку дуже скромний
      Console.WriteLine("I do work");
    }
  }
}

Тепер додаємо цей крок збирання в Nuke-клас (через імплементацію інтерфейсу) та реєструємо залежності у DI-контейнері.

public class Program: NukeBuildWithDependencyInjection, ICanTestClient //Додаємо інтерфейс із кроком збирання
{
  public static void Main() => Execute<Program>(x => ((ICanTestClient)x).DefaultTarget);
 
  protected override void AddOrOverrideDependencies(IServiceCollection services)
  {
    //Залишаємо всі залежності базового класу
    base.AddOrOverrideDependencies(services);
    //Додаємо залежності для ICanTestClient
    services.AddSingleton<Ping>();
    services.AddSingleton<TestClient>();
    services.AddSingleton<ICanTestClient.TargetAction>();
  }
 }
}

Тепер з метою тестування запускаємо Nuke-проєкт з аргументами командного рядка –DefaultTarget –TestExternalServiceUrl {необхідне значення}.

У першому випадку спробуємо запустити, передавши справний і доступний хост, наприклад https://google.com:

Бачимо, що спочатку відбувається перевіряння доступності хоста google.com, потім успішно виконується крок збирання DefaultTarget і збирання закінчується успішно.

Тепер спробуємо передати для TestExternalServiceUrl якийсь заздалегідь неробочий хост, наприклад https://aaaaaaakkkkkkk.com:

Під час спроби створити TestClient виникає помилка, пов’язана з недоступністю хоста aaaaaaakkkkkkk.com. При цьому кроки збирання не запускаються (а значить fail-fast спрацював), і збирання закінчується неуспішно. Ми ніде явно не вказували, що Nuke має перевірити TestClient, це сталося автоматично, оскільки код спроєктований у межах раніше запропонованої системи.

Висновок

Підхід fail-fast гарно працює й у повсякденному програмуванні — він допомагає формувати адекватні стектрейси та швидше знаходити помилки в коді. Але у випадку з CI/CD він має особливість: виконання перевірок працездатності та параметрів до запуску кроків збирання/доставлення.

Nuke у цьому плані дозволяє зручно перевіряти параметри, послідовність виконання кроків та наявність CLI tools. Але визначати, який крок збирання від якого параметра залежить, доведеться самим.

Способи розширення fail-fast підходу, описані в статті, — це своєрідний апгрейд Nuke. Його основна мета — створити можливість перевіряти класи-сервіси до початку виконання етапів збирання. Отриманий підхід має додаткові переваги, наприклад спрощення тестування як окремих target`ів, так і логіки всередині класів-сервісів.

Радий почути вашу думку щодо цієї теми в коментарях!

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі