第五章 数据类型和默认值
本章涵盖:
- 我们对数据类型的理解
- 空值与不设置任何值之间的区别
- 对各种基本数据类型和集合数据类型的探讨
- 如何处理各种数据类型的序列化
在设计任何API时,我们总是要考虑接受、理解和可能存储的数据类型。有时这听起来可能相当简单:一个名为“name”的字段可能只是一串字符。然而,在这个问题中隐藏着复杂的世界。例如,字符串应该如何表示为字节(事实上有很多选项)?如果在API调用中省略了名称会发生什么?这是否与提供一个空的名称有所不同(例如,{ "name": "" }
)?在本章中,我们将探讨在设计或使用API时几乎肯定会遇到的各种数据类型,以及如何最好地理解它们的基础数据表示方式,以及如何以明智且直观的方式处理各种类型的默认值。
5.1 数据类型介绍
数据类型是几乎每种编程语言中的重要方面,它告诉程序如何处理一块数据以及它应该如何与语言或类型本身提供的各种运算符进行交互。即使在具有动态类型的语言中(其中单个变量可以充当许多不同的数据类型,而不仅仅是限制为一个类型),在单个时间点的类型信息仍然非常重要,它指导编程语言应该如何处理变量。
然而,在设计API时,我们必须摆脱仅考虑单一编程语言的思维模式,因为我们API的主要目标之一是让使用任何编程语言的人与服务进行交互。我们通常通过依赖某种序列化协议来实现这一点,该协议将我们选择的编程语言中的数据的结构化表示转换为一种与语言无关的序列化字节表示,然后将其发送到请求它的客户端。在另一端,客户端反序列化这些字节并将它们转换回内存表示,以便他们可以用他们使用的语言进行交互(这可能与我们使用的语言相同,也可能不同)。请参见图5.1了解此过程。
图5.1 数据从API服务端转移至客户端
虽然这个序列化过程提供了巨大的好处(基本上允许API被任何编程语言使用),但它并非没有缺点。最大的缺点之一是由于每种语言的行为略有不同,因此在翻译过程中会有一些信息丢失。换句话说,由于不同编程语言的功能差异,所有序列化协议在某种程度上都会有“损失”。
这将我们带回到数据类型的重要性以及如何在API中使用它们。简而言之,仅依赖于我们选择的编程语言提供的数据类型是不够的,尤其是当考虑到使用Web API的人时。相反,我们必须考虑我们选择的序列化格式(最常见的是JSON)提供的数据类型,以及在它不能满足我们需求的情况下如何扩展该格式。这意味着我们需要决定要发送到客户端和从客户端接收的数据类型,并确保对它们进行足够详细的文档化,以便客户端永远不会对它们的行为感到惊讶。
这并不是说Web API必须像关系数据库系统那样具有严格的模式;毕竟,现代开发工具中最强大的功能之一就是动态无模式结构和数据存储提供的灵活性。然而,重要的是要考虑我们正在交互的数据类型,并在必要时用额外的注释来澄清。没有这些有关数据的额外上下文,我们可能会陷入猜测客户端实际意图的困境。例如,对于数字值,a + b可能会以一种方式工作(例如,2 + 4可能会得到6),但对于文本值,它可能会完全不同(例如,“2” + “4”可能会得到“24”)。如果没有这种类型上下文,当客户端使用+运算符时,我们将被迫猜测它的意图:是加法还是连接?而且如果一个值是数字而另一个值是字符串呢?这可能导致更多的猜测。那么,如果一个值完全被省略呢?
5.1.1 省略 vs NULL
令人惊讶的是,最令人困惑的一个方面是数据缺失而不是存在的情况。首先,在许多序列化格式中,存在一个null
值,它是一个表示非值的标志(例如,字符串可以是值,也可以是文字null
)。那么,例如,API在尝试添加null
和2
时应该如何行为?任何使用支持此类null
值的序列化格式的API都需要决定如何最好地处理其API的此类输入。它应该假装null
在数学上等同于0
吗?试图添加null
和"value"呢?在这种情况下,null
是否应该被解释为空字符串(“”)并尝试连接它呢?
更糟糕的是,动态数据结构(例如JSON对象)有一个新问题:如果值根本不存在怎么办?换句话说,与显式设置为null的值相比,如果存储该值的键完全缺失了呢?这是否被视为与明确的null值相同?为了了解这意味着什么,想象一下你有一个期望包含名称和颜色的Fruit
资源,两者都是字符串数据类型。考虑以下示例JSON对象以及API可能为资源的颜色值辨别出的值:
fruit1 = { name: "Apple", color: "red" };
fruit2 = { name: "Apple", color: "" };
fruit3 = { name: "Apple", color: null };
fruit4 = { name: "Apple" };
如您所见,第一个颜色值是明显的(fruit1.color == "red"
)。然而,对于其他情况应该怎么办呢?空颜色(“”)是否与显式null颜色相同(fruit2.color
是否与fruit3.color
有所不同)?那么缺少颜色值的水果呢?fruit3.color
是否与fruit4.color
有所不同?这些可能只是JSON的缺陷,然而,它们确实存在于许多其他序列化格式中(例如,Google的Protocol Buffers [https://opensource.google/projects/protobuf] 在这个领域有一些令人困惑的行为),它们提供了每个API都必须解决的场景,否则可能会对使用API的人产生极大的不一致和混乱。换句话说,您不能简单地假设序列化库会做正确的事情,因为几乎可以肯定每个人都会使用不同的序列化库!
在本章中,我们将遍历所有常见的数据类型,从简单的(有时称为基本数据类型)开始,逐渐过渡到更复杂的类型(如映射和集合)。您应该已经对这些概念大致熟悉,因此重点将不再是每种数据类型的基础知识,而更多地关注在Web API中使用它们时需要考虑的问题。我们将深入研究每种数据类型的不同“陷阱”,以及如何最好地为任何API解决这些问题。我们还将坚持使用JSON作为首选的序列化格式,但大多数建议都适用于支持动态数据结构的任何格式,包括隐式和显式类型定义。让我们从最简单的开始:true
和 false
值。
5.2 布尔类型
在大多数编程语言中,布尔值是最简单的数据类型,表示两个值中的一个:true
或 false
。由于这个有限的值集,我们倾向于依赖布尔值来存储标志(flag)或简单的是或否问题。例如,我们可能会在聊天室上存储一个标志,用于指示房间是否已存档或是否允许在房间中使用聊天机器人。
代码5.1 带有布尔标志的聊天室
interface ChatRoom {
id: string;
// ...
archived: boolean;
allowChatbots: boolean;
}
然而,布尔标志在将来的情况下可能会受到限制,其中是或否的问题可能会变成一个更一般的问题,这就需要其他方式能够比布尔字段提供更细致的答案。例如,可能会出现这样的情况,聊天室允许各种不同类型的参与者,而不仅仅是普通用户和聊天机器人。在这种情况下,我们目前的设计将导致一个包含每种允许(或不允许)的类型的长列表,例如 allowChatbots
、allowModerators
、allowChildren
、allowAnonymousUsers
等等。如果这是一种可能性,实际上可能更明智的做法是避免使用一组布尔标志,而是依赖于不同的数据类型或结构来定义哪些参与者被允许或不允许进入特定的聊天室。
假设布尔字段是变量的正确选择,还有一些其他有趣的方面需要考虑。在布尔字段的名称中隐藏着字段是正面还是负面的陈述。在 allowChatbots
的例子中,字段的 true
值表示允许聊天机器人进入聊天室。但它同样可以是 disallowChatbots
,其中 true
值将禁止聊天机器人进入聊天室。为什么要选择其中之一呢?
对于大多数人来说,正面的布尔字段最容易理解,原因很简单:人们在处理双重否定时需要花费更多思考。而对于否定字段的 false
值也是如此。例如,想象一下字段是 disallowChatbots
。您如何检查在给定的聊天室中是否允许聊天机器人呢?
代码5.2 添加机器人到聊天室
function addChatbotToRoom(chatbot: Chatbot,
roomId: number): void {
let room = getChatRoomById(roomId);
if (room.disallowChatbots === false) {
chatbot.join(room.id);
}
}
这是不是难以理解?可能不是。是否比简单版本(if (room.allowChatbots) { ... }
)增加了一些认知负担?对于大多数人来说,可能是的。仅仅基于这一点,选择使用正面的布尔标志总是更明智的。但在考虑字段的默认或未设置值时,也存在一些场景可能更适合依赖负面标志。
例如,在某些语言或序列化格式中(例如,直到最近,Google 的 Protocol Buffers v3),许多基础类型,如布尔字段,只能具有零值,而不能是 null
或缺失值。正如您可能猜到的,对于布尔值,零值几乎总是等于 false
。这意味着当我们考虑在创建聊天室时的 allowChatbots
字段的值时,如果没有进一步的干预,聊天室不允许聊天机器人(defaultChatRoom.allowChatbots == false
)。但我们无法区分用户是否说:“我没有设置这个值,请按照你认为最好的方式处理”(即 null
或缺失值)和“我不想允许聊天机器人”(即明确的 false
值)。在这两种情况下值都只是 false
。我们该怎么办呢?
虽然有很多解决这个问题的方法,但一种常见的选择是依赖布尔字段名称的正面或负面特性,以确定“正确”的默认值。换句话说,我们可以选择为布尔字段命名,使得零值提供我们想要的默认值。例如,如果我们希望默认允许匿名用户,我们可以决定将字段命名为 disallowAnonymousUsers
,以便零值(false
)会导致我们想要的默认值(允许匿名用户)。不幸的是,这种选择将默认值锁定在字段的名称中,这会限制未来的灵活性(即您无法在以后更改默认值),但正如人们所说,非常情况用非常手段。
5.3 数值类型
比起简单的是或否问题,数值字段稍微复杂一些,它们允许我们存储各种有价值的信息,如计数(例如 viewCount
)、尺寸和距离(例如 itemWeight
)或货币值(例如 priceUsd
)。总体而言,任何我们可能想要进行算术计算的内容(即使这些计算只是在逻辑上有意义),数值字段都是理想的数据类型选择。
有一个显著的例外实际上并不适合这种数据类型:数值标识符(ID)。这可能让人感到惊讶,因为许多数据库系统(几乎所有关系数据库系统)使用自动增加的整数值作为行的主键标识符。那么为什么我们在API中不这样做呢?
虽然我们可能出于各种原因(例如性能或空间限制)在底层仍然使用数值字段,但在API界面上,数值类型最适合于可以提供算数方面便利的情景。换句话说,如果API公开的物品具有以克为单位的重量值,我们可以想象将所有这些值相加,以确定这些物品的组合重量。而另一方面,一组数值标识符的相加并不会产生什么意义。因此,重要的是要依赖数值数据类型的数值或算术好处,而不是它们碰巧只用数字字符编写(这里或那里可能有小数点)。通常,那些只看起来像数字但更像令牌或符号的值可能应该是字符串值而不是数值(参见第5.4节)。
话虽如此,在API中使用数值值时,有一些事项需要考虑。让我们首先看看如何定义这些数值字段的可接受值的范围或界限。
5.3.1 界限
在定义数值字段的边界时,无论是整数还是小数(或其他数学表示,如分数、虚数等),都需要考虑上限或最大值、下限或最小值以及零值。由于它们彼此之间存在影响,让我们考虑绝对值边界,然后决定是否利用该范围的负面和正面。
在边界方面,我们主要关注它们的大小。换句话说,所有数字最终都需要存储在某个地方,这意味着我们需要知道为这些值分配多少位的空间。对于小整数,可能8位足够了,有256个可能的值(从-127到127)。对于大多数常见的数字值,32位通常是可接受的大小,有大约40亿个可能的值(从负20亿到正20亿)。对于我们可能关心的大多数情况,64位是一个安全的大小,有大约18万亿个可能的值(从负9万亿到正9万亿)。当我们引入浮点数时,这个范围变得有些混乱,因为有很多不同的表示,存储这些值的位数多达256位,但关键是通常有一种表示方式适用于您在API中考虑的范围。
虽然这些都是可以接受的选择,您可能会想知道为什么我们要关注存储在某个数据库中的数字的大小。毕竟,API的目的不就是要抽象掉所有这些吗?这是正确的;但是,这很重要,因为不同的计算机和不同的编程语言处理数字的方式并不一致。例如,JavaScript甚至没有一个合适的整数值,而是只有一个处理语言所有数值的数字类型。此外,许多语言以非常不同的方式处理非常大的数字。例如,在Python 2中,int类型可以存储32位整数,而long类型可以处理任意大的数字,最少存储36位。简而言之,这意味着如果API响应向消费者发送一个非常大的数字,接收端的语言可能无法正确解析和理解它。此外,那些可能收到这些巨大数字的人需要知道为存储它们分配多少空间。简而言之,边界将非常重要。
因此,通常的做法是在内部使用64位整数类型来处理整数,除非有很好的理由不这样做。一般来说,即使当前可能根本不需要接近64位范围,但在软件中绝对的确定性是罕见的,因此更安全的做法是设置合理上下限,使其具有一定的增长空间。
5.3.2 默认值
如我们在5.1.1节中学到的那样,对于大多数数据类型,我们还有其他情景需要考虑,特别是空值和缺失值。其中字段被简单省略,就像布尔值(5.2节)一样,但在不提供空值的序列化格式中,我们将无法区分0(或0.0)的真值和用户表示“我在这里没有意见,所以你可以为我选择最好的”默认值。
虽然有可能依赖零值作为默认值的标志,但这有一些问题。首先,零值作为API的一部分实际上可能是有意义且是必要的;然而,如果我们将其用作默认值的指示器,我们将无法指定零的实际值。换句话说,如果我们使用0来表示“做最好的选择”(在这种情况下可能是57),我们无法刻意指定零值(因为0用来代表了一种默认情况并映射到其他值)。其次,在零值可能具有逻辑意义的情况下,将此值用作标志可能令人困惑,并导致意外后果,违反了一些良好API的关键原则(在这种情况下是可预测性)。为了回答处理数值的默认值的问题,我们实际上需要转换思路,并更详细地讨论这些值应该如何序列化。
5.3.3 序列化
正如我们在5.3.1节中学到的,一些编程语言对待数值与其他语言不同。这在我们有非常大的数字时特别明显,至少在32位数字以上,但在超过64位限制的数字中更为明显。在处理小数时,由于浮点精度的问题,这也经常出现,这是这种格式设计的众所周知的缺点。
不过,最终我们需要将数值发送给API用户(并从这些用户那里接收传入的数值)。如果我们打算依赖于序列化库而不深入挖掘任何更深层次的东西,那么我们可能会感到非常失望。
代码5.3 两个不同的大数被视为相等
const compareJsonNumbers(): boolean {
// 这两个数字相差1。但是如果我们使用JSON解析后再比较,
// Node.JS会显示他们相等。
const a = JSON.parse('{"value": 9999999999999999999999999}');
const b = JSON.parse('{"value": 9999999999999999999999998}');
return a['value'] == b['value'];
}
这个问题不仅仅局限于大整数。在处理小数时,浮点数算术也存在问题。
代码5.4 两个浮点数相加时的问题
const jsonAddition(): number {
const a = JSON.parse('{"value": 0.1}');
const b = JSON.parse('{"value": 0.2}');
// 很遗憾,这里会返回0.30000000000000004而非0.3
return a['value'] + b['value'];
}
那么我们该怎么办呢?代码5.4中的情况足以表明,使用数值并假设它们在各种语言中都相同可能会让人感到非常担忧。答案可能会让一些纯粹主义者感到沮丧,但它确实效果很好:使用字符串。
这种简单的策略,在序列化时将数值表示为字符串值,只是一种避免这些基本数值被解释为实际数值的机制。相反,这些值本身可以由一个库来解释,该库可能更擅长处理这些类型的情况。换句话说,与其让一个JSON库将0.2解析为JavaScript的Number类型,我们可以使用Decimal.js等库将值解析为任意精度的十进制类型。
代码5.5 避免两个浮点数相加的问题
const Decimal = require('decimal.js');
const jsonDecimalAddition(): number {
// 注意这里的数值是字符串类型
const a = JSON.parse('{"value": "0.1"}');
const b = JSON.parse('{"value": "0.2"}');
// 当我们使用一个类似Decimal.js这样的任意精度函数库,
// 我们可以得到正确的值0.3。
return Decimal(a['value']).add(Decimal(b['value']);
}
由于这种策略的基础依赖于字符串,让我们花一些时间来探讨字符串字段。
5.4 字符串
在几乎所有的编程语言中,我们往往认为字符串是理所当然的东西,而不真正了解它们在底层是如何工作的。即使是那些花时间学习C语言如何处理字符串的人,也倾向于回避字符编码和Unicode的广阔世界。虽然成为Unicode、字符编码或其他与字符串相关的主题的专家并非必须,但在考虑如何在API中处理字符串时,有一些东西是相当重要的。由于API中的大多数字段通常是字符串,这显得尤为重要。
在西方世界,可以相对安全地将字符串视为表示文本内容的单个字符的集合。这是一个相当大的概括,但这不是一本关于Unicode的书籍,因此我们必须概括处理。由于我们想要在API中传达的大部分内容都是文本性质的,字符串可能是所有可用数据类型中最有用的。
在构建API时,字符串也可能是最通用的数据类型。它们可以处理像名称、地址和摘要这样的简单字段;它们可以负责处理长篇文本;在序列化格式不支持通过网络发送原始字节的情况下,它们可以表示编码的二进制数据(例如,Base64编码的字节)。对于存储唯一标识符,字符串也是最合适的选项,即使这些标识符看起来更像是数字而不是文本。在底层可能将它们存储为字节(有关更多信息,请参阅第6章),但在API中的表示几乎肯定最适合使用字符字符串。
在我们开始赞美字符串是世界的救世主之前,让我们花一些时间看看字符串字段的一些潜在陷阱以及我们如何最有效地使用它们,首先是边界条件。
5.4.1 界限
正如我们在第5.3.1节中学到的,边界条件很重要,因为最终我们必须考虑分配多少空间来存储数据。就像在数字值中一样,对于字符串值,我们也需要考虑相同的方面。如果您曾经为关系数据库定义模式(schema),并最终得到了VARCHAR(128)
之类的东西,其中的128是一个完全随意的选择,那么您应该对这种有时令人不悦的必要性很熟悉。
正如我们从数字中学到的那样,这些大小限制很重要,因为接收数据的一方需要知道分配多少空间来存储这些值。就像数字一样,由于在API生命周期的早期低估而导致增加大小是一种相当令人不适和不幸的情况。因此,通常最好在选择字符串字段的最大长度时选择向上取整。
然而,值得解决的下一个问题是如何定义最大长度。事实证明,我们最初将字符串定义为字符的集合仅在一些有限的情况下有效,因为许多编程语言并没有像我们期望的那样密切遵循这个概念。然而,更大的问题是,存储空间(磁盘上的字节)的单位与字符串长度的测量单位(我们称之为字符)之间并没有一对一的关系。我们该怎么办呢?
为了避免写一整章关于 Unicode 的内容,对于这个问题的最简单答案是继续以字符为单位思考(即使这些实际上是 Unicode 代码点),然后假设存储使用最冗长的序列化格式:UTF-32。这意味着当我们存储字符串数据时,我们为每个字符分配4个字节,而不是使用 ASCII 时可能期望的单个字节。
尽管存在存储空间的难题,但对于每个字符串字段,我们还需要考虑另一个方面:处理过多的输入。对于数值,超出范围的数字可以被API安全地拒绝,并附上友好的错误消息:“请在0到10之间选择一个值。”对于字符串值,我们实际上有两种不同的选择。我们可以始终拒绝超出范围的值,就像数字超出范围一样,但根据具体情况和上下文,这可能有点不必要。或者,我们可以选择在超出定义的限制后截断文本。
虽然截断可能看起来是个好主意,但它可能会误导和令人困惑,这两者都不是一个好的API的特征,正如我们在第1章中探讨的那样。这还为API引入了一组新的选择(这个字段应该截断还是拒绝?),这可能会导致更多的不可预测性,因为不同的字段展现出不同的行为。因此,与数字一样,通常最明智的做法是拒绝任何超出字段定义限制的输入。
5.4.2 默认值
与数字和布尔字段类似,许多序列化格式并不一定允许存在一个明确的空值(null
),与零值(“”)不同。因此,确定用户是指定字符串为空字符串,还是用户要求API“根据上下文做最好的选择”是很困难的。
幸运的是,有很多选项可供选择。在许多情况下,空字符串根本不是字段的有效值。因此,空字符串确实可以用作指示默认值的标志。在其他情况下,字符串值可能具有一组特定的适当值,空字符串是其中的一种选择。在这种情况下,允许选择 “default” 作为一个标志指示默认值是完全合理的。
5.4.3 序列化
由于 Unicode 标准的普及,几乎所有的序列化框架和编程语言都以基本相同的方式处理字符串。这意味着,与我们在第 5.3 节中探讨的数值值不同,我们的重点不再是精度或微妙的溢出错误,而更多地是安全地处理字符串,这些字符串可能跨越整个人类语言谱系,而不仅仅是西方世界所使用的字符。
在底层,字符串实际上只是一组字节。然而,我们如何解释这些字节(编码)告诉我们如何将它们转换为看起来像实际文本的东西 - 无论我们正在使用的语言是什么。简而言之,这意味着当我们准备在 API 服务器上序列化字符串时,我们不能只是以我们当前使用的任何编码将其发送回去。相反,我们应该在所有请求和响应中始终一致地使用单一的编码格式。
虽然有很多编码格式(例如 ASCII、UTF-8 [https://tools.ietf.org/html/rfc3629]、UTF-16 [https://tools.ietf.org/html/rfc2781] 等),但世界已经趋向于使用 UTF-8,因为它对于大多数常见字符来说相当紧凑,同时仍然灵活到足以编码所有可能的 Unicode 代码点。大多数面向字符串的序列化格式(例如 JSON 和 XML;https://www.w3.org/TR/xml/)已经确定使用 UTF-8,而其他格式(例如 YAML)则没有明确注明必须使用哪种编码。简而言之,除非有很好的理由不这样做,API 应该使用 UTF-8 编码所有的字符串内容。
如果你认为这是一个我们可以让库来处理的地方,你几乎是对的,但还不太对。事实证明,即使在我们指定了一个编码之后,由于 Unicode 的规范化形式 (https://unicode.org/reports/tr15/#Norm_Forms),仍然有多种方法来表示相同的字符串内容。可以将其视为表示数字 4 的各种方式:4、1+3、2+2、2*2、8/2 等。在 UTF-8 编码的字符串中,可以使用相同的二进制表示方式。例如,字符 “è” 可以表示为单个特殊字符 (“è” 或 0x00e9),也可以表示为基本字符 (“e” 或 0x0065) 与重音字符 (“`” 或 0x0301) 的组合。这两种表示在视觉和语义上是相同的,但它们在磁盘上的字节表示不同,因此对于进行精确匹配的计算机来说,它们是完全不同的值。
为了解决这个问题,Unicode (http://www.unicode.org/versions/Unicode13.0.0/) 支持不同的规范化形式,并且一些序列化格式(例如 XML)标准化了特定的形式(在 XML 的情况下,规范化形式 C),以避免混合和匹配这些在语义上相同的文本表示。尽管这对于表示 API 资源描述的字符串可能没有那么重要,但当字符串表示标识符时,这变得非常重要。如果标识符具有不同的字节表示,那么可能相同的语义标识符实际上指的是不同的资源。因此,API 最好拒绝不使用规范化形式 C 进行 UTF-8 编码的字符串,而对于表示资源标识符的字符串来说,这是绝对必要的。有关标识符及其格式的更多信息,请参阅第 6 章。
5.5 枚举类型
枚举类型(Enumerations), 类似于程序员的下拉选择器,是强类型编程语言世界中的基本元素。虽然在诸如Java之类的语言中它们可能非常有用,但将它们移植到Web API的世界中通常是一个错误。
虽然枚举可能非常有价值,因为它们既充当验证的形式(只有指定的值被视为有效),又具有压缩性(通常每个值由一个数字表示,而不是我们在代码中引用的文本表示),但当涉及到Web API时,这两个方面通常是有代价的,即降低了灵活性和清晰度。
代码5.6 在API中的枚举类型
enum Color {
Brown = 1,
Blue,
Green,
}
interface Person {
id: string;
name: string;
eyeColor: Color;
}
例如,让我们考虑列表5.6中的枚举。如果我们使用真实的整数值,我们可能最终会得到 person.eyeColor
被设置为 2。显然,这比 person.eyeColor
被设置为 “blue” 更加混乱,因为前者要求我查找数字值的实际含义。在代码中,这通常不是问题;然而,在查看请求日志时,这可能变得相当繁琐。
此外,当服务器上添加新的枚举值时,客户端将需要更新其本地映射的副本(通常需要更新客户端库),而不仅仅是发送不同的值。更可怕的是,如果API决定添加一个新的枚举值,而客户端没有被告知该值的含义,客户端代码将感到困惑并不知道该怎么办。
例如,考虑API添加了一个名为 Hazel
(#4) 的新颜色值的情况。除非客户端代码已更新以适应这个新值,否则我们可能会陷入一个相当令人困惑的场景。另一方面,如果我们使用不同类型的字段(比如字符串),我们可能会由于先前未知的值而遇到类似的错误,但我们不会被该值的含义所困扰("hazel"比4更清晰)。
简而言之,在可以使用其他类型(比如字符串)的情况下,通常应避免使用枚举。当预计会添加新值时,尤其是当存在该值的某种标准时,这一点尤为真实。例如,与其使用枚举来表示有效的文件类型(PDF、Word文档等),更安全的做法是使用一个允许特定媒体类型(以前称为MIME类型)的字符串字段,如 application/pdf
或 application/msword
。
5.6 列表
现在我们对各种基本数据类型有了一定的了解,我们可以开始深入研究这些数据类型的集合,其中最简单的是列表或数组。简而言之,列表只不过是一组其他数据类型,例如字符串、数字、映射(参见第5.7节)或甚至其他列表。这些集合通常可以通过索引或在列表中的位置进行访问,使用方括号表示法(例如,items[2]),并且几乎所有的序列化格式都支持这种表示(例如,JSON)。
虽然并非所有存储系统都原生支持项目列表,但请记住,API的目标是为远程用户提供最有用的接口,而不是暴露数据库中的数据。话虽如此,列表是当今Web API中最常被误用的结构之一。那么何时应该使用列表呢?
总的来说,列表非常适用于简单的基本集合,这些集合代表API资源的某些固有属性。例如,如果您有一个“Book(书)”资源,您可能希望显示关于该书的一系列类别或标签,这可能最好表示为一个字符串列表。
代码5.7 在Book资源中存储一个类别列表
interface Book {
id: string;
title: string;
categories: string[];
}
5.6.1 原子性
上个例子中隐藏着一些很重要的内容:尽管列表字段包含多个项目,但最好将这些项目被视为数据的一个原子部分,在使用时进行完全替换而不是分开修改。换句话说,如果我们想要更新“Book”资源上的类别(categories
),我们应该通过完全替换其项目列表来实现。换句话说,在列表字段中永远不应该有更新单个元素的方式。这其中有许多很好的理由,比如当我们通过它们在列表中的位置来引用项目时,顺序变得非常重要;或者我们可能不能保证插入新项目时,不会将感兴趣的项目推到一个新的位置。
此外,与资源上的任何其他字段一样,允许数据从两个不同的地方存储和修改几乎总是一个坏主意,因此最好避免允许多种方法更新列表字段中的内容。这是一种特别诱人的做法,因为我们在关系数据库中被训练依赖规范化原则。例如,如果我们使用像MySQL这样的数据库存储“Book”资源上的这个类别列表,我们实际上可能会有一个单独的“BookCategories”表,具有唯一标识符、指向书籍的外键以及一个具有唯一性约束的类别字符串。很多人忍不住想要通过公开一个API来允许通过提供书籍的唯一ID和我们要编辑的类别来更新这些图书类别(因为在技术上这足以唯一标识所涉及的行)。
允许这种修改方式会引发与一致性相关的一系列令人担忧的问题:有可能在某人设置类别列表的同时,另一个人正在使用不同的入口编辑单个类别。在这种情况下,即使依赖于其他事务隔离机制(例如ETags),也不会有太多用处,会导致API充满了意外。
对于列表值的一个良好的经验法则是几乎将它们视为实际上是项目的JSON编码字符串。换句话说,你不是在设置book.categories = ["history", "science"]
,而是更接近book.categories = "[\"history\", \"science\"]"
。你不会期望API允许你修改字符串中的单个字符,所以也不要期望API允许你修改列表字段中的单个条目。
有关资源及其相互关系的更多探讨,请参阅第4部分,特别是第13、14和15章,它们都涉及使用列表字段在Web API中表示关系数据的概念。
下一步需要考虑的是列表是否应该允许在同一个列表中混合不同的数据类型。换句话说,是否在同一个列表中混合使用字符串值和数字值是个好主意?虽然这样做并非完全不可行,但可能会导致一些混淆,特别是考虑到第5.3.3节关于处理大数字和小数的指导。这些值可能被表示为字符串,因此很难弄清楚给定的条目实际上是字符串还是仅以字符串表示的数值。基于此,通常最好坚持使用单一数据类型,并保持列表值的同质性。
5.6.2 界限
最后,关于列表值的一个非常常见的情景涉及到大小:当列表变得很长以至于难以管理时会发生什么?为了避免这种令人沮丧的情况,所有的列表都应该有一些界限,指定它们可能具有的最大项数以及每个项可能的大小(参见第5.3.1或5.4.1节,了解如何处理数值和字符串数据类型的界限)。此外,超出这些边界的输入应该被拒绝而不是被截断(原因与第5.4.1节中解释的相同)。这有助于避免因异常庞大的列表而产生的意外和困惑,这些列表可能会意外地增长到难以处理的大小。
如果很难估算列表的潜在大小,或者有充分的理由怀疑它可能会在没有硬性边界的情况下增长,那么依赖于实际资源的子集合而不是资源上的内联列表可能是一个不错的主意。虽然这可能看起来很繁琐,但在未来,当资源不会因为无界列表字段而变得庞大时,API将更加易于管理。然而,如果你陷入了这个境地,可以查看分页模式(第21章),它可以帮助将庞大且难以处理的资源转化为更小且易于处理的块。
5.6.3 默认值
与字符串类似,在某些序列化格式和库中,很难区分零值(对于列表是[])和空值。然而,与字符串不同的是,将空列表作为值几乎总是合理的,这使得API几乎不可能依赖于空列表值来表示请求使用某个默认值而不是空列表。这给我们留下了一个复杂的问题:在这种情况下,如何指定你希望该值是默认值而不是字面上的空列表?
不幸的是,在这类情况下似乎没有任何简单而优雅的答案。要么可以在创建时使用默认值(假设空列表对于新创建的资源来说是无效的),要么列表值根本不是此信息的正确数据类型。如果前一种情况由于某种原因不起作用,唯一的安全选择是抛弃使用內联列表类型,并将这些数据作为集合由子资源分开管理。显然,这不是一个理想的解决方案,但鉴于限制,它肯定是最安全的选择。
5.7 映射
最后,我们可以讨论最多功能且有趣的数据类型:映射(Map)。在本节中,我们将实际考虑两种相似但不同的基于键值的数据类型:自定义数据类型(我们一直称之为资源或接口)和动态键值映射(通常是JSON中称为对象或映射的东西)。尽管这两者并不相同,它们之间的区别有限,主要区别在于是否存在预定义的模式(schema)。换句话说,映射是键值对的任意集合,而自定义数据类型或子资源可能以相同的方式表示;然而,自定义数据类型具有一组预定义的键以及特定类型的值。让我们首先看看具有预定义模式的那种。
随着资源演变以表示越来越多的信息,一个典型的步骤是将相似的信息组合在一起,而不是保持一切都是扁平的。例如,随着我们为ChatRoom资源添加越来越多的配置,我们可能决定将其中一些配置字段组合在一起。
代码5.8 直接在资源中存储数据,以及将数据存储在其他的结构中
// 所有字段直接扁平化存储在资源中
interface ChatRoomFlat {
id: string;
name: string;
password: string;
requirePassword: boolean;
allowAnonymousUsers: boolean;
allowChatBots: boolean;
}
// 把访问聊天室相关的字段存储到其他结构中
interface ChatRoomReGrouped {
id: string;
name: string;
securityConfig: SecurityConfig;
}
interface SecurityConfig {
password: string;
requirePassword: boolean;
allowAnonymousUsers: boolean;
allowChatBots: boolean;
}
在这种情况下,我们只是根据它们对控制安全性和访问聊天室的共同主题,将几个字段简单地组合在一起。这种抽象层次只有在我们真正考虑将资源的相似字段组合在一起时才有意义。另一方面,如果字段与资源相关但在资源的上下文之外基本上是不同且有意义的,那么使用单例子资源可能是值得的,详情请参阅第12章。
现在我们已经探讨了具有预定义模式的自定义数据类型,让我们花一点时间来看看动态键值映射。尽管它们最终可能以相同的方式呈现,但这两种结构通常以非常不同的方式使用。而我们使用自定义数据类型将相似字段折叠在一起并将其隔离在单个字段中,动态键值映射更适合存储没有预期结构的任意动态数据。换句话说,自定义数据类型只是一种重新排列我们知道的字段并希望更好地组织的方法,而映射更适合带有在定义API时未知的键的动态键值对。此外,虽然这些键可能会在资源之间有一些重叠,但绝对不要求像自定义数据类型一样,所有资源必须有相同的键。
这种键值对的任意结构非常适合诸如动态设置或依赖于资源特定实例的配置之类的事物。为了了解这意味着什么,想象一下我们有一个用于管理杂货店商品信息的API。我们显然需要一种方法来说明每个产品中包含的成分以及其数量,例如3克糖、5克蛋白质、7毫克钙等。使用预定义架构列出所有可能的成分将是非常困难的,即使我们能够做到这一点,这些项的许多值将为零,因为每个项都有各种不同的成分。在这种情况下,动态映射可能是最合适的。
代码5.9 使用动态映射存储配方成分和数量
interface GroceryItem {
id: string;
name: string;
calories: number;
ingredientAmounts: Map<string, string>;
}
使用定义的模式,GroceryItem
的JSON表示可能类似于代码5.10。在这种情况下,成分是动态的,并且可以随每个不同的项目而变化。
代码5.10 配方成分映射的JSON表示示例
{
"id": "514a0119-bc3f-4e3f-9a64-8ad48600c5d8",
"name": "Pringles",
"calories": "150",
"ingredientAmounts": {
"fat": "9 g",
"sodium": "150 mg",
"carbohydrate": "15 g",
"sugar": "1 g",
"fiber": "1 g",
"protein": "1 g"
}
}
重要的是要注意,由于我们可以选择键的数据类型,原则上我们可以选择任何类型。但是某些类型的选择可能会是灾难性的(例如,一些不容易序列化的富数据类型)。其他可能看起来可以接受,但仍然是一个坏主意。例如,技术上允许数字值作为键类型可能有一定道理;然而,正如我们在第5.3.3节中了解到的那样,数字存在一些相当危险的问题,特别是当它们变大时,有时甚至只是小的十进制值也可能存在问题。因此,在键的数据类型中,字符串几乎肯定是最好的选择。
更重要的是,由于键是唯一的标识符,因此这些字符串值以UTF-8格式编码也很重要,并且正如我们在第5.4.3节中学到的(并且将在第6章中更多了解),这些字符串应该处于规范化分解(Normalization Form C),以避免由于字节表示问题导致的重复键值。
5.7.1 界限
自定义数据类型的模式避免了任何边界问题(其模式定义了自己的边界条件),而映射与列表非常相似,因为它们都很容易失控。因此,映射(就像列表一样)应该定义一个上限,规定字段中可能包含多少个键和值。这在设定字段内容可能增长到多大的期望方面变得非常重要。除此之外,还有必要设置每个键和每个值可以作为字符串值的大小限制(有关字符串值边界的更多信息,请参见第5.4.1节)。一般来说,这意味着指定一个键最多可能有大约100个字符,而值最多可能有大约500个字符。
在极少数情况下,值的大小可能分布不太均匀。因此,一些API选择对映射字段中存储的字符总数设定上限,允许用户自由决定如何表示这些不同的字符(一些非常小的值和一两个非常大的值)。虽然如果绝对必要,这是一种可接受的策略,但应该避免使用,因为它往往会导致越来越多的数据最终存储在映射中,而这些数据可能本来应该存储在其他地方。
5.7.2 默认值
与列表不同,在几乎所有序列化格式和编程语言中,很容易区分空映射值或零值({})和空值(null)。在这种情况下,让空值表示“API应根据API请求的其余部分执行其认为最好的操作”是相当安全的。另一方面,空映射是用户指定映射不应包含任何数据的方式,这本身就是一个有意义的表示。
5.8 练习
- 日本一家面向日语使用者的公司希望在其API的字符串字段中使用UTF-16编码而不是UTF-8。在做出此决定之前,他们应该考虑什么?
- 假设ChatRoom资源当前会存档24小时前的所有消息。如果要提供一种禁用此行为的方法,应该如何命名该字段?这种设计是最佳实践吗?
- 如果您的API是用本地支持任意大数的语言编写的(例如Python 3),将它们序列化为JSON字符串仍然是最佳实践吗?还是可以依赖于标准的JSON数字序列化?
- 如果要表示聊天室的母语,是否可以使用枚举值来表示支持的语言?如果可以,这个枚举会是什么样子?如果不行,什么数据类型最合适?
- 如果特定的映射的值大小分布非常不均匀,设置各个键和值的适当大小限制的最佳方法是什么?
本章总结
- 对于每个值,我们还需要考虑null值和未定义或缺失值,它们可能具有相同或不同的含义。
- 布尔值最适合用于标志,并且应命名为true值表示积极方面(例如,使用enableFeature而不是disableFeature)。
- 数值应具有真正的数值含义,而不仅仅由数字位组成。
- 为避免大数字(超过32或64位)或浮点算术问题,不具备适当本地表示的语言中,数值应以字符串形式序列化。
- 字符串应以UTF-8编码,并对任何用作任何标识符的字符串进行Normalization Form C规范化。
- 通常应避免使用枚举,而应使用字符串值,并在服务器端而不是客户端上进行验证。
- 列表应被视为原子集合项,仅在客户端上可以单独寻址。
- 列表和映射都应受到允许集合中的项目总数的限制;但是,映射还应该限制键和值的大小。