65.9K
CodeProject 正在变化。 阅读更多。
Home

用 TDD 编写的通用映射器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.77/5 (13投票s)

2016年2月9日

CPOL

5分钟阅读

viewsIcon

21898

使用 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");
    }

这是更新后的 CustomerCustomerDto 对象。

    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日:初始版本
© . All rights reserved.