去年的时候写了一篇用使用c#强大的表达式树实现对象的深克隆. 最近又看到园子里的另外一篇吐槽automapper性能的文章。正好闲来无事,就想着看如果用Source Generator来实现深克隆,性能上会不会比表达式树更强劲呢,于是有了这篇文章。
之前使用表达式树深克隆的的代码可以实现类型相同/不同之间的克隆(UserEntity->UserDto/UserEntity->UserEntity),支持环状引用(即A的属性引用自身或者A的属性是类型B,类型B中有属性引用A)和可空->不可空转换(public int? id->public int id)。支持枚举转换(public enumXXX type->public int type/public int type->public enumXXX type),在实际生产环境中一直稳定使用,一直没有遇到过问题,但是对于性能上到底能够比手写深拷贝快多少,一直没有闲心去测,这一次正好干脆弄一个Source Generator的版本,再以手写深克隆为基准来实现。
测试环境为windows10、.net版本是9.0.8、引用的BenchmarkDotNet版本是0.15.2。
测试的Dto结构如下:

csharp
public class DtoTest
{
public int? Id { get; set; }
public string Name { get; set; }
public List<ChildDto<DtoTest>> Items { get; set; }
public List<string> Tags { get; set; }
public Dictionary<string, int?> Dict { get; set; }
public TestEnum TestEnum { get; set; }
public int? TestEnum2 { get; set; }
public DtoTest This { get; set; }
}
public class DtoTest2
{
public int Id { get; set; }
public string Name { get; set; }
public List<ChildDto<DtoTest2>?>? Items { get; set; }
public List<string> Tags { get; set; }
public Dictionary<string, int> Dict { get; set; }
public int? TestEnum { get; set; }
public TestEnum TestEnum2 { get; set; }
public DtoTest2 This { get; set; }
}
public class ChildDto<T>
{
public string Key { get; set; }
public int Value { get; set; }
public T Mother { get; set; }
}
public enum TestEnum
{
Take = 0,
Sale = 1,
Pull = 2
}

可以看到这份代码基本还是覆盖了大部分常见的情况,包含泛型、字典、可空转换、枚举等等。
测试时我的类型实例构造如下:

ini
_src = new DtoTest
{
Id = 123,
Name = "hello world",
Tags = new List<string> { "a", "b", "c" },
Items = new List<ChildDto<DtoTest>>
{
new ChildDto<DtoTest> { Key = "k1", Value = 42 },
new ChildDto<DtoTest> { Key = "k2", Value = 100 }
},
Dict = new Dictionary<string, int?> { ["x"] = 1, ["y"] = 2 },
TestEnum = TestEnum.Sale,
TestEnum2 = null
};
_src.This = _src;
_src.Items.Add(_src.Items[0]);
foreach (var item in _src.Items)
{
item.Mother = _src;
}

增加了循环引用和多次拷贝(items有三个子对象但是有两个指向同一个引用),基本上能够覆盖大部分深拷贝场景了。接下来就是运行后的截图
接下来是增加了一部分测试,确保深拷贝确实是递归到所有属性及其子属性的,而不是简单的浅拷贝(即修改原始对象属性会导致克隆的新对象的属性变化(比如属性的集合内容和属性自身的子属性随着变化)):
测试代码如下:

ini
var _src = new DtoTest
{
Id = 123,
Name = "hello world",
Tags = new List<string> { "a", "b", "c" },
Items = new List<ChildDto<DtoTest>>
{
new ChildDto<DtoTest> { Key = "k1", Value = 42 },
new ChildDto<DtoTest> { Key = "k2", Value = 100 }
},
Dict = new Dictionary<string, int?> { ["x"] = 1, ["y"] = 2 },
TestEnum = TestEnum.Sale,
TestEnum2 = null
};
_src.This = _src;
_src.Items.Add(_src.Items[0]);
foreach (var item in _src.Items)
{
item.Mother = _src;
}
var aaa = DeepClone.DeepCloneHelper.CopyTo<DtoTest, DtoTest2>(_src);
var bbb = InfrastructureBase.Object.ExtensionMapper<DtoTest, DtoTest2>.Map(_src);
var jsonOpts = new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.Preserve };
_src.Id = 222;
_src.Name = "bbb";
_src.Tags[0] = "d";
_src.Items.First().Key = "k0";
_src.Dict["x"] = -1;
Console.WriteLine(JsonSerializer.Serialize(_src, jsonOpts));
Console.WriteLine(JsonSerializer.Serialize(aaa, jsonOpts));
Console.WriteLine(JsonSerializer.Serialize(bbb, jsonOpts));
打印的json结果:

可以看到对src的属性修改并没有影响到SG和EXP生成的新对象。
最后是benchmark的情况如下:

从结果来看,Ratio这一行中以手写深拷贝(new dto2(){xxx= dto1.xxx...})作为基准值1的情况下,Source Generator大概是其2倍成本,而表达式树的版本大概在其5倍+左右的成本。cong Gen0 垃圾回收代以及Allocated每次执行方法分配的内存大小来看三者的成本差异不大,都在同一个区间。Alloc Ratio内存分配比来看SG和EXP的分配版本也落在X1~X2之间,属于基本可以接受的范畴。
具体的技术细节就不聊了,大家有兴趣可以到github上下载对应的代码测试,上面包含了完整的两种深克隆的实现:github.com/sd797994/SG...