代码即文档:免费使用 Vercel AI SDK 和 ZenStack 实现自动化
很少有开发人员喜欢编写文档。
如果你曾在大型公司担任过开发人员,你就会知道,编写代码只是日常工作中很小的一部分。谷歌的一位全栈软件工程师 Ray Farias 曾估计,谷歌的开发人员每天大约编写 100 到 150 行代码。虽然这个估计值可能因团队而异,但数量级与我作为微软开发人员的观察结果相符。
那么时间都去哪儿了呢?相当一部分时间都花在了会议、代码审查、计划会议和文档编写等活动上。在所有这些任务中,文档编写是我最不喜欢的——我猜很多其他团队成员也有同感。
主要原因是我们觉得它没什么价值。每个迭代周期开始之前,我们都要先写设计文档,互相审核之后,大部分文档就再也没改过。我数不清有多少次在文档里发现奇怪的地方,结果作者告诉我文档过时了。😂 为什么我们不更新文档呢?因为我们的老板觉得更新文档不如修复bug或添加新功能重要。
文档应该作为代码的高级抽象,以帮助理解。当文档与代码不同步时,它就失去了意义。然而,保持文档与代码同步需要付出努力——而这恰恰是很少有人乐意做的。
鲍勃大叔(罗伯特·C·马丁)有一句关于编写整洁代码的名言:
好的代码本身就是注释。
我认为如果能将这一原则推广到文档编写领域,那就太好了:
好的代码本身就是最好的文档
使用人工智能生成文档
当前人工智能应用的趋势遵循一条简单的原则:如果人类不喜欢做某件事,那就让人工智能来做。文档编写似乎完美地契合了这一范畴,尤其是在如今越来越多的代码已经由人工智能生成的情况下。
时机真是太好了,GitHub 刚刚宣布Copilot 的所有功能都免费了。您可以尝试让它免费生成您项目的文档。不过,结果可能不如您预期。是因为您的提示不够好吗?也许是,但背后还有更重要的原因:
LLM 处理命令式代码的能力不如处理声明式文本的能力。
命令式代码通常涉及复杂的控制流、状态管理和错综复杂的依赖关系。这种过程式特性要求对代码背后的意图有更深入的理解,而语言学习模型(LLM)很难准确推断出这种意图。此外,代码量越大,结果就越有可能不准确且信息量越少。
在 Web 应用程序的文档中,你最想看到什么?很可能是作为整个应用程序基础的数据模型。数据模型可以用声明式的方式定义吗?当然可以!Prisma ORM在这方面做得非常出色,它允许开发人员使用直观的数据建模语言来定义应用程序模型。
ZenStack工具包基于 Prisma 构建,通过添加额外功能增强了数据模式。通过直接在数据模型中定义访问策略和验证规则,它成为应用程序后端唯一的数据源。
我所说的“单一数据源”,不仅包含后端所需的所有信息,它实际上就是你的整个后端。ZenStack 会自动为你生成 API 和相应的前端钩子。定义好访问策略后,就可以直接从前端安全地调用这些 API,而无需在数据库层启用行级安全性 (RLS)。换句话说,你几乎不需要编写任何后端代码。
以下是一个极其简化的博客文章应用程序示例:
datasource db {
provider = 'postgresql'
url = env('DATABASE_URL')
}
generator js {
provider = 'prisma-client-js'
}
plugin hooks {
provider = '@zenstackhq/tanstack-query'
output = 'lib/hooks'
target = 'react'
}
enum Role {
USER
ADMIN
}
model Post {
id String @id @default(cuid())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String @default(auth().id)
@@allow('all', auth() == author)
@@allow('read', auth() != null && published )
@@allow('read', auth().role == 'ADMIN')
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String @password @omit
role Role @default(USER)
posts Post[]
@@allow('create,read', true)
@@allow('update,delete', auth() == this)
}
我们可以轻松地创建一个工具,利用人工智能技术根据此模式生成文档。您无需再手动编写和维护文档——只需将生成流程集成到 CI/CD 流水线中,即可彻底解决文档不同步的问题。以下是根据此模式生成的文档示例:
我将一步步指导您如何创建这个工具。
ZenStack插件系统
与许多优秀的 Web 开发工具一样,ZenStack 也采用了基于插件的架构。该系统的核心是 ZModel 模式,各种功能都围绕它以插件的形式实现。让我们创建一个插件,为 ZModel 生成 Markdown 文档,以便其他人能够轻松地使用它。
为简洁起见,我们将重点介绍核心部分。完整的插件开发细节请参阅ZenStack 文档。
插件其实就是一个Node.js模块,它由两部分组成:
- 一个命名导出
name,用于指定用于日志记录和错误报告的插件名称。 - 包含插件逻辑的默认函数导出。
它的样子是这样的:
import type { PluginOptions } from '@zenstackhq/sdk';
import type { DMMF } from '@zenstackhq/sdk/prisma';
import type { Model } from '@zenstackhq/sdk/ast';
export const name = 'ZenStack MarkDown';
export default async function run(model: Model, options: PluginOptions, dmmf: DMMF.Document) {
...
}
model是 ZModel AST。它是解析和链接 ZModel 模式的结果对象模型,ZModel 模式是一个树状结构,包含模式中的所有信息。
我们可以使用ZModelCodeGeneratorZenStack SDK 提供的功能从 AST 中获取 ZModel 内容。
import { ZModelCodeGenerator } from '@zenstackhq/sdk';
const zModelGenerator = new ZModelCodeGenerator();
const zmodel = zModelGenerator.generate(model);
现在我们已经有了食材,就让人工智能来烹饪吧。
使用 Vercel AI SDK 生成文档
最初,我计划使用 OpenAI 来完成这项工作。但很快我意识到,这会将那些无法使用 OpenAI 付费服务的开发者排除在外。感谢埃隆·马斯克,你可以从 Grok(https://x.ai/)获取免费的 API 密钥。
然而,我不得不为每个模型提供商编写单独的代码。Vercel AI SDK 的优势就在于此。它提供了一个标准化的接口,用于与各种 LLM 提供商交互,使我们能够编写适用于多个 AI 模型的代码。无论您使用的是 OpenAI、Anthropic 的 Claude 还是其他提供商,实现方式都保持一致。
它提供了一种统一的 LanguageModel 类型,允许您指定任何您想要使用的 LLM 模型。只需检查环境即可确定哪个模型可用。
let model: LanguageModel;
if (process.env.OPENAI_API_KEY) {
model = openai('gpt-4-turbo');
} else if (process.env.XAI_API_KEY) {
model = xai('grok-beta');
}
...
其余实现部分都使用相同的统一 API,无论你选择哪个提供商。
以下是我们用来让AI生成文档内容的提示:
const prompt = `
You are the expert of ZenStack open-source toolkit.
You will generate a technical design document from a provided ZModel schema file that help developer understand the structure and behavior of the application.
The document should include the following sections:
1. Overview
a. A short paragraph for the high-level description of this app
b. Functionality
2. an array of model. Each model has below two information:
a. model name
b. array of access policies explained by plain text
here is the ZModel schema file:
\`\`\`zmodel
${zmodel}
\`\`\`
`;
生成结构化数据
在处理 API 时,我们更倾向于使用 JSON 数据而非纯文本。虽然许多 LLM 都能够生成 JSON,但它们各自的实现方式有所不同。例如,OpenAI 提供 JSON 模式,而 Claude 则要求在提示符中指定 JSON 格式。好消息是,Vercel SDK 也使用 Zod schema 统一了不同模型提供商的这一功能。
对于上述提示,我们期望收到的相应响应数据结构如下。
const schema = z.object({
overview: z.object({
description: z.string(),
functionality: z.string(),
}),
models: z.array(
z.object({
name: z.string(),
access_control_policies: z.array(z.string()),
})
),
});
然后调用generateObjectAPI 让 AI 执行其任务:
const { object } = await generateObject({
model
schema
prompt
});
返回的类型可以让你以类型安全的方式进行操作:
const object: {
overview: {
description: string;
functionality: string;
};
models: {
name: string;
access_control_policies: string[];
}[];
}
生成美人鱼ERD图
我们再为每个模型生成ER图。这部分比较简单易行,所以我认为用代码实现更可靠高效。当然,你也可以借助AI来辅助完成。😄
export default class MermaidGenerator {
generate(dataModel: DataModel) {
const fields = dataModel.fields
.filter((x) => !isRelationshipField(x))
.map((x) => {
return [
x.type.type || x.type.reference?.ref?.name,
x.name,
isIdField(x) ? 'PK' : isForeignKeyField(x) ? 'FK' : '',
x.type.optional ? '"?"' : '',
].join(' ');
})
.map((x) => ` ${x}`)
.join('\n');
const relations = dataModel.fields
.filter((x) => isRelationshipField(x))
.map((x) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const oppositeModel = x.type.reference!.ref as DataModel;
const oppositeField = oppositeModel.fields.find(
(x) => x.type.reference?.ref == dataModel
) as DataModelField;
const currentType = x.type;
const oppositeType = oppositeField.type;
let relation = '';
if (currentType.array && oppositeType.array) {
//many to many
relation = '}o--o{';
} else if (currentType.array && !oppositeType.array) {
//one to many
relation = '||--o{';
} else if (!currentType.array && oppositeType.array) {
//many to one
relation = '}o--||';
} else {
//one to one
relation = currentType.optional ? '||--o|' : '|o--||';
}
return [`"${dataModel.name}"`, relation, `"${oppositeField.$container.name}": ${x.name}`].join(' ');
})
.join('\n');
return ['```
mermaid', 'erDiagram', {% raw %}`"${dataModel.name}" {\n${fields}\n}`{% endraw %}, relations, '
```'].join('\n');
}
}
把一切都缝合起来
最后,我们将所有生成的组件组合在一起,得到最终文档:
const modelChapter = dataModels
.map((x) => {
return [
`### ${x.name}`,
mermaidGenerator.generate(x),
object.models
.find((model) => model.name === x.name)
?.access_control_policies.map((x) => `- ${x}`)
.join('\n'),
].join('\n');
})
.join('\n');
const content = [
`# Technical Design Document`,
'> Generated by [`ZenStack-markdown`](https://github.com/jiashengguo/zenstack-markdown)',
`${object.overview.description}`,
`## Functionality`,
`${object.overview.functionality}`,
'## Models:',
dataModels.map((x) => `- [${x.name}](#${x.name})`).join('\n'),
modelChapter,
].join('\n\n');
现成的
当然,您不必自己实现。它已经作为 NPM 包发布,您可以直接安装:
npm i -D zenstack-markdown
将插件添加到您的 ZModel 架构文件中
plugin zenstackmd {
provider = 'zenstack-markdown'
}
别忘了把你可用的所有 AI API 密钥都添加到 .env 文件中。否则,你可能会遇到一些意想不到的结果。😉
OPENAI_API_KEY=xxxx
XAI_API_KEY=xxxxx
ANTHROPIC_API_KEY=xxxx

