Sử dụng Gói Context trong GO

Post on: 2023-04-19 23:27:55 | in: Golang
Gói Context trong GO là một phương thức được sử dụng để chia sẻ thông tin giữa các hàm hoặc giữa các goroutine trong cùng một quá trình.

Giới thiệu

Định nghĩa

Context là một gói được cung cấp bởi GO. Trước tiên hãy hiểu về một số vấn đề đã tồn tại trước đây và gói context cố gắng giải quyết chúng.

Mô tả vấn đề:

  • Hãy giả sử bạn bắt đầu một hàm và bạn cần chuyển một số tham số chung cho các hàm hạ tầng. Bạn không thể truyền từng tham số chung như là một đối số cho tất cả các hàm hạ tầng.
  • Bạn bắt đầu một goroutine mà sau đó bắt đầu nhiều goroutine khác và cứ như vậy. Giả sử nhiệm vụ mà bạn đang làm không còn cần thiết nữa. Sau đó, làm thế nào để thông báo cho tất cả các goroutine con để thoát một cách dễ dàng để tài nguyên có thể được giải phóng.
  • Một nhiệm vụ nên được hoàn thành trong một thời gian chờ nhất định, ví dụ như 2 giây. Nếu không, nó nên được thoát một cách dễ dàng hoặc trả về.
  • Một nhiệm vụ nên được hoàn thành trong một hạn chót nhất định, ví dụ như nó phải kết thúc trước 5 giờ chiều. Nếu không hoàn thành, nó nên thoát một cách dễ dàng và trả về.

Nếu bạn chú ý, tất cả các vấn đề trên đều rất áp dụng cho các yêu cầu HTTP và những vấn đề này cũng áp dụng cho nhiều lĩnh vực khác.

Đối với một yêu cầu HTTP web, nó cần được hủy khi khách hàng đã ngắt kết nối, hoặc yêu cầu phải được hoàn thành trong một khoảng thời gian nhất định và các giá trị phạm vi yêu cầu như request_id cần có sẵn cho tất cả các hàm hạ tầng.

Khi nào nên sử dụng (Một số trường hợp sử dụng):

  • Để truyền dữ liệu cho các hàm hạ tầng. Ví dụ: một yêu cầu HTTP tạo ra một request_id, request_user cần được truyền cho tất cả các hàm hạ tầng phía dưới để theo dõi phân tán.
  • Khi bạn muốn dừng hoạt động ở giữa chừng - Một yêu cầu HTTP nên bị dừng vì khách hàng đã ngắt kết nối.
  • Khi bạn muốn dừng hoạt động trong một khoảng thời gian nhất định kể từ khi bắt đầu, tức là với timeout. Ví dụ: Một yêu cầu HTTP phải được hoàn thành trong 2 giây hoặc nếu không nên bị hủy bỏ.
  • Khi bạn muốn dừng hoạt động trước một thời gian nhất định. Ví dụ: Một cron đang chạy và cần bị hủy bỏ trong vòng 5 phút nếu không hoàn thành.

Context Interface

Điểm cốt lõi để hiểu về context là hiểu về giao diện Context (Context interface).

type Context interface {
    //It retures a channel when a context is cancelled, timesout (either when deadline is reached or timeout time has finished)
    Done() <-chan struct{}

    //Err will tell why this context was cancelled. A context is cancelled in three scenarios.
    // 1. With explicit cancellation signal
    // 2. Timeout is reached
    // 3. Deadline is reached
    Err() error

    //Used for handling deallines and timeouts
    Deadline() (deadline time.Time, ok bool)

    //Used for passing request scope values
    Value(key interface{}) interface{}
}

Tạo mới Context

context.Background():

Hàm Background() của gói context trả về một Context rỗng (empty Context) và đã thực thi giao diện Context.

  • Nó không có giá trị
  • Nó không bao giờ bị hủy
  • Nó không có thời hạn

Vậy thì context.Background() được sử dụng để làm gì? context.Background() được sử dụng như một gốc của tất cả các Context sẽ được tạo ra từ nó. Chúng ta sẽ rõ hơn khi tiếp tục đi sâu vào.

context.ToDo():

  • Hàm ToDo() của gói context trả về một Context rỗng. Context này được sử dụng khi hàm xung quanh chưa nhận được bất kỳ Context nào và người dùng muốn sử dụng Context như một giá trị giữ chỗ (placeholder) trong hàm hiện tại và dự định thêm Context thực tế trong tương lai gần. Một trong những ứng dụng của việc thêm Context làm giá trị giữ chỗ là giúp trong việc kiểm tra lỗi cú pháp tĩnh (Static Code Analysis).
  • Context trả về từ hàm ToDo() cũng là một Context rỗng giống như context.Background().

Hai phương thức trên miêu tả cách tạo Context mới. Nhiều Context khác có thể được tạo ra từ các Context này. Đó là lý do tại sao cây Context trở nên quan trọng.

Cây Context (Context Tree)

Trước khi hiểu về Context Tree, hãy đảm bảo rằng nó được tạo ngầm trong nền tảng khi sử dụng context. Bạn sẽ không tìm thấy bất kỳ đề cập nào về nó trong gói context của Go.

Mỗi khi bạn sử dụng context, thì Context trống được lấy từ context.Background() là gốc của tất cả các Context. context.ToDo() cũng hoạt động như một Context gốc nhưng như đã đề cập ở trên, nó giống như một context placeholder để sử dụng trong tương lai. Context trống này không có chức năng gì cả và chúng ta có thể thêm chức năng bằng cách tạo một Context mới từ nó. Thực chất, một Context mới được tạo bằng cách bao bọc một Context không thay đổi đã tồn tại và thêm thông tin bổ sung. Hãy xem một số ví dụ về cây Context được tạo ra.

Two level tree

rootCtx := context.Background()
childCtx := context.WithValue(rootCtx, "msgId", "someMsgId")

Ở trên

  • rootCtx là một Context rỗng không có chức năng gì cả.
  • childCtx iđược tạo ra từ rootCtx và có chức năng lưu trữ các giá trị liên quan đến yêu cầu. Trong ví dụ trên, nó lưu trữ cặp key-value của {"msgId": "someMsgId"}.

Three level tree

rootCtx := context.Background()
childCtx := context.WithValue(rootCtx, "msgId", "someMsgId")
childOfChildCtx, cancelFunc := context.WithCancel(childCtx)

IỞ trên

  • rootCtx là Context rỗng không có chức năng gì.
  • childCtx được tạo ra từ rootCtx và có chức năng lưu trữ giá trị liên quan đến yêu cầu. Trong ví dụ trên, nó lưu trữ cặp khóa-giá trị {"msgId": "someMsgId"}.
  • childOfChildCtx 

    được tạo ra từ childCtx. Nó có chức năng lưu trữ giá trị liên quan đến yêu cầu và cũng có chức năng kích hoạt tín hiệu hủy bỏ. cancelFunc có thể được sử dụng để kích hoạt tín hiệu hủy bỏ.

Multi-level tree

rootCtx := context.Background()
childCtx1 := context.WithValue(rootCtx, "msgId", "someMsgId")
childCtx2, cancelFunc := context.WithCancel(childCtx1)
childCtx3 := context.WithValue(rootCtx, "user_id", "some_user_id)

Ở trên:

  • rootCtx là một context rỗng không có chức năng gì cả.
  • childCtx1 được tạo ra bằng cách phát sinh từ rootCtx, có chức năng lưu trữ các giá trị được liên quan đến request. Ví dụ như nó có thể lưu trữ cặp khóa-giá trị {"msgId": "someMsgId"} như trong ví dụ trên.
  • childCtx2 được tạo ra bằng cách phát sinh từ childCtx1. Nó có chức năng kích hoạt tín hiệu hủy bỏ (cancellation signal). Ta có thể sử dụng cancelFunc để kích hoạt tín hiệu hủy bỏ.
  • childCtx3 được tạo ra bằng cách phát sinh từ rootCtx. Nó có chức năng lưu trữ thông tin người dùng hiện tại.

Cây ba cấp như trên sẽ trông như sau:

Vì nó là một cây, nên cũng có thể tạo thêm các con cho một nút cụ thể. Ví dụ, chúng ta có thể tạo ra một context mới childCtx4 từ childCtx1.

childCtx4 := context.WithValue(childCtx1, "current_time", "some_time)

Cây với nút trên được thêm vào sẽ như sau:

Đến thời điểm hiện tại, có thể chưa rõ ràng về cách sử dụng hàm WithValue () hoặc WithCancel (). Hiện tại chỉ cần hiểu rằng khi sử dụng context, một cây context được tạo với gốc là emptyCtx. Những hàm này sẽ được giải thích rõ ràng hơn khi chúng ta tiếp tục.

Tạo một context mới từ một context khác sử dụng:

Một context có thể được tạo ra theo 4 cách sau:

  • Truyền các giá trị liên quan đến yêu cầu - bằng cách sử dụng hàm WithValue() của gói context.
  • Với tín hiệu hủy - bằng cách sử dụng hàm WithCancel() của gói context.
  • Với thời hạn - bằng cách sử dụng hàm WithDeadine() của gói context.
  • Với thời gian chờ - bằng cách sử dụng hàm WithTimeout() của gói context.

Hãy hiểu chi tiết về mỗi phương pháp trên.

context.WithValue()

Dùng để truyền giá trị liên quan đến yêu cầu. Chữ ký đầy đủ của hàm là:

withValue(parent Context, key, val interface{}) (ctx Context)

Hàm này nhận vào một context cha, key, value và trả về một context được phát sinh từ context cha. Context được phát sinh này sẽ có giá trị key liên kết với value. Ở đây, context cha có thể là context.Background() hoặc bất kỳ context nào khác. Ngoài ra, bất kỳ context nào được phát sinh từ context này sẽ có giá trị liên quan đến key này.

#Root Context
ctxRoot := context.Background() - #Root context

#Below ctxChild has acess to only one pair {"a":"x"}
ctxChild := context.WithValue(ctxRoot, "a", "x")

#Below ctxChildofChild has access to both pairs {"a":"x", "b":"y"} as it is derived from ctxChild
ctxChildofChild := context.WithValue(ctxChild, "b", "y")

Ví dụ:

Dưới đây là ví dụ hoạt động đầy đủ của withValue(). Trong ví dụ dưới đây, chúng tôi đang chèn một msgId cho mỗi yêu cầu đến. Nếu bạn lưu ý trong chương trình dưới đây:

  • injctMsgID là một hàm trung gian net HTTP, điền vào trường "msgID" trong context
  • HelloWorld là hàm xử lý cho api "localhost: 8080/welcome" nhận msgID này từ context và gửi lại như tiêu đề phản hồi
package main

import (
    "context"
    "net/http"
    "github.com/google/uuid"
)

func main() {
    helloWorldHandler := http.HandlerFunc(HelloWorld)
    http.Handle("/welcome", inejctMsgID(helloWorldHandler))
    http.ListenAndServe(":8080", nil)
}

//HelloWorld hellow world handler
func HelloWorld(w http.ResponseWriter, r *http.Request) {
    msgID := ""
    if m := r.Context().Value("msgId"); m != nil {
        if value, ok := m.(string); ok {
            msgID = value
        }
    }
    w.Header().Add("msgId", msgID)
    w.Write([]byte("Hello, world"))
}

func inejctMsgID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        msgID := uuid.New().String()
        ctx := context.WithValue(r.Context(), "msgId", msgID)
        req := r.WithContext(ctx)
        next.ServeHTTP(w, req)

    })
}

Sau khi chạy chương trình trên, bạn có thể sử dụng lệnh curl để gọi API như sau:

curl -v http://localhost/welcome

Chúng ta sẽ nhận được phản hồi như sau. Lưu ý MsgId được điền vào phần header của phản hồi. Hàm injectMsgId hoạt động như một middleware và chèn một msgId duy nhất vào context của yêu cầu.

curl -v http://localhost:8080/welcome
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /do HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Msgid: a03ff1d4-1464-42e5-a0a8-743c5af29837
< Date: Mon, 23 Dec 2019 16:51:01 GMT
< Content-Length: 12
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact

context.WithCancel()

Hàm WithCancel() được sử dụng để đưa ra tín hiệu hủy bỏ. Dưới đây là chữ ký của hàm WithCancel()

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

Hàm context.WithCancel() trả về hai điều

  • Một bản sao của parentContext với kênh done mới
  • Một hàm cancel khi được gọi sẽ đóng kênh done này

Chỉ có người tạo ra context mới có thể gọi hàm cancel. Không khuyến khích việc truyền hàm cancel xung quanh. Hãy hiểu về withCancel qua ví dụ sau.

Ví dụ:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx := context.Background()
    cancelCtx, cancelFunc := context.WithCancel(ctx)
    go task(cancelCtx)
    time.Sleep(time.Second * 3)
    cancelFunc()
    time.Sleep(time.Second * 1)
}

func task(ctx context.Context) {
    i := 1
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Gracefully exit")
            fmt.Println(ctx.Err())
            return
        default:
            fmt.Println(i)
            time.Sleep(time.Second * 1)
            i++
        }
    }
}

Output:

1
2
3
Gracefully exit
context canceled

Trong chương trình trên:

  • Hàm task sẽ graceful exit (thoát đúng cách) khi hàm cancelFunc được gọi. Khi cancelFunc được gọi, chuỗi lỗi được đặt là "context cancelled" bởi package context. Đó là lý do tại sao kết quả của ctx.Err() là "context cancelled".

context.WithTimeout()

Được sử dụng để hủy bỏ thời gian. Chữ ký của hàm như sau

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

chức năng context.WithTimeout() sẽ

  • Sẽ trả về một bản sao của parentContext với kênh done mới.
  • Chấp nhận một deadline, sau đó kênh done này sẽ bị đóng và context sẽ bị hủy.
  • Một hàm cancel có thể được gọi nếu context cần bị hủy trước khi deadline được đạt đến.

Hãy xem một ví dụ

Ví dụ:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx := context.Background()
    cancelCtx, cancel := context.WithTimeout(ctx, time.Second*3)
    defer cancel()
    go task1(cancelCtx)
    time.Sleep(time.Second * 4)
}

func task1(ctx context.Context) {
    i := 1
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Gracefully exit")
            fmt.Println(ctx.Err())
            return
        default:
            fmt.Println(i)
            time.Sleep(time.Second * 1)
            i++
        }
    }
}

Output:

1
2
3
Gracefully exit
context deadline exceeded

Trong chương trình trên:

  • Hàm task sẽ gracefully exit sau khi hết timeout 3 giây. Chuỗi lỗi được đặt thành "context deadline exceeded" bởi package context. Đó là lý do tại sao kết quả của ctx.Err() là "context deadline exceeded".

context.WithDeadline()

Sử dụng cho việc hủy bỏ dựa trên deadline. Chữ ký của hàm như sau:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

hàm context.WithDeadline()

  • Trả về một bản sao của parentContext với kênh done mới.
  • Chấp nhận một deadline sau khi hết thời hạn, kênh done này sẽ bị đóng và context sẽ bị hủy.
  • Cung cấp một hàm cancel để có thể gọi nếu context cần được hủy trước khi thời hạn được đạt.

Hãy xem ví dụ

Ví dụ:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx := context.Background()
    cancelCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second*5))
    defer cancel()
    go task(cancelCtx)
    time.Sleep(time.Second * 6)
}

func task(ctx context.Context) {
    i := 1
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Gracefully exit")
            fmt.Println(ctx.Err())
            return
        default:
            fmt.Println(i)
            time.Sleep(time.Second * 1)
            i++
        }
    }
}

Output:

1
2
3
4
5
Gracefully exit
context deadline exceeded

Trong chương trình trên,

  • hàm task sẽ thoát một cách graceful khi thời gian timeout 5 giây được hoàn thành vì chúng ta đã đặt deadline là Time.now() + 5 giây. Chuỗi lỗi được đặt là "context deadline exceeded" bởi package context. Đó là lý do tại sao đầu ra của ctx.Err() là "context deadline exceeded".

Chúng ta đã học được gì

Làm thế nào để tạo context:

  • Sử dụng context.Background()
  • Sử dụng context.Todo()

Cây Context (Context Tree)

Tạo một context mới từ một context khác sử dụng

  • context.WithValue()
  • context.WithCancel()
  • context.WithTimeout()
  • contxt.WithDeadline()

BestPractices và Caveats

Sau đây là danh sách các best practice mà bạn có thể tuân thủ khi sử dụng context.

  • Không lưu trữ context trong kiểu struct.
  • Context nên được truyền qua chương trình của bạn. Ví dụ, trong trường hợp của một yêu cầu HTTP, một context mới có thể được tạo ra cho mỗi yêu cầu đến, được sử dụng để giữ một request_id hoặc đặt một số thông tin chung trong context như người dùng đang đăng nhập hiện tại có thể hữu ích cho yêu cầu đó.
  • Luôn luôn truyền context như là đối số đầu tiên cho một hàm.
  • Khi bạn không chắc chắn liệu có nên sử dụng context hay không, nên sử dụng context.ToDo() như một placeholder.
  • Chỉ có goroutine hoặc hàm cha mới được phép hủy context. Do đó, không nên truyền cancelFunc đến các goroutine hoặc hàm con. Golang sẽ cho phép bạn truyền cancelFunc đến các goroutine con nhưng đó không phải là một thực hành được khuyến khích.
Tag: go Golang nâng cao