数据模型与代码生成

文件:

herd/_model.goat

描述应用程序的结构,是 Goat 生成器使用的主要信息来源。

模型定义的内容包括:

  • 应用程序元数据,
  • 支持的语言,
  • 用户角色,
  • 实体,
  • 实体属性,
  • 关系,
  • 数据约束,
  • 读写权限,
  • 分配给实体的模块,
  • CRUD 行为,
  • SEO 和 SSR 元素。

基于这些信息,Goat 可以生成应用程序多个层的一致性组件,包括:

  • Go 模型,
  • DAO 和仓储,
  • DTO,
  • API,
  • CRUD 命令,
  • SQL 迁移,
  • 表单,
  • 列表和管理视图,
  • 前端元素。

最重要的优势并不只是生成文件本身。该模型允许你一次描述某个结构,然后在应用程序的多个部分中使用该定义。

无需手动同步后端、数据库、API 和前端;你可以修改模型,并让生成器准备由此变更产生的修改。

作为应用程序描述的模型

herd/_model.goat 应主要描述应用程序的意图和结构,而不是每个生成文件的实现细节。

简而言之:

_model.goat
    ↓
领域模型
    ↓
生成器
    ↓
后端 + 数据库 + API + 前端

例如,字段定义:

add --name=title --type=web_title

不仅可能影响后端模型,还会影响数据存储方式、生成的 DTO、表单和视图。

因此,相同的信息无需在不同技术中重复声明多次。

应用程序元数据

模型从应用程序的基本配置开始:

app --name goat \
    --version 0.0.1 \
    --path app \
    --go-prefix code.pozoga.eu/spozoga/goatcms.com \
    --terminal-prefix APP \
    --default-language=en \
    --supported-languages=pl,en,de

该定义指定的内容包括:

  • 应用程序名称,
  • 版本,
  • 项目路径,
  • Go 模块前缀,
  • 终端使用的前缀,
  • 默认语言,
  • 支持的语言列表。

这些信息之后可由生成器及应用程序的各个模块使用。

角色与权限

角色直接在模型中定义。

示例:

role:add --name=admin --super
role:add --name=manager
role:add --name=user

在此情况下,应用程序具有三个角色:

  • admin,
  • manager,
  • user。

标志:

--super

表示拥有扩展系统权限的角色。

之后可以在定义实体属性的访问权限时使用角色。

示例:

def --write=admin,manager --read=user

允许指定哪些角色可以修改数据,哪些角色可以读取数据。

这样,基本访问规则可以直接在模型层定义,而无需在 API、表单和后端中分别重复定义。

基础实体

如果多个实体具有共同的结构,建议将其提取为基础实体。

content 就是一个示例:

entity:base:add --name content \
    --label slug \
    --base base_entity

基础实体可以定义通用的:

  • 属性,
  • 关系,
  • 约束,
  • 访问规则。

在 content 项目中,它表示应用程序中发布内容的基本结构。

随后可供以下内容使用:

  • 页面,
  • 文档,
  • 文章,
  • 其他内容类型。

实体属性

属性在 --properties 部分中定义。

示例:

--properties=<<PROPERTIESEOF
    def --write=admin,manager --read=user --list

    add --name=title \
        --type=web_title \
        --doc="Title is a short heading that describes the content."

    add --name=slug \
        --type=web_slug \
        --required \
        --doc="Slug is a URL-friendly name for the content."

    add --name=lang \
        --type=language \
        --required \
        --doc="Language of the current content."
PROPERTIESEOF

每个属性可以指定的内容包括:

  • 名称,
  • 类型,
  • 是否必填,
  • 呈现方式,
  • 访问权限,
  • 文档说明,
  • 生成器的附加行为。

属性类型携带的信息比数据库中的列类型本身更多。

例如:

web_title
web_slug
language
seo_description
block_content
file_set

还可以决定数据在界面中的验证、序列化和呈现方式。

实体继承

实体可以扩展基础实体。

示例文档实体:

entity:extended --name doc --base content

表示 doc 继承了为 content 定义的属性和行为。

这样可以避免在每个表示内容的实体中重复定义以下字段:

title
slug
lang
description
body

实体还可以同时添加自己的属性。

示例:

--properties=<<PROPERTIESEOF
    def --write=admin,manager --read=user --list

    add --name=ver \
        --type=short_text \
        --doc="Goat CLI version number for the page"
PROPERTIESEOF

这样,doc 保留了完整的 content 模型,同时还存储文档版本。

关系

实体之间的关系可以直接在模型中定义。

示例:

--relations=<<RELATIONSEOF
    def --onremove

    add --name=owner \
        --to=user \
        --doc="Owner is the author of the article."
RELATIONSEOF

该定义指定了指向实体 user 的 owner 关系。

生成器可以在多个位置使用此类信息:

  • 数据模型,
  • SQL 模式,
  • API,
  • DAO,
  • 表单,
  • 管理视图。

因此,关系只需在一个位置描述,而不必在应用程序的每一层中分别定义。

数据约束

模型还可以定义数据相关的约束。

示例:

--constraints=<<CONSTRAINTSEOF
    unique:add --fields=lang,slug
CONSTRAINTSEOF

表示以下组合:

lang + slug

必须唯一。

这使得可以为不同语言保存相同的 slug:

pl / architektura
en / architecture
de / architektur

同时防止创建语言和 slug 均相同的两条记录。

实体模块

实体可以通过模块获得额外行为。

例如,实体 doc 使用:

module:add --name=seo
module:add --name=ssr
module:add --name=crud

模块允许扩展实体,而无需重复整个实现。

因此,在实践中,实体模型不仅可以描述其数据,还可以描述应用程序应如何使用它。

SEO 模块

模块:

module:add --name=seo

添加与页面元数据相关的行为。

对于继承自 content 的实体,它可以使用例如:

title
description

来准备搜索引擎和内容分享机制所需的信息。

这样,基础 SEO 支持可以由模型生成,而无需为每个页面手动实现。

SSR 渲染

模块:

module:add --name=ssr

允许将实体记录关联到应用程序的公开路由。

文档示例:

module:add \
    --name=ssr \
    --slug=doc \
    --route="/doc/{lang}/{slug}" \
    --property:list:list="title,description" \
    --property:list:details="title,body"

该定义指定了:

  • 公开路径,
  • 记录识别方式,
  • 列表中所需的字段,
  • 详情视图中所需的字段。

对于示例文档,生成的路径可能如下:

/doc/zh/project-architecture

随后,SSR 模块可以使用实体数据来准备服务端渲染的页面。

CRUD 模块

模块:

module:add --name=crud

定义通过生成的管理操作处理实体的方式。

示例:

module:add \
    --name=crud \
    --property:list:list="ver,lang,title" \
    --property:list:persist="title,slug,lang,description,body,ver"

property:list:list 指定展示记录列表时使用的属性。

property:list:persist 指定创建和更新记录时使用的字段。

crud 模块定义 WebUI/API 契约。控制台命令字段则通过 crud_cli 单独配置:

module:add \
    --name=crud_cli \
    --property:list:list="username,email" \
    --property:list:persist="password,role,username,email"

生成的命令会保留命名空间 crud:<entity>:*。crud_cli.list 指定 crud:<entity>:list 返回的字段,而 crud_cli.persist 指定创建和更新标志。list 命令每行输出一个 JSON 对象,并支持 --limit、--order-by、--order-direction 和 --search。

可扩展模块

Goat 还支持定义自定义模块类型。

示例:

entity:module:def \
    --type=top_box_counter \
    --query:count=<<QUERYDEFEOF
        def --required
QUERYDEFEOF \
    --short_text:label=<<LABELDEFEOF
        def --required
LABELDEFEOF

该定义创建了模块类型 top_box_counter,它需要:

  • 查询 count,
  • 标签 label。

随后可基于该类型创建具体模块。

示例:

module:add \
    --name=admin_counter \
    --type=top_box_counter \
    --label=Administrators \
    --query:count=<<QUERYEOF
        where:and --conditions=<<WHEREEOF
            eq --field=role --value=admin
        WHEREEOF
QUERYEOF

这使得可以在更高的抽象层级上构建可复用的应用组件。

无需每次都在后端和前端实现用户计数器,而是可以将其结构定义为模型模块。

仪表盘模块示例

多个模块可以组合成更大的结构。

示例:

entity:module:def \
    --type=summary_cards \
    --modules:list=<<MODULESLISTEOF
        def --types=top_box_counter --required
MODULESLISTEOF

然后,实体 user 可以获得一组计数器:

module:add \
    --name=summary_cards \
    --type=summary_cards \
    --modules:list=<<SUMMARYCARDSOF

        module:add \
            --name=admin_counter \
            --type=top_box_counter \
            --label=Administrators \
            --query:count=<<QUERYEOF
                where:and --conditions=<<WHEREEOF
                    eq --field=role --value=admin
                WHEREEOF
            QUERYEOF

        module:add \
            --name=manager_counter \
            --type=top_box_counter \
            --label=Managers \
            --query:count=<<QUERYEOF
                where:and --conditions=<<WHEREEOF
                    eq --field=role --value=manager
                WHEREEOF
            QUERYEOF

        module:add \
            --name=user_counter \
            --type=top_box_counter \
            --label=Users \
            --query:count=<<QUERYEOF
                where:and --conditions=<<WHEREEOF
                    eq --field=role --value=user
                WHEREEOF
            QUERYEOF
SUMMARYCARDSOF

这里的模型不仅描述数据结构,也描述仪表盘组件及为其提供数据所需的查询。

这表明 herd/_model.goat 并不只是数据库模式定义的对应物。它还可以描述应用行为的更高层级。

独立实体

并非每个实体都必须继承自 content。

例如,图库:

entity:add \
    --name gallery \
    --label title \
    --base base_entity

其属性可以如下:

--properties=<<PROPERTIESEOF
    def --write=admin,manager --read=user --list

    add --name=title \
        --type=nice_name \
        --doc="Title is a short heading that describes the content."

    def --write=admin,manager --read=user

    add --name=slug \
        --type=web_slug \
        --unique \
        --doc="Slug is a URL-friendly name for the content."

    add --name=files \
        --type=file_set \
        --doc="Files for the gallery"
PROPERTIESEOF

这表明模型既可以描述基于共同基础的实体,也可以描述完全独立的领域结构。

代码生成

修改后:

herd/_model.goat

运行生成器:

goat re

或直接运行:

go run ./scripts re

生成器读取当前模型,并准备由此产生的应用变更。

该过程可简化表示如下:

herd/_model.goat
        ↓
herd/gen.goat
        ↓
模板
        ↓
生成器
        ↓
应用代码

可生成的内容包括:

  • 模型,
  • 仓储,
  • API,
  • CLI 命令,
  • 前端,
  • SQL 迁移。

基础数据库模式还会生成到:

app/static/raw/data/sql/migrations/0001_schema.sql

生成器配置

在对生成方式进行较大修改之前,建议检查:

herd/gen.goat

该文件描述生成过程、所使用的模板以及其生成结果写入的位置。

如果想要修改大量相似元素的生成方式,通常最好修改相应模板,而不是在多个文件中手动重复相同的改动。

简而言之:

_model.goat

决定生成什么,

而:

herd/gen.goat
herd/gen/templates/

决定结果如何呈现。

生成的代码可以编辑

Goat 创建的代码仍然是项目的普通源代码。

你可以:

  • 修改它,
  • 重构它,
  • 扩展它,
  • 添加自己的逻辑,
  • 像提交其他代码一样提交它。

生成器的目的是加快工作,而不是将用户限制在其生成模型中。

因此,并非每项变更都必须映射到 herd/_model.goat。

如果你需要一次性的、特定于某项功能的修改,直接编辑生成的代码可能是最简单的解决方案。

不过,如果你发现自己反复执行同一种修改,就值得将其上移一层——放到模型或生成器模板中。

何时修改模型?

当修改涉及领域结构或行为时,应修改 herd/_model.goat。

示例:

  • 添加实体,
  • 添加属性,
  • 修改关系,
  • 添加约束,
  • 修改权限,
  • 添加模块,
  • 修改 CRUD 配置,
  • 修改实体的公开路由。

示例:

add \
    --name=published_at \
    --type=datetime

如果 published_at 是领域模型的一部分,就应在此处进行描述。

何时修改模板?

当修改应影响某一特定类型的所有元素时,应修改生成器模板。

示例:

  • 所有表单都应获得额外的结构,
  • 每个生成的端点都应使用新的机制,
  • 所有实体都应获得额外的辅助代码,
  • 希望修改生成的前端约定。

这类修改最好在以下位置进行:

herd/gen/templates/

而不是分别修正多个生成的文件。

何时直接编辑代码?

当修改仅适用于特定情况时,直接编辑是合适的。

例如:

  • 非典型的业务逻辑,
  • 额外的集成,
  • 特殊端点,
  • 某个表单的特殊行为,
  • 复杂查询,
  • 手动优化,
  • 自定义 UI 元素。

没有必要仅仅为了处理一个例外而让生成器变得复杂。

一个很好的判断标准是:

这项修改描述的是项目规则,还是某项功能特有的例外?

规则值得迁移到模型或生成器中。例外则可以直接保留在代码中。

安全的变更流程

典型的模型变更如下:

修改 _model.goat
      ↓
goat re
      ↓
git diff
      ↓
手动完善代码
      ↓
测试
      ↓
提交

在实践中:

# 修改模型
vim herd/_model.goat

# 生成变更
goat re

# 检查结果
git diff

# 运行测试
goat run:script --path=herd/test.goat

git diff 尤其重要,因为它可以让你查看生成器准备的实际变更范围。

不要将生成视为一种无需检查便可接受其结果的操作。

生成器负责准备代码,但最终是否接受变更的决定权仍在开发者手中。

模型与现有数据库

修改模型可能会导致生成的 SQL 模式发生变化。

这并不自动意味着它对现有数据库是安全的。

例如:

修改 _model.goat
      ↓
修改目标模式

并不等同于:

对现有数据进行安全迁移

以下情况尤其需要注意:

  • 删除字段,
  • 修改类型,
  • 添加必填字段,
  • 修改关系,
  • 新增约束,
  • 修改索引。

在部署此类变更之前,请检查生成的 SQL,并准备合适的迁移路径。

作为 AI 层的模型

声明式应用模型在与 AI 协作时还具有额外优势。

在许多情况下,模型无需分别分析:

Go 模型
DAO
DTO
API
迁移
表单
Angular 视图

而是可以修改一个定义:

herd/_model.goat

然后让 Goat 生成该修改带来的后续结果。

例如:

“文档应具有发布日期”
                ↓
AI 修改 _model.goat
                ↓
goat re
                ↓
更新所需层级

这减少了分析所需的代码量,也降低了遗漏某一层级的风险。

不过,并非每项变更都应通过模型完成。如果任务只涉及实现中的单个片段,直接编辑代码可能更高效。

示例模型

以下片段展示了 Goat 模型的一些基本功能:

# App metadata
app --name goat \
    --version 0.0.1 \
    --path app \
    --go-prefix code.pozoga.eu/spozoga/goatcms.com \
    --terminal-prefix APP \
    --default-language=en \
    --supported-languages=pl,en,de

# Roles
role:add --name=admin --super
role:add --name=manager
role:add --name=user

# Dashboard module types
entity:module:def --type=top_box_counter --query:count=<<QUERYDEFEOF
    def --required
QUERYDEFEOF --short_text:label=<<LABELDEFEOF
    def --required
LABELDEFEOF

entity:module:def --type=summary_cards --modules:list=<<MODULESLISTEOF
    def --types=top_box_counter --required
MODULESLISTEOF

# User
entity:overwrite --name=user --base=user_base_entity --system=<<ENTITYSYSTEMEOF
    module:add --name=summary_cards --type=summary_cards --modules:list=<<SUMMARYCARDSOF
        module:add --name=admin_counter --type=top_box_counter --label=Administrators --query:count=<<QUERYEOF
            where:and --conditions=<<WHEREEOF
                eq --field=role --value=admin
            WHEREEOF
        QUERYEOF

        module:add --name=manager_counter --type=top_box_counter --label=Managers --query:count=<<QUERYEOF
            where:and --conditions=<<WHEREEOF
                eq --field=role --value=manager
            WHEREEOF
        QUERYEOF

        module:add --name=user_counter --type=top_box_counter --label=Users --query:count=<<QUERYEOF
            where:and --conditions=<<WHEREEOF
                eq --field=role --value=user
            WHEREEOF
        QUERYEOF
    SUMMARYCARDSOF

    module:add --name=crud \
        --property:list:list="username,email" \
        --property:list:persist="role,username,email"
    module:add --name=crud_cli \
        --property:list:list="username,email" \
        --property:list:persist="password,role,username,email"
ENTITYSYSTEMEOF

# Base content entity
entity:base:add --name content --label slug --base base_entity --properties=<<PROPERTIESEOF
    def --write=admin,manager --read=user --list

    add --name=title \
        --type=web_title \
        --doc="Title is a short heading that describes the content."

    add --name=slug \
        --type=web_slug \
        --required \
        --doc="Slug is a URL-friendly name for the content."

    add --name=lang \
        --type=language \
        --required \
        --doc="Language of the current content."

    def --write=admin,manager --read=user

    add --name=description \
        --type=seo_description \
        --doc="Short description for SEO and social media."

    add --name=body \
        --type=block_content \
        --doc="Body contains the content data structured into blocks."
PROPERTIESEOF --relations=<<RELATIONSEOF
    def --onremove

    add --name=owner \
        --to=user \
        --doc="Owner is the author of the article."
RELATIONSEOF --constraints=<<CONSTRAINTSEOF
    unique:add --fields=lang,slug
CONSTRAINTSEOF

# Page
entity:extended --name page --base content --doc=<<DOCEOF
    Pages are the main sections of the website.
    They contain essential content that rarely changes and
    plays an important role in the overall structure and context of the website.
DOCEOF --system=<<ENTITYSYSTEMEOF
    module:add --name=seo

    module:add --name=ssr \
        --slug=page \
        --property:list:list="title,description" \
        --property:list:details="title,body"

    module:add --name=crud \
        --property:list:list="title,description" \
        --property:list:persist="title,slug,lang,description,body"
    module:add --name=crud_cli \
        --property:list:list="title,description" \
        --property:list:persist="title,slug,lang,description,body"
ENTITYSYSTEMEOF

# Documentation
entity:extended --name doc --base content --doc=<<DOCEOF
    The document contains comprehensive project documentation,
    including available commands, configuration details,
    usage examples and other information required to work
    with the project effectively.
DOCEOF --properties=<<PROPERTIESEOF
    def --write=admin,manager --read=user --list

    add --name=ver \
        --type=short_text \
        --doc="Goat CLI version number for the page"
PROPERTIESEOF --system=<<ENTITYSYSTEMEOF
    module:add --name=seo

    module:add --name=ssr \
        --slug=doc \
        --route="/doc/{lang}/{slug}" \
        --property:list:list="title,description" \
        --property:list:details="title,body"

    module:add --name=crud \
        --property:list:list="ver,lang,title" \
        --property:list:persist="title,slug,lang,description,body,ver"
    module:add --name=crud_cli \
        --property:list:list="ver,lang,title" \
        --property:list:persist="title,slug,lang,description,body,ver"
ENTITYSYSTEMEOF

# Gallery
entity:add --name gallery --label title --base base_entity --properties=<<PROPERTIESEOF
    def --write=admin,manager --read=user --list

    add --name=title \
        --type=nice_name \
        --doc="Title is a short heading that describes the content."

    def --write=admin,manager --read=user

    add --name=slug \
        --type=web_slug \
        --unique \
        --doc="Slug is a URL-friendly name for the content."

    add --name=files \
        --type=file_set \
        --doc="Files for the gallery"
PROPERTIESEOF

该示例展示了 Goat 模型最重要的理念:一个声明式定义不仅可以描述数据库,还可以描述权限、关系、CRUD、路由、SEO、SSR 和界面元素。

最重要的原则

模型应接管应用中那些可重复且能够以声明式方式描述的部分。

生成器会将其转换为具体实现,而生成的代码仍完全由开发者掌控。

在实践中,你可以在三个层级上工作:

模型 — 当变更涉及应用的结构和规则时,

生成器和模板 — 当你希望改变整类元素的生成方式时,

应用代码 — 当你需要个性化实现时。

目标并不是不惜一切代价生成尽可能多的项目部分。

Goat 应生成那些可重复的内容,从而让你能将更多时间投入到真正使应用脱颖而出的代码中。