Оригінал статті опубліковано на DOU.
Всім привіт, мене звуть Владислав, я .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. 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 підхід справедливий під час написання будь-якого коду, зокрема й для систем автоматизації збирання. Але код для CI/CD має кілька особливостей:
Як ці особливості впливають на fail-fast design? По-перше, для коду CI/CD «крихкість» не така критична як для інших застосунків. Швидше навпаки, це той випадок, коли важливо якомога раніше зупинити потенційно хибний процес (в ідеалі навіть не запускати).
По-друге, оскільки всі параметри програма отримує на старті, є можливість відразу зрозуміти, чи достатньо їх для роботи чи ні. Отже, можна навіть не починати потенційно помилковий процес. Так виконання перевірок (і в разі потреби зупинка процесу) зміщується на старт програми.
Очевидний плюс — економія часу. Збирання чи доставлення коду — частина загального робочого процесу. Залежно від конкретного проєкту, вона може займати різну кількість часу, впливаючи на тривалість робочого процесу в цілому. У разі виконання об’ємних тестів цей час може суттєво збільшитися.
Окрім переваги в зменшенні часу, є ще одна вигода від використання такого підходу для коду CI/CD. Розберемо знайому кожному послідовність дій:
Якщо помилка відбувається на етапі «Подивитись», двері холодильника залишаються відчиненими, що явно негативно позначиться на роботі холодильника.
Ту ж проблему можна зустріти у планах доставлення коду. Розглянемо приклад плану доставлення вебзастосунку:
Що буде, якщо щось піде не так на кроці Deploy
? Логічно, що план впаде і наш код не буде доставлений на потрібне оточення. Але крім цього варто врахувати, що вже було виконано кроки Build, Migration, StopIisPool
. А значить у нас ще й вимкнений пул і застосунок не працює, хоча міг би.
Виконання якихось кроків під час доставлення коду може призвести до втрати працездатності застосунку, а fail-fast підхід навіть не дасть почати таке доставлення коду.
У прикладі вище фігурує міграція. Можливо, у когось виникло питання «Навіщо?», адже її можна виконати у момент старту застосунку. Виконання міграції як етапу публікації застосунку — також реалізація принципу fail-fast. Припустімо, міграція містить якісь помилки й виконатися не може. Якщо ми будемо використовувати таку міграцію на старті застосунку, то отримаємо неробочий застосунок. А за підходу, вказаного на схемі, ми побачимо проблеми з міграцією до публікації.
Можна справедливо зауважити, що це проблеми пайплайну. Що це потрібно було б урахувати й, наприклад, зробити включення пулу обов’язковою дією незалежно від решти. Але fail-fast design спонукає до думок на кшталт: «Навіщо запускати те, що все одно впаде?» і «Чи можна було б зрозуміти, що збірка впаде, до її запуску?».
Краще не замислюватися «Навіщо запускати те, що все одно впаде?», тому що тоді взагалі можна перестати писати код.
Nuke — це open-source фреймворк для збирання та доставлення застосунків. Це консольний застосунок на C#, а це означає, що під час створення коду в ньому можна використовувати будь-які NuGet-пакети, всі принади C# і можливості IDE. Це й відрізняє Nuke від інших фреймворків. Наприклад, у Cake нічого з перерахованого вище використовувати не можна.
«З коробки» Nuke пропонує зручні способи:
Також 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
. Виходить кругова залежність. Якщо запустити цей приклад, то отримаємо:
Ця перевірка також виконується на старті застосунку, до початку виконання етапів збирання.
У 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 інструменти, потрібно розібратися в життєвому циклі проєкту 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, на групи:
Запобігти помилкам всередині коду не вдасться, але до інших трьох пунктів цього списку можна застосувати fail-fast підхід.
У невеликих проєктах досить просто розміщувати всю логіку всередині методу .Execute()
. Але зі збільшенням кількості Target-ів та логіки в них виникають проблеми з перевикористанням коду та з тим, що Nuke-клас починає ставати дуже великим. У такому разі правильніше винести всю логіку в окремі класи й просто перевикористовувати їх усередині Target-ів.
Щоб перевірити умовний сервіс, який використовується в третьому етапі збирання, необхідно гарантувати, що й на етапі перевірки, й на етапі кроку збирання ми будемо використовувати один і той самий екземпляр сервісу.
Найпростіше рішення — зробити такі класи-сервіси статичними. Як уже говорилося раніше, екземпляр Nuke-класу створюється «під капотом» фреймворку, а отже передати до нього екземпляри класів-сервісів неможливо. Альтернативним варіантом використання статичних класів-сервісів є dependency injection (DI). Я віддаю перевагу саме цьому підходу.
Nuke як фреймворк не має готової реалізації DI, тому я використовую надбудову на базі NuGet-пакету Microsoft.Extensions.DependencyInjection
. Але, якщо замінити іншу реалізацію DI, нічого принципово не зміниться. Я використовую DI виключно для того, щоб створити всі необхідні класи до початку збирання. І щоб бути впевненим, що в етапах збирання використовуватиму той самий екземпляр класу, що й перевіряв.
Ще одна особливість проєктів 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 нічого не змінює. Але насправді відбувається наступне:
.Requires(() => NukeDependencies.Get<Т>())
з’являється можливість указувати, які класи-сервіси необхідні кожному з етапів збирання, і перевіряти до початку всіх етапів.Requires
у різних кроках збирання.Requires
. Параметри можна отримати всередині класів-сервісів через інтерфейс INukeBuild
, перетворивши його до необхідного інтерфейсу з параметром. Набагато логічніше виконувати перевірки параметрів усередині класів-сервісів, тому що там наявно видно, де і як вони використовуються.Але отримана система також має низку недоліків, а саме:
Execute
виглядає досить громіздко і не дуже звично.Requires
), які класи-сервіси будуть у ньому використовуватись (у методі Execute
). Якщо не вказати таким чином один із класів-сервісів, то fail-fast для нього не працюватиме.Execute
буде використовуватися, наприклад, 20 класів з DI-контейнера, опис етапу збирання (Target
) буде громіздким і погано читатиметься через 20 однотипних Requires
.Для усунення вищезгаданих недоробок застосовується підхід «тонкого 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>;
перевірка класу відбувається до початку виконання кроків складання. Автоматично перевіряються всі залежності зазначеного класу. Необхідності вказувати вручну всі класи, які потрібно перевірити, немає.Execute
. За замовчуванням код у метод Execute
передається у вигляді лямбди, що ускладнює його тестування.Target
) у конструктор кожного класу-сервісу. Це дуже органічно, що, проєктуючи клас, ми описуємо в ньому логіку його перевірки.У попередніх двох розділах я описав, як створити систему, в якій реалізується розширений підхід. Під час створення логіки вашого особистого 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
виникає помилка, пов’язана з недоступністю хоста https://aaaaaaakkkkkkk.com. При цьому кроки збирання не запускаються (а значить fail-fast спрацював), і збирання закінчується неуспішно. Ми ніде явно не вказували, що Nuke має перевірити TestClient
, це сталося автоматично, оскільки код спроєктований у межах раніше запропонованої системи.
Підхід fail-fast гарно працює й у повсякденному програмуванні — він допомагає формувати адекватні стектрейси та швидше знаходити помилки в коді. Але у випадку з CI/CD він має особливість: виконання перевірок працездатності та параметрів до запуску кроків збирання/доставлення.
Nuke у цьому плані дозволяє зручно перевіряти параметри, послідовність виконання кроків та наявність CLI tools. Але визначати, який крок збирання від якого параметра залежить, доведеться самим.
Способи розширення fail-fast підходу, описані в статті, — це своєрідний апгрейд Nuke. Його основна мета — створити можливість перевіряти класи-сервіси до початку виконання етапів збирання. Отриманий підхід має додаткові переваги, наприклад спрощення тестування як окремих target`ів, так і логіки всередині класів-сервісів.