Spis treści:

Kategoria:Windows Communication Foundation


Referencje cykliczne w WCF

Wszystko zaczyna się od serializacji

Cykliczne referencje to nie jest wymysł WCF. Na tę samą dolegliwość cierpią, lub są nią zagrożone, wszystkie rodzaje serializacji. Nawet zwykłe kopiowanie obiektów może spłatać figla i wyprowadzić nas w pole w pewnym przypadku...

Na czym polega zagrożenie? Gdzie jest pułapka? Rozważmy taki oto przykład: mamy obiekt A, którego jedna z właściwości przechowuje obiekt B. Załóżmy dodatkowo, że jedna z właściwości obiektu B wskazuje na obiekt A. Reasumując: A wskazuje na B, B wskazuje na A. Spróbujmy teraz takie obiekty poddać serializacji. Serializujemy najpierw wszystkie właściwości obiektu A, aż natrafimy na właściwość z obiektem B. Co robimy dalej? Oczywiste jest, że przystępujemy do serializacji właściwości obiektu B. Serializujemy, serializujemy, aż natrafimy na właściwość z obiektem A. Tym samym, od którego zaczęliśmy. Jak by nie było - serializujemy obiekt A tak jak wszystkie inne obiekty - właściwość po właściwości. Znów natrafiamy na obiekt B... i znów ten obiekt serializujemy. Otrzymujemy nieskończoną pętlę.

Podobne rozważania można przeprowadzić po analizie algorytmów kopiowania obiektów lub sprawdzania jego właściwości.

Zanim przejdę do możliwych rozwiązań problemu cyklicznych referencji w WCF i nie tylko, pokażę realny przykład.

Przykład cyklicznej referencji w WCF

Przypuśćmy, że gdzieś w kodzie napisaliśmy dwie klasy reprezentujące odpowiednio Kredyt oraz Ratę:

[DataContract]
public class Kredyt
{
    [DataMember]
    public string Nazwa { getset; }
    [DataMember]
    public int OkresSplaty { getset; }
    [DataMember]
    public DateTime DataRozpoczecia { getset; }
    [DataMember]
    public List<Rata> Raty { getset; }
}

[DataContract]
public class Rata
{
    [DataMember]
    public decimal Kwota { getset; }
    [DataMember]
    public DateTime DataWplaty { getset; }
    [DataMember]
    public Kredyt Kredyt { getset; }
}

Bardzo prosta struktura. Kredyt ma listę rat, a każda z rat, zapewne dla wygody posługiwania się strukturą, posiada referencję Kredytu, do którego przynależy. Załóżmy, że chcemy taką strukturę przesłać przy pomocy WCF. Wiemy, że WCF będzie próbował taką strukturę serializować i napotka na mały problem:

var kredyt1 = new Kredyt()
{
    Nazwa = "Kredyt hipoteczny",
    DataRozpoczecia = DateTime.Now,
    OkresSplaty = 360
};
var rata = new Rata()
{
    DataWplaty = DateTime.Now,
    Kwota = 1000.0M,
    Kredyt = kredyt1
};
kredyt1.Raty = new List<Rata>()
{
    rata,rata
};
            
//Próba serializacji obiektu
DataContractSerializer wcfSerializer = new DataContractSerializer(typeof(Kredyt));
var kredytStream = new MemoryStream();
wcfSerializer.WriteObject(kredytStream, kredyt1);

Próba wykonania powyższego kodu doprowadzi nas do wyjątku o następującej treści:

Object graph for type 'SerializeTest.Rata' contains cycles and cannot be serialized if reference tracking is disabled.

Obiekt, który chcieliśmy serializować będzie wyglądał podobnie do poniższego:

<Kredyt xmlns="http://schemas.datacontract.org/2004/07/SerializeTest"
        xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
  <DataRozpoczecia>2013-10-08T21:16:43.5681151+02:00</DataRozpoczecia> 
  <Nazwa>Kredyt hipoteczny</Nazwa> 
  <OkresSplaty>360</OkresSplaty> 
  <Raty>
    <Rata>
      <DataWplaty>2013-10-08T21:16:43.5691151+02:00</DataWplaty> 
      <Kredyt>
        <DataRozpoczecia>2013-10-08T21:16:43.5681151+02:00</DataRozpoczecia> 
        <Nazwa>Kredyt hipoteczny</Nazwa> 
        <OkresSplaty>360</OkresSplaty> 
        <Raty>
          <Rata>
          <DataWplaty>2013-10-08T21:16:43.5691151+02:00</DataWplaty>
          ...i tak dalej

Warto zwrócić uwagę na powtarzającą się definicję kredytu i listy rat, które będą powtarzane aż do osiągnięcia limitów. Co można z tym zrobić?

Przerywanie referencji cyklicznych

W .NET Framework 3.0 istniało kilka sposobów poradzenia sobie z problemem. Jednym z nich było... unikanie referencji cyklicznych. Powód był bardzo prosty: wymagało to zastosowania własnego mechanizmu serializacji lub przerywanie takich cyklicznych zależności atrybutem NonSerialized (NonSerializedAttribute). Pierwsza technika była stosunkowo złożona, natomiast druga pomijała w serializacji oznaczone atrybuty. Te atrybuty nie były przestyłane i w konsekwencji nie mogły być odtworzone po stronie klienta. Trochę to przeczy defnicji serializacji - obiekt koncowy nie był tożsamy z obiektem początkowym. Co w takim przypadku mogę polecić? Aktualizację .NET Framework do wersji 3.5 lub wyższej.

DataContract i atrybut IsReference

W tytule zdradziłem już w zasadzie wszystko. Aby domyślna klasa serializująca właściwie przetwarzała referencje cykliczne, wystarczy w odpowiednim miejscu umieścić atrybut. Obserwacja napisanego przez innych kodu uświadamia mnie, że odpowiednie umieszczenie atrybutu nie jest takie proste. Jest on nadużywany, a to też jest w pewnym sensie błędem. Błędem ignorowanym, bo dla przeciętnego programisty niedostrzegalnym. Pisząc ten artykuł postanowiłem rozebrać problem referencji cyklicznych na drobne części, dlatego i ten drobny błąd postaram się pokazać. Przyjrzyjmy się najpierw drobnej modyfikacji w klasie Kredytu:

[DataContract(IsReference = true)]
public class Kredyt
{
    [DataMember]
    public string Nazwa { getset; }
    [DataMember]
    public int OkresSplaty { getset; }
    [DataMember]
    public DateTime DataRozpoczecia { getset; }
    [DataMember]
    public List<Rata> Raty { getset; }
}

Kluczowy fragment został pogrubiony. Tylko tyle i aż tyle - problem zostaje rozwiązany. Przyjrzyjmy się teraz wynikowi serializacji:

<Kredyt z:Id="i1"
  xmlns="http://schemas.datacontract.org/2004/07/SerializeTest"
  xmlns:i="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:z="http://schemas.microsoft.com/2003/10/Serialization/">
  <DataRozpoczecia>2013-10-08T21:49:52.1338545+02:00</DataRozpoczecia>
  <Nazwa>Kredyt hipoteczny</Nazwa>
  <OkresSplaty>360</OkresSplaty>
  <Raty>
    <Rata>
      <DataWplaty>2013-10-08T21:49:52.1358547+02:00</DataWplaty>
      <Kredyt z:Ref="i1"/>
      <Kwota>1000.0</Kwota>
    </Rata>
    <Rata>
      <DataWplaty>2013-10-08T21:49:52.1358547+02:00</DataWplaty>
      <Kredyt z:Ref="i1"/>
      <Kwota>1000.0</Kwota>
    </Rata>
  </Raty>
</Kredyt>

Tym razem warto zwrócić uwagę na następujące elementy:

  • Pojawiła się nowa przestrzeń nazw xmlns:z, która identyfikuje napotkane podczas serializacji referencje.
  • Każdy obiekt klasy opatrzonej atrybutem otrzymuje kolejny numer identyfikatora z:Id (i1, i2, i3...).
  • Jeżeli w którymś miejscu pojawia się napotkany już obiekt z referencją, zamiast jego atrybutów umieszczana jest wartość z:Ref z odpowiednim kluczem.

Wygląda bardzo prosto i takie jest. Mogliśmy się przekonać, że przybyło kilka nowych elementów, ale taka jest cena atrybutu DataContract z ustawioną właściwością IsReference.

U niektórych w tym momencie pojawia się pytanie: dlaczego taka serializacja nie jest wykonynywana domyślnie? Odpowiedź brzmi: ze względów wydajnościowych. Oszczędzamy na czasie serializacji i rozmiarze komunikatu końcowego. Przypuśćmy, że w pokazanym przykładzie ustawiliśmy właściwość IsReference także dla Raty. Otrzymamy taki oto rezultat:

<Kredyt z:Id="i1"
  xmlns="http://schemas.datacontract.org/2004/07/SerializeTest"
  xmlns:i="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:z="http://schemas.microsoft.com/2003/10/Serialization/">
  <DataRozpoczecia>2013-10-08T22:07:12.9933883+02:00</DataRozpoczecia>
  <Nazwa>Kredyt hipoteczny</Nazwa>
  <OkresSplaty>360</OkresSplaty>
  <Raty>
    <Rata z:Id="i2">
      <DataWplaty>2013-10-08T22:07:12.9943884+02:00</DataWplaty>
      <Kredyt z:Ref="i1"/>
      <Kwota>1000.0</Kwota>
    </Rata>
    <Rata z:Ref="i2"/>
  </Raty>
</Kredyt>

Jest to szczególny przypadek, bo otrzymaliśmy... krótszy komunikat. Serializator rozpoznał, że obie raty to w rzeczywistości ten sam obiekt i pominął pełną serializację drugiego obiektu z listy.

Zasada oznaczania klasy atrybutem z właściwością IsReference jest następująca - należy go umieszczać na obiekcie rodzica. Inaczej mówiąc, na obiekcie, który podczas serializacji grafu będzie przetwarzany jako pierwszy. Jako zadanie domowe polecam próbę serializacji przykładowego obiektu, ale z właściwością IsReference ustawioną dla Raty.

Prosty algorytm likwidowania zależności cyklicznych

Obszar WCF mamy załatwiony. Co zrobić w innych przypadkach? Żeby poważnie zakończyć temat przedstawię prosty sposób na uniknięcie takich zależności we własnych rozwiązaniach. Zakładam, że nie korzystamy z żadnego atrybutu.

  • Przed operacją utwórz słownik, którego kluczem będzie referencja, wartością natomiast kolejne liczby całkowite.
  1. Pobierz obiekt do serializacji.
  2. Jeżeli obiekt jest typem prostym - serializuj, skocz do 1.
  3. Jeżeli obiekt jest klasą/strukturą/typem referencyjnym - sprawdź, czy referencja jest w pomocniczym słowniku.
  4. Referencji nie ma - obiekt serializowany jest po raz pierwszy. Nadaj mu identyfikator i zapamiętaj referencję w słowniku. Poddaj serializacji wszystkie właściwości obiektu (rekurencyjnie wywołaj algorytm).
  5. Referencja jest - najprawdopodobniej znaleziono zależność cykliczną. Zapisz obiekt jako referencję z identyfikatorem. Obiekt już został raz zserializowany. Poddaj serializacji wszystkie właściwości obiektu (rekurencyjnie wywołaj algorytm).

Ważne w tym wszystkim jest to, aby cała serializacja korzystała z jednego słownika (czytaj: każde rekurencyjne wywołanie).

Serializacja WCF działa podobnie, ale ogranicza numerowanie i wartości przechowywane w słowniku tylko do tych obiektów, których klasa oznaczona jest odpowiednim atrybutem. Pozostałą część pokazanego algorytmu można w przybliżeniu przyłożyć do algorytmu stosowanego przez serializator WCF.

Jeżeli nie serializacja WCF to co? NetDataContractSerializer.

W bibliotece .NET znajduje się dużo różnych klas, ale o jednej wypada jeszcze wspomnieć. To klasa NetDataContractSerializer. Nazwa jest bardzo podobna, ale działanie nieco się różni. Podstawowe rozbieżności wskazałbym w kilku miejscach:

  • NetDataContractSerializer zapisuje w serializowanym pliku informacje o typie danych.
  • NetDataContractSerializer zapisuje typ, więc nie jest konieczne korzystanie z atrybutu KnownType (zob. Dziedziczenie w WCF).
  • NetDataContractSerializer dodaje identyfikator do każdego typu referencyjnego, a to pozwala na naturalną likwidację problemu z zależnościami cyklicznymi.
  • NetDataContractSerializer zapisuje typ ukazując często wewnętrzną implementację (na przykład obiektó dziedziczących po IList, IDictionary, zob. Serializacja w WCF od środka).

Konsekwencje przenoszenia typu implikują również mocniejsze przywiązanie dwóch stron transmisji. Nie jest to dobre rozwiązanie jeżeli zależy nam na szerokim upublicznieniu usług.

Na koniec przyjrzyjmy się wynikowi serializacji przetwarzanej w artykule struktury przez klasę NetDataContractSerializer:

<Kredyt z:Id="1z:Type="SerializeTest.Kredyt"
  z:Assembly="SeriaizeTest, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
  xmlns="http://schemas.datacontract.org/2004/07/SerializeTest"
  xmlns:i="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:z="http://schemas.microsoft.com/2003/10/Serialization/">
  <DataRozpoczecia>2013-10-08T22:41:30.3060599+02:00</DataRozpoczecia>
  <Nazwa z:Id="2">Kredyt hipoteczny</Nazwa>
  <OkresSplaty>360</OkresSplaty>
  <Raty z:Id="3">
    <_items z:Id="4z:Size="4">
      <Rata z:Id="5">
        <DataWplaty>2013-10-08T22:41:30.3070599+02:00</DataWplaty>
        <Kredyt z:Ref="1i:nil="true"/>
        <Kwota>1000.0</Kwota>
      </Rata>
      <Rata z:Ref="5i:nil="true"/>
      <Rata i:nil="true"/>
      <Rata i:nil="true"/>
    </_items>
    <_size>2</_size>
    <_version>2</_version>
  </Raty>
</Kredyt>

Nie od dziś wiadomo, że jak coś jest do wszystkiego, to jest do niczego. Trochę mocne słowa, ale po obejrzeniu efektu uzyskanego przez klasę NetDataContractSerializer łatwo zrozumieć decyzję twórców WCF o zastosowaniu uproszczonej serializacji i atrybutowym rozwiązaniu cyklicznych zależności.

Zanim zacząłem pisać o serializacji cyklicznych referencji wydawało mi się, że jest to temat krótki. W trakcie okazało się, że dotyka on kilku problemów, które przy okazji zaznaczyłem. Pobłądziłbym pewnie jeszcze bardziej, ale zostawiłem sobie to na oddzielny wpis. Zachęcam do dzielenia się spostrzeżeniami w komentarzach i zgłaszania propozycji tematów, które mógłbym rozwinąć.

Kategoria:Windows Communication Foundation

, 2013-12-20

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
Dzieki za te informacje. były kluczowe
Dobrze wyjaśnione, dzięki !
a z innej strony - co gdybym ciąg znaków chciał mieć rozbity nie na wiersze a na kolumny? Czyli ciąg ABCD: 1. kolumna: A, 2. kolumna: B, 3. kolumna: C, 4 kolumna: D?
Ciekawy artykuł.
Czy można za pomocą EF wysłać swoje zapytanie?
Czy lepiej do tego użyć ADO.net i DataTable?