65.9K
CodeProject 正在变化。 阅读更多。
Home

人类可读的枚举元数据

2010年12月14日

CPOL

16分钟阅读

viewsIcon

117047

downloadIcon

595

为枚举成员提供显示名称和描述:一种非侵入性、可靠、可本地化的方法。

Human-readable enumeration meta-data Demo

目录

引言

本文将继续关于枚举类型的小系列文章

  1. 枚举类型不枚举!规避 .NET 和语言限制,用于枚举迭代和数组索引的泛型类;
  2. 本文;
  3. 基于枚举的命令行实用程序;
  4. 用于 PropertyGrid 和 Visual Studio 的位枚举编辑器.

我将使用相同的代码库,并升级新功能。在需要时,我还会引用这项工作,以便正确理解该主题。

我注意到,为枚举成员提供人类可读的名称需求很高。我看到了许多解决这个问题的尝试,但没有发现任何一个令人满意,包括我几年前自己做的工作。我将审查在 CodeProject 上找到的一些作品。然而,后来我意识到,我最初的想法是正确的,但它需要改进的实现。

我最近做了这项改进;现在我认为这种方法几乎是 Microsoft .NET 可以使用的最佳方法(我不是说我的代码是最好的)。本文描述了我解决这个问题的方法。对于批评和任何想法,我将非常感激。

基本用法

我将从示例用法开始。

假设我们正在使用以下枚举类型

[DisplayName(typeof(EnumerationDeclaration.DisplayNames))]
[Description(typeof(EnumerationDeclaration.Descriptions))]
enum StringOption {
    InputDirectory,
    InputFileMask,
    OutputDirectory,
    ForceOutputFormat,
    ConfigurationFile,
    LogFile,
} //enum StringOption

[DisplayName(typeof(EnumerationDeclaration.DisplayNames))]
[Description(typeof(EnumerationDeclaration.Descriptions))]
[System.Flags]
enum BitsetOptions {
    Default = 0,
    Recursive = 1 << 0,
    CreateOutputDirectory = 1 << 1,
    Quite = 1 << 2,
} //BitsetOptions

第二种类型用作位集,并用 System.Flags 属性进行修饰。System.FlagsAttribute 属性不修改任何行为,但对于获取字符串表示(见下文)很重要。

另外两个属性,DisplayNameAttributeDescriptionAttribute,在我的项目Enumerations中定义;因此,我们需要一些 using 子句来编译这些类型声明

using DisplayNameAttribute = SA.Universal.Enumerations.DisplayNameAttribute;
using DescriptionAttribute = SA.Universal.Enumerations.DescriptionAttribute;

这些属性允许为部分或全部枚举成员提供人类可读的元数据:分别为显示名称和描述。

两种类型用作属性的参数:DisplayNamesDescriptions

有一种方法可以创建此类:让我们使用 Visual Studio 创建一个resx资源:在“项目”节点上,使用上下文菜单 -> 添加 -> 新项… -> Visual C# 项 -> 常规 -> 资源文件。如果资源命名为 DisplayNames,此步骤将创建一个 XML 资源文件和一个自动生成的 C# 文件 DisplayNames.Designer.cs,其中包含一个自动生成的类 DisplayNames。让我们取这个类的完整名称,并将其用作 DisplayNames 属性的参数,用于两个枚举类型。

完成后,代码将编译并运行而不会出现异常,但对枚举成员名称没有任何影响。使用任何类型都可以达到相同的效果。

下一步是为枚举成员提供备用名称。为此,我们需要向资源文件添加几个字符串资源。现在,资源字符串可以包含任何必要的字符,以便将枚举成员呈现为人类可读的,包括任何分隔符和空格。

每个字符串的键名应与枚举成员的名称完全相同(不带类型名),例如 InputDirectoryInputFileMaskDefaultCreateOutputDirectory 等。可以在同一资源文件中描述多个枚举类型。当两个不同的类型包含相同的成员名称时,这可能会成为一个问题。如果它们需要相同的显示名称或描述,则它们仍然可以使用单个资源文件进行描述(但不要忘记可能的本地化:在这种情况下,此声明应适用于您可能想本地化项目的每一种外语文化;这并不总是显而易见的)。如果不同枚举类型中同名枚举成员的显示名称或描述不总是相同,则选项是为单个枚举声明或一组此类声明使用两个或多个单独的资源文件。

这项工作应单独为显示名称和描述完成,因此我们可以有两个单独的资源文件和资源类;在我们的示例中:DisplayNamesDescriptions,如我们的枚举声明的属性所示。

在这些资源中呈现每个枚举成员并不那么重要。如果缺少某个字符串资源,则会应用以下回退机制:为 DisplayName 生成默认成员名称,为 Description 生成 null 字符串。

最后,我们需要一些方法在运行时获取每个枚举成员的人类可读元数据。

这可以在两个不同级别实现。第一个是枚举类迭代,如我在上一篇文章中所述。泛型类 EnumerationItem 增强了两个新属性:DisplayNameDescription

public sealed class EnumerationItem<ENUM> { 
    internal EnumerationItem(
        string name, string displayName, string description,
        Cardinal index, object value, ENUM enumValue) {/*…*/}
    public string Name { get { /*…*/ } }
    public string DisplayName { get {/*…*/} }
    public string Description { get {/*…*/} }
    public Cardinal Index { get {/*…*/} }
    public ENUM EnumValue { get {/*…*/} }
    public object Value { get {/*…*/} }
    // implementation…
} class EnumerationItem

此类中的其他成员在我上一篇文章的“枚举什么?”部分中进行了说明;另请参阅源代码。这是一个如何生成枚举类型的一些人类可读文档的示例

Enumeration<BitsetOptions> bitsetOptions = new Enumeration<bitsetoptions>();
foreach (EnumerationItem<BitsetOptions> item in bitsetOptions) {
    WriteLine(" {0:}", item.Name);
    WriteLine("\tDisplay Name: \"{0}\"", item.DisplayName);
    WriteLine("\tDescription: \"{0}\"", item.Description);
    WriteLine();
} //loop</bitsetoptions>

请注意,迭代是按自然顺序(枚举在源代码中声明的顺序)进行的,而与成员的基础整数值无关(请参阅我上一篇文章)。这允许正确迭代位集和任何其他内容。

我们还需要一些方法来获取单个枚举成员的显示名称或描述;这些方法应与具体的枚举类型无关。这可以通过以下方式完成

static class SA.Universal.Enumerations.StringAttributeUtility:
String displayName = StringAttributeUtility.GetDisplayName(
    BitsetOptions.Recursive | BitsetOptions.CreateOutputDirectory);
String description = StringAttributeUtility.GetDescription(
    StringOption. OutputDirectory);

请注意第一个示例。位运算组合不同枚举成员的显示名称计算正确。在此示例中,displayName 将被分配:“Recursive, CreateOutputDirectory”,但这些名称将被替换为资源中找到的人类可读名称(如果有)。

临时用法

人类可读的元数据可以在不使用资源的情况下指定和使用。考虑以下替代声明

enum StringOption {
    [DisplayName("Input Directory")]
    InputDirectory,
    [Description("Input File Mask")]
    InputFileMask,
    OutputDirectory,
    ForceOutputFormat,
    ConfigurationFile,
    [DisplayName ("Log File")]
    LogFile,
} //enum StringOption

[System.Flags]
[DisplayName(typeof(EnumerationDeclaration.DisplayNames))]
[Description(typeof(EnumerationDeclaration.Descriptions))]
enum BitsetOptions {
    Default = 0,
    [Description("Recurce through sub-directories")]
    Recursive = 1 << 0,
    [DisplayName ("Create Output Directory")]
    CreateOutputDirectory = 1 << 1,
    Quite = 1 << 2,
} //BitsetOptions

对于 StringOption 类型,未使用类型级别属性;相反,DisplayNameDescription 属性应用于选定的枚举成员。对于 BitsetOptions 类型,DisplayNameDescription 属性的某种组合应用于类型级别和某些单个枚举成员。在这种情况下,单个枚举成员级别的属性具有更高的优先级:仅当在单个成员级别上未解析人类可读元数据时,才会查找声明类型(请参阅下方的操作方法)。

当然,基于 DisplayNameDescription 属性的字符串参数的方法有点糟糕,不利于代码可维护性:字符串值必须硬编码在枚举声明中(但是,允许使用单独声明的常量);当然,不能应用本地化。

尽管如此,此功能对于快速原型设计、内部使用/个人使用实用程序、测试项目和其他临时工作非常有用。

超越资源类型

上述用法基于这样一个假设:DisplayNameAttributeDisplayNameAttribute 类的构造函数的类型参数代表使用 Visual Studio 创建 XML 资源时自动生成的某个类。它可以是其他类型吗?

嗯,快速回答是:是的,但随后此类将被忽略(DisplayName 的默认枚举成员名称,Description 的 null 字符串)。然而,请看这样一个自动生成类型的示例。这是一个不实现任何接口的内部类;它的基类是 Object 类。它如何被识别为一个代表任何枚举类型的资源的类?

答案很简单:这是通过反射完成的;实现只是查找一个名称与所讨论的枚举名称相同的静态成员;此外,它必须是 System.String 类型的非索引属性;并且属性的值不能为 null,表示一个非空字符串。如果这些条件未得到满足,则假定为默认行为。(请参阅源代码以了解更多详细信息。)

这一切意味着 .NET XML 资源不是唯一可以用于创建人类可读枚举元数据的资源类型。任何类都可以使用,只要它能以上述方式将枚举名称解析为字符串。如果出于某种原因,需要使用非标准资源(例如外部数据库、远程 Internet 资源、纯文本等),或者如果 .NET Framework 的未来版本引入了某种新类型的资源 — 在所有情况下,所有这些资源都可以采用,以承载枚举类型的人类可读元数据。

目前,使用 .NET XML 资源是绝对最推荐的选项,因为它们旨在提供本地化 — 请参阅下一节。

本地化

本地化机制本身超出了本文的范围。我将参考 Microsoft 文档以获取有关本地化的说明。

根据 .NET 术语,迄今为止描述的所有步骤都是为了确保解决方案的全球化(当然,如果临时技术被避免)。基本上,这意味着当需要本地化到特定文化时,可以在不修改现有代码的情况下实现。

当涉及到用于指定人类可读显示名称和描述的 XML 资源的本地化时,本地化资源以称为Satellite Assemblies 的附加程序集的.NET 形式添加。Microsoft 文档提供了关于使用 Satellite Assemblies 的资源管理机制以及创建和支持它们的步骤的全面而清晰的解释。

工作原理

DisplayNameAttributeDescriptionAttribute 属性只负责记住它们的参数;它们共享同一个基类:StringAttribute

using System;

public abstract class StringAttribute : Attribute {
    public StringAttribute(string value) { FValue = value; }
    public StringAttribute(Type type) { FType = type; }
    internal string Value { get { return FValue; } }
    internal Type Type { get { return FType; } }
    #region implementation
    string FValue;
    Type FType;
    #endregion implementation
} //class StringAttribute

[AttributeUsage(
    AttributeTargets.Field | AttributeTargets.Enum,
    AllowMultiple = false, Inherited = false)]
public class DisplayNameAttribute : StringAttribute {
    public DisplayNameAttribute(string value) : base(value) { }
    public DisplayNameAttribute(Type type) : base(type) { }
} //class DisplayNameAttribute

[AttributeUsage(
    AttributeTargets.Field | AttributeTargets.Enum,
    AllowMultiple = false, Inherited = false)]
public class DescriptionAttribute : StringAttribute {
    public DescriptionAttribute(string value) : base(value) { }
    public DescriptionAttribute(Type type) : base(type) { }
} //class DisplayNameAttribute

所有工作都由静态实用工具类 StringAttributeUtility 完成

using System;
using System.Reflection;
using StringBuilder = System.Text.StringBuilder;

public static class StringAttributeUtility {

    public static string GetDisplayName(Enum value) {
        Type type = value.GetType();
        if (IsFlags(type))
            return GetFlaggedDisplayName(type, value);
        else
            return GetSimpleDisplayName(value);
    } //GetDisplayName

    public static string GetDescription(Enum value) {
        return ResolveValue<DescriptionAttribute>(
            value.GetType().GetField(value.ToString()));
    } //GetDescription

    internal static string ResolveValue<ATTRIBUTE_TYPE>(FieldInfo field)
        where ATTRIBUTE_TYPE : StringAttribute {
        if (field == null)
            return null;
        string value = ResolveValue<ATTRIBUTE_TYPE>(
            field.GetCustomAttributes(typeof(ATTRIBUTE_TYPE), false),
                field.Name);
        if (!string.IsNullOrEmpty(value))
            return value;
        // field attribute not found, looking for it type's attributes:
        return ResolveValue<ATTRIBUTE_TYPE>(
            field.DeclaringType.GetCustomAttributes(
                typeof(ATTRIBUTE_TYPE), false), field.Name);
    } //ResolveValue

    #region implementation
    // …
    #region implementation

}  //class StringAttributeUtility

这是实现的核心

public static class StringAttributeUtility {

// …

    static string ResolveValue(StringAttribute attribute, string memberName) {
        string value = attribute.Value;
        if (!string.IsNullOrEmpty(value))
            return value;
        // immediate (hardcoded string) value not found, try using resources:
        Type resourceType = attribute.Type;
        if (resourceType == null)
            return null;
        BindingFlags bindingFlags =
            BindingFlags.NonPublic |
            BindingFlags.Public |
            BindingFlags.Static |
            BindingFlags.GetProperty;
        PropertyInfo pi = resourceType.GetProperty(memberName, bindingFlags);
        if (pi == null)
            return null;
        object stringValue = pi.GetValue(null, new object[] { });
        if (stringValue == null)
            return null;
        return stringValue as string;
    } //ResolveValue

// …

从这个实现中,可以很容易地看到人类可读元数据解析的回退机制。首先,此方法与具体的属性类型无关:实际上,它始终使用 DisplayNameAttributeDescriptionAttribute 属性参数调用。每个属性都通过其参数类型来区分:它是 stringType 类型;如果属性字符串 Valuenull,则考虑属性 Type type;这样,string 参数具有更高的优先级。

首先尝试在单个枚举成员级别解析为人类可读值。如果未解析,则考虑类型级别。

public static class StringAttributeUtility {

// …

    internal static string ResolveValue<ATTRIBUTE_TYPE>(FieldInfo field)
        where ATTRIBUTE_TYPE : StringAttribute {
        if (field == null)
            return null;
        string value = ResolveValue<ATTRIBUTE_TYPE>(
            field.GetCustomAttributes(typeof(ATTRIBUTE_TYPE), false),
            field.Name);
        if (!string.IsNullOrEmpty(value))
            return value;
        // field attribute not found, looking for it type's attributes:
        return ResolveValue<ATTRIBUTE_TYPE>(
            field.DeclaringType.GetCustomAttributes(
                typeof(ATTRIBUTE_TYPE), false),
            field.Name);
    } //ResolveValue
    static string ResolveValue<ATTRIBUTE_TYPE>(
        object[] attributes, string memberName)
        where ATTRIBUTE_TYPE : StringAttribute {
        if (attributes == null) return null;
        if (attributes.Length < 1) return null;
        ATTRIBUTE_TYPE attribute = (ATTRIBUTE_TYPE)attributes[0];
        return ResolveValue(attribute, memberName);
    } //ResolveValue

// …

}  //class StringAttributeUtility

这些 ResolveValue 方法的泛型参数用于将其替换为 DisplayNameAttributeDisplayNameAttribute 属性类。

这基本上解释了所有机制。有关详细信息,请参阅源代码。

其他方法

我发现了一些尝试通过操作方法 object.ToString 返回的默认名称来获取描述枚举的人类可读数据的尝试;例如,通过解析名称,假设驼峰命名法约定并插入诸如空格之类的分隔符。我认为这种方法没有任何价值。一个例子是 Joe Sonderegger 的作品“Making an Enum Readable (The Lazy Way)” 。这部作品获得了相当差的评分,我认为是恰如其分的;但我将这部作品作为一个典型案例引用。

Alex Kolesnichenko 在他的作品 “Humanizing the Enumerations” 中描述的方法确实有一定道理,并且基于传递给他 HumanReadableAttribute 类的硬编码字符串参数。这些硬编码字符串不被解释为即时可用的人类可读名称,而是作为资源的键名,因此仍然可以进行本地化。作者非常正确地指出,“当你的代码中有字符串常量时,这并不好”,但他没有意识到他的属性的字符串参数仍然是硬编码的;这是唯一的选择。即使额外的间接层允许本地化,这也可能导致支持噩梦。

我只知道一种强大而全面的解决方案,它确实能正确工作: Grant Frisken 的“Localizing .NET Enums”。我测试了这段代码,足以验证其正确性和可用性。不幸的是,我对易用性和可维护性并不完全满意;因此,我想在下一节讨论这个问题。

我不想引用多个基于以下思想的作品:由于枚举类型不提供适合 UI 使用和本地化的可读字符串数据(并且不枚举),让我们避免使用它们,转而使用作者建议的某个类。我认为任何此类尝试都不值得关注,因为作者至少未能看到枚举的一个主要优点:在编译时,枚举成员通过编译器识别的名称进行引用,因此此类引用可以立即被发现错误。

实际上,完全相反的方法非常有用:在可以使用字符串或数字常量的地方使用枚举类型。如果我有时间,我会尝试提交一些关于这个主题的其他文章。目前,请看我 CodeProject 对一个成员问题的回答:第一个答案没有使用我发布的代码第二个答案使用了。

为什么不使用 TypeConverter?

我冒着引发一场关于该主题的小小的争论的风险,但我无法避免讨论 Grant Frisken 提出的方法

首先,这种方法强大之处在于利用了获取任何类型值的字符串表示的预定义机制。静态方法类似于我的方法 StringAttributeUtility.GetDisplayName,实现如下

static public string ConvertToString(Enum value) {
    TypeConverter converter = TypeDescriptor.GetConverter(value.GetType());
    return converter.ConvertToString(value);
}

注意:此技术不适用于 object.ToString,它始终返回与原始枚举成员名称相同的字符串。

此方法也适用于带有 System.Flags 属性的枚举值的按位组合。另一方面,没有办法创建和使用额外的属性,如 Description

这种方法的一个明显好处是与 UI Component Model 兼容。例如,要填充 System.Windows.Forms.ComboBox,而不是使用 ConvertToString 方法计算每个单独的字符串值,而是将 ComboBox.DataSource 分配给通过调用 Enum.GetValues 方法获得的数组。另一方面,使用此类 Component Model 技术在运行时和调试时很难验证:毕竟,DataSource 的类型是 object,因此任何不相关的对象都可以通过编译,而没有正确的效果。我认为,获取枚举成员所需的字符串表示更为重要,因为它使用户能够自由设计和实现任何可想象的 UI 组件。

enumListBox.DataSource = Enum.GetValues(typeof(TextStyle));

此方法基于派生自基类 System.ComponentModel.EnumConverter 的类

public class ResourceEnumConverter : System.ComponentModel.EnumConverter {/*…*/}

问题是:整个 System.ComponentModel 命名空间都受到使用属性构造函数中类型参数的纯粹技术的困扰。问题是很难确定用于此类参数的类的要求,而且很容易犯下无法检测到的错误。我将在下一节讨论这些问题的根本原因,但 System.ComponentModel 命名空间以其特有的方式存在问题:在某些情况下,用作参数的类的要求基于命名约定,这几乎是不可接受的。(更多细节超出了本文的范围;但是,如果有人感兴趣,我很乐意在讨论过程中分享细节。)

现在,让我们评估某个枚举类型的全球化过程。首先,我们需要创建一个 XML 资源来应用于一个或多个枚举类型。资源键结合了枚举类名和枚举成员名,并用下划线字符分隔。这种格式是一种隐藏的、间接指定的命名约定,它会微妙地损害可维护性。另一方面,任何数量的枚举类型都可以由一个资源文件提供,而没有任何名称冲突的风险。

此资源不能直接应用于枚举声明。相反,应该创建一个新类,该类派生自 ResourceEnumConverter 类。此类唯一的目的是创建一个与特定 ResourceManager 实例关联的实体,该实体由自动生成的资源类的静态属性 ResourceManager 返回。最后,将此派生类作为 System.ComponentModel.TypeConverterAttribute 类的构造函数的类型参数提供。这种额外的间接层没有实际意义,但可能导致各种难以检测的错误,因为语法中没有任何内容可以表明使用了正确的配方。整个设计带来了额外的烦恼,并且明显 appeals to be shaved with 奥卡姆剃刀

我想强调,所有这些烦恼绝不是枚举本地化方法作者的错。他只是诚实且非常准确地遵循了 System.ComponentModel 设计。真正的问题是 Microsoft Component Model 设计本身。我的想法仅仅是一种在可能的情况下避免使用此设计的方法。

实际上,所有解决方案都受到某种妥协的约束,原因将在下一节中解释。

题外话:.NET 和(缺乏)元类

元类在许多语言中都得到支持,但 .NET 只提供最基本的功能,类似于元类。简单来说,整个 .NET Framework 中只有一个元类,由 Type 类型表示。

此类型涵盖运行时可想到的所有类型,无一例外。至于功能齐全的元类系统,它允许用户定义无限数量的此类元类,每个元类代表所有类的某个子集。

class MyClass { /*…*/ }
public abstract class StringAttribute : Attribute {
    // …
    // will not compile:
    public StringAttribute(ClassOf(MyClass) type) { FType = type; }
    // …
} //class StringAttribute

在此(假想的)语法中,符号 MyClass 代表一个用户定义的类;而 ClassOf(MyClass) 代表一个元类;这个元类的实例将是根据标准选择的一组类:它们都共享一个共同的基类:MyClass。这种结构允许在编译时检查在应用属性的地方为属性类的构造函数提供的类型。

此功能似乎非常适合应用于属性,因此我不明白为什么 .NET 没有继承 Delphi 的这一特性,而 Delphi 是 .NET 的主要前身。

这乍看起来可能过于复杂,但这不仅仅是我的幻想:元类概念的成功实现有很多例子。例如,我的经验中充满了在 Borland Object Pascal 和 Delphi 中大量使用元类,尽管 Borland 没有使用元类术语。此概念具有深远的影响,例如虚拟构造函数或虚拟静态(类)成员,这些在基于元类的体系结构中是允许的,并且具有很高的价值。

这个主题远远超出了当前工作的范围,并且可以作为另一篇文章的主题。

代码构建和兼容性

代码支持 Microsoft .NET Framework 2.0 至 4.0 版本,并在 Mono 上使用 Ubuntu Linux v. 8.04 测试。可以使用 Visual Studio 2005、2008 或 2010 进行构建,或者使用批处理构建针对任何命名的 Framework 版本进行构建,这不需要 Visual Studio。有关更多详细信息,请参阅我上一篇文章的“构建”部分(Build section)。

有关更多详细信息,请参阅我上一篇文章的“.NET”和“Mono”兼容性部分(.NET and Mono compatibility sections)。

结论

上文所述,枚举类型的人类可读元数据和本地化的全面解决方案不可避免地需要处理某种妥协,这是由于 .NET 架构的一些限制。同时,周到的设计可以提供一个非常实用的解决方案,易于使用、极其灵活且相对可维护。成功的关键是避免 System.ComponentModel 中备受质疑的设计。

我希望这项工作能帮助理解资源和属性的工作机制,并且可以很好地应用于许多实际项目。再次,非常欢迎读者提出想法和批评,并将不胜感激。

© . All rights reserved.