سفارشیسازی پیشرفته 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
و بعد کد زیر را به آن اضافه کنید:
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 }
اینجا دو نکته هست که میخواهم روی آنها تاکید کنم:
اگر متد
MarshalJSON()شما مثل متد ما یک مقدار JSON string برمیگرداند، باید قبل از return کردن، string را داخل double quote قرار دهید. وگرنه به عنوان JSON string تفسیر نمیشود و یک runtime error شبیه این میگیرید:json: error calling MarshalJSON for type data.Runtime: invalid character 'm' after top-level value
ما عمدا برای متد
MarshalJSON()از value receiver استفاده میکنیم، نه از pointer receiver مثلfunc (r *Runtime) MarshalJSON(). این انعطاف بیشتری میدهد، چون یعنی encoding سفارشی JSON ما هم روی مقدارهایRuntimeو هم روی pointer به مقدارهایRuntimeکار میکند. همانطور که Effective Go میگوید:The rule about pointers vs. values for receivers is that value methods can be invoked on pointers and values, but pointer methods can only be invoked on pointers.
خب، حالا که type سفارشی Runtime تعریف شده، فایل internal/data/movies.go را باز کنید و struct مربوط به Movie را بهروزرسانی کنید تا به این شکل از آن استفاده کند:
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 را سفارشی کنید، میتواند گزینه خوبی باشد. اما بدون نقطه ضعف هم نیست.
به طور مشخص:
این تکنیک کمی شبیه یک «ترفند» به نظر میرسد که روی این واقعیت تکیه دارد که typeهای تازه تعریفشده methodها را inherit نمیکنند. با اینکه هنوز idiomatic Go است، نسبت به رویکرد اول هوشمندانهتر اما کمتر روشن است. این همیشه trade-off خوبی نیست… مخصوصا اگر توسعهدهندههای تازهکار Go هم روی codebase شما کار میکنند.
کنترل دقیق روی ترتیب fieldها در پاسخ JSON را از دست میدهید. در مثال بالا، key مربوط به
runtimeاز این به بعد همیشه آخرین item در JSON object خواهد بود، به این شکل:{ "id": 123, "title": "Casablanca", "genres": [ "drama", "romance", "war" ], "version": 1, "runtime": "102 mins" }از نظر فنی، این نباید مهم باشد، چون JSON RFC میگوید JSON objectها مجموعههایی «بدون ترتیب» از صفر یا چند جفت name/value هستند. اما همچنان ممکن است از نظر زیباییشناسی یا UI رضایتبخش نباشد، یا اگر برای backward compatibility لازم دارید ترتیب دقیق fieldها را حفظ کنید، مشکلساز شود.