利用 Babel 插件与元数据端点实现 C# DTO 到 TypeScript Valtio Store 的自动生成


在一个前后端紧密协作的项目中,维持数据传输对象 (DTO) 在 C# 后端和 TypeScript 前端之间的一致性,是一个持续存在的痛点。任何一方的修改,如果未能及时同步到另一方,轻则导致编译错误,重则引发难以排查的运行时 bug。

// Backend: ASP.NET Core DTO
public class UserProfileDto
{
    public Guid Id { get; set; }
    public string Username { get; set; } = string.Empty;
    public int Age { get; set; }
    public bool IsActive { get; set; }
    public List<string> Roles { get; set; } = new();
    public DateTime LastLogin { get; set; }
}

对应的 TypeScript 类型定义通常是手动编写和维护的:

// Frontend: Manual TypeScript Interface
export interface UserProfile {
  id: string;
  username: string;
  age: number;
  isActive: boolean;
  roles: string[];
  lastLogin: string; // 注意:DateTime 变成了 string
}

这种手动同步的模式在项目初期尚可接受,但随着 DTO 数量和复杂度的增加,它会迅速演变为团队的噩梦。字段增删、类型变更(尤其是 C# 的 GuidDateTime 到 TypeScript string 的转换)和可空性调整,都成为潜在的错误源。

方案 A:业界标准 OpenAPI Codegen

解决此问题的常规思路是使用 OpenAPI (Swagger) 规范,并通过 openapi-generator 或类似的工具生成前端的 API 客户端和类型定义。

优势:

  • 标准化: OpenAPI 是行业标准,生态成熟,工具链完善。
  • 跨语言: 一份规范可以为多种语言生成客户端代码。
  • 自动化: 集成到 CI/CD 流程中,可以实现完全自动化。

劣势:

  • 代码冗余与非惯用性: 生成的代码通常非常冗长,包含大量辅助函数和配置,且风格可能与项目现有的代码规范格格不入。它生成的是一个通用的 HTTP 客户端,而不是我们期望的、与特定状态管理库(如 Valtio)深度集成的状态模型。
  • 定制化困难: 虽然生成器提供了一些模板定制功能,但这通常需要学习其内部复杂的模板语言(如 Mustache),维护成本很高。想要生成精确符合 Valtio proxy 对象的代码,几乎是不可能的。
  • 黑盒问题: 生成过程是一个黑盒。当遇到问题时,调试起来非常困难。
  • 依赖笨重: 很多生成器依赖 Java 运行时,给前端构建环境引入了不必要的复杂性。

在真实项目中,我们往往需要的不是一个庞大而全的 API SDK,而仅仅是与后端模型精准同步的、符合前端架构习惯的类型和状态容器。OpenAPI Codegen 在这里显得“用力过猛”,产出物与我们的需求存在显著偏差。

方案 B:元数据端点 + 自定义 Babel 插件

另一个思路是构建一个更轻量、更可控的自定义解决方案。这个方案的核心是:

  1. ASP.NET Core 元数据端点: 在后端创建一个仅在开发环境下暴露的 API 端点,它通过反射读取 C# DTO 的结构,并将其序列化为一份简洁的、机器可读的 JSON 元数据。
  2. 自定义 Babel 插件: 在前端项目中编写一个自定义的 Babel 插件。该插件在编译过程的早期阶段执行,它会向后端的元数据端点发起请求,获取 DTO 结构,然后利用 Babel 的 AST (抽象语法树) 能力,动态地生成 TypeScript 代码,包括类型定义和 Valtio 的 proxy store。

优势:

  • 完全控制: 生成的代码格式、风格、内容完全由我们自己定义。我们可以生成完全符合 Valtio 最佳实践的、即开即用的 store。
  • 无缝集成: Babel 插件是前端构建生态的一等公民,可以无缝集成到现有的 React/Vite/Next.js 项目中,无需引入额外的运行时依赖。
  • 轻量高效: 整个过程只涉及一次开发时的 HTTP 请求和内存中的代码生成,对构建性能影响极小。
  • 单一事实源: C# DTO 代码是唯一的事实源 (Single Source of Truth),前端类型定义永远与之保持同步。

劣势:

  • 初始投入: 需要投入时间开发和维护元数据端点和 Babel 插件。
  • 方案绑定: 这是一个为特定技术栈(ASP.NET Core, Valtio, Babel)量身定制的方案,通用性不如 OpenAPI。

决策:
对于一个追求极致开发体验和长期可维护性的团队来说,方案 B 的初始投入是值得的。它能从根本上消除一整类潜在的 bug,并大幅提升开发效率。接下来,我们将详细剖析这个方案的核心实现。

核心实现概览

整个流程在前端构建时触发,可以用下面的 Mermaid 图来表示:

sequenceDiagram
    participant FE as Frontend Build Process
    participant BP as Custom Babel Plugin
    participant BE as ASP.NET Core Dev Server
    participant Assembly as C# DTO Assembly

    FE->>BP: Starts compilation
    BP->>BE: HTTP GET /api/dev/type-metadata
    activate BE
    BE->>Assembly: Use Reflection to scan DTOs
    Assembly-->>BE: Return type structures
    BE-->>BP: Respond with JSON metadata
    deactivate BE
    
    activate BP
    BP->>BP: Parse JSON & Generate TS AST
    BP-->>FE: Inject generated code/files
    deactivate BP
    
    FE->>FE: Continue with transpilation

1. 后端:ASP.NET Core 元数据端点

首先,我们需要定义一个自定义特性,用于标记哪些 DTO 需要被暴露给前端。

[ExposeToFrontendAttribute.cs]

/// <summary>
/// Marks a DTO class to be exposed to the frontend code generation pipeline.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class ExposeToFrontendAttribute : Attribute
{
    /// <summary>
    /// Optional: Specifies the name of the generated Valtio store.
    /// If null or empty, the class name (without "Dto") will be used.
    /// </summary>
    public string? StoreName { get; set; }
}

接下来,创建一个服务来处理反射逻辑。这个服务需要能够将 C# 类型转换为一种简单的、可序列化的格式。

TypeMetadata.cs (DTOs for the metadata response)

public class TypeMetadata
{
    public string Name { get; set; } = string.Empty;
    public List<PropertyMetadata> Properties { get; set; } = new();
}

public class PropertyMetadata
{
    public string Name { get; set; } = string.Empty;
    public string Type { get; set; } = string.Empty;
    public bool IsNullable { get; set; }
}

TypeMetadataService.cs

using System.Reflection;

public class TypeMetadataService
{
    private static readonly Dictionary<Type, string> CSharpToTypeScriptMap = new()
    {
        { typeof(string), "string" },
        { typeof(Guid), "string" },
        { typeof(DateTime), "string" },
        { typeof(DateTimeOffset), "string" },
        { typeof(bool), "boolean" },
        { typeof(byte), "number" },
        { typeof(sbyte), "number" },
        { typeof(char), "string" },
        { typeof(decimal), "number" },
        { typeof(double), "number" },
        { typeof(float), "number" },
        { typeof(int), "number" },
        { typeof(uint), "number" },
        { typeof(long), "number" },
        { typeof(ulong), "number" },
        { typeof(short), "number" },
        { typeof(ushort), "number" }
    };

    public List<TypeMetadata> DiscoverExposedTypes(Assembly assembly)
    {
        var exposedTypes = assembly.GetTypes()
            .Where(t => t.IsClass && t.GetCustomAttribute<ExposeToFrontendAttribute>() != null);
        
        var metadataList = new List<TypeMetadata>();

        foreach (var type in exposedTypes)
        {
            var attribute = type.GetCustomAttribute<ExposeToFrontendAttribute>()!;
            var storeName = !string.IsNullOrEmpty(attribute.StoreName) 
                ? attribute.StoreName 
                : type.Name.Replace("Dto", "").Replace("DTO", "");

            var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Select(prop => new PropertyMetadata
                {
                    // Convert property name to camelCase for JS convention
                    Name = char.ToLowerInvariant(prop.Name[0]) + prop.Name[1..],
                    Type = MapToTypeScriptType(prop.PropertyType),
                    IsNullable = IsPropertyNullable(prop)
                })
                .ToList();

            metadataList.Add(new TypeMetadata
            {
                Name = storeName,
                Properties = properties
            });
        }

        return metadataList;
    }

    private string MapToTypeScriptType(Type type)
    {
        // Handle nullable value types (e.g., int?, bool?)
        var underlyingType = Nullable.GetUnderlyingType(type);
        if (underlyingType != null)
        {
            type = underlyingType;
        }

        if (CSharpToTypeScriptMap.TryGetValue(type, out var tsType))
        {
            return tsType;
        }

        // Handle collections (List<T>, IEnumerable<T>, T[])
        if (type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(List<>) || type.GetGenericTypeDefinition() == typeof(IEnumerable<>)))
        {
            var itemType = MapToTypeScriptType(type.GetGenericArguments()[0]);
            return $"{itemType}[]";
        }

        if (type.IsArray)
        {
            var itemType = MapToTypeScriptType(type.GetElementType()!);
            return $"{itemType}[]";
        }

        // For complex objects, we might want to return the type name itself
        // if it's also an exposed DTO, but for simplicity, we'll default to 'any'.
        // A more advanced implementation would handle nested DTOs.
        return "any";
    }
    
    // A robust nullability check requires more context in modern C#
    private bool IsPropertyNullable(PropertyInfo property)
    {
        // Simple check for reference types and Nullable<T>
        if (Nullable.GetUnderlyingType(property.PropertyType) != null) return true;
        if (!property.PropertyType.IsValueType)
        {
            // This is a simplified check. For full C# 8+ nullable reference types support,
            // one would need to inspect the Nullable-related attributes.
            var nullabilityInfoContext = new NullabilityInfoContext();
            var nullability = nullabilityInfoContext.Create(property);
            return nullability.WriteState == NullabilityState.Nullable;
        }
        return false;
    }
}

最后,创建 Controller。这里的关键是使用 app.Environment.IsDevelopment() 来确保该端点只在开发环境中可用,避免暴露到生产环境。

Program.cs (Minimal API setup)

// ... other services

// Add our custom service
builder.Services.AddSingleton<TypeMetadataService>();

var app = builder.Build();

// ... other middleware

if (app.Environment.IsDevelopment())
{
    app.MapGet("/api/dev/type-metadata", (TypeMetadataService metadataService) =>
    {
        try
        {
            // Assuming DTOs are in the same assembly as Program
            var assembly = Assembly.GetExecutingAssembly(); 
            var metadata = metadataService.DiscoverExposedTypes(assembly);
            return Results.Ok(metadata);
        }
        catch (Exception ex)
        {
            // Proper logging should be used here
            Console.WriteLine($"Error discovering types: {ex.Message}");
            return Results.Problem("An error occurred while generating type metadata.", statusCode: 500);
        }
    });
}

app.Run();

现在,给我们的 DTO 加上特性:

UserProfileDto.cs

[ExposeToFrontend(StoreName = "userProfile")]
public class UserProfileDto
{
    // ... properties
}

运行后端开发服务器,访问 /api/dev/type-metadata 将会得到如下 JSON 响应:

[
  {
    "name": "userProfile",
    "properties": [
      { "name": "id", "type": "string", "isNullable": false },
      { "name": "username", "type": "string", "isNullable": false },
      { "name": "age", "type": "number", "isNullable": false },
      { "name": "isActive", "type": "boolean", "isNullable": false },
      { "name": "roles", "type": "string[]", "isNullable": false },
      { "name": "lastLogin", "type": "string", "isNullable": false }
    ]
  }
]

2. 前端:自定义 Babel 插件

Babel 插件本质上是一个 Node.js 模块,它导出一个函数,该函数返回一个带有 visitor 对象的对象。我们的插件会更特殊一些,它需要在 pre 钩子(在文件遍历开始前)中执行异步操作。

scripts/babel-plugin-generate-valtio-stores.js

const fs = require('fs/promises');
const path = require('path');
const http = require('http');

// The URL of our .NET backend's metadata endpoint
const METADATA_URL = 'http://localhost:5000/api/dev/type-metadata';
// Where to write the generated files
const OUTPUT_DIR = path.resolve(__dirname, '../src/generated');

// A simple utility to fetch data. In a real project, use a robust library like axios.
function fetchMetadata(url) {
  return new Promise((resolve, reject) => {
    http.get(url, (res) => {
      if (res.statusCode < 200 || res.statusCode >= 300) {
        return reject(new Error(`Status Code: ${res.statusCode}`));
      }
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => resolve(JSON.parse(data)));
    }).on('error', reject);
  });
}

// Generates the TypeScript code for a single store
function generateStoreCode(metadata) {
  const interfaceName = `${metadata.name.charAt(0).toUpperCase() + metadata.name.slice(1)}State`;

  const properties = metadata.properties.map(prop => 
    `  ${prop.name}${prop.isNullable ? '?' : ''}: ${prop.type};`
  ).join('\n');

  const initialStateProperties = metadata.properties.map(prop => {
    let defaultValue;
    if (prop.type.endsWith('[]')) {
      defaultValue = '[]';
    } else {
      switch (prop.type) {
        case 'string': defaultValue = "''"; break;
        case 'number': defaultValue = '0'; break;
        case 'boolean': defaultValue = 'false'; break;
        default: defaultValue = 'null'; // For complex types or nullable ones
      }
    }
    return `  ${prop.name}: ${defaultValue},`;
  }).join('\n');

  return `// This file is auto-generated by a Babel plugin. Do not edit directly.

import { proxy } from 'valtio';

export interface ${interfaceName} {
${properties}
}

const initialState: ${interfaceName} = {
${initialStateProperties}
};

export const ${metadata.name}Store = proxy<${interfaceName}>(initialState);

// Optional: A reset function
export function reset${interfaceName.replace('State', '')}() {
  Object.assign(${metadata.name}Store, initialState);
}
`;
}

// The Babel plugin itself
module.exports = function(babel) {
  let hasRun = false; // Ensure it only runs once per build

  return {
    name: 'generate-valtio-stores',
    pre(state) {
      if (hasRun) return;
      hasRun = true;
      
      // We need to run this async logic. Babel plugins are sync, so we can't `await` here.
      // A common pattern is to block synchronously, but that's bad practice.
      // A better way is to make the generation step a separate script that runs before babel.
      // However, for simplicity and demonstration, we'll show how to wrap it.
      // NOTE: This approach is NOT recommended for production builds as it introduces
      // complex async behavior into a sync pipeline. A better architecture is a dedicated codegen script.
      // The `deasync` package could be used to make it "look" sync, but it has its own issues.
      //
      // The "pragmatic" approach: Make this a separate script in package.json:
      // "codegen": "node ./scripts/generate-stores.js"
      // "dev": "npm run codegen && vite"
      //
      // For this article's purpose, we proceed with the integrated plugin concept.
      // A better plugin design would use a sync IPC call to a long-running codegen daemon.

      console.log('Babel Plugin: Fetching DTO metadata...');
      
      this.opts.onGenerationComplete = fetchMetadata(METADATA_URL)
        .then(async (allMetadata) => {
          if (!Array.isArray(allMetadata)) {
            throw new Error('Metadata is not an array.');
          }
          await fs.mkdir(OUTPUT_DIR, { recursive: true });

          const indexFileContent = [];

          for (const metadata of allMetadata) {
            const code = generateStoreCode(metadata);
            const fileName = `${metadata.name}.store.ts`;
            await fs.writeFile(path.join(OUTPUT_DIR, fileName), code);
            console.log(`✓ Generated ${fileName}`);
            indexFileContent.push(`export * from './${metadata.name}.store';`);
          }

          await fs.writeFile(path.join(OUTPUT_DIR, 'index.ts'), indexFileContent.join('\n'));
          console.log('✓ Generated index.ts');
        })
        .catch(err => {
          // This is a critical build error.
          // We should fail the build loudly.
          console.error('\n\x1b[31m%s\x1b[0m', 'ERROR: Failed to generate Valtio stores from backend.');
          console.error('Please ensure the ASP.NET Core development server is running and accessible.');
          console.error(`Attempted to reach: ${METADATA_URL}`);
          console.error(`Error details: ${err.message}`);
          
          // A crucial step: exit the process to halt the build.
          process.exit(1);
        });
    },
    // We don't need a visitor because we are not transforming files, but creating them.
    // The `post` hook is where we can wait for our async operation to complete.
    async post(file) {
      if (this.opts.onGenerationComplete) {
        await this.opts.onGenerationComplete;
        delete this.opts.onGenerationComplete; // Avoid re-running
      }
    }
  };
};

babel.config.js

module.exports = {
  presets: [
    // Your other presets like @babel/preset-env, @babel/preset-react, @babel/preset-typescript
  ],
  plugins: [
    // Your other plugins
    './scripts/babel-plugin-generate-valtio-stores.js'
  ]
};

3. 使用生成的 Store

启动后端开发服务器,然后启动前端构建(例如 npm run dev)。插件会执行,并在 src/generated 目录下创建文件。

src/generated/userProfile.store.ts (Auto-generated)

// This file is auto-generated by a Babel plugin. Do not edit directly.

import { proxy } from 'valtio';

export interface UserProfileState {
  id: string;
  username: string;
  age: number;
  isActive: boolean;
  roles: string[];
  lastLogin: string;
}

const initialState: UserProfileState = {
  id: '',
  username: '',
  age: 0,
  isActive: false,
  roles: [],
  lastLogin: '',
};

export const userProfileStore = proxy<UserProfileState>(initialState);

export function resetUserProfile() {
  Object.assign(userProfileStore, initialState);
}

src/components/UserProfile.tsx (Usage)

import React, { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import { userProfileStore, resetUserProfile } from '../generated'; // Import from the generated index

export const UserProfileComponent: React.FC = () => {
  // `snap` is an immutable snapshot with full type safety
  const snap = useSnapshot(userProfileStore);

  useEffect(() => {
    // Fetch data and update the store
    async function fetchUser() {
      try {
        const response = await fetch('/api/user/profile/some-guid');
        const data = await response.json();
        // The store can be mutated directly
        Object.assign(userProfileStore, data);
      } catch (error) {
        console.error('Failed to fetch user profile', error);
      }
    }

    fetchUser();
    
    // Cleanup on unmount
    return () => resetUserProfile();
  }, []);

  return (
    <div>
      <h1>{snap.username}</h1> {/* Autocomplete works! */}
      <p>Age: {snap.age}</p>
      <p>Status: {snap.isActive ? 'Active' : 'Inactive'}</p>
      <p>Roles: {snap.roles.join(', ')}</p>
    </div>
  );
};

现在,每当后端的 UserProfileDto 发生变化(例如添加一个 email 字段),开发者只需重新启动前端构建,Babel 插件就会自动重新生成 userProfile.store.ts,TypeScript 编译器会立即在所有使用到该 store 的地方提示类型错误,从而在编译阶段就捕获了不一致性。

架构的扩展性与局限性

可扩展性:

  • 生成 API 客户端: 此架构可以轻松扩展,不仅仅生成 Valtio store,还可以生成类型安全的 API 调用函数(例如,基于 fetchaxios),将请求参数和返回数据也完全类型化。
  • 支持更多特性: 元数据端点可以暴露更多信息,如 C# 的数据注解 ([Required], [StringLength]),Babel 插件可以据此生成前端的验证逻辑(例如,生成 Zod schemas)。
  • 支持其他状态库: 只需修改 generateStoreCode 函数,就可以为 Zustand、Jotai 或 Redux Toolkit 生成对应的状态模块,核心逻辑保持不变。

局限性:

  • 构建时依赖: 前端构建过程现在依赖于后端开发服务器的运行。这是一个强耦合。可以通过以下方式缓解:
    1. 缓存元数据: 插件可以在第一次成功获取元数据后将其缓存到本地文件,在后端服务不可用时使用缓存的版本。
    2. 提交生成文件:src/generated 目录提交到版本控制。这样,其他开发者或 CI/CD 环境无需运行后端服务即可构建前端,代码生成变成一个需要手动触发的步骤。这是一个在解耦和自动化之间的权衡。
  • 非公开 API 场景: 此方案非常适合内部项目或前后端紧密耦合的单体应用。对于需要提供给第三方消费者的公开 API,行业标准的 OpenAPI 仍然是更好的选择。
  • 维护成本: 自定义的 Babel 插件和元数据端点成为项目的一部分,需要像其他代码一样进行维护和迭代。这需要团队中有人具备相应的 Node.js 和 ASP.NET Core 反射知识。

  目录