Blog Kokosa

.NET i okolice, wydajność, architektura i wszystko inne

NAVIGATION - SEARCH

NDepend - spojrzenie na .NET Core

Czy znacie narzędzie NDepend? Służy do statycznej analizy jakości kodu. Istnieje w świecie .NET już od wielu lat i zyskał zasłużoną renomę. Pisał o nim ostatnio Piotr Gankiewicz, zwycięzca konkursu Daj się poznać, którym przeanalizował swój projekt Warden. I mi nadarzyła się okazja by przyjrzeć się temu narzędziu bliżej. Kilka lat temu bawiłem się nim na potrzeby analizy projektów w pracy. A teraz zapragnąłem nim przebadać coś nieswojego, coś dużego - .NET Core. Jesteśmy szczęściarzami, że żyjemy w czasach, w których .NET ma swoją wersję Open Source. A skoro tak, to kto nam zabroni ocenić jakoś kodu pisanego przez sam Microsoft i zebrane wokół community? Warto tu nadmienić, że NDepend potrafi analizować skompilowane assembly więc tak naprawdę nie musiałbym się ograniczać do .NET Core tylko wziąć na tapetę ten komercyjny, dojrzały .NET Framework. Ale uważam, że podpatrzenie tego, co możemy ew. zmienić jest po prostu fajniejsze.

Temat kompilowania i uruchamiania .NET Core zasługuje na osobny artykuł. Na potrzeby tego wpisu wystarczy nadmienić, że na githubie składa się on z dwóch głównych repozytoriów:

  • https://github.com/dotnet/coreclr - rdzenna część zwana CoreCLR zawiera runtime .NET Core (napisany główne w C++) oraz bazową bibliotekę zwaną mscorlib. To tu właśnie znajdziemy takie kluczowe elementy jak garbage collector, JIT i wiele bazowych oraz pomocniczych klas,
  • https://github.com/dotnet/corefx - część zawierająca szereg podstawowych bibliotek, zwana CoreFX. To tu znajdziemy klasy kolekcji, obsługi plików, konsoli, XML, asynca itd. itp.

Zebrane i skompilowane razem pozwalają nam bawić się własną kompilacją .NET Core. I właśnie takową postanowiłem przepuścić przez bezlitosne tryby narzędzia NDepend. Oto co mi z tego wyszło.

To co poddałem analizie to wszystkie assembly niezbędne do uruchomienia przykładowego programu konsolowego - dokładnie tego opisanego w dokumentacji Build CoreCLR on Windows. W wynikowym katalogu są to:

 Directory of c:\Projects\CoreNet\coreclr-demo\runtime

2016-06-14  16:26         4,872,704 clrjit.dll
2016-06-14  16:27        22,284,288 coreclr.dll
2016-06-14  16:25         1,853,952 CoreRun.exe
2016-06-17  11:21             4,096 hello.exe
2016-06-14  16:28            30,720 mscorlib.dll
2016-06-14  17:32            78,848 System.Console.dll
2016-06-14  17:31            23,040 System.Diagnostics.Debug.dll
2016-06-14  17:32            28,672 System.IO.dll
2016-06-14  17:33             6,144 System.IO.FileSystem.Primitives.dll
2016-06-14  16:27         2,476,032 System.Private.CoreLib.dll
2016-06-14  17:31            23,552 System.Runtime.dll
2016-06-14  17:34            18,944 System.Runtime.InteropServices.dll
2016-06-14  17:35             6,144 System.Text.Encoding.dll
2016-06-14  17:35             6,656 System.Text.Encoding.Extensions.dll
2016-06-14  17:35            43,008 System.Threading.dll
2016-06-14  17:35            10,752 System.Threading.Tasks.dll

Ale przy otwieraniu tych plików w NDepend, nie uda nam się to z trzema pierwszymi, ponieważ nie są to .NETowe assembly, lecz zawierają sam runtime (jak wspomniałem, w dużej mierze w C++).

NDepend1

Nie analizowałem też samego programu hello.exe bo nieszczególnie zależy mi na oglądaniu tego mega-prostego programu przykładowego. Dlatego w powyższym okienku wyfiltrowałem cztery pierwsze pliki i po około 20 sekundowej analizie uzyskałem dwa rodzaje wyników.

Pierwszy z nich to otwierający się w tle lokalny plik HTML z obfitym raportem, który wygodnie jest pewnie komuś przekazać do dalszej analizy:

NDepend2

My zaś możemy bawić się wynikami analizy w samym NDepend, poczynając od Dashboardu, który ukaże się naszym oczom:

NDepend3

Tutaj widzimy m.in. że wybrany kod składa się w sumie z ponad miliona instrukcji IL*, że zawiera 5407 typów oraz że w sumie zostało naruszonych 13 krytycznych reguł (5483 wystąpienia) i 85 "zwykłych" reguł (72010 wystąpienia). Wow, brzmi... dużo! W dashboardzie możemy też prześledzić wykresy najróżniejszych parametrów, co pozwoli śledzić w czasie zmiany jakości naszego (lub cudzego) kodu.

Chociaż lekko złośliwa ludzka natura od razu chciałaby się rzucić do przeglądania tych naruszeń, rozejrzyjmy się na początku na spokojnie trochę po interfejsie. Obok Dashboardu, ciekawą zakładką jest Dependency Graph, który ładnie wyrysuje nam wszystkie zależności. Graf ten jest interaktywny i zawiera wiele opcji (m.in. możliwość rysowania zależności na poziomie namespace). Fajne jest np. to, że definiować możemy co wpływa na rozmiar węzłów i grubość krawędzi je łączących:

NDepend4

Trzeba przyznać, że jeśli chodzi o sam .NET Core, wygląda to przejrzyście i logicznie. Ale też i assembly biorących w udział nie ma tak dużo. Z tego też powodu pominę kolejną zakładkę - Dependency Matrix - która przedstawia tak naprawdę to samo, ale w formie macierzy zależności.

Ciekawą zakładką jest Metrics, ukazująca w sposób graficzny najróżniejsze zależności statystyk. Ta domyślna polega na wyświetleniu jako prostokąt każdej metody, o rozmiarze proporcjonalnym do # IL Instructions (ilości instrukcji IL w danej metodzie) - zatem im metoda dłuższa, tym prostokąt większy. Ponadto kolor prostokąta wyznacza tzw. IL Cyclomatic Complexity czyli złożoność cyklomatyczną na poziomie kodu IL. Definicje i opisy tej metryki i wszystkich innych, którymi posługujemy się w NDepend są opisane na stronie produktu. Efekt:

NDepend5

Tutaj widzimy momentami dość czerwoną mapę, zatem mamy w kodzie .NET Core pewne kwiatki o wysokiej złożoności. I widać, że są  to też czasem dość duże metody. Najeżdżając na każdy z tych prostokątów, dowiemy się dokładnie o jaką metodę chodzi i jakie są dla niej konkretne wartości metryk. Bawiąc się chwilę z powyższą mapą dostrzeżemy, że sporo "czerwonych" typów/metod związanych jest z manipulowaniem tekstem (System.Text) oraz klasami w namespace System.Runtime. Szczegóły poznamy lepiej gdy przyjrzymy się konkretnym naruszonym regułom w dalszej części wpisu.

Co do omawianej wizualizacji, możemy rozmiary i kolory uzależniać od naprawdę wielu parametrów, na różnym poziomie szczegółowości. Na przykład: na poziomie każdego namespace, rozmiar niech będzie proporcjonalny do ilości metod, a kolor do # IL Instructions - nie ma sprawy:

NDepend6

Dzięki takiej elastyczności dostosujemy zapewne wizualizację do naszych potrzeb.

Wróćmy jednak do sedna czyli reguł, które NDepend uznał za naruszone. Okienko z tymi regułami, wraz z ich kategoryzacją prezentuje się następująco:

NDepend7

Widzimy tu m.in. metody zbyt złożone, zawierające zbyt dużą liczbę parametrów, nieuprawnione używanie nazwy Dispose, naruszające dobre praktyki programowania obiektowego itd. itp. Klikając każdą z tych reguł dostajemy listę metod, których ona dotyczy. Możemy ją sobie dowolnie pogrupować, dla poglądu wygodnie jest np. względem assembly i typu:

NDepend8

Jak widać najbardziej złożone są metody związane z parsowaniem dat - nie dziwota jeśli się pomyśli nad ilością różnych warunków brzegowych, które pewnie muszą zostać tam obsłużone. Jeśli spróbujemy otworzyć metodę Lex widoczną powyżej, dostaniemy błąd:

NDepend9

NDepend po prostu nie może pokazać nam danej metody, bo w katalogu z assembly nie znajdowały się pliki z symbolami. Ale przecież sami .NET Core skompilowaliśmy (i to w domyślnym trybie Debug), więc możemy je tam dograć! Jeśli to zrobimy, po dwukliknięciu w daną metodę, będzie się nam ona otwierać w Visual Studio. I tak dla przykładu omawiany DateTimeParse.Lex():

//
// This is the lexer. Check the character at the current index, and put the found token in dtok and
// some raw date/time information in raw.
//
[System.Security.SecuritySafeCritical]  // auto-generated
private static Boolean Lex(DS dps, ref __DTString str, ref DateTimeToken dtok, ref DateTimeRawInfo raw, ref DateTimeResult result, ref DateTimeFormatInfo dtfi, DateTimeStyles styles)
{
    ...

Jest to bardzo długo metoda leksera dokonująca analizy leksykalnej stringa zawierającego "datetime" do sparsowania. Jest to imponujący switch z mnóstwem zagnieżdzonych case'ów, taka duża złożoność cyklomatyczna zatem jest wybaczalna.

Inną regułą jest np. Types too big, do której wpadło 21 typów z CoreLib:

NDepend10

I trzeba przyznać, że typy mające ponad sto metod są imponujące, np. rekordowa klasa Convert mająca 295 metod - oczywiście ze względu na dziesiątki przeciążeń typu:

public static byte ToByte(ushort value);
public static byte ToByte(uint value);
public static byte ToByte(long value);
public static decimal ToDecimal(sbyte value);
public static decimal ToDecimal(byte value);
public static decimal ToDecimal(char value);
public static decimal ToDecimal(short value);

Co dalej? Możemy się przyjrzeć regule Don't call your method Dispose, oznaczająca, że zdefiniowano w typie metodę Dispose, mimo że w hierarchii dziedziczenia żaden rodzic nie implementuje interfejsu IDisposable. Znaleziony w ten sposób przykład:

namespace System.Reflection.Emit
{
    internal class TypeNameBuilder
    {
        // ...
        internal void Dispose() { ReleaseTypeNameBuilder(m_typeNameBuilder); }
        // ...
    }
}

Inny przykład - reguła Constructors of abstract classes should be declared as protected or private i 30 naruszonych typów, w tym m.in. System.Array z ciekawym komentarzem:

public abstract class Array : ICloneable, IList, IStructuralComparable, IStructuralEquatable 
{
    // This ctor exists solely to prevent C# from generating a protected .ctor that violates the surface area. I really want this to be a
    // "protected-and-internal" rather than "internal" but C# has no keyword for the former.
    internal Array() {}
    // ...

Wśród reguł normalnych mamy jeszcze większy wybór, bo jak wspomniałem jest ich naruszonych aż 95. Tutaj mamy np. regułę Types with too many fields i rekordzistę System.Globalization.CultureData z liczbą 78 pól. Reguły takie jak Methods with too many local variables oraz Methods with too many parameters też są ciekawe ale już Wam oszczędzę wymieniania rekordzistów. Są jednak kolejne warte przyjrzenia się reguły. Np. dość groźnie brzmiące Avoid types initialization cycles. Jej naruszenie polega na stworzeniu bezpośredniego lub pośredniego cyklu zależności pomiędzy typami poprzez statyczne konstruktory, co ładnie opisuje dokumentacja NDepend:

The class constructor (also called static constructor, and named cctor in IL code) of a type, if any, is executed by the CLR at runtime, the first time the type is used. A cctor doesn't need to be explicitly declared in C# or VB.NET, to exist in compiled IL code. Having a static field inline initialization is enough to have the cctor implicitly declared in the parent class or structure.

If the cctor of a type t1 is using the type t2 and if the cctor of t2 is using t1, some type initialization unexpected and hard-to-diagnose buggy behavior can occur. Such a cyclic chain of initialization is not necessarily limited to two types and can embrace N types in the general case. More information on types initialization cycles can be found here: http://codeblog.jonskeet.uk/2012/04/07/type-initializer-circular-dependencies/

The present code rule enumerates types initialization cycles. Some false positives can appear if some lambda expressions are defined in cctors or in methods called by cctors. In such situation, this rule considers these lambda expressions as executed at type initialization time, while it is not necessarily the case.

Czy taka ciekawa reguła jest naruszona w .NET Core? Tak, 42 razy, przykłady:

NDepend11

Trzeba przyznać, że na ogół w cykl zamieszanych jest sporo, bo kilkanaście różnych typów. Wskazuje na to kolumna cctorsCycle, w komórkach której możemy podejrzeć, o które typy dokładnie chodzi. To może oznaczać, że są to naruszenia false positive. Ale mimo wszystko narzędzie daje nam wskazówkę gdzie szukać.

Kolejna ciekawa reguła to Constructor should not call a virtual method, która też jest naruszona, ale zdaje się w bezpieczny sposób - to zasługuje na osobny opis, na który poświęcę jeden z kolejnych wpisów.

Czy udało mi się znaleźć w .NET Core w ten sposób jakieś niesamowite kwiatki? Na razie nie, ale mam nadzieję, że coś ciekawego się znajdzie... I wtedy na pewno o tym napiszę. Zresztą, zachęcam do ściągnięcia wersji testowej i pobawienia się samemu.

To o czym tu mówię to tak naprawdę wierzchołek góry lodowej bo to co w NDepend jest niesamowite, to że wszystkie reguły, o których tu mową są zdefiniowane w języku zapytań określonym jako CQLinq (Code Query LINQ) - dzięki temu reguły możemy modyfikować, ale przede wszystkim - tworzyć całkiem nowe, na swoje potrzeby. Weźmy np. na tapetę regułę Classes that are candidate to be turned into structures. Definicja i wyczerpujący opis reguły jest widoczny w okienku Queries and Rules Edit:

// <Name>Classes that are candidate to be turned into structures</Name>

warnif count > 0 from t in JustMyCode.Types where 
  t.IsClass &&
 !t.IsGeneratedByCompiler &&
 !t.IsStatic &&
  t.SizeOfInst > 0 &&
  t.SizeOfInst <= 16 &&   // Structure instance must not be too big, 
                          // else it degrades performance.

  t.NbChildren == 0 &&    // Must not have children

  // Must not implement interfaces to avoid boxing mismatch 
  // when structures implements interfaces.
  t.InterfacesImplemented.Count() == 0 &&

  // Must derive directly from System.Object
  t.DepthOfDeriveFrom("System.Object".AllowNoMatch()) == 1
  
  // && t.IsSealed    <-- You might want to add this condition 
  //                      to restraint the set.
  // && t.IsImmutable <-- Structures should be immutable type.
  // && t.!IsPublic   <-- You might want to add this condition if 
  //                      you are developping a framework with classes 
  //                      that are intended to be sub-classed by 
  //                      your clients.
  
select new { t, t.SizeOfInst, t.InstanceFields } 

Składnia CQLinq dzięki operowaniu na dobrze przygotowanym modelu danych jest bardzo intuicyjna.

Co więcej, składnia ta pozwala nam szukać interesujących nas typów, nie tylko określać reguły. Chcemy znaleźć wszystkie typy, które bezpośrednio lub pośrednio dziedziczą po System.ArgumentException? Proszę bardzo:

from t in Types 
let depth = t.DepthOfDeriveFrom("System.ArgumentException")
where depth  >= 0 
orderby depth
select new { t, depth }

Bardziej intuicyjnie sobie tego nie wyobrażam.

Niestety nie wszystko szło różowo w trakcie analizowania .NET Core, ponieważ jest on kompilowany w dość złożony sposób - jeden pliku solucji, który nam się pięknie kompiluje do binarek z plikami PDB i wszystkimi zależnościami nie istnieje. Nie udało mi się jeszcze przez to poprawnie podpiąć wszystkich symboli i dość często dostaję komunikaty o niemożliwości otworzenia kodu danej metody. Alternatywą jest zainstalowanie i podpięcie Reflectora, który zdekompiluje nam po prostu kod - choć to mniej interesujące rozwiązanie, skoro mamy do dyspozycji kod źródłowy.

Abstrahując od tego, że NDepend świetnie może się nadać do codziennego monitorowania stanu naszego projektu, jak widać bardzo fajnie może się też sprawdzić gdy chcemy poznać czyjś projekt. A szczególnie zrozumieć czy nie ma jakiś... słabości. Mimo imponującej liczby naruszeń reguł i dłuższego czasu spędzonego w NDepend, na razie jednak nie znalazłem w .NET Core naprawdę kompromitującej wpadki, bu...  A na serio, brawo!

 

blog comments powered by Disqus