鸿蒙HarmonyOS ArkTS JSON 类型安全实战 —— 设计类型安全的 JsonValue 体系
一、前言:ArkTS 不是 TypeScript
如果你从 TypeScript 转到 ArkTS,第一周大概率会写出这样的代码:
// 从智谱或 DeepSeek 拿到一段响应
const text = '{"id":"chatcmpl-1","choices":[{"delta":{"content":"你好"}}]}'
const data: any = JSON.parse(text)
const content = data.choices[0].delta.content // ← 期望拿到 "你好"
console.info(content)
在 TypeScript 里,这段代码能跑(虽然不严谨)。但在 ArkTS 严格模式下,编译器会报三个错:
-
arkts-no-any:const data: any禁止使用any类型。 -
arkts-no-indexed-signatures:data.choices这种动态属性访问需要索引签名,而 ArkTS 禁止[key: string]: T形式的索引签名。 -
arkts-no-untyped-obj-literals:JSON.parse返回的对象是未类型化的,不能直接用.xxx访问。
这三条约束不是可以绕过的警告,是硬性编译错误。你会陷入一个困境:大模型的所有交互(请求、响应、流式 chunk、工具参数)都是动态 JSON,但 ArkTS 不让你用动态的方式处理它们。怎么办?
最直觉的解法是"绕过":用 @ts-ignore、用 ESObject、用 as object as SomeType 强制转换。但这些都是技术债——它们把"类型不安全"从编译期推迟到了运行期,原本编译器能在你写代码时抓住的错误(字段拼错了、类型搞错了),现在变成了线上 crash。
ArkAgent 选择了另一条路:正面设计一套类型安全的 JSON 体系,从根上消除对 any 的依赖。这套体系覆盖了从 JSON 文本解析、到动态数据建模、到领域模型编解码、到 Provider 扩展参数守卫的完整链路。本文就拆解这套体系。
二、ArkTS 类型系统的五条约束
先完整认识对手。ArkTS 严格模式通过一组 arkts-no-* 规则,结构性禁止了 TypeScript 里常见的动态特性。和 JSON 处理直接相关的有五条:
2.1 约束清单
|
约束规则 |
禁止的行为 |
对 JSON 处理的影响 |
|---|---|---|
|
|
使用 |
不能用 |
|
|
|
不能定义递归的 |
|
|
未类型化的对象字面量 |
不能用 |
|
|
throw 任意类型 |
错误必须是 |
|
对 |
返回动态对象无法用 |
必须自己解析或用类型安全包装 |
下面逐一展开。
2.2 arkts-no-indexed-signatures:递归类型别名的死刑
这是最致命的一条。TypeScript 里处理 JSON 最优雅的写法是递归类型别名:
// ❌ ArkTS 编译器直接拒绝
export type JsonScalar = string | number | boolean | null
export type JsonValue = JsonScalar | JsonObject | JsonValue[]
export interface JsonObject {
[key: string]: JsonValue // ← 这一行触发 arkts-no-indexed-signatures
}
ArkTS 为什么禁止字符串索引签名?因为索引签名破坏了类型安全的根基——它允许你用任意字符串作为属性名访问,编译器无法在编译期验证属性是否存在、类型是否正确。ArkTS 选择"宁可麻烦,不可不安全"。
ArkAgent 在阶段 1 的首日就真实撞上了这条墙(阶段报告踩坑记录 #2):
现象:字符串索引签名触发
arkts-no-indexed-signatures根因:interface JsonObject { [key: string]: JsonValue }不属于 ArkTS 支持子集 方案:JsonValue class + JsonKind + Map 封装
有趣的是,ArkAgent 在阶段 0 的 JSON spike 里就已经预判了这一点,并准备好了降级方案:
"若阶段 1 最小编译验证拒绝递归 alias,立即使用
JsonValueclass +JsonKindenum 和显式 scalar/object/array 访问器。禁止退回any或在公共 API 使用Object强制转换。"
这条"二选一"策略的聪明之处在于:它把"尝试递归别名"和"降级到 class 方案"都提前锁定了,避免在编译失败后临时讨论"怎么办"。阶段 1 编译一失败,立即切换,不再争论第三种方案。
2.3 arkts-no-untyped-obj-literals:对象字面量的限制
这条约束禁止用 { key: value } 字面量直接实现接口:
// ❌ 编译报错:arkts-no-untyped-obj-literals
const observer: StreamObserver<StreamingEvent> = {
onNext: (e: StreamingEvent) => { /* ... */ },
onError: (e: ArkAgentError) => { /* ... */ },
onComplete: () => { /* ... */ }
}
// ✅ 必须用显式 class 实现
class MyObserver implements StreamObserver<StreamingEvent> {
onNext(e: StreamingEvent): void { /* ... */ }
onError(e: ArkAgentError): void { /* ... */ }
onComplete(): void { /* ... */ }
}
const observer = new MyObserver()
这条约束对 JSON 的影响是:你不能用对象字面量构造 JSON DTO,必须走显式的 JsonObject + JsonValue.string(...) 工厂方法。写起来更啰嗦,但每个字段的类型在构造时就确定了。
2.4 arkts-limited-throw:错误必须是 Error 子类
// ❌ 不能 throw 字符串或自定义非 Error 对象
throw 'something went wrong'
throw { code: 'decode_error', message: '...' }
// ✅ 必须 throw Error 的子类
throw new Error('something went wrong')
throw new ArkAgentError(...) // ArkAgentError extends Error
ArkAgent 的 ArkAgentError 明确继承 Error,源码注释直接写了原因:
/**
* Throwable domain error. Extends Error so ArkTS `throw` is valid.
*/
export class ArkAgentError extends Error {
2.5 JSON.parse 返回的对象无法用 .xxx 访问
即使你写了 const data = JSON.parse(text),拿到的对象也无法用 data.choices 访问——因为 JSON.parse 的返回类型是 Object(或动态类型),ArkTS 不允许对它做属性访问。你必须自己解析,或者把它喂给类型安全的编解码器。
⚠️ ArkTS 严格模式的设计哲学:这五条约束的本质是——所有动态数据必须在边界主动建模,不能依赖运行时的鸭子类型。你可以处理 JSON,但必须用类型安全的方式;你可以有动态数据,但必须用受控的容器(
JsonObject/JsonValue)承载。这换来的是编译期保证:凡是能编译通过的代码,类型就是对的。
三、JsonValue 三件套:class + enum + Map
面对 ArkTS 的约束,ArkAgent 设计了三个紧密配合的类型:JsonValue(值容器)、JsonKind(类型标签)、JsonObject(对象容器)。这三个类型定义在 core/Json.ets,一共只有 73 行,却是整个 SDK 的类型安全基石。
3.1 设计概览
┌───────────────────────────────────────────────────────────┐
│ JsonKind(枚举) │
│ nullValue | stringValue | numberValue | booleanValue │
│ objectValue | arrayValue │
├───────────────────────────────────────────────────────────┤
│ JsonObject(对象容器) │
│ 内部:Map<string, JsonValue> │
│ 方法:set / get / has / keys / copy │
├───────────────────────────────────────────────────────────┤
│ JsonValue(值容器,sealed by design) │
│ kind: JsonKind ← 唯一的运行时类型标签 │
│ 私有数据字段:stringData / numberData / ... │
│ 静态工厂:string() / number() / boolean() / object()… │
│ 访问器:asString() / asNumber() / asObject() / … │
└───────────────────────────────────────────────────────────┘
3.2 JsonKind:六个类型标签
export enum JsonKind {
nullValue = 'null',
stringValue = 'string',
numberValue = 'number',
booleanValue = 'boolean',
objectValue = 'object',
arrayValue = 'array'
}
这六个值精确对应 JSON 规范的六种数据类型。枚举值用字符串而不是数字,是为了调试可读性——日志里看到 kind=objectValue 比看到 kind=4 直观得多。
3.3 JsonObject:用 Map 而不是索引签名
export class JsonObject {
private readonly values: Map<string, JsonValue> = new Map<string, JsonValue>()
set(key: string, value: JsonValue): JsonObject {
this.values.set(key, value)
return this // ← 返回 this,支持链式调用
}
get(key: string): JsonValue | undefined {
return this.values.get(key)
}
has(key: string): boolean {
return this.values.has(key)
}
keys(): string[] {
return Array.from(this.values.keys())
}
copy(): JsonObject {
const result = new JsonObject()
this.values.forEach((value: JsonValue, key: string) => result.set(key, value))
return result
}
}
为什么用 Map 而不是 Record? 因为 Record<string, T> 在 ArkTS 里也会触发 arkts-no-indexed-signatures(它的底层就是索引签名)。阶段 2 的报告记录了这个发现:"Record/header map 被 arkts-no-indexed-signatures 拒绝"。Map 是 ArkTS 原生支持的集合类型,不依赖索引签名,所以它是承载动态键值对的正确容器。
链式 set 的设计让构造 JSON 更紧凑:
// 链式构造(紧凑)
const params = new JsonObject()
.set('type', JsonValue.string('object'))
.set('properties', JsonValue.object(new JsonObject()))
.set('required', JsonValue.array([JsonValue.string('recordId')]))
// 如果不返回 this,就得这么写(啰嗦)
const params = new JsonObject()
params.set('type', JsonValue.string('object'))
params.set('properties', JsonValue.object(new JsonObject()))
params.set('required', JsonValue.array([JsonValue.string('recordId')]))
3.4 JsonValue:私有构造器 + 静态工厂
JsonValue 是整个体系的核心。它的设计借鉴了"标签联合体"(tagged union)的思想——用 kind 字段标记当前持有的类型,用私有字段存储实际数据:
export class JsonValue {
readonly kind: JsonKind
private readonly stringData?: string
private readonly numberData?: number
private readonly booleanData?: boolean
private readonly objectData?: JsonObject
private readonly arrayData?: JsonValue[]
// 构造器是 private —— 外部不能 new JsonValue(...)
private constructor(kind: JsonKind, stringData?: string, numberData?: number,
booleanData?: boolean, objectData?: JsonObject, arrayData?: JsonValue[]) {
this.kind = kind
this.stringData = stringData
this.numberData = numberData
this.booleanData = booleanData
this.objectData = objectData
this.arrayData = arrayData
}
// 静态工厂:唯一的创建入口
static nullValue(): JsonValue { return new JsonValue(JsonKind.nullValue) }
static string(value: string): JsonValue { return new JsonValue(JsonKind.stringValue, value) }
static number(value: number): JsonValue { return new JsonValue(JsonKind.numberValue, undefined, value) }
static boolean(value: boolean): JsonValue {
return new JsonValue(JsonKind.booleanValue, undefined, undefined, value)
}
static object(value: JsonObject): JsonValue {
return new JsonValue(JsonKind.objectValue, undefined, undefined, undefined, value)
}
static array(value: JsonValue[]): JsonValue {
return new JsonValue(JsonKind.arrayValue, undefined, undefined, undefined, undefined, value)
}
// 访问器:按类型取值
asString(): string | undefined { return this.stringData }
asNumber(): number | undefined { return this.numberData }
asBoolean(): boolean | undefined { return this.booleanData }
asObject(): JsonObject | undefined { return this.objectData }
asArray(): JsonValue[] | undefined { return this.arrayData }
}
3.5 为什么构造器是 private
这是一个关键的设计决策。如果构造器是 public,外部就可能写出:
// 如果构造器 public,这种写法就合法了
const bad = new JsonValue(JsonKind.stringValue, undefined, 42)
// ↑ 声称是 string ↑ 却塞了个 number
类型标签和数据不匹配,这是"伪类型安全"——编译能过,但运行时 asString() 返回 undefined(因为 stringData 没赋值),而 asNumber() 反而返回 42。这种 bug 极难排查。
把构造器设为 private,强制通过静态工厂创建,就保证了类型标签和数据永远一致:
// 工厂方法内部保证 kind 和 data 对齐
static string(value: string): JsonValue {
return new JsonValue(JsonKind.stringValue, value) // kind=string, stringData=value ✓
}
static number(value: number): JsonValue {
return new JsonValue(JsonKind.numberValue, undefined, value) // kind=number, numberData=value ✓
}
每个工厂方法内部精确地只填入对应类型的数据字段,其他字段保持 undefined。这样 kind 就是绝对可信的类型标签——kind === JsonKind.stringValue 时,asString() 一定返回有效字符串。
3.6 访问器:用 undefined 表示类型不匹配
访问器的设计是"宽容返回"——如果类型不匹配,返回 undefined 而不是抛异常:
const value = JsonValue.string('hello')
value.kind // JsonKind.stringValue
value.asString() // 'hello'
value.asNumber() // undefined ← 不抛异常,返回 undefined
value.asObject() // undefined
为什么不抛异常?因为 JSON 数据是动态的,"某个字段可能是 string 也可能是 number"是合法场景(比如某些 Provider 的 id 字段有时是数字有时是字符串)。如果访问器抛异常,调用方就得写 try-catch,代码会很丑。返回 undefined 让调用方用可选链优雅处理:
const id = root.get('id')?.asString() ?? root.get('id')?.asNumber()?.toString() ?? ''
但"宽容访问"不代表"不校验"——校验逻辑在 DomainCodec 和各 Codec 层做,那里会对 undefined 做明确判断并返回 DecodeError。访问器只负责"取值",校验器负责"判断对不对",职责分离。
四、⚠️ 踩坑一:递归类型别名被编译器拒绝
这是整个 JSON 体系的"创世踩坑"——正是因为这次失败,才催生了 JsonValue class 方案。
4.1 症状
阶段 1 首日,按照阶段 0 JSON spike 的"首选方案",写下了递归类型别名:
export type JsonScalar = string | number | boolean | null
export type JsonValue = JsonScalar | JsonObject | JsonValue[]
export interface JsonObject {
[key: string]: JsonValue
}
编译,直接报错:arkts-no-indexed-signatures。JsonObject 接口里的 [key: string]: JsonValue 被 ArkTS 编译器拒绝。
4.2 错误的尝试
尝试一:换成 Record。 心想 Record<string, JsonValue> 也许不算索引签名?
export type JsonObject = Record<string, JsonValue> // ❌ 同样被拒
结果:阶段 2 报告记录了这条路也走不通——"Record/header map 被 arkts-no-indexed-signatures 拒绝"。Record 的底层就是索引签名,换汤不换药。
尝试二:退回 any。 既然类型搞不定,用 any 绕过:
const data: any = JSON.parse(text) // ❌ arkts-no-any
结果:被 ADR-0003 的红线挡住——"公共 API 禁止 any"。而且这等于放弃类型安全,把所有错误推到运行期。
尝试三:用 Object 强制转换。
const data = JSON.parse(text) as Object // 拿到 Object,然后呢?
// data.choices ← 还是不能属性访问
结果:Object 类型同样不支持动态属性访问,这条路是死胡同。spike 文档明确写了:"禁止退回 any 或在公共 API 使用 Object 强制转换"。
4.3 根因
ArkTS 严格模式的核心立场:动态数据必须有显式的类型建模,不能靠索引签名或 any 偷懒。递归类型别名 + 索引签名是 TypeScript 的惯用法,但它在 ArkTS 的类型安全模型里没有容身之地。这不是 bug,是设计选择——ArkTS 用编译期的严格,换取运行期的安全。
4.4 最终方案:JsonValue class + Map
三条路堵死后,唯一正确的方案就是阶段 0 spike 预先准备的降级方案:
// JsonKind 枚举作为类型标签
export enum JsonKind { nullValue, stringValue, numberValue, booleanValue, objectValue, arrayValue }
// JsonObject 用 Map 承载键值对(不依赖索引签名)
export class JsonObject {
private readonly values: Map<string, JsonValue> = new Map<string, JsonValue>()
// get / set / has / keys ...
}
// JsonValue 用 class + private 构造器 + 静态工厂
export class JsonValue {
readonly kind: JsonKind
private readonly stringData?: string
// ... 其他类型字段
private constructor(...) { ... }
static string(v: string): JsonValue { ... }
asString(): string | undefined { ... }
}
这个方案在阶段 1 通过编译和往返测试后,实验用的失败代码被删除(ADR-0003 记录:"实验文件已删除"),正式实现就是你现在看到的 core/Json.ets。风险登记册里这条(R-003)被标记为"已缓解"。
4.5 教训与预防
这次踩坑沉淀出两条工程规则:
-
ArkTS 动态数据必须在边界主动建模,不能依赖 TypeScript 索引签名。如果你发现自己在写
[key: string],停下来——这条路在 ArkTS 走不通。 -
遇到编译约束,准备降级方案,而不是绕过约束。ArkAgent 在阶段 0 就预判了递归别名可能被拒,准备了 class 方案作为降级。编译一失败立即切换,不浪费时间争论。
五、手写递归下降 Parser:不依赖 JSON.parse
JsonValue 体系解决了"动态数据怎么建模"的问题。但还有个前置问题:JSON 文本怎么变成 JsonValue?
标准答案是 JSON.parse。但在 ArkTS 里,JSON.parse 返回的是动态对象,你没法把它直接转成 JsonValue(那需要遍历动态对象,又回到索引签名问题)。ArkAgent 的解法:自己写一个递归下降 Parser,从字符到 JsonValue,全程类型安全。
这个 Parser 定义在 core/JsonCodec.ets,是一个 200 行的私有类 JsonParser,加上导出的 JsonCodec 静态工具类。
5.1 JsonCodec:对外接口
先看对外的接口(业务代码只和这个打交道):
export class JsonCodec {
// 解析 JSON 文本 → JsonValue(根可以是任意 JSON 类型)
static parse(source: string): Result<JsonValue, ArkAgentError> {
return new JsonParser(source).parse()
}
// 解析 JSON 文本 → JsonObject(根必须是对象,否则报错)
static parseObject(source: string): Result<JsonObject, ArkAgentError> {
const parsed = JsonCodec.parse(source)
if (!parsed.ok || parsed.value === undefined) {
return Result.failure<JsonObject, ArkAgentError>(parsed.error as ArkAgentError)
}
const object = parsed.value.asObject()
if (object === undefined) {
return Result.failure<JsonObject, ArkAgentError>(
ArkAgentError.decode('json_expected_object', 'Expected JSON object'))
}
return Result.success<JsonObject, ArkAgentError>(object)
}
// 序列化 JsonValue → JSON 文本
static stringify(value: JsonValue): string { ... }
}
三个要点:
第一,parse 和 parseObject 都返回 Result,不抛异常。 解析失败时,你拿到的是一个包含 ArkAgentError 的 Result.failure,而不是一个 try-catch 里捕获的异常。这让错误处理变成线性的 if (!result.ok) 判断,而不是嵌套的 try-catch。
第二,parseObject 是 parse 的特化版本。 它先调 parse 拿到 JsonValue,再检查 kind 是不是 objectValue,不是就报 json_expected_object 错误。业务代码绝大多数场景需要的是对象(Provider 响应、工具参数、状态文件),所以 parseObject 是最常用的入口。
第三,stringify 把 JsonValue 序列化回文本。 它是 parse 的逆操作,用于把领域模型编码后的 JsonObject 发送给 Provider 或写入持久化文件。
5.2 JsonParser:逐字符递归下降
JsonParser 是一个标准的递归下降解析器(recursive descent parser)。它维护一个 position 游标,逐字符扫描输入文本,根据当前字符决定解析逻辑:
class JsonParser {
private readonly source: string
private position: number = 0
constructor(source: string) {
this.source = source
}
parse(): Result<JsonValue, ArkAgentError> {
this.skipWhitespace()
const value = this.parseValue()
if (!value.ok) return value
this.skipWhitespace()
// 解析完后,必须到文本末尾——不允许尾部有多余内容
if (this.position !== this.source.length) {
return Result.failure<JsonValue, ArkAgentError>(
this.error('json_trailing_content', 'Unexpected trailing content'))
}
return value
}
private parseValue(): Result<JsonValue, ArkAgentError> {
if (this.position >= this.source.length) {
return Result.failure<JsonValue, ArkAgentError>(
this.error('json_unexpected_end', 'Unexpected end of JSON'))
}
const token = this.source[this.position]
if (token === '"') return this.parseStringValue() // 字符串
if (token === '{') return this.parseObject() // 对象
if (token === '[') return this.parseArray() // 数组
if (token === 't') return this.parseLiteral('true', JsonValue.boolean(true))
if (token === 'f') return this.parseLiteral('false', JsonValue.boolean(false))
if (token === 'n') return this.parseLiteral('null', JsonValue.nullValue())
if (token === '-' || this.isDigit(token)) return this.parseNumber()
return Result.failure<JsonValue, ArkAgentError>(
this.error('json_unexpected_token', 'Unexpected token'))
}
}
parseValue 是分发器——看第一个字符,决定走哪条解析路径。这种"首字符分发"是递归下降解析器的经典模式,直观且易于维护。
5.3 parseObject:对象的解析
对象解析最能体现这个 Parser 的设计:
private parseObject(): Result<JsonValue, ArkAgentError> {
this.position++ // 跳过 '{'
this.skipWhitespace()
const object = new JsonObject()
if (this.peek('}')) { // 空对象 {}
this.position++
return Result.success<JsonValue, ArkAgentError>(JsonValue.object(object))
}
while (this.position < this.source.length) {
// 1. 解析 key(必须是字符串)
const keyResult = this.parseStringRaw()
if (!keyResult.ok || keyResult.value === undefined) {
return Result.failure<JsonValue, ArkAgentError>(keyResult.error as ArkAgentError)
}
this.skipWhitespace()
// 2. 期望冒号
if (!this.peek(':')) {
return Result.failure<JsonValue, ArkAgentError>(
this.error('json_expected_colon', 'Expected colon'))
}
this.position++ // 跳过 ':'
this.skipWhitespace()
// 3. 递归解析 value
const valueResult = this.parseValue()
if (!valueResult.ok || valueResult.value === undefined) return valueResult
// 4. 放入 JsonObject
object.set(keyResult.value, valueResult.value)
this.skipWhitespace()
// 5. 判断是结束还是继续
if (this.peek('}')) { // 对象结束
this.position++
return Result.success<JsonValue, ArkAgentError>(JsonValue.object(object))
}
if (!this.peek(',')) { // 不是 '}' 就必须有 ','
return Result.failure<JsonValue, ArkAgentError>(
this.error('json_expected_comma', 'Expected comma'))
}
this.position++ // 跳过 ','
this.skipWhitespace()
}
return Result.failure<JsonValue, ArkAgentError>(
this.error('json_unclosed_object', 'Unclosed object'))
}
每一步都有明确的错误处理——期望冒号却没找到、期望逗号却没找到、对象没闭合,都会返回带 offset 信息的 ArkAgentError。
5.4 字符串解析:转义和 Unicode
字符串解析是最复杂的部分,因为要处理转义序列:
private parseStringRaw(): Result<string, ArkAgentError> {
if (!this.peek('"')) {
return Result.failure<string, ArkAgentError>(this.error('json_expected_string', 'Expected string'))
}
this.position++ // 跳过开头的 '"'
let result = ''
while (this.position < this.source.length) {
const character = this.source[this.position++]
if (character === '"') {
return Result.success<string, ArkAgentError>(result) // 闭合引号
}
if (character === '\\') { // 转义序列
const escaped = this.source[this.position++]
if (escaped === '"' || escaped === '\\' || escaped === '/') result += escaped
else if (escaped === 'b') result += '\b'
else if (escaped === 'f') result += '\f'
else if (escaped === 'n') result += '\n'
else if (escaped === 'r') result += '\r'
else if (escaped === 't') result += '\t'
else if (escaped === 'u') { // \uXXXX Unicode 转义
const hex = this.source.substring(this.position, this.position + 4)
const code = parseInt(hex, 16)
if (Number.isNaN(code)) {
return Result.failure<string, ArkAgentError>(
this.error('json_invalid_unicode', 'Invalid unicode escape'))
}
result += String.fromCharCode(code)
this.position += 4
} else {
return Result.failure<string, ArkAgentError>(
this.error('json_invalid_escape', 'Invalid escape'))
}
} else {
result += character // 普通字符
}
}
return Result.failure<string, ArkAgentError>(
this.error('json_unclosed_string', 'Unclosed string'))
}
完整的转义处理:\" \\ \/ \b \f \n \r \t \uXXXX。每种转义都有对应的还原逻辑。非法转义(比如 \x)会报 json_invalid_escape 错误。
5.5 ⚠️ 踩坑二:错误定位必须有 offset
症状:Provider 返回了一段畸形 JSON(比如 {"choices":[{"delta":{"content":"你好"}]},少了一个 }),SDK 报了"解析失败",但日志里只有 json_unclosed_object: Unclosed object,没有告诉你是哪个位置出错了。排查时对着一大段 JSON 手动找错,非常痛苦。
根因:最初版本的错误信息只有错误码和文字描述,没有带上 position(出错的字符位置)。
修复:在 error 方法里把 position 拼进错误信息:
private error(code: string, message: string): ArkAgentError {
return ArkAgentError.decode(code, `${message} at offset ${this.position}`)
}
现在错误信息变成 json_unclosed_object: Unclosed object at offset 47,你直接去文本的第 47 个字符附近看,就能快速定位问题。
预防:所有解析错误必须携带 offset。这条规则不仅适用于 JSON Parser,也适用于 SSE 解析、UTF-8 解码等所有"逐字符/逐字节处理"的组件。错误定位信息是可调试性的基础。
六、解码四规则:从 JsonObject 到领域模型
JSON 文本变成 JsonObject 后,下一步是把 JsonObject 变成强类型的领域模型(AgentMessage、ToolCall、ModelResponse 等)。这个转换由 DomainCodec 负责,它遵循四条解码规则。
6.1 四条规则总览
|
规则 |
处理方式 |
示例 |
|---|---|---|
|
必填字段缺失或类型错误 |
返回 |
|
|
未知字段 |
默认忽略(wire DTO 可保留到 |
Provider 返回了新字段 |
|
未知枚举 |
映射为 |
新的 |
|
数字无效 |
返回 |
|
6.2 标量读取工具:Decode
DomainCodec 内部定义了一个 Decode 工具类,封装了四种标量的安全读取:
class Decode {
// 必填字符串:缺失或类型错 → DecodeError
static requiredString(object: JsonObject, key: string): Result<string, ArkAgentError> {
const value = object.get(key)?.asString()
if (value === undefined) {
return Result.failure<string, ArkAgentError>(ArkAgentError.decode(
'decode_required_string', `${key} is required and must be string`))
}
return Result.success<string, ArkAgentError>(value)
}
// 可选字符串:不存在 → undefined(合法);存在但类型错 → DecodeError
static optionalString(object: JsonObject, key: string): Result<string | undefined, ArkAgentError> {
if (!object.has(key)) return Result.success<string | undefined, ArkAgentError>(undefined)
const value = object.get(key)?.asString()
if (value === undefined) {
return Result.failure<string | undefined, ArkAgentError>(ArkAgentError.decode(
'decode_optional_string', `${key} must be string`))
}
return Result.success<string | undefined, ArkAgentError>(value)
}
// 可选数字:支持整数校验(integer=true 时检查是否有小数部分)
static optionalNumber(object: JsonObject, key: string, integer: boolean = false): Result<number | undefined, ArkAgentError> {
if (!object.has(key)) return Result.success<number | undefined, ArkAgentError>(undefined)
const value = object.get(key)?.asNumber()
if (value === undefined || !Number.isFinite(value) || (integer && !Number.isInteger(value))) {
return Result.failure<number | undefined, ArkAgentError>(ArkAgentError.decode(
integer ? 'decode_optional_integer' : 'decode_optional_number', `${key} has invalid number`))
}
return Result.success<number | undefined, ArkAgentError>(value)
}
// 可选布尔
static optionalBoolean(object: JsonObject, key: string): Result<boolean | undefined, ArkAgentError> {
if (!object.has(key)) return Result.success<boolean | undefined, ArkAgentError>(undefined)
const value = object.get(key)?.asBoolean()
if (value === undefined) {
return Result.failure<boolean | undefined, ArkAgentError>(ArkAgentError.decode(
'decode_optional_boolean', `${key} must be boolean`))
}
return Result.success<boolean | undefined, ArkAgentError>(value)
}
}
关键区分:必填 vs 可选。
-
requiredString:字段不存在 → 报错(因为"必填")。 -
optionalString:字段不存在 → 返回undefined(合法);字段存在但不是 string → 报错。
这个区分很重要。必填字段缺失是 Provider 违约,必须报错;可选字段缺失是正常的,可能是版本差异。但可选字段存在却类型错误仍然是错误——不能因为"可选"就容忍类型错误。
数字校验的两个层次: optionalNumber 有一个 integer 参数。integer=false(默认)只检查是否有限值(Number.isFinite),拒绝 NaN 和 Infinity;integer=true 额外检查是否为整数(Number.isInteger),用于 index 这种必须为整数的字段。
6.3 解码 ToolCall:规则的综合应用
看一个完整的领域模型解码示例——ToolCall:
static decodeToolCall(object: JsonObject): Result<ToolCall, ArkAgentError> {
const id = Decode.requiredString(object, 'id') // 必填
const name = Decode.requiredString(object, 'name') // 必填
const argumentsJson = Decode.requiredString(object, 'argumentsJson') // 必填
const index = Decode.optionalNumber(object, 'index', true) // 可选,整数
// 任一失败 → 短路返回第一个错误
if (!id.ok || !name.ok || !argumentsJson.ok || !index.ok) {
return Result.failure<ToolCall, ArkAgentError>(
(id.error ?? name.error ?? argumentsJson.error ?? index.error) as ArkAgentError)
}
return Result.success<ToolCall, ArkAgentError>(new ToolCall(
id.value as string, name.value as string, argumentsJson.value as string, index.value))
}
注意编码时 index 是可选的(if (call.index !== undefined)),但解码时用 optionalNumber——如果 JSON 里有 index 但不是整数,会报 decode_optional_integer 错误。这就是"可选字段存在却类型错误仍然是错误"的体现。
6.4 未知枚举:保留 raw value
这是四规则里最有远见的一条。以 ContentKind 为例:
static decodeContentPart(object: JsonObject): Result<ContentPart, ArkAgentError> {
const rawKindResult = Decode.requiredString(object, 'kind')
if (!rawKindResult.ok || rawKindResult.value === undefined) {
return Result.failure<ContentPart, ArkAgentError>(rawKindResult.error as ArkAgentError)
}
const rawKind = rawKindResult.value
// 逐个匹配已知枚举值,都不匹配 → unknown
let kind = ContentKind.unknown
if (rawKind === ContentKind.text) kind = ContentKind.text
else if (rawKind === ContentKind.image) kind = ContentKind.image
else if (rawKind === ContentKind.audio) kind = ContentKind.audio
else if (rawKind === ContentKind.video) kind = ContentKind.video
else if (rawKind === ContentKind.document) kind = ContentKind.document
// ... 解码其他字段 ...
return Result.success<ContentPart, ArkAgentError>(new ContentPart(
kind, text.value, data.value, uri.value, mime.value, metadata,
kind === ContentKind.unknown ? rawKind : undefined))
// ↑ unknown 时保留 rawKind ↑
}
为什么不能直接报错? 因为 Provider 协议会升级。假设智谱明天加了一个新的 ContentKind(比如 audio),你的 SDK 还没更新。如果未知枚举直接报错,旧版 SDK 就完全无法处理新 Provider 的响应——整个 Agent 崩溃。
保留 raw value 的做法是:未知枚举映射为 ContentKind.unknown,同时把原始字符串存在 rawKind 字段里。这样 SDK 不会崩溃,业务可以决定是忽略还是提示"遇到了未知类型"。状态持久化时 raw value 也会被保存,恢复时不会丢失信息。
阶段 1 的实施心得把这条规则提炼成了一句经验:
"Unknown enum 必须同时保留 raw value,否则 Provider 协议升级会破坏状态回放。"
这条规则也是版本策略的一部分——minor 版本只追加可选字段或新枚举 raw fallback,不删除字段。有了 raw fallback,新旧版本的状态文件可以互操作。
6.5 encode/decode 对称性
每个领域模型都有成对的 encode 和 decode 方法:
// encode:领域模型 → JsonObject(用于持久化或发送)
static encodeToolCall(call: ToolCall): JsonObject {
const object = new JsonObject()
.set('id', JsonValue.string(call.id))
.set('name', JsonValue.string(call.name))
.set('argumentsJson', JsonValue.string(call.argumentsJson))
if (call.index !== undefined) object.set('index', JsonValue.number(call.index))
return object
}
// decode:JsonObject → 领域模型(用于加载或接收)
static decodeToolCall(object: JsonObject): Result<ToolCall, ArkAgentError> {
// ... 见 6.3 ...
}
encode 不返回 Result(它不会失败——从强类型到 JsonObject 是安全的),decode 返回 Result(它可能失败——从动态数据到强类型需要校验)。这种不对称是合理的:编码是"我们信任自己的数据",解码是"我们不信任外部数据"。
往返测试(round-trip test)验证 encode/decode 的对称性:decode(encode(x)) === x。这是保证状态持久化正确性的基础——保存的状态一定能恢复。
七、Result + ArkAgentError:错误模型
整个 JSON 体系的错误处理建立在两个类型上:Result<T, E> 和 ArkAgentError。
7.1 Result:不抛异常的错误传递
export class Result<T, E> {
private readonly valueData?: T
private readonly errorData?: E
readonly ok: boolean
private constructor(ok: boolean, value?: T, error?: E) {
this.ok = ok
this.valueData = value
this.errorData = error
}
static success<T, E>(value: T): Result<T, E> {
return new Result<T, E>(true, value)
}
static failure<T, E>(error: E): Result<T, E> {
return new Result<T, E>(false, undefined, error)
}
get value(): T | undefined { return this.valueData }
get error(): E | undefined { return this.errorData }
}
Result 是一个标准的代数数据类型——要么是 success(携带值),要么是 failure(携带错误)。它用 ok 布尔标志区分两种状态,用泛型 T 和 E 分别表示成功值和错误类型。
为什么用 Result 而不是 try-catch? 三个原因:
-
ArkTS 的 throw 限制。
arkts-limited-throw要求 throw 的必须是Error子类。如果你想让错误携带结构化信息(错误码、layer、retryability),用Result比自定义 Error 子类 + try-catch 更直接。 -
错误路径显式化。
Result强制调用方处理错误——你不能"忘记 catch",因为result.value是T | undefined,不检查result.ok就直接用.value会拿到undefined。try-catch 允许你完全忽略异常(不写 catch 块),Result不允许。 -
类型推断更好。
Result<T, ArkAgentError>明确告诉你这个操作可能返回T或ArkAgentError。try-catch 的异常类型在 ArkTS 里难以精确标注。
使用模式:
const parsed = JsonCodec.parseObject(text)
if (!parsed.ok || parsed.value === undefined) {
// 错误路径
return Result.failure<...>(parsed.error as ArkAgentError)
}
// 成功路径
const object = parsed.value // JsonObject
几乎每个 Codec 方法都是这个模式——先检查 ok,失败则短路返回错误,成功则继续。这种"错误短路"模式让代码线性可读,没有嵌套的 try-catch。
7.2 ArkAgentError:分层 + 可重试性
export enum ErrorLayer {
decode = 'decode', // JSON 解析、类型校验失败
config = 'config', // 配置错误(缺 Key、不支持的内容、未知扩展)
transport = 'transport', // 网络传输错误
provider = 'provider', // Provider 返回的错误(401/429/500 等)
tool = 'tool', // 工具执行错误
runtime = 'runtime', // Runtime 内部错误
storage = 'storage', // 状态持久化错误
security = 'security', // 安全违规
cancelled = 'cancelled' // 用户取消
}
export enum ErrorRetryability {
never = 'never', // 永不重试(401、配置错误)
safe = 'safe', // 安全重试(幂等操作)
conditional = 'conditional' // 条件重试(429/500,有退避)
}
export class ArkAgentError extends Error {
readonly layer: ErrorLayer
readonly code: string
readonly retryability: ErrorRetryability
readonly cause?: string
readonly statusCode?: number
readonly providerCode?: string
readonly details?: JsonObject
// ...
}
九个错误层覆盖了 SDK 的所有错误来源,每个都有明确的语义和重试策略:
|
ErrorLayer |
含义 |
典型场景 |
默认重试性 |
|---|---|---|---|
|
|
解析/校验失败 |
JSON 畸形、必填缺失、类型不匹配 |
never |
|
|
配置错误 |
缺 Key、不支持的内容、未知扩展 |
never |
|
|
网络传输错误 |
连接失败、超时、DNS 错误 |
conditional |
|
|
Provider 返回错误 |
401/403/429/500 |
视状态码 |
|
|
工具执行错误 |
工具抛异常、返回 isError |
never(转 ToolResult) |
|
|
Runtime 内部错误 |
状态机违规、循环检测 |
never |
|
|
状态持久化错误 |
文件读写失败、迁移失败 |
never |
|
|
安全违规 |
Key 入日志、非 HTTPS |
never |
|
|
用户取消 |
点停止、切后台取消 |
never |
和 JSON 处理最相关的是 decode 和 config 两层:
-
decode 层:JSON 解析失败、必填字段缺失、类型不匹配。这类错误不可重试(同样的输入再试还是错)。
-
config 层:API Key 缺失、不支持的内容类型、未知 Provider 扩展。这类错误也不可重试(配置不变,结果不变)。
静态工厂让创建错误更简洁:
ArkAgentError.decode('json_expected_object', 'Expected JSON object')
ArkAgentError.config('missing_token', 'Bearer token is empty')
ArkAgentError.config('unknown_extension', `extensions.${key} is not allowed by provider profile`)
注意 decode 和 config 工厂默认 retryability = never——解析和配置错误天然不可重试。这避免了"401 错误被重试三次浪费时间"这种问题。
八、Provider extensions 守卫:不信任外部输入
JSON 类型安全的最后一道防线,是 Provider 扩展参数的守卫。这条规则看似和 JSON 无关,实则是"类型安全边界"理念的延伸。
8.1 问题:Provider 的语义漂移
智谱和 DeepSeek 都兼容 OpenAI 接口,但扩展参数不同:
|
扩展参数 |
智谱支持 |
DeepSeek 支持 |
|---|---|---|
|
|
✅ |
✅ |
|
|
✅ |
❌ |
|
|
✅ |
✅ |
如果用户给 DeepSeek 配了 tool_stream: true,会发生什么?DeepSeek 不认识这个参数,行为不可预测——有的 Provider 会报错,有的会忽略,有的会产生奇怪副作用。
8.2 ProviderExtensionGuard:白名单守卫
ArkAgent 的做法是:每个 Provider Profile 声明自己支持的扩展白名单,调用前校验,未识别的扩展报配置错误。
export class ProviderExtensionGuard {
static validate(request: ModelRequest, allowed: string[]): Result<void, ArkAgentError> {
const extensions = request.modelConfig.extensions
if (extensions === undefined) {
return Result.success<void, ArkAgentError>(undefined as void)
}
const keys = extensions.keys()
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (allowed.indexOf(key) < 0) {
return Result.failure<void, ArkAgentError>(
ArkAgentError.config('unknown_extension',
`ModelConfig.extensions.${key} is not allowed by provider profile`))
}
}
return Result.success<void, ArkAgentError>(undefined as void)
}
// 按类型读取扩展(返回 undefined 表示不存在)
static readObject(extensions: JsonObject | undefined, key: string): JsonObject | undefined {
return extensions?.get(key)?.asObject()
}
static readString(extensions: JsonObject | undefined, key: string): string | undefined {
return extensions?.get(key)?.asString()
}
static readBoolean(extensions: JsonObject | undefined, key: string): boolean | undefined {
return extensions?.get(key)?.asBoolean()
}
}
每个 Profile 在 capabilities 里声明自己的白名单:
// 智谱支持三个扩展
this.capabilities = new ProviderCapabilities(
true, true, true, true, true, true,
['thinking', 'tool_stream', 'reasoning_effort'], // ← 白名单
...)
// DeepSeek 只支持两个
this.capabilities = new ProviderCapabilities(
true, true, true, true, true, false,
['thinking', 'reasoning_effort'], // ← 没有 tool_stream
...)
8.3 为什么"不静默发送"
核心契约里有一条明确规则:
"Provider 特有参数仅进入
extensions,由 Provider Profile 消费;未消费的扩展必须在调用前报配置错误,不能静默发送。"
ADR-0008 的设计动机记录了原因:
"智谱与 DeepSeek 都宣称兼容 OpenAI,但请求扩展、thinking、Tool streaming、Usage 和错误存在差异。"
静默发送一个 Provider 不认识的参数,等于把控制权交给未知——你不知道 Provider 会报错、忽略还是产生副作用。与其让 bug 隐藏在"静默忽略"里,不如显式拒绝,让开发者在调用前就知道"这个参数不被这个 Provider 支持"。
这条规则把"Provider 语义漂移"的风险前移到了可观测、可处理的 config 错误层——而不是让它在运行期变成不可预测的行为。
8.4 Profile 怎么消费扩展
白名单校验通过后,各自的 Profile 用 readObject/readString/readBoolean 消费自己支持的扩展。智谱消费三个:
// ZhipuProviderProfile
applyRequestExtensions(body: JsonObject, request: ModelRequest): Result<void, ArkAgentError> {
const extensions = request.modelConfig.extensions
const thinking = ProviderExtensionGuard.readObject(extensions, 'thinking')
if (thinking !== undefined) {
body.set('thinking', JsonValue.object(thinking))
}
const toolStream = ProviderExtensionGuard.readBoolean(extensions, 'tool_stream')
if (toolStream !== undefined) {
body.set('tool_stream', JsonValue.boolean(toolStream))
}
const effort = ProviderExtensionGuard.readString(extensions, 'reasoning_effort')
if (effort !== undefined) {
body.set('reasoning_effort', JsonValue.string(effort))
}
return Result.success<void, ArkAgentError>(undefined as void)
}
DeepSeek 只消费两个(没有 tool_stream):
// DeepSeekProviderProfile
applyRequestExtensions(body: JsonObject, request: ModelRequest): Result<void, ArkAgentError> {
const extensions = request.modelConfig.extensions
const thinking = ProviderExtensionGuard.readObject(extensions, 'thinking')
if (thinking !== undefined) {
body.set('thinking', JsonValue.object(thinking))
}
const effort = ProviderExtensionGuard.readString(extensions, 'reasoning_effort')
if (effort !== undefined) {
body.set('reasoning_effort', JsonValue.string(effort))
}
return Result.success<void, ArkAgentError>(undefined as void)
}
注意 readObject/readBoolean/readString 都是类型安全的——它们内部用 asObject()/asBoolean()/asString() 访问器,类型不匹配返回 undefined(表示"这个扩展不存在或类型不对"),不会 crash。
九、类型安全的边界哲学
把前面的内容串起来,ArkAgent 的 JSON 类型安全体系可以画成一张边界图:
┌─────────────────────────────────────────────────────────────────┐
│ 外部世界(Provider 响应、工具参数、状态文件) │
│ 完全不信任,类型未知 │
└──────────────────────────┬──────────────────────────────────────┘
│ JSON 文本
▼
┌─────────────────────────────────────────────────────────────────┐
│ 边界一:JsonCodec.parseObject │
│ 手写 Parser,文本 → JsonObject │
│ 畸形 JSON → DecodeError(带 offset) │
└──────────────────────────┬──────────────────────────────────────┘
│ JsonObject(类型安全的动态容器)
▼
┌─────────────────────────────────────────────────────────────────┐
│ 边界二:DomainCodec.decodeXxx │
│ JsonObject → 领域模型(ToolCall / Message / ...) │
│ 必填缺失/类型错 → DecodeError │
│ 未知字段 → 忽略 │
│ 未知枚举 → unknown + raw value │
│ 数字无效 → DecodeError │
└──────────────────────────┬──────────────────────────────────────┘
│ 强类型领域模型
▼
┌─────────────────────────────────────────────────────────────────┐
│ 边界三:ProviderExtensionGuard / ContentCapabilityGuard │
│ 领域模型 → Provider 请求 │
│ 未知扩展 → config error(不静默发送) │
│ 不支持的内容 → config error │
└──────────────────────────┬──────────────────────────────────────┘
│ 校验通过的请求
▼
┌─────────────────────────────────────────────────────────────────┐
│ SDK 内部(Runtime / Loop / Tool) │
│ 全部使用强类型,零 any │
└─────────────────────────────────────────────────────────────────┘
三层边界,每层都有明确的"信任转换":
-
边界一:从不信任的文本,到类型安全的
JsonObject。 -
边界二:从动态的
JsonObject,到强类型的领域模型。 -
边界三:从领域模型,到 Provider 特定的请求格式。
越过这三层边界后,SDK 内部全部是强类型代码,不需要再处理"字段可能不存在"或"类型可能不对"的问题——这些不确定性已经在边界上被消除了。
这就是 ArkAgent 的类型安全哲学:在边界上主动建模、主动校验、主动拒绝;在内部享受强类型的安全和简洁。不靠"小心一点",靠架构。
十、最佳实践清单
10.1 类型建模
-
✅ 用
JsonValue class + JsonKind + JsonObject(Map)作为动态数据的唯一载体。 -
✅
JsonValue构造器设为 private,通过静态工厂创建,保证 kind 和数据一致。 -
✅
JsonObject用Map而不是Record(Record触发索引签名)。 -
✅ 访问器返回
undefined而不是抛异常,让调用方用可选链处理。 -
❌ 不要用递归类型别名 + 索引签名(
[key: string]: T)。 -
❌ 不要用
any接收JSON.parse的结果。 -
❌ 不要用
Object强制转换动态对象。
10.2 JSON 解析
-
✅ 用手写的严格 Parser,不依赖
JSON.parse的动态结果。 -
✅ 所有解析错误携带 offset 定位信息。
-
✅
parse返回Result,不抛异常。 -
✅
parseObject校验根必须是对象。 -
✅ 解析完后检查是否到文本末尾(拒绝尾部多余内容)。
10.3 领域编解码
-
✅ 每个 decode 方法返回
Result<T, ArkAgentError>。 -
✅ 必填字段用
requiredString等工具,缺失即报错。 -
✅ 可选字段用
optionalString等工具,不存在返回 undefined,类型错报错。 -
✅ 数字校验有限值(
Number.isFinite),整数额外校验(Number.isInteger)。 -
✅ 未知枚举映射为
unknown+ 保留 raw value。 -
✅ 未知字段默认忽略。
-
✅ encode/decode 对称,用往返测试验证。
10.4 错误模型
-
✅ 用
Result<T, ArkAgentError>传递错误,不靠 try-catch。 -
✅ 错误必须继承
Error(满足arkts-limited-throw)。 -
✅ decode/config 错误默认
retryability = never。 -
✅ 错误信息不包含敏感数据(Key、Authorization、完整请求体)。
10.5 Provider 边界
-
✅ 每个 Provider 声明扩展白名单。
-
✅ 调用前用
ProviderExtensionGuard.validate校验。 -
✅ 未识别的扩展报 config error,不静默发送。
-
✅ 模型名不进入 Core enum,作为配置字符串传入。
10.6 接口实现
-
✅ 用显式 class 实现接口,不用对象字面量。
-
✅ 测试 Fixture 优先用静态字符串,不用
JSON.stringify({...})。
十一、常见错误对照表
|
错误做法 |
问题 |
正确做法 |
|---|---|---|
|
|
|
用 |
|
|
同样触发索引签名 |
用 |
|
|
|
用 |
|
|
动态对象无法属性访问 |
|
|
|
需要索引签名,编译拒绝 |
用手写 Parser 直接产出 |
|
|
|
用显式 class 实现接口 |
|
|
|
|
|
未知枚举直接报错 |
Provider 升级旧 SDK 崩溃 |
映射为 |
|
必填字段缺失返回 null |
和"值为 null"混淆 |
返回 |
|
可选字段类型错也忽略 |
静默吞错,难排查 |
存在但类型错仍报 |
|
|
破坏后续计算 |
|
|
整数字段接受浮点数 |
|
|
|
给 DeepSeek 配 |
静默发送,行为未知 |
白名单校验 + config error |
|
未识别扩展静默忽略 |
Provider 行为不可预测 |
|
|
解析错误不带 offset |
排查困难 |
|
|
decode 失败 throw 异常 |
调用方要 try-catch |
返回 |
|
encode 也返回 Result |
从强类型到 JsonObject 不会失败 |
encode 直接返回 |
十二、验证清单(真机 Review)
编译验证
-
全项目零
any -
零
arkts-no-indexed-signatures告警 -
零
arkts-no-untyped-obj-literals告警 -
所有 throw 的类型是
Error子类
JSON 解析
-
正常 JSON 解析正确
-
畸形 JSON(少括号、少引号)返回带 offset 的 DecodeError
-
尾部多余内容报
json_trailing_content -
Unicode 转义(
\uXXXX)正确解析 -
所有转义序列(
\n \t \r \b \f \/ \\ \")正确解析 -
非法转义报
json_invalid_escape -
parseObject对非对象根报json_expected_object
领域编解码
-
必填字段缺失返回 DecodeError
-
未知字段被忽略,不报错
-
未知枚举映射为 unknown + raw value 保留
-
整数字段拒绝浮点数
-
NaN/Infinity 被拒绝
-
encode → decode 往返一致(round-trip test)
Provider 扩展
-
智谱
thinking/tool_stream/reasoning_effort正确注入请求 -
DeepSeek
thinking/reasoning_effort正确注入请求 -
给 DeepSeek 配
tool_stream报unknown_extensionconfig error -
扩展类型不匹配(比如 thinking 传字符串)被安全忽略或报错
状态持久化
-
保存的状态文件能完整恢复
-
未知字段的状态文件能加载(前向兼容)
-
未知 ContentKind 的状态能恢复并保留 raw value
-
schemaVersion 存在且正确
十三、构建验证
# 构建 agent_core HAR
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHar --no-daemon
# 运行单元测试(含 JSON 编解码、领域 codec 往返测试)
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw test --no-daemon
构建结果:
CompileArkTS passed
BUILD SUCCESSFUL
JSON 类型安全体系的测试覆盖:正常 JSON 解析、畸形 JSON(每种错误码)、未知字段、未知枚举 raw fallback、数字边界(NaN/Infinity/整数)、encode/decode 往返一致性。这些测试用纯字符串 Fixture,不依赖真机网络,保证了类型安全逻辑的可复现验证。
风险登记册(R-003)记录的"ArkTS 递归 JSON 类型不通过严格编译"风险已通过 JsonValue class 方案已缓解,阶段 1 通过编译验证和往返测试关闭。
十四、写在最后
在 ArkTS 里处理 JSON,你会被五条编译约束(arkts-no-any、arkts-no-indexed-signatures、arkts-no-untyped-obj-literals、arkts-limited-throw、JSON.parse 限制)同时逼到墙角。最直觉的"绕过"(any、ESObject、强制转换)都是技术债——它们把编译期能抓住的问题推迟到运行期 crash。
ArkAgent 的选择是正面设计一套类型安全体系:
JSON 文本
↓ JsonCodec.parseObject(手写 Parser,带 offset 报错)
JsonObject(Map 承载,不依赖索引签名)
↓ DomainCodec.decodeXxx(四规则校验)
强类型领域模型(ToolCall / Message / ModelResponse)
↓ ProviderExtensionGuard(白名单守卫)
Provider 请求(未识别扩展不静默发送)
这条路的核心判断是:动态数据必须在边界主动建模,不能依赖运行时的鸭子类型。JsonValue class + JsonKind + JsonObject(Map) 看起来比 TypeScript 的递归别名啰嗦,但它换来的是编译期保证——凡是能编译通过的代码,类型就是对的。在 AI Agent 这种"大量动态 JSON 交互"的场景下,这个代价极其划算。
记住这几条口诀:
ArkTS 禁 any 禁索引,递归别名走不通。 JsonValue 加 JsonKind,私有构造工厂建。 JsonObject 用 Map 装,链式 set 写得顺。 JSON.parse 不可靠,手写 Parser 带偏移。 必填缺失要报错,未知字段放心略。 未知枚举 unknown 兜,raw value 必须留。 数字校验有限值,整数不能带小数。 Result 不抛裸异常,错误分层带可重试。 Provider 扩展白名单,未识别的不静默。 边界建模内部纯,类型安全靠架构。
本文是 ArkAgent 鸿蒙教程系列的第五篇。前三篇讲了架构全景、快速接入和流式输出,这篇拆解了支撑这一切的类型安全基石——JSON 体系。后续文章会逐层深入工具调用、状态持久化、记忆子 Agent、评估框架——每一层的正确性都建立在这套类型安全体系之上。
如果你正在鸿蒙上做涉及大量 JSON 交互的应用(不只是 AI Agent,任何和后端 API 交互的应用),可以把这套 JsonValue 体系和三层边界模型直接作为你的类型安全基线。不要从 JSON.parse + any 开始——那是运行期 crash 的开始。
更多推荐




所有评论(0)