Umut Özel    About    Archive    Feed

BatMap

BatMap 🦇 C# dili ile geliştirilmiş yeni bir Mapper. Hemen akıllara gelen soru: “Neden bir Mapper daha?”

Standards

xkcd - standards

Neden olmasın?

Hemen açıklayayım

Diyelim ki aşağıdaki gibi bir sınıf yapınız var:

public class Order {
    public int Id { get; set; }
    public string OrderNo { get; set; }
    public double Price { get; set; }
    public List<OrderDetail> OrderDetails { get; set; }
}

public class OrderDetail {
    public int Id { get; set; }
    public int Count { get; set; }
    public double UnitPrice { get; set; }
    public Order Order { get; set; }
}

Sipariş ve Sipariş Detay sınıfları iki taraflı ilişkili. DTO sınıflarınız da aşağıdaki gibi olsun:

public class OrderDTO {
    public int Id { get; set; }
    public string OrderNo { get; set; }
    public List<OrderDetailDTO> OrderDetails { get; set; }
}

public class OrderDetailDTO {
    public int Id { get; set; }
    public int Count { get; set; }
    public double UnitPrice { get; set; }
    public OrderDTO Order { get; set; }
}

Bu ilişkiyi DTO sınıflarında da yukarıdaki gibi koruduğunuzda bir çok Mapper kayıt aşamasında StackOverflowException hatası üretir. Neden mi? Eğer bu araçların nasıl çalıştıklarını incelediyseniz sizin için otomatik kod ürettiklerini farketmişsinizdir, aşağıdaki gibi:

Expression<Func<Order, OrderDTO>> orderMapperExpression = o => new OrderDTO {
    Id = o.Id,
    OrderNo = o.OrderNo,
    // Price alanı DTO üzerinde olmadığından atlanır
    OrderDetails = o.OrderDetails.Select(od => new OrderDetailDTO {
        Id = od.Id,
        Count = od.Count,
        UnitPrice = od.UnitPrice,
        Order = new OrderDTO {
            ...
        }
    });
};

Sorunu görebildiniz mi? “…” ile devamını getirmediğim kısım Mapper’ın sonsuz döngüye girip StackOverflowException hatasına sebep olmasıyla sonuçlanıyor.

Bazı Mapper’lar kayıt aşamasında ilişkiler için temsili bir atama oluşturarak bu hatanın önüne geçebiliyor, ancak sadece kayıt sırasında. Gerçekten bir Mapping işlemi yapmak istediğinizde bu iki ilişkinin de dolu olması durumunda beklenmedik bir anda aynı şekilde StackOverflowException alıyorsunuz.

Çözüm

Bir Map süreci başladığında bu süreci temsil eden bir sınıfımız olursa (MapContext) ve bu sınıfı kullanarak kayıt işlemlerini aşağıdaki gibi yaparsak:

Expression<Func<Order, MapContext, OrderDTO>> orderMapperExpression = (o, mc) => new OrderDTO {
    Id = o.Id,
    OrderNo = o.OrderNo,
    OrderDetails = mc.MapToList<OrderDetail, OrderDetailDTO>(o.OrderDetails)
};

Expression<Func<OrderDetail, MapContext, OrderDetailDTO>> orderDetailMapperExpression = (od, mc) => new OrderDetailDTO {
    Id = o.Id,
    Count = o.Count,
    UnitPrice = o.UnitPrice,
    Order = mc.Map<Order, Order>(o.Order)
};

Kayıt sırasında tüm ilişki ağacını gezmeden çok daha basit ifadeler oluşturabiliyoruz.

Bu yapı da çalışma zamanı aynı hataya sebep olabilir, bir şekilde tekrar tekrar aynı objelerin oluşturulmasına engel olabilmeliyiz.

MapWithCache metodu

Referans koruyarak Map işlemi burada devreye giriyor:

Mapper.Map<OrderDTO>(order, preserveReferences: true);

Eğer Map işlemimizi yukarıdaki şekilde çağırırsak atama için otomatik oluşturduğumuz kodun biraz değiştirilmiş bir versiyonu çalışacak:

Expression<Func<Order, MapContext, OrderDTO>> orderMapperExpression = (o, mc) => {
    OrderDTO orderDTO;
    if (mc.GetFromCache(o, out orderDTO)) {
        return orderDTO;
    }
    orderDTO = new OrderDTO();
    mc.NewInstance(o, orderDTO);

    orderDTO.Id = o.Id;
    orderDTO.OrderNo = o.OrderNo;
    orderDTO.OrderDetails = mc.MapToList<OrderDetail, OrderDetailDTO>(o.OrderDetails);
    return orderDTO;
};

OrderDetail için oluşturulan kodu hayal gücünüze bırakıyorum.

Şimdi kodun nasıl çalıştığını inceleyelim:

Böylece istediğimiz karışıklıktaki sınıfları birbirine dönüştürebiliyoruz.

Performans?

Method Mean
BatMap :boom: 1.3309 ms :boom:
Mapster 2.0377 ms
SafeMapper 2.0371 ms
HandWritten 2.1175 ms
AutoMapper 2.6187 ms
TinyMapper 2.8119 ms
ExpressMapper 5.0814 ms
FastMapper 5.9380 ms

Premature optimization is the root of all evil! sözünü unutmayın. Burada yaptığım performans çalışmalarını biraz deneysel biraz da Mapper projelerinin başarısında bir numaralı kriter kabul edildiği için yaptım. Performans, projeniz ilerledikçe hedeflediğiniz süreleri alamazsanız düşünmeniz gereken bir konu olmalı.

Gördüğünüz gibi diğer Mapper’lara göre hatırı sayılır derecede fazla işlem yapıyoruz (preserveReferences aktif olmadığında bile), peki nasıl bir çoğundan daha hızlı çalışıyor?

Genel kullanım bu Expression’ları .Compile() metodu ile çalıştırılabilir metodlara derlemektir (en basit yöntem de budur). Ancak pek bilinmeyen nokta .Compile() ile derlenmiş metodun çalışabilmesi için Delegate çevirimi yapılır:

System.Reflection.Emit.DynamicMethod.CreateDelegate(class System.Type, object)

Bunun sebebine en basit haliyle C# dilinin mimari olarak bir Assembly-Type ilişkisi olmayan fonksiyona sahip olamamasıdır diyebiliriz (dinamik ve fonksiyonel dillerin aksine). Aşağıdaki gibi bir kod ile her Map metodunu yeni oluşturduğumuz bir tip içine static metod olarak eklersek ve Delegate üretme kısmını biz üstlenirsek ölçümlerime göre %30 civarı bir performans artışı gözlemleyebiliyoruz (bazı kaynaklarda %90 üzeri olduğundan bahsediliyor ancak efektif değerlerde bunu göremedim):

var assembly = AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName("BatMap_DynamicAssembly"), AssemblyBuilderAccess.Run);
var moduleBuilder = assembly.DefineDynamicModule("BatMap_DynamicModule");
var typeBuilder = moduleBuilder.DefineType("BatMap_DynamicType" + _typeCounter++, TypeAttributes.Public);
var methodBuilder = typeBuilder.DefineMethod("BatMap_DynamicMethod", MethodAttributes.Public | MethodAttributes.Static);
expression.CompileToMethod(methodBuilder);
var type = typeBuilder.CreateType();
return Delegate.CreateDelegate(delegateType, type.GetMethod("BatMap_DynamicMethod"));

Daha da performans

Diğer Mapper’lara göre daha fazla iş yaptığımızı söylemiştim, bu işlerden bir tanesi de çalıştırılacak tüm Map kodunu üretmek yerine MapContext üzerinden gerekli durumlarda çağrılar yapmak:

orderDTO.OrderDetails = mc.MapToList<OrderDetail, OrderDetailDTO>(o.OrderDetails);

Otomatik oluşturulan kodlardan yukarıdaki satırı OrderDetail ve OrderDetailDTO arasındaki Map kodunu bul ve bunu o.OrderDetails parametresiyle çağır şeklinde düşünebiliriz. Yani bizim elimizde her Map kaydı yapılmış tip ikilileri için Map tanımları (MapDefinition) olması gerekiyor ve bunlara çok hızlı erişebilmemiz gerekiyor. Akla ilk gelen bu iki tip için bir Tuple oluşturup bu kayıtları aşağıdaki gibi bir Dictionary ile saklamak:

Dictionary<Tuple<Type, Type>, IMapDefinition> mapDefinitions;

Bildiğiniz gibi Dictionary kayıtları saklarken Key nesnesinin GetHashCode metodundan dönen değeri indekslemek için kullanır. Dolayısıyla Tuple’ın GetHashCode uygulamasıyla uygulamasını kullanan bir struct yazarak (MapPair) ürettiğimiz int değeri kullanarak önemli bir performans artışı sağlayabiliyoruz. Aşağıda Dictionary ve HashCode üreten kodu görebilirsiniz:

Dictionary<int, IMapDefinition> mapDefinitions;

public static int GenerateHashCode(object o1, object o2) {
    var h1 = o1.GetHashCode();
    var h2 = o2.GetHashCode();
    return ((h1 << 5) + h1) ^ h2;
}

Not: Optimizasyonu biraz abarttım, aracı struct’ları (MapPair ve CachePair) sildim, onların yerine direk üretilecek olan int değerleri kullanıyorum. Böylece hem daha ufak bir bellek imzasına sahip oluyoruz hem de performansta %10’a kadar artış görebiliyoruz.

Bir diğer ufak optimizasyon noktası da bize verilen tipli bir listeyi başka tipli bir listeye çevirirken (MapToList) karşımıza çıkıyor. Bu durumla karşılaşan bir çok yazılımcı içgüdüsel olarak aşağıdaki kodu yazacaktır:

var mapDefinition = (IMapDefinition<TIn, TOut>)_mapper.GetMapDefinition(inType, outType);
var mapper = PreserveReferences ? mapDefinition.MapperWithCache : mapDefinition.Mapper;
return source.Select(i => mapper(i, this)).ToList();

Bu kodda çok ufak değişiklikler yaparak GitHub üzerinden erişebileceğiniz Benchmark projesini çalıştırdığımızda %50-%60 arası performans artışı sağlayabiliyoruz:

var sourceList = source as IList<TIn>;
if (sourceList != null) {
    // listeyi belirli adet için oluştur, bellek ayırma işi ortadan kalksın
    var count = sourceList.Count;
    // boş liste için GetMapDefinition çalıştırmadan dön
    retVal = new List<TOut>(count);
    if (count == 0) return retVal;

    var mapDefinition = (IMapDefinition<TIn, TOut>) _mapper.GetMapDefinition(inType, outType);
    var mapper = PreserveReferences ? mapDefinition.MapperWithCache : mapDefinition.Mapper;
    for (var i = 0; i < count;i++){
        retVal.Add(mapper(sourceList[i],this));
    }
}

Burada dikkat edilmesi gereken noktalar:

comments powered by Disqus