轨道
/
Elm
Elm
/
练习
/
GitHup API
GitHup API

GitHup API

学习练习

简介

JSON

JSON

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:没有数据,即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)都要求传入一个解码器作为实参,由它负责解码该数据结构中的每一个元素。如果这个实参解码器无法解码数组或对象值中的任何元素,整个解码器就会失败,这是一种常见的模式。

把简单的解码器组合成更复杂的解码器,是这项技术的核心思想。下面我们通过一个例子,介绍更多可用来构建任意复杂的真实世界数据解码器的函数。

GeoJSON

为了练习组合基本解码器来解析复杂的 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>)]) }

JSON 编码器

编码器可以用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 来写,但首先你得弄清楚那些臭名昭著的解码器和编码器到底是怎么回事。

Note

抛开这个搞笑的故事不谈,本练习的需求直接取自 GitHub REST API 中关于拉取请求评审评论的端点。 为了简单起见,JSON schema 中删掉了一些字段,但这已经和真实世界的应用非常接近了。

1. 请给我 ID!

你的火眼金睛已经发现,评论和用户都有一个id字段,这正是绝佳的起点。

定义decodeId,让它能解码一个带有整数id的 JSON 对象。

Decode.decodeString decodeId """{id: 10}"""
    --> Ok 10

2. 我叫什么名字来着?

你注意到,在上面的示例里,并不是所有用户都有名字。

定义decodeName,让它能在name字段存在时解码其中的字符串。 没有名字?没关系,直接返回Nothing。 这个解码器永远不应该失败。

Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
    --> Ok (Just Otto)

Decode.decodeString decodeName """null"""
    --> Ok Nothing

3. 用户?我跟他不熟!

解码用户这件事我们已经完成一大半了,把它做完吧。

定义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
    -->   }

4. 我是一条坚强独立的评论

拉取请求评审中的有些评论是独立存在的,因此没有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 ...

5. 副业

接下来是这个看起来很有意思的side字段。 在拉取请求评审里,你可以对删掉的代码(屏幕的Left侧)发表评论,也可以对新增的代码(屏幕的Right侧)发表评论。

定义decodeSide,让它能解码side字段中的"LEFT"或"RIGHT",并把它对应到相应的类型变体上。

Decode.decodeString decodeSide """{"side": "LEFT"}"""
    --> Ok Left

Decode.decodeString decodeSide """{"side": "middle?"}"""
    --> Err ...

6. 链接的觉醒

每条评论都有一组链接,而且这些链接在不同评论之间似乎并不一致。

定义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" )
    -->       ]
    -->   )

7. 应声评论

就快到了,把它做完吧。

定义decodeComment和decodeComments,让它们分别能解码单个 JSON 评论对象或一组评论。 记得在decodeComment里用上前面定义的所有函数,decodeName除外。 当然,decodeComments也应该用上decodeComment。

8. 全部串联起来

这一切里还差一个功能:能把新评论发送到服务器。 为此,你需要做出解码器的反面,也就是能把 Elm 的Comment转换成 JSON 的Value。

定义encodeComment,让它能编码任何合法的Comment。 字段的顺序无所谓,只要编码器生成的值能被decodeComment解码就行。

通过 GitHub 编辑 链接将在新窗口或新标签页中打开
Elm Exercism

准备好开始 GitHup API 了吗?

注册 Exercism,借助 28 个概念110 个练习 和真人导师指导,学习并掌握 Elm,全部免费。