使用 GraphQL、ReasonML 和 TypeScript 中的标记联合进行域建模
GraphQL自 2015 年开源以来,人气一路飙升。对于那些花费大量时间管理后端基础设施数据转换以满足前端产品需求的开发者来说,GraphQL 的出现无疑是一个巨大的进步。手动编写BFF来处理过度获取问题的时代已经一去不复返了。
围绕 GraphQL 的许多价值主张论点都与过度/不足获取、获取所需数据形状等有关。但我认为 GraphQL 为我们提供的远不止这些——它使我们有机会提高领域的抽象级别,并通过这样做让我们能够编写更强大的应用程序,准确模拟我们在现实世界中面临的问题(不断变化的需求、一次性问题)。
GraphQL 的一个被低估的特性是它的类型系统,特别是联合类型和接口等特性。GraphQL 中的联合类型在计算机科学中通常被称为标记联合。
在计算机科学中,带标签联合(tagged union),也称为变体、变体记录、选择类型、可区分联合、不相交联合、和类型或余积,是一种用于保存可采用多种不同但固定类型的值的数据结构。同一时刻只能使用一种类型,并且标签字段会明确指示正在使用哪种类型。它可以被认为是一种具有多种“情况”的类型,在操作该类型时,每种情况都应得到正确处理。与普通联合一样,带标签联合可以通过重叠每种类型的存储区域来节省存储空间,因为同一时刻只能使用一种类型。
说了这么多,但这些真的重要吗?我们先来看一个简单的例子。
形状的形状
TypeScript 编译器支持分析可区分联合。在本文的其余部分,我将使用标记联合和可区分联合作为可互换的术语。根据文档,形成可区分/标记联合有三个要求:
- 具有共同的、单一类型属性的类型——判别式。
- 采用这些类型的并集的类型别名 — — 联合。
- 对公共属性进行类型保护。
让我们看一下示例代码,以确保我们真正理解我们的意思。
// 1) Types that have a common, singleton type property — the discriminant.
// In this example the "kind" property is the discriminant.
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
// 2) A type alias that takes the union of those types — the union.
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
// 3) Type guards on the common property.
// A switch statement acts as a "type guard" on
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
首先,我们需要一个判别式。在这个例子中,kind
属性充当了判别式(因为像 这样的字符串字面量"square"
是单例类型)。其次,我们需要一个类型别名,它接受这些类型的并集,我们在第 20 行用类型别名实现了这一点Shape
。
现在我们有了一个带有判别式的联合类型,我们可以在该属性上使用类型保护来利用 TypeScript 编译器的一些酷炫功能。那么我们刚刚得到了什么呢?
看来 TypeScript 有能力推断 switch 中每个 case 语句的正确类型!这非常有用,因为它为每种数据类型提供了很好的保证,确保我们不会拼写错误或使用该特定类型上不存在的属性。
回到维基百科对标记联合的定义
它可以被认为是一种具有多种“情况”的类型,在操作该类型时,每种情况都应得到正确处理。
在我们的示例中,area
函数处理了联合体的每个情况Shape
。除了缩小类型范围之外,使用可区分联合体还有什么用处?
软件开发中最难的部分之一就是需求的变更。我们该如何处理新的边缘情况和功能请求?例如,如果我们现在要计算三角形的面积怎么办?我们的代码需要如何修改才能解决这个问题?
首先,我们需要将新类型添加到我们的可区分联合中。
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number
}
type Shape = Square | Rectangle | Circle | Triangle;
// This is now giving us an error
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
这很简单。但是,如果我们查看area 函数,我们会发现现在 TypeScript 报错了。
那么这里发生了什么?这是一个称为穷举性检查 的功能,它是在代码中使用可区分联合的杀手级功能之一。TypeScript 会确保你已经处理了area 函数中的所有情况。Shape
一旦我们更新了 area 函数来处理该Triangle
类型,错误就消失了!反过来也一样——如果我们不再想支持该Triangle
类型,可以将其从联合体中移除,然后根据编译器错误提示删除所有不再需要的代码。所以,可区分联合体既能帮助我们提高可扩展性,又能帮助我们消除死代码。
就我们错过的代码路径而言,原始错误并不是非常详细,这就是为什么 TypeScript 文档概述了另一种支持详尽性检查的方法。
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default: return assertNever(s); // error here if there are missing cases
}
}
通过使用类型默认 fallthrough 来构造 switch 语句never
,您可以获得更好的解释问题的错误。
现在,我们可以更容易地看出我们在函数Triangle
中遗漏了类型area
。
虽然上面的例子有点牵强(就像大多数编程示例一样),但在 JavaScript 中,可区分联合是很常见的。Redux的 Action可以被认为是以type
属性作为判别式的可区分联合。
事实证明,GraphQL 中的联合类型也是可区分联合!
我们的架构演变
我们刚刚从渴望成功的风投那里获得了新一轮种子轮融资,他们看到了一个机会,可以重新演绎和推广留言板的概念,这项技术在20世纪70年代中期就已经臻于完美。在软件泡沫的顶峰时期,作为一名看似能力出众的软件开发人员,你抓住了这个机会来提升自己的简历。
输入 GraphQL。
您完全了解精益模式,因此您可以从一些非常基础的东西开始。
type Query {
messages: [Message!]!
}
type Message {
id: ID!
text: String!
author: MessageAuthor!
}
union MessageAuthor = User | Guest
type User {
id: ID!
name: String!
dateCreated: String!
messages: [Message!]!
}
type Guest {
# Placeholder name to query
name: String!
}
你的 UI 会显示一个无限长的消息列表。你的产品团队没有吸取过去的教训,还以为匿名发送消息会很酷。作为一名精明的开发者,你确保将这个需求编码到你的 GraphQL schema 中。
仔细观察我们的模式,发现MessageAuthor
类型联合看起来和我们之前的判别式联合示例非常相似。唯一缺少的似乎是一个共享的判别式属性。如果 GraphQL 允许我们使用类型名称作为判别式,我们就可以沿用之前探讨过的类型收缩和穷举性检查模式。
事实证明,GraphQL确实以特殊属性的形式提供了此功能__typename
,可以在GraphQL 中的任何字段上进行查询。那么,我们该如何利用这一点呢?
你坐下来,开始着手 UI 的首次迭代。你启动create-react-app,并添加Relay作为你的 GraphQL 框架。Relay 提供了一个编译器,它提供静态查询优化,并根据你的客户端查询生成TypeScript (以及其他语言)类型。
您使用新获得的有关可区分联合的知识 — — UI 的第一次迭代并不会花费太长时间。
import React from "react";
import { useLazyLoadQuery } from "react-relay/hooks";
import { AppQuery as TAppQuery } from "./__generated__/AppQuery.graphql";
import { graphql } from "babel-plugin-relay/macro";
const query = graphql`
query AppQuery {
messages {
id
text
author {
__typename
... on User {
id
name
}
... on Guest {
placeholder
}
}
}
}
`;
const App: React.FC = () => {
const data = useLazyLoadQuery<TAppQuery>(query, {});
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh"
}}
>
{data.messages.map(message => (
<Message message={message} />
))}
</div>
);
};
type MessageProps = {
// a message is an element from the messages array from the response of AppQuery
message: TAppQuery["response"]["messages"][number];
};
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
const Message: React.FC<MessageProps> = ({ message }) => {
switch (message.author.__typename) {
case "User": {
return <div>{`${message.author.name}: ${message.text}`}</div>;
}
case "Guest": {
return <div>{`${message.author.placeholder}: ${message.text}`}</div>;
}
default: {
assertNever(message.author);
}
}
};
export default App;
一切看起来都很好。Relay 编译器确认你的查询符合后端 GraphQL 规范。不过 TypeScript(当然,在严格模式下)会提示你有一个错误!
是什么%other
?深入研究 Relay 编译器生成的代码,它的来源就显而易见了。
readonly author: {
readonly __typename: "User";
readonly id: string;
readonly name: string;
} | {
readonly __typename: "Guest";
readonly placeholder: string;
} | {
/*This will never be '%other', but we need some
value in case none of the concrete values match.*/
readonly __typename: "%other";
};
有趣的是……我们的穷举模式匹配失败了,因为 Relay 编译器会为每个可区分的联合体生成一个额外的成员,这代表了一种“意外”的情况。这太棒了!这为我们提供了防护措施,迫使我们处理从我们自身内部衍生出来的模式。它让我们作为消费者可以自由地决定在这种意外情况下该如何处理。在我们的留言板中,我们可以完全隐藏消息,或者为无法解析的实体显示一个占位符用户名。目前我们不会渲染这些帖子。
const Message: React.FC<MessageProps> = ({ message }) => {
switch (message.author.__typename) {
case "User": {
return <div>{`${message.author.name}: ${message.text}`}</div>;
}
case "Guest": {
return <div>{`${message.author.placeholder}: ${message.text}`}</div>;
}
case "%other": {
return null;
}
default: {
assertNever(message.author);
}
}
};
太棒了——我们已经考虑到了在修改 UI 之前创建的所有新作者类型。这样可以避免出现运行时错误!
你的新留言板网站大获成功。你的用户增长速度惊人;很快,留言板的用户就从你的亲朋好友扩展到了其他用户。董事会成员蜂拥而至,询问你的下一个创新点是什么。
管理层意识到他们现在需要盈利,于是想创建付费用户的概念。根据付费金额,付费用户将分为多个等级,他们的奖励将在消息中以不同的颜色显示。
type Query {
messages: [Message!]!
}
type Message {
id: ID!
text: String!
author: MessageAuthor!
}
union MessageAuthor = User | Guest
type User {
id: ID!
name: String!
dateCreated: String!
messages: [Message!]!
role: USER_ROLE!
}
enum USER_ROLE {
FREE
PREMIUM
WHALE
}
type Guest {
# Placeholder name to query
placeholder: String!
}
后端更改已完成。是时候更新 UI 查询了!
query AppQuery {
messages {
id
text
author {
__typename
... on User {
id
name
role
}
... on Guest {
placeholder
}
}
}
}
是时候去实现您向付费用户承诺的颜色编码消息功能了。
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
const Message: React.FC<MessageProps> = ({ message }) => {
switch (message.author.__typename) {
case "User": {
return <div style={{color: premiumColor(message.author.role)}}>{`${message.author.name}: ${message.text}`}</div>;
}
case "Guest": {
return <div>{`${message.author.placeholder}: ${message.text}`}</div>;
}
case "%other": {
return null;
}
default: {
assertNever(message.author);
}
}
};
function premiumColor(role: USER_ROLE) {
switch (role) {
case "PREMIUM": {
return "red";
}
case "FREE": {
return "black";
}
case "%future added value": {
return "black";
}
}
}
很简单。你去办公室冰箱庆祝一下你天才的盈利策略。还没等你打开那瓶讽刺的苦涩双倍IPA,你的老板就慌慌张张地跑开了。
“你忘了鲸鱼的事了。”
当你意识到自己犯下的严重错误时,汗水顺着你的额头流了下来。你那些付费最高的客户——那些为了以专属信息颜色的形式在数字世界中占据主导地位而额外付费的客户——被剥夺了他们承诺的价值。
你冲回电脑前。我搞定了 GraphQL!我搞定了区分联合!
然后你意识到自己的错误。你意识到你没有在premiumColor
函数中添加穷举模式匹配。鲸鱼已经被遗忘了。你清理了代码,并添加了穷举模式匹配来修复这个 bug。
function premiumColor(role: USER_ROLE) {
switch (role) {
case "PREMIUM": {
return "red";
}
case "WHALE": {
return "blue";
}
case "FREE": {
return "black";
}
case "%future added value": {
return "black";
}
default: {
assertNever(role);
}
}
}
你的 bug 已经修复了。你暗自发誓,以后作为一名开发者,你会更加谨慎。或许你会添加一个测试。编译器已经尽力了,但你没有好好利用穷举检查的功能来优化代码。但如果编译器能帮我们做更多呢?如果我们现在使用的模式——匹配特定的值和类型并返回不同的值——能够得到类型系统更好的支持(比如更强大的穷举检查),那又会怎样?
一个合理的替代方案
到目前为止,我的目标是展示可区分联合和联合类型的价值,以及它们如何帮助我们逐步建立需求并根据这种差异解决产品需求的差异。
正如我们所说明的,TypeScript 对可区分联合有很好的支持,但是我们必须付出很多努力并编写额外的样板代码(例如assertNever
)才能获得良好的编译时保证。
回到有关可区分联合的 TypeScript 文档:
您可以将单例类型、联合类型、类型保护和类型别名组合起来,构建一种称为可区分联合(discriminated unions)的高级模式,也称为标记联合或代数数据类型。可区分联合在函数式编程中非常有用。有些语言会自动为您区分联合;而 TypeScript 则基于现有的 JavaScript 模式构建。
这里有一句话让我印象深刻。
有些语言会自动为您区分联合。
这会是什么样子?“自动”区分联合的语言意味着什么?
输入ReasonML。
ReasonML 是 OCaml 语言的一种新语法。ML 语言家族以其对代数数据类型(例如可区分联合)的强大支持和出色的类型推断(这意味着您无需自己编写类型注释)而闻名。
在 ReasonML 中,编译器通过variants优先支持可区分联合。无需编写带有诸如__typename
or 之类的属性的接口kind
,variant 允许你在更高级别的声明中表达它。可以将其视为能够添加编译器知道如何赋予含义的关键字。
与 TypeScript 中只能匹配单个判别属性的 switch 语句不同,ReasonML 支持模式匹配,这使我们能够在更深层次上匹配类型。更重要的是,我们可以在利用这些更高级的匹配功能的同时,保持完整性检查。
这实际上意味着什么?这如何帮助我们避免上述错误?
让我们看一下 ReasonML 中与ReasonReact和ReasonRelay相关的可比示例(在我们添加高级用户颜色功能之前)。
module Query = [%relay.query
{|
query AppQuery {
messages {
id
text
author {
__typename
...on User {
id
name
role
}
...on Guest {
placeholder
}
}
}
}
|}
];
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div> {React.string(user.name ++ ": " ++ message.text)} </div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
让我们一步一步地分解这段代码:
module Query = [%relay.query
{|
query AppQuery {
messages {
id
text
author {
__typename
...on User {
id
name
role
}
...on Guest {
placeholder
}
}
}
}
|}
];
ReasonML 拥有非常强大的模块系统。它们为代码重用和模块化提供了良好的衔接,并且还提供了超出本博文范围的附加功能。
这种%relay.query
语法被称为PPX。你可以把它想象成一个功能强大的标记模板,它在编译器级别拥有一流的支持。这使我们能够通过这些自定义语法在编译时挂载额外的功能和类型保证。真是太棒了!
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
};
这是我们 CSS-in-JS 样式的模块。它使用bs-css库来为Emotion提供类型安全的填充程序。
注意到flex
语法了吗?它们被称为多态变体。如果觉得有点乱,也不用担心。从概念上讲,就我们的目的而言,你可以将它们视为增强型字符串字面量(注意这里的主题)。由于 Reason/OCaml 没有“字符串字面量”的概念,因此多态变体的用例也类似。这相当简化,但就本文而言应该足够了。
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div> {React.string(user.name ++ ": " ++ message.text)} </div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
就像普通变体一样,我们也可以对多态变体进行模式匹配!在 ReasonRelay 中,我们的联合类型被解码为多态变体,我们可以对其进行模式匹配。就像 TypeScript 示例一样,每种情况下的类型都会被缩小,如果我们遗漏了任何模式,编译器就会发出警告。
需要注意的是,ReasonML 示例中缺少类型注释——没有任何对外部生成的类型文件的引用,也没有将泛型传递给我们的钩子!由于 PPX 的强大功能以及 ReasonML 对Hindley-Milner 推断的使用,编译器可以根据所有类型的用法推断出它们的含义。不过不用担心,它仍然非常类型安全!
让我们在 ReasonML 中重写我们的高级功能。
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
let message = role =>
switch (role) {
| `PREMIUM => style([color(red)])
| `FREE
| `FUTURE_ADDED_VALUE__ => style([color(black)])
};
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div className={Styles.message(user.role)}>
{React.string(user.name ++ ": " ++ message.text)}
</div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
ReasonRelay 将FUTURE_ADDED_VALUE__
和添加UnmappedUnionMember
到相应的枚举和变体类型,以帮助防止出现未知类型的运行时错误(就像在 TypeScript 中一样)。
这次,我们将我们的premiumColor
函数写为模块内的辅助函数Styles
(就代码而言,这感觉很合适)。
你对自己的代码感觉良好……但是等等!上面的代码中仍然有同样的错误!我们还没有认识到自己的错误!但是查看编辑器,我们发现组件中有一个错误。
编译器发现了一个 bug!但它到底在说什么?看来我们的Styles.message
函数没有处理 的情况Whale
,所以编译器报错了。由于我们函数的使用方式,类型系统可以推断出我们的理解存在不匹配!让我们更新代码来修复这个错误。
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
let message = role =>
switch (role) {
| `PREMIUM => style([color(red)])
| `WHALE => style([color(blue)])
| `FREE
| `FUTURE_ADDED_VALUE__ => style([color(black)])
};
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User(user) =>
<div className={Styles.message(user.role)}>
{React.string(user.name ++ ": " ++ message.text)}
</div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
模式匹配的额外好处
上面我们展示了模式匹配的一些强大功能,但仅仅触及了其实际用途的皮毛。TypeScript 不同,它在匹配复杂模式(例如多个判别式)时会受到限制,尤其是在保留了穷举性检查的情况下。
ReasonML 不受这些限制的约束。以下是我们可以编写“高级”用户功能的另一种方法。
module Styles = {
open Css;
let app =
style([
display(`flex),
justifyContent(`center),
alignItems(`center),
flexDirection(`column),
minHeight(`vh(100.0)),
]);
let premiumMessage = style([color(red)]);
let whaleMessage = style([color(blue)]);
let freeMessage = style([color(black)]);
};
[@react.component]
let make = () => {
let query = Query.use(~variables=(), ());
<div className=Styles.app>
{Belt.Array.map(query.messages, message => {
switch (message.author) {
| `User({name, role: `PREMIUM}) =>
<div className=Styles.premiumMessage>
{React.string(name ++ ": " ++ message.text)}
</div>
| `User({name, role: `WHALE}) =>
<div className=Styles.whaleMessage>
{React.string(name ++ ": " ++ message.text)}
</div>
| `User({name, role: `FREE | `FUTURE_ADDED_VALUE__}) =>
<div className=Styles.freeMessage>
{React.string(name ++ ": " ++ message.text)}
</div>
| `Guest(guest) =>
<div>
{React.string(guest.placeholder ++ ": " ++ message.text)}
</div>
| `UnmappedUnionMember => React.null
}
})
->React.array}
</div>;
};
这个语法有点复杂,我们来分解一下。你可以把它想象成 JavaScript 中的解构。但是这里有两件事——首先,我们将name
用户的属性绑定到变量绑定name
(就像在 JavaScript 中一样)。第二部分才是有意思的部分——我们告诉编译器匹配role
每个作者的值(因此Styles.whaleMessage
只会应用于具有该Whale
角色的用户)。
最棒的是,我们仍然可以充分利用这些属性的详尽性检查功能。我们不再局限于单一的判别式!因此,如果我们注释掉Whales
组件中的以下部分:
理性告诉我们,我们忘了处理鲸鱼了!我们可以借助编译器来记住领域内的所有边缘情况。
结论
本文旨在向您介绍可区分/标记联合的概念,并展示如何利用它们编写更具可扩展性的应用程序。我们通过一些 TypeScript 中的简单示例,对标记联合的含义以及编译器可以为其生成哪些类型的保证有了基本的了解。然后,我们研究了 GraphQL 联合以及它们在运行时如何表示为标记联合。
我们讲解了一个精心设计的需求故事,并展示了如何利用之前学到的经验教训,以及 Relay 等类型生成工具,编写出能够应对需求变化的健壮应用程序。我们遇到了 TypeScript 详尽性检查的局限性,以及嵌套标记联合的代码扩展限制。
然后,我们简要了解了 ReasonML,以及这种通过变体“自动”支持带标签联合的语言是什么样子的。我们使用与 TypeScript 示例非常相似的技术,演示了 Reason 中变体和模式匹配的强大功能,以及编译器的强大功能如何应对 TypeScript 中需要大量处理的情况。
最后,我们探索了 Hindley-Milner 类型推断和模式匹配的强大功能,以及它们如何结合让我们编写高度类型安全的应用程序,而无需提供大量的类型注释。
无论你使用 GraphQL、TypeScript 还是 ReasonML,代数数据类型都是你工具库中一个非常强大的工具。本文只是粗略地介绍了它们能够实现哪些功能。
如果你有兴趣了解更多关于 ReasonML 的信息,欢迎来Discord看看!大家都非常友好,愿意解答你的任何问题。
鏂囩珷鏉ユ簮锛�https://dev.to/ksaldana1/domain-modeling-with-tagged-unions-in-graphql-reasonml-and-typescript-2gnn