七、Reason 中的 JSON

在本章中,我们将学习如何通过构建一个简单的客户管理应用来使用 JSON。该应用位于我们现有应用的/customers路线内,可以创建、读取和更新客户。JSON 数据持续到localStorage。在本章中,我们将外部 JSON 转换为类型化的数据结构,Reason 可以通过两种不同的方式理解这种数据结构:

  • 使用纯理性
  • 使用bs-json

我们将在这一章的结尾对每种方法进行比较和对比。我们还将讨论 GraphQL 如何在使用静态类型语言(如 Reason)处理 JSON 时帮助提供愉快的开发人员体验。

要继续构建客户管理应用,请克隆本书的 GitHub 存储库,并从Chapter07/app-start开始:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter07/app-start
npm install

在本章中,我们将研究以下主题:

  • 建筑视图
  • 与本地存储集成
  • 使用 bs-json
  • 使用图形 SQL

建筑视图

总的来说,我们有三种观点:

  • 列表视图
  • 创建视图
  • 更新视图

每个视图都有自己的路线。创建和更新视图共享一个公共组件,因为它们非常相似。

文件结构

由于我们的bsconfig.json包含子目录,所以我们可以创建一个src/customers目录来存放相关组件,BuckleScript 会在src的子目录中递归查找 Reason(和 OCaml)文件:

/* bsconfig.json */
...
"sources": {
  "dir": "src",
  "subdirs": true
},
...

让我们继续并将src/Page1.re组件重命名为src/customers/CustomerList.re。在同一个目录中,我们稍后将创建Customer.re,它将用于创建和更新个人客户。

更新路由器和导航菜单

Router.re中,我们将用以下内容替换/page1路线:

/* Router.re */
let routes = [
  ...
  {href: "/customers", title: "Customer List", component: <CustomerList />}
  ...
];

我们还将添加/customers/create/customers/:id的路线:

/* Router.re */
let routes = [
  ...
  {href: "/customers/create", title: "Create Customer", component: <Customer />,},
  {href: "/customers/:id", title: "Update Customer", component: <Customer />}
  ...
];

The router has been updated so it can handle route variables (such as /customers/:id). This change has been made for you in Chapter07/app-start.

最后,一定要同时更新<App.re />中的导航菜单:

/* App.re */
render: self =>
  ...
  <ul>
    <li>
      <NavLink href="/customers">
        {ReasonReact.string("Customers")}
      </NavLink>
    </li>
  ...

CustomerType.re

该文件将保存<CustomerList /><Customer />使用的客户类型。这样做是为了避免任何循环依赖编译器错误:

/* CustomerType.re */
type address = {
  street: string,
  city: string,
  state: string,
  zip: string,
};

type t = {
  id: int,
  name: string,
  address,
  phone: string,
  email: string,
};

CustomerList.re

目前,我们将使用硬编码的客户数组。很快,我们将从localStorage检索这些数据。以下组件呈现一个样式化的客户数组。每个客户都被包裹在一个<Link />组件中。单击客户可导航至更新视图:

let component = ReasonReact.statelessComponent("CustomerList");

let customers: array(CustomerType.t) = [
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
];

module Styles = {
  open Css;

  let list =
    style([
      ...
    ]);
};

let make = _children => {
  ...component,
  render: _self =>
    <div>
      <ul className=Styles.list>
        {
          ReasonReact.array(
            Belt.Array.map(customers, customer =>
              <li key={string_of_int(customer.id)}>
                <Link href={"/customers/" ++ string_of_int(customer.id)}>
                  <p> {ReasonReact.string(customer.name)} </p>
                  <p> {ReasonReact.string(customer.address.street)} </p>
                  <p> {ReasonReact.string(customer.phone)} </p>
                  <p> {ReasonReact.string(customer.email)} </p>
                </Link>
              </li>
            )
          )
        }
      </ul>
    </div>,
};

Customer.re

该 reducer 组件呈现一个表单,其中输入元素中的每个客户字段都是可编辑的。该组件基于window.location.pathname有两种模式— CreateUpdate

我们从绑定到window.location.pathname开始,定义组件的动作和状态:

/* Customer.re */
[@bs.val] external pathname: string = "window.location.pathname";

type mode =
  | Create
  | Update;

type state = {
  mode,
  customer: CustomerType.t,
};

type action =
  | Save(ReactEvent.Form.t);

let component = ReasonReact.reducerComponent("Customer");

接下来,我们使用bs-css添加我们的组件样式。要查看样式,请查看Chapter07/app-end/src/customers/Customer.re:

/* Customer.re */
module Styles = {
  open Css;

  let form =
    style([
      ...
    ]);
};

现在,我们将使用硬编码的客户数组,它是在下面的代码片段中创建的。整个数组可以在Chapter07/app-end/src/customers/Customer.re找到:

/* Customer.re */
let customers: array(CustomerType.t) = [|
  {
    id: 1,
    name: "Christina Langworth",
    address: {
      street: "81 Casey Stravenue",
      city: "Beattyview",
      state: "TX",
      zip: "57918",
    },
    phone: "877-549-1362",
    email: "Christina.Langworth@gmail.com",
  },
  ...
|];

我们也有助手函数,原因如下:

  • window.location.pathname提取客户标识
  • 通过身份证获得客户
  • 要生成默认客户,请执行以下操作:
let getId = pathname =>
  try (Js.String.replaceByRe([%bs.re "/\\D/g"], "", pathname)->int_of_string) {
  | _ => (-1)
  };

let getCustomer = customers => {
  let id = getId(pathname);
  customers |> Js.Array.find(customer => customer.CustomerType.id == id);
};

let getDefault = customers: CustomerType.t => {
  id: Belt.Array.length(customers) + 1,
  name: "",
  address: {
    street: "",
    city: "",
    state: "",
    zip: "",
  },
  phone: "",
  email: "",
};

当然,下面是我们组件的make功能:

let make = _children => {
  ...component,
  initialState: () => {
    let mode = Js.String.includes("create", pathname) ? Create : Update;
    {
      mode,
      customer:
        switch (mode) {
        | Create => getDefault(customers)
        | Update =>
          Belt.Option.getWithDefault(
            getCustomer(customers),
            getDefault(customers),
          )
        },
    };
  },
  reducer: (action, state) =>
    switch (action) {
    | Save(event) =>
      ReactEvent.Form.preventDefault(event);
      ReasonReact.Update(state);
    },
  render: self =>
    <form
      className=Styles.form
      onSubmit={
        event => {
          ReactEvent.Form.persist(event);
          self.send(Save(event));
        }
      }>
      <label>
        {ReasonReact.string("Name")}
        <input type_="text" defaultValue={self.state.customer.name} />
      </label>
      <label>
        {ReasonReact.string("Street Address")}
        <input
          type_="text"
          defaultValue={self.state.customer.address.street}
        />
      </label>
      <label>
        {ReasonReact.string("City")}
        <input type_="text" defaultValue={self.state.customer.address.city} />
      </label>
      <label>
        {ReasonReact.string("State")}
        <input type_="text" defaultValue={self.state.customer.address.state} />
      </label>
      <label>
        {ReasonReact.string("Zip")}
        <input type_="text" defaultValue={self.state.customer.address.zip} />
      </label>
      <label>
        {ReasonReact.string("Phone")}
        <input type_="text" defaultValue={self.state.customer.phone} />
      </label>
      <label>
        {ReasonReact.string("Email")}
        <input type_="text" defaultValue={self.state.customer.email} />
      </label>
      <input
        type_="submit"
        value={
          switch (self.state.mode) {
          | Create => "Create"
          | Update => "Update"
          }
        }
      />
    </form>,
};

Save动作还没有保存到localStorage。当导航到/customers/create时,表单为空,当导航到例如/customers/1时,表单被填充。

与本地存储集成

让我们创建一个单独的模块来与数据层交互,我们称之为DataPureReason.re。这里,我们公开了对localStorage.getItemlocalStorage.setItem的绑定,以及将 JSON 字符串解析为前面定义的CustomerType.t记录的解析函数。

填充本地存储

你会在Chapter07/app-end/src/customers/data.json找到一些初始数据。请在您的浏览器控制台中运行localStorage.setItem("customers", JSON.stringify(/* paste JSON data here */))以使用该初始数据填充localStorage

DataPureReason.re

还记得 BuckleScript 绑定感觉有点模糊吗?希望他们现在开始觉得更简单了:

[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

为了解析 JSON,我们将使用Js.Json模块。

The Js.Json documentation can be found at the following URL:

https://bucklescript.github.io/bucklescript/api/Js_json.html

很快,您将看到一种使用Js.Json模块解析 JSON 字符串的方法。不过,有一点需要注意:这有点乏味。但是重要的是要理解正在发生什么,以及为什么我们需要为像理性这样的类型化语言这样做。在高层次上,我们将验证 JSON 字符串以确保它是有效的 JSON,如果是这样,使用Js.Json.classify函数将 JSON 字符串(Js.Json.t)转换为标记类型(Js.Json.tagged_t)。可用的标签如下:

type tagged_t =
  | JSONFalse
  | JSONTrue
  | JSONNull
  | JSONString(string)
  | JSONNumber(float)
  | JSONObject(Js_dict.t(t))
  | JSONArray(array(t));

这样,我们可以将 JSON 字符串转换成类型化的原因数据结构。

正在验证 JSON 字符串

上一节中定义的getItem绑定将返回一个字符串:

let unvalidated = DataPureReason.getItem("customers");

我们可以这样验证 JSON 字符串:

let validated =
  try (Js.Json.parseExn(unvalidated)) {
  | _ => failwith("Error parsing JSON string")
  };

如果 JSON 无效,它将生成运行时错误。在这一章的最后,我们将学习 GraphQL 如何帮助改善这种情况。

使用 Js。Json.classify

假设我们已经验证了以下 JSON(它是一个对象数组):

[
  {
    "id": 1,
    "name": "Christina Langworth",
    "address": {
      "street": "81 Casey Stravenue",
      "city": "Beattyview",
      "state": "TX",
      "zip": "57918"
    },
    "phone": "877-549-1362",
    "email": "Christina.Langworth@gmail.com"
  },
  {
    "id": 2,
    "name": "Victor Tillman",
    "address": {
      "street": "2811 Toby Gardens",
      "city": "West Enrique",
      "state": "NV",
      "zip": "40465"
    },
    "phone": "(502) 091-2292",
    "email": "Victor.Tillman30@gmail.com"
  }
]

现在我们已经验证了 JSON,我们准备对它进行分类:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => ...)
| _ => failwith("Expected an array")
};

我们对Js.Json.tagged_t的可能标签进行模式匹配。如果它是一个数组,我们就用Belt.Array.map(或Js.Array.map)来映射它。否则,我们会在应用的上下文中得到一个运行时错误。

map函数被传递给数组中每个对象的引用。但是理性还不知道每个元素都是一个对象。在map内部,我们再次对数组的每个元素进行分类。分类后,理性现在知道每个元素实际上都是一个对象。我们将定义一个名为parseCustomer的自定义助手函数,用于map函数:

switch (Js.Json.classify(validated)) {
| Js.Json.JSONArray(array) =>
  Belt.Array.map(array, customer => parseCustomer(customer))
| _ => failwith("Expected an array")
};

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      ...
    )
  | _ => failwith("Expected an object")
  };

现在,如果数组的每个元素都是一个对象,我们想要返回一个新的记录。该记录的类型为CustomerType.t。否则,我们会得到一个运行时错误:

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id: ...,
        name: ...,
        address: ...,
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

现在,对于每个字段(即idnameaddress等),我们使用Js.Dict.get来获取和分类每个字段:

The Js.Dict documentation can be found at the following URL:

https://bucklescript.github.io/bucklescript/api/Js.Dict.html

let parseCustomer = json =>
  switch (Js.Json.classify(json)) {
  | Js.Json.JSONObject(json) => (
      {
        id:
          switch (Js.Dict.get(json, "id")) {
          | Some(id) =>
            switch (Js.Json.classify(id)) {
            | Js.Json.JSONNumber(id) => int_of_float(id)
            | _ => failwith("Field 'id' should be a number")
            }
          | None => failwith("Missing field: id")
          },
        name:
          switch (Js.Dict.get(json, "name")) {
          | Some(name) =>
            switch (Js.Json.classify(name)) {
            | Js.Json.JSONString(name) => name
            | _ => failwith("Field 'name' should be a string")
            }
          | None => failwith("Missing field: name")
          },
        address:
          switch (Js.Dict.get(json, "address")) {
          | Some(address) =>
            switch (Js.Json.classify(address)) {
            | Js.Json.JSONObject(address) => {
                street:
                  switch (Js.Dict.get(address, "street")) {
                  | Some(street) =>
                    switch (Js.Json.classify(street)) {
                    | Js.Json.JSONString(street) => street
                    | _ => failwith("Field 'street' should be a string")
                    }
                  | None => failwith("Missing field: street")
                  },
                city: ...,
                state: ...,
                zip: ...,
              }
            | _ => failwith("Field 'address' should be a object")
            }
          | None => failwith("Missing field: address")
          },
        phone: ...,
        email: ...,
      }: CustomerType.t
    )
  | _ => failwith("Expected an object")
  };

See src/customers/DataPureReason.re for the full implementation. DataPureReason.rei hides implementation details and only exposes the localStorage bindings and a parse function.

唷,那有点乏味,不是吗?现在已经完成了,我们可以用以下内容替换CustomerList.reCustomer.re中的硬编码客户阵列:

let customers =
  DataBsJson.(parse(getItem("customers")));

到目前为止,一切顺利!JSON 数据被动态地提取和解析,现在工作方式与硬编码时相同。

正在写入本地存储

现在让我们添加创建和更新客户的功能。为此,我们需要将我们的原因数据结构转换为 JSON。在接口文件DataPureReason.rei中,我们将公开一个toJson函数:

/* DataPureReason.rei */
let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

然后我们将实现它:

/* DataPureReason.re */
let customerToJson = (customer: CustomerType.t) => {
  let id = customer.id;
  let name = customer.name;
  let street = customer.address.street;
  let city = customer.address.city;
  let state = customer.address.state;
  let zip = customer.address.zip;
  let phone = customer.phone;
  let email = customer.email;

  {j|
    {
      "id": $id,
      "name": "$name",
      "address": {
        "street": "$street",
        "city": "$city",
        "state": "$state",
        "zip": "$zip"
      },
      "phone": "$phone",
      "email": "$email"
    }
  |j};
};

let toJson = (customers: array(CustomerType.t)) =>
  Belt.Array.map(customers, customer => customerToJson(customer))
  ->Belt.Array.reduce("[", (acc, customer) => acc ++ customer ++ ",")
  ->Js.String.replaceByRe([%bs.re "/,$/"], "", _)
  ++ "]"
     ->Js.String.split("/n", _)
     ->Js.Array.map(line => Js.String.trim(line), _)
     ->Js.Array.joinWith("", _);

然后我们将使用Customer.re减速器中的toJson功能:

reducer: (action, state) =>
  switch (action) {
  | Save(event) =>
    let getInputValue: string => string = [%raw
      (selector => "return document.querySelector(selector).value")
    ];
    ReactEvent.Form.preventDefault(event);
    ReasonReact.UpdateWithSideEffects(
      {
        ...state,
        customer: {
          id: state.customer.id,
          name: getInputValue("input[name=name]"),
          address: {
            street: getInputValue("input[name=street]"),
            city: getInputValue("input[name=city]"),
            state: getInputValue("input[name=state]"),
            zip: getInputValue("input[name=zip]"),
          },
          phone: getInputValue("input[name=phone]"),
          email: getInputValue("input[name=email]"),
        },
      },
      (
        self => {
          let customers =
            switch (self.state.mode) {
            | Create =>
              Belt.Array.concat(customers, [|self.state.customer|])
            | Update =>
              Belt.Array.setExn(
                customers,
                Js.Array.findIndex(
                  customer =>
                    customer.CustomerType.id == self.state.customer.id,
                  customers,
                ),
                self.state.customer,
              );
              customers;
            };

          let json = customers->DataPureReason.toJson;
          DataPureReason.setItem("customers", json);
        }
      ),
    );
  },

在减速器中,我们用 DOM 中的值更新self.state.customer,然后调用一个更新localStorage的函数。现在,我们可以通过创建或更新客户来给localStorage写信。导航至/customers/create创建新客户,然后导航回/customers查看您新添加的客户。单击客户导航到更新视图,更新客户,单击更新按钮,并刷新页面。

使用 bs-json

现在我们已经完全理解了如何将 JSON 字符串转换成类型化的 Reason 数据结构,我们注意到这个过程有点繁琐。它的代码行数比人们预期的来自动态语言(如 JavaScript)的代码行数要多。此外,还有相当多的重复代码。作为替代方案,理性社区中的许多人已经采用bs-json作为编码和解码 JSON 的“官方”解决方案。

让我们创建一个名为DataBsJson.re的新模块和一个新的接口文件DataBsJson.rei。我们将复制与我们在DataPureReason.rei中完全相同的界面,以便我们知道,一旦我们完成,我们将能够用DataBsJson替换对DataPureReason的所有引用,并且一切都应该相同。

暴露的界面如下:

/* DataBsJson.rei */
[@bs.val] [@bs.scope "localStorage"] external getItem: string => string = "";
[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "";

let parse: string => array(CustomerType.t);
let toJson: array(CustomerType.t) => string;

我们来关注一下parse功能:

let parse = json =>
  json |> Json.parseOrRaise |> Json.Decode.array(customerDecoder);

在这里,我们接受和以前一样的 JSON 字符串,验证它,将其转换为Js.Json.t(通过Json.parseOrRaise),然后将结果传递到这个新的Json.Decode.array(customerDecoder)函数中。Json.Decode.array将尝试将 JSON 字符串解码为一个数组,并使用名为customerDecoder的自定义函数解码数组中的每个元素——我们接下来将看到:

let customerDecoder = json =>
  Json.Decode.(
    (
      {
        id: json |> field("id", int),
        name: json |> field("name", string),
        address: json |> field("address", addressDecoder),
        phone: json |> field("phone", string),
        email: json |> field("email", string),
      }: CustomerType.t
    )
  );

customerDecoder函数接受与数组每个元素相关联的 JSON,并尝试将其解码为CustomerType.t类型的记录。这与我们之前所做的几乎完全相同,但是它不太冗长,更容易阅读。如您所见,我们还有另一个名为addressDecoder的客户解码器,用于解码CustomerType.address类型:

let addressDecoder = json =>
  Json.Decode.(
    (
      {
        street: json |> field("street", string),
        city: json |> field("city", string),
        state: json |> field("state", string),
        zip: json |> field("zip", string),
      }: CustomerType.address
    )
  );

请注意自定义解码器是如何轻松组成的。每个记录字段都是通过调用Json.Decode.field,传递字段的名称(在 JSON 端),并传入一个Json.Decode函数最终将 JSON 字段转换为 Reason 可以理解的类型来解码的。

编码的工作原理类似,但顺序相反:

let toJson = (customers: array(CustomerType.t)) =>
  customers->Belt.Array.map(customer =>
    Json.Encode.(
      object_([
        ("id", int(customer.id)),
        ("name", string(customer.name)),
        (
          "address",
          object_([
            ("street", string(customer.address.street)),
            ("city", string(customer.address.city)),
            ("state", string(customer.address.state)),
            ("zip", string(customer.address.zip)),
          ]),
        ),
        ("phone", string(customer.phone)),
        ("email", string(customer.email)),
      ])
    )
  )
  |> Json.Encode.jsonArray
  |> Json.stringify;

客户数组被映射,每个客户被编码到一个 JSON 对象中。结果是一个 JSON 对象的数组,然后被编码成 JSON,并被字符串化。比我们之前的实现好得多。

DataPureReason.re复制相同的localStorage绑定后,我们的接口现在实现了。在用DataBsJson替换了所有对DataPureReason的引用后,我们看到我们的应用工作正常。

使用图形 SQL

在 2018 年的 ReactiveConf 上,肖恩·格罗夫就理性和 GraphQL 做了一个令人惊叹的演讲,题目是reactive meetiups w/Sean Grove | reasonnml graph QL。以下是本次演讲的摘录,很好地总结了在推理中使用 JSON 的问题和解决方案:

So I would argue that, in typed languages, like Reason, there are three really, really big problems when you want to interact with the real world. The first is all the boilerplate that it takes to get data into and out of your type system. The second is, even if you can program your way out of the boilerplate, you are still worried about the accuracy, the safety of conversion. And then finally, even if you if you get all of this and you're absolutely sure you've caught all the variation, someone can still change it from underneath you without you knowing.

How many times do we get a changelog whenever the server changes fields? In an ideal world, we would. But most of the time we don't. We get to reverse-engineer what our server changed.

So I would argue that, in order to solve this in a broadly applicable way, we want four things:

1) Access to all of the data types that an API can provide to us in a programmatic way. 2) Automatic conversions that are guaranteed to be safe. 3) And we want to have a contract. We want the server to guarantee if it said a field is not nullable, they will never give us null. If they change the field name, then we immediately know and that they know. 4) And we want all of that in a programmatic way.

And that's GraphQL. -Sean Grove You can find the video of ReactiveMeetups w/ Sean Grove | ReasonML GraphQL at the following URL: https://youtu.be/t9a-_VnNilE

And, here is ReactiveConf's Youtube channel:  https://www.youtube.com/channel/UCBHdUnixTWymmXBIw12Y8Qg

过于深入 GraphQL 超出了本书的范围,但是考虑到我们正在讨论在 Reason 中使用 JSON,高级别的介绍似乎是合适的。

什么是图形 SQL?

如果你是 ReactJS 社区的一员,那么你可能已经听说过 GraphQL。GraphQL 是一种查询语言和运行时,我们可以使用它来完成这些查询,它也是由脸书创建的。使用 GraphQL,ReactJS 组件可以包含一个组件需要的数据的 GraphQL 片段——这意味着一个组件可以将 HTML、CSS、JavaScript 和它的外部数据全部耦合到一个文件中。

使用 GraphQL 时,需要创建 JSON 解码器吗?

由于 GraphQL 非常了解应用的外部数据,GraphQL 客户端(reason-apollo)会自动为您生成解码器。当然,解码器必须是自动生成的,因此我们确信它们反映了外部数据的当前形状。这只是当您需要处理外部数据时,考虑在您的推理应用中使用 GraphQL 的另一个原因。

摘要

只要我们在“原因”中工作,类型系统就会防止您遇到运行时类型错误。然而,当与外部世界交互时——无论是 JavaScript 还是外部数据——我们就失去了这些保证。为了能够在理性的范围内保持这些保证,我们需要在使用理性之外的东西时帮助类型系统。我们之前在 Reason 中学习了如何使用外部 JavaScript,在本章中我们学习了如何在 Reason 中使用外部数据。虽然编写解码器和编码器更具挑战性,但它与编写 JavaScript 绑定非常相似。最后,我们只是告诉理性理性之外的事物的类型。使用 GraphQL,我们可以扩展 Reason 的边界以包括外部数据。有取舍,没有什么是完美的,但绝对值得给 GraphQL 一个机会。

在下一章中,我们将在理性的背景下探索测试。我们应该写什么测试?我们应该避免哪些测试?我们还将探讨单元测试如何帮助我们改进本章中编写的代码。