/** * proto → GraphQL SDL 生成器(v2.1 M0) * * 功能: * - 解析 packages/shared-proto/proto/*.proto 文件 * - 根据 service / message 自动生成 GraphQL Federation 子图 SDL * - 支持 @key / @requires / @extends 指令 * - 输出到 services//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", int32: "Int", int64: "String", // GraphQL 无 64 位整数,用 String 表示 uint32: "Int", uint64: "String", bool: "Boolean", float: "Float", double: "Float", bytes: "String", }; // proto → 服务名映射(用于确定输出目录) const SERVICE_MAP: Record = { 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 判定:含 _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();