Entity Framework vs. NHibernate - eli miksi NHibernate rokkaa edelleen

Törmäsin Entity Frameworkiin vuosien 2008/2009 vaihteessa ja siitä tuli pian de facto -datakerros omiin projekteihini. 2010 olin tekemässä projektia MySQL:n päälle, mutta Entity Designer ei oikein toiminut sen kanssa yhteen Visual Studio Expressissä, joten päädyin etsimään vaihtoehtoa. Ja sen tarjosi NHibernate. Vaikeahan se oli... visuaaliseen työkaluun verrattuna. Xml-mäppäykset, LINQ-tuki erillisen kirjaston kautta ja sekin varsin puutteellinen, kummallinen konfiguraatio. Sitten löysin Fluent NHibernaten ja xml-mäppäykset olivat poissa. Myös LINQ-tuki integroitiin myöhemmin samaan pakettiin NHibernaten kanssa ja se on parantunut koko ajan. Opin rakastamaan POCO-luokkia. Entity Designerin tuottama ruma koodi alkoi puistattaa. Hylkäsin EF:n ja NHibernatesta tuli uusi de facto. Code firstin myötä kokeilin EF4:ää, mutta sillä ei pystynyt mäppäämään vaikeita skenaarioita. Oikeastaan jopa FK:n nimeäminen taisi olla sille ylivoimainen haaste. En koskenut siihen tuon jälkeen. Nyt EF5 tarjosi tilaisuuden korjata tilanne. Samalla iskulla kokeilin myös NHibernaten mapping-by-code -systeemiä. Testikantana toimi Adventure Works LT 2012

Mäppäykset

Tein mäppäykset ensiksi NHibernatelle. Siinä meni n. 5 minuuttia per taulu. Muutaman seikan jouduin tarkistamaan googlella. Käännös meni läpi, mutta testiajo paljasti, että tein jotain väärin. Google pelasti jälleen ja testiajo onnistui. Seuraavaksi tein mäppäykset EF:lle. Jälleen jouduin turvautumaan netin ohjeisiin, mutta sain homman pakettiin. Jopa nopeammin kuin NH:n tapauksessa. Sitten totuus iski vasten kasvoja. EF:n mäppäysmahdollisuuksien kattavuus on edelleen huono. Ongelmaksi muodostui komposiitti-id, jossa käytetään FK:ta. Esimerkiksi taulu CustomerAddress, jossa on kentät CustomerId ja AddressId. Sitä vastaamaan tein luokan

public class CustomerAddress
{
  public virtual Customer Customer { get; set; }
  public virtual Address Address { get; set; }
  public virtual string AddressType { get; set; }
  public virtual DateTime ModifiedDate { get; set; }
}
NH:n mäppäys toimi kivuttomasti pätkällä

ComposedId(id =>
{
  id.ManyToOne(a => a.Customer, m =>
  {
    m.Column("CustomerID");
    m.NotNullable(true);
  });
  id.ManyToOne(a => a.Address, m =>
  {
    m.Column("AddressID");
    m.NotNullable(true);
  });
});
mutta EF ei tue kyseistä skenaariota (ainakin muutaman selaamani nettisivun perusteella). Eli seuraavanlaista pätkää tarjosin, mutta komposiitti-id:ssä sallitaan kuitenkin vain yksinkertaiset tyypit (kuten int ja string).

HasKey(a => new { a.Customer, a.Address });
Muuten mäppäykset hoituivat molemmilla kirjastoilla melko kivottomasti. Molempien APIt olivat yhtä johdonmukaisia. EF-mäppäykset hoituivat hieman vähemmällä koodilla, koska metodeita pystyi ketjuttamaan. NH:n tapauksessa saman "fluent"-apin saa käyttämällä Fluent NHibernatea. Tässä vielä pari kokonaista POCO-luokkaa ja niiden mäppäykset.

public interface IEntity
{
  int Id { get; set; }
  DateTime ModifiedDate { get; set; }
}
public abstract class Entity : IEntity
{
  public virtual int Id { get; set; }
  public virtual DateTime ModifiedDate { get; set; }
}
public class Category : Entity
{
  public virtual Category Parent { get; set; }
  public virtual ICollection<Category> Children { get; set; }
  public virtual string Name { get; set; }
  public virtual ICollection<Product> Products { get; set; }
}
public class Product : Entity
{
  public virtual string Name { get; set; }
  public virtual string ProductNumber { get; set; }
  public virtual string Color { get; set; }
  public virtual decimal StandardCost { get; set; }
  public virtual decimal ListPrice { get; set; }
  public virtual string Size { get; set; }
  public virtual decimal? Weight { get; set; }
  public virtual Category Category { get; set; }
  public virtual DateTime SellStartDate { get; set; }
  public virtual DateTime? SellEndDate { get; set; }
  public virtual DateTime? DiscontinuedDate { get; set; }
  public virtual string ThumbNailPhotoFileName { get; set; }
}

// NHibernate
public class CategoryMapping : ClassMapping<Category>
{
  public CategoryMapping()
  {
    Schema("SalesLT");
    Table("ProductCategory");
    Id(c => c.Id, m => m.Column("ProductCategoryID"));
    ManyToOne(c => c.Parent, m => 
    {
      m.Column("ParentProductCategoryID");
      m.NotNullable(false);
    });
    Bag(c => c.Children, m =>
    {
      m.Key(k => k.Column("ParentProductCategoryID"));
    },
    r => r.OneToMany());
    Property(c => c.Name, m => 
    {
      m.Length(50);
      m.NotNullable(true);
    });
    Property(c => c.ModifiedDate);
    Bag(c => c.Products, m => 
    {
      m.Key(k => k.Column("ProductCategoryID"));
    }, r => r.OneToMany());
  }
}
public class ProductMapping : ClassMapping<Product>
{
  public ProductMapping()
  {
    Schema("SalesLT");
    Table("Product");
    Id(c => c.Id, m => m.Column("ProductID"));
    Property(p => p.Name, m =>
    {
      m.Length(50);
      m.NotNullable(true);
    });
    Property(p => p.ProductNumber, m =>
    {
      m.Length(25);
      m.NotNullable(true);
    });
    Property(p => p.Color, m =>
    {
      m.Length(15);
      m.NotNullable(false);
    });
    Property(p => p.StandardCost, m => 
    {
      m.Column(c => c.SqlType("money"));
      m.NotNullable(true);
    });
    Property(p => p.ListPrice, m =>
    {
      m.Column(c => c.SqlType("money"));
      m.NotNullable(true);
    });
    Property(p => p.Size, m =>
    {
      m.Length(5);
      m.NotNullable(false);
    });
    Property(p => p.Weight);
    ManyToOne(p => p.Category, m => 
    {
      m.Column("ProductCategoryID");
      m.NotNullable(false);
    }); 
    Property(p => p.SellStartDate);
    Property(p => p.SellEndDate);
    Property(p => p.DiscontinuedDate);
    Property(p => p.ThumbNailPhotoFileName, m =>
    {
      m.Length(50);
      m.NotNullable(false);
    });
    Property(p => p.ModifiedDate);
  }
}

// Entity Framework
public class CategoryConfiguration : EntityTypeConfiguration<Category>
{
  public CategoryConfiguration()
  {
    ToTable("ProductCategory", "SalesLT");
    HasKey(c => c.Id);
    Property(c => c.Id).HasColumnName("ProductCategoryID");
    HasOptional(c => c.Parent).WithMany(c => c.Children).Map(m => m.MapKey("ParentProductCategoryID"));
    HasMany(c => c.Children).WithOptional(c => c.Parent).Map(m => m.MapKey("ParentProductCategoryID"));
    Property(c => c.Name).HasMaxLength(50).IsRequired();
    Property(c => c.ModifiedDate);
    HasMany(c => c.Products).WithOptional(p => p.Category).Map(m => m.MapKey("ProductCategoryID"));
  }
}
public class ProductConfiguration : EntityTypeConfiguration<Product>
{
  public ProductConfiguration()
  {
    ToTable("Product", "SalesLT");
    HasKey(p => p.Id);
    Property(p => p.Id).HasColumnName("ProductID");
    Property(p => p.Name).HasMaxLength(50).IsRequired();
    Property(p => p.ProductNumber).HasMaxLength(25).IsRequired();
    Property(p => p.Color).HasMaxLength(15).IsOptional();
    Property(p => p.StandardCost);
    Property(p => p.ListPrice);
    Property(p => p.Size).HasMaxLength(5).IsRequired();
    Property(p => p.Weight);
    HasOptional(p => p.Category).WithMany(c => c.Products).Map(m => m.MapKey("ProductCategoryID"));
    Property(p => p.SellStartDate);
    Property(p => p.SellEndDate);
    Property(p => p.DiscontinuedDate);
    Property(p => p.ThumbNailPhotoFileName).HasMaxLength(50).IsOptional();
    Property(p => p.ModifiedDate);
  }
}

Konffaus

NHibernate

NHibernaten konffausta pidin joskus vaikeana, mutta aika simppeli tuo perussetti on. Eipä sitä ulkoa muista, mutta copy-paste toimii. (web.config/app.config).

<configSections>
  <section name="hibernate-configuration" type="NHibernate.Cfg.ConfigurationSectionHandler, NHibernate" />
</configSections>
<hibernate-configuration xmlns="urn:nhibernate-configuration-2.2">
  <session-factory name="">
    <property name="connection.provider">NHibernate.Connection.DriverConnectionProvider</property>
    <property name="connection.driver_class">NHibernate.Driver.SqlClientDriver</property>
    <property name="connection.connection_string_name">OrmComparison</property>
    <property name="dialect">NHibernate.Dialect.MsSql2008Dialect</property>
  </session-factory>
</hibernate-configuration>
Koodin puolellakin niin simppeliä

var mapper = new ModelMapper();
mapper.AddMapping<CategoryMapping>();
mapper.AddMapping<ProductMapping>();

var cfg = new Configuration();
cfg.AddMapping(mapper.CompileMappingForAllExplicitlyAddedEntities());
var sessionFactory = cfg.BuildSessionFactory();
Sitten vain hakemaan dataa

using(var session = sessionFactory.OpenSession())
{
  var products = session.Query<Product>().Where(p => p.Price < 100m);
  // no limits baby
  var entities = session.Query<IEntity>().Where(e => e.Id < 10);
}

Entity Framework

Entity frameworkin kaikki säädöt menee DbContext-luokkaan

public class AdventureWorksContext : DbContext
{
  public DbSet<Product> Products { get; set; }
  public DbSet<Category> Categories { get; set; }
  
  public AdventureWorksContext() : base("Name=OrmComparison")
  {
  }

  protected override void OnModelCreating(DbModelBuilder modelBuilder)
  {
    modelBuilder.Configurations.Add(new CategoryConfiguration());
    modelBuilder.Configurations.Add(new ProductConfiguration());
  }
}
Ja dataa haetaan

var context = new AdventureWorksContext();
var products = context.Products.Where(p => p.Price < 100m);

Suorituskyky

Hain vähän dataa molemmilla kirjastoilla käyttäen LINQ:ta ja seuraavassa yhteenvetoja. NH pärjäsi paremmin, kun haettiin objekteja pelkästään id:n avulla. Muissa hauissa EF pärjäsi paremmin. Include/Fetch hidasti NH:tä vähemmän kuin EF:ää. Erot olivat kuitenkin mitättömiä eikä noilla ole käytännön merkitystä. Insert/update/delete -operaatioissa NH voittaa kirkkaasti, koska se käyttää batchejä ja EF ei. NH on kymmeniä, jopa satoja, kertoja nopeampi riippuen batchin koosta.

Ominaisuudet, joissa NHibernate rokkaa edelleen

Event listener NHibernatessa on useita tapahtumia, joihin voi liittää omia käsittelijöitä. Voit esim. muuttaa objekteja juuri ennen niiden tallentamista tietokantaan tai juuri ennen kuin objektit palautetaan käyttäjälle. EF:ssä on yksi ainoa metodi (SaveChanges), joka edes etäisesti muistuttaa samaa. User type Voit laittaa propertyn

int[]
tietokantaan muodossa

1,2,3,4,5
ja antaa NHibernaten tehdä muunnoksen automaattisesti aina objektia ladatessa. Voit myös mm. serialisoida objekteja json:iksi, xml:ksi jne. EF:ssä ei ole mitään vastaavaa. 2nd level cache Objektit laitetaan molemmissa välimuistiin, mutta NH:ssa voi välimuistiin laittaa myös kyselyiden tulokset. Lazy load NH:ssa minkä tahansa propertyn voi laittaa lazyload-tilaan (esim. pitkä teksti tai binääridata). EF:ssä ainoastaan kokoelmat ja referenssit (navigation property) voivat olla lazyload-tilassa. Kokoelmat NH:ssa on useampia tapoja mäpätä kokoelmia. Esim. listan alkioilla on oikeasti järjestys. EF:hän ei tuota takaa. Ei hardkoodattuja kokoelmia NH:lla kyselyitä voi tehdä mitä tahansa luokkaa/interfacea vasten. EF:ssä joudut tekemään propertyn (DbSet) jokaista kokoelmaa varten erikseen. Esim. seuraavat toimivat vallan mainiosti ja esimerkkitapauksessamme palauttaisi sekä Product- että Category-tyyppiset objektit samalla kertaa.

session.Query<Entity>().Where(e => e.Id < 10);
session.Query<IEntity>().Where(e => e.Id < 10);
Omat (LINQ)-operaatiot NH:ssä voit tehdä generaattoreita, jotka kertovat kuinka jokin metodi muutetaan SQL:ksi. Esim. laajennusmetodi Like

session.Query<Post>().Where(p => p.Title.Like("%NHibernate%"))
joka tuottaisi sql:n

Title LIKE '%NHibernate%'

Asiat, joissa Entity Framework on parempi

Entity Designer Jos haluaa nopeasti viritellä ORM:n olemassa olevaan tietokantaan, on Visual Studion graafinen työkalu nopein tapa. Syntyvä koodi on rumaa, mutta demo yms. projekteihin, joita käytetään vain hetki ja sitten heitetään menemään, tuo on ihan hyvä ratkaisu. (perinteisen) ASP.NET:n komponentit Esim. DataGridiin voi näpsäkästi laittaa EntityDataSourcen. Tosin samat periaatteet kuin edellisessä kohdassa; ei kai kukaan näitä oikeisiin projekteihin laita. Ja suunta on (onneksi) kovasti ASP.NET MVC:hen.

Yhteenveto

Demo- ja vastaaviin projekteihin, jotka pitää tehdä nopeasti ja sitten heitetään menemään, käytä toki Entity Frameworkiä. Entity Designerilla olemassa oleva tietokanta saadaan nopeasti koodiin käytettäväksi. Oikeisiin töihin, joissa vaaditaan monimutkaisia ratkaisuja ja paljon säätömahdollisuuksia, käytä NHibernatea. NH:n säätömahdollisuudet ovat vuosia EF:ää edellä. Peruskonffaus ei vaadi älyttömästi paneutumista ja LINQ toimii, kuten EF:ssäkin. Nyt kun EF on open sourcea, voi tilanne muuttua nopeastikin, mutta sitä odotellessa...