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要求你傳入一個任意的值作為引數(可能是()、Nothing,或任何適合你程式的東西),讓你決定要在程式中如何表示null。
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 為基礎的專門資料格式,用來表示地理特徵,例如位置、路徑或區域,並以 2D 或 3D 地理座標加上其他屬性來描述。
GeoJSON 的幾何物件是一個物件,裡面有一個值為字串的type欄位(共有 7 種可能的型態值),以及一個存放地理座標的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]
]
}
幾何物件在 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 ...
GeoJSON 的特徵物件代表某個在空間上有範圍的東西:它有一個值為"Feature"的type欄位、一個可以是幾何物件或null的geometry欄位、一個可以是任意 JSON 物件或null的properties欄位,以及一個選用的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欄位,它可以是幾何物件,也可以是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的型別簽名相同,但行為不同,因為Decode.nullable的OK Nothing結果只會來自特定的 JSON 值null。
最後,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來執行,或是透過 port 直接把它送到 JavaScript。
我們已經準備好要解析特徵物件了:
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:使用者、Pull Request、留言,後端很快就搞定了。 下面是當你向伺服器要求某個 Pull Request 的留言列表時,伺服器回傳的內容範例:
[
{
"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 來做,但首先你得搞清楚那些惡名昭彰的解碼器與編碼器到底在幹嘛。
撇開這段搞笑的故事不談,這個練習的需求直接取自 GitHub 的 REST API 中,用來處理 Pull Request 審查留言的端點。 為了簡化起見,我們從 JSON schema 中移除了一些欄位,但這已經非常貼近真實世界的應用了。
你銳利的目光已經發現,留言和使用者都有一個 id 欄位,這正是最好的起點。
定義 decodeId,讓它能解碼帶有整數 id 的 JSON 物件。
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 審查中有些留言是獨立存在的,因此沒有 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 欄位。
在 Pull Request 審查中,你可以對被移除的程式碼(畫面上的 Left 側)留言,也可以對新增的程式碼(畫面上的 Right 側)留言。
定義 decodeSide,讓它能解碼 side 欄位中的 "LEFT" 或 "RIGHT",並對應到相應的型別變體。
Decode.decodeString decodeSide """{"side": "LEFT"}"""
--> Ok Left
Decode.decodeString decodeSide """{"side": "middle?"}"""
--> Err ...
每個留言都有一組連結,而這些連結在不同留言之間似乎並不一致。
定義 decodeLinks,讓它能解碼 _links 欄位中的一組 URL。
連結的名稱和數量都可能不同,請確保你能處理這種情況。
這些連結本身是 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。
這整件事還缺一個功能:能夠把新的留言送到伺服器。
為此,你需要做出解碼器的相反,也就是能把 Elm 的 Comment 轉換成 JSON 的 Value。
定義 encodeComment,讓它能編碼任何有效的 Comment。
欄位的順序無所謂,只要編碼器產生的值能被 decodeComment 解碼就行。