← Blog

ModelCraft:01-如何调研和闭环

从 Duck talking、自主验证 demo、区分 Mock 边界到沉淀高质量测试,讨论技术调研如何形成闭环。

ModelCraft

ModelCraft:01-如何调研和闭环

背景

上一章,我们讲解了什么是数据模型,以及数据模型有哪些功能。 接下来,就逐步讲解如何实现一个数据模型。

其实这节课可能大家会觉得有点水,但是实际参与工作中非常重要的一个环节。如何明白你要做什么事以及如何调研?

换句话说,老板给你讲了一个事情,希望你去负责,然后调研,然后得到一个结论,然后大家租户做出决定。这是工作中你成为一个主程必要的一个能力。

这篇文章我其实只想讲 4 件事:

  1. 如何理解问题:先用 Duck talking 把问题说清楚
  2. 如何验证 demo:不要只听 AI 讲,要自己上手改一改
  3. 如何学会 Mock:知道哪些必须真实,哪些可以先假设
  4. AI 时代最大的资产:高质量测试用例

后面 GraphQL、runtime、meta、SQL 这些内容,都是用来承载这 4 件事的案例,而不是为了炫技。

1. 如何理解问题:Duck talking 自我澄清

这里我简单的说一个我们要做数据模型。很多人对这个数据模型的概念还不清楚,它究竟是个什么东西呢?

有这个疑问是非常正常的,在工作中,我们会接触大量这样有可能之前从未出现在之前知识体系内的事情。这个时候,就需要我们拥有一种能力,就是理解事物的能力。我觉得这个能力是区分老手和菜鸟的关键能力。

我一般如何做呢?我经常采用的是Duck talking的方法。即自己问自己。给自己预设一些问题,然后尝试回答它,在回答的过程中,大脑逐渐明白这件事情。

Duck talking:自己提问自己、自己追问自己,直到彻底明白的循环

比如:

Q:数据模型最核心的能力是什么? A:提供一套协议和组件交互,让组件拥有标准的,规范的后端crud接口适配,保证在开发新组件的过程中,仅依赖协议即可完成。 更好的结果是,面对开发者直接调用协议,在不通过工单,以及技术支持的情况下也能很好的使用,在AI时代,通过一个skill,或者其他方式,就能让AI掌握。

Q:如何设计标准的,规范化的协议,为什么不能用开源既有实现? A:首先开源没有。因为这里要做的事情首先是提供动态的,强类型的协议。比如restful动态,但是不是强类型。比如Odata,强大但是过于灵活, 但是安全性有问题,需要明确允许哪些 $expand、最大深度, 而且生态不友好。然后自然而然的就落到了Graphql上。

Q:为什么是Graphql? A:这一步其实需要去读Grapqhl文档,自己最好写1-3个demo,去深刻体会。注意不管你怎样调研,你始终是带着自己的品味的,所以你的目标是1.走广度,即看下这个技术的宽度是多少,即生态,社区活跃度,多少人认可。2.走广度,亲自写demo, 明白哪些具体能力是干什么,然后是哪些官方在使用,这些官方背书决定了技术的上限。

Graphql 1. 强类型语言。 2. 天然的自解释性。 3. github以及facebook的背书。 4. post接口 + query,mutation 接口名的方式天然适合做鉴权。5 设计之初就是解决overfecth问题完美适配select语法等等

在确立Graphql作为协议的底层后,接下来就是去调研使用哪些开源库?

因为我现在是Go的脑残粉,所以技术选型无脑拥抱了Go。

2. 如何验证 demo:不只是让 AI 讲给你听,还要自己上手改一改

Helloworld 的思想 VS AI

我想,但凡是学过编程语言的同学。 都知道Helloworld的思想, 其本质原理就是把一个复杂问题拆解成简单问题。 然后利用TDD的思想,红绿红,一步一步从简单到复杂,然后完成一个功能,从而解决复杂问题。 有人会问,AI来了, 还需要这种思想么? 在我个人的观点看来, AI会不代表你会。即有时候我比较难以理解的是,很多人会有AI出现了后的知识无用论。先不说AI幻觉的问题,难道以后所有复杂问题抛给AI解决么?真的存在这样一种银弹么?

AI时代,虽然说你可以不用写一个真的Helloworld了。但是官方给的Demo你要具备能改的能力。很多人现在依赖AI到什么程度呢,当我交给我的实习生去了解某个事情的任务后。他直接给我了AI总结的一个文档。但是当我去对一些地方提问的时候,他又现场提问AI。额,我们可以把工作交给AI,但是不能把脑子交给AI!!!

数据模型的核心模块是runtime

数据模型的核心就是:如何把一个meta信息暴露成动态的graphql,然后一条grahpql 变成sql真正执行

对应的模块是:https://github.com/luker1228/modelcraft/tree/master/modelcraft-backend/internal/domain/modelruntime

他是数据模型的最高宪法,是一等公民,是因为这点醋,才包的这个项目的饺子。

入口是: /data/home/lukemxjia/modelcraft/modelcraft-backend/internal/app/modelruntime/graphql_app.go

案例:用 GraphQL runtime 调研来验证最小 demo

调研开源库

我当时主要看了 3 个 Go 里最常见的 GraphQL 方案:

  1. github.com/99designs/gqlgen
  2. github.com/graphql-go/graphql
  3. github.com/graph-gophers/graphql-go

它们看起来都叫 GraphQL for Go,但设计哲学其实完全不一样。

这里我特别想补充一点。调研不是让AI给一个结论就完善了。 我见过太多装模作样调研的人,其实很简单,就是“实践是检验真理的唯一标准”,你要真的自己用下demo,手动改一改,哪怕加点日志。 也会让你对问题理解更加深入和具体。 人是无法理解超出自己认知之外的事情的

1. gqlgen:schema-first,类型安全最强

gqlgen 的思路是先写 .graphql schema,然后自动生成 Go 代码和 resolver 骨架。

优点:

  • 类型安全很好,编译期就能发现大量 schema 和代码不一致的问题
  • 对静态业务接口很友好,团队协作体验成熟
  • 生态完整,工程化能力强,做正式 API 很稳

缺点:

  • 太依赖 codegen。schema 一变就要重新生成代码
  • 更适合“接口是提前定义好的”场景,不适合我这种运行时动态拼 schema 的需求
  • 当 model、field、where input 都是运行时根据 meta 生成时,gqlgen 这套静态生成链路会非常别扭

一句话总结:如果你做的是常规业务 GraphQL API,gqlgen 往往是第一选择;但如果你要做 dynamic GraphQL runtime,它反而太重、太静态。

2. graphql-go:code-first,但运行时最灵活

graphql-go 的思路比较朴素:直接在 Go 代码里构造 ObjectFieldSchema,然后执行 graphql.Do()

优点:

  • 运行时拼装 schema 很灵活,特别适合根据 meta 动态生成类型和字段
  • 心智模型简单,核心链路清楚:Schema -> Field -> Resolver -> Execute
  • 非常贴合我这里的需求:把“模型定义”翻译成 GraphQL schema,再把查询参数翻译成内部查询对象

缺点:

  • 类型安全弱很多,很多问题只能在运行时发现
  • 手写对象定义比较啰嗦,复杂后容易散
  • 工程化配套没有 gqlgen 那么强,很多约束需要自己补

一句话总结:它不是最现代的方案,但非常适合做“运行时动态 schema 构建器”。

3. graph-gophers/graphql-go:更偏标准 schema,但动态能力一般

graph-gophers/graphql-go 也是 Go 里很常见的一套实现,它更偏“先有 schema,再绑定 resolver”。

优点:

  • API 设计比较干净,整体体验比纯手写 AST/对象层舒服
  • 对标准 GraphQL 语义支持不错,适合常规服务端接口
  • 比较适合“我有一份稳定 schema,然后把 resolver 绑上去”的模式

缺点:

  • 还是更偏静态 schema,不像 graphql-go 那样适合在运行时大规模拼装类型
  • 做动态 input、动态 relation、动态 filter 时,不如 graphql-go 直接
  • 对我这个“meta -> runtime schema” 的核心问题帮助没有那么大

一句话总结:它比 graphql-go 更规整,但没有 graphql-go 那么适合做高度动态的 runtime。

最后为什么我会落到 graphql-go

因为我这里真正要解决的问题,不是“给几个固定表写一个 GraphQL API”,而是:

  • 模型是动态的
  • 字段是动态的
  • where / orderBy / relation 也是动态的
  • 整个 schema 需要在运行时根据 meta 组装出来

这时最重要的能力不是 codegen,也不是静态类型绑定,而是运行时拼 schema 的自由度

所以如果把这 3 个库放在一起看:

  • gqlgen 最适合静态、正式、工程化很强的业务 API
  • graph-gophers/graphql-go 适合偏标准、偏静态 schema 的服务
  • graphql-go 最适合我这里这种 dynamic runtime

因此最后选 graphql-go,不是因为它“最先进”,而是因为它最贴合问题本身

接下来真正关键的,不再是“继续讨论概念”,而是要把问题收缩成一个可以验证的 demo。因为调研如果不能落到一个可运行、可修改、可观察的最小闭环,最后大概率只会停留在嘴上理解。

只看一条链路:select * from xxx limit 10

query {
findMany(
where: { status: { eq: "done" } }
orderBy: [{ createdAt: "desc" }]
take: 10
skip: 20
) {
items {
id
title
status
}
totalCount
}
}

我当时真正想验证的问题只有一个:

在 demo 阶段,怎么先验证“查询多条”这件事能不能用 graphql-go 跑通?

这里先别讲复杂架构,也先别讲动态 SQL 生成。

因为 demo 阶段根本不该追求“大而全”。 这时候要看的就 3 件事:

  1. graphql-go 能不能定义一个 findMany 查询入口
  2. 这个入口能不能拿到参数并命中 resolver
  3. resolver 能不能返回一个列表结果

这 3 件事一旦成立,就够了。 说明这个库至少能承载“查询多条”这个最小能力。

第 1 步:先用 graphql-go 定义一个最小 schema

graphql-go 是 code-first 的。也就是说,你不用先写 .graphql 文件,而是直接在 Go 里定义类型、字段和 resolver。

所以 demo 阶段完全没必要一上来就搞动态模型。先写一个最小版本就够了,比如:

  • 一个 Todo 类型
  • 一个 findMany 查询字段
  • 返回值里先有 items

本质上先证明这件事:

GraphQL Query
-> graphql-go Schema
-> findMany resolver
-> 返回列表

这个闭环能跑起来,后面才值得继续投时间。

第 2 步:给 findMany 挂一个 resolver

graphql-go 的核心点很简单:每个字段都可以挂一个 Resolve 函数。

所以 findMany 能不能工作,关键根本不在 SQL,而在于:

这条查询,最后能不能稳定命中一个 resolver

这个 resolver 在 demo 阶段甚至可以先不查数据库,直接返回假数据。 因为你此时要验证的不是“数据库链路有没有通”,而是:

  • GraphQL 字段定义对不对
  • 参数结构能不能被解析
  • 返回列表结构对不对

最小的 resolver,大概就是这样:

Resolve: func(p graphql.ResolveParams) (any, error) {
return map[string]any{
"items": []map[string]any{
{"id": 1, "title": "write article", "status": "done"},
{"id": 2, "title": "record video", "status": "done"},
},
"totalCount": 2,
}, nil
}

这一步一旦成立,就已经证明 graphql-go 能做“查询多条”。

第 3 步:再验证参数能不能传进来

只有返回死数据还不够。 查询多条真正有意义的地方,在于它通常会带筛选、排序、分页。

所以第二个要验证的点是:

where / orderBy / take / skip
能不能从 GraphQL 请求里进到 resolver

graphql-go 里,这些参数最终都会出现在 graphql.ResolveParams 里。

也就是说,当你写下这条查询:

query {
findMany(
where: { status: { eq: "done" } }
orderBy: [{ createdAt: "desc" }]
take: 10
skip: 20
) {
items {
id
title
status
}
totalCount
}
}

resolver 里理论上就能拿到这些参数。

demo 阶段不需要把它们优雅抽象,也不需要马上设计统一查询对象。 你只需要先证明:

func(p graphql.ResolveParams) (any, error) {
where := p.Args["where"]
orderBy := p.Args["orderBy"]
take := p.Args["take"]
skip := p.Args["skip"]
_ = where
_ = orderBy
_ = take
_ = skip
...
}

这里能拿到值,就说明 graphql-go 已经具备承载查询语义的能力。

第 4 步:最后再把 resolver 里的假数据换成真实查询

到这一步,demo 的核心目标其实已经差不多完成了。

因为你已经验证了:

Schema 能定义
-> Resolver 能命中
-> 参数能传入
-> 列表能返回

剩下的事情才是:把 resolver 里的假数据替换成真正的数据来源。

这个数据来源一开始甚至也不用复杂。 完全可以分三层逐步验证:

  1. 先返回内存切片,确认 GraphQL 结构没问题
  2. 再写死一条 select * from xxx limit 10,确认数据库链路没问题
  3. 最后再把 whereorderBytakeskip 一点点补进去

也就是说,demo 阶段真正合理的顺序不是:

一上来就设计完整 runtime

而是:

先证明 graphql-go 能承接查询多条
-> 再接一个最简单的 SQL
-> 再逐步增加查询语义

第 5 步:所以 demo 阶段真正要看的,不是“架构优不优雅”

而是下面这个最小闭环有没有成立:

query findMany
-> graphql.Do()
-> findMany resolver
-> 拿到 Args
-> 查询数据
-> 返回 items

这个闭环成立,就说明 graphql-go 这个库可用。

这个时候,你才有资格继续往下想:

  • 如何抽 Input
  • 如何做统一 SQL Mapper
  • 如何支持动态模型
  • 如何把 relation、aggregate、cursor pagination 全补上

这些都属于第二阶段的问题。

完成这一步之后,其实“计算”这件事就已经被解决得差不多了。

所谓计算,讲白了就是:给我一条 GraphQL,我能不能命中 resolver,能不能把参数接住,能不能把结果算出来再返回。 只要这个最小闭环成立,说明执行层是通的。

那剩下来的问题就不是计算了,而是存储。

也就是:这些 meta 信息到底怎么存?

但这个问题本身没有想象中玄。 它本质上就是表设计。

而且这里也不需要故意发明新东西。 因为我做的不是数据库本体,而是一层装饰层。 我关心的不是重造 table、column、index 这些概念,而是对这些 meta 信息做增强

所以最自然的做法,就是直接模仿数据库自己的结构去设计:

  • 表,就对应一张 meta table
  • 字段,就对应一张 meta column
  • 关系,就对应 relation meta
  • 再在这些基础信息上补充自己的增强字段

比如展示配置、校验规则、权限标签、关联语义,本质上都是“增强信息”,而不是底层数据库事实本身。

所以存储层这件事,反而不需要讲得太神秘。 它没有 runtime 这边这么多执行细节,本质就是:沿着数据库原有的结构建模,然后在上面加一层增强 metadata。

接下来真正要考虑的问题,不是“怎么存”,而是:怎么触发存储。

因为这些 meta 信息不是凭空来的。 你总得有一个入口,把数据库里的表、字段、索引、关系这些基础事实,同步到你自己的 meta 表里。

这时候要做的事情也不复杂,核心就是暴露一个“同步接口”。

比如你完全可以先设计成这样一种语义:

sync database metadata

它的职责很单纯:

  1. 连接目标数据库
  2. 扫描数据库里的表结构信息
  3. 把 table / column / relation 这些事实层数据写入自己的 meta 表
  4. 对已经存在的记录做更新,对不存在的记录做新增

这里最重要的不是实现细节,而是接口语义要清楚。

也就是说,这个接口不是“帮用户改数据库结构”,而是:

把数据库当前的事实,拉一份出来,同步成系统自己的 metadata。

这个边界一定要守住。 因为我是装饰层,不是数据库迁移工具,也不是 ORM migration 引擎。 我不负责替用户创建表、删除列、改索引。 我只负责识别数据库现在长什么样,然后把它映射成我的 meta 系统。

所以这一段设计接口时,简单描述就够了,不需要展开太多实现。 真正重要的是把职责说清楚:

  • 输入是数据库连接和目标库信息
  • 过程是扫描数据库 schema
  • 输出是同步后的 meta table / meta column / relation metadata

到了这里,存储这件事也就差不多闭环了。

再往后,数据模型的核心工作其实就只剩一条主线:findMany 这条最小查询链路扩展出去。

也就是从:

  • findMany

逐步扩成:

  • count
  • create
  • update
  • delete
  • findUnique

这里为什么这么设计,其实也没什么特别高深的原因。 我基本就是直接抄 Prisma。

原因很朴素:不是因为我做过什么严密论证,也不是因为它在理论上最优,而是因为 Prisma 足够流行。

既然它流行,就说明这套命名和操作心智,很多开发者已经被教育过了。 那我直接沿用它,成本最低。

换句话说,这里的目标不是发明一套全新的 CRUD 语言,而是尽量站在现成生态上。 用户一看 findManyfindUniquecreateupdatedeletecount,基本不用解释,就知道大概是什么意思。

所以这里的设计动机其实非常现实:不是追求原创,而是直接复用 Prisma 已经跑出来的用户心智。

本质上这不是重新发明一套新系统,而是在同一套 runtime 思路上,把不同操作一个个补齐。

当这些基础能力补完以后,数据模型这件事的核心其实就已经设计完成了。 后面再往下做的 relation、aggregate、分页、权限,更多是能力增强,而不是架构性质变。

别看这里前面写了这么多,其实思路本身并不复杂。

说到底,无非就是几件事:

  • 先验证 graphql-go 能不能承接最小查询闭环
  • 再把数据库事实同步成自己的 meta 存储
  • 再把 findMany 这条链路扩成 countcreate 这些基础能力

这套东西在今天这个阶段,代码怎么写反而已经不是最难的问题了。 有 AI 编程在,很多 demo、样板代码、重复劳动,推进速度都会非常快。

真正难的地方,不是“这一行代码该怎么敲”,而是:你脑子里有没有这套完整闭环。

也就是你是否真的想清楚了:

  • 先验证什么
  • 后抽象什么
  • 存储边界在哪
  • 同步接口负责什么
  • runtime 和 metadata 的分工是什么

这些东西一旦想清楚,代码只是把设计翻译出来。 但如果闭环没想清楚,就算 AI 帮你一天写几千行,也只是更快地把混乱实现出来。

3. 学会 Mock:最重要的不是会不会造假数据,而是知道哪里必须真实

很多人第一次做 demo,最容易犯的错就是两个极端:

  • 要么什么都想一次做真,结果被数据库、权限、网络、初始化脚本拖死
  • 要么什么都 mock,最后跑出来一个看似可用、其实什么关键问题都没验证到的空壳

所以真正难的,不是“会不会 mock”,而是:你能不能判断出哪些东西是这次调研必须保真的,哪些东西只是为了加快验证速度可以先替代。

在我这个案例里,真正要验证的是 GraphQL 这一层执行语义能不能成立,所以“查询链路”必须尽量真实,而“数据内容”本身完全可以先假。

也就是说,Mock 不是偷懒,而是在保护你的验证目标不被无关细节稀释。

这里继续往前走一步,就会自然落到第四件事:当你已经知道怎么验证、也知道哪些地方该 mock,接下来最值钱的就不再是某一段样板代码,而是你沉淀下来的验证资产。

4. AI 时代最大的资产:高质量测试用例

因为在古法编程时期,我本来就是一个很注重单测的人。 对我来说,写高质量测试一直不是“锦上添花”,而是工程里最值钱的那部分资产之一。

不过这里要说得更准确一点:高质量测试资产,并不是简单的“越早写越好”,而是要尽早开始定义核心语义,但不要过早追求把所有测试一次写满。

为什么呢?因为测试真正有价值,不是因为它出现得早,而是因为它测的是不是系统真正应该成立的语义。

如果你的代码已经写完、甚至已经上线,再回头补单元测试,问题就会变得很明显:

  • 这些测试往往测的是“现在这坨代码怎么工作”
  • 而不是“这个系统本来应该怎么工作”
  • AI 为了保证线上行为不被破坏,也会倾向于迎合现状
  • 结果就是把很多历史兼容、偶然行为、脏边界一起固化进测试里

很多兼容,在系统一开始其实根本没有必要。 但如果你太晚补测试,这些本来不该长期存在的东西,就会因为“测试已经这样写了”而被反向合理化。

所以今天真正稀缺的,已经不是“会不会写测试代码”,而是:你能不能在语义还干净、边界还清楚的时候,把最核心的行为先沉淀成测试。

这一点在这里尤其重要。 因为从 graphqlsql 的转换,本质上不是一个小函数,而是一整组函数在协同工作。

这里面会不断出现各种边界:

  • where 怎么解析
  • orderBy 怎么落
  • take / skip 怎么处理
  • 空条件怎么处理
  • 非法字段怎么报错
  • 不同组合条件下 SQL 长什么样

这些东西如果只靠人脑记,迟早会漏。 但一旦通过测试把它们固化下来,这条链路就会越来越稳。

而且这里的测试价值,不只是“防止改坏”。 更重要的是,它会把你对 runtime 语义的理解沉淀成可执行资产。

这里还有一个很重要的分寸感:测试用例只有经过多层次验证才真正有用。 也就是说,它不是“脑子里想一个 case 就立刻永久固化”,而是随着你对问题理解越来越深,不断筛掉偶然行为,留下真正该被长期保护的语义。

换句话说,测试测的不只是代码对不对,更是在定义:

  • 这个系统允许什么输入
  • 遇到什么边界应该怎么表现
  • 最终应该生成什么样的 SQL

所以这类测试一旦写好,它就不只是辅助代码,而会成为项目里非常重要的财富。 真正好的测试,不是为了迁就历史实现,而是为了守住那些你确认过、验证过、值得长期保留的系统行为。

后面你改实现、换 SQL builder、重构参数解析,甚至换人维护,都没关系。 只要这批测试还在,核心语义就还在。

回到调研本身:调研不是看资料,而是设计验证路径

最后再补一句,到底什么叫调研该做的事情。

很多时候,我们去调研一门技术,不是为了“多懂一个名词”,也不是为了看完文档以后能复述几段概念。 真正关键的是:这门技术到底能不能解决我的问题。

所以调研一定是带着问题去调研,而不是纸上谈兵。

这里最怕的就是问题说不清,最后调研也只能停留在“这个框架看起来不错”“那个生态好像很成熟”这种空话上。 这些话没有任何工程价值。

真正有效的调研,反而很简单。 还是那句话:先把你的问题表达清楚。

然后把“怎么验证它能不能解决”也说清楚。

而且这个问题一定要具象化。 越具体,调研越有效。

比如我这里的问题不是:

GraphQL 好不好

而是:

我能不能通过这门技术,实现动态的 GraphQL

这就是一个可以被验证的问题。

围绕这个问题去调研,你就不会跑偏。 你不会再关心那些太泛的评价,而是只看几件事:

  • 它能不能动态定义 schema
  • 它能不能承接 findMany 这种查询入口
  • 它能不能把参数传进 resolver
  • 它能不能先把最小闭环跑通

只要这些事能做到,就说明这门技术在你的问题上是可用的。

当然,这里面还有一个很重要的点:你要知道哪些东西必须真实,哪些东西可以 mock。

比如我这里,真正要验证的是 GraphQL 这层执行能力,那数据库数据本身就完全可以先 mock。

也就是说:

  • schema 定义要真实
  • resolver 命中要真实
  • 参数传递要真实
  • 返回结构要真实
  • 但数据内容本身可以先 mock

这样调研才会快,而且不容易被无关细节拖死。

所以说到底,调研这件事真的没那么玄。 它的核心不是“看了多少资料”,而是:

你有没有把自己的问题说清楚,然后把验证路径设计清楚。

这节课的总结

如果是学生时期的我,看这节课讲什么的时候,我会觉得云里雾里。那个时候的我只想看代码,只想看设计模式,只想看数据结构。我感觉这更像编程里的“术”。如今,我今天想讲的更多的是编程的“道”,即如何自己完成一个功能,或者说一个复杂的事情,或者说你想做个东西,你究竟如何开始。

总结:

  1. 如何理解问题————duck talking自我澄清
  2. 如何验证demo————不只是AI,还要自己上手改改
  3. 学会Mock————其实这里最难的是要知道哪些可以Mock,哪些不可以Mock才是最重要的
  4. AI时代最大的资产————高质量测试用例

如果你对这类数据模型、架构设计、Agentic Engineering 的内容感兴趣,欢迎关注我的公众号:Luke’s AI Hub

公众号二维码