Code Review: Zbyt długie wyrażenia LINQ

Zapytania LINQ bardzo skracają kod i zamiast wielu pętli czy warunków, możemy wszystko ładnie przedstawić za pomocą jednego zapytania. Niestety, dosyć często można zaobserwować następujący kod (sztuczny przykład):

class Person
{
   public int Age { get; set; }
   public string FirstName { get; set; }
   public IEnumerable<Person> Friends { get; set; } 
}


 dataSource.Where(p => p.FirstName == firstName).Where(p => p.Friends.Any(f => f.FirstName == friendName)).GroupBy(p => p.Age).Where(g => g.ToArray().Length > 0).OrderBy(f => f.Key).ThenBy(g => g.Count());
 

Proszę nie zwracać uwagi na optymalizacje czy logikę. Przykład stworzyłem tylko po to, aby pokazać jak bardzo można zaciemnić kod, tworząc zbyt długie zapytania. LINQ zwiększa czytelność kodu jeśli nie jest zbyt długi. Idealnie, LINQ powinien wyglądać jak zwykłe zdanie po angielsku, które nie wymaga żadnego komentarza.

Refaktoryzacja jest bardzo łatwa. Wystarczy rozdzielić powyższe zapytanie na podzapytania w formie zwykłych lub rozszerzających metod. Jeśli podobne podzapytania będą występować w różnych miejscach w kodzie, to wtedy lepiej skorzystać z metod rozszerzających:

static class PersonExtensions
{
public static IOrderedEnumerable<IGrouping<int, Person>> GroupAndSortByAge(this IEnumerable<Person> persons)
{
  return persons.GroupBy(p => p.Age).Where(g => g.ToArray().Length > 0).OrderBy(f => f.Key).ThenBy(g => g.Count());
}
public static IEnumerable<Person> HasFriend(this IEnumerable<Person> persons,string friendName)
{
  return persons.Where(p => p.Friends.Any(f => f.FirstName == friendName));
}
}

Teraz zapytanie będzie wyglądać następująco:

dataSource.Where(p => p.FirstName == firstName).HasFriend(friendName).GroupAndSortByAge();

Następnie można pokusić się o dalsze rozdzielanie podzapytań na coraz to mniejsze zapytania.

Inną kwestią jest stworzenie specjalnej metody, która zwraca zapytanie a nie za każdym razem kopiowanie całego query. Można to zrobić za pomocą wzorca gateway, repository czy znów metody rozszerzającej.

Powyższe rozwiązanie nie zadziała jeśli korzystamy z Entity Framework albo z LINQ to SQL. W takich technologiach, zapytanie musi być skonwertowane np. do T-SQL. Wyrażenia lambda to po prostu skompilowany kod c# i ciężko z tego byłoby wydobyć SQL. Z tego względu, musimy skorzystać z Expression Tree, o którym można poczytać sobie np. tutaj. W skrócie, expression dostarczają informacji o danym wyrażeniu. Za pomocą ich łatwo można zobaczyć, jakie wyrażenia zawiera kod (warunki, pętle itp.). Na końcu można skompilować to do standardowej metody lub skonwertować  wyrażenie do innego języka, np. SQL. Wyrażenia zatem zawierają opis logiki, a nie informacje w jaki sposób ją przetworzyć. Za pomocą obiektów możemy przeanalizować całą logikę metody, bez potrzeby parsowania tekstu.

Entity Framework zatem nie potrafi operować na czystych lambda ale możemy zwrócić po prostu Expression tzn.:

class Program
{
   private static void Main(string[] args)
   {
       var dataContext=new CustomDataContext();

       var lambdaQuery = dataContext.Persons.Where(SimpleLambda("test"));
       var expressionQuery = dataContext.Persons.Where(SimpleExpression("test"));
   }

   private static Func<Person,bool> SimpleLambda(string firstName)
   {
       return person => person.FirstName == firstName;
   }
   private static Expression<Func<Person, bool>> SimpleExpression(string firstName)
   {
       return person => person.FirstName == firstName;
   }
}

Pierwsze wyrażenie lambda nie zadziała, ponieważ nie da się wygenerować SQL na podstawie czystej metody C# (lambda to nic innego jak delegate). Drugie zwraca Expression – wyrażenie opisujące logikę jaką należy wykonać. Metody Where tak naprawdę zwracają kolejno IEnumerable oraz IQueryable, ale o tym pisałem już kiedyś tutaj.

Poza tym, Expression można dynamicznie tworzyć. Na przykład można dodać warunek na etapie działania aplikacji a nie kompilacji. Expression należy traktować jako kolekcje warunków, pętli i innych elementów programistycznych.

Leave a Reply

Your email address will not be published.