做个平台这件事,最初是被一套没人维护的模板逼出来的。某个周一早上,线上一个订单服务突然报错,查了大半天,根因是两周前初始化新服务时,从旧脚手架复制来的配置里带着一个已经被移除的扩展点。那条配置在旧项目里没有触发问题,换到新项目就成了定时炸弹。复盘会上大家沉默了很久,因为类似的“复制粘贴事故”已经不是第一次了。也就是从那天起,我决定认真想想 HagiCode Soul 应该怎么设计。
HagiCode Soul 的定位,在我看来不是低代码平台,也不是什么业务中台。它更像一层“工程生成底座”:当你准备开发一个新模块、一个新微服务,或者一套标准化的 API 工程时,它能帮你把骨架代码生成、依赖版本锁定、基础设施配置注入这些事,从“靠记忆复制”变成“靠模型生成”。服务端、前端、平台工程师都能在这套流程里找到适合自己的部分,前提是你愿意花一两天时间,把最初散落的模板整理成可生成的结构。
这篇文章适合谁看?一句话:如果你现在还在用“把上次那个项目拷过来改一改”的方式搭工程,或者团队里已经有多个脚手架但没人维护,那这篇文章应该能给你一条相对清晰的演进路线。我会从最初的需求萌芽讲起,把平台化过程中最关键的技术决策、踩过的坑、以及能直接落地的实操路径都摊开来说。
1. 需求萌芽与平台化的动机
1.1 模板维护不下去,是平台化最真实的起点
我以前也觉得,项目初始化不就是复制个模板吗?直到团队里同时存在 Go、Java、Node.js 三套服务端模板,再加上一个 React 前端模板,问题才彻底暴露出来。
先说依赖版本。每套模板都由不同的人维护,维护者离职后模板几乎进入冻结状态。新项目初始化时,大家默认“模板里的依赖应该是公司统一标准”,实际上模板可能已经落后生产环境一年甚至更久。Go 模板里有同学手动升级过 gin,Java 模板里还在用老一套配置,Node 模板生成的工程 lint 规则和另外两套风格不一致。代码评审的时候,最耗费精力的往往不是业务逻辑,而是“这个依赖版本是谁定的、为什么模板里是这样”。
更隐蔽的问题是约定没有文档。早先约定所有服务必须实现统一的 health check 接口,但代码模板不强制,新服务忘写的概率极高。平台化不是为了让代码生成得更多,而是为了让“默认约定”真正变成“默认被强制执行”。这一点在我后来的架构设计里反复出现过:先保证默认动作正确,再考虑提供灵活覆盖。
1.2 从“复制粘贴”走向平台化的三个核心目标
当时我们定了三件事,作为后续所有技术决策的判断标准。
第一,统一入口。不管创建什么类型的新服务,不能再靠“问老员工要模板”。必须有唯一入口,进去之后通过选择或参数配置拿到工程,模板的版本、更新记录、变更内容全部可见可回溯。
第二,结果可审计。代码生成器很容易变成一个黑盒:输入几个参数,吐出一坨代码。一旦生成的代码有问题,你根本不知道是哪条规则、哪个模板片段导致的。所以平台生成的内容必须能对应到具体的模板文件和元数据定义,发现问题能顺着链路定位。
第三,不被锁定。生成工程之后,开发者应当能自由修改生成结果,平台不能搞“二次生成覆盖代码”的骚操作。我们只负责生成初始骨架和提供增量升级能力,一旦进入正常开发阶段,项目就是开发者的,平台不介入也不限制。
1.3 Soul 这个名字背后,平台到底在解决哪一层问题
HagiCode 早期只是我一个内部脚本工具的名字,后来拆成两个概念:HagiCode 是代码生成引擎本身,Soul 是围绕引擎建立的“服务定义与编排层”。为什么需要单独一层 Soul?
因为大多数团队的代码规范问题,根源不在“代码怎么写”,而在“服务边界不统一”。同样一个订单服务,A 同学重构了项目结构,B 同学按自己的习惯加了一层 repository,C 同学把配置全塞在 application.yml 里。每个都合理,但放在一起,整个团队的知识传递成本就很高。Soul 层做的事情,是用一整套领域描述规范,把服务对外暴露的接口、依赖的外部组件、环境配置项、监控告警方式都结构化地表达出来。代码可以由模板生成,但服务定义先于代码存在。这个“定义先行”的思路,直接影响平台后续形态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心架构设计:HagiCode Soul 为什么采用元数据驱动
2.1 字符串拼接式的生成器,为什么走到头就会崩
很多人第一次写代码生成器,都是拿模板引擎拼字符串:把变量塞进一个写死的模板文件里,循环输出 controller、service、repository。早期我也这么干,确实很爽,一天就能拼出一个能跑的生成器。
但等到模板数量变多,你会发现一个致命问题:模板之间是有依赖的。比如生成一个带数据库表的 CRUD 服务,涉及实体类、Mapper 接口、建表 SQL、API 路由、配置项五处改动。用字符串拼接,你等于手工维护五处关联规则的同步。今天加一个字段,明天可能就忘了改建表 SQL。代码里数量少的时候勉强能靠人肉盯住,一旦出现几十个不同业务模型,维护量直接爆炸。
从这个角度看,纯粹的模板字符串拼接不是“简单”,而是“把问题延期了”。真正该做的,是先把业务模型抽象出来,再基于模型统一驱动所有相关代码的生成。
2.2 三段式流水线:Metadata → IR → 目标代码
HagiCode Soul 的核心引擎参考了编译器设计里非常经典的三段式思路,把生成过程拆成三个层级。
- Metadata(原始描述):开发者或平台界面输进去的 JSON / YAML 结构,描述业务模型、接口定义、部署环境等信息。
- IR(中间表示):对元数据做校验、补充默认值、建立关联关系之后,得到的内部统一模型。
- 目标代码:IR 经过模板渲染后,输出到具体的工程目录。
为什么中间要插一层 IR,而不是直接拿 Metadata 去渲染模板?因为元数据是偏业务语义的,模板需要的是偏工程结构的对象。举个例子,你在元数据里定义一个字段叫 createdAt,类型是 datetime。IR 层经过分析后会知道,在数据库建表语句里它要映射为 timestamp,在 Java 实体里要映射为 LocalDateTime,在 TypeScript 类型里要映射为 string。如果不经过 IR 直接渲染,等于把字段映射逻辑分散到每个模板文件里,改一个类型映射关系得动几十个模板。有了 IR 层,映射规则集中收敛在转换器内部,模板只管消费 IR 已经算好的结果。
下面的伪代码大致展示这个流程:
typescript复制// 一个简化版的心智模型
const metadata = parseYaml('./service-definition.yaml');
const ir = buildIR(metadata); // 校验、补默认值、做字段类型映射
const templateContext = {
serviceName: ir.service.name,
entities: ir.entities,
apiRoutes: ir.routes,
dependencies: ir.resolveDependencies(),
};
const renderedFiles = renderTemplates('./templates/java-spring', templateContext);
writeFiles('./output/my-service', renderedFiles);
这段代码不是完整实现,但它代表了引擎内部最重要的数据流向。后续每一次扩展,我都在问自己一个问题:这个能力应该放在 Metadata 解析阶段、IR 转换阶段还是模板渲染阶段?放错位置的代价,短期内看不出来,长期就是无穷无尽的 hack。
2.3 让模板工程自身具备“工程化”能力
模板文件并不是随手写的文本。自己实现了一段时间后,我越来越觉得,模板自身的维护方式决定了平台能走多远。
HagiCode Soul 里每个技术栈的模板,都是一个独立的 Git 仓库,内部按照惯用目录可以这样组织:
text复制templates/java-spring/
├── template/
│ ├── src/main/java/{{packagePath}}/
│ │ ├── controller/
│ │ │ └── {{entityName}}Controller.java.ftl
│ │ ├── service/
│ │ │ └── {{entityName}}Service.java.ftl
│ │ └── Application.java.ftl
│ ├── src/main/resources/
│ │ └── application.yml.ftl
│ └── pom.xml.ftl
├── hooks/
│ ├── before-generate.js
│ └── after-generate.js
└── metadata-schema.json
template 目录里的每个 .ftl 文件是一个模板片段,变量用类似 {{entityName}} 的占位符表达。hooks 目录存放生命周期钩子,metadata-schema.json 描述这个模板期望接收哪些参数。
把所有约定都固化成代码和配置之后,新增一个技术栈模板,就不再是“找个人从头写一遍”,而是按同样的结构填内容。新模板的维护者不需要理解引擎内部细节,只需要知道:我要提供哪些变量、暴露哪些钩子、约束哪些元数据字段。这个抽象效果,比写一万行文档都有效。
2.4 用生命周期钩子代替“万能配置项”
我在设计早期犯过一个错误,总想给平台加配置项,试图覆盖各种团队的奇怪需求。结果配置项越加越多,最后没人分得清哪些配置组合是有效的。
后来干脆换了个思路:引擎只保留最核心的固定行为和有限的基础配置,把多样化需求交给生命周期钩子。当前支持的核心阶段包括:
- before-generate:对 IR 做“最后一公里”修改,比如根据团队规范动态加一个统一的反腐注解。
- after-generate:对生成完毕的工程做后处理,比如执行 gofmt、格式化 import 排序、自动 git init 并完成第一次提交。
- pre-commit-validation:校验生成结果中是否还有模板残留占位符,防止变量没替换干净。
举个实际场景:某团队要求所有对外 RPC 接口必须打印调用日志并做耗时统计。这个需求如果做成内置能力,Java、Go、Node 模板都要改一遍。但做成钩子之后,各自模板目录下的 after-generate 脚本里加一段统一的代码插入逻辑就行,核心发布和模板升级互不阻塞。平台要兼容不同团队的多样性,靠的不是穷举选项,而是提供一个足够清晰的扩展点。
3. 实操:从零搭出一个可用的小型 Soul 平台
3.1 第一步:先用一份 JSON Schema 描述领域模型
整个系统最关键的文件,不是生成器代码,而是领域模型的描述规范。拿用户服务举例,一个最小的模型定义大概长这样:
json复制{
"$schema": "../platform-schema.json",
"serviceName": "user-service",
"language": "java-spring",
"entities": [
{
"name": "User",
"tableName": "t_user",
"fields": [
{ "name": "id", "type": "long", "primary": true, "autoIncrement": true },
{ "name": "username", "type": "string", "length": 64, "unique": true },
{ "name": "nickname", "type": "string", "length": 64 },
{ "name": "status", "type": "int", "default": 0 },
{ "name": "createdAt", "type": "datetime" }
]
}
],
"api": {
"basePath": "/api/v1/users",
"operations": ["create", "getById", "update", "delete", "list"]
}
}
这份 JSON 是平台输入的“种子”。它没有包含任何具体框架的痕迹,描述的全是业务侧事实:服务叫什么、有哪些实体、实体有哪些字段、对外暴露哪些操作。
如果不定义这份 schema,而是直接用一堆命令行动态参数生成代码,结果会很可怕:参数没有约束、错误要等渲染到一半才暴露、输入输出之间没有版本概念。JSON Schema 的价值在于,它既是给人看的契约,也是引擎用来校验、自动补全的基准。文件开头引用的 platform-schema.json,定义了 id 需要是自增字段还是雪花 ID、status 字段的取值范围等团队规则。
3.2 第二步:写一个极简版生成引擎
这部分用一个可以理解的最小实现演示引擎的核心逻辑,实际项目里会复杂不少,但骨架是一致的。我习惯用 TypeScript 实现引擎侧逻辑,因为可读性好,团队里前端同学也能参与贡献。
typescript复制import { parse as parseYaml } from 'yaml';
import { glob } from 'glob';
import { render } from 'nunjucks';
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import path from 'path';
interface IR {
serviceName: string;
packagePath: string;
entities: any[];
}
function buildIR(metadata: any): IR {
// 1. 校验必须字段
if (!metadata.serviceName) throw new Error('serviceName is required');
if (!metadata.language) throw new Error('language is required');
// 2. 把 serviceName 转成包路径
const packagePath = metadata.serviceName.replace(/-/g, '').toLowerCase();
// 3. 给实体字段补默认枚举,比如类型映射
const entities = metadata.entities.map((entity: any) => {
const fields = entity.fields.map((field: any) => {
if (field.type === 'datetime') {
return { ...field, javaType: 'LocalDateTime' };
}
return { ...field, javaType: mapJavaType(field.type) };
});
return { ...entity, fields };
});
return { serviceName: metadata.serviceName, packagePath, entities };
}
function generate(metadata: any, templateDir: string, outputDir: string) {
const ir = buildIR(metadata);
// 找到 template 目录下所有 .ftl 文件
const templateFiles = glob.sync('**/*.ftl', { cwd: templateDir });
for (const relativePath of templateFiles) {
const sourcePath = path.join(templateDir, relativePath);
const templateContent = readFileSync(sourcePath, 'utf8');
// 把模板里的变量路径替换成 IR 字段
const rendered = render(templateContent, {
...ir,
packagePath: `com.example.${ir.packagePath}`,
});
// 输出路径同样要处理变量,比如 UserController -> 首字母小写
const outputRelativePath = relativePath
.replace(/\.ftl$/, '')
.replace(/{{entityName}}/g, 'user');
const outputFilePath = path.join(outputDir, outputRelativePath);
mkdirSync(path.dirname(outputFilePath), { recursive: true });
writeFileSync(outputFilePath, rendered, 'utf8');
}
}
整个过程一句话概括:读模板文件、把它当成函数、IR 当成入参、执行完写出结果。
这个 demo 没有处理的一个核心问题是路径占位符。真实项目里每个模板文件都可能引用多个实体,UserController.java.ftl 要同时支持生成 Order、Product 等多个实体的控制器。所以真实引擎会按实体粒度循环执行渲染,而不是把所有模板一次性平铺出来。每个实体跑一轮,每次输出都放到对应的实体目录。这个“循环 + 投影”的模型,值得多花时间想清楚。
3.3 第三步:定义好模板片段的输出结构
模板文件写得好不好,决定生成的代码能不能被开发者接受。我在维护 Java Spring 模板时踩过一个坑:模板为了让生成结果看起来“完整”,把大量只跟单个团队业务相关的类也内置进去,比如 CurrentUserHolder、ApiResponseWrapper、GlobalExceptionHandler。这些基础设施类本应由独立的基础库提供,而不是由模板生成。
后来我定了一个原则:模板里只生成“和当前服务定义直接相关”的文件,通用能力一律引用公共库。 例如,统一响应体 ApiResponse<T> 不生成,只生成常量或配置指向公共包里的实现;异常处理逻辑不重复生成,只生成一个空的策略子类,方便开发者按服务自定义。这不仅是减少代码量,更是减少生成代码和公共库版本不一致的风险。否则公共库升个级,每个服务都有一份拷贝在维护,谁都不敢动。
另一个技巧是模板文件中的注释要写明“此文件由 HagiCode Soul 生成,请谨慎直接修改,领域变更请回平台调整”。这不是为了吓人,而是因为很多开发者会忘记代码来源,改了模板生成文件后,下次平台升级时不知道会发生冲突。清晰的标注意味着给后人留了一条追溯线索。
3.4 第四步:把平台接上 CLI 与 CI/CD,让生成成为日常工作流
如果平台只能由几个人在浏览器里点来点去,推广价值会大打折扣。我建议从一开始就提供 CLI 入口,让生成行为和 Git 操作、CI 流水线自然融合。一个 CLI 调用大概长这样:
bash复制hagicode init user-service \
--language java-spring \
--definition ./docs/user-service.yaml
命令执行后,平台会做这几件事:
- 拉取对应技术栈模板的最新稳定版本到本地缓存;
- 校验传入的服务定义文件是否符合元数据规范;
- 跑 before-generate 钩子,允许模板做预处理;
- 渲染生成完整工程到
/tmp/user-service下的临时目录; - 跑 after-generate 钩子做格式化;
- 生成一个
hagicode.lock.json文件,记录本次模板版本和引擎版本。
第六步经常被忽略,但它非常重要。hagicode.lock.json 相当于 npm 里的 lock 文件,锁定了生成时的模板 commit。以后服务出问题,只要看这个文件就知道是用哪个版本生成的,几乎不用猜。有了这个 lock,平台后续版本升级、模板更新,都能安全地做增量 diff。
再往后,可以把 hagicode init 集成进 CI 的某个任务里。比如某个前端工程管理后台的代码仓库里,每当产品同学在一个“服务登记表”里新增一行服务信息,流水线就自动调用生成器,创建一个新服务仓库并提交初始化代码。这个自动化能力一旦跑通,平台才真正从“工具”变成“基础设施”。
4. 演进路上的常见问题与排查经验
4.1 典型症状速查表
这里整理了一份排障速查,都是我真金白银踩过的问题,先直接给结论。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
生成的代码里出现了 {{...}} 占位符 |
模板行(.ftl)内部嵌套了另一个模板引擎的语法,被 nunjucks 当成普通文本处理 |
对字面量占位符做转义,模板文件里尽量避免嵌套模板语法 |
| 同一个实体多次生成目录结构不一致 | 模板文件路径里实体名大小写转换规则没统一 | 在 IR 层集中处理命名转换,模板内一律不要调用底层函数 |
| 老服务没办法升级到新模板结构 | 模板结构变化没有伴随迁移脚本 | 为重大结构调整单独写 codemod,先升级 IR,再重放生成 |
| 生成结果在 Windows 和 Mac 上面换行符不一致 | 不同开发者 checkout 仓库时自动转换了换行符 | 模板仓库增加 .gitattributes 强制统一 LF |
| 有人手工改了生成文件后被平台覆盖 | 生成器重跑时直接写目标目录 | 默认每次生成都构建到全新临时目录,由开发者手动确认是否合并 |
这个表不用记全,核心思路是:出了问题先看是“元数据问题、IR 转换问题还是模板问题”,不要一上来就怀疑引擎。
4.2 版本漂移与老项目升级的折腾经历
平台演进最棘手的问题不是新项目怎么生成,而是已经存在的几十个老项目怎么跟上新模板。刚开始我觉得很简单:下次更新模板后,让开发者手动重跑一次生成器就行。现实给了我一记耳光:老项目里有大量人工改动,重跑生成器会直接覆盖掉,没有人敢点确认。
后来我采取了一种相对安全的方案:结果对比 + 补丁落地。引擎支持对已存在工程做一次独立的“虚拟生成”,即将当前代码快照和目标生成结果放到两个临时目录,然后用 diff 工具逐文件比较,最终产出一个补丁文件。开发者自己 review 补丁,去掉不想接受的部分,再应用剩余变更。
这个流程不追求一次到位,而是允许团队按模块渐进升级。真正经历过整梯升级的人都会明白,与其强制一把梭,不如保证可控性和可回滚。
4.3 生成性能问题:一次生成几千个文件时怎么办
初期平台一次只生成一个服务,通常几十个文件,性能压力微乎其微。直到有团队拿它生成包含几十张表的报表平台工程,单次渲染超过 800 个模板文件,问题才浮现。
瓶颈主要在模板文件的重复读取和重复渲染。没有做缓存时,同一个 layout 会被每个业务模板反复 include 和渲染,IO 和 CPU 都被浪费。优化措施有三类:
- 模板文件全量加载进内存并做语法树级别的缓存,不要每次渲染都重新读文件。
- IR 的字段类型映射结果缓存起来,多个模板共同依赖同一份映射结果时,不要重复计算。
- 支持并行渲染多个实体的模板,实体之间在输出路径上大多隔离,天然适合并发处理,但要小心公共静态资源目录的唯一性。
做到这三步之后,单次生成耗时从原来的几十秒降到几秒,已经完全不影响交互体验。因为这种平台本身不会是高频调用服务,所以我认为只要保证到“人不会明显等待”的程度就够了,没必要做更重的常驻服务。
4.4 平台要不要做成“独立部署系统”,这个问题需要放在团队推广后回答
不少朋友看完架构之后会问,为什么一开始不直接做一个 Web 平台 + 可视化配置页面,非得先用 CLI 和 YAML?主要原因是投入产出比的问题。可视化界面需要维护前端工程、权限系统、版本发布流程,在项目早期需求还不确定时,投入 UI 开发的风险非常高。CLI + 配置文件的方式,可以用最小的代价验证整个生成流程是否可行,等到核心流程稳定,再去包一层 Web 界面完全来得及。
真正的平台化节点,其实是团队里“第一批不写模板、只使用平台”的用户出现。当有人开始向你提需求说:能不能帮我加一个默认缓存配置?你才真正需要建立需求池、版本规划、兼容性策略。所以,独立部署的 Web 平台不是技术目标,而是成长到一定阶段自然出现的结果。
我对平台功能取舍有一种很强烈的感觉:任何一个生成平台,最怕的是功能无限膨胀。今天有人要加消息队列模板,明天有人要加分布式锁代码,后天有人要加 Kubernetes 部署清单模板。每个需求单独看都合理,全做进去平台就会变成一个难以维护的巨型代码生成器。实际情况是,HagiCode Soul 到今天也坚持一个原则:核心版本只覆盖绝大多数服务都用得到的公共路径(工程骨架、接口层、数据模型、基础配置),偏门场景一律放到独立模板扩展仓库,由特定团队自行维护。
最后说几句真心话
回过头看,HagiCode Soul 从最初一个躺在脚本里的念头,到慢慢形成独立平台,真正难的不是写渲染器,也不是定义 JSON Schema,而是持续抵抗“什么都想生成”的冲动。代码生成平台的价值从来不是消灭编码,而是消灭编码前那些重复、隐性、容易出错的结构性决策。只要能把“服务到底应该长什么样”这件事在团队里约定清楚,哪怕不用复杂平台,只用一套严格的模板加自动化脚本,也能解决一大半问题。
如果你也想做类似的平台,我给的最核心建议是:别一开始就追求通用和高大上,先服务好自己团队一个最痛、最频繁的场景,把这个场景的生成过程做成自来水一样流畅,再逐步抽象通用能力。每一步都不难,难的是坚持不偏离“让骨架代码默认正确”这个最初的目标。
