用 TDD 编写的通用映射器






4.77/5 (13投票s)
使用 TDD 编写自己的简单通用映射器。
引言
本文将逐步展示如何编写自己的映射器。
采用先测试后开发的编写方式,可以更容易地理解映射器实际功能以及如何从头开始实现复杂功能。
背景
什么是映射器以及为什么要使用它们?
映射器用于将数据从一个对象重写到另一个对象。通常,这些对象是同一个业务对象,只是属于不同的应用程序层。
在简单的小型应用程序中,通常不需要映射器。对象之间的数据重写是手动完成的,因为通常这些对象完全不同。例如
var email = new Email
{
From = user.Name,
Topic = topicTxt.Text,
Body = bodyTxt.Text,
Date = DateTime.Now
};
但在具有更多抽象层的更大应用程序中,有许多不同的类代表同一个业务实体。当数据访问层(EntityFramework
中的实体)、数据事务对象(WCF 传递的对象)或 ViewModel
(视图层显示的用于例如 MVC 应用程序的对象)结合时,就会出现这种情况。按这些层划分的应用程序可能看起来像这样
var customerDto = new CustomerDto
{
Id = customerEntity.Id,
Name = customerEntity.Name,
Surname = customerEntity.Surname
};
var customerViewModel = new CustomerViewModel
{
Id = customerDto.Id,
Name = customerDto.Name,
Surname = customerDto.Surname
};
当然,构造函数可以接受另一个对象或工厂方法来提供帮助。但代码中仍然会有一些初始化工作。
映射器可以“神奇地”完成这项工作 :)
var customerDto = mapper.Map<CustomerDto>(customerEntity);
var customerViewModel = mapper.Map<CustomerViewModel>(customerDto);
让这项无聊而愚蠢的工作自动完成,这样我们就可以节省时间去解决真正的问题。 ;)
TDD 代表测试驱动开发。其理念是首先编写单元测试,然后编写满足测试的实现。
编写测试是巨大的优势。它使代码更能抵抗修改。它还可以节省应用程序测试的时间,尤其是在有许多路径需要检查或测试应用程序耗时的情况下。
在我看来,TDD 还有其他的好处。
有时,操作结果很简单,但实现却很模糊,乍一看很难编写代码。
另一个好处,尤其是在 API 方面,这些单元测试是对 API 的基本用法。这意味着在编写测试时,已经知道 API 的使用方式。我认为有很多工具可以做很多很棒的事情,但它们真的很难理解和使用。换句话说,如果你不是 API 的用户,你就不关心别人会如何使用它。这是错误的。 :)
Using the Code
对于那些不熟悉 TDD 甚至不熟悉单元测试的人,我们的旅程将从创建一个新项目开始。
在 Visual Studio 中,选择文件 -> 新建...
在“新建项目”窗口中,搜索“单元测试项目”(可在模板 -> Visual C# -> 测试 中找到)。
为项目键入名称(例如,SandboxTests
)并创建项目。
现在创建一个新的单元测试类。
右键单击项目,选择添加 -> 单元测试...
然后将文件名更改为GenericMapperTests.cs。
Visual Studio 已自动准备好编写单元测试所需的所有引用。所以...
让我们编写第一个单元测试。
[TestClass]
public class GenericMapperTests
{
[TestMethod]
public void ShouldMapPropertiesFromOneObjectToAnother()
{
// Given
// When
// Then
}
}
我们想做的是创建一个对象并对其进行赋值。然后创建相同的对象并将值从第一个重写到第二个。
完整的测试应如下所示
[TestMethod]
public void ShouldMapPropertiesFromOneObjectToAnother()
{
// Given
var customer = new Customer
{
Id = 1,
Name = "Miłosz",
Surname = "Wieczorek"
};
var newCustomer = new Customer();
var mapper = new GenericMapper();
// When
mapper.Map(customer, newCustomer);
// Then
Assert.AreEqual(customer.Id, newCustomer.Id, "Id");
Assert.AreEqual(customer.Name, newCustomer.Name, "Name");
Assert.AreEqual(customer.Surname, newCustomer.Surname, "Surname");
}
通过编写此测试,我们生成了一个基本的 Customer
对象以及我们的目标——GenericMapper
类。
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
}
public class GenericMapper
{
public void Map(Customer customer, Customer newCustomer)
{
}
}
正如本文标题所示,它应该是一个通用映射器。所以让我们修正方法签名。
顺便说一句,让我们进行一些初步的实现来满足测试。
public class GenericMapper
{
public void Map<T>(T from, T to)
{
var type = typeof(T);
var properties = type.GetProperties();
foreach (var property in properties)
{
property.SetValue(to, property.GetValue(from));
}
}
}
没有火箭科学,但这是满足我们测试的基本实现。
现在映射器将属性从一个对象映射到另一个对象,但这两个对象需要是相同的类型。让我们稍微改进一下我们的映射器,以便它可以将值从一种类型映射到另一种类型,因为这是映射器的真正目标。
正如我们所知映射器应该做什么,编写第二个测试很明显。
[TestMethod]
public void ShouldMapPropertiesFromOneObjectToAnotherWithDifferentTypes()
{
// Given
var customer = new Customer
{
Id = 1,
Name = "Miłosz",
Surname = "Wieczorek"
};
var newCustomer = new CustomerDto();
var mapper = new GenericMapper();
// When
mapper.Map(customer, newCustomer);
// Then
Assert.AreEqual(customer.Id, newCustomer.Id, "Id");
Assert.AreEqual(customer.Name, newCustomer.Name, "Name");
Assert.AreEqual(customer.Surname, newCustomer.Surname, "Surname");
}
顺便说一句,我们创建了一个新对象,一个 CustomerDto
,它看起来与 Customer
完全相同。
public class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
}
不幸的是,这些更改会导致编译错误,因为映射器中的 Map
方法不允许传递两种不同的类型。
让我们改进 Map
方法的实现,以便我们的两个测试都能通过。
public void Map<TFrom, TResult>(TFrom from, TResult to)
{
var typeFrom = typeof(TFrom);
var typeTo = typeof(TResult);
var propertiesFrom = typeFrom.GetProperties();
var propertiesTo = typeTo.GetProperties();
foreach (var propFrom in propertiesFrom)
{
foreach (var propTo in propertiesTo)
{
if (propTo.Name == propFrom.Name &&
propTo.PropertyType == propFrom.PropertyType)
{
propTo.SetValue(to, propFrom.GetValue(from));
}
}
}
}
现在运行测试,然后……是的,我们的两个测试都通过了。 :)
但是这段代码不是很漂亮……迭代两个嵌套集合……看起来不好:) 让我们重构一下并使用 LINQ。
public void Map<TFrom, TResult>(TFrom from, TResult to)
{
var typeFrom = typeof(TFrom);
var typeTo = typeof(TResult);
var properties = typeFrom.GetProperties()
.Join(typeTo.GetProperties(), f => f.Name, t => t.Name, (f, t) => new
{
propFrom = f,
propTo = t
});
foreach (var prop in properties.Where
(p => p.propFrom.PropertyType == p.propTo.PropertyType))
{
prop.propTo.SetValue(to, prop.propFrom.GetValue(from));
}
}
现在好多了。
thanks to Unit Tests, we can easily check if our refactoring doesn't break the Map
method.
测试通过了,所以让我们改进一下映射器。 :)
基本的映射器实现工作正常,现在让我们考虑一下我们的映射器应该具有哪些功能。
我希望我的映射器不要写入只读字段。我也希望在相反的情况下实现相同效果:值不应该从只写字段重写。
让我们准备测试。
[TestMethod]
public void ShouldNotMapReadonlyAndWriteOnlyFields()
{
// Given
var customer = new Customer
{
Id = 1,
DateOfBirth = new DateTime(1990, 01, 01),
Age = 26,
UpdatedBy = 15
};
var customerDto = new CustomerDto();
var mapper = new GenericMapper();
// When
mapper.Map(customer, customerDto);
// Then
Assert.AreEqual(customer.DateOfBirth, customerDto.DateOfBirth, "Date of birth");
Assert.AreNotEqual(customer.Age, customerDto.Age, "Age");
Assert.AreEqual(default(int), customerDto.UpdatedBy, "UpdatedBy");
Assert.AreNotEqual(customer.GetUpdatedBy(), customerDto.UpdatedBy, "Age");
}
这是更新后的 Customer
和 CustomerDto
对象。
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
public DateTime DateOfBirth { get; set; }
public int Age { get; set; }
public int UpdatedBy
{
private get;
set;
}
public int GetUpdatedBy()
{
return UpdatedBy;
}
}
public class CustomerDto
{
public int Id { get; set; }
public string Name { get; set; }
public string Surname { get; set; }
public DateTime DateOfBirth { get; set; }
public int Age
{
get;
private set;
}
public int UpdatedBy { get; set; }
}
现在让我们让测试通过。 :)
public void Map<TFrom, TResult>(TFrom from, TResult to)
{
var typeFrom = typeof(TFrom);
var typeTo = typeof(TResult);
var properties = typeFrom.GetProperties()
.Join(typeTo.GetProperties(), f => f.Name, t => t.Name, (f, t) => new
{
propFrom = f,
propTo = t
});
foreach (var prop in properties.Where
(p => p.propFrom.PropertyType == p.propTo.PropertyType &&
p.propFrom.CanRead && p.propTo.CanWrite &&
p.propFrom.GetMethod.IsPublic && p.propTo.SetMethod.IsPublic))
{
prop.propTo.SetValue(to, prop.propFrom.GetValue(from));
}
}
测试通过了,但代码看起来很难看……需要一点重构 :)
public class GenericMapper
{
public void Map<TFrom, TResult>(TFrom from, TResult to)
{
var typeFrom = typeof(TFrom);
var typeTo = typeof(TResult);
var properties = typeFrom.GetProperties()
.Join(typeTo.GetProperties(), f => f.Name, t => t.Name, (f, t) => new
{
propFrom = f,
propTo = t
});
foreach (var prop in properties.Where
(p => CanRewriteValue(p.propFrom, p.propTo)))
{
prop.propTo.SetValue(to, prop.propFrom.GetValue(from));
}
}
private bool CanRewriteValue(PropertyInfo propFrom, PropertyInfo propTo)
{
return propFrom.PropertyType == propTo.PropertyType &&
propFrom.CanRead &&
propTo.CanWrite &&
propFrom.GetMethod.IsPublic &&
propTo.SetMethod.IsPublic;
}
}
好多了。 :)
好的,我们有了一个不错的、可用的通用映射器。但正如我们在单元测试中所看到的,这个映射器使用起来很不方便。
首先,我希望它成为静态
的。这将影响现有测试,但这是一个小的改动。
其次,我希望我的映射器创建一个具有已映射属性的新对象实例。为此,需要一个新的测试。
我认为这些将是使我们的映射器更有用的绝佳功能。
带对象创建的新测试。
[TestMethod]
public void ShouldCreateNewObjectWithAlreadyMappedProperties()
{
// Given
var customer = new Customer
{
Id = 4,
Name = "John",
Surname = "Doe"
};
// When
var newCustomer = GenericMapper.Create<Customer, CustomerDto>(customer);
// Then
Assert.AreEqual(customer.Id, newCustomer.Id, "Id");
Assert.AreEqual(customer.Name, newCustomer.Name, "Name");
Assert.AreEqual(customer.Surname, newCustomer.Surname, "Surname");
}
以及实现。很简单,但很有用。
public static TResult Create<TFrom,
TResult>(TFrom source) where TResult : class, new()
{
var result = new TResult();
Map(source, result);
return result;
}
就是这样!
简单的通用映射器,带有几个单元测试。可以轻松地移到另一个项目,因为我们知道它的作用、行为方式等等。
通用映射器可以扩展许多功能。只需要编写一个测试,然后编写满足测试的实现。最后,可能需要一些重构。 :)
历史
- 2016年2月9日:初始版本