Golang Blazing Fast Unit Tests - Fiber/fasthttp/http Internals and Optimizing HTTP Server Tests

Emre Savcı
7 min readSep 11, 2022

--

Selamlar bu yazımda sizlere Go ile geliştirdiğimiz projelerde HTTP serverlarımızın test performansını nasıl arttırabileceğimizi anlatacağım.

Geçtiğimiz günlerde, geliştirdiğimiz bir uygulama için yeni bir özellik üzerinde çalışırken unit testlerin tamamlanması beklerken kahve almaya gidip gelebileceğimi fark ettim. Ve testleri paralel çalıştırmak mümkün değildi. Eğer mevcut haliyle paralel çalıştırmak isteseydik bu test süresi çok daha uzun sürebilirdi.

Şimdi içeriğe kısaca bir göz atıp konumuzun detaylarına geçelim.

İçerik

  • Go HTTP Internals
    — http.ListenAndServe
    — net.Conn & net.Listener
    — http.Client Dial Function
    — net.Pipe

Testing in Different Packages

  • Fiber
    — app.Test Internals
    — app.ServeConn
  • Fasthttp
    — fasthttputil.NewInmemoryListener
  • Go HTTP
    — httptest.NewUnstartedServer
    — Mock InMemListener Example
  • Our Test Performance Improvement
  • Conclusion

Go HTTP Internals

Go dili ile basit bir HTTP server çalıştırmak istediğimizde yapmamız gereken işlemlere bir bakalım.

http.ListenAndServe

Go standart kütüphanesini kullanarak yazdığımız bir server çalıştırmak istediğimizde ListenAndServemethodunu kullanırız.

ListenAndServe methodunun detaylarına bir bakalım:

Şimdi de server.ListenAndServe() methodunun detaylarına bakalım:

Son olarak srv.Serve(ln) methodunun detayına bakalım. Çok uzun olmaması açısından bazı kodları kaldırdım.

Burada dikkat etmemiz kısım ise l.Accept methodu.

Buraya kadar paylaştığım kodlarda yapılan işlemin özeti aslında şu:

  • http.ListenAndServe methodu arka planda bir server oluşturuyor ve server ListenAndServe methodu çalıştırılıyor
  • Server kendi içerisinde net.Listen("tcp", addr)method çağrımı ile belirtilen porttan istek kabul eden bir net.Listener objesi oluşturuyor ve bunu Serve methodunu çalıştırıyor
  • Serve methodu parametre olarak aldığı net.Listener objesinin Accept() methodu ile yeni gelen bağlantıları temsil edennet.Conn objesi üzerinde işlem yapıyor

Buraya kadar temel haliyle bir API oluşturma işlemini gördük.

Artık yavaş yavaş “iyi de hocam bunlar gerçek hayatta ne işimize yarayacak” dediğinizi duyar gibiyim.

Merak etmeyin, birazdan bu bilgileri kullanarak test performansımızı nasıl arttıracağımızı göreceğiz.

net.Listen & net.Conn

Şimdi HTTP paketi içerisinde yer alan bu iki temel tipe yakından bir bakalım.

Görüyoruz ki bu iki tip aslında birer interface.

Peki bu ne anlama geliyor? Bu interface tiplerini parametre olarak bekleyen methodlara, bu interfaceleri implement ettiğimiz özel structları parametre olarak verebiliriz. Burası bizim için önemli bir nokta.

http.Client Dial Function

Şimdi de Go ile HTTP isteği yapabilmemizi sağlayan http.Client objesinin nasıl kullanıldığına bakalım.

Go HTTP paketini kullanarak istek göndermenin iki yöntemi var.

  • Birinci yöntem doğrudan http.Get http.Post methodlarını kullanmak
  • İkinci yöntem http.Client objesini kullanmak

resp, _ := http.Get("http://localhost:8080/test")

Aslında ilk yöntem olan http.Get de arka planda http.Client objesini kullanıyor.

İsterseniz kısaca bakalım:

Yani aslında iki yöntem de arka planda aynı işlemi yapıyor.

Şimdi http.Client objesini oluştururken özelleştirebileceğimiz alanlarına bakalım.

http.Client tüm bağlantı kurma işlemlerini RoundTripper interface’i ile sağlar. Ve kendi içerisinde RoundTripper implementasyonu olarak Transport objesini kullanır.

Eğer istersek http.Client oluştururken bu Transport objesini kendimiz oluşturabiliriz.

Oluşturduğumuz Transport objesinin DialContext isimli bir methodu var. Bu method arka planda bağlantı açma işlemlerini sağlayan methoddur, değişken olarak net.Conn tipinde bir değer döndürür.

Birazdan bu bilgiyi ve az önce gördüğümüz server.Serve methodunun parametre olarak aldığınet.Listener değişkeninin accept methodunu kullanaraknet.Conn ile bağlantıları dinlediği bilgisini birleştireceğiz.

Yavaş yavaş parçalar yerine oturuyor değil mi?

net.Pipe

Son olarak bahsetmek istediğim bir method daha var: net.Pipe(). Return değeri olarak iki adet net.Conn tipinde değişken döndürüyor.

Bu değişkenlerden bir tanesi yazma ucu diğeri okuma ucu olarak kullanılabiliyor. Peki bu ne demek? Bunu nasıl kullanabiliriz?

Hatırlarsanız Listener objesinin Listen() methodu net.Conn değişkeni döndürüyordu. HTTP client Transportobjesinin Dial() methodu da net.Conn değişkeni döndürüyor.

Buradan yola çıkarak, net.Pipe() methodunun döndüğü connectionların bir ucunu HTTP client’ımızın Dial methoduna diğer ucunu da Listener objemizin Listen methoduna verdiğimizde, Listener tarafından beklenen bağlantıyı HTTP client ile yaptığımız istek oluşturacak.

Testing in Different Packages

Şimdi Go dilinde yaygın olan birkaç farklı web application kütüphanesini kullanarak API oluşturalım ve testlerini yazalım.

Fiber

İlk önce projelerimizde sıklıkla kullandığımız ve benim de contribution yaptığım Fiber kütüphanesine bakalım.

Fiber ile test yazmak oldukça basit çünkü kendi içerisinde doğrudan Test methodu bulunduruyor.

Şimdi örnek bir Fiber uygulamasına ve nasıl test edildiğine bakalım:

Burada dikkat etmemiz gereken yer app.Test(r, -1) satırı. Test edebilmek için HTTP request’imizi burada gönderiyoruz.

Peki normalde bir Fiber uygulamasını çalıştırmak ve bir portu dinlemesini sağlamak için yaptığımız app.Listen(":8080") işlemini yapmadan bu HTTP isteği nasıl gitti?

İsterseniz app.Test() methodunun arka planında neler olduğuna bir bakalım (tüm koda buradan erişebilirsiniz) :

Kodu incelediğimizde görüyoruz ki aslında Fiber, uygulamayı test edebilmek için net.Pipe() kısmında anlattığımız gibi kendi içerisinde conn := new(testConn) diyerek kendi bağlantı objelerini oluşturuyor. Bu bağlantıya göndermek istediği HTTP isteğini yazıyor ve bu bağlantıyı app.server.ServeConn(conn) çağrısı ile server’a gönderiyor.

Peki normalde Fiber uygulamasında app.Listen(":8080") dediğimizde ne oluyor? Hadi kısaca buna da göz atalım:

Burada görüyoruz ki Fiber kendi içerisinde net.Listen() işlemini yapıyor ve oluşturduğu listener objesini app.server.Serve(ln) diyerek servera veriyor. Server da bu listener objesinden bağlantıları dinleyip kendi içerisinde serveConn() methoduna parametre olarak geçiyor.

Yani ilk başta açıkladığımız Listener ve net.Conn objelerini bir araya getirerek fiziksel olarak port dinlemeden test edebilmemizi sağlayan bir yaklaşım uyguluyor.

Fasthttp

Fiber arka arplanda Fasthttp paketini kullanıyor demiştik. Eğer istersek biz de doğrudan Fasthttp paketini kullanarak yüksek performanslı API uygulamaları geliştirebiliriz.

Şimdi de Fasthttp ile geliştirdiğimiz bir uygulamanın testini nasıl yapabileceğimize bakalım.

Yukarıdaki kodda basit bir server oluşturup bir handler tanımladık ve /test endpointine GET isteği atıldığında geriye “OK” cevabını döndük.

Burada bir önceki koddan farklı olarak ln := fasthttputil.NewInmemoryListener() şeklinde bir tanımlama görüyoruz.

Devamında ise bir http.Client tanımlıyoruz ve oluşturduğumuz ln değişkeninin Dial methodunu Transport objesinin DailContext methoduna veriyoruz

Peki bu size bir şeyi hatırlattı mı? Evet, yukarıda bahsettiğimiz net.Listener objesini http.Client değişkenimize verebileceğimizi ve in-memory bir şekilde test edebileceğimize değinmiştik. Fasthttp arkaplanda bir utility paketi ile tam olarak bu yaklaşımı uyguluyor.

Kodun detaylarını merak edenler şuradan okuyabilirler.

Go HTTP

Şimdi ise harici hiçbir kütüphane kullanmadan Go’nun kendi paketleri ile geliştirdiğimiz bir API için test yazalım.

Diyelim ki yukarıdaki gibi bir API geliştirdik. Peki bunu nasıl test edeceğiz?

Yukarıda öğrendiğimiz teknikleri burada da kullanarak testlerimizi yazabiliriz. Go dilinde httptest adında bazı utility methodlar barındıran bir paket bulunur. Bu pakette NewUnstartedServer() isimli bir method bize start edilmemiş bir test Server objesi döner.

Kaynak koda şöyle bir göz gezdirelim:

httptest.NewUnstartedServer() methodu ile bir Server oluşturduğumuzda arka planda newLocalListener() internal methodu ile rastgele bir portu dinleyen bir Listener oluşturuluyor.

Start dediğimizde ise s.goServe() methodu ile bir goroutine içerisinde listenerdan bağlantıları dinliyor.

Listener gördüğümüz yerde aklımıza ne geliyor? Fasthttp’de olduğu gibi bir InMemoryListener oluşturup serverimiza verirsek biz de port dinlemeden testimizi gerçekleştirebiliriz.

Yukarıdaki kodda TestHttpServer isimli testte gerçekten fiziksel bir port dinleyerek serverımızı çalıştırıyoruz. time.Sleep işlemini serverın start etmesini beklemek için koydum. srv.Listener.Addr().String() methodunu kullanarak istek atmamız gereken adresi (OS tarafından random atanan port da dahil olacak şekilde) edinebiliriz.

TestHttpServerInMemory isimli testte ise fiziksel bir port dinlemeden, kendi oluşturduğumuz bir in-memory listener aracılığı ile http server-client haberleşmesini gerçekleştirdik.

Burada ben kolaylık olması açısından fasthttputil.NewInMemoryListener methodunu kullandım. Istersek kendimiz biz struct oluşturup Listener interface’ini implement ederek de aynı şeyi yapabiliriz.

Kısaca ona da bir göz atalım:

Yukarıda gördüğümüz gibi kendimiz Listener interface’ini implement ederek ve net.Pipe() methodunu kullanarak in-memory server-client haberleşmesi sağlayabiliriz. InMemListener struct’ımıza connection objelerini bir channel üzerinden vermemin sebebi, server’ın bir döngü içerisinde Accept() methodunu çağırarak yeni connection beklemesidir. Böylelikle ancak Listener objemize bir connnection verirsek Server tarafı yeni bir bağlantı alacaktır.

Peki bu yöntem neden Go tarafından default olarak sağlanmıyor? Aslında bununla ilgili zamanında açılmış bir issue var ve üzerinde çalışan bazı kişiler olmuş fakat henüz süreç sonuçlanmamış.

Our Test Performance Improvements

Şimdi de yukarıda anlattığım yöntemleri kullanarak kendi test performansımızı nasıl iyileştirdiğimizi görelim.

Biz uygulamamızı fasthttp ile geliştirmiştik ve bu uygulama temel bir reverse proxy görevi görüyordu. Reverse proxy işlemi için de fasthttp tarafından sağlanan bazı fonksiyoneliteleri kullanıyoruz.

Testlerimiz için ihtiyacımız, bir adet reverse proxy (kendi uygulamamız) ve bir adet de arka planda yönlendirme yaptığımız server’i temsil edecek sahte bir API.

Bunun için httptest.NewUnstartedServer kullandık ve fasthttptest.NewInmemoryListener kullandık.

Önceden testlerimizde test serverları çalıştırmak için gerçekten bir port dinliyorduk ve bu bizim test sürecimizi oldukça yavaşlatıyordu. CI pipeline’ımızdan baktığımda test sürecimiz yaklaşık 10 saniye sürüyordu.

Yaptığımız geliştirme sonucu in-memory listener yaklaşımını kullandıktan sonra tüm testlerimiz ~0.3 saniyede tamamlanır oldu.

Aşağıdaki görselde tüm testlerimizi ve toplam çalışma süresini görebiliyoruz.

Conclusion

  • Bir HTTP server’ı test etmek istediğimizde fiziksel bir PORT dinlemeden testimizi daha hızlı bir şekilde gerçekleştirebiliriz
  • Bu teknik ile testlerimiz çok daha hızlı bir şekilde çalışır
  • net.Pipe(), net.Listener, net.Conn kullanarak in-memory net işlemlerini simüle edebiliriz
  • Fiber ile uygulamalarımızı test ederken app.Test() methodunu kullanabiliriz
  • fasthttputil.InMemoryListener() kullanarak in-memory Listener oluşturabiliriz

Umarım sizler için eğlenceli ve bilgilendirici bir yazı olmuştur. Bir sonraki yazıda görüşmek üzere :)

Connect:

--

--

Emre Savcı

Tech. Lead @Trendyol & Couchbase Ambassador | Go Türkiye, Kubernetes, Istio, CNCF, Scalability. Open Source Contributor.