部門朋朋前陣子忙著把一卡車陳年專案升級到 .Net 8,原本吃著火鍋唱著歌、升級升得好好的,眼看三下五除二就要升級完畢,但就在這一片祥和安寧之中,突然有個專案升級後 API 就原地死去,只留下 AutoMapper 的錯誤訊息,兇案調查也就此開始……


俗話說得好:不看錯誤訊息的偵探不是好偵探。

讓我們先看看以下片段(為保護當事欄位,已做混淆處理):

---> AutoMapper.AutoMapperMappingException: Error mapping types.

Mapping types:
BooDataModel -> BooDto
NiceProject.Repositories.DataModel.BooDataModel -> NiceProject.Services.Dto.BooDto

Type Map configuration:
BooDataModel -> BooDto
NiceProject.Repositories.DataModel.BooDataModel -> NiceProject.Services.Dto.BooDto

Destination Member:
ProductOrder

---> AutoMapper.AutoMapperMappingException: Missing type map configuration or unsupported mapping.

Mapping types:
OrderedEnumerable`1 -> Int32
System.Linq.OrderedEnumerable`1[[Product...]] -> System.Int32

對第一現場進行勘驗,發現幾個值得注意的點:

  • 來源類別並沒有 ProductOrder 這個欄位,但目標類別有
  • MapperConfiguration 並沒有明確要求 Ignore ProductOrder,但原本的版本運行得好好的,升級 .Net 後才出錯

現在我們已經收集了案發現場的線索,但我們還需要回答一個關鍵的問題:錯誤訊息中的 AutoMapper 在案發時究竟做了什麼?

AutoMapper 的貼心 Flattening

要繼續說明下去,就要先從 AutoMapper 的一些貼心小舉動開始說起。

AutoMapper 作為型別轉換工具,最吸引人的賣點就是「基於慣例的映射(Convention-based mapping)」這代表它預先處理了一些常見的情境,讓我們可以無腦使用。例如從 FooName 直接映射到 fooname(自動處理掉大小寫問題)、或是某些型別會自動幫忙轉型等等。

這些慣例在大多時候是很實用的,而且也非常省事。但在一些情況可能就會變成未爆彈,例如在 之前的 AutoMapper 筆記 介紹過的「2.65 變成 3」之謎:

public class Boo
{
    public double Val { get; set; }
}

public class Foo
{
    public int Val { get; set; }
}

public Foo Sut()
{
    var boo = new Boo
    {
        Val = 2.65
    };

    var config = new MapperConfiguration(cfg => cfg.CreateMap<Boo, Foo>());
    var mapper = config.CreateMapper();
    
    var foo = mapper.Map<Foo>(boo);
    return foo; // 3
}

這次要介紹的就是 AutoMapper 的貼心慣例之一:Flattening

When you configure a source/destination type pair in AutoMapper, the configurator attempts to match properties and methods on the source type to properties on the destination type. If for any property on the destination type a property, method, or a method prefixed with “Get” does not exist on the source type, AutoMapper splits the destination member name into individual words (by PascalCase conventions).

當您在 AutoMapper 中配置來源/目標類型對時,配置器會嘗試將來源類型上的屬性和方法與目標類型上的屬性進行匹配。如果目標類型的任何屬性在來源類型上不存在相應的屬性、方法或以 “Get” 為前綴的方法,AutoMapper 會將目標成員名稱拆分為單獨的單詞(根據 PascalCase 規範)。

Flattening — AutoMapper documentation

簡單來說,AutoMapper 在找不到目標的時候,會嘗試把屬性名稱拆成幾個單字,去抓看看有沒有符合的來源。例如 ItemId 就會拆成 ItemId,再去來源找看看有沒有這兩個東西。

用一組 LinqPad 腳本能更直觀理解這個動作:

public class Source
{
    public SourceItem Item { get; set; }
}

public class SourceItem
{
    public int Id { get; set; }
}

public class Target 
{
    public int ItemId { get; set; }
}

void Main()
{
    var source = new Source
    {
        Item = new SourceItem
        {
            Id = 256 // 準備了 source.Item.Id
        }
    };

    var config = new MapperConfiguration(cfg => cfg.CreateMap<Source, Target>());
    var mapper = config.CreateMapper();

    var target = mapper.Map<Target>(source);
    target.ItemId.Dump(); // target.ItemId = 256, 從 source.Item.Id 抓來的
}

現在我們已經知道了 AutoMapper 會幫忙試試看組裝欄位來兜出一個結果,跟我那個嘗試把夢到的東西跟樂透號碼關聯起來的鄰居一樣。但是在來源型別結構多層又複雜,我們又想賦值到扁平化後的型別的時候,Flattening 還是蠻有用的。

那麼,這個好棒棒慣例跟這次的案情又有什麼關係呢?讓我們看看一個轉型失敗的例子:

public class Source
{
    public SourceItem Item { get; set; }
}

public class SourceItem
{
    public string Id { get; set; }
}

public class Target 
{
    public int ItemId { get; set; }
}

void Main()
{
    var source = new Source
    {
        Item = new SourceItem
        {
            Id = "NaN" // Not a Number 噠!
        }
    };

    var config = new MapperConfiguration(cfg => cfg.CreateMap<Source, Target>());
    var mapper = config.CreateMapper();

    var target = mapper.Map<Target>(source);  // 爆炸!
    target.ItemId.Dump();
}
Error mapping types.

Mapping types:
Source -> Target
UserQuery+Source -> UserQuery+Target

Type Map configuration:
Source -> Target
UserQuery+Source -> UserQuery+Target

Destination Member:
ItemId

沒錯,錯誤訊息跟我們開頭看見的非常相似,都是型別映射錯誤!

走向破案

現在我們已經有了主要手法的假設了,但這個案子還有另一個疑點:升級前的映射沒有出錯,升級後就出錯了。

馬上進入粗暴推理階段:我們知道 AutoMapper 會嘗試去找名稱有關的東西來試試看,而開頭場景中轉型失敗的欄位名稱是 ProductOrder,也就是說 AutoMapper 會嘗試去抓 ProductOrder

正好,來源型別的確有一個 IEnumerable<Product> Product 的成員。但是 Order 又是哪裡來的呢?為什麼升級前找不到、升級後反而找到了呢?

噹噹!讓我們看看 .Net 7 Preview 的介紹:Order and OrderDescending

System.Linq now has the methods Order and OrderDescending, which are there to order an IEnumerable according to T.

System.Linq 現在具有方法 OrderOrderDescending ,可用於根據 T 對 IEnumerable 進行排序。

這樣就有了 Order 這片拼圖,我們的假設也就很明確了:因為升級之後多了 Order() 這個東東,成功滿足了 Flattening 的條件,原本找不到映射來源會保留預設值的 AutoMapper,在看見了 Order 之後覺得「這名字好像有關ㄟ,來試試看」,進而發生了 Error mapping types.

馬上用這組條件測試看看:

public class Source
{
    public IEnumerable<Product> Product { get; set; }
}

public class Product
{
    
}

public class Target 
{
    public int ProductOrder { get; set; }
}

void Main()
{
    var source = new Source
    {
        Product = new List<Product> {}
    };

    var config = new MapperConfiguration(cfg => cfg.CreateMap<Source, Target>());
    var mapper = config.CreateMapper();

    var target = mapper.Map<Target>(source); // Error mapping types!
    target.ProductOrder.Dump(); 
}
Error mapping types.

Mapping types:
Source -> Target
UserQuery+Source -> UserQuery+Target

Type Map configuration:
Source -> Target
UserQuery+Source -> UserQuery+Target

Destination Member:
ProductOrder

Missing type map configuration or unsupported mapping.

Mapping types:
OrderedEnumerable`2 -> Int32
System.Linq.OrderedEnumerable`2[[UserQuery+Product...]] -> System.Int32

Destination Member:
ProductOrder

重現成功,收工下班。


備註:隨著 AutoMapper 新版本開始收費,我們也正在緩慢地進行搬遷。
如果你也在搬家,這些文章推薦給你: