代码即文档:免费使用 Vercel AI SDK 和 ZenStack 实现自动化

很少有开发人员喜欢编写文档

如果你曾经在一家大公司担任过开发人员,你就会知道,编码只是日常职责的一小部分。谷歌的一位全栈软件工程师 Ray Farias 曾估计,谷歌的开发人员每天编写大约 100-150 行代码。虽然这个估计值可能因团队而异,但这个数量级与我作为微软开发人员的观察结果相符。

那么时间都去哪儿了?很大一部分时间都花在了会议、代码审查、规划会议和文档任务等活动上。在所有这些任务中,文档是我最不喜欢的——我怀疑很多队友也有同样的感受。

主要原因是我们看不出它有多大的价值。我们被要求在每个冲刺开始时在编码之前编写设计文档,在彼此审查之后,大多数文档将永远保持不变。我数不清有多少次我在文档中发现一些奇怪的东西,而它的作者却告诉我它已经过时了。😂 我们为什么不更新文档?因为我们的老板认为它不如修复错误或添加新功能重要。

文档应充当代码的高级抽象,以帮助理解。当文档与代码不同步时,它就失去了其目的。但是,保持文档与代码同步需要付出努力——很少有人真正喜欢这样做。

鲍勃大叔(罗伯特·C·马丁)有一句关于整洁代码的名言:

好的代码就是自己的注释

我认为如果这个原则可以扩展到文档中就太好了:

**好的代码就是它自己最好的文档**

使用 AI 生成文档

目前人工智能应用的趋势有一个简单的规则:如果人类不喜欢做某件事,就让人工智能来处理。文档似乎完全符合这一类别,尤其是如今人工智能已经生成了越来越多的代码。

现在时机再好不过了,因为 GitHub 刚刚宣布 Copilot 功能是免费的。你可以尝试让它免费为你的项目生成文档。但是,结果可能不如你预期的那样好。是因为你提示不够好吗?也许吧,但这背后还有一个更重要的原因:

LLM 处理命令式代码的能力不如处理声明式文本的能力。

命令式代码通常涉及复杂的控制流、状态管理和错综复杂的依赖关系。这种程序性要求更深入地了解代码背后的意图,这对于 LLM 来说可能很难准确推断。此外,代码量越大,结果越有可能不准确且信息量越少。

您希望在 Web 应用程序的文档中看到的第一件事是什么?最有可能的是作为整个应用程序基础的数据模型。数据模型可以声明式定义吗?当然可以!Prisma ORM 已经做得很好了,它允许开发人员使用直观的数据建模语言定义他们的应用程序模型。

ZenStack 工具包建立在 Prisma 之上,通过附加功能增强了架构。通过直接在数据模型中定义访问策略和验证规则,它成为应用程序后端的唯一真实来源。

当我说“单一事实来源”时,它不仅包含后端的所有信息,它实际上是您的整个后端。ZenStack 会自动为您生成 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)
}

我们可以轻松创建一个使用 AI 从此模式生成文档的工具。您无需再手动编写和维护文档 - 只需将生成过程集成到 CI/CD 管道中,就不会再出现不同步的问题。以下是从模式生成的文档的示例:

zenstack-doc

我将引导您完成创建此工具的步骤。

ZenStack插件系统

与 Web 开发领域中的许多出色工具一样,ZenStack 采用基于插件的架构。系统的核心是 ZModel 架构,围绕该架构,功能以插件形式实现。让我们创建一个插件来为 ZModel 生成 markdown,以便其他人可以轻松采用它。

为简洁起见,我们将重点介绍核心部分。请参阅 ZenStack 文档了解完整的插件开发详细信息。

插件只是一个 Node.js 模块,它包含两个部分:

  • 命名的导出名称,指定用于日志记录和错误报告的插件的名称。
  • 包含插件逻辑的默认函数导出。
  • 它看起来是这样的:

    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 schema 的结果对象模型,它是一个包含 schema 中所有信息的树结构。

    我们可以使用ZenStack sdk提供的`ZModelCodeGenerator`从AST中获取ZModel内容。

    import { ZModelCodeGenerator } from '@zenstackhq/sdk';
    const zModelGenerator = new ZModelCodeGenerator();
    const zmodel = zModelGenerator.generate(model);

    现在我们有了食材,让我们让人工智能来做饭吧。

    使用Vercel AI SDK生成文档

    最初,我计划使用 OpenAI 来完成这项工作。但很快,我意识到这会排除那些无法使用付费 OpenAI 服务的开发人员。感谢 Elon Musk,您可以从 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。

    vercel-ai-sdk

    以下是我们用来让 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 模式在模型提供商之间统一了此功能。

    对于上面的提示,这里是我们期望收到的相应的响应数据结构。

    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()),
                })
            ),
        });

    然后调用`generateObject` API让AI完成他的工作:

    const { object } = await generateObject({
            model
            schema
            prompt
        });

    以下是允许您以类型安全的方式工作的返回类型:

    const object: {
        overview: {
            description: string;
            functionality: string;
        };
        models: {
            name: string;
            access_control_policies: string[];
        }[];
    }

    生成美人鱼 ERD 图

    我们还为每个模型生成 ERD 图。这部分非常简单且易于实现,所以我认为在这里编写代码更可靠、更高效。当然,你仍然可以在这里使用 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