Spis treści:

Kategoria:C#


Custom Tool w Visual Studio

Własne narzędzia wspomagające pracę Visual Studio

Każdy, kto choć przez chwilę pracował z LINQ To SQL, z Entity Framework lub z projektantami formularzy zastanawiał się, jak one to robią? Jak na podstawie definicji w pliku XML tworzą kod C#? Jak ze zwykłych obiektów DataSet tworzą się typowane klasy? Jak działa generator T4? Jeszcze inni zapewne zastanawiali się, do czego służy właściwość Custom Tool i Custom Tool Namespace dla każdego z plików. Być może ktoś zauważył w menu podręcznym dla pliku opcję Run Custom Tool? Wszystkie te opcje związane są z możliwością rozszerzania Visual Studio o dodatkowe narzędzia przekształcające jeden plik w coś zupełnie innego. To bardzo przydatna funkcja, którą warto poznać. Ja przedstawię najprostszą wersję - generatory jednoplikowe.

Teoria dodatków

Aby zacząć pracę z dodatkami należy zadać sobie podstawowe pytanie: skąd Visual Studio wie o istnieniu naszego narzędzia? Odpowiedzią jest rejestr systemu Windows i model COM (ang. Component Object Model). Należy wobec tego stworzyć narzędzie które:

  • będzie obiektem COM implementującym interfejs IVsSingleFileGenerator,
  • zostanie odpowiednio wskazane przez rejestr Windows.

Pozostałe kroki potrzebne do uruchomienia narzędzia wymagają spełnienia warunków dwóch powyższych punktów. Jeżeli nie wszystko jest w tym momencie zrozumiałe, nie należy się przejmować. Przejdziemy cały proces krok po kroku.

Implementacja interfejsu IVsSingleFileGenerator

Implementacja interfejsu IVsSingleFileGenerator to podstawowe zadanie. Zadanie nie różni się od implementacji innych interfejsów, ale w samych metodach jest kilka rzeczy, o których trzeba wspomnieć. Przyjrzyjmy się samemu interfejsowi:

public class Generator : IVsSingleFileGenerator
{
    public int DefaultExtension(out string pbstrDefaultExtension)
    {

    }

    public int Generate(string wszInputFilePath,
                        string bstrInputFileContents,
                        string wszDefaultNamespace,
                        IntPtr[] rgbOutputFileContents,
                        out uint pcbOutput,
                        IVsGeneratorProgress pGenerateProgress)
    {

    }
}

Wyjaśnijmy sobie obie metody i parametry. Pierwsza z nich, DefaultExtension, wymaga od nas dwóch informacji. Wartość przekazywana w parametrze wszInputFilePath określa domyślne rozszerzenie generowanego przez nas pliku. Wartość zwracana to długość przekazywanego parametrem wszInputFilePath tekstu. Po co tak to rozdzielać? To zaszłość historyczna. Pamiętajmy, że COM to nie .NET. To także C++, w którym łańcuch znaków jest często zwykłą tablicą bajtów zakończoną zerem. Określenie długości takiego łańcucha wymaga przeszukania obszaru pamięci pod wskaźnikiem i zliczenia wszystkich bajtów różnych od zera. Gdy w to wszystko wchodzi kodowanie UNICODE, ostatni znak reprezentowany jest przez dwa zerowe bajty. Żeby ułatwić sobie zadanie, taka dodatkowa wartość jest często w języku C++ przekazywana (wystarczy przejrzeć choćby WinAPI).

Druga metoda, Generate, jest bardziej skomplikowana. Służy ona do generowania pliku i ma następujące parametry:

  1. wszInputFilePath - Ścieżka do pliku wejściowego, to jest pliku, z którego będziemy generować własny plik.
  2. bstrInputFileContents - Zawartość pliku wejściowego wskazanego pierwszym parametrem. Wartość wydaje się nadmiarowa, bo mamy przecież ścieżkę do tego pliku. Opcja Run Custom Tool może być jednak wywołana na pliku, w którym modyfikacje nie zostały jeszcze na dysku zapisane. Lepiej zatem korzystać z tej wartości niż samemu odczytywać plik.
  3. wszDefaultNamespace - domyślna przestrzeń nazw. Narzędzie może użyć tej wartości w procesie generowania kodu umieszczając kod klasy, struktury lub typu wyliczeniowego wewnątrz tej przestrzeni. Visual Studio ustawia tę wartość na podstawie nazwy projektu i katalogów znajdujących się po drodze do pliku wejściowego. Można tę wartość również wpisać we oknie właściwości pliku (Custom Tool Namespace). Taka wartość będzie traktowana priorytetowo. Ostatecznie i tak wszystko zależy od implementacji metody Generate. Skorzystanie z tej wartości jest jednak dobrą praktyką.
  4. rgbOutputFileContents - Tablica wskaźników typu int. Wartość na tyle enigmatyczna, że zajmę się nią oddzielnie.
  5. pcbOutput - Rozmiar zwracanego pliku wyjściowego. Zawartość przekazywana jest w postaci bajtów w parametrze rgbOutputFileContents, rozmiar w parametrze pcbOutput. Patrz uwaga do metody DefaultExtension.
  6. pGenerateProgress - Interfejs pozwalający informować o postępach w generowaniu pliku. Jeżeli proces trwa długo, można się tym parametrem zainteresować. W większości przypadków, także w pokazanym przykładzie, nie jest on obsługiwany.

Wskaźniki i przydzielanie pamięci

Programiści piszący tylko w językach zarządzanych nie muszą pamiętać o przydzielaniu, a następnie zwalnianiu pamięci dla obiektów. Jest w .NET mechanizm usuwania elementów nieużywanych (ang. garbage collector), zwany również odśmiecaczem (osobiście bardzo mi się ta zgrabna nazwa podoba). W języku C++ powszechną praktyką jest przydzielanie obszaru pamięci a następnie przekazywanie tylko i wyłącznie wskaźnika do tego obszaru. Komponenty COM były projektowane jako niezależne od języka programowania. To języki musiały się dostosować. Interfejs IVsSingleFileGenerator jest w rzeczywistości nakładką na interfejs COM. Skoro interfejs COM przyjmuje wskaźnik, a dokładniej tablicę wskaźników, interfejs .NET musi się podporządkować. Kod .NET musi też samodzielnie przydzielić taką pamięć i przekazać wskaźnik w pierwszym elemencie tablicy rgbOutputFileContents. Do rezerwacji obszaru pamięci służy metoda Marshal.AllocCoTaskMem. Zostanie ona za chwilę pokazana. Na ten moment wystarczy wiedzieć, że przyjmuje ona jeden argument określający rozmiar rezerwowanej pamięci. Nie ma potrzeby zwlaniania tej pamięci, bo zrobi to za nas, po drugiej stronie, Visual Studio. Popatrzmy na przykładową implementację.

Implementacja metod interfejsu

Nie napisałem jeszcze tego, co będzie robiło nasze narzędzie. Otóż będzie ono generowało typ wyliczeniowy na podstawie definicji zapisanych w bazie danych. Plik wejściowy będzie zawierał dwie linijki tekstu, na przykład takie:

Data Source=.;Initial Catalog=Test;Integrated Security=True
SELECT Name, ID FROM EnumTest

Pierwsza linijka to łańcuch połączeniowy z bazą, drugi - zapytanie zwracające nazwę oraz wartość typu wyliczeniowego. Z tych informacji powstanie typ enum. Popatrzmy teraz na samą implementację:

public int DefaultExtension(out string pbstrDefaultExtension)
{
    pbstrDefaultExtension = ".cs";
    return pbstrDefaultExtension.Length;
}

public int Generate(string wszInputFilePath, string bstrInputFileContents,
    string wszDefaultNamespace, IntPtr[] rgbOutputFileContents,
    out uint pcbOutput, IVsGeneratorProgress pGenerateProgress)
{
    string path = Path.GetFileNameWithoutExtension(wszInputFilePath);
    string[] lines = bstrInputFileContents.Replace("\r", "").Split('\n');
    StringBuilder sb = new StringBuilder();
    sb.AppendFormat("namespace {0}\r\n", wszDefaultNamespace);
    sb.AppendLine("{");
    sb.AppendFormat("    enum {0}\r\n", path);
    sb.AppendLine("    {");
    using (var conn = new SqlConnection(lines[0]))
    using (var cmd = new SqlCommand(lines[1], conn))
    {
        conn.Open();
        using (var reader = cmd.ExecuteReader())
        {
            while (reader.Read())
            {
                sb.AppendFormat("        {0} = {1},\r\n", reader[0], reader[1]);
            }
        }
    }
    sb.AppendLine("    }");
    sb.AppendLine("}");
    string result = sb.ToString();
    byte[] bytes = Encoding.UTF8.GetBytes(result);
    int length = bytes.Length;

    rgbOutputFileContents[0] = Marshal.AllocCoTaskMem(length);
    Marshal.Copy(bytes, 0, rgbOutputFileContents[0], length);

    pcbOutput = (uint)length;
    return VSConstants.S_OK;
}

Warto zwrócić uwagę na sposób przydziału pamięci i kopiowanie sekwencji bajtów metodami z klasy Marshal. W pokazanym powyżej kodzie celowo pominąłem weryfikację poprawności danych. W kodzie produkcyjnym należałoby jakoś obsłużyć błędne dane wejściowe i sytuacje wyjątkowe.

Przygotowanie komponentu COM

Biblioteki .NET mogą być bardzo łatwo zamienione na komponenty COM. Sprowadza się to do dwóch kroków:

  • Oznaczenia biblioteki atrybutem [assembly: ComVisible(true)]. W domyślnym projekcie biblioteki klas atrybut ten znajduje się w pliku AssemblyInfo.cs i jest ustawiony na false - wystarczy go zmienić.
  • Nadania klasie atrybutu GuidAttribute. Atrybut ten staje się identyfikatorem obiektu COM.

Wartość atrybutu można wygenerować w Visual Studio i udekorować nim klasę:

[Guid("972B96C6-0807-48D4-BDB4-06102C5CF0E3")]
public class Generator : IVsSingleFileGenerator
{
    (...)

Rejestracja komponentu COM

Przygotowana biblioteka może być wskazywana przez rejestr Windows jako klasa COM, ale należy ją najpierw zarejestrować. Służy do tego narzędzie regasm (od ang. Register Assembly):

regasm "ścieżka\EnumGenerator.dll"

Dzięki takiej operacji w kluczu HKEY_CLASSES_ROOT pojawi się odpowiedni wpis wskazujący bibliotekę (EnumGenerator.dll) z funkcją dostępną w COM. Skąd jednak Visual Studio ma wiedzieć, że akurat nasz komponent jest tym Custom Tool? W rejestrze są przecież tysiące komponentów. Visual Studio potrzebuje dodatkowych informacji.

Dodatkowe operacje podczas rejestrowania komponentu

Biblioteki pisane jako kontenery COM mogły przechwytywać moment rejestrowania i wyrejestrowywania samych siebie. W modelu COM służyły do tego metody DllRegisterServer i DllUnregisterServer. Podobną czynność można wykonać w .NET. Metody wywoływane podczas rejestrowania i wyrejestrowywania oznacza się odpowiednio atrybutami ComRegisterFunctionAttribute oraz ComUnregisterFunctionAttribute. Czego wymaga Visual Studio? Wymaga wpisu w rejestrze HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\VisualStudio\{numer}\Generators\{kod-języka}\{nazwa-narzędzia}, gdzie:

  • {numer} - oznacza numer wersji Visual Studio (12.0 - Visual Studio 2013, 11.0 - Visual Studio 2012, 10.0 - Visual Studio 2010, 9.0 - Visual Studio 2008).
  • {kod-języka} - oznacza globalny identyfikator języka programowania. Generator dla języka C# będzie miał kod fae04ec1-301f-11d3-bf4b-00c04f79efbc, dla VB.NET będzie to 164b10b9-b200-11d0-8c61-00a0c91e29d5, J# jeszcze inny - e6fdf8b0-f3d1-11d4-8576-0002a516ece8.
  • {nazwa-narzędzia} - nazwa naszego narzędzia Custom Tool wykorzystywana w Visual Studio.

W podanej ścieżce powinny się znaleźć, według dokumentacji, minimum trzy wpisy i są to:

  • dla klucza domyślnego - pole wprawdzie opcjonalne, ale sugerowane, które zawiera opisową nazwę narzędzia.
  • dla klucza CLSID - określa identyfikator klasy lub komponentu COM, który implementuje interfejs IVsSingleFileGenerator.
  • dla klucza GeneratesDesignTimeSource - wartość 1, jeżeli narzędzie wymaga środowiska graficznego, 0 jeżeli nie wymaga.

Zależności jest dużo, ale wszystko powinno się wyjaśnić po obejrzeniu przykładowych implementacji wspomnianych wcześniej metod rejestrujących:

[Guid("972B96C6-0807-48D4-BDB4-06102C5CF0E3")]
public class Generator : IVsSingleFileGenerator
{
    (...)

    [ComRegisterFunction]
    public static void RegisterClass(Type t)
    {
        GuidAttribute guidAttribute = t.GetCustomAttribute<GuidAttribute>();
        using (RegistryKey key = Registry.LocalMachine.CreateSubKey(GetCSKey("PD.EnumGenerator")))
        {
            key.SetValue("", "Generator klasy z typami wyliczeniowymi na podstawie bazy danych");
            key.SetValue("CLSID", string.Format("{{{0}}}", guidAttribute.Value));
            key.SetValue("GeneratesDesignTimeSource", 0);
        }
    }

    [ComUnregisterFunction]
    public static void UnregisterClass(Type t)
    {
        Registry.LocalMachine.DeleteSubKey(GetCSKey("PD.EnumGenerator"), false);
    }

    private static string GetCSKey(string toolName)
    {
        //                                                    VS Version                 Language GUID (C#)
        return string.Format("SOFTWARE\\Microsoft\\VisualStudio\\12.0\\Generators\\{{FAE04EC1-301F-11D3-BF4B-00C04F79EFBC}}\\{0}", toolName);
    }

Komponent podczas rejestracji powinien dopisać do rejestru jeszcze kilka dodatkowych informacji. To już prawie wszystko.

Rejestracja komponentu w Global Assembly Cache (GAC)

Szybko uspokajam: to już ostatni krok na drodze własnego Custom Tool. Biblioteka klas .NET musi być jeszcze zarejestrowana w GAC. Służy do tego prosta instrukcja:

gacutil /i "ścieżka\EnumGenerator.dll"

Aby biblioteka mogła być wstawiona do GAC, musi mieć silną nazwę. W przeciwnym razie dostaniemy taki oto komunikat:

Failure adding assembly to the cache: Attempt to install an assembly without a strong name

Nie zamierzam rozwijać tematu silnych nazw. Wspomnę tylko tyle, że wiąże się to z podpisywaniem bibliotek. Podpisać bibliotekę można bezpośrednio w Visual Studio klikając prawym przyciskiem myszy na projekt, wchodząc w opcję Properties i na zakładkę Signing. Tam należy zaznaczyć pole Sign the assembly i wybrać plik z listy (ewentualnie kliknąć <new...>). Po skompilowaniu projektu można spróbować wykonać instrukcję gacutil jeszcze raz. Tym razem powinna się zakończyć sukcesem.

Testowanie narzędzia

Typy wyliczeniowe mają się generować z bazy danych, więc taką bazę należy sobie przygotować. W przykładzie zakładam, że baza jest zainstalowana lokalnie i nazywa się test. W bazie danych znajduje się tabelka Enum test utworzona przy pomocy następujących instrukcji:

CREATE TABLE EnumTest
(
  ID int NOT NULL,
  Name varchar(20) NOT NULL
)

INSERT EnumTest VALUES
(1, 'Niski'),
(2, 'Umiarkowany'),
(3, 'Wysoki')

Zakładając, że narzędzie jest zarejestrowane (regasm) oraz umieszczone w GAC (gacutil), możemy stworzyć sobie testowy projekt i dodać do niego plik MyEnum.txt. Teraz należy na niego kliknąć, wybrać właściwości i w polu Custom Tool wpisać PD.EnumGenerator:

rysunek przedstawiający sposób ustawienia narzędzia Custom Tool
Ustawienie narzędzia Custom Tool.

Teraz można kliknąć na pliku prawym przyciskiem myszy i wybrać Run Custom Tool. Poniżej pliku tekstowego powinien się pojawić nowy, wygenerowany plik:

rysunek przedstawiający strukturę plików po uruchomieniu narzędzia Custom Tool
Struktura plików po uruchomieniu narzędzia Custom Tool.

Zawartość pliku powinna być taka, jak pokazana na poniższym listingu:

namespace GeneratorTest
{
    enum MyEnum
    {
        Niski = 1,
        Umiarkowany = 2,
        Wysoki = 3,
    }
}

Jeżeli wszystko zadziała, możemy się cieszyć. Jeżeli nie, jest problem.

Co się może nie udać?

Szukanie błędów jest trudne, bo wszystko dzieje się w ramach obcego procesu Visual Studio. W procesie bierze też udział rejestr, globalne identyfikatory, w których można się pomylić. Jest też GAC, który może przechowywać starszą wersję pliku. Gdy po kliknięciu Run Custom Tool nic się nie wydarzy lub dostaniem komunikat, że Visual Studio nie może znaleźć pliku polecam następujące kroki:

  • wyrejestrowanie komponentu z GAC:
    gacutil /u "EnumGenerator"
  • usunięcie komponentu z rejestru:
    regasm /u "ścieżka\EnumGenerator.dll"
  • sprawdzenie, czy nie zrobiliśmy jakiejś literówki i powtórna rejestracja i instalacja w GAC,
  • należy się upewnić, że podana jest właściwa wersja Visual Studio i właściwy kod języka,
  • w ostateczności można wykonać restart Visual Studio,
  • przede wszystkim czytać komunikaty wyświetlane na każdym z etapów.

Gdy wszystko się uda można umieścić skrypty regasm oraz gacutil w projekcie w sekcji Post Build Events i jeszcze bardziej zautomatyzować cały proces:

ścieżka/gacutil /u "$(ProjectName)"
ścieżka/regasm /u "$(TargetPath)"
ścieżka/regasm "$(TargetPath)"
ścieżka/gacutil /i "$(TargetPath)"

Należy oczywiście odpowiednio ustawić ścieżki. Niezidentyfikowane problemy można zgłaszać w komentarzach, być może uda mi się jakoś pomóc.

Kategoria:C#

, 2014-02-27

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
Dzieki za rozjasnienie zagadnienia upsert. wlasnie sie ucze programowania :).
Co się stanie gdy spróbuję wyszukać:
SELECT * FROM NV_Airport WHERE Code='SVO'
SELECT * FROM V_Airport WHERE Code=N'SVO'
(odwrotnie są te N-ki)
Będzie konwersja czy nie znajdzie żadnego rekordu?