1 puan yazan GN⁺ 5 시간 전 | 1 yorum | WhatsApp'ta paylaş
  • Go’daki nil kontrolleri panikleri önleyebilir; ancak tekrarlandıkları yer yanlışsa kod, “neyin nil olabileceğini” kendi kendine açıklayamaz
  • Redis istemcisi gibi zorunlu bağımlılıkları iç metotlarda kontrol etmek, oluşturma hatasını normal çalışma yolunun parçasıymış gibi ele almak anlamına gelir
  • Yalnızca constructor’da nil’i elemek yeterli değildir; NewRedisClient(addr) gibi başlatma noktalarında hata hemen ele alınmalıdır
  • İstek nesnesi gibi dışarıdan gelen değerler HTTP handler’ı, RPC dispatch’i, kuyruk consumer’ı gibi sınır katmanlarında doğrulanmalı; iç mantık bu garantilere güvenmelidir
  • İmkânsız olması gereken durumları sessizce kabul etmek hatayı sessiz, gecikmeli ve belirsiz hale getirir; daha sonra metrikler, dashboard’lar ve alarmlarla kaybolan sinyali yeniden oluşturma maliyeti doğar

nil kontrolü her zaman savunmacı programlama değildir

  • Production’da panikleri önlemek için deferred recoverdan önce girdileri, aralıkları ve pointer’ları kontrol eden savunmacı programlama gerekir
  • Doğru yerde yapılan nil kontrolü güvenli kod üretir; yanlış yerde yapılan kontrol ise hangi değerin nil olabileceğinin izlenemediğine dair bir sinyale dönüşür
  • Bu kalıp üretilen kodda daha sık görülür, ancak ne yeni bir olgudur ne de yalnızca yapay zekayla sınırlıdır
  • nil kontrolü ucuz ve güvenli görünebilir; fakat bir sonraki okuyucuya “bu değer nil olabilir” mesajını bırakır ve çoğu zaman yanlış anlam taşır

Bağımlılık nil kontrollerinin sorunu

  • RateLimiterın alan olarak *redis.Client taşıyıp Allow içinde r.redis != nil kontrolü yapması dışarıdan güvenli görünebilir
  • Redis istemcisi nil ise sorun Allow çalıştığı anda değil, zaten oluşturma anında ortaya çıkmıştır
  • İç metotta nil kontrolü yapmak, oluşturma hatası durumunda çalışmaya devam etmeyi kabul edilebilir bir durum gibi ele alır
  • Bu tür kontroller, kodun nesnenin kaynağını, başlatma sorumluluğunu ve nil’in imkânsız olması gereken değişmez koşulunu kaybettiğinin sinyalidir

Constructor’da nil kontrolü tek başına yeterli değildir

  • NewRateLimiter(client *redis.Client) içinde client == nil ise hata döndürmek daha iyidir, ancak eksiksiz bir çözüm değildir
  • nil pointer’ın fonksiyona kadar aktarılmış olması bile hatalı bir durumun sisteme çoktan girdiği anlamına gelir
  • Asıl hata, Redis istemcisinin oluşturulduğu başlatma noktasında ele alınmalıdır
    • redisClient, err := NewRedisClient(addr) sırasında hata oluşursa hemen dönülmelidir
    • Sonrasında NewRateLimiter(redisClient) yalnızca geçerli bir istemci almalıdır
  • Böylece RateLimiter constructor’ının hata döndürmesine de gerek kalmaz
  • Depolamanın geçici olarak kullanılamaz olmasına izin vermek gerekiyorsa nil’i yaymayın; bunun yerine her zaman non-nil olan bir dış tiple sarmalayarak yeniden deneme veya performans düşüşü davranışını içeride kapsülleyin
  • Bu, veritabanlarındaki NOT NULL veya foreign key kısıtlarına benzer
    • Hatalı satırlar baştan var olamazsa her sorgunun veriyi yeniden kontrol etmesine gerek kalmaz
    • Runtime değerleri için de değişmez koşul bir kez kurulduğunda kodun geri kalanı tekrarlı kontrollerden kaçınabilir

Sessiz hataların maliyeti

  • Küçük bir değişiklik yüzünden programı durdurmak istemeyip yalnızca nil kontrolü yapmak veya log bırakmak istikrarlı hissettirebilir
  • Gerçek seçenekler “crash mi, çalışmaya devam mı”dan çok yüksek sesle başarısız olmak mı, sessizce başarısız olmak mı ayrımına yakındır
  • Açıkça döndürülen hataların üç özelliği vardır
    • Açıklık: Hatanın oluştuğu anlaşılır
    • Anındalık: Hata, nedenine yakın yerde fark edilir
    • Atfedilebilirlik: Çağıran taraf hatayı ilgili işlemle ilişkilendirebilir
  • Yutulan hatalar bunun tersine çalışır
    • Hata sessizce kaybolur
    • Daha fazla kod çalıştıktan sonra daha sonra belirti olarak ortaya çıkar
    • Belirti göründüğünde nedeni belirlemek zorlaşır
  • Program hatalı durumla hayatta kaldıkça, neden ile belirti arasındaki mesafe de büyür
  • Doğru düzeltme hatayı yerel olarak gizlemek değil; hatanın nereye yayıldığını ve nerede isteği reddetmeye, işi başarısız kılmaya, yeniden denemeye, alarm üretmeye ya da çıkış yapmaya dönüştüğünü anlamaktır
  • Hata döndürmek sistemi gerekenden fazla durduruyorsa sorun o fonksiyonda değil, hata işleme sınırındadır

Kaybolan sinyali yeniden üretmenin ikincil maliyeti

  • Hatalar sessizleştiğinde gerçekte ne olduğunu bilemezsiniz; bug’lar gizlenebilir
  • Bu durumda davranışın yokluğunu algılamak için metrikler, dashboard’lar ve alarmlar gibi gözlemlenebilirlik altyapısı kurmanız gerekir
  • İmkânsız veya ele alınmamış bir duruma her izin verişinizde, attığınız sinyali daha sonra gözlemlenebilirlikle geri kazanmanın mühendislik maliyetini ödersiniz

Dış katmanların ve iç katmanların rolleri

  • Çalışmanın başladığı ve dış verinin içeri girdiği yer dış katmandır; bu çağrının ulaştığı daha derindeki kod ise iç katmandır
  • Çalışmanın başında hiçbir şey garanti değildir, ancak henüz yapılmış bir iş de yoktur
  • Başlatma sırasında programın bağımlı olduğu unsurlar ayarlanmalı ve her unsurun mutlaka gerekli mi yoksa geçici olarak yok olabilir mi olduğuna karar verilmelidir
  • Tasarım, her zaman kullanılabilir bağımlılıklar yönünde olmalı; çalışma ortasında ortadan kaybolabilecek bağımlılıklar en aza indirilmelidir

İstek kapsamındaki veriler sınırda doğrulanmalıdır

  • İstek nesnesi, istek alanları ve istekten türetilen değerler sabit bağımlılıklardan farklıdır
  • İstekler her çağrıda HTTP handler’ı, RPC, kuyruk, test helper’ı veya başka paketler gibi dış kaynaklardan gelir
  • RateLimiter.Allow(ctx, req) içinde req == nil kontrolü yapmak da bağımlılık nil kontrolüyle aynı hatadır
  • İstek Allow içinde ilk kez ortaya çıkmış değildir; daha öndeki taşıma sınırından girip kodun içinde ilerleyen bir değerdir
  • Allow gibi iç fonksiyonlarda yeniden doğrulamak, dış katmanın garanti etmesi gereken şeyi derindeki bir fonksiyonun tekrar doğrulaması anlamına gelir ve belirsizlik yayılır

Sınır doğrulamasından sonra iç mantık değişmez koşullara güvenir

  • nil kontrolü, güvenilmeyen byte’ların *Request gibi iç tipe dönüştüğü sınır noktasında olmalıdır
  • HTTP handler örneğinde DecodeRequest(r) başarısız olursa http.StatusBadRequest ile yanıt verilir ve dönülür
  • Doğrulama bittikten sonra req geçerli bir değerdir; ardından h.limiter.Allow(r.Context(), req) bu değere güvenebilir
  • Dışarıdan alınan veri kontrol edilemediği için sınırda nil’i ve gerekli kısıtları kontrol etmek mantıklıdır
  • Sınırı geçen veri iç tiplere ve iş mantığına eşlenir; sonrasında sistemin değişmez koşulu haline gelir
  • Nihai Allow, nil kontrolü olmadan gerçek mantığa odaklanır
    • userID := GetUserID(req)
    • userID == "" ise false, nil döndürür
    • Aksi halde r.checkLimit(ctx, userID) çağrılır
  • Boş userID kontrolü HTTP katmanına da taşınabilir; ancak örnekte bu politikanın hız sınırlayıcıya ait olması tercih edilmiştir

Tekrarlanan nil kontrolleri yeni dallar ve yeni davranışlar yaratır

  • Bu yapıdaki sistemlerin akıl yürütmesi ve değiştirilmesi kolaydır
  • Buna karşılık değişmez koşulları olmayan sistemler, her yere kontroller ekledikten sonra her kontrol için ne yapılacağına karar vermek zorunda kalır
  • Her nil kontrolü yeni bir daldır; her dal da var olmaması gereken bir durum için yeni bir davranış tanımlatır
  • nil kontrolleri, belgelenmiş sınırları zorunlu kılarken veya bilinçli olarak seçilebilir durumları modellerken yararlıdır
  • Programın imkânsız saydığı durumları sessizce ele alan nil kontrollerinden şüphe edilmelidir
  • nil kontrolleri her yerde görünüyorsa iki olasılıktan biri söz konusudur
    • Güvenilmeyen sınır girdisini koruyan normal kod
    • Kod tabanının değişmez koşullar kuramadığı bir tasarım sorunu
  • Hiçbir parametreye güvenilemeyen bir sistemde hemen kontrol eklemek gerekebilir; ancak asıl iş, bu kontrolün yerine geçtiği değişmez koşulu kurmak ve bunu güvenilir bir garantiye dönüştürmektir

1 yorum

 
GN⁺ 5 시간 전
Lobste.rs yorumları
  • Diğer Go programcılarından tekrar rica ediyorum: lütfen hataları sarmalayın

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    Çağrı yığını açılırken hataya dair bağlam birikmeli

    • Daha idiomatik bir örnek şöyle görünür
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      Sonrasında her katmanın yalnızca hatanın nerede çıktığını eklediği, en içteki errnin ise ne olduğunu söylediği bir yapı daha iyi
    • Ne yazık ki hatalar için birleşik, fiilen standart sayılabilecek bir yığın izi yok
      Pratikte “sarmalama” çoğu zaman hata dizgesini greplemek, o dizgenin benzersiz olmasını ummak ve dizgeyi benzersiz kılmak için zoraki yaratıcılığa başvurmak anlamına geliyor
    • Hata yığınının çok uzun olduğundan şikâyet edenler de var, ama çoğu kişi bu tür mesajların eyleme dökülebilir ve yararlı olduğunu düşünüyor
      Eskiden bir ağ ürününde bir mühendis yüzlerce hata mesajını düzeltmek için bir ay harcamıştı; çünkü günlüklerde “What the f-ck?” yazması son kullanıcıya yardımcı olmuyordu
      Bu mesajları yararlı hâle getirmek ve yukarıdaki nedenlerle hata yığını da eklemek gerekiyordu
    • Güncel yaklaşım, hatırladığım kadarıyla errors.Join kullanmak yönünde
  • Go’nun burada iki sorun yarattığını düşünüyorum

    1. Go’da açık bir null olabilirlik (nullability) olsaydı, bu sorun neredeyse tamamen ortadan kalkardı
    2. Adlandırılabilen tiplerin sıfır değerle başlatılmasını engellemenin bir yolu yok gibi görünüyor; bu yüzden hatalar her an araya sızabilir
    • Yazıdaki şu cümlenin temel sorunu iyi ortaya koyduğunu düşünüyorum
      “Ne geçirileceğini kontrol edemediğiniz için, o sınırda nil olup olmadığını kontrol etmek makuldür” kısmı
      Dış girdiler için bu doğru, ancak tüm işaretçiler nil olabiliyorsa kod tabanı içinde güvenli sınırları izlemek için çıkarım yapmak gerekir
      Go’nun sorunu, bu çıkarımı derleyiciye değil tüm programcıların zihnine yaptırmaya zorlaması
  • Rust’ta Option<T> var, C#’ta ise null olabilir tipler var
    2026’da hâlâ bu tür sorunlar yaşamamız gerektiğini düşünmüyorum

    • Karşı taraftan bakarsak, “yok” veya “eksik” durumunu kısa yoldan ifade edebilmek, özellikle JSON gibi keyfî veri yapılarını işlerken çok kullanışlı
      Dillerde sözdizimi genelde daha az ilginç bir unsur olsa da, sevdiğiniz betik dilinde foo.bar.baz yazmak Rust’taki foo.unwrap().bar.unwrap().baz yazmaktan çok daha kolay
      Rust’ı seven biri olarak da böyle düşünüyorum; ayrıca Go ve Rust sık sık aynı sepete konulsa da Go’nun, C programcılarının yeniden yaptığı bir betik diline çok daha yakın olduğunu düşünüyorum
      Yine de bir dil null kullanıyorsa varsayılanın null olamaz olması daha iyi. Özellikle ? veya .? gibi kısa bir sözdizimi varsa, büyük projelerde sözdizimsel yükü taşımaya değer
    • İşaretçi kullanmazsan null da olmaz, yaşasın… 😭
  • Go’nun null olamaz nesneleri iyi modelleyen bir dil olmadığını anlıyorum
    Bu açıdan C’ye benziyor; Option<T>, T* ile temsil edilebilir ama T* mutlaka Option<T> anlamına gelmez
    Genel olarak yazıya katılıyorum. Gömülü yazılım geliştiren bir şirkette çalışırken de C++ kodunda her yere null kontrolü koymak yerine assert kullanalım diye ikna etmeye çalışmıştım
    assert hata ayıklamayı kolaylaştırır, kapsama açısından dal olarak sayılmaz ve okuyana beklenen koşulu açıkça iletir. Sürüm derlemelerinde çıkarıldığı için daha verimlidir de
    Ancak Go’da nil dereference zaten iyi hata ayıklama bilgisi verdiğinden, assert’in faydasının C++’taki kadar büyük olmadığını anlıyorum

    • Go’daki nil dereference, C’deki null pointer dereference’a göre daha iyi biçimde deterministik olarak panic üretir; ama gerçek işaretçi dereference edildiği anda hata verdiği için o kadar da harika değil
      Yazıdaki örnekte checkLimitin derinliklerinde patlar ve orada nil’in kaynağını geriye doğru izlemek gerekir. Sisteme veya mimariye bağlı olarak bu epey karmaşık olabilir
      Bu yüzden NewRateLimiterın hemen içinde assert etmek kesinlikle faydalı. Örnek kodda
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      şunu yapmak gibi olur
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      Ancak Go ekibi assertion’a güçlü biçimde karşı ve panic de ideal değil; yakalanmazsa tüm runtime’ı çökertir
    • null kontrolü ile assert’in tamamen farklı şeyler olduğunu düşünüyorum
      assert “bu durum geçerli değil” demektir ve assert makrosu sürüm derlemesinde o null kontrolünü no-op hâline getirebilir
      assert makrosunun tanımlanma biçimine bağlı olarak tanımsız davranışla ilgili optimizasyonlar gerçekleşebilir; sonrasında kontroller kaldırılıp kafa karıştırıcı çökmelere yol açabilir
      Örneğin assert(p); if (!p) { ... } ifadesinde sonraki kontrolün kaldırıldığı türden assert tanımları gördüm
      Düşünmeden “null kontrolü yapma, assert kullan” demek durum değişmezleri için uygun olabilir ama hata kontrolü için uygun değil
  • Sonuç bölümünde iyi bir tavsiye var
    nil kontrolleri her yerde görünmeye başladıysa bu iki şeyden biridir: güvenilmeyen sınır girdilerine karşı savunma yapan normal kod ya da kod tabanının değişmezler oluşturamadığını gösteren bir tasarım sorunu
    Hiçbir parametreye güvenilemeyen bir sistemde çözüm daha fazla kontrol eklemek değildir. Kısa vadede bunu yapmak gerekebilir, ama asıl iş, o kontrollerin yerine geçtiği değişmezleri oluşturmak ve korkudan doğan gürültüyü sistemin dayanabileceği garantilere yavaş yavaş dönüştürmektir
    Bence bu, nil kontrollerinin de ötesine geçiyor. Sistemin “yaprak” kısımlarına kontrol ya da savunma kodu eklemek, çoğu zaman değişmezlerin eksik olduğu veya düzgün zorlanmadığı bir belirtisini tedavi etme biçimi olarak ortaya çıkıyor
    “Bir kontrol daha ekle” yaklaşımını varsayılan yapmak kolaydır ama ölçeklenme sınırı vardır. Bir noktada kontrol mantığı, işlev mantığından daha fazla hale gelir ve toplam karmaşıklık kontrolden çıkar
    Bir iki hatayı önlemek için eklenen kontroller genelde zararlı değildir; ama kontrol sayısının ve karmaşıklığının fazla arttığını hissettiğinizde, sürekli yaprakları düzeltmek yerine bir adım geri çekilip kök nedeni aramak, uzun vadede hem sistem hem de bakımını yapanların hayatı için daha iyi oldu

    • Değişmezleri assert etmek, en baştan böyle başlayıp bunu sürekli koruduğunuzda harikadır
      Ancak geliştiricileri savunmacı programlamayı bırakmaları için eğitmek daha zor bir problemdir
  • Bu tür değişmezler, burada null olamazlık gibi şeyler, Go’dan daha ifade gücü yüksek tip sistemlerinde çok daha iyi modellenebilir
    Bu konuda en sevdiğim yazı, Alexis King’in 2019 tarihli Parse, don't validate yazısı
    İlke her yerde uygulanabilir, ama Haskell’in tip sisteminde gerçekten kolay görünüyor. TypeScript’te Alexis’in tavsiyesini yıllarca uygulamaya çalıştım ama kolay olmadı

  • Özetle sorun kontrollerin fazla olması değil, nil’i bir değer olarak sarmalamak

  • Bu sorun tekrar tekrar gündeme geldi; bence bu, hata işlemenin birinci sınıf özellik olmadığı dillerin sonucu
    Hatırladığım kadarıyla başka başlıklarda da geçtiği gibi, fiilen standart linter’lar bu yapıyı zorunlu kılıyor
    Bu nil kontrollerinin mantıksal olarak kötü olup olmadığını bilmiyorum. Birçok dil hata işlemeyi yerleşik olarak sunuyor; fark daha çok yaymanın tutarlılığı ve basitliği düzeyinde
    Hata döndüren bir arayüzle karşılaşıldığında seçenekler kabaca dört tane: işleyip toparlamak, yok saymak, hatayı yaymak, hatayı atıp kendi hatasını yaymak; sonuncusu mevcut hatayı sarmalayabilir de
    Hata işlemenin birinci sınıf özellik olduğu diller genelde 2. ve 3. seçenekleri kolaylaştırır; modern dillerde bu daha da böyledir. Bu yüzden 4. seçenek de dile bağlı olarak epey temiz hale gelebilir

    1. seçenek, böyle bir işlemenin gerektiğini daha açık hale getirmek dışında birinci sınıf destekle de pek kolaylaştırılamaz
      Temelde bir fonksiyon hata üretebiliyorsa, uygulamadan bağımsız olarak her dil {error,result} = functioncall() ardından if (error) { ... } yapıyormuş gibidir
      Go’da hata işleme birinci sınıf olmadığı için birçok fonksiyon önceden (result, err) demeti döndürür; linter da err != nil kontrolünü fiilen zorunlu kılınca kodun bu kalıpla dolduğu izlenimi oluşur
      Doğru hata işlemeyi dilin doğrudan ele almamasını bir dil tasarımı kusuru olarak görüyorum; ama bir kez o konumdaysanız bu model muhtemelen en iyiye yakın görünüyor
      Go kodunun deyimsel olarak, işlevsel açıdan yok sayılabilir hatalarla “önemsenmesi gereken” hataları ayırmak için isteğe bağlı dönüş tipleri kullanıp kullanmadığından emin değilim. Böyle durumlarda bile her zaman hata tipi döndürmek deyimsel ise linter her zaman bu kalıbı dayatacak gibi
      Go’dan nefret etmiyorum; yalnızca bir tasarım tercihine katılmıyorum. Neredeyse her dilin tasarım tercihleri hakkında şikâyet edilebilir
      Bence Go’nun en büyük hatası, açık err != nil kontrolünün fiilen her yerde işlevsel olarak gerekli olması ve bu yüzden linter’ların da bunu istemesi
  • Go ilk çıktığında da yüzlerce kişi bu bütün yapının ne kadar gülünç olduğuna dikkat çekmişti
    Ama dil çok popüler oldu ve Rob Pike’ın daha iyi bildiği havası içinde eleştiriler geçiştirildi
    İnsanların bunu ancak şimdi mantıklı gerekçelerle düzgün biçimde tartıştığını görmek güzel
    Sanki bu onlarca yıl öncesinden beri kötü bir fikir olarak bilinmiyordu da, Google yapıyorsa iyidir… değil mi?

    • Go hayranı değilim, ama bu çerçeveleme beni rahatsız ediyor
      Çünkü buna “gülünç saçmalık” demek, daha fazlasını görmek istediğini söylediğin mantıksal düşüncenin kendisini bastırmayı kolaylaştırır
      Hangi Oxide podcast’iydi unuttum ama Bryan Cantrill bir keresinde “bundan daha iyi nefret edebilmek için bunu incelemek istiyorum” gibi bir şey söylemişti
      Bu anlamda 2010’larda insanların Go’ya neden bu kadar heyecanlandığını anlamak istiyorum. Bunun bir kısmı kesinlikle abartıydı; o dönemde iş yerimde geliştiricilerin neden iyi olduğunu açıklayamadan heyecanlandıklarını bizzat gördüm
      Ama sadece saf abartıdan ibaret olmasa gerek. O dönemde Go kullanmak için en güçlü steel-man argument neydi, merak ediyorum