مشکلات کار با http.ResponseWriter توی گولنگ!


توی پست قبل که از اینجا می‌تونید مشاهده‌اش کنید، راجع به پکیج net/http نوشتم. توی این پست می‌خوام راجع به اینترفیس http.ResponseWriter بنویسم و بعضی از مشکلاتی که موقع کار کردن باهاشون ممکنه بخوریم رو توضیح بدم.

مقدمه

خب همونطور که تو پست قبل هم نوشتم، http.ResponseWriter صرفا یه Interface توی پکیج net/http با تعریف زیره:

net/http/server.go
type ResponseWriter interface {
   Header() Header
   Write([]byte) (int, error)
   WriteHeader(statusCode int)
}

از این اینترفیس، که یکی از ورودی‌های تابع‌های Handler محسوب میشه، برای تولید HTTP Response استفاده میشه.

پیاده‌سازی این اینترفیس تو تایپ http.response انجام شده. پیاده‌سازی این تایپ رو میتونید از اینجا ببینید. البته این تایپ، تنها پیاده‌سازی از اینترفیس نیست. نمونه‌ی دیگه‌ی پیاده‌سازی این اینترفیس توی پکیج net/http/httptest به اسم ResponseRecorder هست که برای تست نوشتن استفاده میشه.

نحوه‌ی کارکرد http.response و مشکلات معمول

این تایپ ظاهرا کار پیچیده‌ای نباید بکنه. سه تا متد برای کار با هدر‌ها، ست کردن status code و نوشتن body داره دیگه. حداقل از دور که اصلا پیچیده به نظر نمی‌رسه :)
ولی قضیه خیلی پیچیده تر از این حرفاست! با یه نگاه به پیاده‌سازی این تایپ متوجه این موضوع خواهید شد. (توضیح پیچیدگی این پیاده‌سازی تو چارچوب این نوشته نیست. ایشالا یه نوشته‌ی دیگه!) این پیچدگی باعث میشه که اگه بعضی از جزئیات پیاده‌سازی این تایپ رو ندونیم با ارورهای عجیب و غریبی رو به رو بشیم. چند تا از ارورهای معمول رو در ادامه توضیح میدم.

ترتیب فراخوانی متد ها مهمه

اول از همه باید گفت که وقتی داریم یه Abstraction ایجاد می‌کنیم، باید حواسمون باشه که حتی الامکان Idempotent و Stateless باشه. این باعث میشه ترتیب فراخوانی متد‌های یک تایپ توی یک اسکوپ آنچنان مهم نباشه و فراخوانی یه متد به فراخوانی یه متد دیگه وابسته نباشه.

ولی متاسفانه پیاده‌سازی http.response این شکلی نیست. این یکی از اشکال‌هایی هست که می‌تونیم به این قسمت از پیاده‌سازی استاندارد لایبرری گولنگ وارد کنیم (چه غلطا! حتما یه دلیلی داشته که اینجوری پیاده سازی کردن :) ولی من دلیلش رو نمی‌دونم. اگه شما می‌دونید کامنتش کنید. )

ترتیب درست به این شکل هست که شما اول باید Header هایی که می‌خواید رو با استفاده از متد ()Header به Response اضافه کنید و یا حتی در شرایطی بعضی از هدر‌ها رو حذف کنید.
بعد با متد WriteHeader استاتوس کد پاسخ درخواست رو مشخص کنید. بعد از همه‌ی این کارا تازه می‌تونید body رو با استفاده از متد Write پر کنید.

هر ترتیبی غیر از ترتیب بالا باعث ایجاد مشکلاتی خواهد شد. البته دلیل پیاده‌سازی به این نحو تا حدودی مشخصه. با توجه به اینکه پروتکل HTTP یک پروتکل text-based هست و دقیقا به همین ترتیب یک Response باید ارسال بشه. اول Status Code و Header ها و بعد Body.

به خاطر همینه که انتظار میره اول هدر ها رو مشخص کنیم. و بعد با مشخص کردن Status Code قسمت اول پیام مربوط به Response رو بسازیم و بعد Body رو بنویسیم. تو عکس زیر جزئیات یک Response نمونه رو می‌تونید مشاهده کنید.

نمونه‌ی یک HTTP Response
نمونه‌ی یک HTTP Response


حالا با توجه به توضیحاتی که دادیم، اگه متد‌ها رو جا به جا فراخوانی کنیم به مشکل می‌خوریم. مسئله یه خورده پیچیده تر میشه چون وقتی این اشتباه رو بکنیم، با Compile Error یا حتی Runtime Error مواجه نمی‌شیم و اپلیکیشن به کار کردن ادامه میده. این مسئله پیدا کردن مشکل رو بعضی وقتا سخت‌تر می‌کنه.

مثلا تو قطعه کد زیر:

package main

import &quotnet/http&quot

func main() {
   fn := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
      writer.Write([]byte(&quotHello World!&quot))
      writer.WriteHeader(http.StatusBadRequest)

   })

   http.ListenAndServe(&quot:9090&quot, fn)
}

بعد از هر بار ارسال درخواست به سرور، اپلیکیشنتون یه همچین چیزی لاگ می‌کنه.

http: superfluous response.WriteHeader call from main.main.func1 (main.go:8)

و Status Code ای که ست میشه روی Response کد ۲۰۰ خواهد بود. چون وقتی متد Write رو بدون این که قبلش WriteHeader رو فراخوانی کنیم، فرض میکنه باید کد ۲۰۰ رو ست کنه. حالا هر چه قدر بعد از Write بیایم WriteHeader رو فراخوانی کنیم یه چیزیه مثل نوش‌دارو بعد مرگ آقا سهراب.

آب رفته به جوی بر نمی‌گردد!

بعضی وقتا لازمه یه کاری رو بکنیم ولی بعدا پشیمون بشیم! مثلا یه هدر رو «بنویسیم» ولی بعد منصرف بشیم یا بخوایم مقدارش رو عوض کنیم. یا این حالت که بعد از اینکه Body رو نوشتیم، بخوایم یه خورده تغییرش بدیم. مثلا یه فیلد JSON ناقابل به Body اضافه کنیم.

متاسفانه باید بگم که تو گولنگ همیشه نمیشه این کار رو کرد!

شما وقتی متد WriteHeader رو فراخوانی می‌کنید دیگه نمیشه تغییری تو بخش هدر داد. به خاطر اینکه بعد از فراخوانی این متد State تایپ http.response رو جوری تغییر میده (در واقع میاد فلگ wroteHeader رو ست می‌کنه) که فراخوانی‌های بعدی اثری نداشته باشه.

متد Write هم یه همچین حالتی داره. Body توی این تایپ به حالت یک بافر پیاده‌سازی شده که هر چند بار این متد رو فراخوانی کنیم، ورودیش به ‌بدنه‌ی پیام Append میشه. برای مثال تو قطعه کد زیر Response دریافتی شما Hello WorldHello Donya خواهد بود.

package main

import &quotnet/http&quot

func main() {
   fn := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
      writer.Write([]byte(&quotHello World&quot))
      writer.Write([]byte(&quotHello Donya&quot))
   })

   http.ListenAndServe(&quot:9090&quot, fn)
}

// Response Of Requests: Hello WorldHello Donya

خب ممکنه بگید «چه کاریه؟ همیشه به ترتیب و فقط یک بار متد‌ها رو فراخوانی می‌کنیم. اینطوری مشکل حل میشه.» توی اکثر سناریو ها بله. مشکل اینطوری حل میشه. ولی بعضی از سناریو‌ها هستن که نمیشه کاریش کرد. مثلا شما نمی‌تونید یه Middleware (Middleware اصطلاحا به تابعی گفته میشه که یه Handler ورودی می‌گیره و یه Handler دیگه خروجی می‌ده) داشته باشید که بعد از انجام کار Handler اصلی یه فیلد JSON به Response اضافه بکنه!

قبلا چی نوشته بودم!؟

توی بعضی از حالت‌ها ممکنه شما به یه Middleware نیاز داشته باشید که ببینه قبلا شما چه چیزی رو به عنوان Body نوشتید. مثلا یه Middleware رو در نظر بگیرید که می‌خواد یه فیلد خاص از Body رو ببینه و به عنوان یه Prometheus Metric اون رو Expose بکنه. با این Abstraction موجود چنین کاری نمی‌تونیم بکنیم. چون دسترسی به محتوای نوشته شده توسط متد Write رو نداریم.

راه حل چیه؟

خب این همه مشکل گفتیم. نمیشه که راه حل نداشته باشه. نمیشه که همیشه غر زد.

بیایم به این سوال جواب بدیم که «چیکار کنیم؟»

یه راه معمول اینه که یه جوری Signature تابع‌های Handler رو عوض کنیم. و به جای ورودی‌های قبلی یه تایپ ورودی جدید تعیین کنیم و اون رو به Handler پاس بدیم. فریمورک‌هایی مثل Gin و Echo همچین کاری کردن و اسم اون ورودی جدید رو گذاشتن Context. البته این اسم به نظرم زیاد خوب نیست چون با context خود گولنگ قاطی میشه. (شاید اسم بهتر نبوده. چی بگم والا!)

با این ترفند هر کاری با Response می‌خوایم بکنیم رو تو یه داده‌ساختار میانی (که اینجا اسمش شده Context) انجام می‌دیم. بعد که کارمون تموم شد متد‌های ResponseWriter گولنگ رو با ترتیب و مقادیر درست فراخوانی می‌کنیم. (البته این کار به این سادگی که تو متن میگم نیست و توضیحش توی این مثال نمی‌گنجه. ولی گرا رو دادم بهتون دیگه :) می‌تونید برید پیاده‌سازی اون دو تا فریمورک رو ببینید و ایده بگیرید.)

خب پس مشکل اینطوری حل میشه. پس چرا همیشه اینطوری کد نزنیم؟ واقعیتش اینه که برای انجام همچین کاری، میزان پیاده‌سازی شما بیشتر خواهد شد و همچنین باید به هندل کردن کلی Edge case که مربوط به اپلیکیشن شما نیست و مربوط به پروتکل HTTP هست فکر کنید.

اون دو تا فریمورک هم که بالا گفتم با توجه به نیاز خودشون یه Context ساختن که با در نظر گرفتن میزان پیچیدگی نیاز شما ممکنه اونا هم جوابگوی شما نباشن.

از طرف دیگه کلی ابزار ساخته شده که با لایبرری استاندارد گولنگ سازگار ان و اگه شما از چنین راه حلی استفاده کنید ممکنه مزیت‌های استفاده از ابزار‌های آماده رو هم از دست بدید!

نتیجه چی شد بالاخره؟

نتایج متفاوتی از این متن میشه گرفت ولی نظر خودم اینه:

تا جایی که میشه و نیاز هامون اجازه می‌ده می‌تونیم از استاندارد لایبرری گولنگ استفاده کنیم.

اگه نشد فریمورک‌هایی مثل Gin و Echo می‌تونن جوابگوی نیاز‌های ما باشن.

اگه نه هم که خودمون بنویسیم دیگه :)


ممنون که وقت گذاشتید و تا اینجا خوندید. حتما نظراتتون رو برام بنویسید تا به بهتر شدن نوشته‌های بعدی کمک بکنه.