زمانبندیهای درخواست زمینه
به عنوان جایگزینی برای الگویی که در فصل ۸.۳ برای مدیریت timeoutهای پایگاه داده پیادهسازی کردیم، میتوانستیم یک context با timeout در handlerهایمان ایجاد کنیم و سپس آن context را به مدل پایگاه دادهمان منتقل کنیم.
به طور کلی، الگویی شبیه به این:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) { ... // Create a context.Context with a one-second timeout deadline and which has // context.Background() as the 'parent'. ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() // Pass the context on to the Get() method. example, err := app.models.Example.Get(ctx, id) if err != nil { switch { case errors.Is(err, data.ErrNotFound): app.notFoundResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } ... }
func (m ExampleModel) Get(ctx context.Context, id int64) (*Example, error) { query := `SELECT ... FROM examples WHERE id = $1` var example Example err := m.DB.QueryRowContext(ctx, query, id).Scan(...) if err != nil { switch { case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound default: return nil, err } } return &example, nil }
مزیت کلیدی این الگو، انعطافپذیری اضافی آن است؛ هر کسی که مدل پایگاه داده را فراخوانی میکند، به راحتی میتواند مدت زمان timeout را کنترل کند، به جای اینکه همیشه یک مدت زمان ثابت توسط مدل پایگاه داده تنظیم شده باشد.
استفاده از درخواست context به عنوان parent
به عنوان یک گزینه دیگر، میتوانید از این الگو با درخواست context به عنوان parent context (به جای context.Background()) استفاده کنید. به این صورت:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) { ... // Use the request context as the parent. ctx, cancel := context.WithTimeout(r.Context(), time.Second) defer cancel() example, err := app.models.Example.Get(ctx, id) if err != nil { switch { case errors.Is(err, data.ErrNotFound): app.notFoundResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } ... }
از بسیاری جهات، انجام این کار یک عمل خوب محسوب میشود — اجازه دادن به context برای ‘جریان’ در سراسر application شما به طور کلی ایده خوبی است.
اما استفاده از درخواست context به عنوان parent، پیچیدگیهای زیادی اضافه میکند.
نکته کلیدی که باید از آن آگاه باشید این است که درخواست context در صورت بسته شدن اتصال HTTP توسط کلاینت لغو میشود. از مستندات net/http:
برای درخواستهای ورودی سرور، [request] context در صورت بسته شدن اتصال کلاینت، لغو شدن درخواست (با HTTP/2)، یا بازگشت روش ServeHTTP لغو میشود.
اگر از درخواست context به عنوان parent استفاده کنیم، این سیگنال لغو به درایور پایگاه داده pq منتقل میشود و query SQL در حال اجرا دقیقاً به همان شکلی که هنگام رسیدن به timeout context رخ میدهد، خاتمه مییابد. مهم است که بدانید این بدان معناست که ما همان پیام خطای pq: canceling statement due to user request را دریافت خواهیم کرد که هنگام timeout شدن query پایگاه داده دریافت میکنیم.
از یک طرف این یک چیز مثبت است — اگر کلاینتی برای ارسال پاسخ باقی نمانده باشد، میتوانیم query SQL را لغو کرده و منابع را آزاد کنیم.
از طرف دیگر، ما همان پیام خطا را در دو سناریوی بسیار متفاوت دریافت خواهیم کرد:
- هنگامی که query پایگاه داده برای تکمیل زمان زیادی میبرد، در این صورت ما میخواهیم آن را به عنوان خطا ثبت کنیم.
- هنگامی که کلاینت اتصال را میبندد — که میتواند به دلایل بیضرر زیادی رخ دهد، مانند بستن تب مرورگر توسط کاربر یا خاتمه دادن به یک پردازش. این واقعاً از دیدگاه application ما یک خطا نیست، و احتمالاً میخواهیم یا آن را نادیده بگیریم یا شاید آن را به عنوان یک هشدار ثبت کنیم.
خوشبختانه، امکان تمایز بین این دو سناریو با فراخوانی روش ctx.Err() روی context وجود دارد. اگر context لغو شده باشد (به دلیل بسته شدن اتصال کلاینت)، آنگاه ctx.Err() خطای context.Canceled را برمیگرداند. اگر timeout رسیده باشد، آنگاه context.DeadlineExceeded را برمیگرداند. اگر هم deadline رسیده باشد و هم context لغو شده باشد، ctx.Err() هر کدام که زودتر رخ داده باشد را نشان میدهد.
همچنین مهم است که بدانید ممکن است خطای context.Canceled دریافت کنید اگر کلاینت اتصال را در حین queue شدن query توسط sql.DB ببندد، و به همین ترتیب Scan() نیز ممکن است خطای context.Canceled را برگرداند.
با کنار هم قرار دادن همه اینها، یک روش منطقی برای مدیریت این موضوع این است که خطای pq: canceling statement due to user request را در مدل پایگاه داده بررسی کنیم و آن را با خطای ctx.Err() قبل از بازگشت ترکیب کنیم. به عنوان مثال:
func (m ExampleModel) Get(ctx context.Context, id int64) (*Example, error) { query := `SELECT ... FROM examples WHERE id = $1` var example Example err := m.DB.QueryRowContext(ctx, query, id).Scan(...) if err != nil { switch { case err.Error() == "pq: canceling statement due to user request": // Wrap the error with ctx.Err(). return nil, fmt.Errorf("%w: %w", err, ctx.Err()) case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound default: return nil, err } } return &example, nil }
سپس در handlerهای خود میتوانید از errors.Is() استفاده کنید تا بررسی کنید آیا خطای برگشتی از مدل پایگاه داده برابر با (یا شامل) context.Canceled هست یا نه، و آن را بر این اساس مدیریت کنید.
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) { ... ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() example, err := app.models.Example.Get(ctx, id) if err != nil { switch { // If the error is equal to or wraps context.Canceled, then it's not // really an error and we return without taking any further action. case errors.Is(err, context.Canceled): return case errors.Is(err, data.ErrNotFound): app.notFoundResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } ... }
علاوه بر این مدیریت خطای اضافی، باید از پیامدهای استفاده از goroutineهای پسزمینه نیز آگاه باشید. به یاد داشته باشید چیزی که قبلاً درباره لغو درخواست context نقل قول کردم:
برای درخواستهای ورودی سرور، [request] context … هنگام بازگشت روش ServeHTTP لغو میشود.
این بدان معناست که اگر context خود را از درخواست context مشتق کنید، هر query SQL که از context در یک goroutine پسزمینه طولانی استفاده میکند، هنگام ارسال پاسخ HTTP برای درخواست لغو خواهد شد! اگر نمیخواهید این اتفاق بیفتد (و احتمالاً نمیخواهید)، پس باید برای goroutine پسزمینه یک context کاملاً جدید با استفاده از context.Background() ایجاد کنید.
پس، به طور خلاصه، استفاده از درخواست context به عنوان parent context برای timeoutهای پایگاه داده پیچیدگی رفتاری زیادی اضافه میکند و ظرافتهایی را معرفی میکند که شما و هر کس دیگری که روی codebase کار میکند باید از آنها آگاه باشد. باید از خود بپرسید: آیا ارزشش را دارد؟
برای اکثر applicationها، در اکثر endpointها، احتمالاً ارزشش را ندارد. استثناها احتمالاً applicationهایی هستند که اغلب نزدیک به نقطه اشباع منابع خود اجرا میشوند، یا برای endpointهای خاصی که queryهای SQL کند یا بسیار پرهزینه محاسباتی را اجرا میکنند. در آن موارد، لغو تهاجمی queryها هنگام ناپدید شدن کلاینت میتواند تأثیر مثبت معناداری داشته باشد و ارزش این مصالحه را ایجاد کند.
تابع context.WithoutCancel
از Go 1.21، یک گزینه دیگر وجود دارد. میتوانید از درخواست context به عنوان parent context استفاده کنید اما آن را با تابع context.WithoutCancel() بپوشانید، به این صورت:
func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) { ... ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), time.Second) defer cancel() example, err := app.models.Example.Get(ctx, id) if err != nil { switch { case errors.Is(err, data.ErrNotFound): app.notFoundResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } ... }
تابع context.WithoutCancel() یک کپی از parent context برمیگرداند که هنگام لغو parent context لغو نمیشود. اما در غیر این صورت، دقیقاً مانند parent است — و مهم این است که مقادیر context یکسانی را با parent حفظ میکند.
پس، وقتی مانند مثال بالا از این استفاده میکنیم، context حاوی timeout پایگاه داده اگر کلاینت اتصال HTTP را ببندد یا روش ServeHTTP بازگردد، لغو نخواهد شد. این بدان معناست که از تمام پیچیدگیها و مدیریت خطای اضافی که در بالا توضیح داده شد اجتناب میکنیم. خطای pq: canceling statement due to user request فقط هنگام timeout بازگردانده خواهد شد.
و مزیت استفاده از این به جای context.Background() به عنوان parent این است که context مقادیری را که درخواست context دارد حفظ خواهد کرد.
پس، اگر میخواهید مدت زمان timeout را از handlerهای خود کنترل کنید (به جای داشتن timeout ثابت در مدل پایگاه داده)، این الگو تعادل خوبی بین کاهش پیچیدگی و همچنان اجازه دادن به مقادیر context برای ‘جریان’ در سراسر application شما فراهم میکند.