Go ile web sunucusu yazmak için harici bir çatıya ihtiyacınız yoktur: standart kütüphanedeki net/http paketi bir isteği karşılayan handler kavramını, istekleri doğru handler'a dağıtan ServeMux yönlendiricisini ve bağlantıları yöneten http.Server tipini birlikte sunar. En kısa hali http.HandleFunc ile bir yol kaydetmek ve http.ListenAndServe(":8080", nil) çağırmaktır; üretimde ise kendi ServeMux değerinizi, zaman aşımları ayarlanmış bir http.Server ile ve zarif kapanışla birlikte kullanırsınız.
Bu yazıda handler arayüzünü, Go 1.22 ile gelen metot ve joker (wildcard) destekli yönlendirme kalıplarını, desenler arasındaki öncelik kuralını, ara katman (middleware) yazmayı, JSON yanıt vermeyi, sunucu zaman aşımlarını ve Shutdown ile kapanmayı anlatıyoruz. Örneklerin hepsi net/http/httptest ile aynı program içinde bir test sunucusu açıp kapattığı için kopyalayıp doğrudan go run ile çalıştırabilirsiniz; hepsi Go 1.27.3 ile denendi. İstek gönderen tarafı ayrıca Go HTTP istemcisi yazısında ele alıyoruz.
Handler, HandlerFunc ve ResponseWriter

Paketin merkezinde tek metotlu bir arayüz vardır: http.Handler, ServeHTTP(ResponseWriter, *Request) metodunu tanımlar. Bu metodu yazan her tip bir isteğe yanıt verebilir. Çoğu zaman ayrı bir tip tanımlamak gerekmez; imzası uyan sıradan bir fonksiyonu http.HandlerFunc tip dönüşümüyle handler'a çevirebilirsiniz. mux.HandleFunc bunu sizin yerinize yapar. Arayüzlerin örtük uygulanması hakkında ayrıntı için Go interface yazısına bakabilirsiniz.
ResponseWriter üç şey yapar: Header() yanıt başlıklarını tutan haritayı verir, WriteHeader(kod) durum kodunu gönderir, Write gövdeyi yazar. Belgelerdeki önemli kural şudur: WriteHeader açıkça çağrılmazsa ilk Write çağrısı örtük olarak 200 OK gönderir ve başlıkta Content-Type yoksa yazılan ilk 512 baytı DetectContentType ile inceleyip bir tür belirler. Başlıklar durum kodu gönderildikten sonra değiştirilirse etkisi olmaz. Bu yüzden sıralama her zaman başlıklar, durum kodu, gövde şeklindedir.
Aşağıdaki program bir ServeMux oluşturur, /merhaba yoluna handler kaydeder ve aynı mux'u httptest.NewServer ile gerçek bir yerel portta çalıştırıp kendisine istek gönderir. Gerçek uygulamada son adım yerine http.ListenAndServe(":8080", mux) satırını kullanırsınız; bu fonksiyon her zaman nil olmayan bir hata döndürdüğü için genellikle log.Fatal ile sarılır.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func merhaba(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Merhaba, %s!\n", r.URL.Query().Get("ad"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/merhaba", merhaba)
// Gerçek programda: log.Fatal(http.ListenAndServe(":8080", mux))
// Burada aynı mux'u test sunucusunda çalıştırıyoruz.
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Get(ts.URL + "/merhaba?ad=Ayşe")
if err != nil {
fmt.Println("hata:", err)
return
}
defer resp.Body.Close()
govde, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(resp.Header.Get("Content-Type"))
fmt.Print(string(govde))
}
// Çıktı:
// 200 OK
// text/plain; charset=utf-8
// Merhaba, Ayşe!Çıktıdaki text/plain; charset=utf-8 değerini biz yazmadık; içerik türü koklama (sniffing) ile belirlendi. JSON ya da HTML döndürüyorsanız Content-Type başlığını kendiniz ayarlamanız daha güvenlidir. Sorgu parametrelerine r.URL.Query().Get("ad") ile, form alanlarına r.FormValue ile erişirsiniz. FormValue önce POST, PUT ve PATCH isteklerindeki URL kodlu gövdeye, sonra sorgu dizesine, en son multipart gövdeye bakar ve ayrıştırma hatalarını sessizce yok sayar.
DefaultServeMux yerine kendi ServeMux değeriniz
http.HandleFunc ve http.Handle üst düzey fonksiyonları kayıtları paket genelindeki DefaultServeMux değişkenine yapar; ListenAndServe çağrısına handler olarak nil verdiğinizde de bu mux kullanılır. Küçük denemelerde pratiktir, fakat içe aktardığınız herhangi bir paket de aynı global mux'a yol ekleyebilir. Hangi yolların dışarıya açık olduğunu tek yerden görmek için http.NewServeMux() ile kendi mux'unuzu oluşturup sunucuya açıkça vermeniz önerilir.
Go 1.22 yönlendirme kalıpları: metot, joker ve {$}

Go 1.22 sürüm notlarına ve Go Blog: Routing Enhancements for Go 1.22 yazısına göre ServeMux kalıpları iki yetenek kazandı: HTTP metodu eşleştirme ve yol içinde joker segmentler. Genel biçim [METOT ][HOST]/[YOL] şeklindedir ve üç parça da isteğe bağlıdır. Kurallar kısaca şöyledir:
GET /yazilar/{id}: yalnızca GET (ve HEAD) isteklerinde, iki segmentli ve ilkiyazilarolan yollarla eşleşir.{id}değerir.PathValue("id")ile okunur.- Metotsuz kalıp her metotla eşleşir; GET kalıbı HEAD isteklerini de kabul eder, diğer metotlar birebir eşleşmelidir.
{yol...}biçimindeki joker yolun geri kalanını eğik çizgiler dahil yakalar; bu yüzden yalnızca kalıbın sonunda olabilir.- Sonu
/ile biten kalıp (örneğin/static/) o önekle başlayan tüm yolları kapsar; yalnızca o tam yolu istiyorsanız/static/{$}yazarsınız./{$}sadece kök yolla eşleşir. - Joker tam bir segment olmalıdır:
/b_{bucket}geçersizdir ve Go 1.22 ile birlikte geçersiz kalıplar kayıt sırasında panic üretir. - Go 1.23 sürüm notlarına göre metot adından sonra bir veya daha fazla boşluk ya da sekme kullanılabilir.
Aşağıdaki örnekte istekleri ağ kullanmadan httptest.NewRecorder ile doğrudan mux'a veriyoruz. Dikkat edin: aynı yol için yalnızca GET kaydı varken gelen DELETE isteğine sunucu 404 değil 405 Method Not Allowed döndürür ve izin verilen metotları Allow başlığında listeler. Go 1.23 ile gelen Request.Pattern alanı da isteği hangi kalıbın yakaladığını gösterir.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /yazilar/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "yazı %s (desen: %s)", r.PathValue("id"), r.Pattern)
})
mux.HandleFunc("POST /yazilar", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, "oluşturuldu")
})
mux.HandleFunc("GET /dosyalar/{yol...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "dosya: %s", r.PathValue("yol"))
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ana sayfa")
})
istekler := []struct{ metot, yol string }{
{"GET", "/yazilar/42"},
{"POST", "/yazilar"},
{"DELETE", "/yazilar/42"},
{"GET", "/dosyalar/css/site.css"},
{"GET", "/"},
{"GET", "/yok"},
}
for _, i := range istekler {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(i.metot, i.yol, nil))
fmt.Printf("%-6s %-22s %d %q", i.metot, i.yol, rec.Code, rec.Body.String())
if a := rec.Header().Get("Allow"); a != "" {
fmt.Printf(" Allow=%s", a)
}
fmt.Println()
}
}
// Çıktı:
// GET /yazilar/42 200 "yazı 42 (desen: GET /yazilar/{id})"
// POST /yazilar 201 "oluşturuldu"
// DELETE /yazilar/42 405 "Method Not Allowed\n" Allow=GET, HEAD
// GET /dosyalar/css/site.css 200 "dosya: css/site.css"
// GET / 200 "ana sayfa"
// GET /yok 404 "404 page not found\n"PathValue değerleri URL kodlaması çözülmüş halde döndürür: belgedeki örneğe göre /b/{bucket} kalıbı /b/a%2fb yoluyla eşleşirse PathValue("bucket") sonucu a/b olur. Değer her zaman string'dir; sayıya çevirmek için strconv paketini kullanır ve dönüşüm hatasını 400 yanıtıyla bildirirsiniz.
Desen önceliği ve çakışma panic'i
Birden fazla kalıp aynı istekle eşleşebilir. Go burada kayıt sırasına bakmaz; en özel kalıp kazanır. Belgedeki tanıma göre P1 kalıbının eşleştiği isteklerin tamamını P2 de eşliyor ve fazlasını da eşliyorsa P1 daha özeldir. Bu yüzden /yazilar/son, /yazilar/{id} kalıbından önce gelir; GET /yazilar/{id} de metotsuz /yazilar/{id} kalıbından daha özeldir. Hiçbiri diğerinden özel değilse iki kalıp çakışır ve ikinci kaydı yapan Handle ya da HandleFunc çağrısı panic üretir. Tek istisna host içeren kalıplardır: aksi halde çakışacak iki kalıptan host içereni öncelik alır.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func yaz(metin string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, metin) }
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/yazilar/{id}", yaz("tek yazı"))
mux.HandleFunc("/yazilar/son", yaz("en son yazı"))
mux.HandleFunc("/resimler/", yaz("resim ağacı"))
for _, yol := range []string{"/yazilar/7", "/yazilar/son", "/resimler/a/b.png", "/resimler"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", yol, nil))
if rec.Code == http.StatusOK {
fmt.Println(yol, rec.Code, rec.Body.String())
} else {
fmt.Println(yol, rec.Code, "Location:", rec.Header().Get("Location"))
}
}
// Hiçbiri diğerinden daha özel olmayan iki desen çakışır: kayıt panic üretir.
defer func() {
_, aciklama, _ := strings.Cut(fmt.Sprint(recover()), "\n")
fmt.Println(aciklama)
}()
mux2 := http.NewServeMux()
mux2.HandleFunc("/yazilar/{id}", yaz("tek yazı"))
mux2.HandleFunc("/{kaynak}/son", yaz("çakışan"))
}
// Çıktı:
// /yazilar/7 200 tek yazı
// /yazilar/son 200 en son yazı
// /resimler/a/b.png 200 resim ağacı
// /resimler 307 Location: /resimler/
// /{kaynak}/son and /yazilar/{id} both match some paths, like "/yazilar/son".
// But neither is more specific than the other.
// /{kaynak}/son matches "/kaynak/son", but /yazilar/{id} doesn't.
// /yazilar/{id} matches "/yazilar/id", but /{kaynak}/son doesn't.Panic mesajının ilk satırı iki kalıbı ve kaydedildikleri dosya ile satır numarasını içerir (örnekte yolun makineye özel olduğu için o satırı kestik); sonraki satırlar neden çakıştıklarını örnek yollarla açıklar. Bu kontrol sunucu başlarken yapıldığı için hatalı bir kayıt üretime ulaşmadan yakalanır.
Örnekteki dördüncü satır da önemli bir davranışı gösteriyor: /resimler/ alt ağacı kayıtlıyken eğik çizgisiz /resimler isteği, sonuna eğik çizgi eklenmiş adrese yönlendirilir. Go 1.26 sürüm notlarına göre bu yönlendirme artık 301 yerine 307 Temporary Redirect koduyla yapılır. ServeMux ayrıca . veya .. içeren ve tekrarlanan eğik çizgili yolları temiz haline yönlendirir.
Ara katman (middleware) ve JSON yanıt
Go'da ara katman, bir http.Handler alıp yeni bir http.Handler döndüren sıradan bir fonksiyondur. İçteki handler'ı çağırmadan önce ya da sonra başlık ekleyebilir, süre ölçebilir, kimlik doğrulayabilir veya isteği reddedebilirsiniz. Bu desen fonksiyon değerleri ve closure'lar üzerine kuruludur (Go fonksiyonlar). Aşağıdaki örnekte her yanıta X-Sunucu başlığı ekleyen bir ara katman, bir ürünü JSON olarak döndüren handler ve hata durumları için http.Error kullanılıyor.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
)
type urun struct {
ID int `json:"id"`
Ad string `json:"ad"`
Stok int `json:"stok"`
}
var urunler = map[int]urun{1: {1, "Klavye", 12}, 2: {2, "Fare", 0}}
func urunGetir(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "geçersiz id", http.StatusBadRequest)
return
}
u, ok := urunler[id]
if !ok {
http.Error(w, "ürün bulunamadı", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
}
// sunucuAdi her yanıta başlık ekleyen basit bir ara katmandır (middleware).
func sunucuAdi(sonraki http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Sunucu", "ornek")
sonraki.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /urunler/{id}", urunGetir)
ts := httptest.NewServer(sunucuAdi(mux))
defer ts.Close()
for _, yol := range []string{"/urunler/1", "/urunler/abc", "/urunler/9"} {
resp, err := http.Get(ts.URL + yol)
if err != nil {
fmt.Println("hata:", err)
return
}
govde, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%s %d %s | %s", yol, resp.StatusCode, resp.Header.Get("X-Sunucu"), govde)
}
}
// Çıktı:
// /urunler/1 200 ornek | {"id":1,"ad":"Klavye","stok":12}
// /urunler/abc 400 ornek | geçersiz id
// /urunler/9 404 ornek | ürün bulunamadıhttp.Error belgelerine göre Content-Length başlığını siler, Content-Type değerini text/plain; charset=utf-8 yapar ve X-Content-Type-Options: nosniff ekler; fakat isteği sonlandırmaz. Bu yüzden ardından return yazmak sizin görevinizdir, aksi halde kod akmaya devam eder ve yanıta fazladan veri yazılır. json.NewEncoder(w).Encode çıktının sonuna satır sonu ekler; JSON ayrıntıları için Go encoding/json yazısına bakın.
Handler içinde bir panic oluşursa sunucu bunu o isteğe izole kabul eder: panic'i yakalar, yığın izini sunucu hata loguna yazar ve HTTP sürümüne göre bağlantıyı kapatır ya da HTTP/2 akışını sıfırlar. Yani bir handler'daki panic bütün süreci çökertmez, ama istemci yarım bir yanıt görür. Kendi hata yönetiminizi yine de açık bir şekilde yapmalısınız; defer, panic ve recover yazısı bu mekanizmayı ayrıntılı anlatıyor.
http.Server: zaman aşımları ve zarif kapanış

http.ListenAndServe sıfır değerli bir http.Server kullanır ve bu sunucuda hiçbir zaman aşımı yoktur. Yavaş ya da kötü niyetli istemciler bağlantıları süresiz açık tutabilir. Belgelerdeki alanlar şu anlamlara gelir:
| Alan | Ne sınırlar | Sıfır veya negatifse |
|---|---|---|
| ReadHeaderTimeout | İstek başlıklarını okuma süresi | ReadTimeout değeri kullanılır |
| ReadTimeout | Gövde dahil tüm isteği okuma süresi | Zaman aşımı yok |
| WriteTimeout | Yanıtı yazma süresi; her yeni istek başlığı okunduğunda sıfırlanır | Zaman aşımı yok |
| IdleTimeout | Keep-alive açıkken bir sonraki isteği bekleme süresi | ReadTimeout değeri kullanılır |
| MaxHeaderBytes | İstek satırı dahil başlıkların bayt sınırı (gövdeyi sınırlamaz) | DefaultMaxHeaderBytes |
| MaxHeaderValueCount | Kabul edilecek başlık değeri sayısı (Go 1.27) | DefaultMaxHeaderValueCount |
Belgeler, ReadTimeout handler'ların gövde için istek bazında karar vermesine izin vermediğinden çoğu kullanıcının ReadHeaderTimeout değerini tercih edeceğini, ikisinin birlikte de kullanılabileceğini belirtir. Gövde boyutunu sınırlamak için http.MaxBytesReader veya http.MaxBytesHandler kullanılır; sınır aşıldığında okuma *http.MaxBytesError döndürür.
Kapanış için Shutdown(ctx) önce dinleyicileri, sonra boştaki bağlantıları kapatır ve aktif bağlantıların bitmesini bekler. Çağrıldığı anda Serve ve ListenAndServe hemen http.ErrServerClosed döndürür; belgelerin uyarısıyla, programın bu noktada çıkmayıp Shutdown dönene kadar beklemesi gerekir. Aşağıdaki örnek gerçek bir http.Server değerini işletim sisteminin seçtiği bir portta çalıştırır, bir istek atar ve sunucuyu zarifçe kapatır.
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /saglik", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "tamam")
})
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Port 0: işletim sistemi boş bir port seçer (örnek çakışmadan çalışsın diye).
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println("dinlenemedi:", err)
return
}
bitti := make(chan error, 1)
go func() { bitti <- srv.Serve(ln) }()
resp, err := http.Get("http://" + ln.Addr().String() + "/saglik")
if err != nil {
fmt.Println("istek hatası:", err)
return
}
govde, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println("yanıt:", string(govde))
// Zarif kapanış: yeni bağlantı kabul etme, açık istekleri en fazla 5 sn bekle.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Println("kapanış hatası:", err)
}
err = <-bitti
fmt.Println("Serve döndü:", err)
fmt.Println("ErrServerClosed mi:", errors.Is(err, http.ErrServerClosed))
}
// Çıktı:
// yanıt: tamam
// Serve döndü: http: Server closed
// ErrServerClosed mi: trueÜretimde Shutdown çağrısını genellikle Ctrl+C ya da SIGTERM sinyaline bağlarsınız; signal.NotifyContext ile bunu nasıl yapacağınızı os/exec ve os/signal yazısında gösteriyoruz. Bağlamlarla iptal ve zaman aşımı için Go context paketi yazısına, sunucuyu bir servis olarak çalıştırmak için systemd servis dosyası rehberi ve önüne ters vekil koymak için Nginx yapılandırma rehberi yazılarına bakabilirsiniz.
Handler'ları test etmek
httptest.NewRecorder bir ResponseWriter gibi davranıp yazılanları kaydeder; httptest.NewRequest ise handler'a verilecek bir gelen istek üretir ve hata durumunda panic yapar, bu da testlerde kabul edilebilir bir kolaylıktır. Recorder'ın Code alanı handler hiç yazmadıysa 0 kalabilir; örtük 200 değerini görmek için Result() metodunu kullanın. Go 1.27 sürüm notları, testing/synctest ile kullanılabilen bellek içi sahte ağa sahip httptest.NewTestServer fonksiyonunu ekledi; belgelere göre testlerin çoğu bu fonksiyonu tercih etmelidir. Test yazımının genel kuralları Go test yazma yazısında.
Sık yapılan hatalar
- http.Error sonrası return yazmamak: fonksiyon isteği bitirmez; sonraki
Writeçağrıları hata metninin arkasına eklenir. - Başlığı gövdeden sonra ayarlamak: ilk
Writeörtük 200 gönderdiği için sonradan yapılanw.Header().SetveWriteHeaderçağrılarının etkisi olmaz. - "/" kalıbını yalnızca ana sayfa sanmak:
/her yolu yakalar. Sadece kökü istiyorsanız/{$}kullanın ya da handler içinder.URL.Pathdeğerini kontrol edin. - Metot kontrolünü unutmak: metotsuz kalıp DELETE isteğini de GET gibi işler. Kalıba metot yazmak bu sınıf hatayı ortadan kaldırır ve 405 yanıtını sizin yerinize üretir.
- Zaman aşımı olmayan sunucu:
http.ListenAndServedoğrudan internete açıldığında hiçbir okuma ya da yazma sınırı yoktur; kendihttp.Serverdeğerinizi kurun. - Paylaşılan durumu kilitsiz değiştirmek: sunucu her bağlantı için ayrı bir goroutine açar, yani handler'lar eşzamanlı çalışır. Ortak sayaç ya da map için sync.Mutex gerekir.
- Handler döndükten sonra ResponseWriter kullanmak: belgeler bunu açıkça geçersiz sayar; arka plana attığınız bir goroutine
wdeğerini vermeyin.
Sık Sorulan Sorular
Go ile web sunucusu için Gin gibi bir çatı şart mı?
Hayır. Go 1.22 ile metot eşleştirme ve yol jokerleri standart ServeMux içine girdi; Go Blog yazısı da amacın birçok proje için bir bağımlılığı azaltmak olduğunu söyler. Aynı yazı gelişmiş yönlendirme ihtiyaçları için üçüncü taraf çatıların hâlâ iyi bir seçenek olduğunu da ekler. Üçüncü taraf çatılar bu serinin kapsamı dışındadır.
Sunucu her isteği ayrı goroutine'de mi çalıştırır?
Evet. Server.Serve belgesine göre gelen her bağlantı için yeni bir servis goroutine'i oluşturulur ve istekler bu goroutine'lerde handler'a verilir. Bu nedenle handler'larınız eşzamanlı çağrılabilir; goroutine kavramı için Go goroutine yazısına bakın.
İstemci bağlantıyı kopardığında handler bunu nasıl anlar?
r.Context() ile gelen bağlam, istemcinin bağlantısı kapandığında, HTTP/2'de istek iptal edildiğinde ya da ServeHTTP döndüğünde iptal edilir. Uzun süren bir işte ctx.Done() kanalını dinleyerek gereksiz çalışmayı durdurabilirsiniz.
Statik dosyaları nasıl sunarım?
http.FileServer(http.Dir("dizin")) diskteki bir dizini, Go 1.22 ile gelen http.FileServerFS ise herhangi bir fs.FS değerini sunar. Alt yolda sunmak için http.StripPrefix ile sarmalarsınız. Dosyaları ikili dosyanın içine gömüp sunmak için Go embed yazısına bakın.
Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.
İlgili içerikler: Go öğrenme rehberi · Go HTTP istemcisi · Go encoding/json · Go context paketi · Go log/slog · Go embed · Go nedir