Context di Go

Membahas Context di GO dalam konteks apa?

Akhir-akhir ini lagi sering nulis kode di Go, Go adalah bahasa pemrograman yang dibuat oleh Google, menggunakan static typing dan memiliki first class concurrency.

Ada salah satu pola dalam concurrency di Go, yakni Context. Mengutip salah satu tulisan dari blog nya Go:

At Google, we require that Go programmers pass a Context parameter as the first argument to every function on the call path between incoming and outgoing requests.

Sumber

Karena Go lahir di Google, bisa diasumsikan orang-orang Google menulis kode Go secara idiomatic. Jadi, mari kita coba tenggelam lebih dalam dengan mengetahui apa itu Context di Go.

Tentang Context

Context adalah... Konteks. Hmm, oke. Konteks literally bisa diartikan sebagai hubungan, situasi, keadaan dan aluran. Jika seseorang ada yang bilang "tidak sesuai konteks" mungkin bisa diartikan sebagai "tidak sesuai hubungan/situasi/keadaan/aluran", tergantung konteks.

Dalam Programming—khususnya di dunia distributed—context bukanlah hal yang baru mengingat kita tidak hanya mengandalkan 1 mesin untuk melakukan suatu pekerjaan.

Gue buat ilustrasi, misal mengambil data halaman ini:

  • User mengunjungi halaman ini (blog.evilfactory.id/some-slug)
  • Router memanggil Controller post, memvalidasi beberapa hal, dan memanggil Model.
  • Model melakukan query ke Database, Postgres.
  • Postgres memberikan data, dikirim ke model, dan dibalikkan ke Controller.
  • Controller memanggil View, beserta data yang dibutuhkan.
  • User melihat halaman ini, hasil kerja sama antara Router; Model, Controller, View, dan Database.

Kira-kira, gambarannya kurang lebih seperti ini:

Yang mana "pekerjaan" terjadi di 1 server/mesin. 1 mesin memproses 1 (keseluruhan) request. Kita tau kemana (dan yang mana) yang harus di "investigasi" bila terjadi sesuatu yang tidak diharapkan terjadi.

Apakah penghambatnya ada di Model? Database? Controller? Router? Kita bisa dengan mudah meng-investigasi nya karena itu berada di 1 mesin yang sama.

Beda cerita ketika kita menggunakan lebih dari 1 mesin.

Jika menggunakan 1 mesin, dan di tengah permintaan something is wrong misalnya server mati/aplikasi crash dsb, that's it. Client bisa mendapatkan response Timed out dari bawaan peramban atau menampilkan 503 dari server.

Kembali ke context, dan masih menggunakan ilustrasi yang sama, inilah salah satu "kegunaan" context di konteks ini.

Kurang jelas? Mari kita buat seakan-akan misalnya menggunakan 2 mesin.

Oke oke, kenapa harus peduli dengan ini? Di Go, setiap request masuk di handle oleh Goroutine tersendiri. Setiap goroutine biasanya melakukan hal-hal yang membutuhkan nilai khusus, seperti slug yang diminta. Ketika request timeout/dibatalkan, semua goroutine yang ada harus berakhir sehingga sistem bisa mengembalikan resources yang digunakan oleh goroutine tersebut.

Misal, jika user A mengakses /some-slug lalu user B mengakses /donate, singkatnya, ada n+2 goroutine yang dibuat. Di /some-slug oke-oke aja, dan selesai dengan sukses. Sedangkan di /donate misal terjadi galat bahwa database tidak memberikan response untuk request ini.

Lalu user C mengakses /donate juga 3 detik setelahnya, sekarang goroutine ada 2 untuk hal yang sama. Lalu ada 10 user yang mengakses halaman tersebut juga, diasumsikan ada 12 goroutine yang sedang berjalan dan terus berjalan untuk hal yang sama.

Pertanyaan nya, bagaimana cara membatalkan request tersebut? Spesifiknya, bagaimana cara membatalkan request tersebut bila sudah lewat dari deadline (misal 10 detik)?

Inilah salah satu kegunaan dari context sehingga bisa membuat aplikasi tidak memakan banyak resources dalam kurun waktu.

Syntax

Interface untuk Context sendiri sangat sederhana, yakni seperti ini:

type Context interface {
    Done() <-chan struct{}
    Err() error
    Deadline() (deadline time.Time, ok bool)
    Value(key interface{}) interface{}
}

Karena inti dari package context memiliki type Context, itulah mengapa kita perlu tau interface dari Context itu sendiri.

Context biasanya berjenis tree, yang mana alurnya dari atas kebawah. Root tree dari context disini adalah Background, yang biasanya berada di function main.

func main () {
  rootCtx := context.Background()
}

Jika melihat dari kegunaan server, context disini bisa digunakan untuk membatalkan request berdasarkan timeout (deadline) ataupun secara manual.

Context for Cancelation

Sebagai gambaran, mari kita membuat program sederhana. Goroutine memiliki "masa hidup" sekitar 3 detik, jika lewat dari itu, maka batalkan request yang masih ada.

func main () {
  rootCtx := context.Background()
  deadline := 3 * time.Second
  
  fmt.Println(deadline) // 3s
}

Karena kita ingin melakukan pembatalan, kita bisa menggunakan WithTimeout dari package context. Dan karena WithTimeout memberikan "kembalian" 2 variable yang bertipe Context  dan CancelFunc , mari kita ubah kode diatas menjadi seperti ini:

func main () {
  rootCtx := context.Background()
  deadline := 3 * time.Second

  ctx, cancel := context.WithTimeout(rootCtx, deadline)
  
  defer cancel()
  
  fmt.Println(ctx, deadline)
}

Kita harus memanggil function cancel atau compiler akan rewel karena kita mendeklarasinya namun tidak memanggilnya. Oke serius, pastinya kita harus memanggil function cancel yang menandakan ingin membatalkan proses yang ada (nanti kita bahas) karena sudah mencapai deadline, kan?

Kata defer diatas adalah "kata kunci" untuk menandakan bahwa function tersebut akan dipanggil setelah function "setelahnya" sudah dieksekusi.

Oke program diatas belum berarti apa-apa, karena singkatnya kita tidak tau "harus membatalkan apa" untuk kasus disini.

Sekarang, mari kita buat. Karena kita memiliki waktu 3 detik, jika kita memiliki waktu tersebut, mari kita buat program kita untuk menampilkan kata "tobat" karena... well, oke.

func main () {
  rootCtx := context.Background()
  deadline := 3 * time.Second

  ctx, cancel := context.WithTimeout(rootCtx, deadline)
  
  defer cancel()
  
  select {
  case <-time.After(666 * time.Millisecond):
    fmt.Println("2) tobat", time.Now())
  case <-ctx.Done():
    fmt.Println("2)", ctx.Err(), time.Now())
  }

  fmt.Println(ctx, deadline)
}

Kode diatas akan selalu menampilkan kata "tobat" karena ehm kita masih memiliki waktu untuk melakukan itu. Mari kita lihat apa yang terjadi menggunakan cara "console.log" haha

func main() {
  rootCtx := context.Background()
  deadline := 3 * time.Second

  ctx, cancel := context.WithTimeout(rootCtx, deadline)

  fmt.Println("deadline", ctx)
  fmt.Println("---")
  fmt.Println("1) now", time.Now())

  defer cancel()

  select {
  case <-time.After(666 * time.Millisecond):
	fmt.Println("2) tobat", time.Now())
  case <-ctx.Done():
	fmt.Println("2)", ctx.Err(), time.Now())
  }

  time.Sleep(2 * time.Second)

  fmt.Println("3) die (after sleep)", time.Now())
}

Berikut keluarannya:

deadline context.Background.WithDeadline(2020-04-26 23:00:03 +0000 UTC m=+3.000000001 [3s])
---
1) now 2020-04-26 23:00:00 +0000 UTC m=+0.000000001
2) tobat 2020-04-26 23:00:00.666 +0000 UTC m=+0.666000001
3) die (after sleep) 2020-04-26 23:00:02.666 +0000 UTC m=+2.666000001

Jika kita lihat keluaran diatas:

  • Deadline nya adalah 26-04-2020 23:00:03
  • Waktu sekarang adalah 26-04-2020 23:00:00
  • Kata "tobat" terpanggil karena waktu menunjukkan 26-04-2020 23:00:00.666

Bagaimana bila kita pindahkan baris time.Sleep keatas pemanggilan cancel dan naikkan 1 detik?

func main() {
  rootCtx := context.Background()
  deadline := 3 * time.Second

  ctx, cancel := context.WithTimeout(rootCtx, deadline)

  fmt.Println("deadline", ctx)
  fmt.Println("---")
  fmt.Println("1) now", time.Now())
  
  time.Sleep(3 * time.Second)

  defer cancel()

  select {
  case <-time.After(666 * time.Millisecond):
	fmt.Println("2) tobat", time.Now())
  case <-ctx.Done():
	fmt.Println("2)", ctx.Err())
  }

  fmt.Println("3) die (after sleep)", time.Now())
}

Keluarannya:

deadline context.Background.WithDeadline(2020-04-26 23:00:03 +0000 UTC m=+3.000000001 [3s])
---
1) now 2020-04-26 23:00:00 +0000 UTC m=+0.000000001
2) context deadline exceeded 2009-11-10 23:00:03 +0000 UTC m=+3.000000001
3) die (after sleep) 2020-04-26 23:00:02.666 +0000 UTC m=+2.666000001

Karena si "tobat" bisanya dipanggil ketika 23:00:00.666 sedangkan karena beberapa hal (well it because that damn time.Sleep) kita tidak sempat untuk "tobat".

Kita tidak sempat bertobat tapi sudah mati, hmm.

Oke mas mbak, itu salah satu kegunaan Context. Mari kita ke yang lebih praktikal dan tidak menggunakan contoh yang menyeramkan seperti tadi.

Context in action

Mari kita ambil contoh yang sudah dibuat oleh tim Golang Google di tulisan ini, tentang sebuah aplikasi web yang meng-handle permintaan dan meneruskannya ke Google Web Search API lalu menampilkan hasilnya. Ini adalah tentang "membuat Search Engine sendiri" yang padahal ditenagai oleh Google.

Dalam aplikasi tersebut, terdapat 3 packages yang digunakan:

  • server, yang nge-handle permintaan ke /search. Entrypoint dari aplikasi ini ada disini.
  • userip, yang berguna untuk meng-ekstrak alamat IP dari request dan menyimpannya ke Context.
  • google, yang ngatur untuk meneruskan permintaan ke Google Web Search API.

Gambarannya, bila terdapat payload seperti ini:

/search?q=golang&timeout=3

Berarti kita akan melakukan penerusan query "golang" tersebut ke Google Web Search API dengan deadline selama 3 detik.

// server.go

package main

import (
	"context"
	"html/template"
	"log"
	"net/http"
	"time"

	"golang.org/x/blog/content/context/google"
	"golang.org/x/blog/content/context/userip"
)

func main() {
	http.HandleFunc("/search", handleSearch)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleSearch(w http.ResponseWriter, req *http.Request) {
	var ctx context.Context
	var cancel context.CancelFunc

    timeout, err := time.ParseDuration(req.FormValue("timeout"))

	if err == nil {
		ctx, cancel = context.WithTimeout(context.Background(), timeout)
	} else {
		ctx, cancel = context.WithCancel(context.Background())
	}

	defer cancel()

	query := req.FormValue("q")

	if query == "" {
		http.Error(w, "no query", http.StatusBadRequest)
		return
	}

	userIP, err := userip.FromRequest(req)

	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	ctx = userip.NewContext(ctx, userIP)

	start := time.Now()
	results, err := google.Search(ctx, query)
	elapsed := time.Since(start)

	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	if err := resultsTemplate.Execute(w, struct {
		Results          google.Results
		Timeout, Elapsed time.Duration
	}{
		Results: results,
		Timeout: timeout,
		Elapsed: elapsed,
	}); err != nil {
		log.Print(err)
		return
	}
}

var resultsTemplate = template.Must(template.New("results").Parse(`
<html>
<head/>
<body>
  <ol>
  {{range .Results}}
    <li>{{.Title}} - <a href="{{.URL}}">{{.URL}}</a></li>
  {{end}}
  </ol>
  <p>{{len .Results}} results in {{.Elapsed}}; timeout {{.Timeout}}</p>
</body>
</html>
`))

Kode diatas adalah salinan dari server.go dengan sedikit pemformatan dan komentar gue hapus. Ini adalah aplikasi inti nya, dan context.Background() berada disini.

Hal kedua yang dilakukan adalah mengambil alamat IP, kan? Lihat baris ini:

	userIP, err := userip.FromRequest(req)

	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	ctx = userip.NewContext(ctx, userIP)

Disini kita coba memanggil FromRequest dan NewContext dari package userip. Mari kita lihat kode userip.go tersebut:

// userip.go

package userip

import (
	"context"
	"fmt"
	"net"
	"net/http"
)

func FromRequest(req *http.Request) (net.IP, error) {
	ip, _, err := net.SplitHostPort(req.RemoteAddr)

	if err != nil {
		return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
	}

	userIP := net.ParseIP(ip)

	if userIP == nil {
		return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
	}

	return userIP, nil
}

type key int

const userIPKey key = 0

func NewContext(ctx context.Context, userIP net.IP) context.Context {
	return context.WithValue(ctx, userIPKey, userIP)
}

func FromContext(ctx context.Context) (net.IP, bool) {
	userIP, ok := ctx.Value(userIPKey).(net.IP)

	return userIP, ok
}

Tidak ada yang spesial dari function FromRequest selain melakukan parsing dari nilai req.RemoteAddr. Sekarang lihat ke bagian NewContext. Akhirnya relevan dengan pembahasan ini.

Jika melihat dari kode sumber yang ada di context.go, kodenya adalah seperti ini:

func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}

	if key == nil {
		panic("nil key")
	}

	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}

	return &valueCtx{parent, key, val}
}

NewContext bertujuan untuk "menyimpan" userIP ke context dengan nilai alamat IP dari client, misal yang formatnya, key nya adalah userIPKey dan nilainya adalah alamat IP nya (dari user, misal 89.69.123.0).

Yang nantinya ini akan digunakan di package google. Jika kita coba lihat nilai dari context sekarang, nilai nya kurang lebih seperti ini:

context.Background.WithDeadline(2020-03-26 23:00:03 +0000 UTC m=+3.000000001 [3s]).WithValue(type main.key, val 89.69.123.0)

In case timeout nya adalah 3 detik dengan alamat IP dari client bernilai 89.xxx yang tentu saja itu tidak nyata.

Sekarang mari kita lihat kode dari google.go tersebut.

// google.go

package google

import (
	"context"
	"encoding/json"
	"net/http"

	"golang.org/x/blog/content/context/userip"
)

type Result struct {
	Title, URL string
}

type Results []Result

func Search(ctx context.Context, query string) (Results, error) {
	req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)

	if err != nil {
		return nil, err
	}

	q := req.URL.Query()
	q.Set("q", query)

	if userIP, ok := userip.FromContext(ctx); ok {
		q.Set("userip", userIP.String())
	}

    req.URL.RawQuery = q.Encode()

	var results Results

	err = httpDo(ctx, req, func(resp *http.Response, err error) error {
		if err != nil {
			return err
		}

		defer resp.Body.Close()

		var data struct {
			ResponseData struct {
				Results []struct {
					TitleNoFormatting string
					URL               string
				}
			}
		}

		if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
			return err
		}

		for _, res := range data.ResponseData.Results {
			results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
		}

		return nil
	})

	return results, err
}

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
	c := make(chan error, 1)
	req = req.WithContext(ctx)

	go func() { c <- f(http.DefaultClient.Do(req)) }()

    select {
	case <-ctx.Done():
		<-c
		return ctx.Err()
	case err := <-c:
		return err
	}
}

Mari kita fokus ke baris ini:

	if userIP, ok := userip.FromContext(ctx); ok {
		q.Set("userip", userIP.String())
	}

Google Web Search API (sudah deprecated) menggunakan IP untuk membedakan request yang dibuat oleh server (referensi). Cuma buat hal-hal "penghematan" dalam sisi biaya disisi developer.

Oke kembali ke kode dulu, lihat diberkas server.go dibagian ini:

	results, err := google.Search(ctx, query)

Lihat bagaimana function Search melakukan request ke Google API, yang mana request tersebut di handle oleh function httpDo. Request nya menggunakan req.WithContext yang gue sebenernya gak tau kenapa enggak pakai NewRequestWithContext aja dari lahir.

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    c := make(chan error, 1)
    req = req.WithContext(ctx)

    go func() { c <- f(http.DefaultClient.Do(req)) }()

    select {
    case <-ctx.Done():
        <-c
        return ctx.Err()
    case err := <-c:
        return err
    }
}

Di kode tersebut, httpDo melakukan request dan memproses response nya di goroutine baru. Request akan dibatalkan bila ctx.Done terpanggil sebelum goroutine selesai.

Bingung? Mari kita ilustrasikan.

Gambaran sederhana dari program tersebut kurang lebih seperti itu. Untuk panah yang berwarna oranye, anggap saja request tersebut bisa saja dibatalkan, alias, berikan results kosong aja daripada dari Google API karena sudah lewat deadline.

Di proses Request (plus yang di httpDo), kita bukan hanya sekedar mengirim request ke Google API, tapi melakukan proses untuk melakukan hal yang berkaitan dengan response (decoding & appending).

Dan panah ke kanan bisa saja memberikan error ataupun hasil yang diinginkan.

Kesimpulan

Context ini berguna untuk berhadapan dengan banyak hal sekaligus. Sejauh yang gue tau, concurrency adalah tentang menghadapi banyak pekerjaan sekaligus. Tentang cara untuk menghadapi pekerjaan tersebut, tentang cara kerja.

Salah satunya adalah bagaimana membatalkan proses yang ada.

Juga, context biasa digunakan untuk tracing. Setiap request memiliki id nya masing-masing. Misal di handleSearch kita menetapkan X-Request-ID dengan nilai aec666cf lalu memanggil service-service lain, kita bisa melihat data seputar apa yang terjadi berdasarkan id tersebut.

Misal seperti ini:

Kita bisa melihat payload apa saja yang ada di checkRateLimits, fetchUserInfo, dsb sekaligus kita bisa melihat berapa latensi ataupun waktu proses yang terjadi pada suatu proses.

Jika melihat dari blog nya di Go, menggunakan context direkomendasikan meskipun kita masih bingung untuk apa context tersebut. Dan untuk di testing, kita bisa menggunakan context.TODO() sebagai "placeholder" nya.

Penutup

Gue masih relatif baru dengan Go, tapi sudah tidak heran dengan if err != nil nya yang gue anggep sebagai sebuah seni. Jika ada kesalahan; informasi yang kurang jelas, ataupun feedback bisa mention twitter yours truly di @faultable.

Mungkin ditulisan selanjutnya gue ingin bahas lebih dalam tentang goroutine, semoga tidak lupa.

Terima kasih sudah membaca, jangan lupa cuci tangan!

Menikmati tulisan ini?

Blog ini tidak menampilkan iklan, yang berarti blog ini didanai oleh pembaca seperti kamu. Gabung bersama yang telah membantu blog ini agar terus bisa mencakup tulisan yang lebih berkualitas dan bermanfaat!

Pendukung

Kamu?