通用映射器(Mapper)结合值元组(Value Tuples)和泛型测试。





5.00/5 (1投票)
本文演示了如何构建一个简单的通用映射器,该映射器可以将一个类的所有属性值复制到另一个类中,前提是两个类中的属性名称和类型都相同;文章还提出了一种构建通用测试的方法,该方法可用于该类的任何实例。
引言
映射(Mapping)是从生产者类到消费者类的过程。这通常是一个重复且会膨胀代码的操作,因此最好通过使用映射器来自动化该过程。此处演示的映射器使用反射来识别生产者类和消费者类中匹配的属性名称。它将属性值从生产者属性传输到消费者类中具有相同名称的属性。映射器通常与简单的数据传输对象(DTO)一起使用,以在项目模块之间传输值。映射从生产者模块到 DTO,然后从 DTO 到消费者模块。这提供了良好的关注点分离。只传输消费者模块所需的数据,模块之间无需相互了解;它们只需要了解 DTO。
它是如何工作的?
映射器使用 typeof
方法获取生产者类和消费者类的 Type
。然后,它使用 Type.GetProperties()
方法返回一个 PropertyInfo
类型的数组,其中包含该类的每个属性的元数据。PropertyInfo
类还包含用于获取和设置其相关属性值的方法。这些方法用于获取生产者类属性的值,并将其设置为消费者类属性的值。
Type classAType = typeof(ClassA);
PropertyInfo[] classAProps = classAType.GetProperties();
Type classBType = typeof(ClassB);
PropertyInfo[] classBProps = classBType.GetProperties();
映射器必须将 ClassA
中的 PropertyInfo
与 ClassB
中的 PropertyInfo
配对,其中属性名称相同。它有一个 Map
方法来执行实际的映射。该方法需要能够遍历配对,获取生产者配对成员的值并将其设置为消费者配对成员的值。一种存储配对的 neat 方法是使用 值元组(ValueTuple)。
值元组(ValueTuples)的插曲
关于值元组需要记住的一个重要事项是,它们是值类型而不是引用类型,因此您不能“new
它们”。它们的声明方式与声明带有参数但没有返回值和方法名的方**法**相同。因此,用于存储配对的元组数组将简单地是 (PropertyInfo classAInfo, PropertyInfo classBInfo)[]
。解构元组很容易,
var (classAInfo, classBInfo) = matchingProperties[1];
现在 classAInfo
和 classBInfo
可以作为独立于元组的变量使用。并非元组中的所有变量都需要赋值;您可以使用下划线字符来表示该变量将被丢弃。
var (classAInfo, _) = matchingProperties[1];
很容易将值元组与 system.Tuples
混淆。两者的一个重要区别是 ValueTuples
在任何声明中从不使用 Tuple
限定符。它们的使用方式就好像它们是匿名类型一样。另一个区别是 systen.Tuples
非常笨拙。
将事物连接起来
使用 Linq
查询可以将来自不同 PropertyInfo
数组的配对成员匹配起来,形成一个元组集合。
IEnumerable<(PropertyInfo classA, PropertyInfo classB)> matchingProperties =
from a in classAProps
join b in classBProps on a.Name equals b.Name
select (
a,
b
);
如果您想让外行感到困惑,可以一直使用流畅的语法编写查询。
IEnumerable<(PropertyInfo classAInfo, PropertyInfo classBInfo)> matchingProperties =
classAPropInfos.Join( // outer collection
classBPropInfos, // inner collection
a => a.Name, // outer key , match on the Name property
b => b.Name, // inner key, match on the Name property
(a, b) => (a, b) //project into a ValueTuple
);
查询返回一个 IEnumerable
,通过在闭括号后添加 .ToList()
,它可以返回一个 List<T>
。但是,由于只需要迭代集合,因此不需要 List<T>
的增强功能、复杂性和内存需求。
Map 方法
该方法非常简单。matchingProperties
可枚举对象被枚举,并在遍历过程中获取和设置属性值。
public void Map(ClassA producer, ClassB consumer)
{
foreach (var (classAInfo, classBInfo) in matchingProperties)
{
classBInfo.SetValue(consumer, classAInfo.GetValue(producer));
}
}
将可枚举对象的构造放在映射器的构造函数中是最好的选择,这样 Map
函数就不必在每次调用该方法时重建它。尽量减少调用使用反射的方法是很值得的,因为它们往往有点慢。
强制映射
Map
方法有点局限,因为它只会匹配同名的属性。在某些情况下,能够映射具有不同名称但类型相同的属性(例如 long Id
和 long RecordNumber
)是很有益的。要做到这一点,只需要有一个方法,该方法将包含要配对的属性名称的新元组添加到 matchingProperties
集合中。需要将 matchingProperties
变量转换为列表,以便可以向其添加元素。
public void ForceMatch(string propNameA, string propNameB)
{
var propA = classAProps.FirstOrDefault(a => a.Name == propNameA) ;
var propB = classBProps.FirstOrDefault(a => a.Name == propNameB);
//....check for argument exceptions
matchingProperties.Add((propA, propB));
}
这种方法存在一个问题——它使用了“魔术字符串”作为参数。魔术字符串是指字符串的内容会影响方法功能的字符串。这些字符串本应是属性的名称,但编译器会欣然接受任何无意义的字符串内容,只有在“运行时”才能发现错误。更糟糕的是,如果实际属性名称发生更改,您将不得不翻遍代码寻找对该属性的文字字符串引用并进行修改。解决此问题的方法是在调用方法时使用 nameof
运算符。
mapper.ForceMatch(nameof(student.ForeName), nameof(dto.FirstName));
nameof
看起来像一个方法调用,但它实际上是一个编译器指令,用于从类定义中查找属性的名称并在编译后的代码中使用它。
排除匹配
有时能够从匹配属性列表中删除某个匹配项会很有帮助。通过简单地搜索列表即可轻松实现此目的。
public bool Exclude(string propName)
{
var target = matchingProperties.FirstOrDefault(p => p.classA.Name == propName||
p.classB.Name==propName);
return matchingProperties.Remove(target);
}
通用映射器(Mapper)
到目前为止,映射器使用了两个特定的类:ClassA
和 ClassB
。通过使用 泛型(Generics),可以定义映射器来接受任何两个类。Mapper
是使用类的占位符定义的。按照惯例,这些占位符的前面会加上 T
字符。因此,类定义以
public class Mapper<TClassA, TClassB> : IMapper<TClassA, TClassB>
where TClassA : class
where TClassB : class
where
语句定义了 TClassA
和 TClassB
对象必须是类的约束。要指示编译器使用类 Student
和 Dto
,请像这样实例化映射器。
Mapper<Student, Dto> mapper = new Mapper<Student, Dto>();
这是 Mapper
的完整定义
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Mapper
{
public class Mapper<TClassA, TClassB> : IMapper<TClassA, TClassB>
where TClassA : class
where TClassB : class
{
private readonly List<(PropertyInfo classA, PropertyInfo classB)> matchingProperties;
private readonly PropertyInfo[] classAProps;
private readonly PropertyInfo[] classBProps;
public Mapper()
{
Type classAType = typeof(TClassA);
classAProps = classAType.GetProperties();
Type classBType = typeof(TClassB);
classBProps = classBType.GetProperties();
matchingProperties =
classAProps.Join( // outer collection
classBProps, // inner collection
a => a.Name, // outer key
b => b.Name, // inner key
(a, b) => (a, b)//project into ValueTuple
).ToList();
}
public void Map(TClassA producer, TClassB consumer)
{
foreach (var (classAInfo, classBInfo) in matchingProperties)
{
if (classAInfo.PropertyType.FullName != classBInfo.PropertyType.FullName)
throw new InvalidOperationException(
$"{Constants.NoMatchPropTypes} {classAInfo.Name}, {classBInfo.Name}");
classBInfo.SetValue(consumer, classAInfo.GetValue(producer));
}
}
public void Map(TClassB producer, TClassA consumer)
{
foreach (var (classAInfo, classBInfo) in matchingProperties)
{
if (classAInfo.PropertyType.FullName != classBInfo.PropertyType.FullName)
throw new InvalidOperationException(
$"{Constants.NoMatchPropTypes} {classBInfo.Name}, {classAInfo.Name}");
classAInfo.SetValue(consumer, classBInfo.GetValue(producer));
}
}
public void ForceMatch(string propNameA, string propNameB)
{
var propA = classAProps.FirstOrDefault(a => a.Name == propNameA);
var propB = classBProps.FirstOrDefault(a => a.Name == propNameB);
if (propA == null)
throw new ArgumentException($"{Constants.PropNullOrMissing} {nameof(propNameA)}");
if (propB == null)
throw new ArgumentException($"{Constants.PropNullOrMissing} {nameof(propNameB)}");
if (propA.PropertyType.FullName != propB.PropertyType.FullName)
throw new ArgumentException($"{Constants.NoMatchPropTypes} {propNameA}, {propNameB}");
matchingProperties.Add((propA, propB));
}
public bool Exclude(string propName)
{
var target = matchingProperties.FirstOrDefault(p =>
p.classA.Name == propName || p.classB.Name == propName);
return matchingProperties.Remove(target);
}
public int GetMappingsTotal => matchingProperties.Count;
}
}
单元测试泛型方法。
在测试通用映射器时,重要的是测试也要是泛型的,以便可以使用类的任何特定实例来运行它们。为了在泛型测试和测试实现之间实现良好的分离,泛型测试被放置在一个抽象的基类中,并且需要运行具有特定映射器实现的测试的方法在派生类中定义。基类定义如下:
public abstract class MapperTestsGeneric<TClassA, TClassB>
where TClassA : class
where TClassB : class
....
派生类定义如下:
public class MapperUnitTests : MapperTestsGeneric<ClassA, ClassB>
{
...
TClassA
和 TClass
占位符已被替换为特定类 ClassA
和 ClassB
。当类编译时,泛型基类将使用这些类。为了了解测试是如何构建的,请查看 ForceMatch
方法的单元测试。
[TestMethod]
public void ForceMatchAddsATupleToMatchingProperties()
{
Mapper<TClassA, TClassB> mapper = new Mapper<TClassA, TClassB>();
(string NameA, string NameB) = Get2PropNamesToForceMatch();
int mappings = mapper.GetMappingsTotal;
mapper.ForceMatch(NameA, NameB);
Assert.IsTrue(mappings + 1 == mapper.GetMappingsTotal);
}
这测试了 ForceMatch
方法的功能。ForceMatch
所做的就是将一个元组添加到 matchingProperties
列表中。测试使用 Get2PropNamesToForceMatch
方法来提供要匹配的两个属性的名称。这些属性的名称取决于映射器使用的特定类。因此,该方法在基类中定义,但在派生类中被覆盖。
//In the base test class
protected abstract (string NameA, string NameB) Get2PropNamesToForceMatch();
//In the derived test class
protected override (string NameA, string NameB) Get2PropNamesToForceMatch()
{
return (nameof(ClassA.Code), nameof(ClassB.CodeName));
}
ForceMatch
方法应该在找不到属性名称匹配时抛出异常,因此对此的测试将如下所示:
[TestMethod] //test fail message
[ExpectedException(typeof(ArgumentException), "Different property Types were allowed")]
public void ForceMatchThrowsArgumentExceptionWhenMatchTypesDoNotMatch()
{
Mapper<TClassA, TClassB> mapper = new Mapper<TClassA, TClassB>();
(string NameA, string NameB) = Get2PropNamesToForceMatchFromPropsWithDifferentTypes();
mapper.ForceMatch(NameA, NameB);
}
测试 Map 方法
单元测试应该只有一个断言语句。Map
函数测试稍微有点“作弊”,有一个断言,但断言辅助方法测试多个属性。如果所有属性都按预期映射,则它们的测试通过;如果一个或多个属性未按预期映射,则测试失败。我的偏好是停在这个层面,而不是为每个属性设置单独的测试。单元测试有一个危险,那就是深入代码会变成测试编译器是否正常工作,而不是测试方法的实际功能。这是用于测试具有匹配名称的属性是否从 TClassA
映射到 TClassB
的泛型测试。
[TestMethod]
public void MapAtoBMapsSameNamePropertyValuesFromAtoB()
{
Mapper<TClassA, TClassB> mapper = new Mapper<TClassA, TClassB>();
TClassA a = CreateSampleClassA();
TClassA unmappedA = CreateSampleClassA();
TClassB b = CreateSampleClassB();
mapper.Map(a, b);
Assert.IsTrue(AreSameNamePropsMappedFromAtoB(b, unmappedA));
}
辅助方法在派生类中被覆盖。
protected override bool AreSameNamePropsMappedFromAToB(ClassB b, ClassA unmappedA) =>
b.Name == unmappedA.Name &&
b.Age == unmappedA.Age &&
b.Cash == unmappedA.Cash &&
b.Date == unmappedA.Date &&
b.Employee == unmappedA.Employee;
使用 ClassA
的另一个实例而不是作为 Map
方法参数的实例的原因是,要确保映射是从 ClassA
到 ClassB
。如果使用了作为 Map
方法参数的 ClassA
的实例,即使映射是从 ClassB
到 ClassA
,测试也会通过。
结论
使用反射、Linq 查询和值元组可以轻松开发一个简单的映射器。泛型的使用增加了映射器的效用价值,因为它允许映射器与生产者和消费者类的任何给定实例一起使用。最后,通过定义一个抽象基类来保存泛型测试,并使用派生类包含泛型类的特定实例的实现细节,可以简化通用类的单元测试。
参考文献
历史
- 2019 年 10 月 2 日:初始版本