Let's Go Further ارسال پاسخ‌های JSON › سفارشی‌سازی پیشرفته JSON
قبلی · فهرست · بعدی
فصل ۳.۵.

سفارشی‌سازی پیشرفته JSON

با استفاده از struct tagها، افزودن whitespace و envelope کردن داده، تا همین‌جا توانسته‌ایم مقدار زیادی سفارشی‌سازی به پاسخ‌های JSON اضافه کنیم. اما وقتی این‌ها کافی نباشند و آزادی بیشتری برای سفارشی کردن JSON لازم داشته باشید چه می‌شود؟

برای پاسخ به این سوال، اول باید کمی درباره این صحبت کنیم که Go پشت صحنه چطور encoding به JSON را مدیریت می‌کند. نکته اصلی که باید بفهمیم این است:

وقتی Go در حال encode کردن یک type مشخص به JSON است، بررسی می‌کند که آیا آن type متدی به نام MarshalJSON() دارد یا نه. اگر داشته باشد، Go این متد را صدا می‌زند تا مشخص کند آن type چطور باید encode شود.

این بیان کمی کلی است، پس دقیق‌ترش کنیم.

به شکل دقیق‌تر، وقتی Go در حال encode کردن یک type مشخص به JSON است، بررسی می‌کند که آیا آن type interface مربوط به json.Marshaler را satisfy می‌کند یا نه؛ interfaceای که به این شکل است:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}

اگر آن type این interface را satisfy کند، Go متد MarshalJSON() آن را صدا می‌زند و از slice نوع []byte که برمی‌گرداند به عنوان مقدار JSON encode‌شده استفاده می‌کند.

اگر آن type متد MarshalJSON() نداشته باشد، Go سراغ تلاش برای encode کردن آن به JSON بر اساس مجموعه ruleهای داخلی خودش می‌رود.

پس اگر بخواهیم نحوه encode شدن چیزی را سفارشی کنیم، کافی است روی آن یک متد MarshalJSON() پیاده‌سازی کنیم که نمایش JSON سفارشی خودش را در یک slice از نوع []byte برگرداند.

سفارشی‌سازی field مربوط به Runtime

برای روشن‌تر شدن موضوع، بیایید یک مثال concrete در application خودمان ببینیم.

وقتی struct مربوط به Movie به JSON encode می‌شود، field مربوط به Runtime که type آن int32 است، فعلا به صورت یک JSON number فرمت می‌شود. بیایید این را تغییر دهیم تا به جای آن، به صورت یک string با فرمت "<runtime> mins" encode شود. به این شکل:

{
    "id": 123,
    "title": "Casablanca",
    "runtime": "102 mins",      ← This is now a string
    "genres": [
        "drama",
        "romance",
        "war"
    ],
    "version":1
}

چند راه برای رسیدن به این نتیجه وجود دارد، اما یک رویکرد تمیز و ساده این است که یک type سفارشی مخصوص field مربوط به Runtime بسازیم و یک متد MarshalJSON() روی این type سفارشی پیاده‌سازی کنیم.

برای اینکه فایل internal/data/movie.go شلوغ نشود، یک فایل جدید می‌سازیم تا منطق مربوط به type نوع Runtime را نگه دارد:

$ touch internal/data/runtime.go

و بعد کد زیر را به آن اضافه کنید:

File: internal/data/runtime.go
package data

import (
    "fmt"
    "strconv"
)

// Declare a custom Runtime type, which has the underlying type int32 (the same as our
// Movie struct field).
type Runtime int32

// Implement a MarshalJSON() method on the Runtime type so that it satisfies the 
// json.Marshaler interface. This should return the JSON-encoded value for the movie 
// runtime (in our case, it will return a string in the format "<runtime> mins").
func (r Runtime) MarshalJSON() ([]byte, error) {
    // Generate a string containing the movie runtime in the required format.
    jsonValue := fmt.Sprintf("%d mins", r)

    // Use the strconv.Quote() function on the string to wrap it in double quotes. It 
    // needs to be surrounded by double quotes in order to be a valid *JSON string*.
    quotedJSONValue := strconv.Quote(jsonValue)

    // Convert the quoted string value to a byte slice and return it.
    return []byte(quotedJSONValue), nil
}

اینجا دو نکته هست که می‌خواهم روی آن‌ها تاکید کنم:

خب، حالا که type سفارشی Runtime تعریف شده، فایل internal/data/movies.go را باز کنید و struct مربوط به Movie را به‌روزرسانی کنید تا به این شکل از آن استفاده کند:

File: internal/data/movies.go
package data

import (
    "time"
)

type Movie struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"-"`
    Title     string    `json:"title"`
    Year      int32     `json:"year,omitzero"`
    // Use the Runtime type instead of int32. Note that the omitzero directive will
    // still work on this: if the Runtime field has the underlying value 0, then it will
    // be considered zero and omitted -- and the MarshalJSON() method we just made 
    // won't be called at all.
    Runtime Runtime  `json:"runtime,omitzero"`
    Genres  []string `json:"genres,omitzero"`
    Version int32    `json:"version"`
}

بیایید این را با restart کردن API و فرستادن یک request به endpoint مربوط به GET /v1/movies/:id امتحان کنیم. حالا باید پاسخی ببینید که مقدار سفارشی runtime را با فرمت "<runtime> mins" دارد؛ شبیه این:

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

در مجموع، این روش خیلی خوبی برای تولید JSON سفارشی است. کد ما کوتاه و روشن است، و یک type سفارشی Runtime داریم که هر جا و هر وقت لازم باشد می‌توانیم از آن استفاده کنیم.

اما یک نقطه ضعف هم دارد. مهم است بدانید که استفاده از custom typeها گاهی هنگام یکپارچه‌سازی کدتان با پکیج‌های دیگر می‌تواند ناخوشایند باشد، و ممکن است لازم شود type conversion انجام دهید تا custom type خودتان را به مقداری تبدیل کنید که پکیج‌های دیگر می‌فهمند و می‌پذیرند، یا از آن مقدار برگردانید.


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

چند رویکرد جایگزین وجود دارد که می‌توانید برای رسیدن به همین نتیجه استفاده کنید، و می‌خواهم سریع آن‌ها را توضیح بدهم و مزایا و معایبشان را مرور کنم. اگر همراه ساخت پروژه کدنویسی می‌کنید، هیچ‌کدام از این تغییرات را انجام ندهید، مگر اینکه صرفا کنجکاو باشید.

جایگزین شماره ۱: سفارشی‌سازی struct مربوط به Movie

به جای ساخت یک type سفارشی Runtime، می‌توانستیم یک متد MarshalJSON() روی struct مربوط به Movie پیاده‌سازی کنیم و کل آن را سفارشی کنیم. به این شکل:

// Note that there are no struct tags on the Movie struct itself.
type Movie struct {
    ID        int64
    CreatedAt time.Time
    Title     string
    Year      int32
    Runtime   int32
    Genres    []string
    Version   int32
}

// Implement a MarshalJSON() method on the Movie struct, so that it satisfies the
// json.Marshaler interface.
func (m Movie) MarshalJSON() ([]byte, error) {
    // Declare a variable to hold the custom runtime string (this will be the empty 
    // string "" by default).
    var runtime string

    // If the value of the Runtime field is not zero, set the runtime variable to be a
    // string in the format "<runtime> mins".
    if m.Runtime != 0 {
        runtime = fmt.Sprintf("%d mins", m.Runtime)
    }

    // Create an anonymous struct to hold the data for JSON encoding. This has exactly
    // the same fields, types and tags as our Movie struct, except that the Runtime
    // field here is a string, instead of an int32. Also notice that we don't include
    // a CreatedAt field at all (there's no point including one, because we don't want
    // it to appear in the JSON output).
    aux := struct {
        ID      int64    `json:"id"`
        Title   string   `json:"title"`
        Year    int32    `json:"year,omitzero"`
        Runtime string   `json:"runtime,omitzero"` // This is a string.
        Genres  []string `json:"genres,omitzero"`
        Version int32    `json:"version"`
    }{
        // Set the values for the anonymous struct.
        ID:      m.ID,
        Title:   m.Title,
        Year:    m.Year,
        Runtime: runtime, // Note that we assign the value from the runtime variable here.
        Genres:  m.Genres,
        Version: m.Version,
    }

    // Encode the anonymous struct to JSON, and return it.
    return json.Marshal(aux)
}

بیایید سریع مرور کنیم اینجا چه اتفاقی می‌افتد.

در متد MarshalJSON() یک struct جدید و anonymous می‌سازیم و آن را به variable مربوط به aux assign می‌کنیم. این anonymous struct اساسا با struct مربوط به Movie ما یکسان است، با این تفاوت که field مربوط به Runtime به جای int32 type نوع string دارد. بعد همه مقدارها را مستقیما از struct مربوط به Movie داخل anonymous struct کپی می‌کنیم، به جز مقدار Runtime که اول آن را به string با فرمت "<runtime> mins" تبدیل می‌کنیم. در نهایت هم anonymous struct را به JSON encode می‌کنیم، نه struct اصلی Movie را، و آن را return می‌کنیم.

همچنین بد نیست اشاره کنیم که این طراحی طوری انجام شده که directive مربوط به omitzero همچنان با encoding سفارشی ما کار کند. اگر مقدار field مربوط به Runtime صفر باشد، variable محلی runtime برابر با "" باقی می‌ماند، که zero value برای string است، و حذف می‌شود.

جایگزین شماره ۲: embed کردن یک alias

نقطه ضعف رویکرد بالا این است که کد کمی verbose و تکراری به نظر می‌رسد. شاید بپرسید: راه بهتری وجود ندارد؟

برای کاهش duplication، به جای نوشتن همه fieldهای struct به شکل کامل، می‌توان یک alias از struct مربوط به Movie را داخل anonymous struct embed کرد. به این شکل:

// Notice that we use the - directive on the Runtime field, so that it never appears 
// in the JSON output.
type Movie struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"-"`
    Title     string    `json:"title"`
    Year      int32     `json:"year,omitzero"`
    Runtime   int32     `json:"-"`
    Genres    []string  `json:"genres,omitzero"`
    Version   int32     `json:"version"`
}

func (m Movie) MarshalJSON() ([]byte, error) {
    // Create a variable holding the custom runtime string, just like before.
    var runtime string

    if m.Runtime != 0 {
        runtime = fmt.Sprintf("%d mins", m.Runtime)
    }

    // Define a MovieAlias type which has the underlying type Movie. Due to the way that
    // Go handles type definitions (https://golang.org/ref/spec#Type_definitions) the
    // MovieAlias type will contain all the fields that our Movie struct has but, 
    // importantly, none of the methods. 
    type MovieAlias Movie

    // Embed the MovieAlias type inside the anonymous struct, along with a Runtime field 
    // that has the type string and the necessary struct tags. It's important that we 
    // embed the MovieAlias type here, rather than the Movie type directly, to avoid 
    // inheriting the MarshalJSON() method of the Movie type (which would result in an 
    // infinite loop during encoding).
    aux := struct {
        MovieAlias
        Runtime string `json:"runtime,omitzero"`
    }{
        MovieAlias: MovieAlias(m),
        Runtime:    runtime,
    }

    return json.Marshal(aux)
}

از یک طرف، این رویکرد خوب است چون تعداد خط‌های کد را به شکل چشمگیری کم می‌کند و تکرار را کاهش می‌دهد. اگر یک struct بزرگ دارید و فقط لازم است چند field را سفارشی کنید، می‌تواند گزینه خوبی باشد. اما بدون نقطه ضعف هم نیست.

به طور مشخص: