如何在 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的示例不能直接当作通用序列化器使用。 - 块注释正文不能直接包含
*/。示例在特性构造时拒绝这类内容,避免生成损坏的配置文件。
备注:内容仅供参考。
