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

مدیریت درخواست‌های بد

حالا createMovieHandler وقتی یک JSON request body معتبر با داده مناسب دریافت می‌کند خوب کار می‌کند. اما در این نقطه شاید برایتان سوال شود:

خب… بیایید ببینیم!

# Send some XML as the request body
$ curl -d '<?xml version="1.0" encoding="UTF-8"?><note><to>Alice</to></note>' localhost:4000/v1/movies
{
    "error": "invalid character '\u003c' looking for beginning of value"
}

# Send some malformed JSON (notice the trailing comma)
$ curl -d '{"title": "Moana", }' localhost:4000/v1/movies
{
    "error": "invalid character '}' looking for beginning of object key string"
}

# Send a JSON array instead of an object
$ curl -d '["foo", "bar"]' localhost:4000/v1/movies
{
    "error": "json: cannot unmarshal array into Go value of type struct { Title string 
    \"json:\\\"title\\\"\"; Year int32 \"json:\\\"year\\\"\"; Runtime int32 \"json:\\
    \"runtime\\\"\"; Genres []string \"json:\\\"genres\\\"\" }"
}

# Send a numeric 'title' value (instead of string)
$ curl -d '{"title": 123}' localhost:4000/v1/movies
{
    "error": "json: cannot unmarshal number into Go struct field .title of type string"
}

# Send an empty request body
$ curl -X POST localhost:4000/v1/movies
{
    "error": "EOF"
}

در همه این حالت‌ها می‌بینیم که createMovieHandler کار درست را انجام می‌دهد. وقتی یک request نامعتبر دریافت می‌کند که نمی‌تواند داخل struct مربوط به input decode شود، پردازش بیشتری انجام نمی‌شود و یک پاسخ JSON برای client ارسال می‌شود که شامل پیام خطای برگشتی از متد Decode() است.

برای یک API خصوصی که قرار نیست عموم مردم از آن استفاده کنند، این رفتار احتمالا قابل قبول است و لازم نیست کار دیگری انجام دهید.

اما برای یک API عمومی، خود پیام‌های خطا ایدئال نیستند. بعضی بیش از حد جزئیات دارند و اطلاعاتی درباره پیاده‌سازی زیرین API افشا می‌کنند. بعضی دیگر به اندازه کافی توصیفی نیستند، مثل "EOF"، و بعضی هم واقعا گیج‌کننده و سخت‌فهم هستند. از نظر formatting و زبان استفاده‌شده هم consistency وجود ندارد.

برای بهتر کردن این وضعیت، توضیح می‌دهیم چطور خطاهای برگشتی از Decode() را triage کنیم و آن‌ها را با پیام‌های خطای روشن‌تر و قابل اقدام‌تر جایگزین کنیم، تا به client کمک کنیم دقیقا بفهمد مشکل JSON او چیست.

triage کردن خطای Decode

در این نقطه از ساخت application، متد Decode() ممکن است این پنج نوع خطا را برگرداند:

دلیل خطا
There is a syntax problem with the JSON being decoded. json.SyntaxError و
io.ErrUnexpectedEOF
یک مقدار JSON برای type مقصد در Go مناسب نیست. json.UnmarshalTypeError
مقصد decode معتبر نیست؛ معمولا چون pointer نیست. این در واقع مشکل کد application ماست، نه خود JSON. json.InvalidUnmarshalError
JSONای که decode می‌شود خالی است. io.EOF

triage کردن این خطاهای احتمالی، که می‌توانیم با functionهای errors.Is() و errors.As() در Go انجامش دهیم، کد داخل createMovieHandler را خیلی طولانی‌تر و پیچیده‌تر می‌کند. این logic چیزی است که باید در handlerهای دیگر پروژه هم duplicate کنیم.

پس برای کمک به این موضوع، یک helper جدید به نام readJSON() در فایل cmd/api/helpers.go می‌سازیم. در این helper، JSON را طبق معمول از request body decode می‌کنیم، بعد خطاها را triage می‌کنیم و در صورت نیاز با پیام‌های سفارشی خودمان جایگزینشان می‌کنیم.

اگر همراه کتاب کدنویسی می‌کنید، کد زیر را به فایل cmd/api/helpers.go اضافه کنید:

File: cmd/api/helpers.go
package main

import (
    "encoding/json"
    "errors"
    "fmt" // New import
    "io"  // New import
    "net/http"
    "strconv"

    "github.com/julienschmidt/httprouter"
)

...

func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    // Decode the request body into the target destination. 
    err := json.NewDecoder(r.Body).Decode(dst)
    if err != nil {
        // If there is an error during decoding, start the triage...
        var syntaxError *json.SyntaxError
        var unmarshalTypeError *json.UnmarshalTypeError
        var invalidUnmarshalError *json.InvalidUnmarshalError

        switch {
        // Use the errors.As() function to check whether the error has the type 
        // *json.SyntaxError. If it does, then return a plain-english error message 
        // which includes the location of the problem.
        case errors.As(err, &syntaxError):
            return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset)

        // In some circumstances Decode() may also return an io.ErrUnexpectedEOF error
        // for syntax errors in the JSON. So we check for this using errors.Is() and
        // return a generic error message. There is an open issue regarding this at
        // https://github.com/golang/go/issues/25956.
        case errors.Is(err, io.ErrUnexpectedEOF):
            return errors.New("body contains badly-formed JSON")

        // Likewise, catch any json.UnmarshalTypeError errors. These occur when the
        // JSON value is the wrong type for the target destination. If the error relates
        // to a specific field, then we include that in our error message to make it 
        // easier for the client to debug.
        case errors.As(err, &unmarshalTypeError):
            if unmarshalTypeError.Field != "" {
                return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field)
            }
            return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset)

        // An io.EOF error will be returned by Decode() if the request body is empty. We
        // check for this with errors.Is() and return a plain-english error message 
        // instead.
        case errors.Is(err, io.EOF):
            return errors.New("body must not be empty")

        // A json.InvalidUnmarshalError error will be returned if we pass something 
        // that is not a non-nil pointer as the target destination to Decode(). If this 
        // happens we panic, rather than returning an error to our handler. At the end of 
        // this chapter we'll briefly discuss why panicking is an appropriate thing to do 
        // in this specific situation.
        case errors.As(err, &invalidUnmarshalError):
            panic(err)

        // For any other error, return it as-is.
        default:
            return err
        }
    }

    return nil
}

حالا که این helper جدید آماده است، برگردیم به فایل cmd/api/movies.go و createMovieHandler را به‌روزرسانی کنیم تا از آن استفاده کند. به این شکل:

File: cmd/api/movies.go
package main

import (
    "fmt"
    "net/http"
    "time"

    "greenlight.alexedwards.net/internal/data"
)

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string   `json:"title"`
        Year    int32    `json:"year"`
        Runtime int32    `json:"runtime"`
        Genres  []string `json:"genres"`
    }

    // Use the new readJSON() helper to decode the request body into the input struct. 
    // If this returns an error we send the client the error message along with a 400
    // Bad Request status code, just like before.
    err := app.readJSON(w, r, &input)
    if err != nil {
        app.errorResponse(w, r, http.StatusBadRequest, err.Error())
        return
    }

    fmt.Fprintf(w, "%+v\n", input)
}

...

API را دوباره راه‌اندازی کنید و بعد با تکرار همان bad requestهایی که در ابتدای فصل فرستادیم، این را امتحان کنید. حالا باید پیام‌های خطای جدید و سفارشی‌شده‌ای شبیه این ببینید:

# Send some XML as the request body
$ curl -d '<?xml version="1.0" encoding="UTF-8"?><note><to>Alex</to></note>' localhost:4000/v1/movies
{
    "error": "body contains badly-formed JSON (at character 1)"
}

# Send some malformed JSON (notice the trailing comma)
$ curl -d '{"title": "Moana", }' localhost:4000/v1/movies
{
    "error": "body contains badly-formed JSON (at character 20)"
}

# Send a JSON array instead of an object
$ curl -d '["foo", "bar"]' localhost:4000/v1/movies
{
    "error": "body contains incorrect JSON type (at character 1)"
}

# Send a numeric 'title' value (instead of string)
$ curl -d '{"title": 123}' localhost:4000/v1/movies
{
    "error": "body contains incorrect JSON type for \"title\""
}

# Send an empty request body
$ curl -X POST localhost:4000/v1/movies
{
    "error": "body must not be empty"
}

خیلی بهتر شده‌اند. پیام‌های خطا حالا ساده‌تر، روشن‌تر و از نظر formatting سازگارتر هستند، و علاوه بر این هیچ اطلاعات غیرضروری درباره کد زیربنایی ما افشا نمی‌کنند.

اگر دوست دارید با این بخش کمی بازی کنید و request bodyهای مختلف بفرستید تا ببینید handler چطور واکنش نشان می‌دهد.

ساخت helper برای bad request

در کد createMovieHandler بالا، از helper عمومی app.errorResponse() استفاده می‌کنیم تا یک پاسخ 400 Bad Request همراه با پیام خطا برای client بفرستیم.

بیایید سریع آن را با یک helper function تخصصی به نام app.badRequestResponse() جایگزین کنیم:

File: cmd/api/errors.go
package main

...

func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
    app.errorResponse(w, r, http.StatusBadRequest, err.Error())
}
File: cmd/api/movies.go
package main

...

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string   `json:"title"`
        Year    int32    `json:"year"`
        Runtime int32    `json:"runtime"`
        Genres  []string `json:"genres"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        // Use the new badRequestResponse() helper.
        app.badRequestResponse(w, r, err) 
        return
    }

    fmt.Fprintf(w, "%+v\n", input)
}

...

این تغییر کوچک است، اما مفید است. هرچه application ما به‌تدریج پیچیده‌تر می‌شود، استفاده از helperهای تخصصی مثل این برای مدیریت انواع مختلف خطا کمک می‌کند مطمئن شویم پاسخ‌های خطای ما در همه endpointها سازگار باقی می‌مانند.


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

panic کردن در برابر return کردن خطا

موضوع panic کردن در برابر return کردن خطا چیزی است که در کتاب اول Let’s Go درباره‌اش صحبت کردیم، و من یک آموزش مفصل هم درباره آن اینجا نوشته‌ام؛ اگر Let’s Go را نخوانده‌اید، پیشنهاد می‌کنم آن را ببینید.

پس نمی‌خواهم همان اطلاعات را دوباره تکرار کنم، جز اینکه بگویم تصمیم برای panic کردن در helper مربوط به readJSON() وقتی خطای json.InvalidUnmarshalError می‌گیریم، تصمیمی سرسری نیست. همان‌طور که احتمالا می‌دانید، در Go عموما best practice این است که errorها را return کنید و gracefully مدیریتشان کنید، نه اینکه panic کنید.

تنها دلیلی که اینجا panic می‌کنیم این است که اگر در runtime یک json.InvalidUnmarshalError بگیریم، قطعا با یک خطای غیرمنتظره برنامه‌نویس روبه‌رو هستیم. این فقط زمانی رخ می‌دهد که ما به عنوان developer یک مقدار پشتیبانی‌نشده را به عنوان مقصد decode به Decode() پاس بدهیم. در عملکرد عادی نباید این خطا را ببینیم، و این چیزی است که باید مدت‌ها قبل از deployment در development و testها پیدا شود.

و اگر به جای panic کردن، این error را return می‌کردیم، لازم بود در هر کدام از handlerهای API کد اضافه‌ای برای مدیریت آن وارد کنیم؛ چیزی که برای خطایی که بعید است هرگز در production ببینیم، trade-off خوبی به نظر نمی‌رسد.