Refactoring Kataloğu 6: API'yi Refactor Etmek — Separate Query from Modifier, Remove Flag Argument, Parameterize Function, Factory Function ve Command
API refactoring'leri bir class'ın dışarıya açtığı method'ları daha açık, daha güvenli ve daha kolay test edilir hâle getirir; ama paket olarak dağıtılan bir API'de varsayılan değerli bir parametre eklemek bile yeniden derlenmeyen kodu kırar. Yazıda Bookstore'un Notifications module'ü on iki refactoring'le dönüştürülüyor; [Obsolete] ile korunan eski sözleşme, CS0200 ve CS0618 çıktıları ve yeni DLL ile çalışan eski uygulamanın MissingMethodException'ı gerçek çıktılarla gösteriliyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.404) ile, testler xUnit.net v3 4.0.3 ve Verify.XunitV3 32.0.0 ile derlenip denenmiştir.
Önce Tahmin Et: Varsayılan Değerli Bir Parametre Kimi Kırar?
Katalog bloğunun altıncı yazısı bir class'ın dışarıya açtığı API'yi değiştiren on iki refactoring'i ele alıyor: Separate Query from Modifier, Parameterize Function, Remove Flag Argument, Replace Parameter with Query, Replace Query with Parameter, Remove Setting Method, Replace Constructor with Factory Function, Replace Function with Command, Replace Command with Function, Return Modified Value, Replace Error Code with Exception ve Replace Exception with Precheck. Adlar refactoring.com/catalog'daki gibi; açıklama ve örnekler bizim. Sahne, sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yeni Notifications module'ü. Kargo bildirimi gönderen method şöyle:
public class Notifier(Inbox inbox, ISmsGateway sms, IReadOnlyDictionary<int, string> phones, IReadOnlySet<int> smsOptOut)
{
// 0: tamam, -1: kalemsiz sipariş, -2: SMS gönderilemedi
public int SendNotification(Order order, bool viaSms, bool urgent)
{
if (order.Items.Count == 0)
return -1;
string text = new ShippedMessageBuilder(order).Build();
inbox.Add(new Notification(order.CustomerId, "shipped", text, DateTimeOffset.UtcNow));
if (!viaSms)
return 0;
if (smsOptOut.Contains(order.CustomerId))
return -2;
string phone;
try
{
phone = phones[order.CustomerId];
}
catch (KeyNotFoundException)
{
return -2;
}
sms.Send(phone, urgent ? "URGENT: " + text : text);
return 0;
}
}
// Çağıranlar
notifier.SendNotification(order, true, false);
notifier.SendNotification(order, false, false);
Bu class bir NuGet paketi olarak dağıtılıyor ve onu kullananlardan biri Kurumsal Sipariş ekibinin kendi paketi; o paket Notifications'ın 1.3.0 sürümüne karşı derlenmiş. Ürün ekibi e-posta da istiyor ve en kısa yol bir parametre daha eklemek gibi görünüyor:
public int SendNotification(Order order, bool viaSms, bool urgent, bool viaEmail = false)
Parametrenin varsayılan değeri olduğu için mevcut çağrıların hiçbiri değişmeden derleniyor. Bu yüzden sürümü 1.4.0 yapıp yayınlıyorsun. Uygulama Notifications'ı 1.4.0'a yükseltiyor, Kurumsal Sipariş paketi ise yeniden derlenmeden 1.3.0'a göre yazılmış hâliyle kalıyor. O paketteki SendNotification(order, true, false) çağrısı çalışınca ne olur?
Separate Query from Modifier
Önce bildirim kutusu. Kullanıcının okunmamış bildirimlerini döndüren method onları aynı anda okundu olarak işaretliyordu:
public IReadOnlyList<Notification> GetAndMarkAsRead(int customerId)
{
List<Notification> unread = _items.Where(n => n.CustomerId == customerId && !n.IsRead).ToList();
foreach (Notification notification in unread)
notification.IsRead = true;
return unread;
}
Bir değer döndüren ve aynı zamanda state değiştiren method, onu çağıran herkesi sıraya bağımlı yapar. Sayfanın üstündeki rozet okunmamış bildirim sayısını göstermek için bu method'u çağırırsa, hemen sonra gelen liste boş kalır. Debugger'ın watch penceresine yazılan bir çağrı bile bildirimleri okundu yapar. Separate Query from Modifier, method'u yan etkisi olmayan bir query'ye ve değer döndürmeyen bir modifier'a böler. Bertrand Meyer'in CQS (Command-Query Separation; bir method ya soru sorar ya iş yapar, ikisini birden yapmaz) prensibinin refactoring hâli; prensibin kendisi SOLID (beş temel tasarım ilkesinin baş harflerinden oluşan kısaltma) ve Design Principles serisinin "Daha Az Bilinen Prensipler" yazısında. HTTP tarafında karşılığı, GET isteklerinin güvenli (safe) olması: bir tarayıcının ya da proxy'nin GET'i önceden çağırması veri değiştirmemeli.
Mekanik adımlar: method'u kopyala ve query olarak adlandır, kopyadan yan etkileri sil; her çağrıyı önce query'yi, sonra modifier'ı çağıracak şekilde değiştir; asıl method'dan dönüş değerini kaldır, artık o bir modifier.
public IReadOnlyList<Notification> Unread(int customerId) =>
_items.Where(n => n.CustomerId == customerId && !n.IsRead).ToList();
public void MarkAsRead(IEnumerable<Guid> ids)
{
HashSet<Guid> toMark = [.. ids];
foreach (Notification notification in _items.Where(n => toMark.Contains(n.Id)))
notification.IsRead = true;
}
Modifier'ın parametresi müşteri değil, gösterilen bildirimlerin id'leri. Query ile modifier arasında yeni bir bildirim gelirse, kullanıcının görmediği bir bildirim okundu sayılmamalı. Ayırmanın ortaya çıkardığı bu küçük soru, birleşik method'da hiç sorulmuyordu.
Parameterize Function, Replace Query with Parameter ve Tersi
Sepette ürün bırakan müşteriye hatırlatma gönderen iki method neredeyse aynıydı:
public void RemindAfterOneDay(int customerId, DateTimeOffset cartUpdatedAt)
{
Notification notification = new(customerId, "reminder", "Your cart is waiting for you.", cartUpdatedAt.AddDays(1));
ApplyQuietHours(notification, IsNight(notification.SendAt));
inbox.Add(notification);
}
public void RemindAfterThreeDays(int customerId, DateTimeOffset cartUpdatedAt)
{
Notification notification = new(customerId, "reminder", "Your cart is waiting for you.", cartUpdatedAt.AddDays(3));
ApplyQuietHours(notification, IsNight(notification.SendAt));
inbox.Add(notification);
}
private static bool IsNight(DateTimeOffset at) => at.Hour >= 22 || at.Hour < 8;
// Gece gönderilecek hatırlatma sabah 08:00'i bekler.
private static void ApplyQuietHours(Notification notification, bool isNight)
{
if (!isNight)
return;
DateTimeOffset day = notification.SendAt.Hour >= 22 ? notification.SendAt.AddDays(1) : notification.SendAt;
notification.SendAt = new DateTimeOffset(day.Year, day.Month, day.Day, 8, 0, 0, day.Offset);
}
Parameterize Function, yalnız bir değerde ayrışan fonksiyonları o değeri parametre olarak alan tek bir fonksiyonda birleştirir; Duplicated Code smell'inin cevabı. Mekanik adımlar: fonksiyonlardan birini seç, farklı olan literal'i parametreye çevir (Rider ve Visual Studio Code'daki "Introduce Parameter" bunu yapar), diğer fonksiyonun çağıranlarını yeni fonksiyona yönlendir, eskisini sil. Sonuç RemindAfter(int days, int customerId, DateTimeOffset cartUpdatedAt) oldu ve beşinci gün için yeni bir method yazmak gerekmiyor.
ApplyQuietHours'ın isNight parametresi ise fazlalık: her çağıran onu aynı şekilde, method'a zaten verdiği zamandan hesaplıyor. Replace Parameter with Query, çağrılan fonksiyonun kendisinin bulabileceği bir değeri parametre olarak almayı bırakır ve hesabı fonksiyonun içine alır. Bunun tersi Replace Query with Parameter; aynı yazıda ikisine birden ihtiyacımız oldu:
// Önce: zamanı kendisi soruyor
public IReadOnlyList<Notification> DueReminders()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
return _items.Where(n => n.Kind == "reminder" && n.SendAt <= now).ToList();
}
// Sonra: zamanı çağıran veriyor
public IReadOnlyList<Notification> DueReminders(DateTimeOffset now) =>
_items.Where(n => n.Kind == "reminder" && n.SendAt <= now).ToList();
DateTimeOffset.UtcNow bir global. Onu içeriden okuyan method aynı girdiyle her çağrıda farklı sonuç verebilir; eski test bu yüzden bir yıl önceye ve on yıl sonraya tarih koymak zorundaydı. Parametre olarak alınca test tam sınırı, "şimdi" ile "bir dakika sonra"yı deneyebiliyor. Karar şu soruya bağlı: değer fonksiyonun sorumluluğunda mı, çağıranın mı? isNight zamanın bir özelliği ve fonksiyon zamanı zaten biliyor; "şimdi" ise fonksiyonun bilmemesi gereken bir dış durum. Beşinci yazıda aynı problemi bir dependency olarak TimeProvider ile çözmüştük; çağrı zincirinde yukarıda bir yer zamanı zaten biliyorsa düz parametre daha basit.
Return Modified Value, Remove Setting Method ve Factory Function
ApplyQuietHours'ın bir sorunu daha var: aldığı nesneyi değiştiriyor ve bunu imzasından anlamak mümkün değil. Return Modified Value, bir değeri yerinde değiştiren fonksiyonu yeni değeri döndüren bir fonksiyona çevirir ve atamayı çağırana bırakır. Mekanik adımlar: fonksiyonun yeni değeri döndürmesini sağla, çağıranda dönen değeri ata, test et, fonksiyonun içindeki atamayı sil. Değişen veri artık çağrı satırında görünüyor:
public void RemindAfter(int days, int customerId, DateTimeOffset cartUpdatedAt) =>
inbox.Add(Notification.Reminder(customerId, QuietHoursAdjusted(cartUpdatedAt.AddDays(days))));
private static bool IsNight(DateTimeOffset at) => at.Hour >= 22 || at.Hour < 8;
// Gece gönderilecek hatırlatma sabah 08:00'i bekler.
private static DateTimeOffset QuietHoursAdjusted(DateTimeOffset at)
{
if (!IsNight(at))
return at;
DateTimeOffset day = at.Hour >= 22 ? at.AddDays(1) : at;
return new DateTimeOffset(day.Year, day.Month, day.Day, 8, 0, 0, day.Offset);
}
Zaman, bildirim oluşturulmadan önce hesaplandığı için SendAt'in setter'ına gerek kalmadı. Id'nin setter'ı da yalnız Inbox.Add'de bir kez çağrılıyordu. Remove Setting Method, nesne oluştuktan sonra değişmemesi gereken bir alanın setter'ını kaldırır ve değeri constructor'da ya da factory'de verir. İki setter'ı silince derleyici kalan atamaları gösterdi:
src/Bookstore.Notifications/Inbox.cs(11,9): error CS0200: Property or indexer 'Notification.Id' cannot be assigned to -- it is read only
src/Bookstore.Notifications/ReminderScheduler.cs(8,9): error CS0200: Property or indexer 'Notification.SendAt' cannot be assigned to -- it is read only
İkincisi Return Modified Value'nun son adımıyla birlikte ortadan kalktı. IsRead'in setter'ı ise duruyor, çünkü okunma gerçekten nesnenin ömrü boyunca değişen bir state. C#'ta üç seçenek var: setter'ı tamamen kaldırmak ({ get; }), yalnız object initializer'da izin vermek (init) ya da dışarıya kapatmak (private set). init, record'larda with ifadesiyle yeni bir kopya oluştururken hâlâ değiştirilebildiğini de unutma.
Aynı commit serisinde constructor da değişti. new Notification(customerId, "shipped", text, at) çağrıları üç yerde ve "shipped", "reminder", "price-drop" string'leri her çağıranda tekrar ediyordu. Replace Constructor with Factory Function, constructor çağrılarını adı olan static fonksiyonlara çevirir. C#'ta constructor'ın adı class'ın adıdır, dönüş tipi hep o class'tır ve mutlaka yeni bir instance döndürür; factory function'da bu üç sınır yok. Mekanik adımlar: constructor'ı çağıran bir factory function yaz, çağıranları factory'ye taşı, constructor'ı private yap.
public class Notification
{
private Notification(int customerId, string kind, string text, DateTimeOffset sendAt)
{
CustomerId = customerId;
Kind = kind;
Text = text;
SendAt = sendAt;
}
public static Notification Shipped(int customerId, string text, DateTimeOffset at) =>
new(customerId, "shipped", text, at);
public static Notification Reminder(int customerId, DateTimeOffset sendAt) =>
new(customerId, "reminder", "Your cart is waiting for you.", sendAt);
public static Notification PriceDrop(int customerId, string text, DateTimeOffset at) =>
new(customerId, "price-drop", text, at);
public Guid Id { get; } = Guid.NewGuid();
public int CustomerId { get; }
public string Kind { get; }
public string Text { get; }
public DateTimeOffset SendAt { get; }
public bool IsRead { get; set; }
}
Rider bunu "Replace Constructor with Factory Method" adıyla otomatik yapıyor. .NET'te tanıdık örnekleri TimeSpan.FromMinutes, Task.FromResult ve Guid.NewGuid. Beşinci yazıda constructor'ı parametreleyip test için bir kapı açmıştık; factory function o kapıyı dışarıya karşı kapatıp her kullanım için ayrı ve adı olan bir giriş bırakıyor.
Remove Flag Argument ve Public API
SendNotification(order, true, false) çağrısını okuyan biri true ve false'un ne demek olduğunu öğrenmek için method'a gitmek zorunda. Flag argument, çağıranın hangi kodun çalışacağını literal bir değerle seçmesi. Remove Flag Argument her anlamlı değer için adı olan bir fonksiyon açar. Dördüncü yazıda Calculate(request, isPreview: true)'yu ikiye bölmüştük; buradaki fark, method'un bir paketin public API'si olması. Mekanik adımlar: flag'in her değeri için yeni bir fonksiyon yaz, literal ile çağıranları yeni fonksiyonlara taşı, flag'i değişkenle geçen çağıran kalmadıysa eskisini sil. Paket dışındaki çağıranları taşıyamayacağımız için son adım bir sonraki major sürüme kalıyor:
public enum SmsResult
{
Sent,
Skipped
}
// 0: tamam, -1: kalemsiz sipariş, -2: SMS gönderilemedi
[Obsolete("Use NotifyShipped, NotifyShippedBySms or NotifyShippedBySmsUrgently. SendNotification will be removed in 2.0.")]
public int SendNotification(Order order, bool viaSms, bool urgent)
{
if (order.Items.Count == 0)
return -1;
if (!viaSms)
{
NotifyShipped(order);
return 0;
}
SmsResult result = urgent ? NotifyShippedBySmsUrgently(order) : NotifyShippedBySms(order);
return result == SmsResult.Sent ? 0 : -2;
}
public void NotifyShipped(Order order) => PostShipped(order);
public SmsResult NotifyShippedBySms(Order order) => TextShipped(order, PostShipped(order));
public SmsResult NotifyShippedBySmsUrgently(Order order) => TextShipped(order, "URGENT: " + PostShipped(order));
private string PostShipped(Order order)
{
if (order.Items.Count == 0)
throw new ArgumentException("A shipped order must have at least one item.", nameof(order));
string text = ShippedText(order);
inbox.Add(Notification.Shipped(order.CustomerId, text, DateTimeOffset.UtcNow));
return text;
}
private SmsResult TextShipped(Order order, string text)
{
if (smsOptOut.Contains(order.CustomerId) || !phones.TryGetValue(order.CustomerId, out string? phone))
return SmsResult.Skipped;
sms.Send(phone, text);
return SmsResult.Sent;
}
Flag'ler tamamen yok olmadı: urgent artık private bir fonksiyona giden bir string ön eki. Önemli olan, dışarıdaki her çağrının ne yaptığını adıyla söylemesi. Eski method yeni method'lara yönleniyor ama kendi sözleşmesini koruyor: hâlâ -1 ve -2 döndürüyor, exception fırlatmıyor. Characterization snapshot'ı eski method'u çağırmaya devam ediyor ve tek satırı bile değişmedi; derleyici de onu kullanan her yeri listeliyor:
tests/Bookstore.Notifications.Tests/NotifierTests.cs(15,32): warning CS0618: 'Notifier.SendNotification(Order, bool, bool)' is obsolete: 'Use NotifyShipped, NotifyShippedBySms or NotifyShippedBySmsUrgently. SendNotification will be removed in 2.0.'
Testte bu uyarıyı #pragma warning disable CS0618 ile bilerek susturdum; o test eski sözleşmenin 2.0'a kadar korunduğunu kanıtlıyor. Sürüm numarası da buna göre: yeni method'lar eklemek geriye uyumlu, yani minor (1.4.0); eskiyi silmek major (2.0.0). Semantic versioning'in kuralları Versioning ve Release Management serisinin "Sürüm Numarası Ne Anlatır?" yazısında.
Cevap: MissingMethodException
Varsayılan değerli parametre bir derleyici özelliği. Çağıran derlenirken derleyici eksik argümanı çağrı yerine yazar; çalışma zamanında ise method dört parametreli tek bir imzadır. Üç parametreli imza artık yok. Bunu küçük bir kütüphane ve onu kullanan bir console uygulamasıyla denedim (samples/binary-compat): uygulama 1.3.0'ı temsil eden kütüphaneyle derleniyor, sonra yalnız kütüphanenin DLL'i (derlenmiş .NET assembly dosyası) 1.4.0'ınkiyle değiştiriliyor:
$ dotnet build App -o out
$ dotnet out/App.dll
order 42: inbox + sms
$ dotnet build Lib -p:LibVersion=2 -o out
$ dotnet out/App.dll
Unhandled exception. System.MissingMethodException: Method not found: 'System.String Bookstore.Notifications.Client.ShippingNotifier.SendNotification(Int32, Boolean, Boolean)'.
at Program.<Main>$(String[] args)
$ dotnet build App -p:LibVersion=2 -o out
$ dotnet out/App.dll
order 42: inbox + sms
Yeniden derlenen uygulama sorunsuz çalışıyor; değişiklik source düzeyinde uyumlu, binary düzeyinde kırıcı. NuGet dünyasında bu tam olarak "yeniden derlenmeyen bir ara paket" senaryosu ve hata ancak o kod yolu çalışınca, üretimde çıkar. Doğru yol yukarıdaki gibi yeni bir overload ya da yeni bir method eklemek. .NET SDK'nın package validation özelliği önceki sürümün paketiyle karşılaştırıp bu durumu CP0002 (önceki sürümde olan bir üyenin yeni sürümde bulunmadığını söyleyen package validation kodu) hatasıyla yakalıyor; kurulumunu Versioning ve Release Management serisinin "NuGet Paketi Yayınlamak" yazısı anlatıyor. Bu yazı için package validation'ı çalıştırmadım.
Replace Error Code with Exception ve Replace Exception with Precheck
Eski method iki farklı türden hatayı aynı yolla, negatif bir sayıyla bildiriyordu. Kalemsiz bir siparişe kargo bildirimi göndermek çağıranın hatası; doğru yazılmış bir program bunu hiç yapmaz. SMS (kısa mesaj) gönderilememesi ise beklenen bir iş sonucu: müşteri SMS istemiyor ya da telefonunu vermemiş. Replace Error Code with Exception, hata kodunu kontrol etmeyi unutabilen çağıranı sessiz bir yanlışlıktan kurtarır; exception unutulamaz. Mekanik adımlar: hata kodunu kontrol eden çağıranlarda exception'ı yakalayacak bir yer olduğundan emin ol, kodu döndüren yerde exception fırlat, kodu kontrol eden dalları sil. Yeni API'de -1 bir ArgumentException oldu.
-2'yi ise exception'a çevirmedim. Her siparişte olabilecek bir durum için exception fırlatmak, çağıranı akış kontrolü için try/catch yazmaya zorlar ve "SMS gitmedi" bilgisini bir hata gibi loglatır. Bunun için SmsResult dönüş tipi var; Clean Code serisinin "Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern" yazısı bu ayrımı ayrıntılı anlatıyor.
Replace Exception with Precheck ters yönde çalışır: çağıranın önceden kontrol edebileceği bir durum için exception'ı yakalamak yerine önce kontrol eder. Eski kodda telefon numarası olmayan müşteri KeyNotFoundException ile yakalanıyordu:
// Önce
string phone;
try
{
phone = phones[order.CustomerId];
}
catch (KeyNotFoundException)
{
return -2;
}
// Sonra
if (smsOptOut.Contains(order.CustomerId) || !phones.TryGetValue(order.CustomerId, out string? phone))
return -2;
Exception pahalıdır, ama asıl sorun okunurluk: catch bloğu "bu satırda bir şey ters gidebilir" der, oysa telefonun olmaması sıradan bir durum. .NET'in Try pattern'i (TryGetValue, TryParse) tam bu refactoring için var. Precheck'in yetmediği yer race condition: dosyanın varlığını kontrol edip sonra açmak, iki adım arasında dosya silinirse yine exception verir. Orada exception'ı yakalamak doğru cevap.
Replace Function with Command ve Tersi
Fiyatı düşen bir kitabı istek listesinde tutan müşterilere bildirim gönderen fonksiyon sekiz parametre alıyordu ve back office ekibi göndermeden önce "kaç kişiye gidecek?" diye görmek istedi. Replace Function with Command, fonksiyonu kendi class'ına taşır: parametreler field olur, gövde bir Execute method'una gider. Command class'ında fonksiyonun ara adımları ayrı method'lara bölünebilir, yaşam döngüsü (önce hazırla, sonra çalıştır, gerekirse geri al) tanımlanabilir ve aynı veriyle birden fazla işlem yapılabilir. Mekanik adımlar: boş bir class aç, fonksiyonu Execute olarak oraya taşı, eski fonksiyonu çağıranları new X(...).Execute()'a çevir, sonra parametreleri ve yerel değişkenleri field'lara ve method'lara böl.
public sealed class PriceDropAlert(Inbox inbox, IReadOnlyDictionary<int, IReadOnlyList<string>> wishlists,
IReadOnlySet<int> marketingOptOut, string isbn, string title, decimal oldPrice, decimal newPrice)
{
private decimal DropPercent => (oldPrice - newPrice) / oldPrice * 100;
private string Text => FormattableString.Invariant($"{title} is now {newPrice:0.00} TRY ({DropPercent:0}% off).");
// Back office önizlemesi: hiçbir şey göndermeden alıcıları listeler.
public IReadOnlyList<int> Recipients() =>
DropPercent < 10
? []
: wishlists.Where(w => w.Value.Contains(isbn) && !marketingOptOut.Contains(w.Key))
.Select(w => w.Key)
.Order()
.ToList();
public int Execute(DateTimeOffset now)
{
IReadOnlyList<int> recipients = Recipients();
foreach (int customerId in recipients)
inbox.Add(Notification.PriceDrop(customerId, Text, now));
return recipients.Count;
}
}
İki commit: önce gövde olduğu gibi Execute'a taşındı, sonra önizleme ayrıldı. Command'ın kendisi, undo ve queue ile birlikte, Design Patterns serisinin "Command Pattern: İsteği Object'e Çevirmek, Undo/Redo ve Kuyruğa Atmak" yazısında.
Replace Command with Function bunun tersi ve çoğu zaman daha sık gereken yön. Kargo metnini üreten ShippedMessageBuilder tek bir method'u olan, durumu olmayan bir class'tı; her kullanımda new ShippedMessageBuilder(order).Build() yazılıyordu. Ondan geriye private bir fonksiyon kaldı:
// Önce
public class ShippedMessageBuilder(Order order)
{
public string Build() =>
order.Items.Count == 1
? $"Your order of {order.Items[0].Title} has shipped."
: $"Your order of {order.Items.Count} books has shipped.";
}
// Sonra (Notifier içinde)
private static string ShippedText(Order order) =>
order.Items.Count == 1
? $"Your order of {order.Items[0].Title} has shipped."
: $"Your order of {order.Items.Count} books has shipped.";
Ölçü basit: command, fonksiyonun yapamadığı bir şeyi (önizleme, geri alma, kuyruğa atma, adım adım kurma) gerçekten kullanıyorsa kalır; kullanmıyorsa fonksiyon olur.
IDE Desteği ve Bütün Adımlar
IDE sütunu Microsoft Learn, Visual Studio Code ve JetBrains belgelerine dayanıyor; IDE'leri bu yazı için çalıştırmadım. Derleyici satırları yukarıdaki gerçek çıktılardan.
| Refactoring | Smell | IDE / analyzer | Tersi |
|---|---|---|---|
| Separate Query from Modifier | Mutable Data | elle | yok |
| Parameterize Function | Duplicated Code | Introduce Parameter (Rider, Visual Studio Code) | yok |
| Remove Flag Argument | Long Parameter List | elle; eskisine [Obsolete] ve CS0618 | yok |
| Replace Parameter with Query | Long Parameter List | Change Signature ile parametreyi silmek (Visual Studio, Rider) | Replace Query with Parameter |
| Replace Query with Parameter | Global Data | Introduce Parameter (Rider, Visual Studio Code) | Replace Parameter with Query |
| Remove Setting Method | Mutable Data | elle; CS0200 kalan atamaları listeler | yok |
| Replace Constructor with Factory Function | yok (constructor'ın sınırları) | Replace Constructor with Factory Method (Rider) | yok |
| Replace Function with Command | Long Function | elle | Replace Command with Function |
| Replace Command with Function | Lazy Element | elle | Replace Function with Command |
| Return Modified Value | Mutable Data | elle | yok |
| Replace Error Code with Exception | yok (unutulabilen kontrol) | elle | yok |
| Replace Exception with Precheck | yok (akış kontrolü için exception) | elle | yok |
$ git log --oneline
39a5f78 chore: samples/binary-compat (a default parameter breaks old binaries)
b31365e refactor: replace command ShippedMessageBuilder with function ShippedText
fbbaf5d refactor: split PriceDropAlert into Recipients (preview) and Execute
3eff95c refactor: replace function PriceDropAlerts.Send with command PriceDropAlert
3cd9855 refactor: replace error codes in the new API (ArgumentException for -1, SmsResult for -2)
9d940d5 refactor: remove flag arguments (NotifyShipped, NotifyShippedBySms, NotifyShippedBySmsUrgently; SendNotification obsolete)
3ebd834 refactor: replace exception with precheck (TryGetValue for the phone)
8146b64 refactor: remove setting methods (Id and SendAt are set once)
edd292a refactor: replace constructor with factory functions (Shipped, Reminder, PriceDrop)
d59bca6 refactor: return modified value (QuietHoursAdjusted returns the time)
dae92ee refactor: replace parameter isNight with a query inside ApplyQuietHours
3375ebc refactor: parameterize RemindAfterOneDay/ThreeDays into RemindAfter(days)
532c96d refactor: replace query with parameter (DueReminders takes now)
ead604d refactor: separate query from modifier (Unread + MarkAsRead)
72c8117 test: characterize notifications (legacy)
Bütün adımlar refactor:. Yeni API'nin boş siparişte exception fırlatması bir davranış farkı gibi görünebilir, ama yeni method'ların henüz paket dışında bir çağıranı yoktu; dışarıdaki herkesin kullandığı eski method'un davranışı snapshot'la sabit kaldı.
Sık Düşülen Tuzaklar
Replace Error Code with Exception'ı beklenen iş sonuçlarına uygulamak. "Müşteri SMS istemiyor", "stok yok", "kupon süresi dolmuş" hata değil, iş kuralı. Bunları exception'la bildirmek çağıranı try/catch ile akış yönetmeye zorlar ve loglarda gerçek hataları gizler. Exception programın yanlış kullanıldığı ya da dış sistemin beklenmedik davrandığı durumlar için.
Public API'ye varsayılan değerli parametre eklemek. Source düzeyinde uyumlu görünür, binary düzeyinde kırıcıdır. Paket kullanan biri varsa yeni overload ya da yeni method ekle, eskisini [Obsolete] ile işaretle ve major sürüme kadar koru.
Query ile modifier'ı ayırıp modifier'a yanlış şeyi vermek. MarkAsRead(customerId) yazmak daha kısa görünür, ama iki çağrı arasında gelen bildirimi okunmadan okundu yapar. Modifier, query'nin gösterdiği şeyi işaretlemeli.
Her fonksiyonu command'a çevirmek. Command'ın bedeli fazladan bir class ve bir yaşam döngüsü. Bir fonksiyonu yalnız "daha nesne yönelimli görünsün" diye command'a çevirme; önizleme, geri alma ya da kuyruğa atma gibi gerçek bir ihtiyaç yoksa tersini uygula.
Flag'i silip yerine aynı anlama gelen bir enum koymak ve yine tek method'da bırakmak. Send(order, Channel.Sms) bir bool'dan daha okunurdur, ama method'un içindeki dallanma aynı kalır. Değerler gerçekten farklı işler yapıyorsa ayrı method'lar, yalnız bir veriyi değiştiriyorsa parametre.
Küçük Bir Deneme
★☆☆ Codebase'te bir değer döndüren ve aynı zamanda bir şey kaydeden, gönderen ya da işaretleyen bir method bul. Onu bir debugger watch penceresinde ya da bir log satırında çağırmak ne değiştirir? Query ve modifier'a ayır.
★★☆ Ekibinin bir paketinde varsayılan değerli parametresi olan public bir method bul. samples/binary-compat'taki gibi küçük bir deneme kur: eski sürüme karşı derlenmiş bir uygulamayı yeni DLL ile çalıştır. Sonra package validation'ı açıp aynı değişikliği yakalıyor mu bak.
★★★ DateTime.Now, DateTimeOffset.UtcNow ya da bir configuration değerini içeriden okuyan üç method seç. Her biri için karar ver: değeri parametre olarak mı almalı, TimeProvider gibi bir dependency olarak mı, yoksa olduğu gibi mi kalmalı? Kararını testin ne kadar kolaylaştığıyla ölç.
API ailesi de bitti. Katalogda tek bir aile kaldı ve serinin son yazısı onu ele alıyor: inheritance ile çalışan on bir refactoring. Pull Up ve Push Down, Extract Superclass ve Collapse Hierarchy, type code'u subclass'a çevirmek ve geri almak, ve inheritance'ı delegation ile değiştirmek.