Konsol Uygulaması ve CLI Aracı Yazmak: args, System.CommandLine, Exit Code, Renkli Çıktı ve Generic Host
İyi bir CLI aracı args'ı elle parse etmez, sonucu stdout'a ve hataları stderr'e yazar, her durum için belgelenmiş bir exit code döner ve Ctrl+C'de yarım dosya bırakmaz. Bookstore'un bookstore-admin import-prices aracını System.CommandLine 3.0 ile yazıyor; yardım ve tab completion'ı, exit code sözleşmesini, SIGINT/SIGTERM ile iptali, NO_COLOR ve .NET 11'in FORCE_COLOR desteğini ve Generic Host ile DI ve logging'i gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, System.CommandLine 3.0.0 ve Microsoft.Extensions.Hosting 10.0.15 ile derlenip denenmiştir; FORCE_COLOR örneği .NET 11 runtime'ı (11.0.0) ile çalıştırılmıştır. Çıktılar bir Linux container'ından alınmıştır.
Önce Tahmin Et
Bookstore (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) yayınevlerinden gelen fiyat listelerini bir konsol aracıyla içeri alıyor. Aracın ilk sürümü argümanları herkesin ilk yazdığı şekilde okuyordu:
// Herkesin yazdığı ilk sürüm: komut, dosya, sonra bayraklar.
private static (string File, bool DryRun) ParseManually(string[] args)
{
string file = args[1];
bool dryRun = args.Contains("--dry-run");
return (file, dryRun);
}
Dört farklı komut satırı için ne olur? Özellikle ikincisinde fiyatlar değişir mi?
import-prices prices.csv --dry-run -> file=prices.csv, dryRun=True
import-prices prices.csv --dryrun -> file=prices.csv, dryRun=False
import-prices --dry-run prices.csv -> file=--dry-run, dryRun=True
import-prices -> IndexOutOfRangeException
En tehlikelisi ikinci satır: kullanıcı "sadece kontrol et" demek istedi, tireyi unuttu ve araç hiçbir uyarı vermeden fiyatları gerçekten değiştirirdi. Üçüncü satırda sıralama değişince bayrak dosya adı oldu; dördüncüde kullanıcıya ne yapması gerektiğini söyleyen bir mesaj yerine stack trace gördü. Elle yazılan parser her yeni seçenekte büyür ve bu hataların hepsini tek tek kapatmak gerekir.
Bu yazıda bu aracı gerçek bir CLI (komut satırı aracı) hâline getiriyoruz: System.CommandLine ile komut, argüman ve seçenek tanımlamak, yardım metni ve tab completion, exit code sözleşmesi, hataları stderr'e yazmak, Ctrl+C ile iptal edildiğinde yarım dosya bırakmamak, renkli çıktı ve .NET 11'in FORCE_COLOR desteği, son olarak Generic Host ile DI (dependency injection; bağımlılıkların class'lara dışarıdan verilmesi), configuration ve logging. Hedef komut şu:
bookstore-admin import-prices prices.csv --dry-run
System.CommandLine ile Komutlar
System.CommandLine, .NET ekibinin dotnet CLI'ında da kullandığı parser library'si. Yıllarca beta olarak kaldı; 2.0.0 Kasım 2025'te kararlı oldu. Bu yazıyı yazarken NuGet'teki en güncel kararlı sürüm 2026-11-10'da yayımlanan 3.0.0; aynı gün 2.0 hattı da 2.0.15 ile güncellendi. İki sürümün public API'sini reflection ile karşılaştırdım: 3.0.0 yalnız ekleme yapıyor (Argument.CaptureRemainingTokens, StringComparer alan AcceptOnlyFromAmong overload'ları, RootCommand.HelpName), kaldırılan bir üye yok. Bu yazıdaki kod 2.0.15 ile de değişmeden derlendi. Paketi projene eklemen yeterli:
dotnet add src/Bookstore.Admin package System.CommandLine --version 3.0.0
import-prices komutu bir argüman (dosya) ve üç seçenekten oluşuyor:
Argument<FileInfo> file = new("file") { Description = "CSV file with isbn,price rows" };
file.AcceptExistingOnly();
Option<bool> dryRun = new("--dry-run", "-n") { Description = "Validate only; do not change any price" };
Option<int> maxErrors = new("--max-errors")
{
Description = "Stop reading after this many invalid rows",
DefaultValueFactory = _ => 10,
};
Option<FileInfo> report = new("--report")
{
Description = "Where to write the applied changes",
DefaultValueFactory = _ => new FileInfo("import-report.csv"),
};
Command command = new("import-prices", "Import new book prices from a CSV file") { file, dryRun, maxErrors, report };
command.SetAction(async (parseResult, cancellationToken) =>
{
FileInfo input = parseResult.GetValue(file)!;
bool isDryRun = parseResult.GetValue(dryRun);
PriceImporter importer = services.GetRequiredService<PriceImporter>();
try
{
ImportResult result = await importer.ImportAsync(input.FullName, isDryRun, parseResult.GetValue(maxErrors),
parseResult.GetValue(report)!.FullName, cancellationToken);
foreach (string error in result.Errors)
ConsoleOutput.Error(error);
if (result.Errors.Count > 0)
{
ConsoleOutput.Error($"{result.Errors.Count} problem(s) in {input.Name}; no price was changed");
return ExitCodes.InvalidData;
}
ConsoleOutput.Success(isDryRun
? $"dry run: {result.ValidRows.Count} of {result.RowsRead} rows are valid; nothing was changed"
: $"updated {result.Applied} prices; report: {parseResult.GetValue(report)!.Name}");
return ExitCodes.Success;
}
catch (OperationCanceledException)
{
ConsoleOutput.Warning("canceled; the report was not completed");
return ExitCodes.Canceled;
}
});
return command;
Argument konumla, option adıyla eşleşir; sıranın önemi kalmaz. Tipi generic parametre belirler: Option<int> "abc" değerini kendisi reddeder, Argument<FileInfo> yolu FileInfo'ya çevirir ve AcceptExistingOnly() dosyanın var olduğunu parse aşamasında kontrol eder. SetAction'a verilen async lambda bir CancellationToken alır ve int döner: bu sayı process'in exit code'u olur. Kök komut iki alt komutu bir araya getiriyor; ikincisi check-isbn, serinin 1. ve 2. yazılarında yazdığımız Isbn tipini kullanıyor. Program.cs'in son satırı tek bir çağrı:
RootCommand root = AdminCommands.Create(host.Services);
return await root.Parse(args).InvokeAsync();
Yardım, Hata Mesajları ve Tab Completion
Tanımları yazdığın anda yardım metni de oluşur. Çıktılar bir Linux container'ında, bookstore-admin komutu yayımlanmış uygulamayı çalıştıracak şekilde ayarlanmışken alındı:
$ bookstore-admin --help
Description:
Bookstore admin tool. Exit codes: 0 success, 1 usage error, 2 invalid data, 130 canceled.
Usage:
bookstore-admin [command] [options]
Options:
-?, -h, --help Show help and usage information
--version Show version information
Commands:
import-prices <file> Import new book prices from a CSV file
check-isbn <isbn> Normalize and validate ISBN-13 values
$ bookstore-admin import-prices --help
Description:
Import new book prices from a CSV file
Usage:
bookstore-admin import-prices <file> [options]
Arguments:
<file> CSV file with isbn,price rows
Options:
-n, --dry-run Validate only; do not change any price
--max-errors <max-errors> Stop reading after this many invalid rows [default: 10]
--report <report> Where to write the applied changes [default: import-report.csv]
-?, -h, --help Show help and usage information
Girişteki hatalı komut satırlarını yeni araçla deneyelim. Tireyi unutan kullanıcı artık fiyatları değiştirmiyor; olmayan dosya da action çalışmadan yakalanıyor:
$ bookstore-admin import-prices prices.csv --dryrun
'--dryrun' was not matched. Did you mean one of the following?
--dry-run
Unrecognized command or argument '--dryrun'.
Description:
Import new book prices from a CSV file
...
exit=1
$ bookstore-admin import-prices price.csv
File does not exist: 'price.csv'.
...
exit=1
Parse hatasında action hiç çalışmaz, exit code 1 olur ve kullanıcı komutun yardımını görür. Bir ayrıntıyı denemeden fark etmedim: "Did you mean" önerisi ve yardım metni stdout'a, hata satırının kendisi stderr'e gidiyor. 2>/dev/null ile çalıştırıldığında yalnız öneri, >/dev/null ile yalnız Unrecognized command or argument '--dryrun'. satırı göründü. Script'ten çağrılan bir araçta karar her zaman exit code'a göre verilmeli, çıktının metnine göre değil.
Tab completion için System.CommandLine bir [suggest] directive'i sunar. Shell entegrasyonu dotnet-suggest adlı global tool ile kurulur; o tool shell'in her Tab basışında araca şu çağrıyı yapar:
$ bookstore-admin "[suggest:16]" "import-prices --"
--dry-run
--help
--max-errors
--report
$ bookstore-admin "[suggest:3]" "imp"
import-prices
Sayı, imlecin komut satırındaki konumu. Directive'i kendim çağırarak denedim; dotnet-suggest'i bu makineye global olarak kurmadım. Kurulumu ve shell script'leri System.CommandLine dokümantasyonunda anlatılıyor.
Exit Code Sözleşmesi ve stderr
Bir CLI aracı en çok başka bir script, bir CI (Continuous Integration; her değişikliği otomatik derleyip test eden süreç) pipeline'ı ya da zamanlanmış bir job tarafından çağrılır. Bu çağıranlar ekrandaki metni okumaz, exit code'a bakar. Bookstore aracının sözleşmesi bir class'ta ve kök komutun açıklamasında:
// Script'lerin ve CI pipeline'larının güvendiği sözleşme; kök komutun açıklaması da bunu tekrarlıyor.
public static class ExitCodes
{
public const int Success = 0;
public const int UsageError = 1;
public const int InvalidData = 2;
public const int Canceled = 130;
}
0 başarı, sıfırdan farklı her değer hata demek. 1'i System.CommandLine'ın parse hataları için kullandığı değerle aynı tuttum. Kötü veriye 2 vermek, çağıranın "komutu yanlış yazdım" ile "dosyada hatalı satır var" durumlarını ayırmasını sağlıyor. 130, Unix'te Ctrl+C ile kesilen bir process'in geleneksel exit code'u: 128 + SIGINT'in (terminalde Ctrl+C'ye basınca gelen sinyal) numarası olan 2. Sözleşmeyi bir kez belirledikten sonra değiştirmemek önemli; bir sonraki sürümde 2'yi 3 yapmak, ona göre yazılmış bütün script'leri sessizce bozar.
Mesajların nereye yazıldığı da sözleşmenin parçası. Sonuç stdout'a, hatalar ve uyarılar stderr'e gider; böylece bookstore-admin ... > result.txt yapan biri dosyasına hata mesajı karışmadığını bilir ve hatayı ekranda görür. Renkli yazmak için küçük bir yardımcı kullanıyoruz:
// Sonuç stdout'a, sorunlar stderr'e; renk yalnız süs, mesajın kendisi değil.
public static class ConsoleOutput
{
public static void Success(string message) => Write(Console.Out, ConsoleColor.Green, message);
public static void Warning(string message) => Write(Console.Error, ConsoleColor.Yellow, message);
public static void Error(string message) => Write(Console.Error, ConsoleColor.Red, "error: " + message);
private static void Write(TextWriter writer, ConsoleColor color, string message)
{
Console.ForegroundColor = color;
writer.WriteLine(message);
Console.ResetColor();
}
}
Üç senaryo: geçerli dosyayla dry run, hatalı satırlar içeren dosya ve aynı dosya stderr kapatılmışken. Örnek CSV (değerleri ayırıcıyla sıralayan düz metin formatı) dosyaları repoda samples/prices klasöründe:
$ bookstore-admin import-prices prices.csv --dry-run
dry run: 6 of 6 rows are valid; nothing was changed
exit=0
$ bookstore-admin import-prices prices-bad.csv
error: line 3: '9780140449137' is not a valid ISBN-13
error: line 4: expected 2 columns (isbn,price), got 3
error: line 5: '-5' is not a price between 0 and 5000
error: 3 problem(s) in prices-bad.csv; no price was changed
exit=2
$ bookstore-admin import-prices prices-bad.csv 2>/dev/null
exit=2
Hata mesajları satır numarasıyla geliyor ve hiçbir fiyat değişmiyor: dosyada tek bir hatalı satır varsa import'un tamamını reddetmek, yarısı uygulanmış bir fiyat listesinden daha kolay yönetilir. 4. satırdaki hata tanıdık: "129,00" Türkçe Excel'den gelmiş ve virgül hem kolon ayırıcısı hem ondalık ayırıcı olunca satır üç kolona bölünmüş. Fiyatı invariant culture ile parse ediyoruz; serinin 2. ve 3. yazılarında bunun nedenini anlattık. stderr'i kapatınca ekranda hiçbir şey kalmıyor ama exit code hâlâ 2; çağıran script hatayı yine de anlıyor.
Ctrl+C ve İptal: Yarım Dosya Bırakmamak
System.CommandLine, action'ı çalıştırırken SIGINT ve SIGTERM'i (process'ten düzgün kapanmasını isteyen sinyal) dinler ve action'a verdiği CancellationToken'ı iptal eder. Token'ı kullanmak ve iptalde tutarlı bir durumda çıkmak bize kalıyor. Import, her fiyat değişikliğini bir rapor dosyasına yazıyor. Rapor geçici bir dosyaya yazılıyor ve son adı ancak her şey bittiğinde alıyor:
private async Task<int> ApplyAsync(IReadOnlyList<PriceRow> rows, string reportPath, CancellationToken cancellationToken)
{
// Son ad yalnız her satır başarılı olduğunda verilir; diğer her durumda dosya .partial olarak kalır.
string tempPath = reportPath + ".tmp";
int applied = 0;
try
{
await using (StreamWriter report = new(tempPath))
{
await report.WriteLineAsync("isbn,old_price,new_price");
foreach (PriceRow row in rows)
{
decimal oldPrice = await catalog.UpdatePriceAsync(row.Isbn, row.Price, cancellationToken);
await report.WriteLineAsync(string.Create(CultureInfo.InvariantCulture, $"{row.Isbn},{oldPrice},{row.Price}"));
applied++;
}
}
File.Move(tempPath, reportPath, overwrite: true);
return applied;
}
catch when (File.Exists(tempPath))
{
string partialPath = reportPath + ".partial";
File.Move(tempPath, partialPath, overwrite: true);
logger.LogWarning("Import stopped: {Applied} of {Total} prices were changed, see {Partial}", applied, rows.Count, Path.GetFileName(partialPath));
throw;
}
}
IPriceCatalog bu yazıda Bookstore'un katalog API'sinin yerine geçen bir simülasyon; her güncelleme 50 ms süren bir "ağ çağrısı". 120 satırlık bir dosyayı içeri alırken Ctrl+C'ye basmayı, timeout komutuyla 3 saniye sonra SIGINT göndererek taklit ettim:
$ timeout --preserve-status -s INT 3 bookstore-admin import-prices prices-big.csv
warn: Bookstore.Admin.Pricing.PriceImporter[0]
Import stopped: 54 of 120 prices were changed, see import-report.csv.partial
canceled; the report was not completed
exit=130
$ ls
import-report.csv.partial
prices-bad.csv
prices-big.csv
prices.csv
$ wc -l import-report.csv.partial
55 import-report.csv.partial
import-report.csv adında bir dosya yok; bu adı bekleyen bir sonraki adım (muhasebeye gönderim, bir rapor job'ı) yarım bir dosyayı tam sanmıyor. Ama bilgi de kaybolmuyor: 54 fiyat gerçekten değişti ve hangileri olduğu .partial dosyasında. Bu, iptalin her zaman temiz olmadığını da gösteriyor: dış sisteme yapılan değişiklikler geri alınmıyor. Tam bir geri alma gerekiyorsa import'u yeniden çalıştırılabilir (idempotent) tasarlamak ya da değişiklikleri tek bir toplu çağrıda göndermek gerekir. Aynı fikir dosya tarafında: önce geçici dosyaya yaz, sonra File.Move ile adını ver. Aynı disk üzerinde bu taşıma tek adımda olur; atomik dosya yazmanın ayrıntısı .NET'te I/O İşlemleri serisinin FileStream yazısında.
Action token'ı hiç dinlemezse ne olur diye ayrı bir denemede 10 saniye bekleyen ve token'ı yok sayan bir action yazdım. SIGINT'ten sonra System.CommandLine 2 saniye bekledi ve process'i 130 exit code ile sonlandırdı; bu süre InvocationConfiguration.ProcessTerminationTimeout property'sinin varsayılanı (00:00:02). Yani token'ı dinlemezsen temizlik kodun hiç çalışmayabilir.
System.CommandLine kullanmayan bir uygulamada aynı işi PosixSignalRegistration (.NET 6) ile kendin yaparsın. Console.CancelKeyPress yalnız Ctrl+C içindir; container'ı durduran docker stop ise SIGTERM gönderir:
using CancellationTokenSource cts = new();
PosixSignal? received = null;
void OnSignal(PosixSignalContext context)
{
context.Cancel = true; // Process hemen sonlanmasın, uygulama kendisi çıksın.
received = context.Signal;
cts.Cancel();
}
using PosixSignalRegistration sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnSignal);
using PosixSignalRegistration sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnSignal);
int batches = 0;
try
{
while (true)
{
await Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token);
Console.WriteLine($"batch {++batches} done");
}
}
catch (OperationCanceledException)
{
Console.Error.WriteLine($"received {received}, stopped cleanly after {batches} batches");
}
$ timeout --preserve-status -s INT 1.8 dotnet Bookstore.Bcl.Samples.dll cli-signal
batch 1 done
batch 2 done
batch 3 done
received SIGINT, stopped cleanly after 3 batches
exit=0
context.Cancel = true varsayılan davranışı (process'in hemen sonlanmasını) engeller ve kapanışı uygulamaya bırakır. Son satırdaki 0 da bir uyarı: örnek uygulamanın Program.cs'i her demodan sonra 0 dönüyor; iptal edilen gerçek bir araç ise 130 dönmeli. bookstore-admin'de bunu ExitCodes.Canceled ile yaptık.
Renkli Çıktı: NO_COLOR ve .NET 11'de FORCE_COLOR
Console.ForegroundColor Linux'ta terminale escape kodları (renk ve imleç komutlarını taşıyan özel karakter dizileri) yazar. .NET bunu yalnız çıktı bir terminale gidiyorsa yapar ve NO_COLOR (renkli çıktıyı kapatmak için kullanılan yaygın ortam değişkeni) tanımlıysa hiç yapmaz. Bir CI log'unda renk görmek isteyenler için .NET 11, FORCE_COLOR'ı da destekliyor. Aynı bookstore-admin binary'sini önce .NET 10, sonra DOTNET_ROLL_FORWARD=Major ile .NET 11 runtime'ında çalıştırdım; çıktıyı cat -v'ye vererek escape kodlarını görünür yaptım (.NET 11 satırlarında container'da TERM=xterm tanımlı):
$ FORCE_COLOR=1 bookstore-admin import-prices prices.csv --dry-run | cat -v # .NET 10
dry run: 6 of 6 rows are valid; nothing was changed
$ bookstore-admin import-prices prices.csv --dry-run | cat -v # .NET 11
dry run: 6 of 6 rows are valid; nothing was changed
$ FORCE_COLOR=1 bookstore-admin import-prices prices.csv --dry-run | cat -v # .NET 11
^[[39;49m^[[32mdry run: 6 of 6 rows are valid; nothing was changed
^[[39;49m
$ NO_COLOR=1 FORCE_COLOR=1 bookstore-admin import-prices prices.csv --dry-run | cat -v # .NET 11
^[[39;49m^[[32mdry run: 6 of 6 rows are valid; nothing was changed
^[[39;49m
^[[32m yeşil rengin, ^[[39;49m varsayılan renklere dönüşün kodu. .NET 10 çıktı yönlendirildiğinde FORCE_COLOR'a bakmıyor; .NET 11 bakıyor. Denerken iki ayrıntı öğrendim. Birincisi, .NET 11'de FORCE_COLOR'ın etkisi ancak TERM ortam değişkeni tanımlıyken görüldü; TERM'ü olmayan bir container'da aynı komut renksiz çıktı verdi, çünkü runtime hangi escape kodlarını yazacağını terminal tanımından öğreniyor. CI ortamında renk istiyorsan ikisini birlikte ver. İkincisi, NO_COLOR ve FORCE_COLOR ikisi birden tanımlıyken FORCE_COLOR kazandı. Terminale doğrudan yazarken ise (yönlendirme olmadan) .NET 10'da da renk çıktı ve NO_COLOR=1 rengi kapattı.
Renk her zaman ek bilgi olmalı, tek bilgi değil: hata satırları error: önekiyle başlıyor, böylece renk görmeyen bir log ya da ekran okuyucu da hatayı ayırt ediyor.
Generic Host ile DI, Configuration ve Logging
Araç büyüdükçe DI, configuration ve logging ihtiyacı da gelir. Bunun için web uygulamasının kullandığı Generic Host'u konsol aracında da kullanabilirsin; host'u çalıştırmadan yalnız servisleri kurmak için yeterli:
// Args'ı System.CommandLine yönetir; host yalnız DI, configuration ve logging sağlar.
HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
ContentRootPath = AppContext.BaseDirectory,
});
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.Configure<ImportOptions>(builder.Configuration.GetSection("Bookstore:Import"));
builder.Services.AddSingleton<IPriceCatalog, SimulatedPriceCatalog>();
builder.Services.AddSingleton<PriceImporter>();
using IHost host = builder.Build();
RootCommand root = AdminCommands.Create(host.Services);
return await root.Parse(args).InvokeAsync();
Üç satır bir CLI için özellikle önemli. args'ı host'a vermiyoruz: host'un command line configuration provider'ı --dry-run gibi argümanları configuration anahtarı olarak yorumlamaya çalışır, bu işi System.CommandLine'a bırakıyoruz. ContentRootPath'i AppContext.BaseDirectory yapıyoruz: varsayılan, çalışma klasörü; araç kullanıcının herhangi bir klasöründen çalıştırıldığında appsettings.json bulunamaz (bir önceki yazıda CurrentDirectory ile BaseDirectory farkını görmüştük). Console logger'ın LogToStandardErrorThreshold ayarı da tüm log'ları stderr'e yönlendiriyor; varsayılanda log'lar stdout'a gider ve aracın gerçek çıktısına karışır. Cancel senaryosundaki warn: satırı bu yüzden stderr'de. MaxPrice değeri appsettings.json'dan geliyor ve hata mesajındaki "between 0 and 5000" oradan okundu. Configuration ve options pattern'in ayrıntısı ASP.NET Core Web API serisinin configuration yazısında.
dotnet tool Olarak Paketlemek
Aracı ekibe dağıtmanın en kolay yolu onu bir .NET tool yapmak. csproj'a birkaç property yeterli:
<OutputType>Exe</OutputType>
<AssemblyName>bookstore-admin</AssemblyName>
<PackAsTool>true</PackAsTool>
<ToolCommandName>bookstore-admin</ToolCommandName>
<PackageId>Bookstore.Admin</PackageId>
<Version>1.0.0</Version>
dotnet pack src/Bookstore.Admin -c Release -o nupkg
dotnet new tool-manifest
dotnet tool install --local --add-source ./nupkg Bookstore.Admin
dotnet tool run bookstore-admin -- check-isbn 9780441172719
Bu dört komutu ayrı bir çalışma klasöründe .NET 10 SDK ile çalıştırdım; paket oluştu, local tool olarak kuruldu ve 9780441172719 -> ISBN 9780441172719 yazdı. Global kurulum, local tool manifest'i ve sürümleme .NET SDK, CLI ve MSBuild serisinin dotnet tool yazısında anlatılıyor. --version çıktısı (1.0.0) csproj'daki Version'dan geliyor.
Üretimde Dikkat
Aracı script'ten çağırılabilir tasarla: etkileşimli soru sorma (ya da --yes gibi bir seçenekle atlanabilir yap), çıktıyı makinenin okuyabileceği bir formatta verme seçeneği ekle (--output json gibi), exit code'ları yardım metninde belgele. Tehlikeli işlemler için varsayılan güvenli olmalı; --dry-run yerine "gerçek çalıştırma için --apply gerekir" demek de bir seçenek.
Uzun süren her işi iptal edilebilir yap ve iptalde tutarlı bir durumda çık. Container'da çalışan bir araç docker stop ile SIGTERM alır ve varsayılan olarak 10 saniye sonra SIGKILL (yakalanamayan sonlandırma sinyali) ile öldürülür; temizlik bu sürede bitmeli. Log'ları stderr'e, sonucu stdout'a yaz ve gizli değerleri (API key, connection string) argüman olarak almaktan kaçın: argümanlar process listesinde ve shell geçmişinde görünür; ortam değişkeni ya da dosya kullan.
Bağımlılıkların başlangıç süresine etkisini ölç. Generic Host ve configuration birkaç düzine assembly yükler; her saniye çağrılan küçük bir araç için bu fark edilir. Native AOT (Ahead-of-Time; uygulamayı önceden makine koduna derleyen yayımlama modu) ile yayımlamak başlangıcı kısaltabilir; .NET SDK, CLI ve MSBuild serisinin publish yazısı seçenekleri anlatıyor.
Sık Düşülen Tuzaklar
Hata durumunda 0 dönmek. Exception'ı yakalayıp ekrana yazan ve normal biten bir araç, onu çağıran pipeline'a "her şey yolunda" der. Her hata yolunun sıfırdan farklı bir exit code'u olsun.
Hata mesajını stdout'a yazmak. Çıktısı bir dosyaya ya da başka bir komuta yönlendirilen araçta hata mesajı verinin arasına karışır ve kimse görmez. Hata ve uyarı stderr'e, log'lar da stderr'e.
İptalde yarım dosya bırakmak. Doğrudan son ada yazılan bir dosya, Ctrl+C'den sonra tam görünür ama değildir. Geçici dosyaya yaz, bitince taşı; yarım veriyi saklaman gerekiyorsa adını açıkça farklı ver.
Args'ı elle parse etmek. Bilinmeyen seçenekleri yok sayan bir parser, yazım hatasını sessizce farklı bir davranışa çevirir. System.CommandLine bilinmeyen token'ları varsayılan olarak hata sayar.
Cancellation token'ı action'a kadar taşıyıp sonra kullanmamak. System.CommandLine 2 saniye bekler ve process'i sonlandırır; temizlik kodun çalışmaz.
Küçük Bir Deneme
★☆☆ import-prices'a --currency seçeneği ekle; yalnız TRY, EUR ve USD kabul etsin (AcceptOnlyFromAmong). Yanlış bir değerle çalıştırıp hata mesajını ve exit code'u kontrol et.
★★☆ --output json seçeneği ekle: sonuç özetini (rowsRead, valid, errors) stdout'a JSON olarak yazsın, renk ve insan için yazılmış mesajlar stderr'e gitsin. Çıktıyı jq ile okuyabildiğini doğrula.
★★★ Import'u yeniden çalıştırılabilir yap: .partial raporu varsa, yeniden çalıştırıldığında rapordaki satırları atlayıp kalanlardan devam etsin. İki kez iptal edip üçüncüde tamamlayan bir senaryoyla dene ve hiçbir fiyatın iki kez güncellenmediğini kontrol et.
Bookstore'un Order class'ının constructor'ı hâlâ elle yazılmış if (... == null) throw blokları içeriyor. Serinin son yazısında bunları .NET'in throw helper'larına çeviriyoruz, equality helper'larına bakıyoruz ve .NET 8'den 11'e BCL'ye (Base Class Library; her .NET uygulamasıyla gelen temel class library) gelen API'leri tek bir tabloda topluyoruz.