Przesyłanie plików w systemach rozproszonych – streaming w WCF

2012-05-10

Często spotykanym wyzwaniem przy budowie systemów rozproszonych jest przesyłanie plików o znacznych rozmiarach (np. rzędu kilkuset MB). Tego typu rozwiązanie w łatwy sposób można zaimplementować w technologii WCF, która pozwala na tworzenie wszechstronnych i wydajnych usług sieciowych. Na przykładowym serwisie WCF pokażę w jaki sposób radzić sobie z wysyłaniem i odbieraniem dużych plików pomiędzy klientem a usługą.

Domyślnym trybem przesyłania danych w WCF jest tryb buforowany (Buffered) co oznacza, że dane przechowywane są w pamięci do czasu odebrania ich w całości i dopiero po zakończeniu transferu stają się dostępne do dalszych operacji (np. ich odczytu przez odbiorcę). Przy operacjach przesyłania dużych plików takie podejście ma negatywny wpływ na wykorzystanie zasobów i wydajność całego rozwiązania. W takim przypadku znacznie lepszym wyborem jest tryb przesyłania strumieniowego (Streamed) i właśnie jemu poświęcę dalszą część tematu. W tym trybie dane mogą być przetwarzane już w trakcie ich przesyłania, nie ma wówczas konieczności oczekiwania na zakończenie transferu.

Prezentowane w tym wpisie rozwiązanie składa się z trzech projektów: serwisu WCF (StreamingService), aplikacji hostującej (StreamingHosting) i klienta korzystającego z metod serwisu (StreamingClient).

Serwis WCF

StreamingService to projekt WCF Service Library będący usługą udostępniającą dwie metody: DownloadFile (po przekazaniu nazwy pliku metoda zwraca klientowi taki plik o ile istnieje) oraz UploadFile (metoda pozwala na wysłanie przez klienta pliku o podanej nazwie). Dodatkowo obie metody zwracają Result typu int (0 – wystąpił błąd, 1 – brak błędów) oraz Message typu string (komunikat w przypadku wystąpienia błędu). Głównymi elementami projektu są:

  • IService.cs – definicje kontraktów serwisu i danych
  • Service.cs – implementacja metod serwisu
  • App.config – konfiguracja serwisu

Żeby skorzystać z trybu przesyłania strumieniowego bardzo ważną rzeczą jest, aby przy definiowaniu kontraktów wiadomości (MessageContract) przesyłany plik (parametr typu Stream) umieszczony był w sekcji Body i był jedyną zawartością tej sekcji. Jeżeli w sekcji Body oprócz pliku znajdzie się jeszcze inny parametr to tryb przesyłania zostanie automatycznie przełączony na buforowanie. W związku z powyższym, aby działał tryb Streamed wszystkie dodatkowe parametry muszą być przekazywane w nagłówku wiadomości (MessageHeader), natomiast sam obiekt Stream w treści wiadomości (MessageBodyMember).

Jeszcze jedną ważną rzeczą jest implementacja interfejsu IDisposable przez klasy kontraktów wiadomości (MessageContract), które są wynikiem metod przesyłających pliki z serwisu do klientów. Metoda Dispose() ma na celu zwolnienie uchwytu do przesyłanego pliku po zakończeniu transferu (zamknięciu wiadomości).

Kod pliku IService.cs:

namespace StreamingService
{
    [ServiceContract]
    public interface IService
    {
        [OperationContract]
        DownloadFileResponse DownloadFile(DownloadFileRequest request);

        [OperationContract]
        UploadFileResponse UploadFile(UploadFileRequest request);
    }

    #region DownloadFile Contracts

    [MessageContract]
    public class DownloadFileRequest
    {
        [MessageHeader]
        public DownloadFileRequestHeader downloadFileRequestHeader;
    }

    [DataContract]
    public class DownloadFileRequestHeader
    {
        [DataMember]
        public string FileName;
    }

    [MessageContract]
    public class DownloadFileResponse : IDisposable
    {
        [MessageHeader]
        public DownloadFileResponseHeader downloadFileResponseHeader;

        [MessageBodyMember]
        public Stream File;

        public void Dispose()
        {
            if (File != null)
            {
                File.Close();
                File = null;
            }
        }
    }

    [DataContract]
    public class DownloadFileResponseHeader
    {
        [DataMember]
        public int Result;

        [DataMember]
        public string Message;
    }

    #endregion

    #region UploadFile Contracts

    [MessageContract]
    public class UploadFileRequest
    {
        [MessageHeader]
        public UploadFileRequestHeader uploadFileRequestHeader;

        [MessageBodyMember]
        public Stream File;
    }

    [DataContract]
    public class UploadFileRequestHeader
    {
        [DataMember]
        public string FileName;
    }

    [MessageContract]
    public class UploadFileResponse
    {
        [MessageHeader]
        public UploadFileResponseHeader uploadFileResponseHeader;
    }

    [DataContract]
    public class UploadFileResponseHeader
    {
        [DataMember]
        public int Result;

        [DataMember]
        public string Message;
    }

    #endregion
}

Implementacja metod serwisu znajduje się w pliku Service.cs:

namespace StreamingService
{
    public class Service : IService
    {
        public DownloadFileResponse DownloadFile(DownloadFileRequest request)
        {
            int result = 1;
            string message = "";
            DownloadFileResponse response = new DownloadFileResponse();
            DownloadFileResponseHeader responseHeader = new DownloadFileResponseHeader();

            string filePath = ConfigurationManager.AppSettings["FilePath"].ToString() +
                request.downloadFileRequestHeader.FileName;

            if (File.Exists(filePath))
                response.File = File.OpenRead(filePath);
            else
            {
                response.File = Stream.Null;
                result = 0;
                message = "Nie odnaleziono pliku!";
            }

            responseHeader.Result = result;
            responseHeader.Message = message;
            response.downloadFileResponseHeader = responseHeader;

            return response;
        }

        public UploadFileResponse UploadFile(UploadFileRequest request)
        {
            int result = 1;
            string message = "";
            UploadFileResponse response = new UploadFileResponse();
            UploadFileResponseHeader responseHeader = new UploadFileResponseHeader();

            string fileName = request.uploadFileRequestHeader.FileName;

            if (string.IsNullOrWhiteSpace(fileName))
            {
                result = 0;
                message = "Nie podano nazwy pliku!";
            }
            else
            {
                try
                {
                    string filePath =
                        ConfigurationManager.AppSettings["FilePath"].ToString() + fileName;
                    using (FileStream fs = new FileStream(filePath, FileMode.Create))
                    {
                        //1MB buffer
                        int bufferSize = 1048576;
                        byte[] buffer = new byte[bufferSize];
                        int bytes;

                        while ((bytes = request.File.Read(buffer, 0, bufferSize)) > 0)
                        {
                            fs.Write(buffer, 0, bytes);
                            fs.Flush();
                        }
                    }
                }
                catch (Exception e)
                {
                    result = 0;
                    message = e.Message;
                }
            }

            responseHeader.Result = result;
            responseHeader.Message = message;
            response.uploadFileResponseHeader = responseHeader;

            return response;
        }
    }
}

Ostatnim elementem projektu usługi jest plik konfiguracyjny. Najważniejsze ustawienia znajdują się w sekcji <binding>:

  • transferMode – tryb przesyłania danych (Buffered/Streamed)
  • receiveTimeout – maksymalny czas działania metody odbierającej dane od klienta (metoda UploadFile)
  • sendTimeout – maksymalny czas działania metody wysyłającej dane do klienta (metoda DownloadFile)
  • maxReceivedMessageSize – maksymalny rozmiar wiadomości w bajtach, przy trybie Streamed może być ustawiony na maksymalną wartość ponieważ wiadomość nie jest buforowana w całości
  • maxBufferSize – maksymalna ilość pamięci w bajtach jaką WCF przeznacza na buforowanie, ważne jest aby wartość ta nie była mniejsza niż wielkość paczek w jakich odczytywane są dane (metoda UploadFile, zmienna bufferSize)

Ważnym parametrem w przypadku udostępniania usługi poprzez np. IIS jest maxRequestLength w sekcji <httpRuntime>. Kontroluje on maksymalną wielkość wiadomości jaką klient może wysłać do serwisu (podawana w kilobajtach).

Plik konfiguracyjny usługi App.config:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <appSettings>
    <add key="FilePath" value="D:\Streaming\Data\" />
  </appSettings>
  <system.web>
    <httpRuntime maxRequestLength="512000" />
  </system.web>
  <system.serviceModel>
    <bindings>
      <basicHttpBinding>
        <binding name="BasicHttpBinding_IService"
                 transferMode="Streamed"
                 closeTimeout="00:10:00" openTimeout="00:10:00"
                 receiveTimeout="01:00:00" sendTimeout="01:00:00"
                 maxBufferSize="2147483647" maxBufferPoolSize="2147483647"
                 maxReceivedMessageSize="2147483647"
                 messageEncoding="Text" textEncoding="utf-8"
                 allowCookies="false" bypassProxyOnLocal="false"
                 hostNameComparisonMode="StrongWildcard" useDefaultWebProxy="true">
          <readerQuotas maxDepth="2147483647" maxStringContentLength="2147483647"
                        maxArrayLength="2147483647" maxBytesPerRead="2147483647"
                        maxNameTableCharCount="2147483647" />
          <security mode="None">
            <transport clientCredentialType="None" proxyCredentialType="None" realm="" />
            <message clientCredentialType="UserName" algorithmSuite="Default" />
          </security>
        </binding>
      </basicHttpBinding>
    </bindings>
    <services>
      <service name="StreamingService.Service">
        <endpoint address="" binding="basicHttpBinding"
                  bindingConfiguration="BasicHttpBinding_IService"
                  contract="StreamingService.IService" />
        <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
        <host>
          <baseAddresses>
            <add baseAddress="http://localhost:8732/StreamingService.Service" />
          </baseAddresses>
        </host>
      </service>
    </services>
    <behaviors>
      <serviceBehaviors>
        <behavior>
          <!-- To avoid disclosing metadata information,
          set the value below to false and remove the metadata endpoint above before deployment -->
          <serviceMetadata httpGetEnabled="True"/>
          <!-- To receive exception details in faults for debugging purposes,
          set the value below to true.  Set to false before deployment
          to avoid disclosing exception information -->
          <serviceDebug includeExceptionDetailInFaults="False" />
        </behavior>
      </serviceBehaviors>
    </behaviors>
  </system.serviceModel>
</configuration>

Hosting

W systemach produkcyjnych serwisy udostępniane są np. poprzez IIS, jednak aby nie komplikować prezentowanego rozwiązania ja w tym celu stworzyłem prostą aplikację konsolową (StreamingHosting). Jedyne co trzeba zrobić to dodać  referencje do System.ServiceModel, referencje do naszego projektu usługi WCF (StreamingService) oraz skopiować plik konfiguracyjny usługi. Plik Program.cs zawiera następujący kod:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Uruchamianie usługi...");
        using (ServiceHost host = new ServiceHost(typeof(Service)))
        {
            try
            {
                host.Open();
                Console.WriteLine("Usługa uruchomiona. Naciśnij Enter aby zakończyć.");
                Console.ReadLine();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.Message);
            }
        }
        Console.WriteLine("Usługa zatrzymana.");
        Console.ReadLine();
    }
}

Po uruchomieniu aplikacji nasz serwis będzie dostępny pod adresem wskazanym w pliku konfiguracyjnym w sekcji <baseAddresses>.

Może się zdarzyć, że próba uruchomienia aplikacji zakończy się następującym błędem:
"HTTP could not register URL http://+:8732/StreamingService.Service. Your process does not have access rights to this namespace (see http://go.microsoft.com/fwlink/?LinkId=70353 for details)."
Błąd ten oznacza brak możliwości nasłuchiwania żądań przychodzących po http. Rozwiązaniem tego problemu jest wywołanie następującej instrukcji z poziomu wiersza poleceń uruchomionego z prawami administratora (należy podać właściwą nazwę użytkownika):

netsh http add urlacl url=http://+:8732/StreamingService.Service/ user=UserName

Jeżeli nasłuchiwanie nie jest nam już potrzebne, poniższe polecenie przywraca stan pierwotny:

netsh http delete urlacl url=http://+:8732/StreamingService.Service/

Klient

Ostatnim elementem realizowanego rozwiązania jest aplikacja kliencka (StreamingClient) korzystająca z metod usługi. Aby możliwa była współpraca z udostępnionym serwisem konieczne jest dodanie do niego referencji (Service References) i jej skonfigurowanie zgodnie z poniższym screenem:

Poniżej znajduje się implementacja metod pozwalających na pobieranie i wysyłanie plików z/do serwisu WCF z wykorzystaniem jego metod DownloadFile i UploadFile:

public void DownloadFileFromService(string fileName)
{
    StreamingService.DownloadFileRequest request =
        new StreamingService.DownloadFileRequest();
    StreamingService.DownloadFileRequestHeader requestHeader =
        new StreamingService.DownloadFileRequestHeader();
    StreamingService.DownloadFileResponse response;

    using (StreamingService.ServiceClient sc = new StreamingService.ServiceClient())
    {
        requestHeader.FileName = fileName;
        request.downloadFileRequestHeader = requestHeader;
        response = sc.DownloadFile(request);
    }

    if (response.downloadFileResponseHeader.Result == 1)
    {
        try
        {
            string filePath =
                ConfigurationManager.AppSettings["ClientFilePath"].ToString() + fileName;
            using (FileStream fs = new FileStream(filePath, FileMode.Create))
            {
                //1MB buffer
                int bufferSize = 1048576;
                byte[] buffer = new byte[bufferSize];
                int bytes;

                while ((bytes = response.File.Read(buffer, 0, bufferSize)) > 0)
                {
                    fs.Write(buffer, 0, bytes);
                    fs.Flush();
                }
            }
            MessageBox.Show("Zakończono pobieranie pliku.");
        }
        catch (Exception e)
        {
            MessageBox.Show(e.Message);
        }
    }
    else
        MessageBox.Show(response.downloadFileResponseHeader.Message);
}

public void UploadFileToService(string fileName)
{
    StreamingService.UploadFileRequest request =
        new StreamingService.UploadFileRequest();
    StreamingService.UploadFileRequestHeader requestHeader =
        new StreamingService.UploadFileRequestHeader();
    StreamingService.UploadFileResponse response;
    string filePath =
        ConfigurationManager.AppSettings["ClientFilePath"].ToString() + fileName;

    if (!File.Exists(filePath))
    {
        MessageBox.Show("Nie odnaleziono pliku!");
        return;
    }

    requestHeader.FileName = fileName;
    request.uploadFileRequestHeader = requestHeader;

    using (FileStream fs = new FileStream(filePath, FileMode.Open))
    {
        request.File = fs;
        using (StreamingService.ServiceClient sc = new StreamingService.ServiceClient())
        {
            response = sc.UploadFile(request);
        }
    }

    if (response.uploadFileResponseHeader.Result == 1)
        MessageBox.Show("Wysłano plik!");
    else
        MessageBox.Show(response.uploadFileResponseHeader.Message);
}

Plik konfiguracyjny aplikacji klienckiej wygląda następująco:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <appSettings>
    <add key="ClientFilePath" value="D:\Streaming\ClientData\" />
  </appSettings>
  <system.serviceModel>
    <bindings>
      <basicHttpBinding>
        <binding name="BasicHttpBinding_IService"
                 transferMode="Streamed"
                 closeTimeout="00:10:00" openTimeout="00:10:00"
                 receiveTimeout="01:00:00" sendTimeout="01:00:00"
                 maxBufferSize="2147483647" maxBufferPoolSize="2147483647"
                 maxReceivedMessageSize="2147483647"
                 messageEncoding="Text" textEncoding="utf-8"
                 allowCookies="false" bypassProxyOnLocal="false"
                 hostNameComparisonMode="StrongWildcard" useDefaultWebProxy="true">
          <readerQuotas maxDepth="2147483647" maxStringContentLength="2147483647"
                        maxArrayLength="2147483647" maxBytesPerRead="2147483647"
                        maxNameTableCharCount="2147483647" />
          <security mode="None">
            <transport clientCredentialType="None" proxyCredentialType="None"
                realm="" />
            <message clientCredentialType="UserName" algorithmSuite="Default" />
          </security>
        </binding>
      </basicHttpBinding>
    </bindings>
    <client>
      <endpoint address="http://localhost:8732/StreamingService.Service"
          binding="basicHttpBinding" bindingConfiguration="BasicHttpBinding_IService"
          contract="StreamingService.IService" name="BasicHttpBinding_IService" />
    </client>
  </system.serviceModel>
</configuration>

Podsumowanie

Przedstawione rozwiązanie pozwala na efektywne przesyłanie plików w systemach rozproszonych, a dzięki zastosowaniu trybu Streamed ich rozmiar nie stanowi problemu. W połączeniu z mechanizmem ładowania do bazy dużych zbiorów danych (omawianym przeze mnie w tym wpisie) możliwe jest tworzenie wydajnych rozwiązań zdalnego pobierania/wysyłania danych. Więcej o WCF i trybie streamingu można przeczytać na MSDN: link1, link2, link3.

Reklamy

Posted on 2012-05-10, in .NET/C# and tagged , , , , . Bookmark the permalink. 4 Komentarze.

  1. Przepraszam gdzie można znaleźć kod źródłowy rozwiązania ??

    • Niestety nie mam technicznych możliwości zamieszczania na blogu gotowych projektów. Zaprezentowany w tym temacie kod jest wystarczający do odtworzenia całej omawianej solucji. Niemniej jeżeli zależy Ci na gotowym rozwiązaniu odezwij się na mojego maila (dział Informacje).

  2. Nie wiem dlaczego w projekcie klienta nie widzi pol, które maja atrybut [MessageHeader].

  1. Pingback: Podsumowanie 2012 « Developer notes

Skomentuj

Wprowadź swoje dane lub kliknij jedną z tych ikon, aby się zalogować:

Logo WordPress.com

Komentujesz korzystając z konta WordPress.com. Wyloguj / Zmień )

Zdjęcie z Twittera

Komentujesz korzystając z konta Twitter. Wyloguj / Zmień )

Zdjęcie na Facebooku

Komentujesz korzystając z konta Facebook. Wyloguj / Zmień )

Zdjęcie na Google+

Komentujesz korzystając z konta Google+. Wyloguj / Zmień )

Connecting to %s

%d blogerów lubi to: