JSON(JavaScript Object Notation)是一种人类可读的数据和文件格式,常用于交换数据,尤其是在 Web 应用中。因此,它在 Elm 语言中占有特殊地位也就不足为奇了。
Elm 提供了核心模块Json.Decode和Json.Encode,用来解析和写入 JSON 数据。JSON 解码器是一种_声明_预期数据类型以及数据应映射到哪种数据结构的方式。
让我们从这些模块中导入一些别名和类型:
import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)
把解码器和一个 JSON 字符串传给Decode.decodeString : Decoder a -> String -> Result Error a,如果字符串符合解码器的规范就会成功,否则就会失败。在定义解码器时无法检查原始的 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 的数据格式,用于表示地理要素,例如用 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来运行,也可以通过端口把它直接发送给 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:用户、拉取请求、评论,后端很快就写完了。 下面是你向服务器请求某个拉取请求的评论列表时,服务器返回的数据示例:
[
{
"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 中关于拉取请求评审评论的端点。 为了简单起见,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_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,让它能解码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解码就行。