先记住这个答案
自定义标量通过声明 scalar 类型并定义三个解析函数来扩展 GraphQL 的基础类型。serialize 用于将服务端内部值转换为 JSON 可传输格式(输出);parseValue 用于将变量中的 JSON 值转换为内部表示(输入);parseLiteral 则解析查询字符串中的字面量。例如 DateTime 通常序列化为 ISO 8601 字符串或时间戳。
- 自定义标量需实现三个函数
- serialize 用于输出,输入用 parseValue/parseLiteral
- DateTime 内部存储不一定是字符串
三个转换函数的职责与差异
GraphQL 默认提供 Int、Float、String、Boolean、ID 五种标量,但业务中常需要日期时间、URL 等精确类型。自定义标量在 SDL 中用 scalar DateTime 声明,实际行为由服务端实现决定。它要求提供三个函数。 serialize(value) 在字段作为输出时调用,将服务端内部值(如 Date 对象或时间戳)转换成客户端可接收的 JSON 兼容值。 parseValue(value) 在字段作为参数且值来自查询的 variables 对象时调用,将变量中的 JSON 值转换为内部表示,用于后续解析或处理。 parseLiteral(ast) 在字段作为参数且值直接写在查询字符串中时调用,接收的是抽象语法树(AST)节点,通常是 StringValueNode 或 IntValueNode,需从节点中提取字面量再转换。输入路径有两个函数是因为变量和字面量的来源不同,parseValue 接收已解析的 JSON,parseLiteral 直接面对 AST。若未实现 parseLiteral,那么查询中直接用字面量(如 since: "2024-01-01T00:00:00Z")将无法工作。
这三者的返回值和错误处理也有区别。 serialize 可返回字符串、数字或布尔值,这些值最终会被写入响应 JSON;如果返回 Date 对象会被错误序列化,导致客户端收到格式混乱。 parseValue 和 parseLiteral 的返回值将作为 resolver 的参数,其类型应当与内部处理所需一致。若输入无效,应抛出 GraphQLError 或自定义错误,GraphQL 执行引擎会将其加入 errors,且该字段将得到 null。注意 schema 上的 scalar DateTime 定义并不强制校验输入是字符串还是数字,因此服务端必须自行决定一种规范格式。
事件查询场景中 DateTime 的实现
假设有一个活动排期系统,客户端需要查询“在 2024-03-01 之后发布的文章”和创建文章的 mutation。后端内部存储时间统一使用 Unix 时间戳(毫秒)。服务端使用 Apollo Server 与 graphql-tools。Schema 中定义 scalar DateTime,字段 publishedAt: DateTime,输入 filter: { since: DateTime }。实现时,serialize(timestampMs) 返回 new Date(timestampMs).toISOString(),输出为 ISO 8601 字符串;parseValue(value) 接收来自变量的字符串(如 "2024-03-01T00:00:00Z"),校验格式并返回 Date.parse(value);parseLiteral(ast) 检查 ast.kind === Kind.STRING,从 ast.value 中提取字符串并同样调用 Date.parse。这样,客户端可以使用字符串变量获得统一输入,而输出始终是带时区标记的字符串,便于前端直接显示。
为什么内部不直接存字符串?因为 ISO 字符串在比较时依赖时区且不可直接计算,而 Unix 毫秒数值让数据库索引和比较更高效。序列化时再转成字符串控制格式。实际执行时,resolver 从数据库取出的 timestamp 传给 serialize,返回 ISO 字符串;输入时 parseValue 会把合法日期字符串解析为毫秒数,resolver 再利用这个毫秒数构造查询条件。若客户端传入格式如 "2024/03/01",实现应抛出“无效日期格式”错误,让客户端调整,而不能静默转为 null 或使用默认值。
自定义标量的限制与成本
自定义标量只在单个 schema 内部统一,跨服务(如联邦服务)时需在每个服务中重复定义实现,且客户端也依赖约定。若内部保存的就是 ISO 字符串,仍然可以自定义标量,但 serialize 需做正则验证,parseValue 和 parseLiteral 需校验并可能转换为标准对象。但必须明确的是,GraphQL 规范不要求标量输出与输入必须使用同一种表示。例如输出可以是时间戳,输入要求 ISO 字符串,这会让客户端不一致,因此强烈建议定义成一种对客户端友好的格式(如 ISO 8601),并在文档中指明。
实现上的易错点包括:parseLiteral 中忽略 IntValueNode 导致数字输入失败;serialize 返回了非 JSON 兼容值(如 Date 对象)或未处理格式异常。当字段值为 null 时,序列化过程不会调用 serialize,而是由字段可空性处理;若内部值存在,serialize 必须返回正确的标量类型,否则会抛错。规范并不要求 serialize 返回 null 来标记错误,错误应明确抛出。
容易答错的地方
- 以为必须内部存字符串
- 常见误区是自定义 DateTime 标量就必须用字符串存储,实际上 serialize 是转换层,内部可以存时间戳、Date 对象或其它业务结构。关键是三个函数负责双向转换,只要输入输出合法,内部存储随意。
- 忽略 parseLiteral 导致查询失败
- 有些实现只写了 parseValue 忽略 parseLiteral,导致使用字面量参数时直接报错或返回 null。应在实现中同时覆盖 ParseValue 和 ParseLiteral,确保内联字面量和 variables 都支持。
面试官还会怎么问?
serialize 返回 null 是否正确?
如果字段解析结果为 null,且字段是 nullable,则不会调用 serialize,直接响应 null。如果内部值存在,serialize 必须返回正确格式的值;若 serialize 返回 null,对于 nullable 字段会得到 null,对于非空字段会引发父级错误,所以不应返回 null 来表示序列化失败。规范并不禁止 serialize 返回 null,但 null 应视为字段可空,而非错误标记。若内部值存在但不能转为正确格式,应明确抛错,而不是返回 null 或掩盖错误。
parseValue 和 parseLiteral 抛错后查询结果是什么?
抛错后该字段的值为 null,错误对象被加入 errors 数组,不影响其他字段。如果字段非空,错误会冒泡至可空父级。建议错误信息包含预期格式示例,便于客户端修正。
如何选择 DateTime 的输出格式?字符串还是数字?
常用的是 ISO 8601 字符串,因为人类可读且自带时区。时间戳数字节省空间但可读性差,且需约定单位(秒/毫秒)。若API已有客户端影响,可选用 GraphQL 社区日历库默认的 ISO 字符串或 RFC3339,但必须一致并明确文档。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。