Elm和GraphQL的相识- 再见Json解码器!

首先

去年,GitHub发布了GraphQL的API,引起了广泛讨论。关于GraphQL的详细信息,请参阅这篇文章。最近,作为BaaS(后端即服务)的GraphQL服务器(Scaphold、Hasura、AWS Appsync等)也越来越多地被使用。

在JavaScript中,使用广泛的客户端库是Relay和Apollo。据说它们在大型项目中也有很多应用案例。我认为可以通过Elm的Ports功能来使用这些库,但如果不需要缓存、离线支持、订阅同步或其他高级功能,它们归根结底只是用来交换JSON数据的,因此也可以直接通过Elm中的Http模块调用GraphQL服务器来使用。

从 Elm 代码中,将变量嵌入到以字符串形式编写的 GraphQL 查询中,然后将其编码为 JSON 格式并发送到 GraphQL 服务器。

{"operationName":null,"variables":{},"query":"{\n  country(code: \"JP\") {\n    code\n    name\n  }\n}\n"}

将下面这种以JSON格式返回的字符串解码为Elm类型。

{"data":{"country":{"code":"JP","name":"Japan"}}}

遗憾的是,这种方法没有充分利用Elm和GraphQL各自的类型检查功能。而且,当规模变大并变得复杂时,很容易想象这种方法会出现问题。

GraphQL服务器和架构

这次我们以一个开放源代码的简单的GraphQL API服务器(Graphiql控制台和仓库)为例。下面是其定义的模式内容。

type Continent {
  code: String
  name: String
}

type Country {
  code: String
  name: String
  native: String
  phone: String
  continent: Continent
  currency: String
  languages: [Language]
  emoji: String
  emojiU: String
}

type Language {
  code: String
  name: String
  native: String
  rtl: Int
}

type Query {
  continents: [Continent]
  continent(code: String): Continent
  countries: [Country]
  country(code: String): Country
  languages: [Language]
  language(code: String): Language
}

在GraphQL中,根据类型(type)定义的部分被称为对象类型(object type),可以拥有多个字段。每个字段都有相应的返回类型进行定义。请查看详细信息。有点类似于Elm的类型定义。实际上,可以通过以下方式将其全部写成记录类型的别名。

type alias Continent =
    { code : Maybe String
    , name : Maybe String
    }


type alias Country =
    { code : Maybe String
    , name : Maybe String
    , native : Maybe String
    , phone : Maybe String
    , continent : Maybe Continent
    , currency : Maybe String
    , languages : Maybe (List (Maybe Language))
    , emoji : Maybe String
    , emojiU : Maybe String
    }


type alias Language =
    { code : Maybe String
    , name : Maybe String
    , native : Maybe String
    , rtl : Maybe Int
    }

{-- 実際にはこの部分はElmのレコード型としては使いません
type alias Query =
    { continents: Maybe (List (Maybe Continent))
    , continent: Maybe String -> Maybe Continent
    , countries: Maybe (List (Maybe Country))
    , country: Maybe String -> Maybe Country
    , languages: Maybe (List (Maybe Language))
    , language: String -> Maybe Language
    }
--}

嗯…或许可能性很大…这是由于先前的GraphQL模式定义了所有字段都可以为null。本来应该在模式上施加更多的限制,但如果无法更改模式,只能接受它。

Screen Shot 2018-12-03 at 0.08.53.png

在@dillonkearns的方法中,通过引入外部工具,可以利用GraphQL服务器的内省功能来解析GraphQL模式,并自动为Elm生成代码。生成的代码主要由用于解码GraphQL对象及其字段的函数组成。这保证了编写类型安全的代码。

准备环境

我之前已经准备了一个将parcel-bundler和Elm结合在一起的内容的样板文件,因此你可以使用那个。首先,通过npm安装一个可以生成Elm代码的工具,该工具可以从GraphQL服务器生成Elm代码。然后,您还需要安装所需的Elm包。

git clone git@github.com:kyasu1/elm-parcel-boilerplate.git
cd elm-parcel-boilerplate
npm install --save-dev @dillonkearns/elm-graphql
elm install dillonkearns/elm-graphql
elm install elm/json

从架构生成Elm代码

npx elm-graphql https://countries.trevorblades.com/ --base Country --output src/elm
Screen Shot 2018-12-03 at 0.37.42.png

由于本次是一个简单的架构,所以仅需要使用以下文件:

Country/Query.elmはスキーマ中のtype Queryで定義されたフィールドをデコードする関数の集まり

Country/Object.elmはスキーマ中で定義されたオブジェクトをElm上での表現するためのカスタム型の集まり

Country/Object/Continent.elmはスキーマ中のtype Continentで定義されたフィールドをデコードする関数の集まり

Country/Object/Country.elmはスキーマ中のtype Coutnryで定義されたフィールドをデコードする関数の集まり

Country/Object/Language.elmはスキーマ中のtype Languageで定義されたフィールドをデコードする関数の集まり

尝试编写Elm应用程序

首先,我们需要导入必要的模块。

import Country.Object
import Country.Object.Continent as Continent
import Country.Object.Country as Country
import Country.Object.Language as Language
import Country.Query as Query
import Graphql.Http
import Graphql.Operation exposing (RootQuery)
import Graphql.OptionalArgument exposing (OptionalArgument(..))
import Graphql.SelectionSet exposing (SelectionSet, succeed, with)

Graphql开头的模块中包含了用于构建和发送查询的辅助函数。

简单的查询

当查看先前的模式时,可以看到定义了一个名为country的查询,它以code作为参数并返回Country作为返回值。您可以通过指定国家代码来获取该国家的信息。

query {
    ....
  country(code: String): Country
  ....
}

作为结果,Country对象中定义了许多字段,但我们将在Elm中构建一个简单的查询(SelectionSet),仅获取其中的code和name字段。

用GraphQL查询的话,可以这样写。

query {
  country(code: "JP") {
    code
    name
  }
}

依据我的理解,SelectionSet 在 GraphQL 查询中指的是一组字段。查询对象(query object)包含了一个由 country 字段组成的 SelectionSet。而 country 对象又包含了由 code 和 name 两个字段组成的 SelectionSet。我们可以沿着这个层次逐步解析和构建 SelectionSet。

首先,我们需要定义最终想要的Elm类型。由于之前的模式可能返回null值,所以我们需要在所有字段上添加Maybe。

type alias Response =
    { countryRowMaybe : Maybe CountryRow
    }


type alias CountryRow =
    { code : Maybe String
    , name : Maybe String
    }


query : String -> SelectionSet Response RootQuery
query code =
    succeed Response
        |> with (Query.country (\arg -> { arg | code = Present code }) countrySelection)


countrySelection : SelectionSet CountryRow Country.Object.Country
countrySelection =
    succeed CountryRow
        |> with Country.code
        |> with Country.name

我好像在哪里看过这个。没错,它和NoRedInk/elm-json-decode-pipeline的语法一样。1

RootQuery表示GraphQL模式定义中的type query {}对象,以下的with指示获取并解码所需字段,并最终以Response类型返回。同样,Country.Object.Country表示type Country {}对象,在以下的with指示中只获取并解码所需字段,并最终以CountryRow类型返回。

Query.country这个查询接受一个参数来指定国家代码,但在GraphQL模式中却被定义为可以为null,因此需要用(\arg -> { arg | code = Present code })这种形式传递参数。如果传递identity参数,将被认为指定了null参数。

当实际将这个查询发送到服务器时,我们将使用GraphQL.Http。

execQuery : Cmd Msg
execQuery =
    query "JP"
        |> Graphql.Http.queryRequest "https://countries.trevorblades.com/"
        |> Graphql.Http.send GotRespponse

有点复杂的查询

在GraphQL中,您可以一次获取多个对象或相关对象。

query {
  continents {
    code
    name
  }
  countries {
    country {
    code
    name
    native
    phone
    continent {
      code
      name
    }
    currency
    languages {
      code
      name
      native
      rtl
    }
    emoji
    emojiU
  }
}

在这个查询中,同时获取大陆和国家的列表。构建用于发出这个查询的SelectionSet会如下所示。

type alias Response =
    { continents : List Continent
    , countries : List Country
    }

query : SelectionSet Response RootQuery
query =
    succeed Response
        |> with
            (Query.continents continentSelection
                |> SelectionSet.nonNullOrFail
                |> SelectionSet.nonNullElementsOrFail
            )
        |> with
            (Query.countries countrySelection
                |> SelectionSet.nonNullOrFail
                |> SelectionSet.nonNullElementsOrFail
            )


continentSelection : SelectionSet Continent Country.Object.Continent
continentSelection =
    succeed Continent
        |> with (Continent.code |> SelectionSet.nonNullOrFail)
        |> with (Continent.name |> SelectionSet.nonNullOrFail)


countrySelection : SelectionSet Country Country.Object.Country
countrySelection =
    succeed Country
        |> with
            (Country.code
                |> SelectionSet.nonNullOrFail
            )
        |> with
            (Country.name
                |> SelectionSet.nonNullOrFail
            )
        |> with Country.native
        |> with Country.phone
        |> with
            (Country.continent continentSelection
                |> SelectionSet.nonNullOrFail
            )
        |> with Country.currency
        |> with
            (Country.languages languageSelection
                |> SelectionSet.nonNullOrFail
                |> SelectionSet.nonNullElementsOrFail
            )
        |> with Country.emoji
        |> with (Country.emoji |> SelectionSet.withDefault "")


languageSelection : SelectionSet Language Country.Object.Language
languageSelection =
    succeed Language
        |> with
            (Language.code
                |> SelectionSet.nonNullOrFail
            )
        |> with Language.name
        |> with Language.native
        |> with Language.rtl

在这个地方,SelectionSet.nonNullOrFail和SelectionSet.nonNullElementsOrFail被添加到管道中,它们假设传递给Elm时,强制将允许为null的字段变为不为null的值并且移除了Maybe。如果返回null的情况发生,将会导致解码错误。

如果一个字段本来就不会为空,那在设计模式时应该将其定义为非空字段。但在公共模式等无法更改的情况下,可能会冒着风险使用。此外,如果可能为空且存在默认值的情况下,也可以通过添加SelectionSet.withDefault到管道来处理。这种情况不会引发错误。

由于我创建了一个使用这个模式的简单示例,所以请参考一下。

动态演示:http://kyasu1.github.io/
代码仓库:https://github.com/kyasu1/elm-graphql-example

最后

    • JSONデコーダを書いていく作業とSelectionSetの構築は似ています(というか同じ記法になるように合わせている)。前者でなんとなくデコーダを書いてコンパイルが通っても、フィールド名にタイポがあったり、自分の想定外のJSONが返されると実行時にデコードに失敗ます。こういったエラーの原因の追求には結構時間が取られたりします。一方、後者ではElmの型チェックによりコンパイル時にエラーが発生します。逆にコンパイルが通ればGraphQLスキーマに対しては実行時エラーが発生しないことを保証してくれます。

 

    • 冒頭にも書いたように、当初はGraphQLクエリを文字列として書いてました。レコードに含まれるフィールドが微妙に異なれば、JSONデコーダも別々に用意する必要がありました。スキーマに変更があればその都度手書きで変更…とても苦痛でした。この辺の作業がほとんど自動化されてしまうのでとても楽ができて素晴らしいです。

 

    • 紹介したパッケージの使い方を理解するには、Json.Decodeの使い方が理解できているのが前提です。Json Decoderが不要になるわけではありません

偶然にもちょっとだけ貢献しました

在写这篇文章的时候,有一个更新并改变了这种语法(https://github.com/dillonkearns/elm-graphql/blob/master/CHANGELOG-ELM-PACKAGE.md#200—2018-12-02)。Json.Decode也提供了同样使用mapN的语法。同时,似乎还在考虑更易理解的方法(https://discourse.elm-lang.org/t/optional-key-records-syntax-proposal/2634)。
广告
将在 10 秒后关闭
bannerAds