PAYATHON 2026

如何在 JSON 属性名前添加注释?

技术老齐

可以实现,需要自定义写出逻辑。用 [JsonComment(...)] 保存说明文字,再由写出代码把说明放到对应的属性名之前。

标准 JSON 不支持注释,RFC 8259 定义的语法中也没有注释。题目中的内容属于带注释的 JSON,读取程序必须支持这种扩展语法。

为什么属性值转换器不适合这个位置?

序列化对象成员时,通常先写属性名,再写属性值。挂在属性上的 JsonConverter 主要负责转换属性值,执行时属性名往往已经写出。

要把注释放在属性名之前,需要控制外层对象的写出过程,按以下顺序写入:

写注释
写属性名
写属性值

Json.NET 提供 JsonWriter.WriteComment(),System.Text.Json 提供 Utf8JsonWriter.WriteCommentValue()。调用顺序能决定注释在文本中的位置,但默认格式化器未必会把注释、逗号和换行排成题目中的样子。

用 Json.NET 输出题目中的布局

对于题目中的配置对象,可以先让 Json.NET 完成普通序列化,再逐个写出顶层成员。这样可以沿用属性重命名、忽略属性等处理结果,同时控制注释所在的行。

先定义自定义特性 JsonCommentAttribute 和配置类:

using System;

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class JsonCommentAttribute : Attribute
{
    public string Text { get; }

    public JsonCommentAttribute(string text)
    {
        if (text == null)
            throw new ArgumentNullException(nameof(text));

        // 防止说明文字提前结束块注释。
        if (text.Contains("*/"))
            throw new ArgumentException(
                "Comment text cannot contain */.", nameof(text));

        Text = text;
    }
}

public class Person
{
    [JsonComment("Name of the person")]
    public string Name { get; set; } = string.Empty;

    [JsonComment("Age of the person")]
    public int Age { get; set; }
}

然后实现配置文件的写出方法:

using System;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;

public static class CommentedConfig
{
    public static string Serialize<T>(
        T value,
        JsonSerializerSettings settings) where T : class
    {
        if (value == null)
            throw new ArgumentNullException(nameof(value));

        var serializer = JsonSerializer.CreateDefault(settings);

        var contract = serializer.ContractResolver
            .ResolveContract(value.GetType()) as JsonObjectContract;

        if (contract == null)
            throw new ArgumentException(
                "The value must use an object contract.", nameof(value));

        // 普通序列化先决定实际输出的成员名称、值和顺序。
        var obj = JObject.FromObject(value, serializer);
        var properties = obj.Properties().ToList();
        var output = new StringBuilder();

        output.AppendLine("{");

        for (int i = 0; i < properties.Count; i++)
        {
            var property = properties[i];

            // 使用序列化后的名称查找成员,兼容 JsonProperty 重命名。
            var member = contract.Properties.GetProperty(
                property.Name, StringComparison.Ordinal);

            var comment = member?.AttributeProvider
                ?.GetAttributes(typeof(JsonCommentAttribute), true)
                .OfType<JsonCommentAttribute>()
                .FirstOrDefault();

            if (comment != null)
            {
                output.Append("  /*")
                    .Append(comment.Text)
                    .AppendLine("*/");
            }

            // 名称和数值仍由 JSON 库编码,避免手工转义。
            output.Append("  ")
                .Append(JsonConvert.ToString(property.Name))
                .Append(": ")
                .Append(property.Value.ToString(Formatting.None));

            if (i < properties.Count - 1)
                output.Append(',');

            output.AppendLine();
        }

        output.Append('}');
        return output.ToString();
    }
}

代码通过 Json.NET 的序列化契约,从实际输出的属性名找到对应的成员元数据,再用 AttributeProvider.GetAttributes() 读取自定义特性。相关 API 见 Json.NET 属性元数据文档和特性读取接口。

调用并写入磁盘:

var person = new Person
{
    Name = "Jack",
    Age = 22
};

string text = CommentedConfig.Serialize(
    person, new JsonSerializerSettings());

System.IO.File.WriteAllText("person.jsonc", text);

输出为:

{
  /*Name of the person*/
  "Name": "Jack",
  /*Age of the person*/
  "Age": 22
}

这个方法只为顶层成员添加注释。嵌套对象的值仍由 Json.NET 序列化,以紧凑格式输出,不会递归添加说明。它适用于题目这样的普通配置对象。如果自定义转换器已经改变了整个对象的结构,就需要在该转换器中处理注释与成员的对应关系。

如果使用 Json.NET 对象转换器

如果需要在现有序列化流程中直接输出注释,可以为 Person 编写对象级 JsonConverter。在 WriteJson() 中按以下顺序调用:

writer.WriteStartObject();

writer.WriteComment("Name of the person");
writer.WritePropertyName("Name");
writer.WriteValue(person.Name);

writer.WriteComment("Age of the person");
writer.WritePropertyName("Age");
writer.WriteValue(person.Age);

writer.WriteEndObject();

将固定字符串替换为从 JsonCommentAttribute 读取的内容即可。WriteComment() 接收注释正文,并负责输出外面的 /* 和 */,详见 Json.NET API 文档。

这种方式能控制注释的写入位置,但 Formatting.Indented 不保证注释独占一行。Json.NET 可能在写下一个属性名时才输出成员分隔逗号,因此注释也可能紧跟前一个值,相关逻辑见 Json.NET 写出状态处理源码。如果要求注释独占一行,逗号布局也与题目一致,可以使用前面的配置写出方法。

System.Text.Json 也能写,但需要自行读取特性

在对象级 JsonConverter<Person>.Write() 中,可以读取特性,用 Utf8JsonWriter.WriteCommentValue() 写入注释,再写属性名。Name 成员的处理片段如下:

// 需要 using System.Reflection;
var member = typeof(Person).GetProperty(nameof(Person.Name));

var comment = member?
    .GetCustomAttribute<JsonCommentAttribute>();

if (comment != null)
    writer.WriteCommentValue(comment.Text);

writer.WritePropertyName(nameof(Person.Name));
writer.WriteStringValue(value.Name);

Age 按相同方式处理,并在整个对象的前后分别调用 WriteStartObject() 和 WriteEndObject()。这些逻辑需要放在外层对象转换器中。仅定义 [JsonComment],默认的 JsonSerializer.Serialize() 不会自动输出说明。

读取带注释的配置时,需要显式设置跳过注释:

var readOptions = new System.Text.Json.JsonSerializerOptions
{
    ReadCommentHandling =
        System.Text.Json.JsonCommentHandling.Skip
};

Person person =
    System.Text.Json.JsonSerializer.Deserialize<Person>(
        text, readOptions);

ReadCommentHandling 只影响读取,不会让序列化器自动生成注释。题目中的数据没有尾随逗号,因此不需要设置 AllowTrailingCommas。相关设置见 Microsoft 读取注释说明。

使用时需要注意什么?

  • 添加注释后,文件就不能保证被严格的 JSON 解析器接受。.jsonc 扩展名可以提示文件含有注释,解析器仍需支持相应语法。
  • 注释通常不会进入反序列化后的业务对象。重新保存时,上述方案会根据特性重新生成说明,不会保留用户手工修改的注释。
  • 自定义对象转换器需要自行处理属性命名、忽略条件和属性级转换器等行为。针对 Person 的示例不能直接当作通用序列化器使用。
  • 块注释正文不能直接包含 */。示例在特性构造时拒绝这类内容,避免生成损坏的配置文件。

备注:内容仅供参考。