Mengenal Package Context di Go

Artikel ini mengasumiskam pembaca sudah memahmi goroutine, channel & select
Intro
Ketika menggunakan HTTP server di Go, kita menghandle HTTP request tersebut dengan sebuah function/handler yang mempunyai goroutine sendiri.
HTTP request tersebut bisa saja mengeksekusi function lain (baik di goroutine yang sama atau goroutine tambahan) misalnya untuk mengakses database, file dll.
Hal ini bisa digambarkan menjadi seperti berikut.
HTTP request -> function handleReport() -> function getReportFromDB()
Jika diasumsikan endpoint HTTP api tersebut membutuhkan waktu 10 detik, apa yang terjadi jika user membatalkan HTTP request tersebut di detik ke 3 ? bagaimana caranya kita membatalkan (cancelation) process di function handleReport()
dan turunannya (propagation) function getReportFromDB()
?
Dari kasus tersebut, Go menyediakan package context yang sudah built in di standard library.
Apa itu Context
Context adalah package di standard library Go yang menyediakan mekanisme untuk mengatur cancellation, timeout/deadline, sekaligus values yang bersifat request scoped. context direpresantikan dengan type signature berikut.
type Context interface {
// Deadline akan mengembalikan waktu kapan suatu context akan dicancel
Deadline() (deadline time.Time, ok bool)
// Done akan mengembalikan suatu channel
// sebagai penanda context sudah dicancel
Done() <-chan struct{}
// Mengembalikan error kenapa suatu context dicancel/dibatalkan
Err() error
// Mengambil suatu value berdasarkan key
Value(key any) any
}
Membuat Root/Parent Context
Untuk menginisiasi Root/Parent context kosong, yang tidak berisi timeout/deadline ataupun values, kita bisa menggunakan context.Background()
package main
import (
"context"
"fmt"
)
func main() {
// Create a parent context
ctx := context.Background()
}
Context dengan values
Untuk menambah sebuah key & value kedalam sebuah context, kita bisa menggunakan function context.WithValue(parent context.Context, key any, val any)
.
Berikut contoh kodenya
package main
import (
"context"
"fmt"
)
func main() {
//Buat root context
ctx := context.Background()
//context.WithValue akan mereturn context baru
//tanpa mengubah existing context
//diline ine kita meng-assign ke variable baru
ctxWithValue := context.WithValue(ctx, "UserID", 123)
//atau replace context baru ke variable existing
ctx = context.WithValue(ctx, "UserID", 999)
performTask(ctxWithValue)
//....
}
func performTask(ctx context.Context) {
// get value by key UserID
// type userID masih any
userID := ctx.Value("UserID")
// atau gunakan type assertion agar typenya lebih spesifik
userIDInt := ctx.Value("UserID").(int)
// agar lebih aman secara runtime,
// kita gunakan comma ok idiom untuk memastikan type assertion berhasil
userIDString, ok := ctx.Value("UserID").(string)
if !ok {
fmt.Println("type assertion failed")
return
}
// Gunakan type assertion untuk meng-convert ke type yang sesuai
fmt.Println("User ID:", userID)
fmt.Println("User ID:", userIDInt)
fmt.Println("User ID:", userIDString)
}
Context bersifat immutable, sehingga context.withValue
akan meng-copy parent context sebelumnya dan mereturn context baru dengan tambahan value baru
Karena bersifat immutable context parent tidak akan mengalami perubahan
Untuk menarik data berdasarkan key dari context kita bisa menggunakan context.Value(key)
By default return context.Value
mempunya type any
(tidak type safe), kita bisa meng-convertnya menggunakan type assertion dan comma-ok idiom untuk memastikan type assertionya berhasil
Context dengan Timeout
Untuk membuat context dengan informasi timeout didalamnya, kita bisa menggunakan method context.WithTimeout()
, dimana paramater pertama adalah parent context, dan paramater kedua adalah durasi timeout dengan type data time.Duration
ctx := context.Background()
//
ctx, cancel := context.WithTimeout(ctx, 1 * time.Second)
defer cancel()
Sama seperti context.WithValue
, context.WithTimeout
juga mengembalikan context yang baru, sehingga kita perlu meng-assignnya kembali, selain itu method tersebut juga mengembalikan callback cancel
yang perlu kite defer
untuk merelease context jika function yang sedang berjalan telah selesai.
Anggap kita punya kode seperti berikut, mari kita coba implementasi timeout, agar function processing
bisa kita cancel/batalkan jika melewati batas waktu.
package main
import (
"context"
"fmt"
"time"
)
func main() {
result, err := processing()
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
func processing() (int, error) {
fmt.Println("Processing....")
time.Sleep(3 * time.Second)
fmt.Println("Processing is done")
return 1000, nil
}
Langkah pertama kita perlu membuat root context di function main()
dan kita akan set timeout selama 1 detik.
func main() {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 1 * time.Second)
defer cancel()
result, err := processing(ctx)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
Kemudian kita tambahkan paramater baru dengan type context.Context
difunction processing
. Secara konvensi paramater context selalu ditulis diurutan pertama.
func processing(ctx context.Context) (int, error) {
}
Lalu kita buat sebuah channel yang bernama resultChan
(atau dengan nama yang lain), dimana channel ini berfungsi untuk menandakan bahwa process di function processing
ini selesai. Selain itu, paramater context juga mempunya channel yang bisa diakses dengan context.Done
, dimana channel ini akan menandakan bahwa context tersebut telah melewati deadline/timeout.
Dari 2 channel tersebut kita bisa menggunakan keyword select
, untuk menunggu mana channel yang mengirim sinyal selesai terlebih dahulu
func processing(ctx context.Context) (int, error) {
resultChan := make(chan int)
select {
case result := <-resultChan:
return result, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
Kemudian perlu kita pindahkan juga logic kode sebelumnya ke dalam goroutine dan kita kirimkan hasil kalkulasi ke channel resultChan
.
hal ini dilakukan agar eksekusi tidak blocking sehingga operasi select bisa standby untuk menunggu mana operasi yang selesai terlebih dahulu antara channel resultChan
dan ctx.Done
.
Untuk mengakses value error dari sebuah context, kita bisa menggunakan ctx.Err()
func processing(ctx context.Context) (int, error) {
resultChan := make(chan int)
go func() {
//logic kode sebelumnya
fmt.Println("Processing....")
time.Sleep(3 * time.Second)
fmt.Println("Processing is done")
//kirimkan hasil kalkulasi ke resultChan
resultChan <- 1000
}()
select {
case result := <-resultChan:
return result, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
Jika digabungkan hasil akhirnya akan menjadi seperti berikut
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
result, err := processing(ctx)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
func processing(ctx context.Context) (int, error) {
resultChan := make(chan int)
go func() {
fmt.Println("Processing....")
time.Sleep(3 * time.Second)
fmt.Println("Processing is done")
resultChan <- 1000
}()
select {
case result := <-resultChan:
return result, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
Mari kita coba jalankan dengan kondisi dimana function processing
membutuhkan waktu 3 detik sementara timeout kita set 1 detik
Processing....
error: Task Cancelled
Lalu kita coba ubah timeout menjadi 5 detik seperti berikut
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ketika dijalankan kembali maka akan muncul output seperti berikut
Processing....
Processing is done
result: 1000
Context dengan Deadline
Context deadline relative sama dengan context timeout, perbedaanya hanya pada paramater, ditimeout kita menggunakan durasi dengan type time.Duration
sementara deadline dengan type time.Time
.
Untuk membuat context kita bisa gunakan kode seperti berikut
ctx := context.Background()
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(1*time.Second))
Dicontoh kode diatas kita membuat context dengan deadline 1 detik ke depan dari waktu sekarang. Kita coba ubah kode sebelumnya agar menggunakan deadline
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx := context.Background()
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(1*time.Second))
defer cancel()
result, err := processing(ctx)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
func processing(ctx context.Context) (int, error) {
resultChan := make(chan int)
go func() {
fmt.Println("Processing....")
time.Sleep(3 * time.Second)
fmt.Println("Processing is done")
resultChan <- 1000
}()
select {
case result := <-resultChan:
return result, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
Ketika dijalankan akan muncul output berikut
Processing....
error: context deadline exceeded
Context dengan Cancel
Context dengan cancel relatif sama dengan context sebelumnya, perbedaanya hanya pembatalan (cancelation) tidak bergantung pada nilai waktu (timeout/deadline), melainkan dilakukan dengan memanggil fungsi cancel secara manual. untuk membuat context dengan cancel, kita bisa menggunakan kode berikut
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
Mari kita coba ubah kode sebelumnya agar menggunakan context.WithCancel
menjadi seperti berikut
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx := context.Background()
//ubah menjadi context.WithCancel
ctx, cancel := context.WithCancel(ctx)
defer cancel()
//eksekusi cancel digoroutine yang lain setelah 1 detik
go func() {
time.Sleep(1 * time.Second)
cancel()
}()
result, err := processing(ctx)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
func processing(ctx context.Context) (int, error) {
resultChan := make(chan int)
go func() {
fmt.Println("Processing....")
time.Sleep(3 * time.Second)
fmt.Println("Processing is done")
resultChan <- 1000
}()
select {
case result := <-resultChan:
return result, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
Sebagai demonstrasi kita panggil function cancel
setelah 1 detik digoroutine lain, hal ini agar eksekusi tidak blocking dan dilanjutkan ke function processing().
Ketika kode diatas dijalankan, akan menghasilkan output berikut.
Processing....
error: context canceled
Hal ini karena setelah 1 detik, function cancel
dijalankan, maka context.Done
akan memberikan sinyal cancelation/pembatalan.
Hal yang perlu diperhatikan adalah kita tetep perlu memanggil perintah defer cancel()
, agar context tersebut tetap direlease jika tidak ada pemanggilan cancel
karena kondisi tertentu
Penggunaan context dengan net/http
Untuk menggunakan context dengan http server bawaan standard library. Kita bisa mengambil context existing dengan perintah req.Context()
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
http.HandleFunc("/hello", hello)
http.ListenAndServe(":8000", nil)
}
func hello(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
fmt.Println("server: hello handler started")
defer fmt.Println("server: hello handler ended")
select {
case <-time.After(10 * time.Second):
fmt.Fprintf(w, "hello\n")
case <-ctx.Done():
err := ctx.Err()
fmt.Println("server:", err)
internalError := http.StatusInternalServerError
http.Error(w, err.Error(), internalError)
}
}
Jika kita menjalankan kode diatas, kemudian membuat request ke http://localhost:8000/hello, kemudian secara langsung menghentikan request tersebut, maka channel ctx.Done()
pada operasi select
akan tereksekusi dulu yang menandakan user telah menghentikan request
server: hello handler started
server: context canceled
server: hello handler ended
Penggunaan context dengan operasi database
Context juga umum dipakai untuk operasi database, hal ini sangat berguna untuk menghentikan operasi database yang melewati timeout atau telah dihentikan oleh user, sehingga resource tidak ada yang terbuang.
Berikut contoh kodenya.
package main
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
func main() {
//Buat context dengan timeout 2 detik
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
//konek ke database
db, err := sql.Open("postgres", "postgres://username:password@localhost/mydatabase?sslmode=disable")
if err != nil {
fmt.Println("Error connecting to the database:", err)
return
}
defer db.Close()
// Eksekusi query database dengan context
rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
fmt.Println("Error executing query:", err)
return
}
defer rows.Close()
// Proses hasil query
}
Kesimpulan
Diartikel ini kita telah belajar bagaimana cara membuat context, menambahkan sebuah value, menambahkan timeout/deadline, dan contoh penggunaanya dikasus http server dan operasi database.
Selain itu bisa kita simpulkan pentingnya untuk menangani sebuah timeout/deadline/cancelation agar penggunaan resource menjadi lebih efisien dan tidak ada yang terbuang.
Thanks for reading