在一个前后端紧密协作的项目中,维持数据传输对象 (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# 的 Guid
、DateTime
到 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 插件
另一个思路是构建一个更轻量、更可控的自定义解决方案。这个方案的核心是:
- ASP.NET Core 元数据端点: 在后端创建一个仅在开发环境下暴露的 API 端点,它通过反射读取 C# DTO 的结构,并将其序列化为一份简洁的、机器可读的 JSON 元数据。
- 自定义 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 调用函数(例如,基于
fetch
或axios
),将请求参数和返回数据也完全类型化。 - 支持更多特性: 元数据端点可以暴露更多信息,如 C# 的数据注解 (
[Required]
,[StringLength]
),Babel 插件可以据此生成前端的验证逻辑(例如,生成 Zod schemas)。 - 支持其他状态库: 只需修改
generateStoreCode
函数,就可以为 Zustand、Jotai 或 Redux Toolkit 生成对应的状态模块,核心逻辑保持不变。
局限性:
- 构建时依赖: 前端构建过程现在依赖于后端开发服务器的运行。这是一个强耦合。可以通过以下方式缓解:
- 缓存元数据: 插件可以在第一次成功获取元数据后将其缓存到本地文件,在后端服务不可用时使用缓存的版本。
- 提交生成文件: 将
src/generated
目录提交到版本控制。这样,其他开发者或 CI/CD 环境无需运行后端服务即可构建前端,代码生成变成一个需要手动触发的步骤。这是一个在解耦和自动化之间的权衡。
- 非公开 API 场景: 此方案非常适合内部项目或前后端紧密耦合的单体应用。对于需要提供给第三方消费者的公开 API,行业标准的 OpenAPI 仍然是更好的选择。
- 维护成本: 自定义的 Babel 插件和元数据端点成为项目的一部分,需要像其他代码一样进行维护和迭代。这需要团队中有人具备相应的 Node.js 和 ASP.NET Core 反射知识。