Метод расширения для конкретного класса с дополнительной логикой в атрибуте
Я бы хотел присвоить атрибут к свойствам, Скорее всего это будет мой кастомный атрибут. И потом создать метод расширения который бы использовать логику этого атрибута.
public class MyClass
{
[MyAttribute(MyEnum.Val1)]
public decimal Property1 {get; set;}
[MyAttribute(MyEnum.Val2)]
public decimal Property2 {get; set;}
public static string GetDecimalRoundString(this decimal property)
{
var attr = .GetCustomAttribute<MyAttribute>() // каким-то образом получаем атрибут
switch (attr) {
case MyEnum.Val1:
return decimal.Round(property, 4, MidpointRounding.AwayFromZero).ToString();
break;
case MyEnum.Val2:
return decimal.Round(property, 4, MidpointRounding.ToEven).ToString();
break;
}
}
}
Чтобы потом вызывать его по свойству:
var myobj = new MyClass() { Property1 = 10 };
var str1 = myobj.Property1.GetDecimalRoundString();
Что-то вроде этого. Подскажите можно ли так сделать? Будет ли это метод класса или метод расширения неважно. Цель - вызывать метод на свойство и не передавать лишних параметров так как метод будет вызываться часто на разные свойства.
Ответы (2 шт):
Я думаю, тут можно поступить немного иначе. Сделайте метод расширения для классов, которому можно будет задать выражением нужное свойство, а там уже пойдет стандартная рефлексия. Скажем, что-то такое:
public static string GetDecimalRoundString<T, TProperty>(this T item, Expression<Func<T, TProperty>> expression) where T : class
{
var memberExpression = expression.Body as MemberExpression;
var attribute = memberExpression.Member.GetCustomAttribute<MyAttribute>();
var value = expression.Compile().Invoke(item);
var decimalValue = Convert.ToDecimal(value);
return attribute.Value switch
{
MyEnum.Val1 => decimal.Round(decimalValue, 4, MidpointRounding.AwayFromZero).ToString(),
MyEnum.Val2 => decimal.Round(decimalValue, 4, MidpointRounding.ToEven).ToString(),
_ => value.ToString()
};
}
Объяснять я думаю смысла нету, и так понятно, что да как.
P.S. Я тут специально не стал отлавливать ошибки и возиться с кастом в нужный тип динамически, не хотел нагружать пример.
Вызов тогда будет таким:
var result = myobj.GetDecimalRoundString(x=>x.Property1);
Давайте для пропаганды всего хорошего покажу решение с кодогенерацией. Я базируюсь на следующих документах: Incremental Generators Cookbook, Get started with syntax transformations.
Мы хотим, чтобы наш код выглядел так:
public class MyClass
{
[Stringification(StringificationType.Out4)]
public decimal Property1 { get; set; }
[Stringification(StringificationType.Even4)]
public decimal Property2 { get; set; }
}
а методы для превращения свойств в строку генерировались автоматически, например, вот таким образом:
public string GetProperty1String() =>
decimal.Round(Property1, 4, MidpointRounding.AwayFromZero).ToString();
public string GetProperty2String() =>
decimal.Round(Property2, 4, MidpointRounding.ToEven).ToString();
Атрибут пускай будет определён так:
namespace Code;
[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)]
sealed class StringificationAttribute : Attribute
{
public StringificationAttribute(StringificationType type) => Type = type;
public StringificationType Type { get; }
}
enum StringificationType { Out4 = 0, Even4 = 1 }
держа в голове возможность дальнейшего расширения.
Для начала, установите Workload «Visual Studio extension development» (включая «.NET Compiler Platform SDK») через Visual Studio Installer.
Создайте Solution с двумя проектами. Первый проект — это ваш тестовый код, второй проект создаём следующим образом.
В проектном файле говорим
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>13</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
</ItemGroup>
</Project>
Обратите внимание на target framework! Вам нужно использовать именно netstandard, так как ваш код будет бежать в контексте Visual Studio.
Затем, сам генератор. Я использую более современные инкрементальные генераторы.
Заводим класс, который будет реализовывать интерфейс IIncrementalGenerator
, и дадим ему атрибут [Generator]
.
namespace CodeGenerator;
[Generator]
public class GeneratorAnalyzer : IIncrementalGenerator
{
}
В интерфейсе определён лишь метод Initialize
. Подпишемся на появление/изменение свойств с атрибутом [Stringification]
:
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var propertyPipeline = context.SyntaxProvider.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: "Code.StringificationAttribute",
predicate: static (syntaxNode, cancellationToken) =>
syntaxNode is PropertyDeclarationSyntax,
transform: static (context, cancellationToken) =>
{
// сюда придёт наш код для каждого из атрибутов
}
);
Что именно нам нужно писать в transform? Немножко теории. В этой точке мы не можем ещё создать выходной файл. Вместо этого мы должны создать структуру данных (модельный класс), которая описывает наши данные, которых должно быть достчточно, чтобы сгенерировать нужный код. Экземпляры этого класса будут кэшироваться Студией для того, чтобы понять, необходима ли повторная генерация. Для этого наша структура данных должна быть иммутабельной (хотя бы фактически, не обязательно на уровне интерфейса), и уметь правильно сравниваться с самой собой (IEquatable<...>
).
Для таких классов пригодится record
:
public record PropertyModel(
string? Namespace,
string ClassName,
string PropertyName,
int OutputType);
Мы запишем туда информацию, нужную для генерации: пространство имён, имя класса, имя свойства, и параметр из атрибута.
Наш код будет выглядеть как-то так:
var containingClass = context.TargetSymbol.ContainingType;
var attribute = context.Attributes.Single();
var argument = attribute.ConstructorArguments.Single();
var argumentValue = (int)argument.Value!;
return new PropertyModel(
Namespace: containingClass.ContainingNamespace?.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
SymbolDisplayGlobalNamespaceStyle.Omitted)),
ClassName: containingClass.Name,
PropertyName: context.TargetSymbol.Name,
OutputType: argumentValue);
У нас появился конвейнер (pipeline) из моделей. Но из одного экземпляра модели мы не можем создать выходной класс (нам бы хотелось класть все методы одного класса в один дополнительный файл). Для этого нам нужно сгруппировать конвейер по классам. Для этого нам нужна другая модель, для класса (тоже иммутабельная, и поддерживающая IEquatable
).
Поскольку новая модель содержит список, то и этот список должен поддерживать IEquatable<...>
. Заведём для этого вспомогательный класс, т. к. обычный List<T>
или T[]
не поддерживают такой интерфейс:
public class EquatableList<T> : List<T>, IEquatable<EquatableList<T>>
{
public EquatableList(IEnumerable<T> items) => AddRange(items);
public bool Equals(EquatableList<T>? other) => this.SequenceEqual(other);
public override bool Equals(object obj) => Equals(obj as EquatableList<T>);
public override int GetHashCode()
{
var hc = new HashCode();
foreach (var item in this)
hc.Add(item);
return hc.ToHashCode();
}
public static bool operator ==(EquatableList<T> list1, EquatableList<T> list2) =>
ReferenceEquals(list1, list2) || list1 is not null && list2 is not null && list1.Equals(list2);
public static bool operator !=(EquatableList<T> list1, EquatableList<T> list2) => !(list1 == list2);
}
Имея такой вспомогательный класс, мы создаём модель класса:
public record ClassModel(
string? Namespace,
string ClassName,
EquatableList<PropertyModel> Properties);
Сама перегруппировка делается так:
var classPipeline =
propertyPipeline
.Collect()
.SelectMany((models, ct) =>
models.GroupBy(m => (m.Namespace, m.ClassName))
.Select(g => new ClassModel(g.Key.Namespace,
g.Key.ClassName,
new(g.OrderBy(m => m.PropertyName)))));
Мы группируем свойства из одного класса, сортируем их (чтобы обеспечить сравнимость), и пакуем в EquatableList
.
Имея такие группы, можно заняться генерацией исходников.
context.RegisterSourceOutput(classPipeline, static (context, classModel) =>
{
StringBuilder sb = new();
var (ns, className, properties) = classModel;
GeneratePreamble(ns, className);
foreach (var model in properties)
GeneratePropertyStringifier(model);
GeneratePostamble();
var result = sb.ToString();
var sourceText = SourceText.From(result, Encoding.UTF8);
context.AddSource($"{className}.g.cs", sourceText);
// тут Generate-функции
}
Остались служебные функции Generate*
.
GeneratePreamble
выглядит тривиально:
void GeneratePreamble(string? ns, string className)
{
if (ns is not null)
{
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
}
sb.AppendLine($"partial class {className}");
sb.AppendLine("{");
}
GeneratePostabmle
тоже:
void GeneratePostabmle()
{
sb.AppendLine("}");
}
Рабочие функции генерируем так:
void GeneratePropertyStringifier(Model model)
{
var roundArgs = model.OutputType switch
{
0 /*StringificationType.Out4*/ => "4, MidpointRounding.AwayFromZero",
1 /*StringificationType.Even4*/ => "4, MidpointRounding.ToEven",
_ => null
};
if (roundArgs is null)
...
Ой, а что тут делать? Надо выдать юзеру ошибку. Для этого мы не кидаем исключение (оно вызовет просто ошибку обработки конвейера), а генерируем сообщение об ошибке через context.ReportDiagnostics
.
if (roundArgs is null)
context.ReportDiagnostic(
Diagnostic.Create(?, ?); // а что тут?
Для начала, нам нужно создать Diagnostic Rule:
static readonly DiagnosticDescriptor wrongEnumValue =
new("GEN_01",
"Unsupported Enum value",
"The enum value {0} is not supported",
"Generation",
DiagnosticSeverity.Error, isEnabledByDefault: true);
Затем, нам нужно доставить как-то Location
. В нашей модели этой информации нет, но её можно добавить. Поскольку Location
зависит от SyntaxTree
(а значит, хранить его в модели — плохая идея), заводим record LocationInfo
(украдено отсюда):
public record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan)
{
public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan);
public static LocationInfo? CreateFrom(SyntaxNode node) => CreateFrom(node.GetLocation());
public static LocationInfo? CreateFrom(Location location) => location.SourceTree is null ?
null :
new LocationInfo(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span);
}
Добавляем параметр в модель:
public record Model(
string? Namespace,
string ClassName,
string PropertyName,
int OutputType,
LocationInfo? Location);
и модифицируем код, создающий модель, в функции Initialize
:
return new Model(
Namespace: containingClass.ContainingNamespace?.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
SymbolDisplayGlobalNamespaceStyle.Omitted)),
ClassName: containingClass.Name,
PropertyName: context.TargetSymbol.Name,
OutputType: argumentValue,
Location: LocationInfo.CreateFrom(context.TargetNode.GetLocation()));
Теперь наш if
можно дописать:
if (roundArgs is null)
context.ReportDiagnostic(
Diagnostic.Create(wrongEnumValue, model.Location?.ToLocation(), model.OutputType));
Ну и конечно остальное:
else
sb.AppendLine(
$$"""
public string Get{{model.PropertyName}}String() =>
decimal.Round({{model.PropertyName}}, {{roundArgs}}).ToString();
""");
Готово!
На всякий случай, вот весь код:
using System.Drawing;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace CodeGenerator;
[Generator]
public class GeneratorAnalyzer : IIncrementalGenerator
{
static readonly DiagnosticDescriptor wrongEnumValue =
new("GEN_01",
"Unsupported Enum value",
"The enum value {0} is not supported",
"Generation",
DiagnosticSeverity.Error, isEnabledByDefault: true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var propertyPipeline = context.SyntaxProvider.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: "Code.StringificationAttribute",
predicate: static (syntaxNode, cancellationToken) => syntaxNode is PropertyDeclarationSyntax,
transform: static (context, cancellationToken) =>
{
var containingClass = context.TargetSymbol.ContainingType;
var attribute = context.Attributes.Single();
var argument = attribute.ConstructorArguments.Single();
var argumentValue = (int)argument.Value!;
return new PropertyModel(
Namespace: containingClass.ContainingNamespace?.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
SymbolDisplayGlobalNamespaceStyle.Omitted)),
ClassName: containingClass.Name,
PropertyName: context.TargetSymbol.Name,
OutputType: argumentValue,
Location: LocationInfo.CreateFrom(context.TargetNode.GetLocation()));
}
);
var classPileline =
propertyPipeline
.Collect()
.SelectMany((models, ct) =>
models.GroupBy(m => (m.Namespace, m.ClassName))
.Select(g => new ClassModel(g.Key.Namespace,
g.Key.ClassName,
new(g.OrderBy(m => m.PropertyName)))));
context.RegisterSourceOutput(classPileline, static (context, classModel) =>
{
StringBuilder sb = new();
var (ns, className, properties) = classModel;
GeneratePreamble(ns, className);
foreach (var model in properties)
GeneratePropertyStringifier(model);
GeneratePostamble();
var result = sb.ToString();
var sourceText = SourceText.From(result, Encoding.UTF8);
context.AddSource($"{className}.g.cs", sourceText);
void GeneratePreamble(string? ns, string className)
{
if (ns is not null)
{
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
}
sb.AppendLine($"partial class {className}");
sb.AppendLine("{");
}
void GeneratePropertyStringifier(PropertyModel model)
{
var roundArgs = model.OutputType switch
{
0 /*StringificationType.Out4*/ => "4, MidpointRounding.AwayFromZero",
1 /*StringificationType.Even4*/ => "4, MidpointRounding.ToEven",
_ => null
};
if (roundArgs is null)
context.ReportDiagnostic(
Diagnostic.Create(wrongEnumValue, model.Location?.ToLocation(), model.OutputType));
else
sb.AppendLine(
$$"""
public string Get{{model.PropertyName}}String() =>
decimal.Round({{model.PropertyName}}, {{roundArgs}}).ToString();
""");
}
void GeneratePostamble()
{
sb.AppendLine("}");
}
});
}
}
public record PropertyModel(
string? Namespace, string ClassName, string PropertyName,
int OutputType, LocationInfo? Location);
public record ClassModel(string? Namespace, string ClassName, EquatableList<PropertyModel> Properties);
public class EquatableList<T> : List<T>, IEquatable<EquatableList<T>>
{
public EquatableList(IEnumerable<T> items) => AddRange(items);
public bool Equals(EquatableList<T>? other) => this.SequenceEqual(other);
public override bool Equals(object obj) => Equals(obj as EquatableList<T>);
public override int GetHashCode()
{
var hc = new HashCode();
foreach (var item in this)
hc.Add(item);
return hc.ToHashCode();
}
public static bool operator ==(EquatableList<T> list1, EquatableList<T> list2) =>
ReferenceEquals(list1, list2) || list1 is not null && list2 is not null && list1.Equals(list2);
public static bool operator !=(EquatableList<T> list1, EquatableList<T> list2) => !(list1 == list2);
}
// https://andrewlock.net/creating-a-source-generator-part-9-avoiding-performance-pitfalls-in-incremental-generators/
public record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan)
{
public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan);
public static LocationInfo? CreateFrom(SyntaxNode node) => CreateFrom(node.GetLocation());
public static LocationInfo? CreateFrom(Location location) => location.SourceTree is null ?
null :
new LocationInfo(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span);
}
Мне ещё пришлось положить отдельный полифилл для рекордов:
using System.ComponentModel;
namespace System.Runtime.CompilerServices;
[EditorBrowsable(EditorBrowsableState.Never)]
internal class IsExternalInit { }
В проекте, в котором должна происходить кодогенерация, добавляем:
<ItemGroup>
<ProjectReference
Include="..\CodeGenerator\CodeGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"/>
</ItemGroup>
Классу MyClass
добавляем partial
, т. к. его кусок будет сгенерирован генератором.
Вроде всё работает.
Полный пример можно посмотреть тут.
Кстати, для отладки генератора имеет смысл воспольхзоваться советом отсюда: https://github.com/JoanComasFdz/dotnet-how-to-debug-source-generator-vs2022