Let's Go Further ارسال پاسخ‌های JSON › فرمت‌بندی و envelope کردن پاسخ‌ها
قبلی · فهرست · بعدی
فصل ۳.۴.

فرمت‌بندی و envelope کردن پاسخ‌ها

تا اینجای کتاب، معمولا requestها را با Firefox به API فرستاده‌ایم؛ مرورگری که به لطف «pretty printing» موجود در JSON viewer داخلی‌اش، خواندن پاسخ‌های JSON را آسان می‌کند.

اما اگر چند request را با curl بفرستید، می‌بینید که داده واقعی پاسخ JSON همگی فقط در یک خط و بدون whitespace آمده است.

$ curl localhost:4000/v1/healthcheck
{"environment":"development","status":"available","version":"1.0.0"}

$ curl localhost:4000/v1/movies/123
{"id":123,"title":"Casablanca","runtime":102,"genres":["drama","romance","war"],"version":1}

می‌توانیم با استفاده از function مربوط به json.MarshalIndent() برای encode کردن داده پاسخ، به جای function معمولی json.Marshal()، خواندن این خروجی‌ها را در terminal آسان‌تر کنیم. این function به صورت خودکار به خروجی JSON whitespace اضافه می‌کند، هر element را در یک خط جداگانه قرار می‌دهد و هر خط را با کاراکترهای اختیاری prefix و indent شروع می‌کند.

بیایید helper مربوط به writeJSON() را به‌روزرسانی کنیم تا به جای روش قبلی از این استفاده کند:

File: cmd/api/helpers.go
package main

...

func (app *application) writeJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {
    // Use the json.MarshalIndent() function so that whitespace is added to the encoded 
    // JSON. Here we use no line prefix ("") and tab indents ("\t") for each element.
    js, err := json.MarshalIndent(data, "", "\t")
    if err != nil {
        return err
    }

    js = append(js, '\n')

    for key, values := range headers {
        for _, value := range values {
            w.Header().Add(key, value)
        }
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    w.Write(js)

    return nil
}

اگر API را دوباره راه‌اندازی کنید و همان requestها را دوباره از terminal بفرستید، حالا پاسخ‌های JSON مرتب و دارای whitespace شبیه این‌ها دریافت می‌کنید:

$ curl -i localhost:4000/v1/healthcheck
{
        "environment": "development",
        "status": "available",
        "version": "1.0.0"
}

$ curl localhost:4000/v1/movies/123
{
        "id": 123,
        "title": "Casablanca",
        "runtime": 102,
        "genres": [
                "drama",
                "romance",
                "war"
        ],
        "version": 1
}

performance نسبی

استفاده از json.MarshalIndent() از نظر خوانایی و تجربه کاربری مثبت است، اما متاسفانه بدون هزینه نیست. علاوه بر اینکه پاسخ‌ها حالا از نظر تعداد کل byteها کمی بزرگ‌تر هستند، کار اضافه‌ای که Go برای افزودن whitespace انجام می‌دهد اثر قابل توجهی روی performance دارد.

benchmarkهای زیر با استفاده از کد موجود در این gist کمک می‌کنند performance نسبی json.Marshal() و json.MarshalIndent() را ببینیم.

$ go test -run=^$ -bench=. -benchmem -count=3 -benchtime=5s
goos: linux
goarch: amd64
BenchmarkMarshalIndent-8        2177511     2695 ns/op     1472 B/op     18 allocs/op
BenchmarkMarshalIndent-8        2170448     2677 ns/op     1473 B/op     18 allocs/op
BenchmarkMarshalIndent-8        2150780     2712 ns/op     1476 B/op     18 allocs/op
BenchmarkMarshal-8              3289424     1681 ns/op     1135 B/op     16 allocs/op
BenchmarkMarshal-8              3532242     1641 ns/op     1123 B/op     16 allocs/op
BenchmarkMarshal-8              3619472     1637 ns/op     1119 B/op     16 allocs/op

در این benchmarkها می‌بینیم که اجرای json.MarshalIndent() نسبت به json.Marshal() حدود ۶۵٪ زمان بیشتری می‌برد، حدود ۳۰٪ memory بیشتری مصرف می‌کند و دو allocation اضافه‌تر روی heap انجام می‌دهد. این عددها بسته به چیزی که encode می‌کنید تغییر می‌کنند، اما طبق تجربه من، تصویر نسبتا خوبی از اثر performance می‌دهند.

برای بیشتر applicationها، این تفاوت performance چیزی نیست که لازم باشد نگرانش باشید. در عمل درباره چند هزارم millisecond صحبت می‌کنیم و خواناتر شدن پاسخ‌ها احتمالا ارزش این trade-off را دارد. اما اگر API شما در محیطی با محدودیت شدید منابع اجرا می‌شود، یا باید حجم بسیار بالایی از traffic را مدیریت کند، بهتر است از این موضوع آگاه باشید و شاید ترجیح دهید همچنان از json.Marshal() استفاده کنید.

envelope کردن پاسخ‌ها

در مرحله بعد، پاسخ‌هایمان را به‌روزرسانی می‌کنیم تا داده JSON همیشه داخل یک JSON object بیرونی envelope شود. شبیه این:

{
    "movie": {
        "id": 123,
        "title": "Casablanca",
        "runtime": 102,
        "genres": [
            "drama",
            "romance",
            "war"
        ],
        "version":1
    }
}

دقت کنید که داده movie اینجا زیر key مربوط به "movie" قرار گرفته، نه اینکه خودش top-level JSON object باشد.

envelope کردن داده پاسخ به این شکل الزامی نیست، و اینکه این کار را انجام بدهید یا نه تا حدی به style و سلیقه شما بستگی دارد. اما چند مزیت ملموس دارد:

  1. قرار دادن یک key name مثل "movie" در سطح بالای JSON باعث می‌شود پاسخ self-documentingتر باشد. برای آدم‌هایی که پاسخ را بدون context می‌بینند، کمی راحت‌تر است بفهمند داده به چه چیزی مربوط می‌شود.

  2. ریسک خطا در سمت client را کاهش می‌دهد، چون احتمال اینکه یک پاسخ را تصادفا با تصور اینکه چیز دیگری است پردازش کنید کمتر می‌شود. برای دسترسی به داده، client باید به شکل صریح از طریق key مربوط به "movie" به آن ارجاع دهد.

  3. اگر همیشه داده برگشتی API را envelope کنیم، یک آسیب‌پذیری امنیتی در مرورگرهای قدیمی‌تر را کاهش می‌دهیم؛ آسیبی که ممکن است وقتی یک JSON array را به عنوان پاسخ برمی‌گردانید ایجاد شود.

چند تکنیک برای envelope کردن پاسخ‌های API وجود دارد، اما ما کار را ساده نگه می‌داریم و با ساخت یک type سفارشی به نام envelope که type زیربنایی آن map[string]any است، این کار را انجام می‌دهیم.

اجازه بدهید نشان بدهم.

با به‌روزرسانی فایل cmd/api/helpers.go به شکل زیر شروع می‌کنیم:

File: cmd/api/helpers.go
package main

...

// Define an envelope type.
type envelope map[string]any

// Change the data parameter to have the type envelope instead of any.
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
    js, err := json.MarshalIndent(data, "", "\t")
    if err != nil {
        return err
    }

    js = append(js, '\n')

    for key, values := range headers {
        for _, value := range values {
            w.Header().Add(key, value)
        }
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    w.Write(js)

    return nil
}

بعد باید showMovieHandler را به‌روزرسانی کنیم تا یک instance از map مربوط به envelope بسازد که داده movie را در خودش دارد، و به جای پاس دادن مستقیم داده movie، این envelope را به helper مربوط به writeJSON() بدهد.

به این شکل:

File: cmd/api/movies.go
package main

...

func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        http.NotFound(w, r)
        return
    }

    movie := data.Movie{
        ID:        id,
        CreatedAt: time.Now(),
        Title:     "Casablanca",
        Runtime:   102,
        Genres:    []string{"drama", "romance", "war"},
        Version:   1,
    }

    // Create an envelope{"movie": movie} instance and pass it to writeJSON(), instead
    // of passing the plain movie struct.
    err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
    if err != nil {
        app.logger.Error(err.Error())
        http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
    }
}

همچنین باید کد داخل healthcheckHandler را به‌روزرسانی کنیم تا آن هم یک type از جنس envelope را به helper مربوط به writeJSON() پاس بدهد:

File: cmd/api/healthcheck.go
package main

...

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    // Declare an envelope map containing the data for the response. Notice that the way
    // we've constructed this means the environment and version data will now be nested 
    // under a system_info key in the JSON response.
    env := envelope{
        "status": "available",
        "system_info": map[string]string{
            "environment": app.config.env,
            "version":     version,
        },
    }

    err := app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        app.logger.Error(err.Error())
        http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError)
    }
}

خب، بیایید این تغییرات را امتحان کنیم. server را دوباره راه‌اندازی کنید و بعد با curl دوباره چند request به endpointهای API بفرستید. حالا باید پاسخ‌هایی با فرمت زیر بگیرید.

$ curl localhost:4000/v1/movies/123
{
        "movie": {
                "id": 123,
                "title": "Casablanca",
                "runtime": 102,
                "genres": [
                        "drama",
                        "romance",
                        "war"
                ],
                "version":1
        }
}

$ curl localhost:4000/v1/healthcheck
{
        "status": "available",
        "system_info": {
                "environment": "development",
                "version": "1.0.0"
        }
}

اطلاعات تکمیلی

ساختار پاسخ

مهم است تاکید کنیم که برای ساختاردهی پاسخ‌های JSON شما یک روش واحد درست یا غلط وجود ندارد. چند format محبوب مثل JSON:API و jsend وجود دارد که شاید بخواهید از آن‌ها پیروی کنید یا الهام بگیرید، اما قطعا ضروری نیست و بیشتر APIها از این formatها پیروی نمی‌کنند.

اما هر کاری که می‌کنید، ارزشمند است که از همان ابتدا به formatting فکر کنید و در endpointهای مختلف API، ساختار پاسخ واضح و سازگاری نگه دارید؛ به‌خصوص اگر قرار است برای استفاده عمومی باشند.