70-503: Defining Structural Contracts

Ten artykuł pochodzi z serii przygotowań do egzaminu 70-503: Windows Communication Foundation.

Z poprzedniej lekcji wiemy jak utworzyć usługę i jak zdefiniować jej część “behawioralną”, czyli poszczególne metody serwisu. Dzisiaj dowiemy się jak zdefiniować własne typy danych i przekazywać je przez serwis.

Kontrakt danych

Kontrakt danych definiuje nam format, strukturę i sposób serializacji przesyłanych danych. Rozbudujmy nasz kalkulator o możliwość wykonywania dowolnych działań dwuargumentowych. Najpierw w pliku ICalculator.cs zdefiniujemy rodzaje działań, później obiekt zawierający wszystkie informacje potrzebne do wykonania działania.

   1: [DataContract(Namespace = "http://schemas.eastgroup.pl/2010/02/26/calculator/")]
   2: public enum CalculatorOperationType : int
   3: {
   4:     [EnumMember]
   5:     Add,
   6:     [EnumMember]
   7:     Substract,
   8:     [EnumMember]
   9:     Multiply,
  10:     [EnumMember]
  11:     Divide,
  12: }
  13:  
  14: [DataContract(Namespace = "http://schemas.eastgroup.pl/2010/02/26/calculator/")]
  15: public class CalculatorOperation
  16: {
  17:     [DataMember(IsRequired = true)] 
  18:     public CalculatorOperationType operation;
  19:  
  20:     [DataMember(IsRequired = true)] 
  21:     public double a;
  22:  
  23:     [DataMember(IsRequired = true)] 
  24:     public double b;
  25: }

DataContractAttribute jest zdefiniowany w System.Runtime.Serialization i może być użyty tylko do wyliczeń (enum), struktur i klas. Nie jest dziedziczony, więc każda klasa potomna, jeśli ma być typem danych przekazywanym przez serwis, powinna zawierać ten atrybut. Ma tylko dwa parametry:

  • Name – nazwa typu, np. jeśli chcemy nadać mu inną nazwę niż jest w definicji,
  • Namespace – przestrzeń nazw dla typu.

DataMemberAttribute jest też zdefiniowany w System.Runtime.Serialization i służy do oznaczenia pól lub właściwości, które mają być serializowane. Jako parametry możemy użyć:

  • Name – nazwa typu,
  • IsRequired – czy wartość dla pola jest wymagana czy nie,
  • Order – kolejność pól, domyślnie pola będą pokazane w kolejności alfabetycznej,
  • EmitDefaultValue – czy domyślne wartości mają być serializowane, jeśli true – wszystkie pola będą serializowane, jeśli false – serializowane będą tylko te pola, których wartość jest inna niż domyślna.

Ustawienie EmitDefaultValue na false może powodować problemy jeśli IsRequired jest ustawione na true, ponieważ serializer przekaże wartość domyślną, chociaż właściwie powinien w tym miejscu wystąpić wyjątek.

Jeszcze kilka dodatkowych uwag. Jeśli po klasie opatrzonej atrybutem DataContract coś dziedziczy, to pola i właściwości z klasy bazowej zawsze będą przed polami i właściwościami z klasy potomnej. Kolejne będą pola i właściwości klasy potomnej które nie posiadają parametru Order, a następnie te posiadające parametr Order.

EnumMemberAttribute – jak nazwa wskazuje, służy do deklaracji typów wyliczeniowych, jedynym parametrem jest:

  • Value – wartość, która ma być serializowana.

Teraz do kalkulatora możemy przekazywać paczkę z informacjami potrzebnymi do obliczeń:

   1: [ServiceContract(Name = "Calculator",
   2:     Namespace = "http://schemas.eastgroup.pl/2010/02/26/calculator/")]
   3: public interface ICalculator
   4: {
   5:     //(...)
   6:  
   7:     [OperationContract]
   8:     double BinaryOperation(CalculatorOperation calculatorOperation);
   9: }

Jeśli ktoś z Was używał XmlSerializer, to pewnie wie, że przy użyciu tej klasy serializowane są wszystkie pola oprócz tych oznaczonych NonSerializedAttribute. W przypadku DataContractSerializer’a jest odwrotnie: serializowane będą tylko pola opatrzone odpowiednim atrybutem (a pominięte te nieoznaczone żadnym atrybutem). Poniższy kod oddaje to o czym właśnie napisałem (pola PhoneNumber i EmailAddress będą serializowane, a HomeAddress nie):

   1: [Serializable()]
   2: public class ContactInfo
   3: {
   4:     public string PhoneNumber;
   5:  
   6:     public string EmailAddress;
   7:  
   8:     [NonSerialized()]
   9:     public string HomeAddress;
  10: }
  11:  
  12: [DataContract()]
  13: public class ContactInfo
  14: {
  15:     [DataMember()]
  16:     public string PhoneNumber;
  17:  
  18:     [DataMember()]
  19:     public string EmailAddress;
  20:     
  21:     public string HomeAddress;
  22: }

Kolekcje

W WCF’ie możemy używać dowolnych kolekcji (czyli wszystkiego implementującego IEnumerable lub IEnumerable<T>). Nasze dane będą reprezentowane jako tablica, informacje specyficzne dla danej kolekcji zostaną utracone. Np.:

   1: [ServiceContract()]
   2: interface ITaskManager
   3: {
   4:     [OperationContract()]
   5:     List<Task> GetTasksByAssignedName( string name);
   6: }

 

Po drugiej stronie serwisu będzie widoczny jako:

   1: [ServiceContract()]
   2: interface ITaskManager
   3: {
   4:     [OperationContract()]
   5:     Task[] GetTasksByAssignedName( string name);
   6: }

Będzie to obsłużone automatycznie, ale tylko wtedy gdy jest to konkretny typ kolekcji (nie interfejs) i jest on serializowalny (SerializableAttribute)

Co jeśli koniecznie chcemy by przekazywany obiekt pozostał kolekcją? Musimy użyć atrybutu CollectionDataContractAttribute:

   1: [CollectionDataContract(Name = "MyCollectionOf{0}")]
   2: public class MyCollection<T> : IEnumerable<T>
   3: {
   4:     public void Add(T item) { // Etc...
   5:     }
   6:  
   7:     IEnumerator<T> IEnumerable<T>.GetEnumerator() { // Etc...
   8:     }
   9:  
  10:     public IEnumerator GetEnumerator() { // Etc...
  11:     }
  12:  
  13:     // Etc...
  14: }
  15:  
  16:  
  17: [ServiceContract()]
  18: interface ITaskManager
  19: {
  20:     [OperationContract()]
  21:     MyCollection<Task> GetTasksByAssignedName(string name);
  22: }

Nasza kolekcja po drugiej stronie będzie listą:

   1: [CollectionDataContract]
   2: public class MyCollectionOfTask : List<Task>
   3: {}

Jeśli chodzi o typy danych to pozostał jeszcze jeden atrybut do omówienia: KnownTypeAttribute. Używamy go gdy chcemy skorzystać z polimorfizmu. Aby nie utracić informacji z klasy dziedziczącej musimy poinformować o tym, że po typie Task dziedziczy LoanApprovalTask i może on być przekazywany przez nasz serwis.

   1: [DataContract()]
   2: [KnownType(typeof(LoanApprovalTask))]
   3: class Task
   4: {
   5: }
   6:  
   7: [DataContract()]
   8: class LoanApprovalTask : Task
   9: {
  10: }

Kontrakt wiadomości

 

 

 

Mamy już opisane jakimi danymi się posługujemy. Teraz możemy przejść krok dalej i przejąć kontrolę nad całością przekazu. Jeśli chcemy mieć ustaloną przez nas strukturę wiadomości SOAP lub własne nagłówki (np. do przekazywania numeru licencji) to atrybuty MessageContractAttribute, MessageHeaderAttribute i MessageBodyMemberAttribute będą nam do tego potrzebne.

MessageContractAttribute jest używany do oznaczania klas definiujących strukturę komunikatu. Ma kilka parametrów:

  • IsWrapped – jeśli true to wszystko to co miało się znaleźć w ciele komunikatu będzie opakowane w element o nazwie klasy kontraktu lub nazwie podanej w parametrze WrapperName
  • ProtectionLevel – rodzaj zabezpieczeń (podpis, szyfrowanie, …), będzie omówione w kolejnych lekcjach
  • WrapperName – nazwa elementu opakowującego zawartość ciała komunikatu
  • WrapperNamespacenamespace dla elementu opakowującego ciało komunikatu

MessageHeaderAttribute służy do oznaczenia elementów, które mają wejść w skład nagłówka. Ma 6 parametrów:

  • Name – nazwa serializowanego elementu nagłówka
  • Namespace – przestrzeń nazw elementu
  • ProtectionLevel – rodzaj zabezpieczeń
  • Actor – URI wskazujące aktora – cel nagłówka, domyślnie usługa odbierająca
  • MustUnderstand – czy aktor musi zrozumieć nagłówek (jeśli nie zrozumie a ten parametr jest ustawiony na true to aktor musi zgłosić błąd)
  • Relay – czy nagłówek powinien być przekazany do kolejnego odbiorcy jeśli wiadomość nie została przetworzona przez aktora

A atrybutem MessageBodyMemberAttribute oznaczamy elementy, które mają się znaleźć w ciele wiadomości. Parametry:

  • Name – nazwa serializowanego elementu ciała wiadomości
  • Namespace – przestrzeń nazw elementu
  • ProtectionLevel – rodzaj zabezpieczeń
  • Order – kolejność elementów (podobnie jak przy DataMemberAttribute)

Poniżej przykład kodu (myślę że nie wymaga wyjaśniania):

   1: [DataContract()]
   2: public class ContactInfo
   3: {
   4:     [DataMember()]
   5:     public string PhoneNumber;
   6:  
   7:     [DataMember()]
   8:     public string EmailAddress;
   9: }
  10:  
  11: [MessageContract(IsWrapped = false)]
  12: public class ContactInfoRequestMessage
  13: {
  14:     [MessageHeader()]
  15:     public string LicenseKey;
  16: }
  17:  
  18: [MessageContract(IsWrapped = false)]
  19: public class ContactInfoResponseMessage
  20: {
  21:     [MessageBodyMember()]
  22:     public ContactInfo ProviderContactInfo;
  23: }
  24:  
  25: [ServiceContract()]
  26: public interface ISomeService
  27: {
  28:     [OperationContract()]
  29:     [FaultContract(typeof(string))]
  30:     ContactInfoResponseMessage GetProviderContactInfo(
  31:         ContactInfoRequestMessage reqMsg);
  32: }
  33:  
  34: public class SomeService : ISomeService
  35: {
  36:     public ContactInfoResponseMessage GetProviderContactInfo(
  37:         ContactInfoRequestMessage reqMsg)
  38:     {
  39:         if (reqMsg.LicenseKey != ValidLicenseKey)
  40:         {
  41:             const string msg = "Invalid license key.";
  42:             throw new FaultException<string>(msg);
  43:         }
  44:         
  45:         ContactInfoResponseMessage respMsg =
  46:             new ContactInfoResponseMessage();
  47:         respMsg.ProviderContactInfo = new ContactInfo();
  48:         respMsg.ProviderContactInfo.EmailAddress = "sam@fabrikam.com";
  49:         respMsg.ProviderContactInfo.PhoneNumber = "123-456-7890";
  50:         return respMsg;
  51:     }
  52:     
  53:     private const string ValidLicenseKey = "abc-1234-alpha";
  54: }

Wersjonowanie

Jedną z głównych zalet udostępniania usług w postaci serwisów jest odseparowanie klienta od naszej usługi. Ale co zrobimy gdy będzie coś zmienić…

Przy konieczności dodania czegoś do wiadomości DataContractSerializer jest na tyle uprzejmy że jeśli czegoś się nie spodziewa w odebranej paczce to po prostu to ignoruje.

Przy braku oczekiwanego elementu jeśli nie był on wymagany (IsRequired = false) serializer przyjmie wartość null dla typów referencyjnych lub wartości zerowe dla pozostałych. Jeśli element był wymagany, nastąpi wyjątek.

Co w przypadku gdy serwis został rozszerzony a użytkownik ma jeszcze starego klienta i musi przynajmniej zapamiętać dane które dostał z serwisu? A jeśli te dane ma odesłać bo to np. dane autoryzacyjne? Tutaj przychodzi z pomocą IExtensibleDataObject, który umożliwi nam obsługę “nieznanych” danych.

   1: [DataContract(Namespace =
   2:     "http://schemas.fabrikam.com/2008/04/tasks/")]
   3: public class Task : IExtensibleDataObject
   4: {
   5:     [DataMember(IsRequired = true, Order = 1)]
   6:     public string Description;
   7:     // Etc...
   8:  
   9:     public ExtensionDataObject ExtensionData
  10:     {
  11:         get
  12:         {
  13:             return _extensionData;
  14:         }
  15:  
  16:         set
  17:         {
  18:             _extensionData = value;
  19:         }
  20:     }
  21:  
  22:     private ExtensionDataObject _extensionData;
  23: }

W obiekcie _extensionData będą przechowywane wszystkie dane, które klient nie potrafił obsłużyć. Możemy je sobie gdzieś zachować i przy aktualizacji aplikacji z nich skorzystać.

Kontrola serializacji

 

Na koniec jeszcze krótko o samej serializacji. Do dyspozycji mamy 2 serializery: DataContractSerializer i XmlSerializer i 3 enkodery: enkoder tekstowy dla standardowego XML’a, enkoder MTOM oraz binarny WCF-to-WCF (będą opisane w kolejnych lekcjach).

WCF udostępnia dwa atrybuty mówiące o tym który serializer ma być użyty: XmlSerializerFormatAttribute i DataContractFormatAttribute. Oba mają podobne parametry:

  • StyleRpc (remote procedure call) lub Document – styl formatowania SOAP,
  • UseLiteral lub Encoded – czy wstawiać dane “dosłownie” czy jako zakodowane.
   1: [ServiceContract()]
   2: [XmlSerializerFormat(Style=OperationFormatStyle.Rpc,
   3:     Use=OperationFormatUse.Encoded)]
   4: interface ISomeLegacyService
   5: {
   6:     [OperationContract()]
   7:     string SomeOp1( string name);
   8: }
   9:  
  10: [ServiceContract()]
  11: [DataContractFormat(Style=OperationFormatStyle.Rpc)]
  12: interface ISomeRpcService2
  13: {
  14:     [OperationContract()]
  15:     string SomeOp2( string name);
  16: }

Na dzisiaj tyle, w kolejnym artykule pokażemy jak udostępnić nasz serwis światu.

Tagi: , , , , , ,

70-536: Custom Serialization

Poniższy artykuł pochodzi z serii Przygotowań do egzaminu 70-536.

Niestandardowa serializacja pozwala na zwiększenie kontroli nad tym procesem. Dzięki niej możemy także osiągnąć kompatybilność między różnymi wersjami przekształcanych wcześniej obiektów. Mamy dwie metody przeprowadzenia takiej operacji: implementacja interfejsu ISerializable, oraz odpowiadanie na występujące zdarzenia.

Implementacja ISerializable

Wykorzystując interfejs oraz dodając atrybut Serializable możemy nadpisać domyślny mechanizm frameworka. Wymusza to na nas jednak kilka czynności:

  • Implementacja metody GetObjectData, która jest wywoływana podczas serializacji. W przekazanym podczas wywołania obiekcie SerializationInfo ustawiamy wartości, które chcemy przekształcić.
  • Wykorzystanie specjalnego konstruktora, który będzie wywołany przy deserializacji. Konstruktor taki musi akceptować dwa obiekty o typach SerializationInfo i StreamingContext.

Zakładamy, że mamy poniższą klasę:

   1: class Person
   2: {
   3:     private string _name;
   4:     private DateTime _dateOfBirth;
   5:  
   6:     public Person(string name, DateTime dateOfBirth)
   7:     {
   8:         _name = name;
   9:         _dateOfBirth = dateOfBirth;
  10:     }
  11:  
  12:     public string GetName() { return _name; }
  13:     public DateTime GetDateOfBirth() { return _dateOfBirth; }
  14: }

 

Chcemy dodać do niej niestandardową serializację:

   1: [Serializable]
   2: class Person : ISerializable
   3: {
   4:     private string _name;
   5:     private DateTime _dateOfBirth;
   6:  
   7:     public Person(string name, DateTime dateOfBirth)
   8:     {
   9:         _name = name;
  10:         _dateOfBirth = dateOfBirth;
  11:     }
  12:  
  13:     public string GetName() { return _name; }
  14:     public DateTime GetDateOfBirth() { return _dateOfBirth; }
  15:  
  16:     #region ISerializable Members
  17:  
  18:     public void GetObjectData(SerializationInfo info, StreamingContext context)
  19:     {
  20:         info.AddValue("Name", _name);
  21:         info.AddValue("DOB", _dateOfBirth);
  22:     }
  23:  
  24:     #endregion
  25:  
  26:     public Person(SerializationInfo info, StreamingContext context)
  27:     {
  28:         _name = info.GetString("Name");
  29:         _dateOfBirth = info.GetDateTime("DOB");
  30:     }
  31: }

Serializacja i deserializacja:

   1: Person p = new Person("Jan", new DateTime(2003, 3, 13));
   2:  
   3: // Serializacje
   4: Stream stream = File.Open("test.dat", FileMode.Create);
   5: BinaryFormatter bFormatter = new BinaryFormatter();
   6: bFormatter.Serialize(stream, p);
   7: stream.Close();
   8:  
   9: // Deserializacja
  10: Person o;
  11: Stream stream2 = File.Open("test.dat", FileMode.Open);
  12: BinaryFormatter bFormatter2 = new BinaryFormatter();
  13: o = (Person)bFormatter2.Deserialize(stream2);
  14: stream2.Close();
  15:  
  16: Console.Write("{0}, ur. {1}", 
  17:     o.GetName(), 
  18:     o.GetDateOfBirth().ToShortDateString()
  19: ); // Jan, ur. 2003-03-13

Odpowiadanie na zdarzenia

Jeśli korzystamy z klasy BinaryFormatter mamy możliwość korzystania ze zdarzeń, które wspierane są przez .NET Framework. Zdarzenia wywołują metody w naszej klasie, które oznaczone są specjalnymi atrybutami. Mamy cztery typu zdarzeń:

  • Serializing – występuje przed serializacją, metoda oznaczana atrybutem OnSerialazing,
  • Serialized – występuje po serialiacji, metoda oznaczana atrybutem OnSerialized,
  • Deserializing – występuje przed deserializacją, metoda oznaczana atrybutem OnDeserializing,
  • Deserialized – występuje po deserializacji i po wywołaniu IDeserializationCallback.OnDeserialization, metoda klasy zostanie wywołana, jeśli zostanie oznaczona atrybutem OnDeserialized.

Metoda, która ma być wywołana po wystąpieniu zdarzenia musi spełniać pewne wymagania:

  • Przyjmuje obiekt StreamingContext jako parametr,
  • Zwraca void,
  • Poprzedzona jest atrybutem łączącym ją ze zdarzeniem.

Poniższy przykład pokazuje jak utworzyć obiekt, który odpowiada na zdarzenia serializacji:

   1: [Serializable]
   2: class ShoppingCartItem
   3: {
   4:     public Int32 productId;
   5:     public decimal price;
   6:     public Int32 quantity;
   7:     public decimal total;
   8:  
   9:     [OnSerializing]
  10:     void CalculateTotal(StreamingContext sc)
  11:     {
  12:         total = price * quantity;
  13:     }
  14:  
  15:     [OnDeserialized]
  16:     void CheckTotal(StreamingContext sc)
  17:     {
  18:         if (total == 0) { CalculateTotal(sc); }
  19:     }
  20: }

Podsumowanie

W większości wypadków standardowe metody serializacji powinny być wystarczające. Jeżeli chcemy mieć większą władzę możemy wykorzystać bardziej zaawansowane mechanizmy. Implementując interfejs IFormatter lub IGenericFormatter możemy osiągnąć “całkowitą” kontrolę.

Artykuł ten jest ostatnim na temat serializowania obiektów. Następny wpis będzie dotyczył grafiki.

Kolejny artykuł z serii 70-536: Drawing graphics

Tagi: , , ,