Spis treści:

Kategoria:C#FILESTREAMSQL ServerASP.NET MVC


Strumieniowa obsługa plików w SQL Server i ASP.NET MVC

Obsługa bardzo dużych plików

Ikona do artykułu, obsługa bardzo dużych plików ASP.NET MVC

Dużo jest aplikacji, które w jakiś sposób operują na plikach. Dodawanie załączników, przeszukiwanie plików, import danych, raporty - wszystko to w ostateczności może być plikiem. Pomińmy na razie pochodzenie plików, a zastanówmy się nad średnim ich rozmiarem. Załączniki zwykle nie przekraczają kilku, kilkunastu megabajtów - te większe odrzucane są przez serwery pocztowe. Raporty Word lub PDF też rzadko przekraczają kilka megabajtów. Załączniki w postaci zdjęć i obrazków to ten sam rząd wielkości. Co jednak wtedy, gdy musimy obsługiwać pliki o rozmiarze rzędu kilkuset megabajtów lub nawet takie, które przekraczają gigabajt? I to jeszcze przez sieć? Okazuje się, że nie jest to takie łatwe. Nie pomaga nawet zdjęcie wszelkich limitów ustawionych domyślnie w konfiguracjach różnych komponentów. Naszym oczom ukazuje się magiczny wyjątek: OutOfMemoryException. O tyle magiczny, że nie dający się naprawić jedną prostą instrukcją if lub zmianą jakiejś wartości. Blok obsługi wyjątków też nie jest rozwiązaniem. To właśnie dlatego postanowiłem zająć się tym tematem. I to od razu na całym obszarze - od bazy danych, poprzez logikę biznesową i serwer ASP.NET. Duże pliki dostępne z poziomu przeglądarki.

Strumienie .NET i FILESTREAM

Dawno dawno temu, już nawet dobrze nie wiadomo kiedy, w latach 80' ubiegłego stulecia, pojawiła się koncepcja strumieniowego przesyłania i przetwarzania danych. Języki takie jak Java, C, C++ praktycznie od razu, pod postacią odpowiednich bibliotek, zaakceptowały ten stan rzeczy. Systemy operacyjne, w tym Windows, Unix i Linux, również w jakiś sposób w ten trend się wpisały. Długo, długo później zaprezentowano .NET. Od pierwszej wersji obsługa plików opierała się w głównej mierze na klasie Stream, reprezentanta strumienia. Co się w tym czasie działo w SQL Server? Generalnie rzecz biorąc wszystkie rekordy zwracane z bazy danych były traktowane jak strumień. Sam protokół SQL Server nosi nazwę TDS - Tabular Data StreamTDS to protokół do przesyłania danych pomiędzy serwerem baz danych a aplikacjami klienckimi zaprojektowany przez Sybase w 1984 roku. Relacyjna baza danych Sybase była bezpośrednim przodkiem produktów z rodziny SQL Server.. Problem strumienia SQL Server ujawniał się jednak w przypadku obsługi dużych obiektów (LOB). Każda taka wartość domyślnie traktowana była jako niepodzielna, atomowa jednostka. Zapisanie lub odczytanie takiej wartości powodowało konieczność odtworzenia jej w pamięci komputera. Połączenie tego z koniecznością przetworzenia ich na żądanie sieciowe, czasami policzenie sum kontrolnych, zabezpieczenie komunikacji, nierzadko powodowało jeszcze większe zapotrzebowanie na pamięć operacyjną. Przypomnę, że tradycyjna 32-bitowa aplikacja Windows dostawała 2 GB pamięci wirtualnej i tyle musiało jej wystarczyć do wszystkich zadań. Przydział pamięci udawał się tylko wtedy, gdy pożądana porcja pamięci była ciągłym obszarem. Jednym słowem: problemy na każdym kroku, kłody pod nogi i wiatr, deszcz ze śniegiem w oczy. System operacyjny takie duże pliki również przetwarza strumieniowo, po kawałeczku. SQL Server również pozwalał takie duże obiekty przetwarzać porcjami, sekwencyjnieMechanizm ADO pozwalał wykonać polecenie SELECT z funkcją sekwencyjnego dostępu do danych. Dane pobierało się wtedy w paczkach dowolnego rozmiaru i to od programisty zależało w jaki sposób te paczki będzie przetwarzał. W ADO.NET do obiektu klasy SqlCommand przekazywało się parametr typu wyliczeniowego CommandBehavior.SequentialAccess., ale wewnętrzny mechanizm zapewniania spójności transakcji długo pozostawał ten sam. Do czasu. Pojawiła się nowa wersja silnika baz danych, SQL Server 2008, a wraz z nią nieco inny sposób obsługi bardzo dużych obiektów - FILESTREAM. To tak, jakby typem kolumny w bazie danych stał się zwykły strumień, znany od wielu lat w systemach operacyjnych, lub klasa Stream .NET dla tych, którzy tę platformę preferują. Nowe możliwości to nowe API oraz nowe techniki programistyczne, które warto poznać. Przejdźmy zatem do konkretów i zobaczmy, jak to się robi.

Plan aplikacji SQL Server FILESTREAM + ASP.NET MVC

Aplikacja będzie nieco bardziej skomplikowana niż te, które zwykle prezentuję. Staram się zawsze wyodrębnić to, co najważniejsze. Tutaj też tak będzie, ale rozległość tematyki automatycznie zwiększa to minimum. SQL, .NET, HTML, JavaScript, CSS - każdy z tych skrótów-technologii pełni swoją funkcję i każdy kawałeczek wpływa na ogólny rozmiar całości. Każdy z tych kawałeczków postaram się jednak odseparować od innych tak, aby możliwe było podmienienie zaprezentowanych komponentów i wykonanie podobnej aplikacji w innych technologiach, na przykład WPF+WCF+SQL.

Postaram się przy okazji zaprezentować kilka ważnych decyzji projektowych, które mają wpływ na kształt aplikacji końcowej. Wiele rzeczy można zrobić źle, dlatego w odpowiednich miejscach pozwolę sobie wyjaśnić potrzebę stosowania niektórych rozwiązań i ich zasadność.

Aplikacja będzie realizować dwie podstawowe funkcje - wstawianie dużych plików na serwer oraz pobieranie tych dużych plików z serwera. Cała reszta będzie tylko otoczką. Wszystkie ścieżki, którymi będą przesyłane pliki, będą zapewniały tryb strumieniowy. W żadnym momencie plik nie będzie w całości przechowywany w pamięci operacyjnej co pozwoli na przetwarzanie praktycznie nieograniczonych rozmiarem obiektów. Przyjrzyjmy się zatem kolejnym warstwom aplikacji.

Warstwa bazy danych SQL Server

Baza danych będzie chyba najłatwiejszym w implementacji składnikiem. Tabela będzie się składała z trzech kolumn pełniących różne funkcje i będą to:

  • globalny identyfikator pliku - kolumna wymagana przez FILESTREAM, jednoznacznie wskazuje plik w przestrzeni całej bazy, aplikacji,
  • nazwa pliku - kolumna zwykła, reprezentująca dowolne dane małe (w przeciwieństwie do kolumn dużych obiektów), traktowana jako niestrumieniowa część zapytania i obiektów .NET,
  • zawartość pliku - kolumna na duże dane, reprezentuje obszary, które muszą być przesyłane strumieniowo.

Kolumny można tworzyć dowolnie i może być ich więcej, ale nie wniosłyby one niczego nowego do przykładów poza złożonością. Kolumn zwykłych może być na przykład pięć, z czego jedna mogłaby być kluczem obcym innej tabeli, inna mogłaby oznaczać atrybut pliku, jeszcze inna typ MIME. Kolejne zawierałyby informacje o użytkowniku, który załączył dany plik oraz o dacie tego zdarzenia. Nie ma to żadnego wpływu na cały proces i jest osiągalne przy drobnych modyfikacjach.

Operacje wykonywane w aplikacji będą wykorzystywały tylko jedną tabelę, a sama tabela będzie następuąca:

CREATE TABLE dbo.Files
(
  FileID UNIQUEIDENTIFIER ROWGUIDCOL NOT NULL
      CONSTRAINT UQ_Pliki UNIQUE
      CONSTRAINT DF_FileID DEFAULT NEWID(),
  Name nvarchar(255) NOT NULL,
  Content varbinary(MAX) FILESTREAM NOT NULL,
)

Trzy kolumny o jasnym przeznaczeniu: globalny identyfikator pliku, nazwa oraz dane. Na tym w zasadzie kończy się cała konfiguracja na poziomie bazy danych. Warto nadmienić, że zwykłego limitu na rozmiar pola varbinary(MAX), 2 GB, nie stosuje się do kolumny typu FILESTREAM. Dla takiej kolumny limitem rozmiaru staje się system operacyjny, który pliki będzie przechowywał. Już samo to pozwala zwiększyć potencjał bazy danych do przechowywania obiektów bardzo dużych

Celowo nie zamieszczałem też definicji indeksu grupującego. Nie ma potrzeby, żeby stosować go do kolumny będącej globalnym identyfikatorem. Gdyby okazało się, że lepiej pogrupować pliki po ścieżkach, nazwach lub jeszcze innym identyfikatorze, można to bez problemu uczynić. Indeks na globalnym identyfikatorze ma pewne właściwości, które powodują problemy z jego utrzymywaniem. Więcej na ten temat można znaleźć tutaj: Klucz główny INT i GUID - porównanie.

Wspomnę przy okazji, że rozmiar pliku o wartości 255 został wybrany nieprzypadkowo. Ci, którzy pamiętają jeszcze czasy programowania w Windows przy pomocy czystego Win32 API wiedzą dobrze co to za limit. Zdefiniowany jest on przy pomocy następującego wyrażenia preprocesora C:

#DEFINE MAX_PATH 260

To maksymalna długość całej ścieżki wielu funkcji Win32 API. Wartość 260 zawiera oprócz ścieżki literę dysku, dwukropek, ukośnik oraz znacznik końca łańcucha znaków - 0. Daje to maksymalnie 256 znaków, ale Windows ogranicza to jeszcze bardziej - pojedynczy komponent ścieżki nie może przekraczać 255System plików NTFS pozwala obejść te ograniczenia i pozwala na ścieżki o długości równe w przybliżeniu 215 (32768). Aby zapewnić zgodność z systemem FAT32 oraz większością aplikacji należy pamiętać o właściwym rozmiarze pliku. Jeżeli ścieżka zawiera literę dysku, trzy katalogi po 20 znaków wtedy mamy:

260-3(litera dysku, dwukropek, średnik)-20(katalog)-1(ukośnik)-20-1-20-1-1(znacznik końca)=193 znaki
Warto o tym pamiętać projektując różne rozwiązania. Jeżeli system nie zachowuje pierwotnych nazw i działa tylko w przestrzeni SQL (przy pobieraniu może się generować jakiś inny identyfikator), limity oczywiście nie obowiązują.. To taka ciekawostka, nawiązanie do historii, z którą od czasu do czasu trzeba się zmierzyć. Przejdźmy teraz do funkcji realizujących odczyt i zapis danych strumieniowych.

Metody C# do odczytu i zapisu strumienia w FILESTREAM

Budowę aplikacji zaczęliśmy od bazy danych. Teraz czas na warstwę odczytującą i zapisującą poszczególne pliki.

Strumieniowy zapis FILESTREAM

Popatrzmy na metodę do zapisu pokazaną na poniższym listingu:

public void Save(FileData data)
{
    using (var conn = OpenConnection())
    using (var tran = conn.BeginTransaction())
    using (var cmd = new SqlCommand(
        "INSERT dbo.Files(Name, Content) "+
        "OUTPUT inserted.Content.PathName(), GET_FILESTREAM_TRANSACTION_CONTEXT() "+
        "VALUES(@name, 0x)", conn, tran))
    {
        cmd.Parameters.AddWithValue("@name", data.Name);
        using (var reader = cmd.ExecuteReader())
        {
            while (reader.Read())
            {
                string filePath = (string)reader[0];
                byte[] NTFSContext = (byte[])reader[1];
                using (var dbStream = new SqlFileStream(filePath, NTFSContext, FileAccess.Write))
                {
                    data.File.CopyTo(dbStream);
                }
            }
        }
        tran.Commit();
    }
}

Zanim przejdę do krótkiego wyjaśnienia warto zapoznać się z klasą FileData oraz metodą OpenConnection. Poniżej klasa FileData, która nie wymaga wyjaśnień:

public class FileData
{
    public string Name { get; set; }
    public Stream File { get; set; }
}

Metoda OpenConnection też jest prosta:

private SqlConnection OpenConnection()
{
    var connection = new SqlConnection(ConfigurationManager.ConnectionStrings["ConnectionString"].ConnectionString);
    connection.Open();
    return connection;
}

Wróćmy jednak na chwilę do metody Save, bo to ona jest najważniejsza. Spora jej część nie różni się niczym szczególnym od zwykłej metody realizującej operację INSERT w ADO.NET. Otwieramy połączenie, rozpoczynamy transakcję, tworzymy polecenie SQL. Warto już w tym momencie zwrócić uwagę na samą treść zapytania i parametry tego zapytania. Wstawiane są tutaj wszystkie kolumny niestrumieniowe (w naszym przypadku jedna), a do kolumny strumieniowej wstawiana jest wartość 0x. Jest to pusta tablica bajtów, plik bez zawartości. Skoro kolumna nie przyjmuje NULL, bo takie przyjąłem założenie, trzeba tam coś wstawić. Drugi ważny element to sekcja OUTPUT, która pozwala zwrócić z zapytania modyfikującego dane rezultat operacji. Wykorzystuje się tę sekcję najczęściej do pobrania wartości kolumn, które ustawiane są przez bazę danych (IDENTITY, wartości domyślne, daty bieżące ustawiane przez serwer baz danych). W tym przypadku pobierana jest ścieżka do fizycznego pliku, który zawiera dane kolumny FILESTREAM oraz uchwyt (identyfikator, kontekst) transakcji NTFS. Ścieżka pobierana jest w stylu obiektowym, dzięki metodzie PathNameWiele jest typów w SQL Server, które przejmują obiektowy sposób wywoływania metod. Warto wiedzieć, że baza danych SQL Server może przechować praktycznie dowolny obiekt .NET, wraz z metodami. Co więcej, metody te można potem wywoływać! Część tych możliwości można zobaczyć korzystając z metod typu xml oraz, mniej powszechnych, przestrzennych reprezentantów - geometry oraz geography. Typy te są szczególne (obsługa wbudowana w T-SQL, specjalne indeksy), ale ich obiektowa natura wyróżnia je na tle zwykłych typów. wywołanej na kolumnie FILESTREAM. Transakcję pobiera się przy pomocy funkcji GET_FILESTREAM_TRANSACTION_CONTEXT. Dlaczego ta metoda jest potrzebna? Trzeba wiedzieć, że celem bazy danych jest nie tylko zapewnienie metod do zapisu i odczytu danych, ale także ich spójności. Zwykłe operacje INSERT działają atomowo. Oznacza to, że nawet w przypadku jakiejś awarii, błędu lub innej niespodziewanej sytuacji nie znajdziemy się w sytuacji, w której część kolumn zostanie zapisanych, a część nie. Wymaga się od bazy, aby również kilka następujących po sobie instrukcji było wykonanych jako jedna atomowa operacja. Jeżeli przelewamy pieniądze z konta na konto to chcemy, aby operacja odejmowania na jednym i dodawania na drugim koncie wykonały się w całości albo wcale. Nie chcemy, aby pieniądze nam po drodze zginęły! To zadanie transakcji. Wiemy z drugiej strony, że FILESTREAM zapisuje pliki nie w bazie, lecz w systemie plików systemu operacyjnego. Mamy zatem do czynienia z dwoma systemami, które różnie realizują transakcyjność, każdy po swojemu. Potrzeba nam zatem czegoś, co nazywamy transakcją rozproszoną. Baza danych, o ile operacja odbywa się w transakcji, może zwrócić wykorzystywany przez FILESTREAM identyfikator transakcji NTFS. Identyfikator ten ważny jest tak długo, jak ważna jest transakcja SQL.

Gdy już uda nam się pobrać ścieżkę do pliku oraz transakcję NTFS zwracaną w postaci tablicy bajtów, tworzymy obiekt klasy SqlFileStream. Jest to klasa, która dziedziczy z dobrze znanej w środowisku .NET klasy Stream. Wystarczy wobec tego zwyczajnie skopiować dane z jednego strumienia, wejściowego, do drugiego, bazodanowego.

Strumieniowy odczyt FILESTREAM

Odczyt strumienia z bazy danych jest bardzo podobny do zapisu. Biorą w nim udział te same klasy i podobne techniki. Jest jednak pewien problem. Popatrzmy na poniższy przykład:

public Stream Get(string name)
{
    using (var conn = OpenConnection())
    using (var tran = conn.BeginTransaction())
    using (var cmd = new SqlCommand(
        "SELECT Content.PathName() [Path], GET_FILESTREAM_TRANSACTION_CONTEXT() [TransactionContext] " +
        "FROM dbo.Files F WHERE F.Name=@name", conn, tran))
    {
        cmd.Parameters.AddWithValue("@name", name);

        using (SqlDataReader reader = cmd.ExecuteReader())
        {
            if (reader.Read())
            {
                string path = (string)reader[0];
                byte[] NTFSContext = (byte[])reader[1];
                //Wewnątrz transakcji bazodanowej FileStream jest ważny
                return new SqlFileStream(path, NTFSContext, FileAccess.Read);
            }
        }
        return null;
    }
    //Po zakończeniu transakcji bazodanowej, czyli poza blokiem using,
    //także poza metodą, uchwyt pliku FileStream traci ważność
}

Próba odczytania danych strumienia poza metodą zakończy się błędem o następującej treści:

An exception of type 'System.IO.IOException' occurred in mscorlib.dll but was not handled in user code

Additional information: The handle is invalid.

W polskiej wersji językowej jest jeszcze trudniej, bo komunikat brzmi: Nieprawidłowe dojście. Przyznam, że angielski komunikat jest dla mnie bardziej czytelny, ale to pewnie kwestia przyzwyczajenia.

Jak rozwiązać problem? Bardzo prosto. Aby przekazać zdatny do odczytu strumień należy pozostawić otwartą transakcję SQL oraz aktywne połączenie. Przekazywanie transakcji, połączenia, strumienia i być może innych danych bardzo szybko może się okazać złym rozwiązaniem. Na dłuższą metę znacznie wygodniej zamknąć całość w odpowiedni komponent. Ja postanowiłem stworzyć klasę OpenSqlFileStream, która będzie dziedziczyła z bazowej klasy Stream i implementowała interfejs IDisposable. Dla zewnętrznych komponentów obsługa strumienia będzie taka sama jak obsługa strumienia ze zwykłego pliku. Umieszczenie go w sekcji using zapewni poprawne zwolnienie zasobów. Ponadto, co istotne w przypadku ASP.NET MVC, obróbką strumienia zajmują się gotowe już klasy ActionResult. Wszystkie te, które przetwarzają strumienie, zwolnią je po zakończeniu transmisji. Zwolnią, czyli wykonają metodę Dispose. Implementacja rzeczonej klasy będzie wyglądała następująco:

public class OpenSqlFileStream : Stream, IDisposable
{
    private SqlFileStream Stream { get; set; }
    private DbConnection Connection { get; set; }
    private DbTransaction Transaction { get; set; }

    public OpenSqlFileStream(SqlFileStream stream, DbConnection connection, DbTransaction transaction) :
        base()
    {
        Stream = stream;
        Connection = connection;
        Transaction = transaction;
    }
    public void Dispose()
    {
        Transaction.Dispose();
        Connection.Dispose();
        Stream.Dispose();
    }

    public override bool CanRead
    {
        get { return Stream.CanRead; }
    }
...
    public override int Read(byte[] buffer, int offset, int count)
    {
        return Stream.Read(buffer, offset, count);
    }
...

Celowo pominąłem implementację pozostałych właściwości i metod. Nie robią one nic innego jak tylko przekazanie wywołania do opakowanej klasy strumienia SQL. Całość można obejrzeć w zamieszczonym na końcu archiwum z projektem całej aplikacji. Taki, chciałoby się powiedzieć oszukany, strumień można przekazać poza metodę pobierającą dane. Popatrzmy na implementację tej metody:

public Stream Get(string name)
{
    //Do not close or release connection and transaction,
    //FileResult closes them automatically.
    var conn = OpenConnection();
    var tran = conn.BeginTransaction();
    var cmd = new SqlCommand(
        "SELECT Content.PathName() [Path], GET_FILESTREAM_TRANSACTION_CONTEXT() [TransactionContext] " +
        "FROM dbo.Files F WHERE F.Name=@name", conn, tran);
    cmd.Parameters.AddWithValue("@name", name);

    using (SqlDataReader reader = cmd.ExecuteReader())
    {
        if (reader.Read())
        {
            string path = (string)reader[0];
            byte[] NTFSContext = (byte[])reader[1];
            return new OpenSqlFileStream(new SqlFileStream(path, NTFSContext, FileAccess.Read), conn, tran);
        }
    }
    return null;
}

Gdy ukryjemy implementację otrzymamy dość zgrabną metodę, która na podstawie nazwy pliku zwróci jego strumień.

Metody pomocnicze

Klasa dostępu do danych posiada jeszcze jedną metodę, GetAll, której zadaniem jest pobranie wszystkich istniejących w bazie plików. Popatrzmy na poniższy listing:

public List<string> GetAll()
{
    List<string> result = new List<string>();
    using (var conn = OpenConnection())
    using (var cmd = new SqlCommand("SELECT Name FROM dbo.Files", conn))
    using (var reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            result.Add((string)reader[0]);
        }
    }
    return result;
}

Metoda będzie wykorzystywana do pobierania listy plików już umieszczonych w bazie danych. Myślę, że nie wymaga ona komentarza. Można zatem uznać, że warstwa dostępu do danych jest gotowa. Możemy zapisywać pliki i pobierać je.

Budowa kontrolera ASP.NET MVC

Kontroler ASP.NET MVC jest sercem serwera HTTP. To on odbiera żądania i decyduje co z nimi zrobić i jakie wartości zwrócić. Nasza aplikacja, co zostało już powiedziane i częściowo wykonane ma zadania: zapisywać i pobierać pliki z dostępnej listy. Tak też skonstruowany będzie kontroler:

[HttpGet]
public ActionResult Index()
{
    return View(new DBFile().GetAll());
}

[HttpPost]
public ActionResult Index(FileData uploadData)
{
    var fileRepository = new DBFile();
    fileRepository.Save(uploadData);
    return View(fileRepository.GetAll());
}

[HttpGet]
public ActionResult Download(string name)
{
    return new SqlFileStreamResult(new DBFile().Get(name), name, "application/octet-stream");
}

Dla kogoś kto pisze w ASP.NET MVC implementacja kontrolera wyda się prosta. Gdy napiszę, że klasa DBFile reprezentuje klasę dostępu do danych opisaną nieco wcześniej, prawie wszystko będzie jasne. Wszystko, oprócz klasy SqlFileStreamResult.

SqlFileStreamResult odpowiedzią na buforowanie

ASP.NET MVC posiada specjalną akcję do zwracania plików w postaci strumienia. Nie jest ona zła, jest nawet dobra. Ma jednak jedną wadę: nie radzi sobie z bardzo dużymi plikami. Gdybyśmy zechcieli przesłać gigabajtowy plik, wielce prawdopodobny jest błąd braku pamięci - OutOfMemoryException. Wszystko przez optymalizacje. Przeglądarka podczas pobierania pliku oczekuje zwykle jego rozmiaru. Może sobie wtedy policzyć jaki procent pliku udało się już pobrać i wyświetlić jakieś paski postępu albo inne elementy wizualne wskazujące stan pobierania. Wiele strumieni, w tym również te plikowe, z założenia buforują sobie dane. Wystarczy spojrzeć choćby na metody ogólnej klasy Stream. Znajdziemy tam coś takiego jak Flush - metodę służącą do opróżniania bufora. Co więcej, wiele jest strumieni wolnych, znacznie wolniejszych niż pamięć. Często wygodniej jest uzupełnić co trzeba i zostawić problem synchronizacji konkretnej implementacji strumienia niż ciągle kontrolować stan operacji i dokładać bajt po bajcie.

Jeszcze innym problemem jest niepełny nagłówek odpowiedzi HTTP. Domyślnie strumień nie ma nazwy. Przeglądarka nie zawsze wie, jaką nazwę ma nadać pobieranemu właśnie plikowi. Są wprawdzie atrybuty umieszczane w hiperłączach (download), czasami przeglądarki nazywają plik na podstawie końcówki adresu. Nie warto jednak na tym polegać. Te drobne niedogodności sprawiają, że konieczne staje się nadpisanie kilku rzeczy. Przyjrzyjmy się zatem implementacji klasy SqlFileStreamResult pokazanej na poniższym listingu:

public class SqlFileStreamResult : FileStreamResult
{
    public string FileName { get; set; }

    public SqlFileStreamResult(Stream stream, string fileName, string contentType)
        : base(stream, contentType)
    {
        FileName = fileName;
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        response.BufferOutput = false;
        response.AddHeader("Content-Length", FileStream.Length.ToString());
        response.AddHeader("Content-Disposition", string.Format("attachment; filename=\"{0}\"", FileName));

        base.WriteFile(response);
    }
}

Klasa dziedziczy z FileStreamResult więc nie będę opisywał zachowań specyficznych tej klasy. Zwrócę uwagę na trzy szczegóły. Po pierwsze, należy wyłączyć buforowanie. Nie chcemy przecież, aby kilka gigabajtów leżało bezużytecznie w pamięci. Po drugie, dołożony został nagłówek określający długość odpowiedzi. Przeglądarka może sobie z niego zrobić pożytek. I po trzecie, ustawiany jest nagłówek wskazujący jednoznacznie nazwę pobieranego pliku. Na końcu wywoływana jest metoda z klasy bazowej, która zrobi resztę. Można naturalnie samemu przerzucać odpowiednie porcje danych i zajmować się odpowiednim, to jest ograniczonym objętościowo, buforowaniem. Są to przecież zwykłe strumienie i działają wszystkie potrzebne metody, które dla nich zostały zaprojektowane. Pozostawiam to jednak innym. Buforowanie to jednak nie jest wszystko, o czym trzeba wiedzieć.

Wiązanie i zgodność typów

Jest jeszcze jedna rzecz, która konieczna jest do prawidłowego odebrania pliku po stronie serwera. Tym brakującym elementem jest konwerter typówKlasa konwertująca nie jest tutaj niezbędna, ale znacznie upraszcza czytanie kodu. Domyślna klasa do konwersji danych formularza przychodzącego w żądaniu na obiekt przetwarza pliki na typ HttpPostedFileBase, który nie jest zgodny z klasą Stream. HttpPostedFileBase posiada wprawdzie właściwość InputStream, ale nie jest ona wiązana z polem obiektu docelowego. Tradycyjna obsługa wymagałaby stworzenia jeszcze jednej klasy, posiadającej właściwość File typu HttpPostedFileBase i przepisania właściwości do obiektu typu FileData. Można również zmienić typ właściwości File na HttpPostedFileBase w klasie FileData, ale byłoby to równoważne z modyfikacją warstwy dostępu do danych pod dyktando ASP.NET. Zwykły strumień (Stream) jest bardziej uniwersalny.. Popatrzmy na sposób obsługi wiązania:

public class StreamBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var rawFile = controllerContext.HttpContext.Request.Files[bindingContext.ModelName];
        if (rawFile == null)
            return null;
        else
            return rawFile.InputStream;
    }
}

Klasa wydobywa z kontekstu HTTP plik, którego nazwa pokrywa się z nazwą właściwości reprezentującej strumień. Jeżeli się to uda, zwracany jest tylko i wyłącznie pożądany strumień, a pozostałe właściwości są ignorowane. Skąd jednak ASP.NET MVC ma wiedzieć dla której właściwości wywołać tę klasę i tę procedurę wiązania? Otóż dzieje się to dzięki możliwości skojarzenia typu z klasą odpowiedzialną za wydobycie danych. Wszystko odbywa się w pliku Global.asax:

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
    BindingConfig.RegisterBindings(ModelBinders.Binders);
}

Zadaniem metody RegisterBindings jest natomiast zarejestrowanie nowej klasy realizującej wiązanie:

public class BindingConfig
{
    public static void RegisterBindings(ModelBinderDictionary modelBinderDictionary)
    {
        modelBinderDictionary.Add(typeof(Stream), new StreamBinder());
    }
}

Rejestracja podzielona jest na dwa pliki zgodnie z ogólnie przyjętą konwencją. Najwyższy czas na klienta.

Strona HTML do obsługi wielkich plików

Pliki są wielkie, tymczasem strona mała i nieskomplikowana. Formularz z dwoma polami, jakiś pomocniczy skrypt, obok lista już załączony plików. Popatrzmy na listing:

<div class="row">
    <div class="col-md-6">
        <div class="panel panel-primary">
            <div class="panel-heading">
                <div class="panel-title">Add file</div>
            </div>
            <div class="panel-body">
                <form role="form" action="" enctype="multipart/form-data" method="post">
                    <div class="form-group">
                        <label>
                            File name
                            <input type="text" class="form-control" name="name">
                        </label>
                    </div>
                    <div class="form-group">
                        <label>
                            File
                            <input type="file" name="file">
                        </label>
                    </div>
                    <button type="submit" class="btn btn-default">Submit</button>
                </form>
            </div>
        </div>
    </div>
    <div class="col-md-6">
        <div class="panel panel-primary">
            <div class="panel-heading">
                <div class="panel-title">File collection</div>
            </div>
            <div class="panel-body">
                @foreach (var file in Model)
                {
                    <div>@Html.ActionLink(file, "Download", new { name = file })</div>
                }
            </div>
        </div>
    </div>
</div>
<script>
    document.querySelector("[name=file]").addEventListener("change", function (e) {
        var name = document.querySelector("[name=name]");
        if (name.value === "")
            name.value = this.value.substr(this.value.lastIndexOf("\\") + 1);
    });
</script>

W celu nadania formularzowi odpowiedniego stylu użyto biblioteki Bootstrap, domyślnie wstawianej podczas tworzenia nowego projektu Visual Studio. Całość powinna wyglądać mniej więcej tak.

Add file
File collection
Tutaj będzie lista plików

Zadaniem niewielkiego skryptu jest wydobycie nazwy pliku z pełnej ścieżki. Oczywiście to tylko wycinek strony, całość można obejrzeć pobierając skompresowane archiwum z projektem.

Ograniczenia ASP.NET MVC i IIS

Okazuje się, że i to nie wystarcza aby w pełni cieszyć się z aplikacji obsługującej bardzo duże pliki. Są jeszcze limity ASP.NET MVC i IIS. Czas także i o nich trochę napisać. Nadmienię przy okazji, że to już koniec. Modyfikacja limitów pozwoli w końcu przesłać te nieszczęsne duże pliki. Pierwszym limitem jest maksymalny rozmiar żądań przetwarzanych przez silnik HTTP w ASP.NET. Definiuje się go w sekcji system.web/httpRuntime przy pomocy atrybutu maxRequestLength:

<configuration>
  ..
  <system.web>
    <httpRuntime maxRequestLength="2097152"/>

Atrybut definiuje maksymalny rozmiar (w kilobajtach) żądania, które może być obsłużone. Domyślnie jest to 4096, co oznacza 4 MB. Jest to oczywiście podyktowane względami wydajnościowymi. Pozwala w pewnym stopniu zabezpieczyć się przed atakami DoS blokującymi serwer. Wystarczy sobie wyobrazić sytuację, w której kilkadziesiąt osób zechce w tym samym czasie zapisać gigabajtowy plik.

Drugi limit ustawiony jest przez serwer aplikacji, IIS. On także ma swoje zabezpieczenia. Domyślną konfigurację można oczywiście nadpisać w pliku konfiguracyjnym:

<configuration>
  ...
  <system.webServer>
    <security>
      <requestFiltering>
        <requestLimits maxAllowedContentLength="2147483648" />

Pojawia się tutaj drobna nieścisłość. Wcześniej dane podawane były w kilobajtach, tym razem wartość definiuje się w bajtach. 2147483648 bajtów to 2 GB. Okazuje się, że IIS jest bardziej precyzyjny.

Uruchomienie aplikacji

W końcu, po długich zmaganiach, można uruchomić aplikację i cieszyć się z jej możliwości. Warto zadać sobie trud i prześledzić użycie pamięci podczas obsługi, powiedzmy, gigabajtowego pliku. Na żadnym etapie przetwarzania nie powinien on się znaleźć w całości w pamięci operacyjnej. Bufor powinien być stosunkowo mały i praktycznie niezauważalny przy pojedynczym żądaniu.

Na koniec pozwolę sobie przypomnieć ważniejsze elementy całej aplikacji:

  • założenie w bazie danych tabeli z kolumną FILESTREAM,
  • napisanie metod do obsługi transakcyjnego odczytu i zapisu w ADO.NET,
  • przekazanie otwartego strumienia do kontrolera ASP.NET MVC,
  • wyłączenie buforowania odpowiedzi HTTP.

Pozostałe operacje mają mniejsze znaczenie, ale dodają to, co przyzwoicie napisana aplikacja mieć powinna. Wielu rzeczy tu brakuje. Między innymi rozpoznawania typów MIME, obsługi jakiegoś mechanizmu powiadamiania o postępach w załączaniu pliku. Przesyłanie kilkuset megabajtów może przecież chwilę potrwać. Oddzielnym zadaniem jest odpowiednie poukładanie plików w samej bazie. W pokazanym przykładzie wszystkie pliki znajdują się na tym samym poziomie. Drzewiasta struktura z katalogami z pewnością byłaby w wielu przypadkach rozsądniejsza. Są to jednak tematy, które same z siebie stanowią wystarczające wyzwanie na oddzielny wpis. Zachęcam do zapoznania się z kodem źródłowym załączonym poniżej:

Pobierz kod aplikacji do obsługi bardzo dużych plików w SQL Server i ASP.NET MVC - FILESTREAM-ASP-NET-MVC.zip
Projekt bez blibliotek NuGet. Domyślne ustawienia Visual Studio zezwalają na pobranie ich podczas uruchamiania aplikacji.

Kategoria:C#FILESTREAMSQL ServerASP.NET MVC

, 2014-12-30

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
bardo ciekawe , można dzięki takim wpisom dostrzec wędkę..
Bardzo dziękuję za jasne tłumaczenie z dobrze dobranym przykładem!
Dzieki za te informacje. były kluczowe
Dobrze wyjaśnione, dzięki !