TechArch

Architektura i jej aspekty technologiczne

Użycie WCF do separacji kodu w ramach jednego AppDomain (lub nawet assembly)

clock 22 stycznia, 2009 10:17 przez author tkopacz

Pisząc rozwiązanie, nie zawsze wydzielane są formalne warstwy z fizyczną separacją komponentów. Czasami taka separacja ma miejsce na poziomie klas (czy modułów) w środku aplikacji. W takim scenariuszu warto mieć narzędzie, które zapewni dodatkową infrastrukturę – typu transakcje, sesja, bezpieczeństwo itp. Dodatkowo, warto móc sprawdzić jak taka komunikacja wygląda, ile kosztuje odwołanie do takiego modułu – czy po prostu pozwoli na diagnostykę “środka” aplikacji.

Aby to uzyskać, można użyć Windows Communication Foundation (WCF) – ale w taki sposób że “serwer” i “klient” będą się znajdowały w jednym AppDomain (albo nawet w jednym Assembly)

Do komunikacji najwygodniej jest wykorzystać binding <netNamedPipeBinding>, który jest zoptymalizowany pod kątem komunikacji międzyprocesowej, ale może tez być użyty jako narzędzie do komunikacji “InProc” – w ramach jednego AppDomain. Standardowo, wykorzystuje on mechanizm WS-ReliableMessaging (by zapewnić pewność dostarczenia komunikatu i synchronizację), bezpieczeństwo na poziomie transportu, przesyłane informacje serializuje w sposób binarny a do komunikacji wykorzystuje nazwane potoki (precyzyjniej – IPC).

Aby taki mechanizm wykorzystać, należy:

  1. Utworzyć kontrakty (interfejs)
  2. Zaimplementować usługę
  3. Zdefiniować proxy klienckie - klasę dziedziczącą z ClientBase<T> i zwykle implementującą interfejs kontraktu (ale to nie jest wymagane)
  4. Określić binding (zwykle w pliku konfiguracyjnym)
  5. Uruchomić proces hostujący – ServiceHost
  6. Wywołać daną funkcjonalność za pośrednictwem proxy

W naszym przypadku zdefiniujemy kontrakt, który będzie miał 2 metody. Jedna (CalculateF) wylicza liczbę Fibonacciego. Druga, (Add) dodaje dwie liczby i będzie używana do pokazania narzutu jaki daje taki sposób komunikacji (narzut – uprzedzając dalsze rozważania, jest w praktyce pomijalny).

Kontrakt ma postać następującą:

    [ServiceContract]
    interface ISimpleService {
        [OperationContract]
        long CalculateF(long n);
        [OperationContract]
        int Add(int a,int b);
    }

Implementacja kontraktu też nie jest niczym nadzwyczajnym:

    class MySimpleService:ISimpleService {
        #region ISimpleService Members
        public long CalculateF(long n) {
            if (n <= 1) return n;
            return (CalculateF(n - 1) + CalculateF(n - 2));
        }
        public int Add(int a, int b) {
            return a + b;
        }
        #endregion
    }

Definicja proxy ma następującą postać:

    partial class MySimpleServiceClient : ClientBase<ISimpleService>, ISimpleService {
        public MySimpleServiceClient () { }
        public MySimpleServiceClient(string configurationName): base(configurationName) { }
        #region ISimpleService Members
        public long CalculateF(long n) {
            return Channel.CalculateF(n);
        }
        public int Add(int a, int b) {
            return Channel.Add(a, b);
        }
        #endregion
    }

W tym przypadku nie wykorzystujemy mechanizmu automatycznego generowania proxy, tylko piszemy je ręcznie. Proszę zauważyć, że dziedziczenie z ClientBase<T> daje nam od razu obiekt Channel który pozwala komunikować się z usługą. Zwykle, dla wygody impelementuje się jeszcze interfejs kontraktu – by klient po prostu wywoływał konkretną metodę.

Kolejnym ważnym elementem jest zdefiniowanie bindingów – zarówno dla klienta jak i dla serwisu(w jednym app.config):

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <system.serviceModel>
    <services>
      <service name="WCF35SP1_InsideExe.MySimpleService">
        <endpoint address="net.pipe://localhost/MySimpleService" binding="netNamedPipeBinding" contract="WCF35SP1_InsideExe.ISimpleService" />
      </service>
    </services>
    <client>
      <endpoint address="net.pipe://localhost/MySimpleService" binding="netNamedPipeBinding" contract="WCF35SP1_InsideExe.ISimpleService" />
    </client>
  </system.serviceModel>
</configuration>

Proszę zauważyć, że definiujemy zarówno <service> jak i <client> – wskazując odpowiednie klasy i interfejsy w naszym EXE (WCF35SP1_InsideExe to nazwa projektu i przestrzeni w tym przykładzie)

Należy jeszcze pamiętać, że “host” musi być fizycznie uruchomiony. Tu zwykle wykorzystywany jest ServiceHost. Nie ma potrzeby definiować opcji bindingu (bo są one zdefiniowane w app.config), należy tylko wskazać jaki typ będzie hostowany. Dzięki temu ServiceHost wyszukuje definicję stosownego endpointu i uruchamia “nasłuch”.

ServiceHost service1 = new ServiceHost(typeof(MySimpleService));
service1.Open();

Po tych krokach można  wykonać kod, na przykład taki:

using (MySimpleServiceClient clt = new MySimpleServiceClient()) {
    Console.WriteLine(clt.Add(10, 20)); 
    Console.WriteLine(clt.CalculateF(5)); 
    Console.WriteLine(clt.CalculateF(10));
    Console.WriteLine(clt.CalculateF(15)); 
}

W ten sposób zdefiniowana została i wywołana warstwa “serwisu” w środku jednego pliku EXE.

Dzięki takiej konfiguracji, możemy na przykład dokładnie zobaczyć koszty i komunikację pomiędzy “warstwami”. Można w pliku konfiguracyjnym ustawić:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <system.diagnostics>
    <sources>
      <source name="System.ServiceModel.MessageLogging" switchValue="Warning, ActivityTracing">
        <listeners>
          <add type="System.Diagnostics.DefaultTraceListener" name="Default">
            <filter type="" />
          </add>
          <add name="ServiceModelMessageLoggingListener">
            <filter type="" />
          </add>
        </listeners>
      </source>
      <source name="System.ServiceModel" switchValue="Warning, ActivityTracing"
        propagateActivity="true">
        <listeners>
          <add type="System.Diagnostics.DefaultTraceListener" name="Default">
            <filter type="" />
          </add>
          <add name="ServiceModelTraceListener">
            <filter type="" />
          </add>
        </listeners>
      </source>
    </sources>
    <sharedListeners>
      <add initializeData="C:\TS\PREZENTACJE\WCF35SP1_InsideExe\WCF35SP1_InsideExe\App_messages.svclog"
        type="System.Diagnostics.XmlWriterTraceListener, System, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
        name="ServiceModelMessageLoggingListener" traceOutputOptions="LogicalOperationStack, DateTime, Timestamp, ProcessId, ThreadId, Callstack">
        <filter type="" />
      </add>
      <add initializeData="C:\TS\PREZENTACJE\WCF35SP1_InsideExe\WCF35SP1_InsideExe\App_tracelog.svclog"
        type="System.Diagnostics.XmlWriterTraceListener, System, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
        name="ServiceModelTraceListener" traceOutputOptions="LogicalOperationStack, DateTime, Timestamp, ProcessId, ThreadId, Callstack">
        <filter type="" />
      </add>
    </sharedListeners>
  </system.diagnostics>
  <system.serviceModel>
    <diagnostics>
      <messageLogging logMalformedMessages="true" logMessagesAtTransportLevel="true" />
    </diagnostics>
    <services>
      <service name="WCF35SP1_InsideExe.MySimpleService">
        <endpoint address="net.pipe://localhost/MySimpleService" binding="netNamedPipeBinding" contract="WCF35SP1_InsideExe.ISimpleService" />
      </service>
    </services>
    <client>
      <endpoint address="net.pipe://localhost/MySimpleService" binding="netNamedPipeBinding" contract="WCF35SP1_InsideExe.ISimpleService" />
    </client>
  </system.serviceModel>
</configuration>

Analogiczne operacje można wykonać po wybraniu Tools – WCF Service Configuration Editor (wtedy jest prościej):

image

(w tym przypadku włączone są wszystkie elementy poza komunikatami serwisowymi (które przydają się tylko przy bardzo specyficznej diagnostyce). Logowane informacje są zapisywane w 2 plikach App_messages.svclog (fizyczne komunikaty przesyłane przez WCF) i App_tracelog.svclog (śledzenie aktywności).

Poniżej można zobaczyć graf wywołań:

image 
przesyłane komunikaty (z czasem serializacji i deserializacji)

image

czy po prostu listę aktywności (z czasem)

image

Narzędzie do analizy plików *.svclog znajduje się w <programfiles>\Microsoft SDKs\Windows\v6.0A\bin\SvcTraceViewer.exe. Tam także znajduje się plik z dokumentacją (SvcTraceViewer.chm) którą warto przejrzeć.

Wydajność

Warto pamiętać, że taka technika nie nadaje się do zastępowania każdego wywołania metody w kodzie. Może być zastosowana pomiędzy logicznymi granicami w aplikacji. Użycie WCF “kosztuje” na jednym wywołaniu metody około 3 ms. Dokładniej, na procesorze Intel T7500 samo wywołanie metody w klasie C# trwa 0,00002 ms. Wywołanie przez WCF zajmuje “aż” 2,8691 ms.

Ale – proszę zobaczyć, że jeżeli ta metoda liczy coś bardziej skomplikowanego (na przykład – tu ciąg Fibbonaciego), to się okazuje że ten “narzut” na wywołanie nie jest aż tak bardzo istotny. Bezpośrednie wywołanie to na przykład 69,763 ms, a przy użyciu WCF czas wzrasta do (średnio) 74,357 ms.

Natomiast warto pamiętać, że aby dodać kontekst bezpieczeństwa, sesje, pilnować by nie powstało “za dużo” obiektów danego typu – to konieczne jest samodzielne zakodowanie stosownych algorytmów – które w WCF są gotowe, a dodatkowo ta implementacja jest bardzo wydajna (i skalowalna). Oczywiście, nie zachęcam by w całej aplikacji wymienić wszystkie wywołania na takie z wykorzystaniem WCF. Ale – tam gdzie mamy jakieś granice (typu moduł, czy jakiś blok funkcjonalny w kodzie), warto wykorzystać taką technologię, bo będzie łatwiej chociażby monitorować aplikacje

Pełne wyniki prostego testu:

Proste dodawanie, dla 100000 iteracji 
Wywołanie bezpośrednie: 2 ms 
Wywołanie bezpośrednie/sztukę: 0,00002 ms 
Wywołanie proxy: 286910 ms 
Wywołanie jednej metody: 2,8691 ms 
Ciąg Fibbonaciego, dla 1000 iteracji 
Wywołanie bezpośrednie: 69763 ms 
Wywołanie bezpośrednie/sztukę: 69,763 ms 
Wywołanie proxy: 74357 ms 
Wywołanie jednej metody: 74,357 ms 

Pełny przykład:

WCF_InsideExe.zip (83,06 kb)

Pierwszy oceń post!

  • Currently 0/5 Stars.
  • 1
  • 2
  • 3
  • 4
  • 5


Mechanizm FileStream w SQL 2008

clock 8 stycznia, 2009 14:18 przez author tkopacz

Na potrzeby tego przykładu załóżmy, że budujemy witrynę, która będzie przechowywać dużą liczbę obrazów. Aby zoptymalizować przechowywanie danych, wykorzystany będzie mechanizm FileStream, który pozwala by dane przechowywane w polach typu varbinary(max) były fizycznie zapisywane w postaci pliku na dysku. Warto dodać, że jest to chyba najwygodniejszy sposób przechowywania danych binarnych w SQL Server.

Odblokowanie Filestream

W trakcie instalacji serwera SQL na jednej z zakładek można wybrać czy domyślnie FileStream będzie udostępniony (i w jaki sposób). Po zainstalowaniu można włączać/wyłączać ten mechanizm używając procedury składowanej sp_configure, o następującej składni:

EXEC sp_configure 'filestream_access_level', '[poziom]'

RECONFIGURE

Parametr poziom określa sposób dostępu:

0

Zablokowane. Wartość domyślna.

1

Odblokowane. Dostęp tylko przez T-SQL.

2

Odblokowane. Dostęp przez T-SQL i przez system plików

Aby zobaczyć czy mechanizm FileStream jest odblokowany, można uruchomić konsole i wykonać polecenie NET SHARE. Polecenie NET SHARE pozwala podejrzeć udziały udostępniane na danej maszynie – w tym udziały wynikające z używania FileStream:

Nazwę udziału można także sprawdzić odwołując się do ustawień serwera:

SELECT SERVERPROPERTY ('FilestreamShareName')

Opcje można także ustawić z poziomu Sql Server Configuration Manager, wchodząc we właściwości danej instancji SQL Server i wybierając zakładkę FILESTREAM:

Deklarowanie bazy danych

Tworząc bazę danych, która będzie wykorzystywała mechanizm FileStream należy założyć oddzielną grupę plików (FileGroup) przeznaczonego dla tego typu informacji. Potem w ramach takiego FileGroup definiuje się konkretny plik – w tym przypadku folder na dysku.

CREATE DATABASE [Pictures_FileStream] ON PRIMARY

( NAME = N'Pictures_FileStream', FILENAME = N'C:\SQLEXPRESS\Pictures_FileStream.mdf' , SIZE = 3072KB , MAXSIZE = UNLIMITED, FILEGROWTH = 1024KB ),

FILEGROUP [FSFileGroup] CONTAINS FILESTREAM DEFAULT

( NAME = N'Pictures_FileStreamFS', FILENAME = N'C:\SQLEXPRESS\Pictures_FileStreamFS' )

LOG ON

( NAME = N'Pictures_FileStream_log', FILENAME = N'C:\SQLEXPRESS\Pictures_FileStream_log.ldf' , SIZE = 1024KB , MAXSIZE = 2048GB , FILEGROWTH = 10%)

GO

Oczywiście tych plików (czy grup plików) może być więcej niż jeden – w ten sposób (analogicznie jak przy tworzeniu np. partycji) można rozkładać fizyczne obciążenie pomiędzy różne dyski / macierze itp. Oczywiście – wygodnie jest by jedna z grup była grupą "domyślną".

Tworząc tabelę, należy także pamiętać, że jedną z kolumn musi być typ uniqueidentifier. Kolumnę należy dodatkowo oznaczyć atrybutem ROWGUIDCOL. Może ona być także kluczem głównym w tabeli, ale nie musi. Na przykład, tabela może mieć postać:

CREATE TABLE [dbo].[Pictures](

    [gid] [uniqueidentifier] ROWGUIDCOL NOT NULL,

    [picture] [varbinary](max) FILESTREAM NULL,

CONSTRAINT [PK_Pictures] PRIMARY KEY CLUSTERED

(

    [gid] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]

) ON [PRIMARY]

Ogólny schemat operacji

Na wstępie warto pamiętać, że wszystkie "klasyczne" operacje T-SQL używane do pracy z typami BLOB mogą być także stosowane do pracy z danymi w FileStream (także te opisane w tym poście). Z tego punktu widzenia, poza sposobem przechowywania niczym nie różni się on od normalnego varchar(max). Ale – można wykorzystać

Procedura zapisu danych ze strony aplikacji klienckiej musi składać się z kilku etapów:

  1. Otworzenie transakcji (READ_COMMITED)
  2. Stworzenie pustego wpisu (by powstał plik na dysku)
  3. Wykonanie operacji na bazie (np. zapis metadanych)
  4. Pobranie ścieżki do pliku – metoda .PathName() (na przykład picture.PathName())
  5. Pobranie kontekstu transakcyjnego – funkcja GET_FILESTREAM_TRANSACTION_CONTEXT() (wszystkie operacje na FileStream są wykonywane w kontekście transakcyjnym – za spójność odpowiada motor bazodanowy)
  6. Utworzenie obiektu SqlFileStream z odpowiednimi opcjami (do odczytu, zapisu albo równocześnie do odczytu/zapisu). Przy tworzeniu obiektu trzeba przekazać ścieżkę oraz kontekst transakcyjny.
  7. Odczyt/zapis danych z/do strumienia
  8. Zamknięcie transakcji

Wygodnie jest zdefiniować kilka procedur pomocniczych. Dla naszego przykładu z obrazkami będą to dwie procedury:

  • procedura dodająca nowy "pusty" rysunek i zwracająca jego identyfikator (tu – GUID):

CREATE PROCEDURE [dbo].[spAddEmptyPicture]

AS

BEGIN

    SET NOCOUNT ON;

    declare @gid as uniqueidentifier = newid()

INSERT INTO Pictures (gid,picture)

VALUES

(@gid,cast('' as varbinary(max)))

select @gid

END

  • procedura zwracająca ścieżkę do pliku oraz kontekst transakcyjny dla danego identyfikatora:

CREATE PROCEDURE [dbo].[tkGetPathForPictureGid]

    @gid uniqueidentifier

AS

BEGIN

    SET NOCOUNT ON;

    select picture.PathName(),GET_FILESTREAM_TRANSACTION_CONTEXT()

    from Pictures where gid=@gid

END

GO

Zapis i odczyt danych

Załóżmy, że do wysłania pliku na serwer wykorzystujemy zwykłą kontrolkę FileUpload z ASP.NET

[…]

<form id="form1" runat="server">

<div>

<asp:FileUpload ID="FileUpload1" runat="server" />

<asp:Button ID="Button1" runat="server" Text="Wyślij do bazy"

onclick="Button1_Click" /></div>

<asp:HyperLink ID="MyHyperlink" runat="server">LinkDoPliku</asp:HyperLink>

</form>

Wtedy, sama operacja zapisu ma postać:

protected void Button1_Click(object sender, EventArgs e) {

Database db = DatabaseFactory.CreateDatabase();

DbConnection cnn = db.CreateConnection();

cnn.Open();

DbTransaction tx = cnn.BeginTransaction();

Guid gid = (Guid)db.ExecuteScalar(tx,"spAddEmptyPicture");

IDataReader dr = db.ExecuteReader(tx, "spGetPathForPictureGid", gid);

dr.Read();

string path = dr.GetString(0);

SqlFileStream fs = new SqlFileStream(path, (byte[])dr.GetValue(1), FileAccess.Write);

fs.Write(FileUpload1.FileBytes, 0, FileUpload1.FileBytes.Length);

fs.Close();

dr.Close();

tx.Commit();

cnn.Close();

MyHyperlink.NavigateUrl = "file.axd?gid=" + gid.ToString();

}

W nawiasach transakcyjnych dodajemy pusty rysunek, pobieramy ścieżkę i kontekst transakcyjny, po czym zapisujemy plik i potwierdzamy transakcję. Na koniec ustawiany jest link pozwalający podejrzeć dodany rysunek.

Analogicznie należy postępować przy odczycie informacji. W tym przypadku zdefiniowana jest dodatkowa biblioteka (HandlerLib) która zawiera zaimplementowany IHttpHandler "zwracający" obrazek.

public class FileHandlerSqlStream : IHttpHandler {

const int BufferSize = 1024;

public bool IsReusable { { return false; } }

public void ProcessRequest(HttpContext context) {

Guid gid = new Guid(context.Request.QueryString["GID"].ToString());

context.Response.Clear();

context.Response.ContentType = "image/JPEG";

Database db = DatabaseFactory.CreateDatabase();

DbConnection cnn = db.CreateConnection();

cnn.Open();

DbTransaction tx = cnn.BeginTransaction();

IDataReader dr = db.ExecuteReader(tx, "spGetPathForPictureGid", gid);

dr.Read();

string path = dr.GetString(0);

SqlFileStream fs = new SqlFileStream(path, (byte[])dr.GetValue(1), FileAccess.Read);

byte[] buf = new byte[BufferSize];

int read = 0;

while ((read = fs.Read(buf, 0, BufferSize)) > 0) {

context.Response.OutputStream.Write(buf, 0, read);

}

fs.Close();

dr.Close();

cnn.Close();

}

}

Po pobraniu z kontekstu http identyfikatora (GUID), który wskazuje, jaki plik ma odczytany, pobierana jest ścieżka i kontekst transakcyjny. Pozostałe operacje to po prostu odczyt danych ze strumienia i zapis do Response.OutputStream (by dane zostały wysłane do przeglądarki klienta).

Uwaga! Nie należy zapomnieć o rejestracji handlera, tu pod nazwą file.axd (klient chcąc zobaczyć obrazek, wchodzi na adres URL […]\file.axd?GID=<tu guid>) :

<httpHandlers>

[…]

<add verb="*" path="file.axd" type="HandlerLib.FileHandlerSqlStream,HandlerLib"/>

</httpHandlers>

Oczywiście, zamiast SqlFileStream można skorzystać z funkcji natywnej OpenSqlFilestream i potem przekazać uchwyt (typ Handle WinAPI) do np. API.NET FileStream. Dokładnie to samo robi używana tutaj klasa SqlFileStream.

Backup

Wykonanie kopii zapasowej bazy wykorzystującej FileStream nie różni się niczym od "normalnego" backupu SQL Server.

BACKUP DATABASE [Pictures_FileStream] TO DISK = N'[…]Pictures_FileStream.bak' WITH NOFORMAT, NOINIT, NAME = N'Pictures_FileStream-Full Database Backup', SKIP, NOREWIND, NOUNLOAD, STATS = 10

GO

Odzyskiwanie:

RESTORE DATABASE [Pictures_FileStream] FROM DISK = N'[…]Pictures_FileStream.bak' WITH FILE = 1, NOUNLOAD, STATS = 10

GO

Jeżeli spróbujemy odzyskać poszczególne FileGroup, to po wejściu w Restore Files and FileGroup zobaczymy (w tym przypadku) 2 strumienie:

Inne informacje

Chcą przeczytać więcej o Filestream, warto sięgnąć do publikacji FILESTREAM Storage in SQL Server 2008, autorstwa Paul S. Randal (SQLskills.com)) opublikowanej na stronach MSDN.

Sql Server 2008 Express Edition pozwala wykorzystać mechanism FileStream a dane przechowywane w tej postaci nie "wliczają się" do limitu 4GB / bazę danych.

Wydajność FileStream przeanalizowali pracownicy SQL Skills. Wyniki badań zostały opublikowane pod tym adresem: http://www.sqlskills.com/BLOGS/PAUL/post/SQL-Server-2008-FILESTREAM-performance.aspx. Warto zwrócić uwagę na jeden aspekt – dla małych obiektów (w przypadku tego badania – do ok. 1MB) operacje "klasyczne", wykorzystujące API T-SQL były szybsze niż manipulacja przy użyciu strumieni. Dodatkowo, w każdym przypadku klasyczny sposób przechowywania BLOB-ów, w plikach mdf, był wolniejszy niż FileStream.

Przykład jest dostępny do ściągnięcia tutaj:

BlobInFileStream.zip (622,89 kb)

Własny typ .NET

Na koniec warto chwilę się zastanowić – czy nie opłaca się definiować "dużych" typów .NET do przechowywania BLOB-ów. Tu warto podkreślić, że zaletą tej technologii jest to, że można po stronie serwera SQL pracować z metodami i funkcjami .NET na danych. Czyli można napisać typ, który np. opakuje tablicę i pozwoli z poziomu T-SQL odwołać się do poszczególnych składników (np. bajtów). Ale – już operacje po stronie klienckiej wymagają pobrania (a potem aktualizacji) całego pola – co zwykle nie będzie rozwiązaniem optymalnym.

Aktualnie oceniony na 5.0 (2)

  • Currently 5/5 Stars.
  • 1
  • 2
  • 3
  • 4
  • 5


Przechowywanie dużych obiektów w SQL Server 2008

clock 5 stycznia, 2009 07:01 przez author tkopacz

Na potrzeby tego artykułu załóżmy, że chcemy przechowywać bazę zeskanowanych dokumentów. Analizowane będą trzy sposoby postępowania.

  • Meta dane pliku umieszczamy w bazie, a pliki niezależnie na dysku (jedną z meta danych jest ścieżka)
  • Pliki przechowywane w postaci obiektów BLOB w bazie

W kolejnych artykułach omówiony będą jeszcze:

  • Pliki przechowywane przy użyciu FileStream w SQL 2008 (z punktu widzenia bazy widziane, jako BLOB, ale fizycznie przechowywane w postaci pliku na dysku)
  • Własne typ .NET

Plik na dysku + baza

W takim rozwiązaniu, plik znajduje się na dysku, a w bazie znajduje się opis metadanych wraz z lokalizacją pliku).

Z zalet takie podejścia można wymienić względną prostotę i fakt, że nie wykorzystuje się specyficznych możliwości konkretnej bazy danych. Można zbudować sensowne API, które "opakuje" operację na plikach i z punktu widzenia kodu aplikacji będzie to rozwiązanie wygodne. Ale pozostaną wyzwania związane z backupem (mamy 2 miejsca gdzie są przechowywane dane) i zawsze ryzyko "ręcznej" zmiany zawartości pliku (czy wręcz jego skasowanie).

Najwięcej problemów pojawi się, jeżeli np. zostanie utworzony wpis w bazie, ale fizycznie plik nie będzie zapisany. Wtedy – konieczny jest jakiś program administracyjny, który albo pozwoli ręcznie pousuwać nadmiarowe wpisy albo – automatycznie wyszuka takie braki. Czynność dodawanie nowego wpisu można potraktować, jako prosty proces biznesowy – i np. wykorzystać mechanizm Workflow Foundation do "pilnowania" spójności transakcyjnej. Wtedy, proces zapisu czy kasowania miałby 2 operacje – gdzie w przypadku niepowodzenia można by zdefiniować kompensację, która "posprząta" automatycznie stan systemu.

Ale niestety – nie ma sposób by elegancko rozwiązać problem, gdy przez przypadek zostanie skasowany plik na dysku bez kasowania wpisu w bazie.

Obiekty BLOB w bazie

Jest to rozwiązanie "najbardziej klasyczne". W specjalnym polu BLOB (typy danych to nvarchar(max) czy varbinary(max) przechowywana jest postać binarna danego pliku.

Największym problemem związanym z tym podejściem jest rozmiar pliku mdf/ldf (plików bazy danych). Jeżeli plik są małe i jest ich niewiele, to nie jest to duży problem, jednak próba przechowywania w taki sposób plików video spowoduje, że sama baza bardzo urośnie. Na marginesie, warto dodać, że w części systemów, które wykorzystują taki sposób przechowywania definiowane są 2 bazy – jedna na "BLOB-y" a druga na metadane. Transakcja może obejmować 2 bazy – i spójność ACID operacji będzie zachowana.

Typy danych binarnych I "dużych" tekstowych

W SQL 2008 do przechowywania dużych porcji informacji można użyć następujących typów danych

  • image
  • binary(n) – gdy dane mają stałą długość n
  • varbinary(n) – gdy znana maksymalna długość danych
  • varbinary(max) – typ zalecany, gdy informacje mają różne długości

Jeżeli dane są natury tekstowej:

  • varchar(max) – typ zalecany
  • nvarchar(max) – typ zalecany; Unicode
  • ntext / text

Zalecane jest stosowanie typów nvarchar(max), varchar(max) i (var)binary(max), ponieważ inne typy (na pewno ntext, text i image) zostaną usunięte z przyszłych wersji SQL Server. Dodatkowo, na przykład na (n)varchar(max) można postawić normalny indeks (choć – warto się zastanowić czy to się na pewno opłaca).

Warto się chwilę zastanowić, kiedy wybierać typ danych tekstowy a kiedy binarny. Na danych tekstowych można wykonywać operacje "wybierające" porcje danych. Na przykład STUFF czy inne z tej listy. Dane binarne mają tylko operację SUBSTRING ( value_expression ,start_expression , length_expression ) (wbrew nazwie może ona także operować na danych binarnych – zwraca wtedy typ varbinary)

Uwaga! Polecenia typu TEXTPTR, WRITETEXT czy UPDATETEXT i inne, w których pobierany jest "wskaźnik" do BLOB-a, który używany do odczytu porcji danych będą wycofane w przyszłych wersjach Sql Server – więc lepiej ich nie stosować.

Schemat przykładowej bazy danych

Przykładowy schemat bazy danych:

CREATE DATABASE [ScannedDocument_BLOB] ON PRIMARY

( NAME = N'ScannedDocument_BLOB', FILENAME = N'C:\SQLEXPRESS\ScannedDocument_BLOB.mdf' , SIZE = 3072KB , MAXSIZE = UNLIMITED, FILEGROWTH = 1024KB )

LOG ON

( NAME = N'ScannedDocument_BLOB_log', FILENAME = N'C:\SQLEXPRESS\ScannedDocument_BLOB_log.ldf' , SIZE = 1024KB , MAXSIZE = 2048GB , FILEGROWTH = 10%)

GO

ALTER DATABASE [ScannedDocument_BLOB] SET COMPATIBILITY_LEVEL = 100

GO

CREATE TABLE [dbo].[Document](

    [id] [int] IDENTITY(1,1) NOT NULL,

    [documentImage] [varbinary](max) NOT NULL,

    [author] [nvarchar](50) SPARSE NULL,

[…]

CONSTRAINT [PK_Document] PRIMARY KEY CLUSTERED

(

    [id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]

) ON [PRIMARY]

Dokumenty będą przechowywane w tabeli Document, w polu documentImage. Operacje na tym polu nie są wykonywane tak samo jak na dowolnym innym typie. Pola metadanych (tu author) ma atrybut SPARSE, co powoduje, że jeżeli nie jest wypełnione to w ogóle nie zajmuje miejsca w bazie danych (więcej o tej możliwości SQL 2008 można przeczytać tutaj).

Na przykład, procedura składowana do dodawania nowego elementu może mieć postać:

CREATE procedure [dbo].[spAddScannedDocument](

    @documentImage varbinary(max)

) as

    insert into Document(documentImage) values (@documentImage)

    select cast(SCOPE_IDENTITY() as int)

Z punktu widzenia aplikacji klienckiej, jej wywołanie to (przy drobnej pomocy Enterprise Library by "ukryć" nieistotny kod):

public static int Create(byte[] arr)

{

int id;

Database db = DatabaseFactory.CreateDatabase();

object o;

id = (int)db.ExecuteScalar("spAddScannedDocument", arr);

return id;

}

Jeżeli źródłem pliku będzie strumień, to przy takim sposobie zapisu, trzeba ten strumień (po stronie aplikacji klienckiej) wczytać do pamięci, po czym przekazać go, jako parametr typu tablica, do procedury przechowywanej. Co – w przypadku, gdy jest to na przykład plik z filmem o wielkości 2GB – spowoduje, że w celu zapisu (czy – aktualizacji) informacji na chwilę będzie musiał być zaalokowany duży bufor.

public static int Create(Stream docStream)

{

byte[] arr = new byte[docStream.Length];

docStream.Read(arr, 0, (int)docStream.Length);

return Create(arr);

}

Jednak można zdefiniować inne API, w którym na serwer będą przesyłane tylko małe porcje informacji. Polecenie T-SQL UPDATE w SQL 2008 ma dodatkową klauzulę .WRITE, które pozwala zapisać fragment BLOB-a. Definiując odpowiednią procedurę składowaną można pozwolić klientowi określić, jaka porcja danych będzie uaktualniana:

create procedure spUpdateScannedDocument

( @id as int,

@value varbinary(max),

@offset bigint,

@length bigint

)

as

update Document set documentImage.Write(@value,@offset,@length) where id=@id

Metoda w C#, która będzie np. doklejać informacje do pliku może mieć postać:

public static void Append(int id, Stream str, int chunkSize) {

Database db = DatabaseFactory.CreateDatabase();

byte[] arr = new byte[chunkSize];

int read;

long pos=0;

while ((read = str.Read(arr, 0, chunkSize)) > 0) {

if (read == chunkSize) {

db.ExecuteNonQuery("spUpdateScannedDocument", id, arr, pos, read);

pos += read;

} else {

db.ExecuteNonQuery("spUpdateScannedDocument", id, arr, pos, read);

pos += read;

db.ExecuteNonQuery("spUpdateScannedDocument", id, DBNull.Value, pos, DBNull.Value); //Obcinamy

break;

}

}

}

W podobny sposób można już zdefiniować funkcję dopisywania nowego dokumentu bez konieczności jego buforowania po stronie klienckiej:

public static int CreateByStream(Stream str) {

int id = -1;

using (TransactionScope ts = new TransactionScope(TransactionScopeOption.Required)) {

Database db = DatabaseFactory.CreateDatabase();

id = (int)db.ExecuteScalar("spAddEmptyScannedDocument");

Append(id, str, 1024);

ts.Complete();

}

return id;

}

Na koniec tej części, warto wspomnieć o jeszcze innym sposobie aktualizacji BLOB w bazie – przy założeniu, że dokument fizycznie znajdzie się na serwerze bazodanowym (w jakimś folderze) lub udziale do którego SQL Server ma dostęp. Wtedy można wykonać operację (T-SQL):

UPDATE Document

SET documentImage = (

SELECT *

FROM OPENROWSET(BULK 'c:\myDocument.docx', SINGLE_BLOB) AS x )

WHERE id = 1

OpenRowset pozwala otworzyć dowolne źródło danych OLE DB. Opcja BULK powoduje, że plik jest traktowany jako całość. SINGLE_BLOB oznacza, że plik będzie traktowany jako ciąg danych varbinary(max) – dokładnie to co jest potrzebne w tym przypadku. Można jeszcze wykorzystywać normalny mechanizm Bulk Copy (BCP) czy pakiet SSIS.

Odczyt danych z bazy

Odczyt danych binarnych z bazy można zrealizować na 3 sposoby. Pierwszy – po prostu "pobrać" całą kolumnę:

Database db = DatabaseFactory.CreateDatabase();

IDataReader dr = db.ExecuteReader("spGetDocumentPart", _id, _position, count);

int read = 0;

byte[] tmp;

if (dr.Read()) {

tmp = (byte[])dr.GetValue(0);

read = tmp.Length;

tmp.CopyTo(buffer, 0);

}

dr.Close();

Oczywiście w tym momencie pobieramy cały plik "na raz".

Drugi sposób wykorzystuje mechanizm opcji dostępu sekwencyjnego w ADO.NET. Wtedy, można napisać własny strumień, na przykład w taki sposób (wersja uproszczona; bez żadnej obsługi błędów czy wyjątków):

public class SqlBlobStreamTDS : Stream {

DbDataReader _dr;

long _position;

long _length;

public SqlBlobStreamTDS(int id) {

Database db = DatabaseFactory.CreateDatabase();

_length = (long)db.ExecuteScalar("spGetDocumentLength", id);

_position = 0;

DbCommand cmd = db.GetStoredProcCommand("spGetDocument", id);

cmd.Connection = db.CreateConnection();

cmd.Connection.Open();

_dr = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.CloseConnection);

_dr.Read(); //Czytamy pierwszy rekord, ale ustawione jest do odczytu sekwencyjnego

}

[…]

public override long Length { get { return _length; } }

public override long Position { get { return _position; } set { throw new NotImplementedException();}}

public override int Read(byte[] buffer, int offset, int count) {

long read = (int)_dr.GetBytes(0, _position, buffer, offset, count);

_position += read;

return (int)read;

}

[…]

public override void Close() {

_dr.Close();

}

}

Jeżeli w ExecuteReader zostanie podany parametr CommandBehavior.SequentialAccess to wtedy można pobrać kolejne bajty ze strumienia przy użyciu metody DataReader GetBytes(<id pola>, pozycja, bufor, offset, count), co w łatwy sposób pozwoliło zaimplementować "szkielet" strumienia.

Uwaga! API GetBytes(…) wykorzystuje mechanizm ReadByteArray(..) z klasy TdsParserStateObject. "Strumień" zwracany jest na poziomie kanału komunikacyjnego. W SQL Profiler zobaczymy po prostu "odczyt" pola – z tym, że z czasem wykonania zależnym od tego jak szybko klient będzie pobierał dane (to będzie widoczne dopiero dla bardzo dużych BLOB-ów).

Jeżeli byśmy chcieli mieć API gdzie strumień "zwracany" jest po kawałku – należy, używając wspomnianego wcześniej SUBSTRING zdefiniować odpowiednią procedurę, na przykład:

CREATE PROCEDURE [dbo].[spGetDocumentPart]

    @id int = 0,

    @starting_position bigint ,

    @length bigint

AS

BEGIN

    select SUBSTRING(documentImage,@starting_position,@length) from Document where id=@id

END

Uwaga! SUBSTRING pozycję w BLOB liczy od 1 a nie od zera! Czyli implementacja strumienia może wyglądać w następujący sposób:

public class SqlBlobStreamByPart : Stream {

long _position;

long _length;

int _id;

public SqlBlobStreamByPart(int id) {

Database db = DatabaseFactory.CreateDatabase();

_length = (long)db.ExecuteScalar("spGetDocumentLength", id);

_position = 0;

_id = id;

}

[…]

public override int Read(byte[] buffer, int offset, int count) {

Database db = DatabaseFactory.CreateDatabase();

IDataReader dr = db.ExecuteReader("spGetDocumentPart", _id, (_position + 1), (long)count);

int read = 0;

byte[] tmp;

if (dr.Read()) {

tmp = (byte[])dr.GetValue(0);

read = tmp.Length;

tmp.CopyTo(buffer, 0);

}

dr.Close();

_position += read;

return read;

}

[…]

}

Warto też zauważyć, że ten sposób obsługi plików BLOB ma jeszcze jedną cechę – można w dowolny sposób poruszać się po strumieniu (zmieniając pozycję, od której SUBSTRING czyta informacje).

Na koniec warto pokazać kilka sposobów wywołania zdefiniowanego "API":

int id,id1;

int read;

FileStream fs=new FileStream(@"C:\Documents\Bigfile1.txt",FileMode.Open);

id=DocumentAPI.Create(fs);

fs.Close();

fs=new FileStream(@"C:\Documents\Bigfile1.txt",FileMode.Open);

id1=DocumentAPI.CreateByStream(fs);

fs.Close();

byte[] arr=new byte[10000];

using(SqlBlobStreamTDS tds=new SqlBlobStreamTDS(id)) {

tds.Read(arr,0,arr.Length);

tds.Read(arr,0,arr.Length);

}

using(SqlBlobStreamByPart tds=new SqlBlobStreamByPart(id1)) {

tds.Read(arr,0,arr.Length);

tds.Seek(0,SeekOrigin.Begin);

tds.Read(arr,0,arr.Length);

}

Pełny plik z przykładami można ściągnąć tu: SimpleBlob.zip (282,22 kb)

W kolejnej części opublikowany będzie sposób postępowania przy wykorzystaniu FileStream i kilka uwag na temat typów .NET.

 

Aktualnie oceniony na 4.7 (3)

  • Currently 4,666667/5 Stars.
  • 1
  • 2
  • 3
  • 4
  • 5


O autorze

Tomasz Kopacz, Microsoft

Zajmuje się współpracą z architektami oraz projektantami systemów wykorzystujących, między innymi, technologie Microsoft. W ramach współpracy doradza przy wyborze właściwych elementów pozwalających opracować rozwiązanie informatyczne. Zajmuje się również prezentacją wzorców architektonicznych (ze szczególnym naciskiem na koncepcję rozwiązań opartych o SOA – zorientowanych na usługi) oraz szeroko pojętym wykorzystaniem technologii .NET, serwerów Microsoft i różnych narzędzi wspierających prowadzenie projektów.

Zaloguj