使用动态代理延迟反序列化大型 JSON 文件
用于延迟反序列化大型 JSON 文件的动态代理
引言
本文中描述的解决方案是为了解决我在标准音乐字体布局(https://www.smufl.org/)的控件库项目 Manufaktura.Controls
(https://codeproject.org.cn/Articles/1252423/Music-Notation-in-NET)实现过程中遇到的一个问题而发明的。
标准音乐字体布局(简称 SMuFL)是由 Steinberg 发起,目前由 W3C 音乐符号社区组开发的字体标准。符合 SMuFL 规范的音乐字体会附带一个 JSON 文件,其中包含描述默认雕刻设置(如小节线粗细、梁的粗细等)以及字形之间关系的元数据,例如字形大小、字形切口(用于将字形紧密排列在一起)等。SMuFL 的完整规范可以在这里找到:http://w3c.github.io/smufl/gitbook/specification/。
所有这些设置都存储在一个包含数千个节点的 JSON 文件中。单独的类 Manufaktura.Controls.Model.SMuFL.GlyphBBoxes
,它只映射了 JSON 文件的一小部分,就包含 2964 个属性。根据我的测量,使用流行的框架 Newtonsoft.Json
反序列化整个元数据文件在我机器上(我使用的是第 7 代 i7 CPU)需要 4.8 秒。其他用户报告说,在某些机器上可能需要长达 8 秒。
JSON 元数据文件中包含的大部分数据对大多数应用程序来说是不必要的。如果你熟悉音乐,你可能知道大多数乐谱不使用微音调调整,而 SMuFL 规范提供了来自几种微音调系统的字符。你可能也不需要诸如 PictJingleBells
、PictMusicalSawPeinkofer
或 WindRimOnly
这样的字形,不管它们是什么意思……
显而易见的解决方案是只读取 JSON 文件的一部分,但是将 JSON 树结构映射到强类型对象会很诱人,这样你就可以在不需要手动遍历 JSON 树的情况下访问所有需要的属性。注意在这个方法中它看起来有多简单(其中 ISMuFLFontMetadata
是一个反序列化的 JSON 文件)
[Units(Units.Linespaces)]
public BoundingBox GetSMuFLBoundingBox(ISMuFLFontMetadata metadata)
{
if (RepeatSign == RepeatSignType.Backward) return metadata.GlyphBBoxes.RepeatRight;
else if (RepeatSign == RepeatSignType.Forward) return metadata.GlyphBBoxes.RepeatLeft;
else return null;
}
既能部分读取 JSON 又能保持强类型对象简洁性的方法是实现一个动态代理。
它是如何工作的
动态代理有很多实现,但我想让我的代码支持 .NET Standard,所以我决定使用 Microsoft 的官方 System.Reflection.DispatchProxy
。动态代理基本上会在运行时动态创建一个类型。这个生成的类型会包装另一个类型并添加一些额外的逻辑。例如,它可以重写 virtual
方法,并在用于测量性能、处理异常等的块中包装它们。这种技术是方面编程方法论的一部分。
DispatchProxy
是一个简单的代理,它实现给定的接口,并允许程序员在 Invoke
方法中提供实现,每当调用接口的任何方法时都会调用该方法。
protected override object Invoke(MethodInfo targetMethod, object[] args)
这是我们映射 JSON 节点到对象的接口
public interface ISMuFLFontMetadata
{
[JsonProperty("fontName")]
string FontName { get; set; }
[JsonProperty("fontVersion")]
double FontVersion { get; set; }
[JsonProperty("engravingDefaults")]
Dictionary<string, double> EngravingDefaults { get; set; }
[JsonProperty("glyphBBoxes")]
IGlyphBBoxes GlyphBBoxes { get; set; }
[JsonProperty("glyphsWithAlternates")]
Dictionary<string, GlyphsWithAlternate> GlyphsWithAlternates { get; set; }
[JsonProperty("glyphsWithAnchors")]
GlyphsWithAnchors GlyphsWithAnchors { get; set; }
[JsonProperty("ligatures")]
Dictionary<string, Ligature> Ligatures { get; set; }
[JsonProperty("optionalGlyphs")]
OptionalGlyphs OptionalGlyphs { get; set; }
[JsonProperty("sets")]
Dictionary<string, GlyphSet> Sets { get; set; }
}
我们的目标是在访问每个 JSON 属性时反序列化它。如果一个属性不被使用,它将根本不会被反序列化。这是示例实现
public abstract class LazyLoadJsonProxy : DispatchProxy
{
public static object Create(Type interfaceType, string json)
{
var proxyType = typeof(LazyLoadJsonProxy<>).MakeGenericType(interfaceType);
var method = proxyType.GetTypeInfo().GetDeclaredMethods
(nameof(Create)).First(m => m.GetParameters().First().ParameterType == typeof(string));
return method.Invoke(null, new object[] { json });
}
}
public class LazyLoadJsonProxy<TInterface> : LazyLoadJsonProxy
{
private ConcurrentDictionary<string, object>
cache = new ConcurrentDictionary<string, object>();
private string jsonString;
public static TInterface Create(string json)
{
var proxy = Create<TInterface, LazyLoadJsonProxy<TInterface>>()
as LazyLoadJsonProxy<TInterface>;
proxy.jsonString = json;
return (TInterface)(object)proxy;
}
protected override object Invoke(MethodInfo targetMethod, object[] args)
{
if (cache.ContainsKey(targetMethod.Name)) return cache[targetMethod.Name];
var jsonPropertyAttribute = targetMethod.DeclaringType
.GetTypeInfo()
.GetDeclaredProperty(targetMethod.Name.Replace("get_", ""))?
.GetCustomAttribute<JsonPropertyAttribute>();
if (jsonPropertyAttribute == null) return TryAddDefaultValue
(targetMethod.Name, targetMethod.ReturnType);
using (var textReader = new StringReader(jsonString))
using (var reader = new JsonTextReader(textReader))
{
while (reader.Read())
{
if (reader.Path != jsonPropertyAttribute.PropertyName) continue;
var token = JToken.Load(reader);
if (targetMethod.ReturnType.GetTypeInfo().IsInterface)
{
var prop = token as JProperty;
if (prop != null)
{
var proxy = Create(targetMethod.ReturnType, prop.Value.ToString());
return TryAddValue(targetMethod.Name, proxy);
}
else
{
var proxy = Create(targetMethod.ReturnType, token.ToString());
return TryAddValue(targetMethod.Name, proxy);
}
}
var property = token as JProperty;
if (property != null)
return TryAddValue(targetMethod.Name,
property.Value.ToObject(targetMethod.ReturnType));
else
return TryAddValue(targetMethod.Name,
token.ToObject(targetMethod.ReturnType));
}
}
return TryAddDefaultValue(targetMethod.Name, targetMethod.ReturnType);
}
private object TryAddDefaultValue(string name, Type type)
{
var value = type.GetDefaultValue();
return TryAddValue(name, value);
}
private object TryAddValue(string name, object value)
{
cache.TryAdd(name, value);
return value;
}
}
本文附带的代码包含性能测量,但为了清晰起见,我已将其从本示例中删除。
每次访问属性时,JSON 序列化器只会反序列化与该属性关联的 JSON 文件部分。有些属性包含大对象,例如 GlyphBBoxes
包含 2000 多个节点。反序列化这样的属性非常耗时,所以我决定实现代理嵌套。如果属性类型是接口,则会创建一个新的代理,而不是一次性反序列化整个子树。
如何使用
这是老式反序列化 JSON 的方法(一次性全部反序列化)
var metadata = JsonConvert.DeserializeObject<SMuFLFontMetadata>(jsonString);
这是使用代理的方法
var metadata = LazyLoadJsonProxy<ISMuFLFontMetadata>.Create(jsonString);
在这两个示例中,元数据的类型都是 ISMuFLFontMetadata
,但实现不同。在第一个示例中,我们有一个正常的类 SMuFLFontMetadata
(用自动属性实现),在第二个示例中,我们有我们的代理对象。
测试
我创建了一些单元测试来衡量这种方法的性能
[TestMethod]
public void JsonDeserialziationTestWithoutProxy()
{
var assembly = typeof(SerializationTests).Assembly;
var resourceName = $"{typeof(SerializationTests).Namespace}.Assets.bravura_metadata.json";
using (var stream = assembly.GetManifestResourceStream(resourceName))
using (var reader = new StreamReader(stream))
{
string result = reader.ReadToEnd();
var sw = new Stopwatch();
sw.Start();
var traditionallyLoadedMetadata = JsonConvert.DeserializeObject<SMuFLFontMetadata>(result);
sw.Stop();
Debug.WriteLine(sw.Elapsed);
}
}
[TestMethod]
public void JsonDeserializationTestWithProxy()
{
var assembly = typeof(SerializationTests).Assembly;
var resourceName = $"{typeof(SerializationTests).Namespace}.Assets.bravura_metadata.json";
using (var stream = assembly.GetManifestResourceStream(resourceName))
using (var reader = new StreamReader(stream))
{
string result = reader.ReadToEnd();
var metadata = LazyLoadJsonProxy<ISMuFLFontMetadata>.Create(result);
var defaults = metadata.EngravingDefaults;
var bboxes = metadata.GlyphBBoxes;
var prop1 = bboxes.AccdnCombDot;
var prop2 = bboxes.WindTightEmbouchure;
var prop3 = bboxes.WindRimOnly;
var prop4 = bboxes.MensuralLongaVoidStemDownRight;
var prop5 = bboxes.AccSagittalFlat11LDown;
var prop6 = bboxes.NoteheadSquareBlackWhite;
var prop7 = bboxes.NoteheadWholeWithX;
var prop8 = bboxes.ElecMixingConsole;
var prop9 = bboxes.ElecPause;
var prop10 = bboxes.PictBeaterWoodTimpaniUp;
var prop11 = bboxes.AccdnCombLh2RanksEmpty;
var prop12 = bboxes.AccSagittalSharp5V13LUp;
var prop13 = bboxes.MensuralNoteheadLongaWhite;
var prop14 = bboxes.OrnamentTrill;
var prop15 = bboxes.OrnamentTremblementCouperin;
var prop16 = bboxes.AccSagittalSharp19SUp;
var prop17 = bboxes.NoteShapeRoundDoubleWhole;
var prop18 = bboxes.WindWeakAirPressure;
var prop19 = bboxes.WindRelaxedEmbouchure;
var prop20 = bboxes.AccdnCombLh2RanksEmpty;
var metadataAsProxy = (LazyLoadJsonProxy)metadata;
var bboxesAsProxy = (LazyLoadJsonProxy)bboxes;
var elapsedWithProxy = metadataAsProxy.TotalTimeSpentOnDeserialization +
bboxesAsProxy.TotalTimeSpentOnDeserialization;
Debug.WriteLine(elapsedWithProxy);
}
}
第一个示例测量一次性反序列化整个 JSON 时的性能。第二个示例创建一个代理对象并访问一些随机属性。Manufaktura.Controls
实际上使用的属性不超过几十个,所以我决定随机选择 20 个。
在实际情况中,同时访问 20 个属性的可能性非常小。我创建了一个测试来检查实际渲染中的性能
[TestMethod]
public void JsonDeserializationTestWithProxyOnRealExample()
{
var assembly = typeof(SerializationTests).Assembly;
var resourceName =
$"{typeof(SerializationTests).Namespace}.Assets.bravura_metadata.json";
var scoreResourceName =
$"{typeof(SerializationTests).Namespace}.Assets.JohannChristophBachFull3.0.xml";
using (var stream = assembly.GetManifestResourceStream(resourceName))
using (var scoreStream = assembly.GetManifestResourceStream(scoreResourceName))
using (var reader = new StreamReader(stream))
using (var scoreReader = new StreamReader(scoreStream))
{
var sw = new Stopwatch();
sw.Start();
string metadataJson = reader.ReadToEnd();
var metadata = LazyLoadJsonProxy<ISMuFLFontMetadata>.Create(metadataJson);
var scoreString = scoreReader.ReadToEnd();
var settings = new HtmlScoreRendererSettings();
settings.RenderSurface = HtmlScoreRendererSettings.HtmlRenderSurface.Svg;
settings.LoadSMuFLFont(metadata, "Bravura", 24, "/fakeuri");
settings.Scale = 1;
settings.CustomElementPositionRatio = 0.8;
settings.IgnorePageMargins = true;
var renderer =
new HtmlSvgScoreRenderer(new XElement("root"), "testCanvas", settings);
renderer.Render(scoreString.ToScore());
sw.Stop();
var metadataAsProxy = (LazyLoadJsonProxy)metadata;
Debug.WriteLine($"All rendering done in {sw.Elapsed}");
var deserTime = metadataAsProxy.GetTotalDeserializationTimeWithChildElements();
Debug.WriteLine($"Deserialization done in {deserTime}");
}
}
结果
这些是测试结果(以秒为单位)
不使用代理 | 启用代理后 | 使用嵌套代理 | 使用嵌套代理 | |
测量 1 | 4,97 | 3,93 | 1,68 | 1,51 |
测量 2 | 4,71 | 4,35 | 1,68 | 1,51 |
测量 3 | 4,71 | 4,00 | 1,69 | 1,49 |
Average | 4,80 | 4,10 | 1,68 | 1,50 |
此外,我的机器上测试 WPF 应用程序 Manufaktura.Controls.WPF.Test
的启动时间(附加调试器时)已从 12 秒减少到 5 秒。
结论
提出的方法在性能上提供了显著的改进,并允许程序员继续使用强类型模型。特定节点的反序列化被推迟到请求时才进行,这可能导致一些不可预测性。为了确保稳定的性能,程序员必须了解 JSON 文件的结构,并决定哪些子树将被一次性反序列化,哪些将被代理进行惰性反序列化。
您还必须牢记,如果您正在调试应用程序并尝试检查代理的内容,整个 JSON 将一次性被反序列化,因为所有属性都将被解析。这可能会在调试时导致一些性能问题。