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

جمعبندی: پاکت پاسخ، قالب خطا، نمایش شناسه و زمان، و معناشناسی null در برابر غایب را در قرارداد قفل کنید، پیش از بحث بر سر جزئیات صفحهبندی و نامگذاری؛ همیشه سطح بالا را در پاکت data بپیچید تا صفحهبندی و شناسه ردیابی بعداً بدون شکستن فراخوانهای موجود اضافه شوند.
در طراحی API پاسخ استاندارد وجود ندارد، اما تصمیمهای «سخت پسگرفتنی» وجود دارند که بهتر است پیش از نوشتن نخستین خط کد دربارهشان فکر کنید.
۱. پاکت پاسخ یا نه
سه سبک:
// Style A: bare response
{ "id": 1, "name": "张三" }
// Style B: data envelope
{ "data": { "id": 1, "name": "张三" } }
// Style C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| سبک | مزیتها | عیبها |
|---|---|---|
| پاسخ خالی | سادهترین، یک لایه کمتر برای فرانتاند | نمیتواند فراداده ضمیمه کند؛ آرایه سطح بالا ریسک hijacking و تکامل دارد |
| پاکت داده | افزودن meta / errors / links آسان |
یک سطح تودرتویی بیشتر، فرانتاند باید res.data.data بنویسد |
| داده + فراداده | بهترین قابلیت مشاهده | نیاز به توافق سخت بر اینکه کدام فیلدها در meta باشند |
توصیه: برای فهرستها و جزئیات یکسان از پاکت data استفاده کنید و هرگز سطح بالا را آرایه نکنید. آنگاه افزودن صفحهبندی یا شناسه ردیابی بعداً هرگز قرارداد را نمیشکند.
۲. قالب خطا: از RFC 9457 کپی کنید
اختراع ساختار خطای خودتان منبع عظیم هزینه همکاری است. RFC 9457 (که جایگزین RFC 7807 است) application/problem+json را تعریف میکند:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| فیلد | معنا | الزامی |
|---|---|---|
type |
URI پایدار نوع مشکل | توصیهشده |
title |
خلاصه کوتاه خوانا برای انسان | توصیهشده |
status |
کد وضعیت HTTP | توصیهشده |
detail |
توضیح مشخص این بار چه رخ داد | اختیاری |
instance |
نمونه منبعی که مشکل داشت | اختیاری |
| فیلدهای افزونه | فیلدهای کسبوکار دلخواه | اختیاری |
فیلدهای افزونه (مانند balance / required بالا) توسط مشخصات مجازند و فرانتاند میتواند برای پیامهای دقیق از آنها استفاده کند نه تجزیه متن detail.
۳. صفحهبندی: cursor بر offset پیروز میشود
// Offset pagination
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Cursor pagination
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| بُعد | صفحهبندی offset | صفحهبندی cursor |
|---|---|---|
| سختی پیادهسازی | کم (LIMIT/OFFSET) |
متوسط (کلید مرتبسازی پایدار لازم دارد) |
| عملکرد در صفحات عمیق | ضعیف (هر چه offset بزرگتر، کندتر) | خوب |
| وقتی داده تغییر میکند | تکراری یا گمشده | پایدار |
| پرش به صفحه | ممکن | ممکن نیست |
| نیاز به شمارش کل | شمارش اضافه لازم دارد | لازم ندارد |
توصیه: برای جدولهای مدیریتی (که پرش صفحه و مجموع میخواهند) صفحهبندی offset و برای فیدهای بینهایت کاربر-facing صفحهبندی cursor استفاده کنید.
۴. شناسهها و زمان
۴.۱ همیشه برای شناسهها از رشته استفاده کنید
استدلال در مقاله نوعها پوشش داده شده: در سمت جاوااسکریپت، مقادیر بالای 2^53 بیصدا دقت را از دست میدهند.
حتی اگر شناسه فعلی عدد صحیح کوچک خودافزا است، استفاده از رشته برای مهاجرت بعدی به شناسههای snowflake جا باز میکند.
{ "id": "1000000000000000001" }
۴.۲ برای زمان از رشتههای RFC 3339 استفاده کنید
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- از عدد مهر زمانی میلیثانیهای استفاده نکنید: خواندنش سخت است و واحد (ثانیه/میلیثانیه) مکرر اشتباه گرفته میشود؛
- منطقه زمانی را صریح مشخص کنید: یا با
Zیا با offset؛ هرگز «زمان محلی بدون منطقه» استفاده نکنید؛ - فیلدها را یکدست
xxxAtنامگذاری کنید تا در یک نگاه روشن باشد یک نقطه در زمان است.
۵. null و غایب
باید یکی را انتخاب و در مستندات بنویسید:
| رویکرد | بیان «بدون مقدار» | بیان «تغییر نده» |
|---|---|---|
| رویکرد A | مقدار فیلد null است |
حذف فیلد در درخواست |
| رویکرد B | فیلد وجود ندارد | حذف فیلد در درخواست |
مشکل رویکرد A این است که «حذف شده» و «بهصورت null فرستاده شده» در PATCH معنای متفاوتی دارند، که آسان اشتباه تفسیر میشود؛
مشکل رویکرد B این است که JSON.stringify فرانتاند فیلدهایی با مقدار undefined را حذف میکند، که تصادفاً با معناشناسی میخواند.
توصیه عملی:
- برای بدنه پاسخ از رویکرد A استفاده کنید (
nullصریحاً یعنی بدون مقدار و فرانتاند نیازی به تشخیصundefinedندارد)؛ - برای بدنه درخواست (بهویژه
PATCH) از رویکرد B، بههمراه Merge Patch یا JSON Patch برای صریح کردن معناشناسی.
۶. نامگذاری و ساختار فیلد
| تصمیم | انتخابهای رایج | هشدار |
|---|---|---|
| سبک نامگذاری | camelCase (اکوسیستم JS) / snake_case (Python، پایگاهداده) |
یکدست در پروژه، مخلوط نکنید |
| مقادیر enum | حروف کوچک با زیرخط، "pending_review" |
از عدد استفاده نکنید؛ خواندنش سخت است |
| فیلدهای بولی | isActive / enabled |
از status: 0/1 استفاده نکنید |
| فیلدهای آرایه | اسم جمع، items |
برای آرایه خالی [] برگردانید، هرگز null |
| عمق تودرتویی | بیش از ۳ سطح نه | عمیقتر، تقسیمش کنید یا پارامتر expand بدهید |
برگرداندن [] بهجای null برای آرایههای خالی بسیار مهم است: data.items.map(...) در فرانتاند بهخاطر null کرش نمیکند.
۷. راهبرد تکامل
| تغییر | شکستن سازگاری | چگونه برخورد کنیم |
|---|---|---|
| افزودن فیلد اختیاری | نه | مستقیم منتشر کنید |
| افزودن فیلد الزامی | بله | مقدار پیشفرض بدهید، یا با نسخه منتشر کنید |
| حذف فیلد | بله | اول منسوخ علامت بزنید، فراخوانها را زیر نظر بگیرید، سپس حذف |
| تغییر نوع فیلد | بله | نام فیلد جدید اضافه و در گذار همزمان بنویسید |
| تغییر مجموعه مقدار enum | بستگی دارد | کلاینت باید برای مقادیر ناشناخته جایگزین داشته باشد |
| تغییر معناشناسی فیلد | بله | آن را فیلد جدید بدانید |
سه قاعده انضباط:
- کلاینتها باید نسبت به فیلدهای ناشناخته تحمل داشته باشند (خودتان را با
additionalProperties: falseدر تنگنا نگذارید)؛ - کلاینتها باید شاخه جایگزین برای مقادیر enum ناشناخته داشته باشند؛
- فیلدهای منسوخ را دستکم یک نسخه اصلی نگه دارید و با
Deprecationدر سرآیند پاسخ یا مستندات علامت بزنید.
۸. یک مثال کامل
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
این پاسخ همه قراردادهای بالا را بازتاب میدهد: پاکت data، شناسههای رشتهای، مبلغها بهصورت رشته، مهر زمانی با منطقه زمانی،
null یعنی گمشده، آرایه خالی، صفحهبندی cursor، و meta برای اطلاعات قابلیت مشاهده.
۹. چکلیست
- [ ] سطح بالا آرایه نیست؛ یکسان از پاکت
dataاستفاده میشود؛ - [ ] پاسخهای خطا از RFC 9457 پیروی میکنند و
typeپایدار دارند؛ - [ ] شکل صفحهبندی با سناریوی کسبوکار میخواند؛
- [ ] شناسهها رشتهاند و زمان از RFC 3339 استفاده میکند؛
- [ ] معناشناسی
nullو غایب در مستندات نوشته شده است؛ - [ ] سبک نامگذاری در پروژه یکدست است و enumها از رشته حروف کوچک استفاده میکنند؛
- [ ] کلاینت برای فیلدهای ناشناخته و مقادیر enum ناشناخته جایگزین دارد؛
- [ ] تغییرات شکننده از نسخهبندی یا گذار همزماننویسی عبور میکنند.
پرسشهای متداول
چگونه یک API JSON قابل تکامل طراحی کنم؟
فهرستها و جزئیات را یکسان در پاکت data بپیچید و هرگز سطح بالا را آرایه نکنید، تا صفحهبندی و شناسه ردیابی بدون شکستن قرارداد اضافه شوند. از شناسه رشتهای و مهر زمانی RFC 3339 استفاده کنید و معناشناسی null در برابر غایب را در مستندات بنویسید. فیلدهای جدید را اختیاری و از نظر معنایی مستقل نگه دارید و به کلاینتها برای فیلدها و مقادیر enum ناشناخته جایگزین بدهید.
چرا قالب خطای سفارشی اختراع نکنیم؟
اختراع ساختار خطای خودتان منبع عظیم هزینه همکاری است. از application/problem+json در RFC 9457 کپی کنید، مشکل را با type پایدار شناسایی کنید و فیلدهای افزونه مانند balance و required را مجاز بگذارید، تا فرانتاند بتواند پیام دقیق بسازد بدون تجزیه متن detail.
آیا هر endpoint باید صفحهبندی cursor داشته باشد؟
نه. جدولهای مدیریتی پرش صفحه و مجموع میخواهند، پس offset مناسبتر است؛ فیدهای بینهایت کاربر-facing باید cursor داشته باشند که در صفحات عمیق پایدار میماند و هنگام تغییر داده از تکراری یا شکاف پرهیز میکند. بر اساس اینکه سناریو پرش صفحه و مجموع میخواهد یا نه انتخاب کنید.