Golang Blazing Fast Unit Tests - Fiber/fasthttp/http Internals and Optimizing HTTP Server Tests
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 ListenAndServe
methodunu 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 birnet.Listener
objesi oluşturuyor ve bunuServe
methodunu çalıştırıyor - Serve methodu parametre olarak aldığı
net.Listener
objesininAccept()
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 Transport
objesinin 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