NewtonSoft Json.Schema - 防止在对象定义中传入Description属性

前端开发 2026-07-08

正如下方代码示例所示,我正在为 MotionConfigSchema 类生成JSON架构。它包含两个字段(YawRoll),它们的类型都是 Channel。我想在这两个字段上添加Description属性(YawRoll),但问题在于Description属性会导致架构生成器为 Channel 类创建两个定义。

最容易理解的还是看这里的示例:https://dotnetfiddle.net/u3IsXC

期望结果

我不想在生成的架构中看到两个定义("Channel" 和 "Channel-1"),我希望在生成的JSON架构中的Yaw与 Roll属性里出现Yaw字段和Roll字段的Description属性。

代码示例

using Newtonsoft.Json;
using Newtonsoft.Json.Schema;
using Newtonsoft.Json.Schema.Generation;
using System.ComponentModel;
using System;

public class Program
{
    public static void Main()
    {
        JSchemaGenerator generator = new JSchemaGenerator();
        //generator.SchemaReferenceHandling = SchemaReferenceHandling.None;
        generator.DefaultRequired = Required.DisallowNull;
        JSchema schema = generator.Generate(typeof(MotionConfigSchema));

        Console.WriteLine(schema.ToString());
    }
}

public class Channel
{
    public string DataPath { get; private set; }
    public float Gain { get; private set; } = 1.0f;
}

[Description("Motion Configuration file fields description.")]
public class MotionConfigSchema
{
    [Description("Yaw in °/sec")] 
    public Channel? Yaw {get; set;}

    [Description("Roll in °")]
    public Channel? Roll {get; set;}
}

这将导致以下生成的json架构:

{
  "description": "Motion Configuration file fields description.",
  "definitions": {
    "Channel": {
      "description": "Yaw in °/sec",
      "type": "object",
      "properties": {
        "DataPath": {
          "type": "string"
        },
        "Gain": {
          "type": "number"
        }
      }
    },
    "Channel-1": {
      "description": "Roll in °",
      "type": "object",
      "properties": {
        "DataPath": {
          "type": "string"
        },
        "Gain": {
          "type": "number"
        }
      }
    }
  },
  "type": "object",
  "properties": {
    "Yaw": {
      "$ref": "#/definitions/Channel"
    },
    "Roll": {
      "$ref": "#/definitions/Channel-1"
    }
  }
}

背景

这是一个Unity 6项目。我并不一定要使用Newtonsoft,但我觉得我无法访问.NET 9中的Microsoft实现。

这里的主要目标只是拥有一个易读的架构,以便修改json配置文件。当应用构建时,架构会自动生成,随后用户可以参考该架构来了解可用的属性及其描述。使用json架构生成器的想法,是希望将文档与构建过程紧密绑定。

解决方案

你希望能够在一个 "$ref" 引用旁边生成带有额外关键字的架构,示例如下:

"Yaw": {
  "$ref": "#/definitions/Channel"
  "description": "Yaw in °/sec",
},

不幸的是,似乎Json.NET Schema还不支持生成这样的架构。与 "$ref" 共存的附加属性在当前的JSON Schema草案中被定义为可选的 Draft 2020-12,[1],在JSON Schema草案7 及更早版本中则被禁止 [2]。尽管Newtonsoft在 4.0.1 版本中为Draft 2020-12增加了支持(本回答时的当前版本),但它永远不会写出这样的架构。负责写入架构的内部类型 JSchemaWriter 在写入引用后就立即返回,而不包含任何附加属性。[4]

相反,如你所注意到的,JSON.NET Schema的做法是:当引用对象架构的属性具有不同的标题或描述时,它会克隆被引用的架构并覆盖相应的关键字。这在Draft 7及更早版本中是强制性的,在当前的JSON Schema规范中仍然允许——只是这不是你想要的。

作为部分变通,我能够通过一个自定义契约解析器来强制Json.NET Schema忽略属性描述,该解析器会过滤掉应用于对象值属性的所有 DescriptionAttribute 实例:

var generator = new JSchemaGenerator
{
    ContractResolver = new NoObjectPropertytDescriptionsContractResolver(),
    DefaultRequired = Required.DisallowNull,
};
var schema = generator.Generate(typeof(MotionConfigSchema));
var schemaString = schema.ToString(SchemaVersion.Draft2020_12);

//------------------------------

public class NoObjectPropertytDescriptionsContractResolver : FilteredAttributeProviderContractResolver
{
    public NoObjectPropertytDescriptionsContractResolver() : base(NoObjectPropertytDescriptionsAttributeProvider.Create) { }
}

public class FilteredAttributeProviderContractResolver : DefaultContractResolver
{
    public FilteredAttributeProviderContractResolver(Func<DefaultContractResolver, JsonProperty, IAttributeProvider?, IAttributeProvider?> attibuteProviderFilter)
        => AttibuteProviderFilter = attibuteProviderFilter ?? throw new ArgumentNullException(nameof(attibuteProviderFilter));

    protected Func<DefaultContractResolver, JsonProperty, IAttributeProvider?, IAttributeProvider?> AttibuteProviderFilter { get; }

    protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
    {
        var property = base.CreateProperty(member, memberSerialization);
        property.AttributeProvider = AttibuteProviderFilter(this, property, property.AttributeProvider);
        return property;
    }
}

public class NoObjectPropertytDescriptionsAttributeProvider : FilteredAttributeProvider
{
    public static NoObjectPropertytDescriptionsAttributeProvider? Create(IContractResolver resolver, JsonProperty baseProperty, IAttributeProvider? baseProvider) =>
        baseProvider == null ? null : new NoObjectPropertytDescriptionsAttributeProvider(resolver, baseProperty, baseProvider);

    public static bool TryGetOriginalDescription(IAttributeProvider? provider, [NotNullWhen(true)] out string? description)
    {
        description = provider switch
        {
             NoObjectPropertytDescriptionsAttributeProvider filter => 
                 filter.BaseProvider.GetAttributes(typeof(DescriptionAttribute), true).OfType<DescriptionAttribute>().FirstOrDefault()?.Description,
            _ => null,
        };
        return !string.IsNullOrEmpty(description);
    }

    public NoObjectPropertytDescriptionsAttributeProvider(IContractResolver resolver, JsonProperty baseProperty, IAttributeProvider baseProvider) : base(resolver, baseProperty, baseProvider) { }

    protected override Attribute? Filter(Attribute attribute)
    {
        var filtered = base.Filter(attribute);
        if (filtered is DescriptionAttribute
            && BaseProperty.PropertyType != null
            && Resolver.ResolveContract(BaseProperty.PropertyType) is JsonObjectContract)
            filtered = null;
        return filtered;
    }
}

public class FilteredAttributeProvider : IAttributeProvider
{
    public FilteredAttributeProvider(IContractResolver resolver, JsonProperty baseProperty, IAttributeProvider baseProvider) => 
        (this.Resolver, this.BaseProperty, this.BaseProvider) = 
        (resolver ?? throw new ArgumentNullException(nameof(resolver)), baseProperty ?? throw new ArgumentNullException(nameof(baseProperty)), baseProvider ?? throw new ArgumentNullException(nameof(baseProvider)));
    public IContractResolver Resolver { get; }
    public JsonProperty BaseProperty { get; }
    public IAttributeProvider BaseProvider { get; }
    protected virtual Attribute? Filter(Attribute attribute) => attribute;
    public IList<Attribute> GetAttributes(Type attributeType, bool inherit) => 
        BaseProvider.GetAttributes(attributeType, inherit).Select(a => Filter(a)).Where(a => a != null).Select(a => a!).ToArray();
    public IList<Attribute> GetAttributes(bool inherit) => 
        BaseProvider.GetAttributes(inherit).Select(a => Filter(a)).Where(a => a != null).Select(a => a!).ToArray();
}

有了这个解析器,生成的架构只包含一个 "Channel" 定义。

演示示例#1 在此

然而,如果我尝试在生成架构后通过一个自定义 JSchemaGenerationProvider 将属性描述重新放回去,所发生的只是 "description""Channel" 定义中的值被覆盖,这同样不是你想要的:

{
  "description": "Motion Configuration file fields description.",
  "definitions": {
    "Channel": {
      "description": "Roll in °",

演示示例#2 在此

因此,我能想到的唯一变通办法,是让你把生成的架构解析成一个 JToken 层级,然后手动把描述重新加入。我已经拼凑出一个能在根属性上工作的简易实现:

var provider = new RefPropertiesWithDescriptionProvider();
var generator = new JSchemaGenerator
{
    ContractResolver = new NoObjectPropertytDescriptionsContractResolver(),
    GenerationProviders = { provider },
    DefaultRequired = Required.DisallowNull,
};

var schema = generator.Generate(typeof(MotionConfigSchema));
using var writer = new JTokenWriter();
schema.WriteTo(writer, new JSchemaWriterSettings {  Version = SchemaVersion.Draft2020_12 });
var jschemaToken = writer.Token ?? throw new InvalidOperationException("No JToken written.");

foreach (var (containerSchema, name, _, description) in provider.CollectedDescriptions)
{
    // For the root schema, we know exactly where the properties are, so we can add descriptions if required.
    // For nested schemas, we would need to find the schema JObject corresponding to containerSchema and look for its property list.
    if (containerSchema == schema) 
    {
        var propertyJObject = jschemaToken[JsonSchemaExtensions.Properties]?[name] as JObject;
        if (propertyJObject != null && !propertyJObject.ContainsKey(JsonSchemaExtensions.Description))
            propertyJObject[JsonSchemaExtensions.Description] = description;
    }
}

var schemaString = jschemaToken.ToString();

//------------------------------

public class RefPropertiesWithDescriptionProvider : CustomizedProviderBase
{
    public List<(JSchema containerSchema, string name, JSchema propertySchema, string description)> CollectedDescriptions { get; } = new ();

    // Collect descriptions to add back in after schema generation is complete.
    public override JSchema GetSchema(JSchemaTypeGenerationContext context)
    {
        var schema = base.GetSchema(context);
        if (context.Generator.ContractResolver.ResolveContract(context.ObjectType) is JsonObjectContract contract)
        {
            foreach (var propertySchema in schema.Properties.Where(p => string.IsNullOrEmpty(p.Value.Description)))
            {
                var jProperty = contract.Properties[propertySchema.Key];
                if (NoObjectPropertytDescriptionsAttributeProvider.TryGetOriginalDescription(jProperty.AttributeProvider, out var description))
                {
                    CollectedDescriptions.Add((schema, propertySchema.Key, propertySchema.Value, description));
                }
            }
        }
        return schema;
    }

    public override bool CanGenerateSchema(JSchemaTypeGenerationContext context) =>
        base.CanGenerateSchema(context) && context.Generator.ContractResolver.ResolveContract(context.ObjectType) is JsonObjectContract;
}

public abstract class CustomizedProviderBase : JSchemaGenerationProvider
{
    // Base class that allows generation of a default schema which may then be subsequently customized. 
    // Note this class contains state information and so is not thread safe.
    readonly Stack<Type> currentTypes = new ();

    public override JSchema GetSchema(JSchemaTypeGenerationContext context)
    {
        if (CanGenerateSchema(context))
        {
            var currentType = context.ObjectType;
            try
            {
                currentTypes.Push(currentType);
                return context.Generator.Generate(currentType);
            }
            finally
            {
                currentTypes.Pop();
            }
        }
        else
            throw new NotImplementedException();
    }

    public override bool CanGenerateSchema(JSchemaTypeGenerationContext context) => 
        !currentTypes.TryPeek(out var t) || t != context.ObjectType;
}

public static class JsonSchemaExtensions
{
    public const string Properties = "properties";
    public const string Description = "description";
    public const string Definitions = "definitions";
    public const string Defs = "$defs";

    public static bool IsDefinition(string name) =>
        string.Equals(name, Definitions, StringComparison.Ordinal) || string.Equals(name, Defs, StringComparison.Ordinal);
}

对于嵌套对象的属性,你需要弄清楚如何在标记层级中找到与相关的 JSchema 相对应的 JObject

演示示例#3 在此

另外,因为无法访问微软为 .NET 9 新增的模式生成工具,你也可以研究第三方工具如 JsonSchema.Net,它支持对引用的架构描述。


[1] 参见 2020-12/JSON Schema: A Media Type for Describing JSON Documents 7.7.1.1(强调已添加)

7.7.1.1. Distinguishing Among Multiple Values

应用程序可以根据贡献该值的架构位置来决定在多种注解值中使用哪一个。这旨在实现灵活的用法。收集架构位置有助于实现此类用法。

[2] 草案7 及更早版本不允许与一个 "$ref" 属性并列的附加属性。参见Draft 7/JSON Schema: A Media Type for Describing JSON Documents: 8.3. Schema References With "$ref"

所有其他在 "$ref" 对象中的属性MUST be忽略。

[3] Newtonsoft文档页面 SchemaVersion Enumeration 错误地指出Draft 7是最新支持的版本。完整版本列表可在 SchemaVersion.cs 中看到。

[4] 以作确认,请参见 JSchemaWriter.ReferenceOrWriteSchema()JSchemaWriter.WriteReferenceObject()

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章