跳到主要内容

第 19 章:处理 JSON 数据(Handling JSON Data)

原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 19。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。

数据序列化,也就是在适合写入磁盘或通过网络发送的字节序列与数据之间相互转换,是一项重要且常见的编程任务。你经常需要匹配别人的数据格式(例如 XML);有时需要高度高效的格式;另一些时候,则希望格式便于人类编辑。为此,OCaml 库会根据你的问题提供几种数据序列化技术。

我们会先使用流行而简单的 JSON 数据格式,稍后再在本书中考察其他序列化格式。本章会介绍几种新技术,它们把本书第 I 部分的基本思想粘合起来:

  • 使用多态变体编写更可扩展的库和协议,同时在需要时仍然保留继续扩展的能力。
  • 使用函数式组合子以类型安全的方式组合数据结构上的常见操作。
  • 使用外部工具,根据外部规格文件生成样板 OCaml 模块和签名。

19.1 JSON 基础(JSON Basics)​

JSON 是一种轻量级数据交换格式,常用于 Web 服务和浏览器。它在 RFC4627 中描述,比 XML 等替代方案更容易解析和生成。处理现代 Web API 时,你会非常频繁地遇到 JSON,所以本章会介绍几种不同的 JSON 操作方式。

JSON 由两种基本结构组成:无序的键/值对集合,以及有序的值列表。值可以是字符串、布尔值、浮点数、整数或 null。下面看看一个示例图书描述的 JSON 记录是什么样:

{
"title": "Real World OCaml",
"tags" : [ "functional programming", "ocaml", "algorithms" ],
"pages": 450,
"authors": [
{ "name": "Jason Hickey", "affiliation": "Google" },
{ "name": "Anil Madhavapeddy", "affiliation": "Cambridge"},
{ "name": "Yaron Minsky", "affiliation": "Jane Street"}
],
"is_online": true
}

最外层的 JSON 值通常是一个记录(由花括号分隔),并包含一组无序的键/值对。键必须是字符串,但值可以是任意 JSON 类型。在前面的例子中,tags 是字符串列表,而 authors 字段包含记录列表。与 OCaml 列表不同,JSON 列表可以在同一个列表中包含多种不同 JSON 类型。

JSON 类型的这种自由形式既是福也是祸。生成 JSON 值非常容易,但解析它们的代码也必须处理值表示方式上的细微变化。例如,如果前面的 pages 值实际被表示成字符串值 "450",而不是整数,会怎样?

我们的第一项任务是把 JSON 解析成更结构化的 OCaml 类型,从而更有效地使用静态类型。用 Python 或 Ruby 操作 JSON 时,你可能会编写单元测试,检查自己是否处理了异常输入。OCaml 模型则偏好编译期静态检查,同时也使用单元测试。例如,如果你没有检查一个值既可能是 Null,也可能包含实际值,模式匹配可以对此发出警告。

注:安装 Yojson 库

OCaml 有几个可用的 JSON 库。本章选择流行的 Yojson 库,可以通过运行 opam install yojson 安装。安装后,可以在 utop 中如下打开它:

# open Core;;
# #require "yojson";;
# open Yojson;;

19.2 使用 Yojson 解析 JSON(Parsing JSON with Yojson)​

JSON 规格只有很少的数据类型,下面的 Yojson.Basic.t 类型足以表达任何有效 JSON 结构:

type json = [
| `Assoc of (string * json) list
| `Bool of bool
| `Float of float
| `Int of int
| `List of json list
| `Null
| `String of string
]

读完这个定义后,应该能看出几个有趣性质:

  • json 类型是递归的,也就是说,一些标签会回指整体 json 类型。特别是,Assoc 和 List 类型可以包含指向更多 JSON 值的引用,而且这些 JSON 值可以是不同类型。这与 OCaml 列表不同,OCaml 列表的内容必须是统一类型。
  • 这个定义专门包含了一个 Null 变体,用于表示空字段。OCaml 默认不允许 null 值,所以必须显式编码它。
  • 这个类型定义使用多态变体,而不是普通变体。稍后我们用自定义扩展来扩展 JSON 格式时,这一点会变得重要。

现在把前面的 JSON 示例解析成这个类型。第一站是 Yojson.Basic 文档,其中可以找到这些有用函数:

val from_string : ?buf:Bi_outbuf.t -> ?fname:string -> ?lnum:int ->
string -> json
(* Read a JSON value from a string.
[buf] : use this buffer at will during parsing instead of
creating a new one.
[fname] : data file name to be used in error messages. It does not
have to be a real file.
[lnum] : number of the first line of input. Default is 1. *)

val from_file : ?buf:Bi_outbuf.t -> ?fname:string -> ?lnum:int ->
string -> json
(* Read a JSON value from a file. See [from_string] for the meaning of the optional
arguments. *)

val from_channel : ?buf:Bi_outbuf.t -> ?fname:string -> ?lnum:int ->
in_channel -> json
(** Read a JSON value from a channel.
See [from_string] for the meaning of the optional arguments. *)

第一次阅读这些接口时,通常可以忽略可选参数(类型签名中带问号的部分),因为它们应该有合理默认值。在前面的签名中,这些可选参数提供了更细粒度的控制,用于解析错误 JSON 时的内存缓冲区分配和错误消息。

去掉可选元素后的函数类型签名更清楚地展示了它们的用途。解析 JSON 有三种方式:直接从字符串解析、从文件系统中的文件解析,或通过带缓冲的输入通道解析。

val from_string : string -> json
val from_file : string -> json
val from_channel : in_channel -> json

下面的示例假设 JSON 记录存储在名为 book.json 的文件中,同时展示 string 和 file 函数的用法:

open Core

let () =
(* Read JSON file into an OCaml string *)
let buf = In_channel.read_all "book.json" in
(* Use the string JSON constructor *)
let json1 = Yojson.Basic.from_string buf in
(* Use the file JSON constructor *)
let json2 = Yojson.Basic.from_file "book.json" in
(* Test that the two values are the same *)
print_endline (if Yojson.Basic.equal json1 json2 then "OK" else "FAIL")

可以通过运行 dune 构建它:

$ dune exec -- ./read_json.exe
OK

from_file 函数接收一个输入文件名,并负责为你打开和关闭文件。不过,更常见的做法是用 from_string 构造 JSON 值,因为这些字符串通常来自网络连接(第 17 章“使用 Async 的并发编程(Concurrent Programming with Async)”会看到更多这种情况)或数据库。最后,这个示例检查两种输入机制是否确实产生了相同的 OCaml 数据结构。

19.3 从 JSON 结构中选择值(Selecting Values from JSON Structures)​

现在已经弄清楚如何把示例 JSON 解析成 OCaml 值,接下来从 OCaml 代码中操作它,并提取具体字段:

open Core

let () =
(* Read the JSON file *)
let json = Yojson.Basic.from_file "book.json" in

(* Locally open the JSON manipulation functions *)
let open Yojson.Basic.Util in
let title = json |> member "title" |> to_string in
let tags = json |> member "tags" |> to_list |> filter_string in
let pages = json |> member "pages" |> to_int in
let is_online = json |> member "is_online" |> to_bool_option in
let is_translated = json |> member "is_translated" |> to_bool_option in
let authors = json |> member "authors" |> to_list in
let names = List.map authors ~f:(fun json -> member "name" json |> to_string) in

(* Print the results of the parsing *)
printf "Title: %s (%d)\n" title pages;
printf "Authors: %s\n" (String.concat ~sep:", " names);
printf "Tags: %s\n" (String.concat ~sep:", " tags);
let string_of_bool_option =
function
| None -> "<unknown>"
| Some true -> "yes"
| Some false -> "no" in
printf "Online: %s\n" (string_of_bool_option is_online);
printf "Translated: %s\n" (string_of_bool_option is_translated)

现在像前一个示例一样构建并运行:

(executable
(name parse_book)
(libraries core yojson))
$ dune build parse_book.exe
$ ./_build/default/parse_book.exe
Title: Real World OCaml (450)
Authors: Jason Hickey, Anil Madhavapeddy, Yaron Minsky
Tags: functional programming, ocaml, algorithms
Online: yes
Translated: <unknown>

这段代码引入了 Yojson.Basic.Util 模块,其中包含组合子函数,让你可以轻松把 JSON 对象映射成更强类型的 OCaml 值。

注:函数式组合子(Functional Combinators)

组合子是函数式编程中经常出现的一种设计模式。John Hughes 将其定义为“从程序片段构建程序片段的函数”。在函数式语言中,这通常意味着高阶函数,它们组合其他函数,对值应用有用转换。

你已经在 List 模块中遇到过几个这样的函数:

val map : 'a list -> f:('a -> 'b) -> 'b list
val fold : 'a list -> init:'accum -> f:('accum -> 'a -> 'accum) -> 'accum

map 和 fold 是极其常见的组合子,它们通过对列表中的每个值应用函数来转换输入列表。map 组合子最简单,结果列表会直接输出。fold 则把输入列表中的每个值应用到一个累积单个结果的函数上,并返回这个结果。

val iter : 'a list -> f:('a -> unit) -> unit

iter 是更专门的组合子,只在编写命令式代码时有用。输入函数会被应用到每个值,但不会提供结果。该函数必须改为应用某种副作用,例如修改可变记录字段或打印到标准输出。

Yojson 在 Yojson.Basic.Util 模块中提供了几个用于操作值的组合子:

  • val member : string -> json -> json 从 JSON 记录中选择具名字段。
  • val to_string : json -> string 把 JSON 值转换成 OCaml string。如果无法转换,就会抛出异常。
  • val to_int : json -> int 把 JSON 值转换成 int。如果无法转换,就会抛出异常。
  • val filter_string : json list -> string list 从 JSON 字段列表中过滤出有效字符串,并以 OCaml string list 形式返回它们。

现在逐一看看这些用法。下面示例还使用了第 3 章“变量与函数(Variables and Functions)”中解释过的 |> 前向管道运算符。它让我们可以把多个 JSON 选择函数串在一起,并把一个函数的输出送入下一个函数,而不必为每一步创建单独的 let 绑定。

先从记录中选择单个 title 字段:

# open Yojson.Basic.Util;;
# let title = json |> member "title" |> to_string;;
val title : string = "Real World OCaml"

member 函数接收一个 JSON 对象和具名键,并返回与该键关联的 JSON 字段;如果找不到,就返回 Null。因为我们知道示例 schema 中的 title 值总是字符串,所以希望把它转换成 OCaml 字符串。to_string 函数会执行这个转换,并在遇到意外 JSON 类型时抛出异常。|> 运算符提供了一种方便方式,把这些操作串起来:

# let tags = json |> member "tags" |> to_list |> filter_string;;
val tags : string list = ["functional programming"; "ocaml"; "algorithms"]
# let pages = json |> member "pages" |> to_int;;
val pages : int = 450

tags 字段类似于 title,但它不是单个字符串,而是字符串列表。把它转换成 OCaml string list 需要两个阶段。首先,把 JSON List 转换成 JSON 值的 OCaml 列表,然后过滤出 String 值,得到 OCaml string list。记住,OCaml 列表必须包含同一类型的值,所以任何无法转换成 string 的 JSON 值都会从 filter_string 的输出中跳过。

# let is_online = json |> member "is_online" |> to_bool_option;;
val is_online : bool option = Some true
# let is_translated = json |> member "is_translated" |> to_bool_option;;
val is_translated : bool option = None

is_online 和 is_translated 字段在我们的 JSON schema 中是可选的,因此如果它们不存在,不应该抛出错误。OCaml 类型是 bool option,用来反映这一点,并可以通过 to_bool_option 提取。在示例 JSON 中,只有 is_online 存在,而 is_translated 会是 None。

# let authors = json |> member "authors" |> to_list;;
val authors : Yojson.Basic.t list =
[`Assoc
[("name", `String "Jason Hickey"); ("affiliation", `String "Google")];
`Assoc
[("name", `String "Anil Madhavapeddy");
("affiliation", `String "Cambridge")];
`Assoc
[("name", `String "Yaron Minsky");
("affiliation", `String "Jane Street")]]

JSON 组合子的最后一个用法,是从作者列表中提取所有 name 字段。我们先构造 author list,然后把它 map 成 string list。注意,示例显式把 authors 绑定到一个变量名。也可以使用前向管道运算符写得更简洁:

# let names =
json |> member "authors" |> to_list
|> List.map ~f:(fun json -> member "name" json |> to_string);;
val names : string list =
["Jason Hickey"; "Anil Madhavapeddy"; "Yaron Minsky"]

这种省略变量名并把函数串联在一起的编程风格,称为无点风格编程(point-free programming)。它很简洁,但不应过度使用,因为调试中间值会更困难。如果给每个转换阶段都分配一个显式名称,尤其是调试器就更容易向程序员展示程序流。

这种使用静态类型解析函数的技术与 OCaml 类型系统结合起来非常强大。许多在运行时没有意义的错误(例如混淆列表和对象)都会通过类型错误被静态捕获。

19.4 构造 JSON 值(Constructing JSON Values)​

给定 Yojson.Basic.t 类型,构建并打印 JSON 值非常直接。你可以直接构造 t 类型的值,并对它们调用 to_string 函数。我们先再次回忆 Yojson.Basic.t 类型:

type json = [
| `Assoc of (string * json) list
| `Bool of bool
| `Float of float
| `Int of int
| `List of json list
| `Null
| `String of string
]

可以直接按这个类型构建一个 JSON 值,并使用 Yojson.Basic 模块中的 pretty-printing 函数显示 JSON 输出:

# let person = `Assoc [ ("name", `String "Anil") ];;
val person : [> `Assoc of (string * [> `String of string ]) list ] =
`Assoc [("name", `String "Anil")]

在前面的例子中,我们构造了一个表示单个人的简单 JSON 对象。这里并没有显式定义 person 的类型,而是依赖多态变体的魔法让这一切工作。

OCaml 类型系统会根据构造值的方式推断 person 的类型。在本例中,只有 Assoc 和 String 变体用于定义记录,因此推断类型只包含这些字段,而不知道 JSON 记录中其他可能允许、但尚未使用的变体(例如 Int 或 Null):

# Yojson.Basic.pretty_to_string;;
- : ?std:bool -> Yojson.Basic.t -> string = <fun>

pretty_to_string 函数拥有更明确的签名,要求一个 Yojson.Basic.t 类型参数。当把 person 应用于 pretty_to_string 时,person 的推断类型会被静态检查,确认它与 json 类型结构兼容:

# Yojson.Basic.pretty_to_string person;;
- : string = "{ \"name\": \"Anil\" }"
# Yojson.Basic.pretty_to_channel stdout person;;
{ "name": "Anil" }
- : unit = ()

在这种情况下没有问题。person 值的推断类型是 json 的一个有效子类型,因此转换成字符串无需我们显式指定 person 的类型就能工作。类型推断让你可以编写更简洁的代码,同时不牺牲运行时可靠性,因为多态变体的所有用法仍会在编译期检查。

注:多态变体与更容易的类型检查

你会遇到的一个困难是,涉及多态变体的类型错误可能非常冗长。例如,假设你构建了一个 Assoc,并错误地包含单个值,而不是键列表:

# let person = `Assoc ("name", `String "Anil");;
val person : [> `Assoc of string * [> `String of string ] ] =
`Assoc ("name", `String "Anil")
# Yojson.Basic.pretty_to_string person;;
Line 1, characters 31-37:
Error: This expression has type
[> `Assoc of string * [> `String of string ] ]
but an expression was expected of type Yojson.Basic.t
Types for tag `Assoc are incompatible

这个类型错误比必要情况更冗长,对较大值来说读起来会很不方便。可以添加显式类型标注,作为关于你意图的提示,帮助编译器把错误缩小成更短形式:

# let (person : Yojson.Basic.t) =
`Assoc ("name", `String "Anil");;
Line 2, characters 10-34:
Error: This expression has type 'a * 'b
but an expression was expected of type (string * Yojson.Basic.t) list

我们把 person 标注为 Yojson.Basic.t 类型,因此编译器会发现 Assoc 变体的参数类型不正确。这说明了多态变体的强项和弱项:它们轻量且灵活,但错误消息可能相当令人困惑。不过,稍微谨慎地手动添加类型标注,会让追踪这类问题容易得多。

第 26 章“编译器前端:解析与类型检查(The Compiler Frontend: Parsing and Type Checking)”会讨论更多帮助你更轻松解释类型错误的技术。

19.5 使用非标准 JSON 扩展(Using Nonstandard JSON Extensions)​

标准 JSON 类型真的很基础,而 OCaml 类型的表达力要强得多。当你不需要与外部系统互操作,只想要一种方便、人类可读的本地格式时,Yojson 支持一种扩展 JSON 格式。Yojson.Safe.json 类型是 Basic 多态变体的超集,如下所示:

type json = [
| `Assoc of (string * json) list
| `Bool of bool
| `Float of float
| `Floatlit of string
| `Int of int
| `Intlit of string
| `List of json list
| `Null
| `String of string
| `Stringlit of string
| `Tuple of json list
| `Variant of string * json option
]

Safe.json 类型包含 Basic.json 中的所有变体,并用更多有用变体进行了扩展。标准 JSON 类型(例如 String)既能通过 Basic 模块的类型检查,也能通过非标准 Safe 模块的类型检查。不过,如果把扩展值用于 Basic 模块,编译器会拒绝你的代码,直到你让它符合 JSON 的可移植子集。

Yojson 支持以下 JSON 扩展:

lit 后缀 : 表示值存储为 JSON 字符串。例如,Floatlit 会被存储为 "1.234",而不是 1.234。

Tuple 类型 : 存储为 ("abc", 123),而不是列表。

Variant 类型 : 更显式地编码 OCaml 变体,例如 <"Foo"> 或带参数变体的 <"Bar":123>。

这些扩展的唯一目的,是更好地控制 OCaml 值如何表示为 JSON(例如,把浮点数存储为 JSON 字符串)。输出仍遵循相同的标准格式,可以轻松与其他语言交换。

可以使用 to_basic 函数把 Safe.json 转换成 Basic.json 类型,如下:

val to_basic : json -> Yojson.Basic.t
(** Tuples are converted to JSON arrays, Variants are converted to
JSON strings or arrays of a string (constructor) and a json value
(argument). Long integers are converted to JSON strings.
Examples:

`Tuple [ `Int 1; `Float 2.3 ] -> `List [ `Int 1; `Float 2.3 ]
`Variant ("A", None) -> `String "A"
`Variant ("B", Some x) -> `List [ `String "B", x ]
`Intlit "12345678901234567890" -> `String "12345678901234567890"
*)

19.6 自动把 JSON 映射到 OCaml 类型(Automatically Mapping JSON to OCaml Types)​

前面描述的组合子让编写从 JSON 记录提取字段的函数变得容易,但这个过程仍然相当手工。实现更大的规格时,相比逐个编写转换函数,更适合用更机械的方式生成从 JSON schema 到 OCaml 值的映射。

现在介绍另一种 JSON 处理方式,它更适合大规模 JSON 处理:使用 ATD。ATD 提供一种领域特定语言(domain specific language,DSL),可以把 JSON 规格编译成 OCaml 模块,并在整个应用中使用这些模块。

可以通过调用 opam install atdgen 安装 atdgen 可执行文件。

$ opam install atdgen
$ atdgen -version
2.2.1

如果在路径中找不到 atdgen,可能需要在 shell 中运行 eval $(opam env)。

19.6.1 ATD 基础(ATD Basics)​

ATD 背后的想法是:在单独文件中指定 JSON 的格式,然后运行编译器(atdgen),输出用于构造和解析 JSON 值的 OCaml 代码。这意味着完全不需要编写 OCaml 解析代码,因为这些代码都会自动生成。

直接来看一个例子,使用 GitHub API 的一小部分。GitHub 是流行的代码托管和共享网站,提供基于 JSON 的 Web API。下面的 ATD 代码片段描述 GitHub 授权 API,它基于一种称为 OAuth 的准标准 Web 协议:

type scope = [
User <json name="user">
| Public_repo <json name="public_repo">
| Repo <json name="repo">
| Repo_status <json name="repo_status">
| Delete_repo <json name="delete_repo">
| Gist <json name="gist">
]

type app = {
name: string;
url: string;
} <ocaml field_prefix="app_">

type authorization_request = {
scopes: scope list;
note: string;
} <ocaml field_prefix="auth_req_">

type authorization_response = {
scopes: scope list;
token: string;
app: app;
url: string;
id: int;
?note: string option;
?note_url: string option;
}

ATD 规格语法刻意与 OCaml 类型定义非常相似。每个 JSON 记录都会被分配一个类型名(例如前面例子中的 app)。也可以定义类似 OCaml 变体类型的变体(例如示例中的 scope)。

19.6.2 ATD 标注(ATD Annotations)​

由于 ATD 支持在规格内部使用标注,它确实会偏离 OCaml 语法。标注可以针对特定目标自定义生成的代码(其中 OCaml 后端是我们最感兴趣的)。

例如,前面的 GitHub scope 字段被定义为变体类型,每个选项都按 OCaml 变体惯例以大写字母开头。然而,GitHub 返回的 JSON 值实际上是小写的,因此与选项名并不完全相同。

标注 <json name="user"> 表示该字段的 JSON 值是 user,但在 OCaml 中解析出的变体变量名应该是 User。这些标注常用于把 JSON 值映射到 OCaml 保留关键字(例如 type)。

19.6.3 将 ATD 规格编译成 OCaml(Compiling ATD Specifications to OCaml)​

可以使用 atdgen 命令行工具,把定义好的 ATD 规格编译成 OCaml 代码。我们运行两次编译器,生成一些 OCaml 类型定义,以及一个在输入数据和这些类型定义之间转换的 JSON 序列化模块。

atdgen 命令会在当前目录中生成一些新文件。github_t.ml 和 github_t.mli 会包含一个 OCaml 模块,其中定义与 ATD 文件对应的类型:

$ atdgen -t github.atd
$ atdgen -j github.atd
$ ocamlfind ocamlc -package atd -i github_t.mli
type scope =
[ `Delete_repo | `Gist | `Public_repo | `Repo | `Repo_status | `User ]
type app = { app_name : string; app_url : string; }
type authorization_response = {
scopes : scope list;
token : string;
app : app;
url : string;
id : int;
note : string option;
note_url : string option;
}
type authorization_request = {
auth_req_scopes : scope list;
auth_req_note : string;
}

这与 ATD 定义有明显对应关系。注意,同一模块中 OCaml 记录的字段名不能相互遮蔽,因此我们指示 ATDgen 为每个字段加上一个前缀,用来把它与同一模块中的其他记录区分开。例如,ATD 规格中的 <ocaml field_prefix="auth_req_"> 会让生成的 authorization_request 记录中的每个字段名前缀为 auth_req。

Github_t 模块只包含类型定义,而 Github_j 提供往返 JSON 的序列化函数。可以阅读 github_j.mli 查看完整接口,但多数用途中最重要的是往返字符串的转换函数。对于前面的示例,它看起来像这样:

val string_of_authorization_request :
?len:int -> authorization_request -> string
(** Serialize a value of type {!authorization_request}
into a JSON string.
@param len specifies the initial length
of the buffer used internally.
Default: 1024. *)

val string_of_authorization_response :
?len:int -> authorization_response -> string
(** Serialize a value of type {!authorization_response}
into a JSON string.
@param len specifies the initial length
of the buffer used internally.
Default: 1024. *)

这相当方便。我们现在只写了一个 ATD 文件,所有用于在 JSON 和强类型记录之间转换的 OCaml 样板代码都已经生成好了。可以通过给 atdgen 传标志来控制序列化器的各个方面。对 JSON 来说,重要标志包括:

-j-std : 把元组和变体转换成标准 JSON,并拒绝打印 NaN 和无穷大。如果打算与不使用 ATD 的服务互操作,应该指定这个标志。

-j-custom-fields FUNCTION : 遇到每个未知字段时调用自定义函数,而不是抛出解析异常。

-j-defaults : 只要可能,就总是显式输出 JSON 值。这要求在 ATD 规格中定义该字段的默认值。

完整的 ATD 规格 相当精细,并在线提供文档。ATD 编译器还可以面向 JSON 以外的格式,并能输出其他语言(例如 Java)的代码,以便在需要时获得更多互操作性。

还有几个类似项目可以自动化代码生成过程。Piqi 支持 XML、JSON 和 Google protobuf 格式之间的转换;Thrift 支持许多其他编程语言,并包含 OCaml 绑定。

19.6.4 示例:查询 GitHub 组织信息(Example: Querying GitHub Organization Information)​

最后用一个来自 GitHub 的实时 JSON 解析示例收尾,构建一个通过 GitHub API 查询组织信息的工具。先查看 GitHub 的在线 API 文档,了解检索组织信息时 JSON schema 的样子。

现在创建一个覆盖所需字段的 ATD 文件。响应中出现的任何额外字段都会被 ATD 解析器忽略,因此不需要为 GitHub 可能发送回来的每个字段提供完全穷尽的规格:

type org = {
login: string;
id: int;
url: string;
?name: string option;
?blog: string option;
?email: string option;
public_repos: int
}

先通过对规格文件调用 atdgen -t 来构建 OCaml 类型声明:

$ dune build github_org_t.mli
$ cat _build/default/github_org_t.mli
(* Auto-generated from "github_org.atd" *)
[@@@ocaml.warning "-27-32-35-39"]

type org = {
login: string;
id: int;
url: string;
name: string option;
blog: string option;
email: string option;
public_repos: int
}

OCaml 类型与 ATD 规格有明显映射,但仍然需要把 JSON 缓冲区与这个类型相互转换的逻辑。调用 atdgen -j 会为我们在名为 github_org_j.ml 的新文件中生成这段序列化代码:

$ dune build github_org_j.mli
$ cat _build/default/github_org_j.mli
(* Auto-generated from "github_org.atd" *)
[@@@ocaml.warning "-27-32-35-39"]

type org = Github_org_t.org = {
login: string;
id: int;
url: string;
name: string option;
blog: string option;
email: string option;
public_repos: int
}

val write_org :
Bi_outbuf.t -> org -> unit
(** Output a JSON value of type {!org}. *)

val string_of_org :
?len:int -> org -> string
(** Serialize a value of type {!org}
into a JSON string.
@param len specifies the initial length
of the buffer used internally.
Default: 1024. *)

val read_org :
Yojson.Safe.lexer_state -> Lexing.lexbuf -> org
(** Input JSON data of type {!org}. *)

val org_of_string :
string -> org
(** Deserialize JSON data of type {!org}. *)

Github_org_j 序列化器接口包含在 OCaml 类型和 JSON 之间映射所需的一切。使用这个接口最简单的方式是使用 string_of_org 和 org_of_string 函数;如果需要更高性能,也可以使用更高级的底层缓冲区函数,但本教程不会深入讨论。

要完成示例,只需要一个 OCaml 程序来获取 JSON,并使用这些模块输出一行摘要。下面的示例正是这样做的。

下面代码使用 Shell 接口调用 cURL 命令行工具,运行外部命令并捕获其输出。运行示例之前,需要确保系统已经安装 cURL。如果之前没有安装过,也可能需要执行 opam install shell:

open Core

let print_org file () =
let url = sprintf "https://api.github.com/orgs/%s" file in
Shell.run_full "curl" [url]
|> Github_org_j.org_of_string
|> fun org ->
let open Github_org_t in
let name = Option.value ~default:"???" org.name in
printf "%s (%d) with %d public repos\n"
name org.id org.public_repos

let () =
Command.basic_spec ~summary:"Print Github organization information"
Command.Spec.(empty +> anon ("organization" %: string))
print_org
|> Command_unix.run

下面是一个简短的 shell 脚本,它生成所有 OCaml 代码,并构建最终可执行文件:

(rule
(targets github_org_j.ml github_org_j.mli)
(deps github_org.atd)
(mode fallback)
(action (run atdgen -j %{deps})))

(rule
(targets github_org_t.ml github_org_t.mli)
(deps github_org.atd)
(mode fallback)
(action (run atdgen -t %{deps})))

(executable
(name github_org_info)
(libraries core yojson atdgen shell core_unix.command_unix)
(flags :standard -w -32)
(modules github_org_info github_org_t github_org_j))
$ dune build github_org_info.exe

现在可以用单个参数运行这个命令行工具,指定组织名称。它会动态从 Web 获取 JSON、解析它,并把摘要渲染到控制台:

$ dune exec -- ./github_org_info.exe mirage
MirageOS (131943) with 125 public repos
$ dune exec -- ./github_org_info.exe janestreet
??? (3384712) with 145 public repos

janestreet 查询返回的 JSON 缺少组织名称,但这会在 OCaml 类型中被显式反映出来,因为 ATD 规格把 name 标记为可选字段。我们的 OCaml 代码显式处理这种情况,不必担心空指针异常。同样,id 的 JSON 整数会通过 ATD 转换映射为原生 OCaml 整数。

虽然这个工具显然很简单,但指定可选字段和默认字段的能力非常强大。可以在线查看 ocaml-github 仓库中 GitHub API 的完整 ATD 规格,那里有许多真实 Web API 中典型的怪异细节。

我们的示例通过命令行 shell 到 curl 来获取 JSON,这相当低效。你也可以像第 17 章“使用 Async 的并发编程(Concurrent Programming with Async)”中描述的那样,把基于 Async 的 HTTP 获取直接集成进 OCaml 应用。