示例代码:https://download.csdn.net/download/hefeng_aspnet/90959927
找到将 CSV 数据映射到类属性的快速方法
处理 CSV 文件时,一个常见的任务是读取文件并使用其内容填充类的属性。但如果可以自动化这个过程会怎样呢?在本文中,我们将了解如何使用 C# 反射自动将 CSV 文件中的列连接到类的属性。
这个想法很简单:CSV 文件的标题应该与类中属性的名称匹配。我的代码将读取该文件并根据这些数据创建类的实例。这样,即使 CSV 文件的结构发生变化,或者你向类中添加了新的属性,你的代码仍然可以正常工作。让我们看看反射如何简化这个过程!
但这还不是全部。我们还将探讨另一种使用 C# 源代码生成器的解决方案。源代码生成器允许我们在程序构建过程中创建代码。这可以提高性能并减少运行时使用反射带来的一些问题。在探索这两种方法之后,我们将比较它们的性能,并分析每种解决方案的优缺点。
测试之间共享的代码
我创建了将用于测试的类和方法。模型类:
public class RandomPropertiesClass
{
public string Name { get; set; }
public int Age { get; set; }
public double Height { get; set; }
public string Surname { get; set; }
public int HouseNumber { get; set; }
public double Weight { get; set; }
public string Address { get; set; }
public int TaxCode { get; set; }
public double Salary { get; set; }
public string City { get; set; }
public int ZipCode { get; set; }
public double DiscountPercentage { get; set; }
public string Country { get; set; }
public int PhoneNumber { get; set; }
public double Latitude { get; set; }
public string Email { get; set; }
public int YearsOfExperience { get; set; }
public double Longitude { get; set; }
public string Profession { get; set; }
public int NumberOfChildren { get; set; }
public double Temperature { get; set; }
public string FavoriteColor { get; set; }
public int ShoeSize { get; set; }
public double PurchasePrice { get; set; }
public string Hobby { get; set; }
public int LicenseNumber { get; set; }
public double AverageRating { get; set; }
public string SpokenLanguage { get; set; }
public int RoomNumber { get; set; }
public double Balance { get; set; }
public string SubscriptionType { get; set; }
public int LoyaltyPoints { get; set; }
public double DistanceTraveled { get; set; }
public string Nationality { get; set; }
public int WarrantyYears { get; set; }
public double EnergyConsumption { get; set; }
public string MaritalStatus { get; set; }
public int OrderNumber { get; set; }
}
我填充了这个类,并用随机数据填充了集合:
var faker = new Faker<RandomPropertiesClass>()
.RuleFor(r => r.Name, f => f.Name.FirstName())
.RuleFor(r => r.Age, f => f.Random.Int(18, 80))
...
.RuleFor(r => r.MaritalStatus, f => f.PickRandom(new[] { "Single", "Married", "Divorced", "Widowed" }))
.RuleFor(r => r.OrderNumber, f => f.Random.Int(1000, 9999));
List<RandomPropertiesClass> randomData = faker.Generate(10_000);
我将此文件保存为 csv 格式。
现在是时候读取此文件并反序列化为 C# 集合对象了。
反射(Reflection)
让我们开始反射吧。这是我创建的类:
public static List<RandomPropertiesClass> Import(string[] allRowsFile)
{
List<RandomPropertiesClass> loadedData = [];
try
{
// read header
string[] headers = allRowsFile[0].Split(',');
List<PropertyInfo> propertiesInfo = [];
for (var counter = 0; counter < headers.Length; counter++)
{
propertiesInfo.Add(typeof(RandomPropertiesClass).GetProperty(headers[counter])!);
}
for (var counter = 1; counter < allRowsFile.Length; counter++)
{
// read the data
var reader = allRowsFile[counter];
RandomPropertiesClass item = new();
var cols = reader.Split(',');
for (var i = 0; i < headers.Length; i++)
{
PropertyInfo prop = propertiesInfo[i];
object value = Convert.ChangeType(cols[i], prop.PropertyType);
prop.SetValue(item, value);
}
loadedData.Add(item);
}
return loadedData;
}
catch (Exception ex)
{
Console.WriteLine($"Generic error: {ex.Message}");
return [];
}
}
此类接受一个字符串数组,该数组包含从 csv 文件读取的所有行。第一步是读取包含标题的第一行。我将其内容拆分为单个标题,该标题将用于将值映射到属性类中,这得益于反射:
PropertyInfo prop = propertiesInfo[i];
object value = Convert.ChangeType(cols[i], prop.PropertyType);
prop.SetValue(item, value);
现在该测试一下了:
string[] allRowsFile = File.ReadAllLines(Configuration.GetCsvFileNameWithPath);
Stopwatch sw0 = Stopwatch.StartNew();
var result0 = MapWithEasyReflection.Import(allRowsFile);
sw0.Stop();
ShowData(result0);
Console.WriteLine($"Reflection easy import = {sw0.ElapsedMilliseconds}ms");
并且它运行良好:
源生成器(Source generators)
C# 中的源生成器是 2020 年 .NET 5 引入的一项相对较新的功能,尽管在编译时生成代码的概念在其他编程环境中已经存在了相当长一段时间。源生成器背后的想法是使开发人员能够在编译过程中动态创建代码,通过减少样板代码和增强性能来提高生产力。
最初,开发人员通常依赖 T4(文本模板转换工具包)之类的工具来生成代码。然而,T4 模板是作为预构建步骤执行的,这意味着生成的代码并未与 C# 编译器紧密集成。这种集成度的缺失可能会导致一些问题,因为对生成代码的更改可能不会立即反映在 IDE 中,需要手动构建来同步所有内容。
然而,源生成器直接嵌入在 Roslyn 编译器(C# 编译器平台)中,并作为构建管道的一部分工作。这种集成意味着源生成器生成的代码在创建后即可供 IDE 使用,并实时提供 IntelliSense 支持、错误检测和重构选项等功能。
源代码生成器的主要目的是自动化重复的代码模式,提升特定代码结构的性能,并帮助开发人员专注于编写核心逻辑,而不是冗余或样板代码。这可以加快开发速度,减少人为错误,并简化维护。
基本示例:
using Microsoft.CodeAnalysis;
[Generator]
public class CustomSourceGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) {}
public void Execute(GeneratorExecutionContext context)
{
// Analyze compilation and create new source files
string generatedCode = @"
namespace GeneratedNamespace {
public class GeneratedClass {
public void GeneratedMethod() {
// Generated implementation
}
}
}";
context.AddSource("GeneratedFile.cs", generatedCode);
}
}
这段简单的代码将添加GeneratedClass到我们的源代码中,然后可以像任何 C# 对象一样使用。然而,源生成器的强大功能之一是能够读取我们的文件并分析代码内容,从而动态创建新代码。
从 .NET Core 7 开始,您可以使用源生成器版本的正则表达式。语法如下:
public partial class Utilities
{
[GeneratedRegex(@"^id=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", RegexOptions.Compiled)]
public static partial Regex RegexIdHostName();
}
正则表达式源生成器使用该GeneratedRegex属性来为属性参数中定义的正则表达式模式创建源代码。要添加此新代码,该属性必须位于partial类中,并且没有主体的方法也必须是partial。这样就可以添加具有相同签名且包含实际函数主体的方法,然后可以使用该方法:
bool value = RegexIdHostName.IsMatch(text_string);
同样,在类级别,微软工程师也在中使用了源生成器System.Text.Json,从而实现了比 Newtonsoft 流行的 Json.NET 类更高的性能。根据文档:
如何在 System.Text.Json - .NET 中使用源生成
鉴于以下类:
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureCelsius { get; set; }
public string? Summary { get; set; }
}
可以使用以下代码创建序列化和反序列化类:
[JsonSerializable(typeof(WeatherForecast))]
internal partial class SourceGenerationContext : JsonSerializerContext
{
}
因为它是一个partial类,所以将生成可在代码中使用的方法:
jsonString = JsonSerializer.Serialize(
weatherForecast!, SourceGenerationContext.Default.WeatherForecast);
按照这个语法,我将创建类似的代码。在最终的代码中,我想添加一个方法将字符串映射到我的类,如下所示:
[GenerateSetProperty<RandomPropertiesClass>()]
internal static partial class ClassHelper { }
GenerateSetProperty将是我的属性,我将在源生成器中使用它来搜索源代码,查找partial要添加映射方法的类。泛型参数RandomPropertiesClass将是我要映射属性的类。在我的示例中,生成的方法将是:
internal static void SetPropertiesRandomPropertiesClass(RandomPropertiesClass obj, ReadOnlySpan<char> row)
要创建源生成器,我将首先创建一个针对的类库项目netstandard2.0。文件如下.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
<PackageReference Include="IsExternalInit" Version="1.0.3" PrivateAssets="all" />
<PackageReference Include="Nullable" Version="1.3.1" PrivateAssets="all" />
</ItemGroup>
</Project>
我指定了所需的 C# 版本,然后添加了对必要包的引用:
Microsoft.CodeAnalysis.分析器
微软代码分析.CSharp
我还添加了IsExternalInit并Nullable解决了访问器和可空类型注释netstandard2.0的限制init。
要引用此项目,您需要向ProjectReference添加一些属性:
<ItemGroup>
<ProjectReference Include="..\SourceGeneratorSetProperty\SourceGeneratorSetProperty.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
<PackageReference Include="Microsoft.Net.Compilers.Toolset" Version="4.11.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
必需的,它只是第一个ProjectReference:当您从终端使用 dotnet build 命令进行编译时, Microsoft.Net.Compilers.Toolset很有用。
此外,如果您使用 VSCode 编辑项目,则可以将.csproj中的此代码添加到源生成器创建文件的指定目录中:
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
好的,现在是时候开始编写源生成器代码了。从 .NET Core 8 开始,可以使用IIncrementalGenerator接口代替ISourceGenerator:
[Generator(LanguageNames.CSharp)]
public sealed class GeneratorFromAttributeClass : IIncrementalGenerator
该接口仅公开一种方法,我在我的代码中使用了该方法:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(GenerateFixedClasses);
...
}
RegisterPostInitializationOutput允许我们插入静态代码。我用它来添加GenerateSetPropertyAttribute属性:
private static void GenerateFixedClasses(IncrementalGeneratorPostInitializationContext context)
{
//language=c#
var source = $$"""
// <auto-generated/>
#nullable enable
using System;
namespace GeneratorFromAttributeExample;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
internal sealed class GenerateSetPropertyAttribute<T>() : Attribute where T : class
{}
""";
var fileName = "GeneratorFromAttributeExample.GenerateSetPropertyAttribute.g.cs";
context.AddSource(fileName, source);
}
请记住,此类项目不导入任何 DLL,因此我们无法在代码中创建对此项目中类的引用。任何静态类或必需类都必须直接以代码形式添加。
现在到了更有趣的部分。添加此属性后(我们将在源代码中使用它),我们需要在代码中进行搜索:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(GenerateFixedClasses);
IncrementalValuesProvider<(string TypeName, Accessibility ClassAccessibility, string? Namespaces, MasterType masterType)> provider =
context.SyntaxProvider.ForAttributeWithMetadataName(
"GeneratorFromAttributeExample.GenerateSetPropertyAttribute`1",
predicate: FilterClass,
transform: CreateObjCollection);
var collectedClasses = provider.Collect();
context.RegisterSourceOutput(collectedClasses, CreateSourceCode!);
}
context.SyntaxProvider.ForAttributeWithMetadataName按属性名称搜索,并且必须包含完整的命名空间字符串。由于该属性具有泛型类型,因此必须使用“`1”。
谓词定义了找到的对象的过滤器:
private static bool FilterClass(SyntaxNode node, CancellationToken cancellationToken) =>
node is ClassDeclarationSyntax;
由于该属性附加到一个类,因此我将寻找ClassDeclarationSyntax- 如果它附加到一个方法,我将使用MethodDeclarationSyntax。
经过第一个过滤后,该Transform方法被调用:
private static (string TypeName, Accessibility ClassAccessibility, string? Namespaces, MasterType masterType) CreateObjCollection(GeneratorAttributeSyntaxContext context, CancellationToken _)
{
var symbol = (INamedTypeSymbol)context.TargetSymbol;
var className = symbol.Name;
var classDeclarationSyntax = (ClassDeclarationSyntax)context.TargetNode;
var isPartial = classDeclarationSyntax.Modifiers.Any(SyntaxKind.PartialKeyword);
var isStatic = classDeclarationSyntax.Modifiers.Any(SyntaxKind.StaticKeyword);
var namespacesClass = string.IsNullOrEmpty(symbol.ContainingNamespace.Name) ? null : symbol.ContainingNamespace.ToDisplayString();
MasterType masterType = new(className, isPartial, isStatic);
List<string> classNames = [];
foreach (var attributeData in context.Attributes)
{
var attributeType = attributeData.AttributeClass!;
var typeArguments = attributeType.TypeArguments;
var typeSymbol = typeArguments[0];
if (classNames.Contains(typeSymbol.Name)) continue;
classNames.Add(typeSymbol.Name);
var ns = string.IsNullOrEmpty(typeSymbol.ContainingNamespace.Name) ? null : typeSymbol.ContainingNamespace.ToDisplayString();
var properties = typeSymbol.GetMembers()
.OfType<IPropertySymbol>()
.Select(t => new SubProperty(t.Name, t.Type)).ToList();
masterType.SubTypes.Add(new SubTypeClass(
typeSymbol.Name,
ns,
properties)
);
}
return (className, symbol.DeclaredAccessibility, namespacesClass, masterType);
}
context在类型的对象中GeneratorAttributeSyntaxContext,我将从我添加的代码中获得所有必要的信息:
[GenerateSetProperty<RandomPropertiesClass>()]
internal static partial class ClassHelper { }
首先,我验证该属性所引用的类。我检查它的类型static,partial并将此信息与名称和命名空间一起存储,因为这些信息将用于创建新的代码。
循环context.Attributes是必要的,因为类可以具有多个属性,这允许我们为多个类创建映射方法。在这个循环中,我检索了泛型属性中定义的类名,并将其与属性集合一起保存。
所有这些信息都从函数返回并在最后一步用于创建代码:
var collectedClasses = provider.Collect();
context.RegisterSourceOutput(collectedClasses, CreateSourceCode!);
最后两行代码创建了所有收集到的信息的集合并将其传递给CreateSourceCode生成最终代码的方法:
private static void CreateSourceCode(SourceProductionContext ctx, ImmutableArray<(string TypeName, Accessibility ClassAccessibility, string Namespaces, MasterType MasterType)> collectedClasses)
{
foreach (var info in collectedClasses)
{
if (!info.MasterType.IsPartial || !info.MasterType.IsStatic)
{
Helper.ReportClassNotSupportedDiagnostic(ctx, info.MasterType.ClassName);
continue;
}
using StringWriter writer = new(CultureInfo.InvariantCulture);
using IndentedTextWriter tx = new(writer);
tx.WriteLine("// <auto-generated/>");
tx.WriteLine("#nullable enable");
tx.WriteLine();
tx.WriteLine("using GeneratorFromAttributeExample;");
if (!string.IsNullOrEmpty(info.Namespaces))
{
tx.WriteLine($"namespace {info.Namespaces}");
tx.WriteLine('{');
tx.Indent++;
}
tx.WriteLine($"[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"{typeof(GeneratorFromAttributeClass).Assembly.GetName().Name}\", \"{typeof(GeneratorFromAttributeClass).Assembly.GetName().Version}\")]");
tx.WriteLine($"{SyntaxFacts.GetText(info.ClassAccessibility)} static partial class {info.TypeName}");
tx.WriteLine('{');
tx.Indent++;
foreach (var subType in info.MasterType.SubTypes)
{
var ns = string.IsNullOrEmpty(subType.Namespace) ? string.Empty : subType.Namespace! + ".";
tx.WriteLine($"{SyntaxFacts.GetText(info.ClassAccessibility)} static void SetProperties{subType.Classname}({ns}{subType.Classname} obj, ReadOnlySpan<char> row)");
tx.WriteLine('{');
tx.Indent++;
InsertPropertiesInSwitch(ctx, tx, subType);
tx.Indent--;
tx.WriteLine('}');
tx.WriteLine();
}
tx.Indent--;
tx.WriteLine('}');
if (!string.IsNullOrEmpty(info.Namespaces))
{
tx.Indent--;
tx.WriteLine('}');
}
Debug.Assert(tx.Indent == 0);
ctx.AddSource($"GeneratorFromAttributeExample.{info.TypeName}.g.cs", writer.ToString());
}
}
private static void InsertPropertiesInSwitch(SourceProductionContext context, IndentedTextWriter tx, SubTypeClass subType)
{
tx.WriteLine("var index = row.IndexOf(',');");
for (var counter = 0; counter < subType.Properties.Count; counter++)
{
var property = subType.Properties[counter];
var members = property.Type.GetMembers();
var hasParseMethod = members.Any(m =>
m.Name == "Parse" &&
m is IMethodSymbol method &&
method.IsStatic &&
method.Parameters.Length >= 1 &&
method.Parameters[0].Type.SpecialType == SpecialType.System_String);
var isLastProperty = counter == subType.Properties.Count - 1;
var sliceString = isLastProperty ? "row" : "row.Slice(0, index)";
tx.WriteLine(hasParseMethod
? $"obj.{property.Name} = {property.Type.ToDisplayString()}.Parse({sliceString});"
: $"obj.{property.Name} = {sliceString}.ToString();"
);
if (!isLastProperty)
{
tx.WriteLine("row = row.Slice(index + 1);");
tx.WriteLine("index = row.IndexOf(',');");
}
}
}
这些方法将最终的源代码创建为字符串。对于类型解析,Roslyn 和语法树会检查当前类型是否具有以下Parse方法:
var hasParseMethod = members.Any(m =>
m.Name == "Parse" &&
m is IMethodSymbol method &&
method.IsStatic &&
method.Parameters.Length >= 1 &&
method.Parameters[0].Type.SpecialType == SpecialType.System_String);
以下是生成的代码的示例:
using GeneratorFromAttributeExample;
namespace SourceGeneratorVsReflection.SourceGeneratorTest
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("SourceGeneratorSetProperty", "1.0.0.0")]
internal static partial class ClassHelper
{
internal static void SetPropertiesRandomPropertiesClass(SourceGeneratorVsReflection.Models.RandomPropertiesClass obj, ReadOnlySpan<char> row)
{
var index = row.IndexOf(',');
obj.Name = row.Slice(0, index).ToString();
row = row.Slice(index + 1);
index = row.IndexOf(',');
obj.Age = int.Parse(row.Slice(0, index));
row = row.Slice(index + 1);
...
index = row.IndexOf(',');
obj.OrderNumber = int.Parse(row);
}
}
}
相比基于反射的代码,ReadOnlySpan这里我也使用这个参数来优化性能,因为数值类型的解析方法也支持这个参数。
在 Rider 中,代码出现在使用项目的项目结构中:
在 Visual Studio 2022 中:
在 VSCode 中:
现在该测试一切是否正常了:
接下来,我将使用 Benchmark 类来详细测量性能差异:
源生成器版本在不到一半的时间内完成,并使用三分之一的内存。
有人可能会认为这是因为使用了 Span,所以我创建了一个基于反射的版本,也使用了 Span。结果如下:
在反射版本中,使用 Span 在执行时间和内存使用方面略有优势,但仍然远远落后于源生成器版本。
完整的源代码可以在这里找到:https://download.csdn.net/download/hefeng_aspnet/90959927
包含一个控制台应用程序,在“调试”模式下,它会运行一个简单的测试来显示三种方法的结果。在“发布”模式下,它会运行上面显示的基准测试。
然而,一切真的都是阳光和彩虹吗?
看到这些结果,你可能会想,为什么不把所有用反射编写的代码都移到更现代的源生成器中呢?很多类确实可以从中受益。
然而,源生成器的主要缺点是开发时间。对于这里展示的类,我花费的时间比使用反射多五倍——而且我已经很熟悉源生成器了。此外,目前编辑器在流畅处理源生成器方面存在问题。在 Visual Studio 中,在初始方法定义和早期测试阶段,您经常需要重新启动编辑器,因为方法签名不会更新——编辑器一直显示旧版本的方法,而且 Visual Studio 通常不会按照我上面展示的结构显示生成的文件,等等……
如果您喜欢此文章,请收藏、点赞、评论,谢谢,祝您快乐每一天。