- 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
301 lines
8.3 KiB
TypeScript
301 lines
8.3 KiB
TypeScript
/**
|
||
* 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();
|