Files
Edu/scripts/proto-to-graphql/generator.ts
SpecialX 3c2ea50c7f feat(infra): proto-to-graphql generator + debezium outbox connector
- M0: parse proto to generate Federation 2 subgraph SDL

- M0.5: Debezium Connect monitors 4 outbox tables

- docker-compose: extend Debezium profiles to default+p3+p4+p5+p6

- package.json: add proto:gen-graphql script
2026-07-14 23:45:32 +08:00

301 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* proto → GraphQL SDL 生成器v2.1 M0
*
* 功能:
* - 解析 packages/shared-proto/proto/*.proto 文件
* - 根据 service / message 自动生成 GraphQL Federation 子图 SDL
* - 支持 @key / @requires / @extends 指令
* - 输出到 services/<service>/src/graphql/generated/schema.graphql
*
* 使用:
* pnpm run proto:gen-graphql
*
* 设计原则:
* - proto 是单一契约源
* - 生成器遵循 proto → GraphQL 类型映射规则
* - 生成的 SDL 包含 Federation 指令,各服务 @Resolver 实现解析逻辑
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
const ROOT = resolve(process.cwd());
const PROTO_DIR = join(ROOT, "packages/shared-proto/proto");
// proto → GraphQL 类型映射
const TYPE_MAP: Record<string, string> = {
string: "String",
int32: "Int",
int64: "String", // GraphQL 无 64 位整数,用 String 表示
uint32: "Int",
uint64: "String",
bool: "Boolean",
float: "Float",
double: "Float",
bytes: "String",
};
// proto → 服务名映射(用于确定输出目录)
const SERVICE_MAP: Record<string, string> = {
iam: "iam",
core_edu: "core-edu",
content: "content",
msg: "msg",
ai: "ai",
analytics: "data-ana",
};
interface Field {
name: string;
type: string;
repeated: boolean;
optional: boolean;
number: number;
}
interface Message {
name: string;
fields: Field[];
isEntity: boolean; // 含 id 字段视为 Entity@key
keyField?: string;
}
interface RpcMethod {
name: string;
inputType: string;
outputType: string;
}
interface ProtoFile {
package: string;
services: Array<{ name: string; methods: RpcMethod[] }>;
messages: Message[];
imports: string[];
}
/**
* 简易 proto 解析器(不依赖 protobufjs避免 ESM 问题)
* 仅提取生成 GraphQL 所需的 service / message / field 信息
*/
function parseProto(content: string): ProtoFile {
const result: ProtoFile = {
package: "",
services: [],
messages: [],
imports: [],
};
// package
const pkgMatch = content.match(/^package\s+([\w.]+);/m);
if (pkgMatch) result.package = pkgMatch[1];
// imports
const importMatches = content.matchAll(/^import\s+"([^"]+)";/gm);
for (const m of importMatches) {
result.imports.push(m[1]);
}
// message 解析(含嵌套字段)
const messageRegex = /message\s+(\w+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/g;
let msgMatch: RegExpExecArray | null;
while ((msgMatch = messageRegex.exec(content)) !== null) {
const msgName = msgMatch[1];
const body = msgMatch[2];
const fields: Field[] = [];
const fieldRegex =
/(?:optional\s+|repeated\s+)?(\w[\w.]*)\s+(\w+)\s*=\s*(\d+)/g;
let fieldMatch: RegExpExecArray | null;
while ((fieldMatch = fieldRegex.exec(body)) !== null) {
const isRepeated = body
.slice(fieldMatch.index - 10, fieldMatch.index)
.includes("repeated");
const isOptional = body
.slice(fieldMatch.index - 10, fieldMatch.index)
.includes("optional");
fields.push({
name: fieldMatch[2],
type: fieldMatch[1],
repeated: isRepeated,
optional: isOptional,
number: parseInt(fieldMatch[3], 10),
});
}
// Entity 判定:含 <msgName in snake_case>_id 或 id 字段
const snakeName = msgName
.replace(/([A-Z])/g, "_$1")
.toLowerCase()
.replace(/^_/, "");
const keyField = fields.find(
(f) =>
f.name === "id" ||
f.name === `${snakeName}_id` ||
f.name === `${msgName.toLowerCase()}_id`,
);
result.messages.push({
name: msgName,
fields,
isEntity: !!keyField,
keyField: keyField?.name,
});
}
// service 解析
const serviceRegex = /service\s+(\w+)\s*\{([\s\S]*?)\}/g;
let svcMatch: RegExpExecArray | null;
while ((svcMatch = serviceRegex.exec(content)) !== null) {
const svcName = svcMatch[1];
const body = svcMatch[2];
const methods: RpcMethod[] = [];
const rpcRegex =
/rpc\s+(\w+)\s*\(\s*(\w+)\s*\)\s*returns\s*\(\s*(\w+)\s*\)/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRegex.exec(body)) !== null) {
methods.push({
name: rpcMatch[1],
inputType: rpcMatch[2],
outputType: rpcMatch[3],
});
}
result.services.push({ name: svcName, methods });
}
return result;
}
/**
* proto 类型 → GraphQL 类型
*/
function protoTypeToGraphQL(protoType: string, repeated: boolean): string {
// 去掉包名前缀(如 iam.User → User
const base = protoType.includes(".")
? protoType.split(".").pop()!
: protoType;
const gqlType = TYPE_MAP[base] ?? base; // 自定义 message 类型直接用原名
if (repeated) {
return `[${gqlType}!]!`;
}
return gqlType;
}
/**
* 生成单个服务的 GraphQL 子图 SDL
*/
function generateSubgraphSDL(proto: ProtoFile, serviceName: string): string {
const lines: string[] = [
`# 自动生成的 GraphQL Federation 子图v2.1 M0`,
`# 源文件:${serviceName}.proto`,
`# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql`,
``,
`extend type Query`,
``,
];
// 为每个 Entity 类型生成 type + @key
const entities = proto.messages.filter((m) => m.isEntity);
for (const msg of entities) {
const keyField = msg.keyField!;
lines.push(`type ${msg.name} @key(fields: "${keyField}") {`);
for (const field of msg.fields) {
const gqlType = protoTypeToGraphQL(field.type, field.repeated);
const nullable = field.optional || !field.repeated;
lines.push(` ${field.name}: ${gqlType}${nullable ? "" : "!"}`);
}
lines.push(`}`);
lines.push(``);
}
// 非 Entity 的 message 生成为 input/type
const inputs = proto.messages.filter((m) => !m.isEntity);
for (const msg of inputs) {
// 以 Request/Response 结尾的生成 input type
if (msg.name.endsWith("Request") || msg.name.endsWith("Response")) {
lines.push(`input ${msg.name}Input {`);
for (const field of msg.fields) {
const gqlType = protoTypeToGraphQL(field.type, field.repeated);
lines.push(` ${field.name}: ${gqlType}`);
}
lines.push(`}`);
lines.push(``);
} else {
lines.push(`type ${msg.name} {`);
for (const field of msg.fields) {
const gqlType = protoTypeToGraphQL(field.type, field.repeated);
lines.push(` ${field.name}: ${gqlType}`);
}
lines.push(`}`);
lines.push(``);
}
}
// 根据 service 生成 Query 入口rpc GetXxx → query xxx
for (const svc of proto.services) {
for (const method of svc.methods) {
// Get/List 前缀映射为 query
if (method.name.startsWith("Get") || method.name.startsWith("List")) {
const queryName = method.name
.replace(/^Get/, "")
.replace(/^List/, "")
.replace(/^([A-Z])/, (m) => m.toLowerCase());
const plural = method.name.startsWith("List");
const returnType = plural
? `[${method.outputType}!]!`
: method.outputType;
lines.push(`extend type Query {`);
lines.push(` ${queryName}: ${returnType}`);
lines.push(`}`);
lines.push(``);
}
}
}
return lines.join("\n");
}
/**
* 主函数:遍历 proto 文件,生成各服务子图 SDL
*/
function main(): void {
const protoFiles = [
"iam.proto",
"core_edu.proto",
"content.proto",
"msg.proto",
"ai.proto",
"analytics.proto",
];
for (const protoFile of protoFiles) {
const protoPath = join(PROTO_DIR, protoFile);
const content = readFileSync(protoPath, "utf-8");
const proto = parseProto(content);
// proto 文件名 → 服务目录名
const baseName = protoFile.replace(".proto", "");
const serviceName = SERVICE_MAP[baseName] ?? baseName;
const outputDir = join(
ROOT,
`services/${serviceName}/src/graphql/generated`,
);
const outputPath = join(outputDir, "schema.graphql");
const sdl = generateSubgraphSDL(proto, baseName);
mkdirSync(outputDir, { recursive: true });
writeFileSync(outputPath, sdl, "utf-8");
console.log(
`${protoFile} → services/${serviceName}/src/graphql/generated/schema.graphql`,
);
}
console.log("\nGraphQL 子图 SDL 生成完成");
}
main();