JSON (JavaScript Object Notation) یک قالب داده و فایل قابلخواندن برای انسان است که معمولاً برای تبادل داده به کار میرود، بهویژه در برنامههای وب. پس جای تعجب نیست که جایگاه ویژهای در زبان Elm داشته باشد.
Elm ماژولهای اصلی Json.Decode و Json.Encode را برای تجزیه و نوشتن دادههای JSON فراهم میکند.
دیکودرهای JSON راهی برای اعلام این هستند که چه نوع دادهای انتظار میرود و داده باید به کدام ساختار داده نگاشت شود.
بیایید چند نام مستعار و نوع را از این ماژولها وارد کنیم:
import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)
استفاده از Decode.decodeString : Decoder a -> String -> Result Error a همراه با یک دیکودر و یک رشتهی JSON یا موفق میشود، اگر رشته با مشخصات آن دیکودر مطابقت داشته باشد، یا در غیر این صورت شکست میخورد.
هنگام تعریف دیکودر هیچ راهی برای بررسی دادهی خام JSON وجود ندارد و این ممکن است برای کسی که از زبانهای امری میآید غیرمعمول به نظر برسد.
JSON تعداد کمی نوع دادهی اصلی دارد:
"hello world" کدگذاری میشودtrue یا false
34 یا 0.12
null
هر یک از اینها به یک تابع در Decode مربوط است:
Decode.decodeString Decode.string """\"hello\""""
--> Ok "hello"
Decode.decodeString Decode.string """null"""
--> Err ...
Decode.decodeString Decode.bool """true"""
--> Ok True
Decode.decodeString Decode.bool """0"""
--> Err ...
Decode.decodeString Decode.int """12"""
--> Ok 12
Decode.decodeString Decode.float """3.14"""
--> Ok 3.14
Decode.decodeString (Decode.null AnyValue) """null"""
--> Ok AnyValue
Decode.decodeString (Decode.null AnyValue) """true"""
--> Err ...
توجه کنید که Decode.null به شما اجازه میدهد تصمیم بگیرید null را در برنامهتان چگونه مدلسازی کنید؛ برای این کار باید یک مقدار دلخواه بهعنوان آرگومان بدهید (شاید ()، Nothing یا هر مقداری که برای برنامهتان مناسب است).
JSON همچنین دو ساختار داده برای گردآوری انواع دادهی اصلی تعریف میکند:
[0, null, "none", []]
{"id": 1, "is_admin": false, "more_info": {}}
Decode نیز توابعی برای دیکود کردن اینها ارائه میدهد:
Decode.decodeString (Decode.list Decode.int) """[1, 2, 3]"""
--> Ok [1, 2, 3]
Decode.decodeString (Decode.list Decode.int) """[1, 2, 3, "not an int"]"""
--> Err ...
Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": 71 }"""
--> Ok (Dict.fromList [("key1", 17), ("key2", 71)])
Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": "seventy-one" }"""
--> Err ...
این توابع، یعنی Decode.list : Decoder a -> Decoder (List a) و Decode.dict : Decoder a -> Decoder (Dict String a)، هر کدام یک دیکودر بهعنوان آرگومان میگیرند که انتظار میرود تکتک عناصر ساختار داده را دیکود کند.
اگر دیکودر آرگومان در دیکود کردن هر عنصری از یک آرایه یا شیء شکست بخورد، کل دیکودر شکست میخورد؛ این الگویی رایج است.
ترکیب دیکودرهای ساده و ساختن دیکودرهای پیچیدهتر، ایدهی اصلی این تکنیک است. در ادامه با کمک یک مثال چند تابع دیگر را معرفی میکنیم که میتوان با آنها دیکودرهایی برای دادههای پیچیده و واقعی ساخت.
برای تمرین ترکیب دیکودرهای پایه و تجزیهی دادههای پیچیدهی JSON، بیایید دیکودرهایی برای تجزیهی (زیرمجموعهای از) دادههای GeoJSON بسازیم. GeoJSON یک قالب دادهی تخصصی مبتنی بر JSON است که برای نمایش عوارض جغرافیایی، مانند مکانها، مسیرها یا ناحیهها، به کار میرود؛ این عوارض با مختصات جغرافیایی دوبعدی یا سهبعدی و همراه با سایر ویژگیها مشخص میشوند.
شیء Geometry در GeoJSON شیئی است که یک فیلد type با مقدار رشتهای دارد (۷ مقدار ممکن برای این نوع وجود دارد) و یک فیلد coordinates با مختصات جغرافیایی (که در ساختاری وابسته به نوع گردآوری میشوند).
برای مثال،
{
"type": "Point",
"coordinates": [127.831, 26.461]
}
یا
{
"type": "LineString",
"coordinates": [
[127.831, 26.461],
[127.829, 26.465],
[127.829, 26.469],
[127.83, 26.47]
]
}
یک شیء Geometry را میتوان در Elm با نوع زیر نمایش داد
type Geometry
= Point (List Float)
| LineString (List (List Float))
| ...
برای بررسی مقدار یک فیلد مشخص، میتوانیم از Decode.field : String -> Decoder a -> Decoder a استفاده کنیم که مقدار یک کلید مشخص از یک شیء را دیکود میکند.
decodePointCoordinates : Decoder (List Float)
decodePointCoordinates =
Decode.field "coordinates" (Decode.list Decode.float)
decodeLineStringCoordinates : Decoder (List (List Float))
decodeLineStringCoordinates =
Decode.field "coordinates" (Decode.list (Decode.list Decode.float))
برای تغییر مقداری که یک دیکودر با موفقیت تجزیه کرده است، میتوانیم از Decode.map : (a -> value) -> Decoder a -> Decoder value یا هر یک از همخانوادههایش، از Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value تا Decode.map8 استفاده کنیم.
در این مورد میخواهیم بر اساس مقدار فیلد type تصمیم بگیریم کدام دیکودر مختصات را به کار ببریم؛ این کار دقیقاً مناسب Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b و همراهانش Decode.succeed : a -> Decoder a و Decode.fail : String -> Decoder a است.
decodeGeometry : Decoder Geometry
decodeGeometry =
Decode.field "type" Decode.string
|> Decode.andThen
(\value ->
case value of
"Point" ->
decodePointCoordinates |> Decode.map Point
"LineString" ->
decodeLineStringCoordinates |> Decode.map LineString
_ ->
Decode.fail "Geometry not implemented yet, or invalid type value"
)
در اینجا توجه کنید که میتوانستیم از Decode.andThen برای اعتبارسنجی مختصات استفاده کنیم (بررسی اینکه مختصات دوتایی یا سهتایی میآیند و بررسی اینکه مقادیر مختصات از نظر جغرافیایی معنا دارند).
Decode.decodeString decodeGeometry """{"type": "Point", "coordinates": [127.831, 26.461]}"""
--> Ok (Point [127.831, 26.461])
Decode.decodeString decodeGeometry """{"type": "LineString", "coordinates": [127.831, 26.461]}"""
--> Err ...
یک شیء Feature در GeoJSON چیزی را نمایش میدهد که محدودهی مکانی دارد: یک فیلد type با مقدار "Feature"، یک فیلد geometry با یک شیء Geometry یا null، یک فیلد properties با یک شیء JSON دلخواه یا null، و یک فیلد اختیاری id با یک مقدار رشتهای یا عددی JSON دارد.
{
"type": "Feature",
"geometry": null,
"properties": null
}
یا
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
بیایید اول به فیلد id بپردازیم.
اگر وجود داشته باشد، باید رشتهها یا اعداد را در خود جای دهد؛ پس میتوانیم یا یک نوع سفارشی برای مقدارش معرفی کنیم، یا همیشه آن را بهصورت رشته ذخیره کنیم.
برای انتخاب میان دو دیکودر ممکن، میتوانیم از Decode.oneOf : List (Decoder a) -> Decoder a استفاده کنیم.
decodeFeatureId : Decoder String
decodeFeatureId =
Decode.oneOf
[ Decode.string
, Decode.int |> Decode.map String.fromInt
, Decode.float |> Decode.map String.fromFloat
]
|> Decode.field "id"
Decode.decodeString decodeFeatureId """{"id": "seventeen"}"""
--> Ok "seventeen"
Decode.decodeString decodeFeatureId """{"id": 17}"""
--> Ok "17"
Decode.decodeString decodeFeatureId """{"type": "Point"}"""
--> Er ...
برای بیان اینکه فیلد اختیاری است، میتوانستیم از همان تکنیک استفاده کنیم
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.oneOf
[ decodeFeatureId |> Decode.map Just
, Decode.succeed Nothing
]
اما میتوانستیم در عوض از Decode.maybe : Decoder a -> Decoder (Maybe a) استفاده کنیم
decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
Decode.maybe decodeFeatureId
Decode.decodeString decodeMaybeFeatureId """{"id": 17}"""
--> Ok (Just "17")
Decode.decodeString decodeMaybeFeatureId """{"type": "Point"}"""
--> Ok Nothing
توجه کنید که decodeMaybeFeatureId (یا هر دیکودری که با Decode.maybe پوشانده شده باشد) همیشه موفق میشود، چون همیشه میتواند به Ok Nothing بازگردد؛ هرچند دیکودری که از آن استفاده میکند ممکن است همچنان شکست بخورد
Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
--> Err ...
حالا بیایید روی فیلد geometry تمرکز کنیم که میتواند یک شیء Geometry یا null باشد.
میتوانستیم باز هم از Decode.oneOf استفاده کنیم، اما گزینهی بهتری هست: Decode.nullable : Decoder a -> Decoder (Maybe a).
decodeFeatureGeometry : Decoder (Maybe Geometry)
decodeFeatureGeometry =
Decode.nullable decodeGeometry
|> Decode.field "geometry"
Decode.decodeString decodeFeatureGeometry """{"geometry": {"type": "Point", "coordinates": [127.831, 26.461]}}"""
--> Ok (Just (Point [127.831, 26.461]))
Decode.decodeString decodeFeatureGeometry """{"geometry": null}"""
--> Ok Nothing
Decode.decodeString decodeFeatureGeometry """{"geometry": {}}"""
--> Err ...
Decode.maybe و Decode.nullable امضای یکسانی دارند، اما رفتارشان متفاوت است؛ چون نتیجهی OK Nothing در Decode.nullable فقط از مقدار مشخص null در JSON بهدست میآید.
در پایان، فیلد properties میتواند یک شیء JSON دلخواه یا null در خود داشته باشد که کار Decode.value : Decoder Value است.
decodeProperties : Decoder (Maybe (Dict String Value))
decodeProperties =
Decode.nullable (Decode.dict Decode.value)
|> Decode.field "properties"
Decode.decodeString decodeProperties """{"properties": null}"""
--> Ok Nothing
Decode.decodeString decodeProperties """{"properties": {"country": "Japan"}}"""
--> Ok (Just (Dict.fromList [("country", <internals>)]))
Decode.decodeString decodeProperties """{"properties": 17}"""
--> Err ...
توجه کنید که Value یک نوع مات است، یعنی نمیتوانید بهراحتی محتوای آن را بررسی کنید.
برای استفاده از آن، یا میتوانید یک دیکودر برایش بنویسید و آن را با Decode.decodeValue : Decoder a -> Value -> Result Error a اجرا کنید، یا آن را مستقیماً از طریق یک پورت به JavaScript بفرستید.
آمادهایم شیءهای Feature را تجزیه کنیم:
type alias Feature =
{ id : Maybe String
, geometry : Maybe Geometry
, properties : Maybe (Dict String Value)
}
decodeFeature : Decoder Feature
decodeFeature =
Decode.field "type" Decode.string
|> Decode.andThen
(\value ->
if value /= "Feature" then
Decode.fail "not a Feature"
else
Decode.map3 Feature
decodeMaybeFeatureId
decodeFeatureGeometry
decodeProperties
)
Decode.decodeString decodeFeature """{"type": "Feature", "geometry": null, "properties": null}"""
--> Ok { geometry = Nothing, id = Nothing, properties = Nothing }
"""
{
"type": "Feature",
"id": "0157",
"geometry": {
"type": "Point",
"coordinates": [127.831, 26.461]
},
"properties": {
"country": "Japan"
}
}
"""
|> Decode.decodeString decodeFeature
--> Ok { geometry = Just (Point [127.831,26.461]), id = Just "0157", properties = Just (Dict.fromList [("country",<internals>)]) }
انکودرها به شما اجازه میدهند با استفاده از تابع Encode.encode : Int -> Value -> String از مقادیر Elm، JSON معتبر بنویسید.
آرگومان اول میزان تورفتگی در نتیجهی نهایی را مشخص میکند و آرگومان دوم مقدار JSONی است که باید نوشته شود.
یک Value یا میتواند از Decode.value بهدست آید یا با یکی از انکودرها تولید شود:
Encode.encode 0 (Encode.string "hello")
--> "hello"
Encode.encode 0 (Encode.bool True)
--> "true"
Encode.encode 0 (Encode.int 12)
--> "12"
Encode.encode 0 (Encode.float 3.14)
--> "3.14"
Encode.encode 0 Encode.null
--> "null"
Encode.encode 0 (Encode.list Encode.int [1, 2, 3])
--> "[1,2,3]"
Encode.encode 4 (Encode.list Encode.int [1, 2, 3])
--> "[\n 1,\n 2,\n 3\n]"
Encode.encode 0 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
--> "{\"key1\":17,\"key2\":71}"
Encode.encode 4 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
--> "{\n \"key1\": 17,\n \"key2\": 71\n}"
و همچنین با Encode.object : List ( String, Value ) -> Value
Encode.object
[ ( "key1", Encode.int 17 )
, ( "key2", Encode.int 71 )
]
|> Encode.encode 0
--> "{\"key1\":17,\"key2\":71}"
بیایید انکودرهایی برای دیکودرهای GeoJSON که پیشتر تعریف کردیم بنویسیم
encodeGeometry : Geometry -> Value
encodeGeometry geometry =
case geometry of
Point coord ->
Encode.object
[ ( "type", Encode.string "Point" )
, ( "coordinates", Encode.list Encode.float coord )
]
LineString coord ->
Encode.object
[ ( "type", Encode.string "LineString" )
, ( "coordinates", Encode.list (Encode.list Encode.float) coord )
]
encodeFeature : Feature -> Value
encodeFeature feature =
let
maybeId =
case feature.id of
Nothing ->
[]
Just id ->
[ ( "id", Encode.string id ) ]
in
Encode.object
(maybeId
++ [ ( "type", Encode.string "Feature" )
, ( "geometry"
, case feature.geometry of
Nothing ->
Encode.null
Just geometry ->
encodeGeometry geometry
)
, ( "properties"
, case feature.properties of
Nothing ->
Encode.null
Just dict ->
Encode.dict identity identity dict
)
]
)
بهطور کلی، آزمون خوبی برای جفتهای دیکودر و انکودر شما این است که بررسی کنید رابطهی elmValue |> encoder |> decoder == elmValue برقرار است.
هرچه بیشتر دربارهی برنامهنویسی یاد میگیرید، بیشتر به این فکر میافتید که شاید این کار چیزی بیش از یک مُد زودگذر باشد. شاید حتی فرصتهای تجاری هم در آن باشد: مردم کد مینویسند، میخواهند آن را ذخیره کنند، به اشتراک بگذارند، با هم همکاری کنند و شاید حتی بازخورد بگیرند.
خیلی خب، بیایید این کار را بکنیم؛ بیایید سرویسی بسازیم که مردم را به کدنویسی تشویق کند: هی، هی، مردم، شروع کنید به استفاده از git! بیایید اسمش را بگذاریم... GitHup.
شما ساخت یک REST JSON API را شروع میکنید: کاربران، درخواستهای ادغام، نظرات؛ بکاند بهسرعت تمام میشود. در ادامه نمونهای از دادهای را میبینید که وقتی فهرست نظرات یک درخواست ادغام را از سرور میخواهید، برگردانده میشود:
[
{
"id": 256,
"pull_request_review_id": 42,
"user": {
"id": 101,
"login": "octodog",
"avatar_url": "https://githup.com/images/error/octodog_happy.gif",
"site_admin": false
},
"body": "Great stuff!",
"side": "RIGHT",
"_links": {
"self": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
},
"html": {
"href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1"
},
"pull_request": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1"
}
}
},
{
"id": 11,
"pull_request_review_id": null,
"user": {
"id": 2,
"login": "hexacat",
"name": "Alex Kate",
"avatar_url": "https://githup.com/images/error/hexacat_happy.gif",
"site_admin": true
},
"body": "Amazing stuff!",
"side": "LEFT",
"_links": {
"self": {
"href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
}
}
}
]
البته فرانتاند با Elm نوشته میشود، اما اول باید بفهمید این رمزگشاها و رمزگذارهای بدنام چه چیزی هستند.
جدا از این داستان طنز، الزامات این تمرین مستقیماً از نقطههای پایانی REST API گیتهاب برای نظرات بررسی درخواست ادغام گرفته شدهاند. برای سادهتر شدن کار، برخی فیلدها از شمای JSON حذف شدهاند، اما این تمرین تا حد ممکن به برنامههای دنیای واقعی نزدیک است.
چشم تیزبینتان متوجه شده است که هم نظرات و هم کاربران یک فیلد id دارند؛ جای خوبی برای شروع.
decodeId را طوری تعریف کنید که بتواند یک شیء JSON را با یک id عدد صحیح رمزگشایی کند.
Decode.decodeString decodeId """{id: 10}"""
--> Ok 10
در مثال بالا متوجه میشوید که همهی کاربران اسم ندارند.
decodeName را طوری تعریف کنید که اگر فیلدی به اسم name وجود داشت، رشتهی درون آن را رمزگشایی کند.
اسمی نیست؟ اشکالی ندارد، کافی است Nothing را برگردانید.
این رمزگشا هرگز نباید شکست بخورد.
Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
--> Ok (Just Otto)
Decode.decodeString decodeName """null"""
--> Ok Nothing
برای رمزگشایی کاربران تقریباً تا نیمهی راه رسیدهایم؛ بیایید تمامش کنیم.
decodeUser را طوری تعریف کنید که بتواند یک شیء JSON کاربر را رمزگشایی کند.
حتماً در decodeUser از decodeId و decodeName استفاده کنید.
Decode.decodeString decodeUser
"""
{
"id": 101,
"login": "octodog",
"avatar_url": "https://githup.com/images/error/octodog_happy.gif",
"site_admin": false
}
"""
--> Ok
--> { id = 101
--> , name = Nothing
--> , login = "octodog"
--> , avatarUrl = "https://githup.com/images/error/octodog_happy.gif
--> , siteAdmin = False
--> }
برخی نظرات در یک بررسی درخواست ادغام مستقل هستند و بنابراین pull_request_review_id ندارند.
اما مشخصات میگوید در چنین حالتی هنوز باید فیلد pull_request_review_id وجود داشته باشد، ولی مقدارش باید null باشد؛ همان Nothing فقیر.
decodePullRequestReviewId را طوری تعریف کنید که اگر فیلد pull_request_review_id وجود داشت، یک عدد صحیح را در آن رمزگشایی کند.
اگر عدد صحیحی وجود ندارد، باید یک null باشد؛ و اگر null وجود ندارد، رمزگشا باید شکست بخورد.
Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": 3}"""
--> Ok (Just 3)
Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": null}"""
--> Ok Nothing
Decode.decodeString decodePullRequestReviewId """{"id": 3}"""
--> Err ...
بعد نوبت این فیلد side است که ظاهر جالبی دارد.
در یک بررسی درخواست ادغام، میتوانید روی کدی که حذف شده است (سمت Left صفحه) یا روی کدی که اضافه شده است (سمت Right صفحه) نظر بگذارید.
decodeSide را طوری تعریف کنید که بتواند "LEFT" یا "RIGHT" را در فیلد side رمزگشایی کند و آن را به نوع متناظرش نگاشت کند.
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
هر نظر مجموعهای از پیوندها دارد که به نظر میرسد از نظری به نظر دیگر یکسان نیست.
decodeLinks را طوری تعریف کنید که بتواند مجموعهای از URLها را در فیلد _links رمزگشایی کند.
اسمها و تعداد پیوندها میتواند متفاوت باشد؛ مطمئن شوید که این موضوع را در نظر میگیرید.
خود پیوندها اشیاء JSON هستند و همیشه باید یک فیلد href داشته باشند.
Decode.decodeString decodeLinks """
{
"_links": {
"self": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" },
"html": { "href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" },
"pull_request": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1" }
}
}
"""
--> Ok
--> (Dict.fromList
--> [ ( "self", "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" )
--> , ( "html", "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" )
--> , ( "pull_request", "https://api.githup.com/repos/octodog/Hello-World/pulls/1" )
--> ]
--> )
تقریباً رسیدهایم؛ بیایید تمامش کنیم.
decodeComment و decodeComments را طوری تعریف کنید که بهترتیب بتوانند یک شیء JSON نظر یا فهرستی از نظرات را رمزگشایی کنند.
حتماً در decodeComment از همهی توابعی که تا اینجا تعریف کردهاید، بهجز decodeName، استفاده کنید.
مسلماً decodeComments هم باید از decodeComment استفاده کند.
در میان همهی اینها یک قابلیت دیگر کم است: توانایی فرستادن یک نظر تازه به سرور.
برای این کار باید برعکس یک رمزگشا عمل کنید؛ باید بتوانید یک Comment الم را به یک Value از نوع JSON تبدیل کنید.
encodeComment را طوری تعریف کنید که بتواند هر Comment معتبری را رمزگذاری کند.
ترتیب فیلدها اهمیتی ندارد، تا زمانی که رمزگذار بتواند مقداری تولید کند که decodeComment قادر به رمزگشایی آن باشد.