Zine.net online

Witaj na Zine.net online Zaloguj się | Rejestracja | Pomoc
w Szukaj

ucel.net

  • Parametry opcjonalne i nazywane

    Używając analizy kodu w projektach .NET 4.0 można natknąć się na taki oto komunikat:

    CA1026: Microsoft Design: Replace method xyz with an overload that supplies all default arguments.

    W dokumentacji do tego ostrzeżenia czytamy, że choć używanie metod opcjonalnych jest dozwolone w specyfikacji CLS, to dozwolone jest także ich ignorowanie. Przyjrzyjmy się więc temu nieco bliżej.

    Parametry opcjonalne

    Na tapetę weźmiemy tę oto prostą metodę:
    public void OptionalMethod(string name, int value = 42)
    {
      Console.WriteLine("OptionalMethod z parametrami");
      Console.WriteLine("Name: {0}", name);
      Console.WriteLine("Value: {0}", value);
      Console.WriteLine();
    }

    Jej nagłówek w kodzie IL wygląda następująco:

    .method public hidebysig instance void OptionalMethod(string name,
                [opt] int32 'value') cil managed
    {
     .param [2] = int32(0x0000002A)
     // Code size 48 (0x30)
     .maxstack 8
     IL_0000: nop

    Jasno jest oznaczone, że parametr value jest parametrem opcjonalnym i jego wartość domyślna to 42 (0x2A). Co ciekawe, identyczny nagłówek dostaniemy implementując powyższą metodę w Visual Basicu pod kontrolą .NET 2.0. Parametry opcjonalne nie są więc novum w .NET 4.0, a po prostu ich obsługa została dodana do kompilatora C#.

    Skąd więc ostrzeżenie? Niestety tak jak w poprzedniej wesji .NET nie dało się użyć parametrów domyślnych metody napisanej w VB w programie napisanym w C#, tak cały czas nie da się ich użyć w programie napisanym np. w Managed C++. Poniższy kod się nie skompiluje - pojawi się błąd mówiący, że nie ma metody OptionalMethod z jednym parametrem:

    int main(array ^args)
    {
      OptionalClass ^c = gcnew OptionalClass();
      c->OptionalMethod(L"Test");
      return 0;
    }

    Spowodowane jest to tym, że parametry domyślne nie są obsługiwane przez runtime a bezpośrednio przez kompilator. Informacje z nagłówka metody są używane przez kompilator do wygenerowania poprawnego wywołania metody. Dlatego też poniższy kod:
    OptionalClass oc = new OptionalClass();
    oc.OptionalMethod("Test1");
    oc.OptionalMethod("Test2", 6);

    zostanie przetłumaczony następująco:

    IL_0001: newobj instance void [Classes]ZineTest.OptionalClass::.ctor()
    IL_0006: stloc.0
    IL_0007: ldloc.0
    IL_0008: ldstr "Test1"
    IL_000d: ldc.i4.s 42
    IL_000f: callvirt instance void [Classes]ZineTest.OptionalClass::OptionalMethod(string,int32)
    IL_0014: nop
    IL_0015: ldloc.0
    IL_0016: ldstr "Test2"
    IL_001b: ldc.i4.6
    IL_001c: callvirt instance void [Classes]ZineTest.OptionalClass::OptionalMethod(string,int32)

    Jak więc widać w obu przypadkach przed wywołaniem metody na stosie lądują dwie wartości: łańcuch i liczba. W pierwszym przypadku jest to wartość domyślna, pobrana z nagłówka metody. Kompilator MC++ został niestety potraktowany nieco po macoszemu i parametrów domyślnych .NET nie obsługuje. Może w następnej wersji?

    Parametry nazywane

    Od wersji 4.0 kompilator C# pozwala na przekazywanie parametrów do metod w dowolnej kolejności. Należy "tylko" podać ich nazwy w wywołaniu metody, jak to zostało pokazane poniżej:

    oc.OptionalMethod(value: 12, name:"Test3");
    oc.OptionalMethod(name:"Test4", value:666);

    Resztę pracy wykona kompilator. Ale jaką resztę dokładnie? I znowu ILDasm prawdę Ci powie. Jak wygląda normalne wywołanie widzieliśmy już wyżej. Teraz kod wygląda nieco inaczej.

    IL_0021: nop
    IL_0022: ldloc.0
    IL_0023: ldc.i4.s 12
    IL_0025: stloc.1
    IL_0026: ldstr "Test3"
    IL_002b: stloc.2
    IL_002c: ldloc.2
    IL_002d: ldloc.1
    IL_002e: callvirt instance void [Classes]ZineTest.OptionalClass::OptionalMethod(string,int32)
    IL_0033: nop
    IL_0034: ldloc.0
    IL_0035: ldstr "Test4"
    IL_003a: stloc.2
    IL_003b: ldc.i4 0x29a
    IL_0040: stloc.1
    IL_0041: ldloc.2
    IL_0042: ldloc.1
    IL_0043: callvirt instance void [Classes]ZineTest.OptionalClass::OptionalMethod(string,int32)

    Ups. Dla każdego parametru została wcześniej zadeklarowana zmienna. Kompilator przypisuje zawartość parametrów do odpowiednich zmiennych a następnie na stos ładuje ich wartości - już we właściwej kolejności. Co więcej, czynność ta wykonywana jest nawet gdy kolejność prametrów jest właściwa. Można się sprzeczać, czy jest o co szablę kruszyć. Narzut czasowy jaki udało mi się zmierzyć dla 20 parametrów można praktycznie zignorować. Warto jesdnak jest mieć ten fakt na uwadze, choćby z tego powodu, że gdzieś w pamięci zduplikowane zostaną dane przekazane do metody. A to nie zawsze może być pożądane.

    opublikowano 24 lutego 2011 12:03 przez ucel | 13 komentarzy
    Filed under: , ,
  • Trzy razy to samo, czyli o ukrywaniu kodu notka niekrótka

    Stanąłem ostatnio przed następującym problemem: ukryć algorytm(w tym wypadku zawartość metody) tak, żeby przynajmniej na pierwszy rzut oka nie dało się go przeczytać. Pogrzebałem troche w róźnych helpach i innych internetach i stwierdziłem, że idealnie do tego celu nada się klasa DynamicMethod. Jak się okazało łatwiej powierdzieć, trudniej zrobić. Jak zawsze zresztą...

    Punkt wyjścia

    Punktem wyjścia jest następująca krótka klasa:

    public class Program
    {
      // 1024 losowe wartosci
      private readonly byte[] _values = new byte[]
      {
        0x68, 0x6D, 0x4A, ...
      };

      public SecureString GetValue(int numer)
      {
        int seed = (_values[numer] << 8) | _values[numer + 1];
        Random rnd = new Random(seed);
        SecureString str = new SecureString();
        for (int i = 0; i < 32; i++)
          str.AppendChar(Convert.ToChar(_values[rnd.Next(0, 1024)]));

        return str;
      }
    }

    Ukryć chciałem zawartość metody GetValue(), tak aby po deasemblacji kodu nie dało się na pierwszy rzut oka stwierdzić co tu się dzieje.

    Pierwszym co na przychodzi na myśl w takim wypadku jest Code Obfuscation. Niestety jakość obfuskacji kodu jest lekko mówiąc mizerna i w przypadku tak prostej metody analiza jej kodu IL to przysłowiowa bułka z masłem. Ale swoją drogą, stwierdziłem, może by tak przepisać tę metodę na IL?
    VS generuje coś takiego:

    .method public hidebysig instance class [mscorlib]System.Security.SecureString
    GetValue(int32 numer) cil managed
    {
    // Code size 94 (0x5e)
    .maxstack 5
    .locals init ([0] int32 seed,
    [1] class [mscorlib]System.Random rnd,
    [2] class [mscorlib]System.Security.SecureString str,
    [3] int32 i,
    [4] class [mscorlib]System.Security.SecureString CS$1$0000,
    [5] bool CS$4$0001)

    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: ldfld uint8[] ConsoleApplication16.Program::_values
    IL_0007: ldarg.1
    IL_0008: ldelem.u1
    IL_0009: ldc.i4.8
    IL_000a: shl
    IL_000b: ldarg.0
    IL_000c: ldfld uint8[] ConsoleApplication16.Program::_values
    IL_0011: ldarg.1
    IL_0012: ldc.i4.1
    IL_0013: add
    IL_0014: ldelem.u1
    IL_0015: or
    IL_0016: stloc.0
    IL_0017: ldloc.0
    IL_0018: newobj instance void [mscorlib]System.Random::.ctor(int32)
    IL_001d: stloc.1
    IL_001e: newobj instance void [mscorlib]System.Security.SecureString::.ctor()
    IL_0023: stloc.2
    IL_0024: ldc.i4.0
    IL_0025: stloc.3
    IL_0026: br.s IL_004b
    IL_0028: ldloc.2
    IL_0029: ldarg.0
    IL_002a: ldfld uint8[] ConsoleApplication16.Program::_values
    IL_002f: ldloc.1
    IL_0030: ldc.i4.0
    IL_0031: ldc.i4 0x400
    IL_0036: callvirt instance int32 [mscorlib]System.Random::Next(int32, int32)
    IL_003b: ldelem.u1
    IL_003c: call char [mscorlib]System.Convert::ToChar(uint8)
    IL_0041: callvirt instance void [mscorlib]System.Security.SecureString::AppendChar(char)
    IL_0046: nop
    IL_0047: ldloc.3
    IL_0048: ldc.i4.1
    IL_0049: add
    IL_004a: stloc.3
    IL_004b: ldloc.3
    IL_004c: ldc.i4.s 32
    IL_004e: clt
    IL_0050: stloc.s CS$4$0001
    IL_0052: ldloc.s CS$4$0001
    IL_0054: brtrue.s IL_0028
    IL_0056: ldloc.2
    IL_0057: stloc.s CS$1$0000
    IL_0059: br.s IL_005b
    IL_005b: ldloc.s CS$1$0000
    IL_005d: ret
    } // end of method Program::GetValue

    Jak zaraz pokażę, kod da się trochę odchudzić. Wykorzystamy go, żeby stworzyć dymamiczną metodę, która będzie robić dokładnie to samo co GetValue().

    Drugi raz to samo...

    Nasza funkcja bedzie zwracać obiekt typu DynamicMethod i nazywać się rzecz jasna GenerateDynamicMethod(). Na początku identyfikujemy jakie zewnętrzne fukncje będą wywoływane z kodu IL:

    ConstructorInfo newRandom = typeof(Random).GetConstructor(new[] { typeof(Int32) });
    MethodInfo randomNext = typeof(Random).GetMethod("Next", new[] { typeof(Int32), typeof(Int32) });
    ConstructorInfo newSecString = typeof(SecureString).GetConstructor(new Type[] { });
    MethodInfo secStringAppend = typeof(SecureString).GetMethod("AppendChar", new[] { typeof(Char) });
    MethodInfo convertToChar = typeof(Convert).GetMethod("ToChar", new[] { typeof(byte) });

    Nie wolno też zapomnieć o polu _values:

    FieldInfo fiValues = GetType().GetField("_values", BindingFlags.Instance |
                                                      
    BindingFlags.NonPublic);

    Tworząc obiekt DynamicMethod należy podać zwracany typ (SecureString), typy parametrów (int) i typ, do którego metoda należy (w tym wypadku Program). Metoda będzie miała pełen dostęp do całej zawartości tego typu. Ważny jest też fakt, że jeśli metoda nie jest metodą statyczną, to zawsze ma jeden parametr więcej - pierwszym parametrem jest obiekt wywołujący metodę. Wiedząc to wszystko można zacząć pisać:

    DynamicMethod dm = new DynamicMethod("GetValue2",
                                        
    typeof(SecureString),
                                        
    new[] {typeof(Program), typeof(int) },
                                        
    typeof(Program));
                                        
    var gen = dm.GetILGenerator(256);

    Wygenerowany kod potrzebuje 6 zmiennych. My obędziemy się dwiema. Poza tym będzie potrzebna etykieta dla pętli. Pozostałe zmienne będziemy trzymać tylko na stosie.

    var rnd = gen.DeclareLocal(typeof(Random));
    var ss = gen.DeclareLocal(typeof(SecureString));

    var loop = gen.DefineLabel();

    Zostaje kod IL. Dla zwiększenia czytelności każda linia zawiera komentarz pokazujący bieżący stan stosu.

    gen.Emit(OpCodes.Ldarg_0);                     // (this)

    gen.Emit(OpCodes.Ldfld, fiValues);             // (_values)
    gen.Emit(OpCodes.Ldarg_1);                    
    // (par) (_values)
    gen.Emit(OpCodes.Ldelem_U1);                  
    // (x=_values[par])
    gen.Emit(OpCodes.Ldc_I4_8);                   
    // (8) (x)
    gen.Emit(OpCodes.Shl);                         // (x = x << 8);
    gen.Emit(OpCodes.Ldarg_0);                    
    // (this) (x)
    gen.Emit(OpCodes.Ldfld, fiValues);             // (_values) (x)
    gen.Emit(OpCodes.Ldarg_1);                     
    // (par) (_values) (x)
    gen.Emit(OpCodes.Ldc_I4_1);                   
    // (1) (par) (_values) (x)
    gen.Emit(OpCodes.Add);                        
    // (par=par+1) (_values) (x)
    gen.Emit(OpCodes.Ldelem_U1);                  
    // (y=_values[par]) (x)
    gen.Emit(OpCodes.Or);                         
    // (x = x | y)
    gen.Emit(OpCodes.Newobj, newRandom);          
    // (rnd)
    gen.Emit(OpCodes.Stloc_0, rnd);                //
    gen.Emit(OpCodes.Newobj, newSecString);        // (ss)
    gen.Emit(OpCodes.Stloc_1, ss);                 //
    gen.Emit(OpCodes.Ldc_I4_S, 32);                // (i = 32)
    gen.MarkLabel(loop);
    gen.Emit(
    OpCodes.Ldloc_1);                     
    // (ss) (i)
    gen.Emit(OpCodes.Ldarg_0);                     
    // (this) (ss) (i)
    gen.Emit(OpCodes.Ldfld, fiValues);            
    // (_values) (ss) (i)
    gen.Emit(OpCodes.Ldloc_0);                    
    // (rnd) (_values) (ss) (i)
    gen.Emit(OpCodes.Ldc_I4_0);                   
    // (0) (rnd) (_values) (ss) (i)
    gen.Emit(OpCodes.Ldc_I4, 1024);               
    // (1024) (0) (rnd) (_values) (ss) (i)
    gen.Emit(OpCodes.Callvirt, randomNext);        
    // (rndVal) (_values) (ss) (i)
    gen.Emit(OpCodes.Ldelem_U1);                  
    // (x = _values[rndVal]) (ss) (i)
    gen.Emit(OpCodes.Call, convertToChar);         
    // (x as char) (ss) (i)
    gen.Emit(OpCodes.Callvirt, secStringAppend);  
    // (i)
    gen.Emit(OpCodes.Ldc_I4_1);                   
    // (1) (i)
    gen.Emit(OpCodes.Sub);                         
    // (i = i-1)
    gen.Emit(OpCodes.Dup);                        
    // (i) (i)
    gen.Emit(OpCodes.Brtrue_S, loop);             
    // (i)
    gen.Emit(OpCodes.Pop);                        
    //
    gen.Emit(OpCodes.Ldloc_1);                    
    // (ss)
    gen.Emit(OpCodes.Ret);

    Na koniec pozostaje już tylko "opakowanie" naszej dynamicznej metody w delegata i gotowe:

    private delegate SecureString GetValueDelegate(int nr);
    private GetValueDelegate getValue2;
    public SecureString GetValue2(int nr)
    {
      if (getValue2 == null)
      {
        var dm = GenerateDynamicMethod();
        getValue2=(GetValueDelegate)dm.CreateDelegate(typeof(GetValueDelegate), this);
      }
      return getValue2(nr);
    }

    A jak w tym pogrzebać...

    Funkcja działa tak jak powinna. Ale niestety nie do końca spełnia założenia. OK, zamiast C# mamy IL, ale kod IL jest w miare czytelny i dalej łatwo domyśleć się o co w tym wszystkim chodzi. Ale przecież kod programu nie jest zapisywany jako sekwencja "ldarg.1 ldc.i4.1 add ldelem.u1" a raczej jako coś w rodzaju "03 91 1E 62". I faktycznie taki kod też się da uruchomić jako metodę dynamiczną. A skąd wziąć te wartości? Są dwie możliwości. Po pierwsze każdy obiekt OpCode posiada wartość odpowiadającą swojemu kodowi. Dla przykładu OpCodes.Ret.Value zawiera wartość będącą odpowiedzią na sens istnienia wszechświata czyli 42 :). Zamiast jednak żmudnie przepisywać cyferki można zajrzeć do wnętrza generatora i kod wyciągnąć stamtąd. Konkretniej mówiąc z prywatnego pola m_ILStream. Jest tylko jeden mały problem...

    Przyjrzyjmy się początkowym operandom i odpowiającym im kodom:

    ldarg.0        02
    ldfld _values  7B 03 00 00 04
    ldarg.1        03
    ldelem.u1      91
    ldc.i4.8       1E
    shl            62
    ldarg.0        02
    ldfld _values  7B 04 00 00 04

    I tak dalej, i tak dalej. Jak widać każde odwołanie do zmiennej (a także do metody) to 4 bajty adresu referencyjnego. Co ciekawe dla każdego odwołania do pola _values zostały utworzone oddzielne referencje, co jednak nie jest konieczne.

    Trzeci raz to samo...

    ...czyli inna implementacja GenerateDynamicMethod(). Także i tu potrzebujemy odwołań do konstruktorów i metod zdefioniawych wcześniej. Jeśli chodzi o pole _values, to będziemy używać wartości uchwytu do tego pola. Kod metody zdefiniujemy za pomocą GetDynamicILInfo():

    DynamicMethod dm = new DynamicMethod("GetValue2",
                                         
    typeof(SecureString),
                                         
    new[] {typeof(Program), typeof(int) },
                                         
    typeof(Program));
    var info = dm.GetDynamicILInfo();
    int token = info.GetTokenFor(fiValues.FieldHandle);
    byte[] code = new byte[] {
    0x02, 0x7B, 0x03, 0x00, 0x00, 0x04, 0x03, 0x91, 0x1E, 0x62, 0x02, 0x7B, 0x04, 0x00, 0x00, 0x04,
    0x03, 0x17, 0x58, 0x91, 0x60, 0x73, 0x05, 0x00, 0x00, 0x06, 0x0A, 0x73, 0x06, 0x00, 0x00, 0x06,
    0x0B, 0x1F, 0x20, 0x00, 0x00, 0x00, 0x07, 0x02, 0x7B, 0x07, 0x00, 0x00, 0x04, 0x06, 0x16, 0x20,
    0x00, 0x04, 0x00, 0x00, 0x6F, 0x08, 0x00, 0x00, 0x06, 0x91, 0x28, 0x09, 0x00, 0x00, 0x06, 0x6F,
    0x0A, 0x00, 0x00, 0x06, 0x17, 0x59, 0x25, 0x2D, 0xDD, 0x26, 0x07, 0x2A};

    Kod zdefiniowany wyżej to został wygenerowany w podejściu drugim, co oznacza, że trzeba w nim poprawić wszystkie odwołania do obiektów zewnętrznych. Wartość odwołania otrzymamy z funkcji GetTokenFor(), jak to już pokazałem dla pola _values. Poprawić należało też podreśloną wartość oznaczającą offset skoku - tu już nie mamy etykiet.

    MemoryStream mstr = new MemoryStream(code);
    BinaryWriter sw = new BinaryWriter(mstr);

    sw.Seek(2, SeekOrigin.Begin); sw.Write(token);
    sw.Seek(12, SeekOrigin.Begin); sw.Write(token);
    sw.Seek(41, SeekOrigin.Begin); sw.Write(token);
    sw.Seek(22, SeekOrigin.Begin); sw.Write(info.GetTokenFor(newRandom.MethodHandle));
    sw.Seek(28, SeekOrigin.Begin); sw.Write(info.GetTokenFor(newSecString.MethodHandle));
    sw.Seek(53, SeekOrigin.Begin); sw.Write(info.GetTokenFor(randomNext.MethodHandle));
    sw.Seek(59, SeekOrigin.Begin); sw.Write(info.GetTokenFor(convertToChar.MethodHandle));
    sw.Seek(64, SeekOrigin.Begin); sw.Write(info.GetTokenFor(secStringAppend.MethodHandle));

    Pozostały jeszcze dwie rzeczy: ustawienie kodu i deklaracja zmiennych. Pierwszą czynnością będzie wykonanie metody SetCode(). Przyda się też zobrazowanie stanu stosu z poprzedniego przypadku, gdyż musimy podać maksymalną jego wielkość

    info.SetCode(code, 6);

    Jeśli chodzi o zmienne, to te definiujemy metodą SetLocalSignature(). Zamiast definiować je ręcznie możemy użyć do tego celu klasy SignatureHelper:

    var helper = SignatureHelper.GetLocalVarSigHelper();
    helper.AddArgument(typeof(Random));
    helper.AddArgument(typeof(SecureString));
    var signature = helper.GetSignature();

    info.SetLocalSignature(signature);

    Uwaga: SetLocalSignature() musi zostać zawsze wywołana - nawet jeśli w metodzie nie definiujemy żadnych zmiennych. W przeciwnym wypadku uruchomienie metody zakonczy się wyjątkiem.

    Czy warto?

    Podstawowa reguła bezpieczeństwa danych mówi, że nie może ono zależeć od utajnienia algorytmu. W tym wypadku algorytm nie jest utajniony a raczej ukryty - przy pewnej dozie determinacji da się odczytać jego zawatość.

    Sam kod można potraktować jako wprawkę do dynamicznego generowania kodu - moim zdaniem tym przydatniejszą, że ciężko w Internecie znaleźć informacje na temat poprawnej definicji kodu dla trzeciej metody.

    opublikowano 4 lutego 2011 08:40 przez ucel | 3 komentarzy
  • Śmieszne to czy straszne...

    ...czyli do czego może doprowadzić szeroko rozumiana polityczna poprawność.

    Jeśli zapytam się czy znacie kontrolkę CountryList pod tą czy inną nazwą, to chyba każdemu przed oczami pojawi się mniej lub bardziej elegancki Combo Box z uporządkowaną alfabetycznie listą wszystkich krajów na świecie. No właśnie, alfabetycznie. A jaki kraj jest pierwszy na tej liście? Ab.., Ac..., Ad..., Af...? Tak, tak, niesławny AFGANISTAN.

    Pracuje w dużej firmie, która produkuje oprogramowanie na rynek niemiecki oraz lokalizowane wersje na rynki austriacki, czeski, polski, włoski i hiszpański. I oto „z góry” przyszedł przykaz, żeby coś zrobić z tym nieszczęsnym Afganistanem, bo „klientom się to nie podoba”. Niech domyślnym krajem będą Niemcy, lub docelowy kraj w którym oprogramowanie ma fukncjonować. Głośno nikt tego nie mówi, ale najlepiej byłoby pozbyć się nieszczęsnej nazwy z listy.

    Jak widać paranoja dotyka nie tylko rodaków Wuja Sama...

    opublikowano 28 października 2008 13:20 przez ucel | 5 komentarzy
    Filed under:
  • Windows 7

    Tym razem nie będzie o .NET. Tym razem będzie o polityce nazewniczej firmy Microsoft.

    Jeśli dawno temu ktoś dziwił się dlaczego trzecia wersja Worda jest wersją 6.0 zapewne zrozumie dlaczego Windows 7 będzie nazywał się Windows 7. Dokładniej próbuje wyjaśnić to Mike Nash na swoim blogu.

    Zapraszam do czytania, choć konkluzje są nieco niejasne:

    So we decided to ship the Windows 7 code as Windows 6.1.

    opublikowano 16 października 2008 15:54 przez ucel | 0 komentarzy
    Filed under:
  • XCData a zgodność ze standardami

    Natknąłem się dziś na dość irytującą cechę klasy XCData, reprezentującej element CDATA w dokumencie XML. Otóż jeśli w poniższym kodzie

    string xml = "<node><![CDATA[line1\r\nline2]]></node>";
    XDocument doc = XDocument.Parse(xml);
    XCData data = doc.Element("node").FirstNode as XCData;
    string value = data.Value;

    przeanalizujemy wartość zmiennej value, to okaże się, że nie zawiera ona, jak możnaby oczekiwać, łańcucha znaków line1\r\nline2 a line1\nline2. Okazuje się, że takie zachowanie jest jak najbardziej zgodne ze standardem XML, co można znaleźć tutaj.

    Są dwie możliwości obejścia tego problemu. Można wymusić, by znaki \r nie zostały usuwane z całego dokumentu poprzez użycie XmlTextReadera:

    string xml = "<node><![CDATA[line1\r\nline2]]></node>";
    XDocument doc = XDocument.Parse(new XmlTextReader(new StringReader(xml)));

    bądź skorzystanie z informacji zawartej w specyfikacji. Specyfikacja gwarantuje, że w zmiennej value nie znajdzie się znak \r, można więc zamienić wszystkie wystąpienia \n na \r\n.

    Co ciekawe problem ten nie występuje w standardowych klasach do obsługi dokumentów XML (XmlDocument / XmlCDataSection).

    opublikowano 20 czerwca 2008 17:47 przez ucel | 0 komentarzy
    Filed under: ,
  • Robimy sobie addina – część II

    Można by przypuszczać, że skoro wypuszczona w 2002 roku wersja Visual Studio ma „.net” w nazwie, to bazuje w większej części na technologii .NET. Cóż, tak naprawdę, przynajmniej jeśli chodzi o programowanie rozszerzeń środowiska, nazwa powinna mieć końcówkę .COM zamiast .NET. Bo Visual Studio .NET miało dotneta tylko w nazwie, a tak naprawdę cały model obiektowy funkcjonował,jak i poprzednich wersjach, na bazie interfejsów COM-owskich. Konsekwencją tego jest fakt, że środowisko nie jest tak intuicyjne jak powinno być. Ot chociażby interfejs użytkownika dla addinu: zarówno okienko z ustawieniami jak i okienko sterujące musiały być kontrolkami ActiveX.

    Ironicznie, pierwsza edycja nowego Visual Studio bez dotneta w nazwie, Visual Studio 2005, zaczęła być tą naprawdę dotnetową. A przynajmniej pod względem projektowania interfejsu użytkownika dla addinów. Koniec z modyfikowaniem rejestru, koniec z kontrolkami ActiveX / od tej pory używamy plików XML i natywnych kontrolek wyprowadzanych z System.Windows.Forms.Control. Ale nie koniec niestety z COM-em. W każdej chwili wyskoczyć może COMException, a wszystkie praktycznie interfejsy, które będziemy używać to interfejsy COM-owskie. Przypominam, że warunkiem widzialności addina dla Visual Studio było opakowanie assembly atrybutem ComVisible. Polecam się też przyjrzeć w jaki sposób ustawiany był obrazek na pasku narzędzi. Tak, tak, IPictureDisp to bynajmniej nie jest czysto dotnetowski interfejs.

    Ale dość tego już nieco przydługawego wstępu, wracajmy do naszego addina. Zanim jednak zacznę opisywać projektowanie interfejsu użytkownika – mała uwaga. Zdaję sobie sprawę, że tak naprawdę kolejność projektowania aplikacji jest odwrotna – czyli najpierw projektuje się funkcjonalność a potem interfejs, ale opisując technologię chcę umożliwić czytelnikom jak najszybsze przejście do własnych prób, a w tym wypadku szeroko rozumiana funkcjonalność addinu jest chyba najmniej interesująca.

    Interfejs użytkownika najwygodniej upakować jest w okienku narzędziowym, takim w jakim oferowane są standardowe narzędzia VS, jak Solution Explorer, Class View czy Server Explorer. Samo stworzenie takiego okienka nie jest trudne. Po pierwsze, tworzymy nową kontrolkę, wyprowadzoną z UserControl i umieszczamy na niej potrzebne nam akcesoria:


    Dla wygody i logicznego podziału addinu wszystkie elementy interfejsu użytkownika umieścimy w oddzielnej bibliotece, ZineVersion.UI.dll. Nie muszę chyba dodawać, że w opcjach projektu należy zaznaczyć haczyk przy „Register for COM Interop". Dodatkowo w przypadku tej kontrolki bardzo ważne jest by jej klasa posiadała atrybut ComVisible z wartością true. Po co – o tym za chwilę. Do projektu dodać musimy jeszcze odnośniki do EnvDTE.dll i EnvDTE80.dll oraz zdefiniować właściwości ToolWindow i DTE, gdzie będziemy przechowywać odpowiednio informacje o hoście kontrolki i środowisku:

    public Window2 ToolWindow { get; set; }
    public DTE2 DTE { get; set; }

    Aby wyświetlić właśnie zdefiniowaną kontrolke musimy wrócić do klasy Connect i jej metody Exec(). Zaimplementowanego tam beepa zastępujemy następującym kodem:

    if (CmdName == "ZineVersion.Connect.ZineTool")
    {
      
    if (ctl != null)
       {
          ctl.ToolWindow.Visible =
    true;
          Handled =
    true;
       }
      
    else
      
    {
         
    Window2 wnd = null;
         
    object refObj = null;
         
    string assemblyLocation = Assembly.GetCallingAssembly().Location;
         
    string currentDir = Path.GetDirectoryName(assemblyLocation);

         
    try
         
    {
            
    Windows2 wnds = (Windows2)(_applicationObject.Windows);
             wnd = wnds.CreateToolWindow2(
                        _addInInstance,
                        
    Path.Combine(currentDir, "ZineVersion.UI.dll"),
                       
    "ZineVersion.UI.ZineWindowCtl",
                       
    "Zine Version",
                        ZineGuid.ToString(
    "B"),
                       
    ref refObj) as Window2;
             ctl = refObj
    as IZineVersionCtl;
             ctl.ToolWindow = wnd;
             ctl.DTE = _applicationObject;
             wnd.Visible =
    true;
             Handled =
    true;
          }
         
    catch (Exception ex)
          {
            
    Debug.Write(ex.Message);
          }
       }
    }

    Dodatkowo w nagłówku będziemy potrzebować kilku deklaracji:

    private Guid ZineGuid = new Guid("{6E20BA3F-D4DC-4a48-A219-72BB8BB4BEEE}");
    private IZineVersionCtl ctl;

    Krótkie omówienie tego, co się powyżej dzieje. Aktywując addin mamy do czynienia z dwoma mozliwymi sytuacjami:

    1. okienko addinu było już wcześniej otwarte i teraz jest zamknięte – wystarczy je więc ponownie pokazać

    2. otwieramy okienko poraz pierwszy dla tej instancji VS

    W drugim przypadku musimy utworzyć najpierw okienko hostujące kontrolkę – robimy to używając metody CreateToolWindow2(). Różni się ona od obecnej w poprzednich wersjach Visual Studio metody CreateToolWindow() tym, że poprawnie obsługuje hostowanie kontrolek .NET – poprzednia wymagała kontrolki ActiveX.

    Ponieważ kontrolka znajduje się w innym pliku, wymagane jest podanie ścieżki do niego. Jeśli klasa kontrolki nie zostałaby oznaczona jako ComVisible, to obiekt reprezentujący kontrolkę nie zostałby zwrócony – wartość refObj będzie równa NULL. Stąd też moja wcześniejsza uwaga na temat tego atrybutu. Klucz ZineGuid jest używany do identyfikacji kontrolki w VS i może mieć dowolną wartość. Co ciekawe, jest on przekazywany jako łańcuch znaków i to łańcuch w konkretnym formacie – próba użycia wyniku standardowego wywołania ToString() skończy się mało mówiącym wyjątkiem COM Exception.

    Zwracany obiekt refObj to instancja nowoutworzonej kontrolki. Jeśli z jakiegoś powodu nie chcemy dodawać odwołania do System.Windows.Forms.dll do biblioteki addinu można interesującą nas funkcjonalność opakować w interfejs, tak jak zostało to zaprezentowane powyżej. Po wszystkim pozostaje jedynie ustawić atrybut Visible utworzonej kontrolki na true i cieszyć się nowym okienkiem w systemie, które możemy sobie zadokować np. przy Solution Explorerze. Tytuł okienka pojawi się wówczas w zakładce pod okienkiem, obok mało interesującego obrazka. Aby zmienić ten obrazek należy użyć metody SetTabPicture(). Oczekuje ona bitmapy w formacie IPicture. Co ciekawe format ten nie obsługuje przezroczystości, więc jakiś kolor musi zostać ustalony jako przezroczysty. Jaki? Oto jest pytanie...

    W wersji 2002/2003 był to kolor (0, 254, 0). W wersji 2005 i 2008 do RC były to kolory (254, 0, 254) i (255, 0, 255). W wersji finalnej żaden z nich nie działa i nikt nie wie dlaczego. Osobiście wypróbowałem kolor biały i stwierdziłem że nieźle zlewa się on z tłem zakładki.

    ...
    ctl.DTE = _applicationObject;
    wnd.SetTabPicture(GetTabPicture());
    wnd.Visible =
    true;
    ...

    private object GetTabPicture()
    {
      
    Bitmap img = Properties.Resources.BitmapVersionTool;
      
    Color transparent = Color.FromArgb(0, 255, 255, 255);

      
    if (_applicationObject.Version == "8.0")
          transparent =
    Color.FromArgb(254, 0, 254);

      
    for (int x = 0; x < img.Width; x++)
         
    for (int y = 0; y < img.Height; y++)
            
    if (img.GetPixel(x, y) == Color.FromArgb(192, 192, 192))
                img.SetPixel(x, y, transparent);

       stdole.
    IPicture ret = Support.ImageToIPicture(img) as stdole.IPicture;
      
    return ret;
    }

    W powyższym kodzie zastępuję domyślny kolor tła (szary) kolorem białym. Uwaga: żeby trick zadziałał, plik musi być 24-bitową bitmapą.

    Update

    Zgodnie z sugestią Wojtka wypróbowałem, bez przekonania przyznaję, metodę MakeTransparent(). Jakie było moje zdziwienie, gdy okazało się, że metoda działa możecie sobie sami wyobrazić. Co ciekawe, zostało to najprawdopodobniej zmienione w wersji RTM Visual Studio 2008, bo wersja RC (nie wspominając już VS 2005) potrzebowała jeszcze konkretnego koloru i wywołanie metody MakeTransparent() nie przynosiło żadnego skutku.

    Tak więc nasza metoda GetTabPicture() może zostać nieco zmodyfikowana:

    private object GetTabPicture()
    {
      
    Bitmap img = Properties.Resources.BitmapVersionTool;
      
    Color transparent = Color.FromArgb(0, 254, 0, 254);

      
    if (_applicationObject.Version == "8.0")
       {
         
    for (int x = 0; x < img.Width; x++)
            
    for (int y = 0; y < img.Height; y++)
               
    if (img.GetPixel(x, y) == Color.FromArgb(192, 192, 192))
                   img.SetPixel(x, y, transparent);
       }
      
    else
         
    img.MakeTransparent(Color.FromArgb(192, 192, 192));

       stdole.
    IPicture ret = Support.ImageToIPicture(img) as stdole.IPicture;
      
    return ret;
    }

    Mógłbym tutaj jeszcze na koniec dodać, że najprostsze rozwiązania są najmniej oczywiste...

    c.d.n.

    opublikowano 14 kwietnia 2008 16:26 przez ucel | 2 komentarzy
    Attachment(s): ZineVersion.zip
  • Jak to czasem kompikujemy sobie życie...

    Jakiś czas temu, w pierwszym numerze zine, popełniłem artykuł traktujący o sprawdzaniu czy dana wartość reprezentowana poprzez łańcuch znaków konwertuje się do interesującego mnie typu danych. W długim na półtorej strony artykule przedstawiłem skomplikowaną metodę bazującą na refleksji i szukaniu odpowiednich wersji metody Parse(). Całkiem niedawno okazało się, że wyważałem już dawno otwarte drzwi. Całą pracę załatwia tutaj TypeConverter.

    TypeConverterAttribute jest atrybutem na poziomie klasy definiującym dla niej konwerter typów. Najważniejsze metody konwertera to ConvertTo(), ConvertFrom(), CanConvertTo() i CanConvertFrom(). Sam konwerter dla danego typu otrzymuje się za pomocą klasy TypeDescriptor w następujący sposób:

    TypeConverter c = TypeDescriptor.GetConverter(type);

    Prosto i elegancko. Ale trzeba najpierw o tym wiedzieć. Oczywiście wszystkie typy podstawowe mają zdefiniowane konwertery typów i nie ma sensu próbować wykonywać konwersji ręcznie.

    opublikowano 3 kwietnia 2008 08:55 przez ucel | 1 komentarzy
    Filed under:
  • LINQ i Labmda – to mi się podoba

    Trochę się ostatnio bawiłem z Visual Studio 2008 i wersją 3.0 języka C#. A i w tym najbardziej reklamowanymi nowościami: LINQ i wyrażeniami lambda. I muszę powiedzieć, że wrażenia są bardzo, a to bardzo pozytywne.

    Mówiąc szczerze na początku nieco się obawiałem, czy będę w stanie przekonać się do składni labda expressions, a samo ich wprowadzenie wydawało mi się pewnym rodzaju udziwnieniem. Ale wystarczyły mi dwa dni pracy z LINQ To Objects (czyli m.in. rozszerzeniem interfejsu IEnumerable<T>) i składnię opanowałem w stopniu conajmniej zadowalającym. I teraz zamiast:

    string found = null;
    foreach (string el in lista)
    {
      
    if (el.StartsWith("ABC"))
       {
          found = el;
         
    break;
       }
    }

    piszę po prostu:

    string found = lista.FirstOrDefault(el => el.StartsWith("ABC"));

    O Statement Lambdas i ich zastosowaniu napiszę jeszcze kiedyś, bo to większy temat. A żeby było coś jeszcze o LINQ to mój faworyt: iteracja obiektów (nie wierszy!) zaznaczonych w gridzie posiadających niepustą interesującą mnie wartość:

    foreach (IProjectInfo project in
            
    from DataGridViewRow p in dgProjects.SelectedRows
            
    let prj = p.DataBoundItem as IProjectInfo
             
    where !String.IsNullOrEmpty(prj.Version)
            
    select prj)
    {
      
    DoAction(prj);

    Może nie wszyscy posiadający VS 2008 to wiedzą, ale środowisko zawiera świetny tutorial dla LINQ – polecam rozpakować sobie plik <Program Files>\Microsoft Visual Studio 9.0\Samples\1033\CSharpSamples.zip i skompilować projekt LinqSamples.sln. Naprawdę warto!

    opublikowano 22 lutego 2008 15:39 przez ucel | 3 komentarzy
    Filed under: , ,
  • Robimy sobie addina – część I

    Visual Studio oferuje kilka sposobów rozszerzenia własnej funkcjonalności. Często wykonywane polecenia można nagrać sobie w postaci makra. Akcje wykonywane w ramach projektu można zamknąć w postaci asystenta (bądź Wizarda jak kto woli). Bardziej skomplikowane polecenia i akcje można natomiat zaimplementować w addinie. I o tym jak stworzyć własnego addina chciałbym Wam napisać w krótkiej serii artykułów.
    Sposób tworzenia i konfiguracji Addinów zmienił sie między wersjami 7.1 i 8.0 środowiska Visual Studio. Ja tu skupię się na stworzeniu addina dla Visual Studio 2005/2008, jeśli jednak będzie takie zapotrzebowanie mogę pokrótce przedstawić też wersję dla środowiska Visual Studio .NET.
    Addin przeze mnie opisywany będzie służyć konkretnemu celowi: zmianie wersji projektu napisanego w C++. Informacja o wersji może być zapisana albo w odpowiednim pliku *.rc albo w pliku nagłówkowym o nazwie zapisanej w opcjach środowiska. Zmiana wersji może nastąpić albo poprzez akcję użytkownika wewnątrz środowiska, albo automatycznie, podczas udanej kompilacji odpowiedniej konfiguracji projektu. Oczywiście część wizualna addinu powinna umożliwiać zmianę wersji więcej niż jednego projektu na raz.

    Raz, dwa, trzy, zaczynamy...

    Są dwa podejścia utworzenia nowego addina. Pierwsze polega na wybraniu odpowiedniego typu nowego projektu:



    Drugie, nazywane przeze mnie „podejściem Ch. Petzolda” (kto zna jego książki z pewnością odgadnie dlaczego), polega na stworzeniu addina od zera, zaczynając od pustego projektu typu Class Library. I to podejście wykorzystam, gdyż pozwoli mi ono opisać dokładnie strukturę addina.

    Do utworzonego, pustego Class Library należy najpierw dodać referencje do kilku plików:
    • EnvDTE
    • EnvDTE80
    • EnvDTE90 (jeśli zamierzamy korzystać z obiektów specyficznych tylko dla VS 2008)
    • Extensibility
    • Microsoft.VisualStudio.CommandBars
    • stdole
    Będą one potrzebne zarówno do implementacji addinu jak i interakcji ze środowiskiem VS.

    Podstawowa klasa addina, stanowiąca łącze z VS, nazywa się zazwyczaj Connect i musi implementować interfejsy IDTExtensibility2 i IDTCommandTarget. Niech więc tak nazywa się pierwszy plik nowoutworzonego projektu:

    using System;
    using Extensibility;
    using EnvDTE;
    namespace ZineVersion
    {
      public class Connect : IDTExtensibility2, IDTCommandTarget
      {
        public Connect()
        {
        }
        #region IDTExtensibility2 Members
        public void OnAddInsUpdate(ref Array custom)
        {
        }
        public void OnBeginShutdown(ref Array custom)
        {
        }
        public void OnConnection(object Application,
                                 ext_ConnectMode ConnectMode,
                                 object AddInInst,
                                 ref Array custom)
        {
        }
        public void OnDisconnection(ext_DisconnectMode RemoveMode,
                                    ref Array custom)
        {
        }
        public void OnStartupComplete(ref Array custom)
        {
        }
        #endregion
        #region IDTCommandTarget Members
        public void QueryStatus(string CmdName,
                                vsCommandStatusTextWanted NeededText,
                                ref vsCommandStatus StatusOption,
                                ref object CommandText)
        {
        }
        public void Exec(string CmdName,
                         vsCommandExecOption ExecuteOption,
                         ref object VariantIn,
                         ref object VariantOut,
                         ref bool Handled)
        {
        }
    #endregion
      }
    }


    Pokrótce omówię poszczególne metody interfejsu IDTExtensiibility2:
    • OnAddInsUpdate() jest używana do zaprogramowania zależności od innych addinów. Przykładowo piszemy dwa addiny A1 i A2 i definiujemy sobie, że tylko jeden z nich może być aktywny. Implementujemy więc metodę OnAddInsUpdate i monitorujemy sobie zmiany zbioru aktywnych addinów;
    • OnBeginShutdown() jest uruchamiana wraz z zamykaniem środowiska. Można ją wykorzystać, by zwolnić zasoby używane przez addin;
    • OnConnection() jest uruchamiana gdy addin jest ładowany do VS. Na podstawie parametru ConnectMode można ustalić w jakim kontekście addin jest ładowany. Tej metodzie przyjrzymy się bliżej w dalszym ciągu artykułu;
    • OnDisconnection() jest uruchamiana wraz z usunięciem addina ze środowiska. Podobnie do OnBeginShutdown() można ją wykorzystać do zwolnienia zasobów używanych przez addin;
    • OnStartupComplete() jest uruchamiana wówczas, gdy uruchomione zostanie środowisko Visual Studio. Metoda ta zostanie oczywiście uruchomiona tylko w przypadku addinów, które ładują się razem ze środowiskiem.
    Zazwyczaj podczas rejestracji addinu w systemie jest on powiązywany z poleceniem VS – istniejącym bądź nowym, definiowanym tylko i wyłącznie na potrzeby modułu. Obsługą tego polecenia zajmują się metody interfejsu IDTCommandTarget. QueryStatus() określa status polecenia w danym momencie. Exec() jest używane do implementacji samego polecenia – tą metodą też zajmiemy się za chwilę.

    No to implementujemy...

    W pierwszej kolejności implementujemy metodę OnConnection(). Jak już wspomniałem wyżej, jest ona czymś w rodzaju entry point addina. Poprzez jej parametry otrzymujemy referencje do obiektu reprezentującego instancję Visual Studio jak i obiektu reprezentującego instancję addinu:

    private DTE2 _applicationObject;
    private AddIn _addInInstance;
    public void OnConnection(object Application,
                             ext_ConnectMode ConnectMode,
                             object AddInInst,
                             ref Array custom)
    {
        _applicationObject = (DTE2)Application;
        _addInInstance = (AddIn)AddInInst;


    Teraz należy właściwie zinterpretować wartość parametru ConnectMode. W tej chwili istotna dla nas będzie wartość ext_ConnectMode.ext_cm_UISetup. Metoda OnConnection() uruchamiana jest z tą wartością tylko wtedy, gdy addin jest konfugurowany w VS, np. przy pierwszym uruchomieniu po instalacji. To co w tym momencie należałoby wykonać, to utworzenie nowego polecenia uruchamiającego nasz kod oraz powiązanie go z menu lub paskiem narzędzi:

    // Instalacja addina
    if (ConnectMode == ext_ConnectMode.ext_cm_UISetup)
    {
        object[] contextUIGUIDs = new object[] { };
        Commands2 commands = (Commands2)_applicationObject.Commands;
        try
        {
            // Utworz polecenie
            Command command = commands.AddNamedCommand2(
                    _addInInstance,
                    "ZineTool",
                    "",
                    "Uruchamia ZineTool",
                    true,
                    59,
                    ref contextUIGUIDs,
                    (int)vsCommandStatus.vsCommandStatusEnabled+
                    (int)vsCommandStatus.vsCommandStatusSupported,
                    (int)vsCommandStyle.vsCommandStylePict,
                    vsCommandControlType.vsCommandControlTypeButton);
            // Teraz znajdz odpowiedni pasek narzedzi
            CommandBar commandBar;
            CommandBars bars = (CommandBars)_applicationObject.CommandBars;
            try
            {
                commandBar = bars["ZineBar"] as CommandBar;
            }
            catch (ArgumentException)
            {
                // Pasek narzedzi nie istnieje. Wiec go utworzmy.
                commandBar = commandBars.Add("ZineBar", 1, false, false);
            }
            // Dodaj kontrolke do p.aska
            CommandBarButton button = command.AddControl(commandBar, 1) as CommandBarButton;
            // I zmien jej obrazek
            button.Picture = Support.ImageToIPictureDisp(
                             Properties.Resources.BitmapVersionTool) as stdole.StdPicture;
            button.Mask = Support.ImageToIPictureDisp(
                          Properties.Resources.BitmapVersionToolMask) as stdole.StdPicture;
        }
        catch (ArgumentException)
        {
            // Jesli jestesmy tutaj, to najpewniej komenda juz istnieje
        }
    }


    Stworzyliśmy więc nowy pasek narzędzi, umieściliśmy na nim przycisk i powiązaliśmy go z poleceniem (uwaga!) ZineVersion.Connect.ZineTool. To co nam w tym momencie zostało, to implementacja kodu tego polecenia. Tę wykonujemy w funkcji Exec():

    public void Exec(string CmdName,
                     vsCommandExecOption ExecuteOption,
                     ref object VariantIn,
                     ref object VariantOut,
                     ref bool Handled)
    {
        Handled = false;
        if (ExecuteOption == vsCommandExecOption.vsCommandExecOptionDoDefault)
        {
             if (CmdName == "ZineVersion.Connect.ZineTool")
            {
                Console.Beep();
                Handled = true;
            }
        }
    }


    Na koniec, w metodzie QueryStatus(), określamy status utworzonego przez nas polecenia. Bez implementacji tej metody wyżej zdefiniowane polecenie dałoby się uruchomić tylko jeden raz.

    public void QueryStatus(string CmdName,
                            vsCommandStatusTextWanted NeededText,
                            ref vsCommandStatus StatusOption,
                            ref object CommandText)
    {
      if (NeededText == vsCommandStatusTextWanted.vsCommandStatusTextWantedNone)
        if (CmdName == "ZineVersion.Connect.ZineTool")
          StatusOption = (vsCommandStatus)vsCommandStatus.vsCommandStatusSupported | vsCommandStatus.vsCommandStatusEnabled;
    }


    I to prawie wszystko, tylko...

    Jak to teraz uruchomić???

    Instalacja addinów już w środowisku Visual Studio 2005 stała się bardzo łatwa (w porównaniu do poprzedniej wersji) i nie zmieniło się to w wersji obecnej. Polega ona na spreparowaniu odowiedniego pliku o rozszerzeniu .AddIn, wprowadzeniu do niego informacji o addinie i umieszczeniu go w katalogu <Moje Dokumenty>\Visual Studio 2008\Addins. Jeśli katalog nie istnieje, to należy go utworzyć. Przykładowy plik dla naszego addinu przedstawiam poniżej:

    <?xml version="1.0" encoding="utf-8" standalone="no"?>
    <Extensibility xmlns="http://schemas.microsoft.com/AutomationExtensibility">
      <HostApplication>
        <!-- Pozwol uruchomic addina w VS 2005 -->
        <Name>Microsoft Visual Studio</Name>
        <Version>8.0</Version>
      </HostApplication>
      <HostApplication>
        <!-- Pozwol uruchomic addina w VS 2008 -->
        <Name>Microsoft Visual Studio</Name>
        <Version>9.0</Version>
      </HostApplication>
      <Addin>
        <FriendlyName>ZineVersion</FriendlyName>
        <Description>ZineVersion - narzedzie do zarzadzania wersjami projektow
        </Description>
        <Assembly>e:\Private\Zine\ZineVersion\ZineVersion\bin\Debug\ZineVersion.dll
        </Assembly>
        <FullClassName>ZineVersion.Connect</FullClassName>
        <CommandPreload>1</CommandPreload>
        <CommandLineSafe>1</CommandLineSafe>
        <LoadBehavior>0</LoadBehavior>
      </Addin>
    </Extensibility>


    Poza tym docelowa assembly z addinem musi posiadać atrybut ComVisible. To wystarczy aby addin bezproblemowo ładował się wraz z uruchomieniem środowiska Visual Studio. Debugging addina jest też prosty. W tym celu w zakładce Debug ustawiamy Start Action na Start external program i wybieramy ścieżkę do pliku startowego Visual Studio. Następnie polecam zapisanie konfiguracji i skopiowanie jej jako Debug Reset. W tej skopiowanej dodajemy parametr startowy „/resetaddin ZineVersion.Connect”. Umożliwi nam to sterowanie kodem uruchamianym przy przyłączaniu addina – Visual Studio uruchomione z tym parametrem zachowa się tak jak przy pierwszym uruchomieniu addina i wywoła metodę OnConnection z parametrem ConnectMode = UISetup.

    I to wszystko. Po uruchomieniu nowej instancji Visual Studio konieczne może być dostosowanie wyświetlanych pasków narzędzi i aktywacja ZineBar. Po kliknięciu znajdującego się tam przycisku usłyszymy krótkie beep.

    W drugiej części (jeśli chcecie, żeby druga część powstała) opiszę jak do addinu dodać interfejs użytkownika – okienko narzędziowe, okienko opcji oraz jak porozumiewać się z użytkownikiem.


    opublikowano 21 lutego 2008 14:22 przez ucel | 4 komentarzy
    Attachment(s): ZineVersion.zip
  • Jeszcze o zachowywaniu historii danych…

    W mojej ostatniej notce pisałem o zachowywaniu historii danych za pomocą pól tabeli ValidFrom i ValidTo. Taki sposób zarządzania danymi wymaga oczywiście przedefiniowania operacji INSERT, UPDATE i DELETE. Tutaj chciałbym przedstawić pewien problem związany taką aktualizacją danych poprzez DataSet.

    Tym razem struktura danych jest prostsza, mianowicie mamy relację 1:n (oczywiście dla relacji m:n występuje ten sam problem):

     
    Nie wchodząc w szczegóły: klucze w tabelach są zdefiniowane jako wartości unikalne, generowane automatycznie, pola ValidFrom mają ustawioną wartość domyślną 01.01.1900, pola ValidTo analogicznie 31.12.9999. Dla tej struktury wygenerowałem sobie w Visual Studio 2005 dataset i zdefiniowałem dla tabel operacje SELECT, INSERT, UPDATE i DELETE. Typ relacji w datasecie ustawiłem na „FK & Relation” i włączyłem kaskadowego update’a. Kod dołączyłem do tekstu, tutaj pokaże tylko, najistotniejszą dla dalszej części, implementację operacji UPDATE dla tabeli ParentTable:

    ad.UpdateCommand = new SqlCommand(
       @"UPDATE ParentTable SET ValidTo=@versionTime WHERE Id=@id
       
       INSERT INTO ParentTable (Value, ValidFrom) VALUES (@value, @versionTime)
                              
       SELECT ID, Value, ValidFrom, ValidTo FROM ParentTable
       WHERE ID=@@IDENTITY", cnn, tr);
    ad.UpdateCommand.Parameters.Add("@value",
                                     System.Data.SqlDbType.NVarChar,
                                     50,
                                    "Value");
    ad.UpdateCommand.Parameters.Add("@id", System.Data.SqlDbType.Int, 4, "ID");
    ad.UpdateCommand.Parameters.AddWithValue("@versionTime", VersionTime);


    Testy przeprowadziłem na tabeli Parent zawierającej jeden wiersz o wartości ID=1 powiązany z jednym wierszem w tabeli Child, także o ID=1. Test polegał na zmianie wartości Value wiersza tabeli Parent, dodaniu to tej tabeli nowego wiersza i aktualizacji danych.

    Po zmodyfikowaniu danych próba wykonania operacji UPDATE na pierwszym wierszu kończy się wyjątkiem ConstraintException i komunikatem, że wiersz o ID=2 już istnieje w tabeli. Skąd tez identyfikator? A stąd, że w ramach aktualizacji danych został wpisany do bazy nowy wiersz, ze zmienioną wartością Value i nowym identyfikatorem. Kończąca operację instrukcja SELECT pobiera właśnie ten nowy wiersz powodując wewnętrzny konflikt w datasecie.
    Mądry Exception Helper w Visual Studio proponuje w tym momencie wyłączenie sprawdzania integralności danych na czas ich aktualizacji. Jeśli jednak to zrobimy, to efekty będą conajmniej... zaskakujące:

    Czytam dane:
    1 Parent1                01.01.1900 00:00:00 - 01.01.9999 00:00:00
            1(1) Child1      01.01.1900 00:00:00 - 01.01.9999 00:00:00

    Modyfikuje dataset
    1 Parent1*               01.01.1900 00:00:00 - 01.01.9999 00:00:00
            1(1) Child1      01.01.1900 00:00:00 - 01.01.9999 00:00:00
    2 Parent2                01.01.1900 00:00:00 - 01.01.9999 00:00:00

    Zapisuje dane do bazy
    2 Parent1*               24.01.2008 14:23:29 - 01.01.9999 00:00:00
    3 Parent2                01.01.1900 00:00:00 - 01.01.9999 00:00:00
            2(3) Child1      24.01.2008 14:23:29 - 01.01.9999 00:00:00

    Czytam dane:
    2 Parent1*               24.01.2008 14:23:29 - 01.01.9999 00:00:00
    3 Parent2                24.01.2008 14:23:29 - 01.01.9999 00:00:00
            2(3) Child1      24.01.2008 14:23:29 - 01.01.9999 00:00:00
    1 Parent1                01.01.1900 00:00:00 - 24.01.2008 14:23:29
            1(1) Child1      01.01.1900 00:00:00 - 24.01.2008 14:23:29

    Pierwszy blok to dane początkowe, drugi zmodyfikowane. Trzeci to stan po wykonaniu synchronizacji danych, czwarty to cała baza. Jak widać, wiersz w tabeli podrzędnej zmienił swojego „ojca”! Jak? Wiersze w tabeli Parent przetwarzene są sekwencyjnie, w takiej kolejności w jakiej są zapisane w tabeli. Kaskadowy update dla relacji oznacza, że każda zmiana wartości ID zostanie wprowadzona automatycznie w tabeli Child. Wiersze zostaną więc zmieniowe w następującej sekwencji:
    • W wyniku operacji UPDATE w pierwszym wierszu tabeli Parent ID=1 zostanie zmienione na ID=2
      •  W wyniku kaskadowego update w tabeli Child wszystkie rekordy z IDParent=1 zostaną zmienione na IDParent=2
    • W wyniku operacji INSERT dla drugiego wiersza tabeli Parent jego identyfikator ID=2 zostanie zmieniony na ID=3
      • W wyniku kaskadowego update w tabeli Child wszystkie rekordy z IDParent=2 zostaną zmienione na IDParent=3
    I tu jest pies pogrzebany...

    Rozwiązanie problemu jest bajecznie proste i co ciekawe zostało zaimplementowane w DataSet Designerze w VS 2008. Otoż wystarczy zmienić wartości dwóch właściwości dla kolumny ID w datasecie. AutoIncrementSeed zmienić z 0 na -1 a AutoIncrementStep z 1 ma -1. Mamy w tym momencie gwarantowane, że ID datasetu i bazy nigdy się nie pokryją.

    A wniosek z tego wszystkiego taki: nie wyłączajmy sprawdzania integralności datasetu jeśli naprawdę tego nie potrzebujemy.

    opublikowano 24 stycznia 2008 15:57 przez ucel | 0 komentarzy
    Filed under: ,

    Attachment(s): ConsoleApplication4.zip
  • Modelowanie relacji n:m z zachowaniem historii danych

    Czasami stosowany model danych wymaga, by nieaktualne rekordy nie zostawały usuwane z bazy danych, a zostały zachowane do późniejszej analizy. Zazwyczaj rozwiązuje się ten problem poprzez wersjonowanie rekordów. Są różne metody zachowywanie informacji o wersji, ja przedstawię tutaj metodę polegającą na rozszerzeniu tabeli o dwa pola określające okres ważności rekordu: ValidFrom i ValidTo.

    Uwaga: w całym tekście stosuję założenie, że wszystkie operacje na danych odbywają się po stronie klienta. Oznacza to, że nie mogę stosować ani procedur ani triggerów. Ot takie dodatkowe utrudnienie, ale nic na to nie poradzę... Wszystkie klucze są elementami IDENTITY i są generowane automatycznie po stronie serwera.

     

    Dla tak zdefiniowanej tabeli opreacje SELECT, INSERT, UPDATE i DELETE są zdefiniowane nieco inaczej niż w przypadku standardowym, poniżej przedstawiam odpowiedni kod:

    private SqlDataAdapter GetAdapterTable1(DateTime version)
    {
       SqlDataAdapter ad = new SqlDataAdapter();
       SqlCommand cmdSelect = new SqlCommand(
            @"SELECT Id, Name, ValidFrom, ValidTo
              FROM Table1
              WHERE @versionTime BETWEEN ValidFrom AND ValidTo"
    , cnn);
       cmdSelect.Parameters.AddWithValue("@versionTime", version);
       SqlCommand cmdInsert = new SqlCommand(
            @"INSERT INTO Table1(Name, ValidFrom, ValidTo)
              VALUES (@Name, @ValidFrom)
              SELECT @Id=@@IDENTITY"
    , cnn);
       cmdInsert.Parameters.Add("@Id", SqlDbType.Int, 4, "Id");
       cmdInsert.Parameters.Add("@Name", SqlDbType.VarChar, 50, "Name");
       cmdInsert.Parameters.AddWithValue("@ValidFrom", version);
       SqlCommand cmdDelete = new SqlCommand(
            @"UPDATE Table1 SET ValidTo=@ValidTo WHERE Id=@Id", cnn);
       cmdDelete.Parameters.Add("@Id", SqlDbType.Int, 4, "Id");
       cmdDelete.Parameters.AddWithValue("@ValidTo", version);
       SqlCommand cmdUpdate = new SqlCommand(
            @"UPDATE Table1 SET ValidTo=@ValidTo WHERE Id=@Id
              INSERT INTO Table1(Name, ValidFrom)
              VALUES (@Name, @ValidFrom)
              SELECT Id, Name, ValidFrom, ValidTo FROM Table1
              WHERE Id=SCOPE_IDENTITY()"
    , cnn);
       cmdUpdate.Parameters.Add("@Id", SqlDbType.Int, 4, "Id");
       cmdUpdate.Parameters.Add("@Name", SqlDbType.VarChar, 50, "Name");
       cmdUpdate.Parameters.AddWithValue("@ValidFrom", version);
       cmdUpdate.Parameters.AddWithValue("@ValidTo", version);
       ad.SelectCommand = cmdSelect;
       ad.InsertCommand = cmdInsert;
       ad.UpdateCommand = cmdUpdate;
       ad.DeleteCommand = cmdDelete;
       return ad;
    }


    SELECT i INSERT chyba nie wymagają komentarza, DELETE polega tylko na zmianie daty ważności rekordu, UPDATE to skombinowane DELETE/INSERT/SELECT. W kodzie korzystam z tego, że w tabeli ustawione są predefiniowane wartości dla pól określających ważność rekordu.

    Tak mimochodem wspomnę jeszcze (a’ propos tekstu Pawła), że użycie metody AddWithValue nie jest w tym miejscu „niebezpieczne”, jako że parametr version zostanie przez SQL Server przy kompilacji planu wykonania przedstawiony zawsze jako @version datetime.

    Ale miało być o relacjach, prawda? No więc co się dzieje, jeśli nasz schemat nieco skomplikujemy:

     

    Pierwsza sprawa: właściwości relacji należy tak skonfigurować, by oprócz relacji definiowany był również klucz obcy, a to dlatego, że tylko dla klucza można wymusić kaskadową aktualizację danych – dla reguły Update. Kiedy skorzystamy z kaskadowej aktualizacji? Przy wstawianiu nowego wiersza z tabeli Table1 bądź Table2 – po wstawieniu zawartość kolumny ID odpowiedniego wiersza zostanie zaktualizowana i rozpropagowana do odpowiednich pozycji w tabeli Relation.

    Pozostaje przypadek najtrudniejszy do zrealizowania – mianowicie UPDATE. Zakładając, że aktualizowane są tylko dane w tabeli, zachowane pomiędzy wersjami muszą zostać informacje o strukturze relacji. Innymi słowy update Table1 lub Table2 wiąże się ze zduplikowaniem odpowiednich pozycji w tabeli Relation i wstawieniem tam odpowiednich wartości ID. Zadanie wydawałoby się nietrywialne ale wykonywane praktycznie automatycznie.

    Po operacji UPDATE zdefiniowanej jak wyżej stary rekord „znika” z tabeli a pojawia się nowy, z poprawionym ID. Co ciekawe, dla wiersza jest to traktowane jako operacja UPDATE, więc odpowiedni wiersz w tabeli relacji zostaje także automatycznie zaktualizowany. I tu kryje się cały trick aktualizacji danych: dla synchronizacji tabeli relacji definiujemy tylko dwie komendy (nie definiujemy DELETE bo z definicji nie chcemy tracić informacji), ale obie składniowo są identyczne, tj. zawierają komendę INSERT:

    SqlCommand cmdUpdate = new SqlCommand(
         @"INSERT INTO Relation([OldId], [NewId])
           VALUES (@OldId, @NewId)
           SELECT [Id], [OldId], [NewId] FROM Relation
           WHERE Id=@@IDENTITY"
    , cnn);


    W ten sposób stare wiersze określające strukturę relacji w bazie nie zostaną nadpisane i będzie do nich dostęp kiedy przyjdzie taka potrzeba.
    Nie opisuję tutaj jakiegoś konkretnego systemu, ale mam nadzieję, że powyższe rozważania mogą okazać się pomocne dla kogoś, kto będzie potrzebował stworzyć własny system wersjonowania danych w bazie.

  • DataBinding, który nie działa tak jak trzeba

    Natknąłem się dzisiaj na dziwny problem w mechanizmie DataBinding. Problem ten dał się zredukować do prostego przykładu, który przedstawię poniżej.

    Na początek definiuję sobie strukturę danych złożoną z dwóch tabel, jednej nadrzędnej i jednej podrzędnej:


    Te tabele w jakiś sposób wypełniam sobie danymi:


    Po czym tworzę aplikację do prezentacji i zmiany tych danych:


    Tutaj kilka słów o ustawieniach łącz dla kontrolek. I tak bindingSource1 jest podłączona do data1.Parent, parentChildBindingSource zostało utworzone automatycznie i reprezentuje relację z Rys. 1. ListBox po lewej pobiera dane z bindingSource1 i wyświetla wartość właściwości Name, TextBox analogicznie wartość właściwości Description. Kod aplikacji jest dołączony do postu do ściągnięcia i przetestowania. Wszystko działa (teoretycznie) tak jak powinno:


    Zgodnie z dokumentacją, jeśli spróbujemy zmienić jedną z wartości na tym formularzu, to odpowiedni wiersz zmieni swój status z Unchanged na Modified. Informacja o stanie może zostać później użyta na przykład przy zapisywaniu zawartości datasetu z powrotem do bazy. Stąd olbrzymie moje zaskoczenie, kiedy to okazało się, że nie ma problemu w zmianie wartości pól tabeli podrzędnej (w gridzie), ale zmiana wartości Description tabeli nadrzędnej w TextBoxie powoduje zmianę odpowiedniej wartości w wierszu, ale nie(!) powoduje zmiany stanu wiersza.


    Jak widać, wiersze z DataGridView zmieniły swój stan na Modified, nawet bez zmiany aktualnego wiersza (drugi wiersz), ale choć Description w aktywnym wierszu tabeli głównej został zmieniony, to zmiana ta nie została uwzględniona w opisie stanu wiersza, co oznacza, że zmiany te nie zostaną później zapisane do bazy. Pomaga zmiana wiersza tabeli głównej i powrót do edytowane wiersza, ale co robić, gdy mamy tylko jeden wiersz w tabeli głównej?

    W powyższym przykładzie pomogła implementacja procedury obsługi zdarzenia TextChanged:


    Bezpośrednie przypisanie wartości do właściwości Description powoduje zmianę stanu wiersza na Modified. Ale czy takie rzeczy nie powinny się dziać automatycznie?

    opublikowano 28 października 2007 17:41 przez ucel | 5 komentarzy
    Filed under: ,

    Attachment(s): DataBindingTest.zip
  • Po co komu collation?

    Jedną z właściwości SQL Servera jest collation (nie pytajcie mnie o polskie określenie, bo takowego nie znam, a przecież nie spolszczę tego słówka na kolację). Żeby nieco przybliżyć sens używania i definiowania właściwej wartości collation, pozwolę sobie zacytować odpowiedni fragment Books Online:

    Collations specify the rules for how strings of character data are sorted and compared, based on the norms of particular languages and locales. For example, in an ORDER BY clause, an English speaker would expect the character string 'Chiapas' to come before 'Colima' in ascending order. But a Spanish speaker in Mexico might expect words beginning with 'Ch' to appear at the end of a list of words starting with 'C'. Collations dictate these kinds of sorting and comparison rules. The Latin_1 General collation will sort 'Chiapas' before 'Colima' in an ORDER BY ASC clause, while the Traditional_Spanish collation will sort 'Chiapas' after 'Colima'.

     

     

     


    Opis powyżej jak najbardziej prawidłowo oddaje charakter tej właściwości serwera i jest równocześnie tak mylący i niepełny jak tylko się to dało napisać. Skąd taka opinia? Zanim odpowiem na to pytanie, podzielę się z Wami ostatnimi błędami, które zdarzyły mi się podczas różnych prób instalacji SQL Serwera.

    1) Instalujemy SQL Server 2005 na polskim systemie Windows 2000. Instalacja przeprowadzana jest w trybie silent, parametry instalacji wyspecyfikowane są w pliku *.ini, jednym z nich jest linia SQLCOLLATION="SQL_Latin1_General_Pref_CP1_CI_AS". Instalacja kończy się błędem, ponieważ instalator nie jest w stanie znaleźć użytkownika ZARZADZANIE NT\SYSTEM. Powód? Usługa SQL Servera jest zakładana nie przez instalatora a przez proces serwera. Ten pobiera sobie łańcuch z nazwą użytkownika, przepuszcza go przez swoje funkcje przetwarzania łańcuchów (korzystające oczywiście ze zdefiniowanego collation) i „gubi” po drodze Ą przekształcając je do A. Rozwiązanie polegało na zamianie linii w pliku konfiguracyjnym na SQLCOLLATION="SQL_Polish_Cp1250_CI_AS".

    2) Collation definiuje nie tylko kolejność sortowania, ale także rozróżnianie małych i wielkich liter czy akcentów. I tak powyższe SQL_Polish_Cp1250_CI_AS oznacza sortowanie niezależne od wielkości liter (case-insentitive) i zależne od akcentu (accent-sensitive). Ciekawe efekty dało użycie SQL_Polish_Cp1250_CS_AS z sortowaniem zależnym od wielkości liter. Najpierw program konfigurujący nie był się w stanie połączyć z serwerem zwracając lakoniczny komunikat „Błąd logowania użytkownika SA”. Widzicie już powód? Tak, w bazie master nie ma loginu SA, jest sa. Po poprawieniu nazwy użytkownika w kodzie programu otrzymałem jeszcze kilka błędów, jak np. niemożność uruchomienia skryptu T-SQL, w którym do zadeklarowanej zmiennej @Cmd odwoływałem się poprzez @cmd.

    Tak więc odpowiednie zdefiniowanie collation w SQL Serwerze to nie tylko określenie kolejności sortowania danych. Wartość ta ma wpływ na wszystkie teksty przetwarzane przez serwer – od nazw kolumn po zmienne w skryptach nie wspominając już o parametrach procedur. Na szczęście nie wpływa ona na słowa kluczowe T-SQL – bez względu na zdefiniowanie collation zarówno select jak SELECT i SeLeCt są napisane poprawnie.

    opublikowano 12 września 2007 15:16 przez ucel | 27 komentarzy
    Filed under:
  • Kilka słów na temat śledzenia procedur składowanych w SQL Server 2005

    Visual Studio 2005 umożliwia śledzenie procedur składowanych (po ludzku: Stored Procedures) w SQL Server 2005. Fakt. Mówi się o tym, pisze się o tym, ale nie wspomina się, że cecha ta jest domyślnie wyłączona, nawet w domyślnej instalacji VS z SQL Express. Wiedza o tym, jak owo śledzenie aktywować jest rozbita po kilku artykułach MSDN, więc postaram się ją skonsolidować w jednym miejscu.

    Krok 1: Upewniamy się, że zainstalowany jest Remote Debugger dla VS 2005. Jeśli ktoś wykonywał standardową instalację VS, to nie ma się czym martwić w tym miejscu. W przeciwnym wypadku lepiej sprawdzić i doinstalować. Visual Studio Remote Debugger znajduje się domyślnie w Menu Start --> Microsoft Visual Studio 2005 --> Visual Studio Tools.

    Krok 2: Na komputerze z instancją serwera instalujemy debugger dla serwera. Jest to plik <sql server install dir>\90\Shared\<LCID>\rdbgsetup.exe.

    Krok 2a: Na komputerze klienta udostępniamy port TCP 135 w firewallu i dodajemy devenv.exe do listy aplikacji uprawnionych do dostępu do sieci. Analogicznie na komputerze serwera włączamy porty TCP 135, TCP 139, TCP 445, UDP 137, UDP 138 i dodajemy sqlserv.exe do listy aplikacji uprawnionych do dostępu do sieci. Cały ten krok można sobie darować, jeśli serwer zainstalowany jest na tym samym komputerze co VS.

    Krok 3: Ustawiamy uprawnienia na serwerze. Po pierwsze – nigdzie się o tym nie wspomina, ale debugger działa tylko w trybie Windows Authentication. Najlepiej będzie więc utworzyć login w SQL Serwerze o takiej samej nazwie jak nasz użytkownik Windows i przydzielić mu rolę sysadmina. Następnie, w bazie, w której będziemy uruchamiać procedurę, należy utworzyć użytkownika i zmapować go z właśnie stworzonym loginem. Dla użytkownika tego przydzielany rolę db_owner.

    I tyle. Niby proste, ale jednak trochę skomplikowane. Tekst powyższy tyczy się procedur T-SQL-owych, ale na tak ustawiony serwerze można też bezproblemowo śledzić kod CLR (po włączeniu śledzenia CLR).

    opublikowano 30 sierpnia 2007 10:18 przez ucel | 4 komentarzy
    Filed under:
  • Muj serwer jezd bespieczny

    Microsoft SQL Server obsługuje dwa tryby autoryzacji – autoryzację SQL (wbudowaną) i autoryzację Windows (w tak zwanym trybie mieszanym). W skrócie, różnią się one tym, że w pierwszym przypadku do nawiązania połączenia z serwerem potrzebne są dane autoryzacyjne (użytkownik i hasło), natomiast w drugim używane są uprawnienia bieżącego użytkownika. Nie chcę się tutaj wgłębiać w wyjaśnienia i analizy Microsoftu o tym jak to autoryzacja Windows jest super bezpieczna a SQL jest be, faktem jest natomiast, że niektóre firmy nie życzą sobie, aby jakikolwiek użytkownik systemu (w tym także administrator) miał dostęp do danych przechowywanych w bazie.

    Procedura blokowania autoryzacji Windows w SQL Serwerze jest prosta – należy we właściwościach loginów BuildIn\Administrators i BuildIn\Users ustawić pozwolenie na łączenie się z serwerem (permission to connect to database engine) na deny. Poza tym należy usunąć uprawnienie sysadmin dla loginu BuildIn\Administrators – inaczej administratorzy dalej będą mieli możliwość połączenia się z serwerem, a to z tego powodu, że nie ma możliwości zablokowania dostępu do serwera administratorowi serwera. Po wykonaniu powyższego możemy cieszyć się instancją serwera, do której mogą logować się tylko użytkownicy posiadający odpowiedni login na serwerze. Ale czy na pewno?

    SQL Server 2005, tak jak i jego poprzednik obsługuje tryb pojedynczego użytkownika (single user mode). Tryb ten jest aktywowany, gdy usługa serwera zostanie uruchomiona z dodatkowym parametrem –m. Co ciekawe, tak uruchomiony serwer, oprócz obsługi tylko jednego połączenia, ustawia rolę sysadmin dla administratorów systemu (niejawnie). Konsekwencją tego i tego co już wspomniałem wyżej jest to, że można się podłączyć do zabezpieczonego serwera korzystając z mechanizmu autoryzacji Windows. Błąd? Ależ skądże. Cały trik doczekał się już własnej pozycji w KB (KB937682) i nosi dumną nazwę failure recovery mechanism.

    opublikowano 16 sierpnia 2007 16:24 przez ucel | 1 komentarzy
Więcej wypowiedzi Następna strona »
W oparciu o Community Server (Personal Edition), Telligent Systems