Anophel-آنوفل JSON در Go وارد مرحله تازه‌ای می‌شود: راهنمای کامل encoding/json/v2

JSON در Go وارد مرحله تازه‌ای می‌شود: راهنمای کامل encoding/json/v2

تاریخ انتشار:
Go
زمان مطالعه: 23 دقیقه

JSON تقریباً همه‌جا در توسعه بک‌اند حضور دارد. از دریافت درخواست HTTP و ساخت پاسخ API گرفته تا ارتباط بین microserviceها، خواندن فایل‌های تنظیمات و اتصال به سرویس‌های خارجی، بخش قابل‌توجهی از کد هر سرویس Go به نحوی با JSON سروکار دارد.

Go سال‌هاست پکیج encoding/json را در اختیار توسعه‌دهنده‌ها قرار داده است. API آن ساده و آشناست: یک struct تعریف می‌کنیم، با struct tag نام فیلدها را مشخص می‌کنیم و در نهایت سراغ Marshal یا Unmarshal می‌رویم.

اما استفاده طولانی‌مدت از این پکیج، محدودیت‌هایی را هم آشکار کرده است. بعضی رفتارهای نسخه قدیمی بیش از حد permissive هستند، بعضی تصمیم‌های اولیه به بخشی از قرارداد سازگاری Go تبدیل شده‌اند و در بخش‌هایی از API هم کنترل کافی برای پردازش جریانی یا پیاده‌سازی سفارشی وجود ندارد.

نسخه جدید دو پکیج مرتبط را معرفی می‌کند:

  • encoding/json/v2 برای تبدیل مقدارهای Go به JSON و برعکس
  • encoding/json/jsontext برای کار مستقیم با ساختار و متن JSON

این تغییر صرفاً اضافه شدن چند تابع جدید نیست. طراحی پکیج بازنگری شده تا مسئولیت پردازش Syntax از تبدیل داده‌ها به ساختارهای Go جدا شود، رفتارهای پیش‌فرض دقیق‌تری داشته باشیم و در سناریوهای پیچیده‌تر کنترل بیشتری روی خواندن و نوشتن JSON در اختیارمان باشد.

در این مقاله ابتدا محدودیت‌های نسخه قدیمی را بررسی می‌کنیم، بعد سراغ APIها و تفاوت‌های رفتاری می‌رویم و در پایان درباره مهاجرت به نسخه جدید در یک پروژه واقعی صحبت می‌کنیم.

مثال‌های مقایسه‌ای این مقاله از نام‌های مستعار jsonv1 و jsonv2 استفاده می‌کنند. هر قطعه کد مستقل است و ممکن است برای اجرا به importهای همان بخش نیاز داشته باشد.

۱. نسخه قدیمی encoding/json چه مشکلی داشت؟

یک مثال ساده از نسخه قدیمی:

1package main 2 3import ( 4 encoding/json 5 fmt 6) 7 8type User struct { 9 ID int64 `json:id` 10 Name string `json:name` 11} 12 13func main() { 14 user := User{ID: 42, Name: Alice} 15 16 data, err := json.Marshal(user) 17 if err != nil { 18 panic(err) 19 } 20 21 fmt.Println(string(data)) 22}

خروجی:

1{id:42,name:Alice}

برای Decode کردن هم کافی است بنویسیم:

1var user User 2if err := json.Unmarshal(data, &user); err != nil { 3 panic(err) 4}

این API برای بسیاری از کاربردها کاملاً کافی است. مسئله این نیست که encoding/json از ابتدا طراحی بدی داشته است. مسئله این است که رفتارهای آن در طول سال‌ها به بخشی از قرارداد سازگاری Go تبدیل شده‌اند.

وقتی تعداد زیادی پروژه به یک رفتار وابسته باشند، حتی تغییری که از نظر طراحی منطقی‌تر به نظر می‌رسد می‌تواند برنامه‌های موجود را خراب کند.

۱.۱. UTF-8 نامعتبر بی‌سروصدا اصلاح می‌شد

متن JSON باید از UTF-8 معتبر استفاده کند. با این حال، نسخه قدیمی در بعضی موقعیت‌ها بایت‌های UTF-8 نامعتبر داخل رشته‌ها را می‌پذیرفت و آن‌ها را با کاراکتر جایگزین Unicode یعنی � جایگزین می‌کرد.

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

در v2، UTF-8 نامعتبر به‌صورت پیش‌فرض رد می‌شود. این رفتار باعث می‌شود ورودی خراب زودتر و شفاف‌تر شناسایی شود.

۱.۲. نام تکراری در JSON Object پذیرفته می‌شد

این JSON را در نظر بگیرید:

1{ 2 role: user, 3 role: admin 4}

کلید role دو بار در یک object آمده است. کتابخانه‌های مختلف ممکن است با چنین ورودی‌ای رفتار یکسانی نداشته باشند: بعضی مقدار اول را نگه می‌دارند، بعضی مقدار آخر را و برخی رفتار دیگری دارند.

نسخه قدیمی Go این ورودی را می‌پذیرفت. هنگام Decode کردن، مقدارهای بعدی ممکن بود بسته به نوع مقصد، مقدار قبلی را جایگزین کنند یا با آن ادغام شوند.

اگر API Gateway، لایه اعتبارسنجی و سرویس اصلی یک درخواست را با parserهای متفاوت بررسی کنند، ممکن است درباره مقدار واقعی یک فیلد به نتیجه یکسانی نرسند. چنین اختلافی می‌تواند به باگ‌های امنیتی منجر شود.

encoding/json/v2 به‌صورت پیش‌فرض نام‌های تکراری object را رد می‌کند. اگر برنامه واقعاً به چنین ورودی‌ای نیاز داشته باشد، باید رفتار permissive را آگاهانه فعال کند.

۱.۳. تطبیق نام فیلدها به حروف بزرگ و کوچک حساس نبود

فرض کنیم struct زیر را داریم:

1type User struct { 2 Name string `json:name` 3}

در نسخه قدیمی، نام‌هایی با حروف متفاوت هم ممکن بود به همین فیلد متصل شوند:

1{name: Alice}
1{Name: Alice}
1{NAME: Alice}

این انعطاف گاهی مفید است، اما می‌تواند باعث شود قرارداد API دقیق نباشد. در v2، تطبیق نام‌ها به‌صورت پیش‌فرض case-sensitive است. اگر برای سازگاری با ورودی‌های قبلی به رفتار قدیمی نیاز داشته باشیم، می‌توانیم آن را صریحاً فعال کنیم.

۱.۴. تنظیمات در API یکدست نبودند

نسخه قدیمی توابعی مثل Marshal و Unmarshal و همچنین نوع‌های Encoder و Decoder را ارائه می‌کرد. اما همه تنظیمات از یک مسیر در دسترس نبودند. برای مثال، DisallowUnknownFields روی Decoder وجود داشت، ولی نمی‌شد همان گزینه را مستقیماً به json.Unmarshal داد.

تنظیمات فراخواننده هم لزوماً به پیاده‌سازی‌های سفارشی MarshalJSON و UnmarshalJSON منتقل نمی‌شدند. در v2، Options به‌صورت آرگومان به APIهای اصلی داده می‌شوند و APIهای سفارشی جدید امکان دسترسی به encoder و decoder را فراهم می‌کنند.

۱.۵. اینترفیس‌های سفارشی مبتنی بر []byte محدودیت داشتند

اینترفیس‌های قدیمی به این شکل هستند:

1type Marshaler interface { 2 MarshalJSON() ([]byte, error) 3} 4 5type Unmarshaler interface { 6 UnmarshalJSON([]byte) error 7}

در زمان Marshal، پیاده‌سازی سفارشی باید کل JSON مربوط به مقدار را تولید کند و به شکل یک []byte برگرداند. سپس پکیج JSON باید خروجی را بررسی کند.

در زمان Unmarshal هم پیاده‌سازی سفارشی یک تکه کامل از JSON را به‌صورت byte slice دریافت می‌کند. parser باید مرز مقدار را پیدا کند و خود پیاده‌سازی سفارشی هم معمولاً دوباره همان داده را Parse می‌کند. در ساختارهای تو در تو و پیاده‌سازی‌های recursive، این پردازش تکراری می‌تواند هزینه قابل‌توجهی داشته باشد.

۱.۶. داشتن io.Reader الزاماً به معنی Streaming واقعی نیست

نسخه قدیمی Encoder و Decoder با io.Writer و io.Reader کار می‌کنند. اما استفاده از io.Reader به‌تنهایی تضمین نمی‌کند که parser هر بخش را مستقل و تدریجی پردازش کند. پیاده‌سازی قدیمی برای پردازش یک مقدار کامل JSON می‌تواند آن مقدار را در حافظه Buffer کند.

برای پردازش جریانی سطح پایین، APIای که مستقیماً با tokenها کار کند و امکان نوشتن تدریجی آن‌ها را بدهد، انتخاب مناسب‌تری است.

۲. چرا نسخه قدیمی را اصلاح نکردند؟

Go به سازگاری عقب‌رو اهمیت زیادی می‌دهد. وعده سازگاری Go 1 باعث شده رفتارهایی که شاید امروز ایده‌آل به نظر نرسند، همچنان برای برنامه‌های موجود حفظ شوند.

فرض کنید یک سرویس سال‌هاست به این رفتار وابسته است که nil slice به شکل null در JSON نوشته شود. یا کلاینت‌هایی دارد که نام فیلدها را با حروف متفاوت ارسال می‌کنند. تغییر مستقیم رفتار پیش‌فرض ممکن است خروجی API یا ورودی‌های قابل‌قبول آن سرویس را تغییر دهد.

به همین دلیل مسیر جدید با import path جدا ارائه شده است:

1import json encoding/json/v2

و پکیج قدیمی همچنان در دسترس است:

1import encoding/json

این جداسازی یک مرز روشن برای مهاجرت ایجاد می‌کند. پروژه‌های موجود مجبور نیستند صرفاً به خاطر معرفی v2 فوراً مهاجرت کنند.

نکته مهم این است که v1 و v2 برای تعامل با یکدیگر طراحی شده‌اند. پیاده‌سازی پکیج قدیمی نیز بر پایه پیاده‌سازی جدید و با مجموعه‌ای از گزینه‌های سازگاری انجام می‌شود.

۳. معماری جدید: جداسازی Syntax از Semantics

یکی از مهم‌ترین تغییرهای طراحی، جدا کردن دو مسئولیت متفاوت است:

  1. پردازش ساختار و قواعد خود JSON
  2. تبدیل مقدارهای JSON به نوع‌های Go و برعکس

Syntax با خود فرمت JSON سروکار دارد:

  • خواندن و نوشتن tokenها
  • تشخیص string، number، array و object
  • اعتبارسنجی قواعد JSON
  • پردازش مقدارهای JSON و حفظ ساختار objectها و arrayها

Semantics مسئول ارتباط JSON با نوع‌های Go است:

  • تبدیل JSON object به struct یا map
  • تبدیل JSON array به slice یا array
  • تبدیل مقدارهای JSON به نوع‌های عددی Go
  • اعمال struct tagها
  • فراخوانی custom marshaler و unmarshaler
  • تعیین نحوه نمایش nil و zero valueها

این تفکیک در دو پکیج پیاده‌سازی شده است:

1 Go values 2 | 3 v 4 encoding/json/v2 5 Semantic layer 6 -------------------------- 7 Structs, maps, slices 8 Reflection and struct tags 9 Marshal and Unmarshal 10 Custom type representations 11 -------------------------- 12 | 13 v 14 encoding/json/jsontext 15 Syntax layer 16 -------------------------- 17 JSON values and tokens 18 JSON grammar validation 19 Streaming Encoder / Decoder 20 -------------------------- 21 | 22 v 23 JSON text

encoding/json/v2 مسئول نگاشت داده‌های Go به JSON و برعکس است. در مقابل، encoding/json/jsontext با خود متن JSON کار می‌کند و لازم نیست بداند یک object قرار است به struct تبدیل شود یا map.

این جداسازی باعث می‌شود بتوانیم بدون ساختن یک مدل Go برای کل سند، JSON را بخوانیم، بررسی کنیم یا تغییر دهیم.

۴. آشنایی با encoding/json/v2

برای کاربردهای معمول، API جدید آشنا به نظر می‌رسد:

1package main 2 3import ( 4 fmt 5 6 json encoding/json/v2 7) 8 9type User struct { 10 ID int64 `json:id` 11 Name string `json:name` 12} 13 14func main() { 15 user := User{ID: 42, Name: Alice} 16 17 data, err := json.Marshal(user) 18 if err != nil { 19 panic(err) 20 } 21 22 fmt.Println(string(data)) 23 24 var decoded User 25 if err := json.Unmarshal(data, &decoded); err != nil { 26 panic(err) 27 } 28 29 fmt.Printf(%+v\n, decoded) 30}

تفاوت اصلی در این مثال import path است. برای استفاده‌های ساده، ممکن است تغییر کد بسیار محدود باشد. اما API جدید به Marshal و Unmarshal خلاصه نمی‌شود.

۴.۱. توابع اصلی

تابعکاربرد
Marshalتبدیل مقدار Go به []byte حاوی JSON
MarshalWriteنوشتن JSON مستقیماً در یک io.Writer
MarshalEncodeMarshal کردن از طریق jsontext.Encoder
UnmarshalDecode کردن یک []byte در مقدار Go
UnmarshalReadخواندن JSON از یک io.Reader و Decode کردن آن
UnmarshalDecodeDecode کردن یک مقدار از طریق jsontext.Decoder

این توابع امکان دریافت Options را هم دارند. بنابراین برای بسیاری از تنظیمات لازم نیست حتماً یک Encoder یا Decoder جدا بسازیم.

1data, err := json.Marshal( 2 user, 3 json.Deterministic(true), 4) 5if err != nil { 6 panic(err) 7}

گزینه Deterministic درخواست می‌کند که برای یک مقدار ورودی مشخص، خروجی پایدار و قابل‌تکرار تولید شود. برای mapهای Go، این موضوع شامل مرتب‌سازی کلیدها هم می‌شود.

خروجی deterministic در تست‌ها، تولید خروجی قابل‌تکرار و بعضی فرایندهای hash کردن مفید است. البته نباید آن را با یک پیاده‌سازی کامل Canonical JSON اشتباه گرفت.

۵. مهم‌ترین تفاوت‌های رفتاری v1 و v2

بخش مهم مهاجرت به v2، تغییر رفتارهای پیش‌فرض است. این تغییرها عمدی‌اند و هدفشان کاهش ابهام و نزدیک‌تر کردن رفتار Go به انتظار رایج از JSON است.

۵.۱. تفاوت nil slice و nil map

در v1، یک slice با مقدار nil به null تبدیل می‌شود:

1var names []string 2 3data, err := jsonv1.Marshal(names) 4if err != nil { 5 panic(err) 6} 7 8fmt.Println(string(data))

خروجی:

1null

در v2، همان مقدار به یک آرایه خالی تبدیل می‌شود:

1var names []string 2 3data, err := jsonv2.Marshal(names) 4if err != nil { 5 panic(err) 6} 7 8fmt.Println(string(data))

خروجی:

1[]

همین تفاوت برای map هم وجود دارد:

مقدار Goخروجی v1خروجی v2
var s []stringnull[]
[]string{}[][]
var m map[string]intnull{}
map[string]int{}{}{}

برای APIها، این رفتار می‌تواند مفید باشد. کلاینتی که انتظار یک collection دارد، به‌جای مدیریت دو حالت null و آرایه خالی، همیشه یک آرایه دریافت می‌کند.

با این حال، اگر کلاینت‌ها بین null و collection خالی تفاوت معنایی قائل باشند، تغییر خروجی می‌تواند قرارداد API را بشکند.

۵.۲. تفاوت omitempty و omitzero

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

در v1، گزینه omitempty بر اساس مفهوم empty در Go عمل می‌کند. برای مثال عدد 0، مقدار false، رشته خالی، nil pointer و slice یا map خالی می‌توانند حذف شوند.

در v2، مفهوم omitempty بر اساس خروجی JSON است. فیلد زمانی حذف می‌شود که مقدار JSON آن یکی از این موارد باشد:

  • null
  • رشته خالی
  • آرایه خالی []
  • object خالی {}

در نتیجه، مقدار عددی صفر و boolean برابر false در v2 با omitempty حذف نمی‌شوند.

1type Settings struct { 2 Retries int `json:retries,omitempty` 3 Enabled bool `json:enabled,omitempty` 4}

برای یک مقدار صفر از Settings، خروجی v1 می‌تواند این باشد:

1{}

اما خروجی v2 چنین خواهد بود:

1{retries:0,enabled:false}

اگر هدف حذف zero valueهای Go باشد، باید از omitzero استفاده کنیم:

1type Settings struct { 2 Retries int `json:retries,omitzero` 3 Enabled bool `json:enabled,omitzero` 4}

قاعده کاربردی:

  • وقتی می‌خواهید مقدارهایی حذف شوند که در JSON خالی محسوب می‌شوند، از omitempty استفاده کنید.
  • وقتی می‌خواهید zero valueهای Go حذف شوند، سراغ omitzero بروید.

در بعضی structها استفاده هم‌زمان از هر دو گزینه هم می‌تواند منطقی باشد.

۵.۳. تطبیق case-sensitive نام فیلدها

در v1، تطبیق نام فیلدها در صورت نیاز می‌تواند به شکل case-insensitive انجام شود. در v2، رفتار پیش‌فرض case-sensitive است.

1type User struct { 2 Name string `json:name` 3}

این ورودی با فیلد مطابقت دارد:

1{name: Alice}

اما این ورودی به‌صورت پیش‌فرض به همان فیلد متصل نمی‌شود:

1{NAME: Alice}

اگر فیلد دیگری برای آن وجود نداشته باشد، این member ناشناخته محسوب می‌شود و به‌صورت پیش‌فرض نادیده گرفته می‌شود؛ مگر اینکه رد کردن فیلدهای ناشناخته را فعال کرده باشیم.

برای حفظ رفتار قدیمی:

1err := jsonv2.Unmarshal( 2 data, 3 &user, 4 jsonv2.MatchCaseInsensitiveNames(true), 5)

۵.۴. فیلدهای ناشناخته

در هر دو نسخه، هنگام Decode کردن JSON در یک struct، فیلدهای ناشناخته به‌صورت پیش‌فرض نادیده گرفته می‌شوند.

1{ 2 name: Alice, 3 department: Engineering 4}

اگر struct فقط فیلد Name را داشته باشد، مقدار department به‌طور پیش‌فرض نادیده گرفته می‌شود.

در v2 می‌توانیم این رفتار را سخت‌گیرانه کنیم:

1err := jsonv2.Unmarshal( 2 data, 3 &user, 4 jsonv2.RejectUnknownMembers(true), 5)

این گزینه برای فایل‌های configuration، قراردادهای داخلی سرویس‌ها و endpointهایی که باید ورودی دقیق داشته باشند مفید است. البته برای تمام APIهای عمومی لزوماً انتخاب درستی نیست؛ گاهی نادیده گرفتن فیلدهای جدید به سازگاری کلاینت و سرور کمک می‌کند.

۵.۵. UTF-8 نامعتبر و کلیدهای تکراری

v2 به‌صورت پیش‌فرض UTF-8 نامعتبر و نام‌های تکراری در یک object را رد می‌کند.

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

در بسیاری از مرزهای ورودی، رد کردن داده مبهم انتخاب امن‌تری از حدس زدن منظور فرستنده است.

۶. پکیج encoding/json/jsontext؛ کار مستقیم با JSON

پکیج دوم encoding/json/jsontext است. این پکیج به‌جای تبدیل مستقیم JSON به struct، روی خود Syntax فرمت تمرکز دارد.

مهم‌ترین abstractionهای آن عبارت‌اند از:

  • Value: یک مقدار کامل JSON که به شکل یک نوع مبتنی بر byte slice نمایش داده می‌شود.
  • Token: نمایش opaque از یک token در JSON.
  • Encoder: برای نوشتن مقدارها و tokenهای JSON.
  • Decoder: برای خواندن مقدارها و tokenهای JSON.

این پکیج زمانی مفید است که بخواهیم ساختار یک سند JSON را بررسی یا تبدیل کنیم، بدون اینکه ابتدا کل سند را به یک مدل Go نگاشت کنیم.

۶.۱. خواندن یک Token

1package main 2 3import ( 4 fmt 5 strings 6 7 encoding/json/jsontext 8) 9 10func main() { 11 input := `{name:Alice,active:true}` 12 13 decoder := jsontext.NewDecoder(strings.NewReader(input)) 14 15 token, err := decoder.ReadToken() 16 if err != nil { 17 panic(err) 18 } 19 20 fmt.Println(token) 21}

Decoder توکن بعدی را از ورودی می‌خواند. این مدل برای ساخت parserهای سفارشی، ابزارهای بررسی JSON و transformationهایی مناسب است که باید با ساختار سند کار کنند، نه با یک struct از پیش تعریف‌شده.

نوع Token عمداً opaque است. یعنی به‌جای وابستگی به جزئیات داخلی آن، باید از constructorها و accessorهای ارائه‌شده استفاده کنیم.

۶.۲. نوشتن یک JSON Value

1package main 2 3import ( 4 os 5 6 encoding/json/jsontext 7) 8 9func main() { 10 encoder := jsontext.NewEncoder(os.Stdout) 11 12 value := jsontext.Value(`{name:Alice,active:true}`) 13 14 if err := encoder.WriteValue(value); err != nil { 15 panic(err) 16 } 17}

متد WriteValue یک مقدار کامل JSON را می‌نویسد. Encoder وضعیت ساختاری لازم را نگه می‌دارد تا توالی tokenها و valueهایی که نوشته می‌شوند، یک متن JSON معتبر بسازند.

در سناریوهای پیشرفته‌تر می‌توانیم tokenها را به‌صورت جداگانه بنویسیم و JSON را تدریجی تولید کنیم.

۶.۳. تفاوت آن با Decoder قدیمی چیست؟

اینکه یک Decoder از io.Reader استفاده کند، به‌تنهایی به معنی پردازش کامل و تدریجی tokenها نیست.

jsontext.Decoder با هدف خواندن تدریجی tokenها و valueهای JSON طراحی شده است. برنامه می‌تواند بخش بعدی ورودی را مصرف کند، بدون اینکه لازم باشد از ابتدا کل سند را به یک مقدار Go تبدیل کند.

این قابلیت برای سندهای بزرگ، ابزارهای تبدیل JSON و برنامه‌هایی که به کنترل بیشتری روی مصرف حافظه نیاز دارند مفید است.

البته Streaming به‌صورت خودکار تضمین نمی‌کند که مصرف حافظه برنامه همیشه کمتر شود. ممکن است خود برنامه داده‌های خوانده‌شده را نگه دارد یا ساختارهای بزرگی بسازد. مزیت اصلی این است که API برای هر عملیات سفارشی مجبورمان نمی‌کند ابتدا کل مقدار JSON را در حافظه داشته باشیم.

۷. اینترفیس‌های جدید برای Serialization سفارشی

اینترفیس‌های قدیمی همچنان پشتیبانی می‌شوند:

1type Marshaler interface { 2 MarshalJSON() ([]byte, error) 3} 4 5type Unmarshaler interface { 6 UnmarshalJSON([]byte) error 7}

اما v2 دو مسیر جدید مبتنی بر encoder و decoder معرفی می‌کند:

1type MarshalerTo interface { 2 MarshalJSONTo(*jsontext.Encoder) error 3} 4 5type UnmarshalerFrom interface { 6 UnmarshalJSONFrom(*jsontext.Decoder) error 7}

این متدها اجازه می‌دهند نوع سفارشی مستقیماً با encoder و decoder کار کند.

۷.۱. نمونه‌ای از Marshaler جدید

فرض کنیم یک نوع شناسه داریم که باید در JSON به شکل string نمایش داده شود:

1package main 2 3import encoding/json/jsontext 4 5type UserID int64 6 7func (id UserID) MarshalJSONTo( 8 enc *jsontext.Encoder, 9) error { 10 value := jsontext.Value(`user-42`) 11 return enc.WriteValue(value) 12}

این مثال برای نشان دادن شکل API از یک مقدار ثابت استفاده می‌کند. در کد واقعی باید مقدار شناسه را به رشته تبدیل کنیم و آن را با روش معتبر و امن در قالب JSON بنویسیم. ساختن JSON با چسباندن رشته‌های ورودی به یکدیگر، مخصوصاً وقتی داده از بیرون می‌آید، کار درستی نیست.

تفاوت اصلی این است که متد به‌جای ساختن و برگرداندن یک []byte، خروجی را از طریق encoder می‌نویسد.

۷.۲. نمونه‌ای از Unmarshaler جدید

1type CustomValue struct { 2 Name string 3} 4 5func (v *CustomValue) UnmarshalJSONFrom( 6 dec *jsontext.Decoder, 7) error { 8 value, err := dec.ReadValue() 9 if err != nil { 10 return err 11 } 12 13 // مقدار را مطابق قواعد نوع خود بررسی و تفسیر کنید. 14 _ = value 15 16 return nil 17}

این کد یک طرح ساده از API است، نه یک پیاده‌سازی کامل. در برنامه واقعی باید شکل JSON ورودی اعتبارسنجی شود و فیلدهای receiver مقداردهی شوند.

مزیت اصلی این است که متد سفارشی decoder دریافت می‌کند، نه یک byte slice از پیش آماده‌شده. بنابراین امکان پردازش تدریجی و دسترسی به تنظیمات decoder وجود دارد.

۷.۳. انتقال Options به پیاده‌سازی سفارشی

در API قدیمی، وقتی Decode وارد متد سفارشی UnmarshalJSON می‌شد، تنظیمات فراخواننده لزوماً در دسترس آن پیاده‌سازی نبود.

در API جدید، encoder و decoder امکان دسترسی به Options را فراهم می‌کنند. در نتیجه، پیاده‌سازی سفارشی می‌تواند با همان تنظیماتی کار کند که عملیات اصلی با آن‌ها اجرا شده است.

این موضوع برای توسعه‌دهنده‌هایی که کتابخانه reusable می‌نویسند مهم است. نوع سفارشی نباید صرفاً به خاطر داشتن منطق serialization اختصاصی، مجبور شود بخشی از تنظیمات فراخواننده را نادیده بگیرد.

۸. Options؛ کنترل بیشتر با API یکدست‌تر

در v2، توابع اصلی می‌توانند چند Option دریافت کنند:

1data, err := json.Marshal( 2 user, 3 json.Deterministic(true), 4)

می‌توان چند Option را با هم ترکیب کرد. اگر چند گزینه یک رفتار واحد را تنظیم کنند، گزینه‌ای که دیرتر داده شده است اولویت دارد.

چند نمونه از گزینه‌های کاربردی:

Optionکاربرد
Deterministic(true)درخواست خروجی deterministic
RejectUnknownMembers(true)رد کردن memberهای ناشناخته هنگام Decode در struct
MatchCaseInsensitiveNames(true)فعال کردن تطبیق case-insensitive
FormatNilSliceAsNull(true)بازگرداندن رفتار v1 برای nil slice
FormatNilMapAsNull(true)بازگرداندن رفتار v1 برای nil map
StringifyNumbers(true)کنترل تبدیل عددها به نمایش string

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

نکته مهم این است که رفتارهای مختلف به‌صورت صریح و در محل فراخوانی قابل تنظیم‌اند. این موضوع برای کتابخانه‌هایی که باید از چند قرارداد JSON متفاوت پشتیبانی کنند، کاربردی است.

۹. کارایی؛ واقعاً چه چیزی سریع‌تر شده است؟

نتایج منتشرشده از سوی تیم Go نشان می‌دهد عملکرد Marshal در مجموع نزدیک به v1 است و بسته به workload می‌تواند کمی سریع‌تر یا کندتر باشد. عملکرد Unmarshal در بسیاری از سناریوها بهتر شده و در برخی benchmarkها بهبودهایی تا حدود ۱۰ برابر گزارش شده است. نوع‌های سفارشی هم می‌توانند از اینترفیس‌های جدید و پردازش جریانی سود قابل‌توجهی ببرند.

عبارت «تا ۱۰ برابر» مهم است. این عدد به بهترین بهبود مشاهده‌شده در بعضی benchmarkها اشاره دارد، نه اینکه هر برنامه‌ای با تغییر import path ده برابر سریع‌تر شود.

۹.۱. چرا Unmarshal می‌تواند سریع‌تر باشد؟

جداسازی Syntax از Semantics به پیاده‌سازی اجازه می‌دهد بعضی کارها را کارآمدتر انجام دهد و از پردازش تکراری جلوگیری کند.

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

اما در UnmarshalJSONFrom، پیاده‌سازی سفارشی می‌تواند از طریق decoder داده را مصرف کند. این کار در بعضی سناریوها از Parse کامل و تکراری جلوگیری می‌کند.

۹.۲. Unmarshalerهای recursive می‌توانند پرهزینه باشند

فرض کنید یک نوع سفارشی برای Decode کردن بخش‌های تو در تو، بارها json.Unmarshal را صدا می‌زند. در چنین الگویی ممکن است بخش‌های هم‌پوشان از سند بارها Parse شوند.

در توضیحات تیم Go به یک نمونه واقعی مرتبط با Kubernetes OpenAPI اشاره شده است که در آن، تغییر از الگوی recursive مبتنی بر UnmarshalJSON به UnmarshalJSONFrom بهبود عملکرد بسیار بزرگی ایجاد کرده است.

این مثال نشان می‌دهد benchmark ساده‌ای که فقط چند struct معمولی را بررسی می‌کند، لزوماً مزیت API جدید را نشان نمی‌دهد. بهتر است داده‌ها و custom typeهای واقعی برنامه را هم وارد تست کنیم.

۹.۳. Benchmark مخصوص workload خودتان بنویسید

1func BenchmarkUnmarshalV1(b *testing.B) { 2 data := []byte(`{id:42,name:Alice}`) 3 b.ReportAllocs() 4 5 for i := 0; i < b.N; i++ { 6 var user User 7 if err := jsonv1.Unmarshal(data, &user); err != nil { 8 b.Fatal(err) 9 } 10 } 11} 12 13func BenchmarkUnmarshalV2(b *testing.B) { 14 data := []byte(`{id:42,name:Alice}`) 15 b.ReportAllocs() 16 17 for i := 0; i < b.N; i++ { 18 var user User 19 if err := jsonv2.Unmarshal(data, &user); err != nil { 20 b.Fatal(err) 21 } 22 } 23}

اجرا:

1go test -bench=Unmarshal -benchmem -count=5

به این معیارها توجه کنید:

  • ns/op: زمان اجرای هر عملیات
  • B/op: تعداد بایت تخصیص‌یافته در هر عملیات
  • allocs/op: تعداد allocationها در هر عملیات

برای مقایسه معتبر، نسخه Go، سخت‌افزار، داده ورودی و شرایط benchmark باید یکسان باشند. ابزارهایی مثل benchstat هم می‌توانند نتایج اجرای چندباره را مقایسه کنند.

صرفاً تغییر import path را معادل بهبود عملکرد در نظر نگیرید. نتیجه به شکل داده، نوع مقصد، متدهای سفارشی و نحوه استفاده برنامه از مقدارهای Decode شده بستگی دارد.

۱۰. سازگاری v1 و v2 با یکدیگر

ممکن است یک dependency از نوعی استفاده کند که فقط متد قدیمی MarshalJSON را پیاده‌سازی کرده است. برنامه‌ای که از encoding/json/v2 استفاده می‌کند همچنان می‌تواند با چنین نوعی کار کند.

از طرف دیگر، یک نوع می‌تواند متد جدید MarshalJSONTo را پیاده‌سازی کند و در کدی که از API قدیمی encoding/json استفاده می‌کند هم مورد استفاده قرار بگیرد.

پکیج قدیمی بر پایه پیاده‌سازی جدید و با Optionهای سازگاری ساخته شده است. بنابراین لازم نیست همه پروژه‌ها در یک زمان مهاجرت کنند.

اگر یک سرویس قدیمی دارید که رفتار JSON آن تثبیت شده و تست‌های قابل‌اعتمادی دارد، ضرورتی ندارد صرفاً به خاطر جدید بودن v2 آن را فوراً تغییر دهید.

۱۱. چطور یک پروژه موجود را به v2 مهاجرت دهیم؟

در ساده‌ترین حالت، تغییر import path کار سختی نیست:

1import json encoding/json/v2

فراخوانی‌های پایه مثل json.Marshal و json.Unmarshal هم آشنا هستند. اما ریسک اصلی مهاجرت، تغییر رفتار است؛ نه تغییر Syntax کد.

ممکن است پروژه شما به یکی از این موارد وابسته باشد:

  • nil slice باید همچنان به null تبدیل شود.
  • بعضی کلاینت‌ها نام فیلدها را با حروف متفاوت می‌فرستند.
  • داده ورودی ممکن است member تکراری داشته باشد.
  • custom unmarshaler به API قدیمی مبتنی بر byte slice وابسته است.
  • کلاینت‌ها بین null و آرایه خالی تفاوت قائل‌اند.
  • struct tagها بر اساس رفتار قدیمی omitempty نوشته شده‌اند.

پس بهتر است مهاجرت را یک تغییر رفتاری در نظر بگیرید، نه صرفاً مرتب‌سازی importها.

۱۱.۱. قبل از مهاجرت تست بنویسید

قبل از تغییر پکیج، قراردادهای JSON مهم پروژه را با تست پوشش دهید:

  • nil و empty slice
  • nil و empty map
  • مقدارهای عددی صفر و boolean برابر false
  • فیلدهای حذف‌شده از ورودی
  • فیلدهای ناشناخته
  • تفاوت حروف بزرگ و کوچک در نام memberها
  • memberهای تکراری
  • UTF-8 نامعتبر
  • custom marshaler و unmarshaler
  • payloadهای تو در تو و بزرگ

اگر خروجی دقیق JSON بخشی از قرارداد خارجی سرویس است، Golden Test هم می‌تواند مفید باشد.

۱۱.۲. ابتدا با رفتار سازگار با v1 مهاجرت کنید

برای یک پروژه بزرگ، لازم نیست تمام رفتارها را یک‌باره تغییر دهید. v2 گزینه DefaultOptionsV1 را ارائه می‌کند تا رفتارهای سازگار با نسخه قدیمی را فعال کنید:

1import ( 2 jsonv1 encoding/json 3 jsonv2 encoding/json/v2 4) 5 6data, err := jsonv2.Marshal( 7 user, 8 jsonv1.DefaultOptionsV1(), 9) 10if err != nil { 11 return err 12}

با این روش می‌توان API جدید را به کار گرفت، در حالی که رفتارهای قبلی تا حد زیادی حفظ می‌شوند.

بعد از آن می‌توانید رفتارهای v2 را مرحله‌به‌مرحله فعال کنید. چون Optionهای بعدی اولویت دارند، می‌توان یک رفتار خاص را جداگانه تغییر داد:

1data, err := jsonv2.Marshal( 2 user, 3 jsonv1.DefaultOptionsV1(), 4 jsonv2.FormatNilSliceAsNull(false), 5)

این روش کمک می‌کند اثر هر تغییر را جداگانه بررسی کنیم.

۱۱.۳. تفاوت‌ها را در پروژه واقعی شناسایی کنید

راهنمای رسمی مهاجرت، ابزار jsonsplit را هم برای بررسی تفاوت‌های رفتاری v1 و v2 معرفی می‌کند. در پروژه‌ای با تعداد زیادی محل استفاده از JSON، چنین ابزاری می‌تواند به شناسایی بخش‌هایی کمک کند که مهاجرت روی آن‌ها اثر می‌گذارد.

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

۱۲. چه زمانی مستقیماً از jsontext استفاده کنیم؟

برای بیشتر پروژه‌ها بهتر است از encoding/json/v2 شروع کنیم. این پکیج نگاشت سطح بالای JSON به نوع‌های Go را انجام می‌دهد و برای handlerهای معمول HTTP و DTOها انتخاب طبیعی‌تری است.

استفاده مستقیم از encoding/json/jsontext در این موارد منطقی است:

  • ساخت parser سفارشی
  • خواندن یا نوشتن JSON به شکل token به token
  • تبدیل JSON بدون نگاشت کل سند به struct
  • پیاده‌سازی serialization جریانی سفارشی
  • بررسی جزئیات Syntax یک سند
  • ساخت ابزارهایی که مستقل از مدل‌های برنامه با JSON کار می‌کنند

برای یک endpoint معمولی نیازی نیست وارد لایه پایین‌تر شویم. اگر فقط می‌خواهیم body یک درخواست را در DTO بخوانیم، API سطح بالای v2 کافی است.

بهتر است پایین‌ترین سطحی را انتخاب کنیم که واقعاً مسئله را حل می‌کند. کنترل بیشتر زمانی ارزش دارد که به آن نیاز داشته باشیم.

۱۳. یک مثال کاربردی: HTTP Handler با v2

1package main 2 3import ( 4 net/http 5 6 json encoding/json/v2 7) 8 9type CreateUserRequest struct { 10 Name string `json:name` 11 Age int `json:age` 12} 13 14type CreateUserResponse struct { 15 ID int64 `json:id` 16 Name string `json:name` 17} 18 19func createUser(w http.ResponseWriter, r *http.Request) { 20 defer r.Body.Close() 21 22 var req CreateUserRequest 23 24 if err := json.UnmarshalRead( 25 r.Body, 26 &req, 27 json.RejectUnknownMembers(true), 28 ); err != nil { 29 http.Error(w, invalid request body, http.StatusBadRequest) 30 return 31 } 32 33 // منطق ساخت کاربر در این قسمت اجرا می‌شود. 34 response := CreateUserResponse{ 35 ID: 42, 36 Name: req.Name, 37 } 38 39 w.Header().Set(Content-Type, application/json) 40 41 if err := json.MarshalWrite(w, response); err != nil { 42 http.Error(w, could not encode response, http.StatusInternalServerError) 43 return 44 } 45}

در این مثال، body مستقیماً از io.Reader خوانده می‌شود و پاسخ هم از طریق io.Writer نوشته می‌شود. همچنین با RejectUnknownMembers(true) فیلدهای ناشناخته ورودی رد می‌شوند.

در یک handler واقعی باید محدودیت اندازه body، اعتبارسنجی مقادیر، مدیریت خطا و سیاست مربوط به فیلدهای ناشناخته را هم متناسب با قرارداد API در نظر گرفت. صرف استفاده از v2 به معنی امن شدن خودکار endpoint نیست.

۱۴. تأثیر این تغییر برای توسعه‌دهنده‌های Backend

شاید در نگاه اول این تغییر فقط به کتابخانه JSON مربوط باشد، اما JSON یکی از مرزهای اصلی ورود و خروج داده در سرویس‌های بک‌اند است.

قرارداد API دقیق‌تر: تطبیق case-sensitive نام فیلدها، رد کردن کلیدهای تکراری و اعتبارسنجی UTF-8 باعث می‌شوند رفتار سرویس در برابر ورودی‌های مبهم مشخص‌تر باشد.

کنترل بهتر روی پردازش: APIهای سطح پایین‌تر امکان پردازش تدریجی tokenها را فراهم می‌کنند. این قابلیت برای ابزارها و workloadهایی که با سندهای بزرگ سروکار دارند اهمیت دارد، هرچند نتیجه نهایی به نحوه مصرف داده در خود برنامه هم بستگی دارد.

سفارشی‌سازی بهتر: اینترفیس‌های جدید به نوع‌های سفارشی اجازه می‌دهند با encoder و decoder کار کنند و در بعضی سناریوها از پردازش byte slice کامل اجتناب کنند.

مهاجرت قابل‌کنترل‌تر: وجود Optionهای سازگاری باعث می‌شود تیم‌ها مجبور نباشند تمام رفتارهای قدیمی را یک‌باره کنار بگذارند.

۱۵. جمع‌بندی

encoding/json/v2 صرفاً نسخه‌ای با چند تابع بیشتر نیست. تغییر اصلی در طراحی و تفکیک مسئولیت‌هاست.

با جدا شدن پردازش Syntax در encoding/json/jsontext از نگاشت داده‌ها در encoding/json/v2، مسیر بهتری برای توسعه APIهای سفارشی و پردازش جریانی فراهم شده است.

از طرف دیگر، رفتارهای پیش‌فرض جدید مثل رد کردن UTF-8 نامعتبر و نام‌های تکراری، تطبیق case-sensitive و تغییر معنای omitempty باعث می‌شوند قرارداد JSON صریح‌تر شود. در مقابل، همین تغییرها می‌توانند روی سازگاری پروژه‌های موجود اثر بگذارند.

برای استفاده عملی:

  1. تفاوت‌های رفتاری v1 و v2 را در مدل‌های اصلی پروژه بررسی کنید.
  2. برای ورودی و خروجی APIهای مهم تست قرارداد بنویسید.
  3. اگر پروژه بزرگ است، مهاجرت را با DefaultOptionsV1 شروع کنید.
  4. رفتارهای جدید را به‌صورت مرحله‌ای فعال کنید.
  5. برای تصمیم‌گیری درباره Performance، workload واقعی خودتان را benchmark کنید.
  6. فقط وقتی به کنترل سطح پایین نیاز دارید مستقیماً سراغ jsontext بروید.

لازم نیست همه پروژه‌ها همین امروز مهاجرت کنند. اما آشنایی با این API جدید، به‌خصوص برای کسانی که روی سرویس‌های پرترافیک، کتابخانه‌های عمومی یا ابزارهای زیرساختی Go کار می‌کنند، ارزشمند است.

یک نکته برای مهندسانی که روی Deployment کار می‌کنند

کیفیت یک سرویس Production فقط به کد و کتابخانه‌های آن محدود نمی‌شود. بعد از اینکه کد را بهینه و قراردادهای ورودی و خروجی را دقیق کردید، هنوز باید بتوانید نسخه جدید را با ریسک کنترل‌شده منتشر کنید، وضعیت آن را ببینید و در صورت نیاز سریع Rollback کنید.

این بخشی از مسئله‌ای است که Phelix روی آن تمرکز دارد: مدیریت چرخه Build و Deploy اپلیکیشن‌های Go و Rust، بدون اینکه برای شروع مجبور باشید زیرساخت Kubernetes راه‌اندازی کنید.

اگر روی سرویس‌های Go کار می‌کنید و به دنبال workflow ساده‌تری برای انتشار نسخه‌ها، استراتژی‌هایی مثل Blue-Green و Rollback هستید، نگاهی به Phelix بیندازید. هدفش جایگزین کردن معماری یا ابزارهای فعلی شما نیست؛ می‌خواهد بخشی از glue و کارهای تکراری اطراف deployment را کمتر کند.

GoGolangJSONBackend EngineeringPerformanceStandard LibraryAPI Designگولنگگو
JSON در Go وارد مرحله تازه‌ای می‌شود: encoding/json/v2