Dorabianie GUI do istniejących aplikacji na przykładzie SVN

5

Czas na drugi odcinek serialu pod tytułem “Wymyślże jakiś problem i zaproponuj jego rozwiązanie”. Poprzedni post zgromadził pod sobą interesujące wg mnie komentarze, jak będzie tym razem? Postaram się także zastosować do zawartych tamże sugestii co do formy przedstawienia swojego pomysłu.


Przedstawienie problemu


Dzisiaj zajmę się kwestią “dorabiania GUI” do już istniejących aplikacji konsolowych. “A po cóż owo cuś czynić?”, zakrzyknąć ktoś może w niezmiernym zdziwieniu. A więc po pierwsze: bo przejęcie napisanej przez kogoś funkcjonalności i opakowanie we własny program może dać całkiem interesujące efekty, i po drugie: bo może to bardzo uprościć pracę z takim narzędziem. Wyobraża ktoś sobie korzystanie z, chociażby, Subversion bez tak porządnego klienta jak Tortoise, mając do dyspozycji jedynie konsolowy odpowiednik? Na wypadek, gdyby komuś Tortoise z niewiadomych względów nie odpowiadał, może dopieścić efekt owego posta i pochwalić się w komentarzach tworem go detronizującym:).


Jeszcze lepszy nawet przykład: stsadm.exe. Każdy kto miał do czynienia z Sharepointem musiał używać tego jakże potężnego narzędzia. O ile prościej jednak byłoby dla zwykłego programisty (bo prawdziwy admin z krwi i kości plwa zapewne na jakiekolwiek GUI:) ) odpalić (np ukryty w trayu) programik i w razie potrzeby kliknąć w 1-2 przyciski zamiat uczyć się na pamięć nie-wiadomo-ile komend nadających się pod koniec projektu główne do zapomnienia? Ja zdecydowałem się na przedstawienie “opakowania” SVN, które wszyscy mogą w każdej chwili przetestować (oczywiście wymagana jest uprzednia instalacja Subversion), jednak zrobienie tego samego z stsadm (co już nie jest tak proste do odpalenia na domowym komputerze) to chyba całkiem niezły pomysł.


I na koniec wstępu ogólnikowo rzucę scenariusz, gdzie takie rozwiązanie (opakowanie SVN we własną bibliotekę) znalazło zastosowanie w rzeczywistości: śledzenie zmian dokonywanych w bazie danych (Oracle) i automatyczne wersjonowanie ich w SVN wraz z komentarzami “programistów-modyfikatorów” poprzez intranetową stronę WWW.


Koncepcja rozwiązania


Sam pomysł (o ile można to nazwać “pomysłem”) osiągnięcia takiego efektu jest bardzo prosty: musimy pod wybrane guziki w oknie podpiąć odpalenie żądanego procesu z odpowiednimi parametrami. Oczywiście pojawia się tutaj kwestia wydajności: z pewnością bardziej wydajne byłoby skorzystanie z np. publicznie dostępnego API do SVN niż każdorazowe startowanie gotowego klienta svn.exe. Czy jednak w “programistycznych warunkach” musimy się czymś takim przejmować? Moim zdaniem nie – skoro dana aplikacja powstała, aby być uruchamianą z linii komend to równie dobrze możemy ją uruchomić z C# i nic się wielkiego nie stanie. A pracy będziemy mieli milion razy mniej. W takim razie jak to zrobić “ładnie”, żeby całość nie wyglądała tak obleśnie?:


 1:   private void btnUpdate_Click(object sender, EventArgs e)
2: {
3: Process p = new Process();
4: ProcessStartInfo psi = new ProcessStartInfo(“svn”, “update”);
5: p.Start();
6: }
7:
8: private void btnLog_Click(object sender, EventArgs e)
9: {
10: Process p = new Process();
11: ProcessStartInfo psi = new ProcessStartInfo(“svn”, “log”);
12: p.Start();
13: }

To niestety ma tyle wspólnego z programowaniem obiektowym co Doda z prawdziwym mięsem, z którego nas Bozia ulepiła.


Zobaczmy co mam ja, następnie zobaczymy co dopiszą komentatorzy i wszyscy nauczymy się czegoś nowego. Yay!


Co w kodzie piszczy…


Projekt nazwałem ExternalIntegration. Bądź co bądź integrujemy nowopowstający program z już istniejącą aplikacją, będącą na zewnątrz tworzonego rozwiązania:). Nic lepszego nie przyszło mi do głowy.


Szkieletem całości jest właściwie jedna klasa: SvnCommand. Do wykonania wszystkich niezbędnych operacji wymaga ona informacji o repozytorium, które zostały opakowane w tak karłowatą postać:


 1:   public class SvnRepository
2: {
3: public string RemoteLocation { get; set; }
4: public string WorkingCopyPath { get; set; }
5: public string UserName { get; set; }
6: public string Password { get; set; }
7: }

Pójdźmy dalej, gdzie zaczynają się interesujące rzeczy, czyli do matki wszystkich komend. Wspólna logika uruchamiania zewnętrznego procesu, odpowiedniej jego konfiguracji i dostarczania mu odpowiednich argumentów zawiera się właśnie tu, w metodzie Execute():


 1:   public abstract string GetArguments();
2:
3: protected virtual void BeforeExecute()
4: {
5: }
6:
7: public string Execute()
8: {
9: // (…)
10:
11: BeforeExecute();
12: string arguments = this.GetArguments();
13:
14: Process process = new Process();
15:
16: process.Configure(SVN_EXE_PATH, arguments, _parentRepository.WorkingCopyPath);
17:
18: process.Start();
19:
20: // (…)
21: }

Z takiego wycinka kodu można wysnuć trzy wnioski zaczynające się od słów “klasa potomna…”:
1) …musi dostarczyć argumenty wywołania docelowego procesu (abstract GetArguments())
2) …może “wpiąć” się z własnymi operacjami przez wystartowaniem docelowego procesu (virtual BeforeExecute())
3) …nie zajmuje się niczym innym, dzięki czemu implementacja podstawowych komend ogranicza się do jednej linijki kodu
Dodatkowo przy planowaniu komend zrobiłem użytek z mechanizmu atrybutów. Każda komenda musi być “ozdobiona” atrybutem CommandScopeAttribute przechowującym informację o tym, czy działa ona lokalnie (CommandScopes.Local, jak Add), zdalnie (CommandScopes.Remote, jak Log) czy też lokalnie i zdalnie naraz (CommandScopes.Local|CommandScopes.Remote, jak Checkout czy Commit).


 1:   [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
2: public class CommandScopeAttribute : Attribute
3: {
4: private CommandScopes _scope;
5: public CommandScopes Scope
6: {
7: get { return _scope; }
8: }
9:
10: public CommandScopeAttribute(CommandScopes commandScope)
11: {
12: _scope = commandScope;
13: }
14: }

“Konieczność” użycia atrbutu weryfikuję w konstruktorze klasy bazowej:


 1:   protected SvnCommand(SvnRepository parentRepository)
2: {
3: // each command type must be decorated with CommandScope attribute
4: Type commandType = this.GetType();
5: if (commandType.IsDefined(typeof(CommandScopeAttribute), false) == false)
6: throw new InvalidOperationException(string.Format(“Command {0} does not have a CommandScopeAttribute applied.”, commandType.Name));
7: }

Dzięki temu przed uruchomieniem procesu SVN mogę sprawdzić, czy posiadam wszystkie wymagane dane. Komenda działająca lokalnie potrzebuje lokalnej kopii repozytorium, a zdalna – adresu zdalnego repozytorium oraz danych użytkownika. Wskutek takich założeń metoda uruchamiająca proces rozpoczyna się od liniki wykonującej metodę weryfikującą posiadane informacje:


 1:   private void ValidateScopeInformation(CommandScopeAttribute scopeAttribute)
2: {
3: // local commands need workingCopyPath
4: if ((scopeAttribute.Scope & CommandScopes.Local) > 0)
5: {
6: if (string.IsNullOrEmpty(_parentRepository.WorkingCopyPath))
7: throw new InvalidOperationException(“Working copy path has not been set!”);
8: }
9:
10: // remote commands need credentials and remote repository location
11: if ((scopeAttribute.Scope & CommandScopes.Remote) > 0)
12: {
13: string commandName = this.GetType().Name;
14: if (string.IsNullOrEmpty(_parentRepository.UserName) || string.IsNullOrEmpty(_parentRepository.Password))
15: throw new InvalidOperationException(string.Format(“A command {0} needs to contact remote repository but credentials are not set.”, commandName));
16:
17: if (string.IsNullOrEmpty(_parentRepository.RemoteLocation))
18: throw new InvalidOperationException(string.Format(“A command {0} needs to contact remote repository but the remote location is not set.”, commandName));
19: }
20: }

Najbardziej “reprezentatywną” komendą jest Checkout, która jest oznaczona zarówno jako Local i Remote, a oprócz tego wykorzystuje możliwość podpięcia się pod metodę BeforeExecute. Mimo to jej implementacja jest bardzo prosta, ogracznicza się do zwrócenia odpowiedniego polecenia oraz stworzenia nowego katalogu, jeśli taka jest wola kodu ją wywołującego:


 1:   [CommandScope(CommandScopes.Local | CommandScopes.Remote)]
2: public class CheckoutCommand : SvnCommand
3: {
4: private readonly bool _createDirIfNotExists;
5:
6: public override string GetArguments()
7: {
8: return “checkout “ + _parentRepository.RemoteLocation + ” .”;
9: }
10:
11: public CheckoutCommand(SvnRepository parentRepository, bool createDirIfNotExists)
12: : base(parentRepository)
13: {
14: _createDirIfNotExists = createDirIfNotExists;
15: }
16:
17: protected override void BeforeExecute()
18: {
19: if (Directory.Exists(_parentRepository.WorkingCopyPath) == false)
20: {
21: if (_createDirIfNotExists)
22: Directory.CreateDirectory(_parentRepository.WorkingCopyPath);
23: else
24: throw new InvalidOperationException(“Directory “ + _parentRepository.WorkingCopyPath + ” does not exist!”);
25: }
26:
27: base.BeforeExecute();
28: }
29: }

Na koniec zostało przedstawienie sposobu skorzystania z zaimplementowanego mechanizmu. Wszystko ogranicza się do stworzenia obiektu odpowiedniej klasy Command i wywołania na nim metody Execute():


 1:   SvnRepository _repository = new SvnRepository();
2: ConfigureRepository(_repository);
3: new CheckoutCommand(_repository, true).Execute();

Warto także zwrócić uwagę na sposób komunikacji z zewnętrznym procesem. Naszym zadaniem jest przechwycenie wszelkich informacji z niego wypływających, dlatego też w metodzie przygotowującej proces do użycia przekierowujemy standardowe strumienie tak, abyśmy byli w stanie z nich korzystać:


 1:   public static void Configure(this Process instance, string exe, string arguments, string workingDirectory)
2: {
3: // (…)
4:
5: // redirecting output and error streams so that they do not get flushed to console
6: startInfo.UseShellExecute = false;
7: startInfo.RedirectStandardOutput = true;
8: startInfo.RedirectStandardError = true;
9:
10: // (…)
11: }

Dzięki temu metoda Execute() może zwrócić stringa zawierającego wszystkie dane będące wynikiem działania svn.exe.
Z procesu otrzymujemy jeszcze jedną ważną informację: ExitCode. Na tej podstawie możemy określić, czy operacja wykonywana przez svn.exe zakończyła się sukcesem, czy też nie.
Następujący kod odpowiada za ostateczne uruchomienie procesu svn.exe oraz odczytanie i przekazanie dalej wszystkich interesujących informacji do których mamy dostęp:


 1:   try
2: {
3: process.Start();
4: }
5: catch (Exception exc)
6: {
7: CannotStartSvnException csse = new CannotStartSvnException(“Cannot start SVN process. Make sure that Subversion is intalled.” + Environment.NewLine + exc.Message, exc);
8: csse.Arguments = GetArguments();
9: throw csse;
10: }
11:
12: // calling WaitForExit before StandardOutput.ReadToEnd can cause deadlocks when the child process’s output is longer than 1024 buffer
13: string output = process.StandardOutput.ReadToEnd();
14:
15: process.WaitForExit();
16:
17: // ExitCode different than 0 means that an error occured in SVN client
18: if (process.ExitCode != 0)
19: {
20: SvnException se = new SvnException(process.StandardError.ReadToEnd());
21: se.Arguments = GetArguments();
22: throw se;
23: }
24: return output;

Podsumowanie


W kodzie, oprócz pewnych konstrukcji związanych stricte z “software design”, starałem się wykorzystać pewne mechanizmamy oferowane przez .NET, które mogą sprawiać pewne problemy początkującym programistom. Że sprawiają – to wiem z dość regularnie pojawiających się pytań o nie na forum CodeGuru. Mam nadzieję, że zerknięcie na przykład ich wykorzystania pozwoli szybciej zrozumieć po co powstały i co oferują. W szczególności mam na myśli:



  • klasę System.Diagnostics.Process – uruchamianie i kontrola zewnętrznych procesów

  • mechanizm “własnych atrybutów” (“custom attributes”) – dodawania własnym klasom (i nie tylko) dowolnych charakterystyk (metadanych)

  • atrybut System.FlagsAttribute – bitowe łączenie kilku wartości jednego zbioru “enum”

  • własne wyjątki

Jak moim zdaniem można by ulepszyć to rozwiązanie? Z pewnością zwiększenie możliwości konfiguracyjnych byłoby plusem (aktualnie katalog z svn.exe musi być dodany do PATH, cache loginu i hasła ze strony SVN jest wyłączony w kodzie itp itd), jednak tego akurat elementu nie ma celowo: na zaprezentowanie możliwości klas z System.Configuration przyjdzie czas kiedy indziej. Poza tym można by pomyśleć o większej abstrakcji samych “flaków” od koncepcji wykorzystania SVN, czyli pokuszenie się o stworzenie biblioteczki mającej zastosowanie w więcej niż tylko tym jednym konkretnym scenariuszu.
Inne uwagi, dotyczące zarówno treści jak i formy posta? Czekam.


Kod…


…do pobrania ze strony SAMPLES.

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by ConvertKit
Share.

About Author

Programista, trener, prelegent, pasjonat, blogger. Autor podcasta programistycznego: DevTalk.pl. Jeden z liderów Białostockiej Grupy .NET i współorganizator konferencji Programistok. Od 2008 Microsoft MVP w kategorii .NET. Więcej informacji znajdziesz na stronie O autorze. Napisz do mnie ze strony Kontakt. Dodatkowo: Twitter, Facebook, YouTube.

5 Comments

  1. Wojciech Gebczyk on

    swietny text – ciekawy.
    1. jestem za robieniem takich “czasoprzyspieszaczy” – mniej “malpki” to wiecej czasu na ciekawe rzeczy czy mniej bledow
    2. Jakis czas temu natknalem sie na “http://sharpsvn.open.collab.net/” – trudno sie wypowiadac na temat wartosciowosci tego jako ze nie uzywalem, ale AnkhSvn zuywa tego wiec pewnie dziala ;-)
    3. Podoba mi sie okreslenie “karlowate” – polski odpiwednik “auto properties” to “wlasciwosci karlowate” (a moze zdegenerowane) :PP
    4. Atrybuty sa cool i sam ich (nad)uzywam, ale w tym miejscu moze w bazowej klasie dac abstrakcyjna (koniecznie! bo wymusi w potomnej dodawnie implementacji wiec trudniej zapomniec) wlaciwosc – latwiej, szybciej… mniej “siszarpowo” ;-)
    5. zamiast atrybutu ScopeCostam, dalbym 2 interfejsy (ILocalRepository i IRemoteRepository) oraz 3 klasy (sealowane, widoczne tylko wlasciwosci get) RepoLocal, RepoRemote, RepoLocalRemote (czy jakos tak) i sprawdzalbym w ctorze poprawnosc. potem w “ValidateScopeInformation” sprawdzenie czy interfejs jest odpowiedni. – Jeszce mniej szans na pomylke.
    :-)

  2. re 2. O AnkhSvn slyszalem tylko tyle ze “nie zawsze dziala jak powinno”, więc tym bardziej o Sharpsvn nic nie napiszę:).
    re 4. Atrybut ladnie podkresla fakt, że dana cecha dotyczy klasy, a nie instancji. Z właściwosciami tak nie ma. Chyba, że zrobimy statyczną, ale wtedy odpada “abstract”, i cala misterna hierarchia klas idzie w… kibieni.
    re 5. Ciekawy pomysł, ale koniecznie chcialem umiescic w tym projekcie “custom attributes”:).

  3. Wojciech Gebczyk on

    AnkhSvn: Uzywam na serio tego narzedzia (niestety nie w pracy bo mamy “wspanialego” CVSa) i do tej pory po za 2 bledami kosmetycznymi jest swietnie! Jesli nasz lepsza integracje z VS to daj znac, z checia popatrze na to.
    CustAttr: Tak pomyslalem i wyszlo mi ze ostatnio coraz mniej uzywam custom attrybutow. nadal jest tego sporo, ale nie to co w czasach 1.1 czy 2.0 :-)
    abstract|attr:
    a. i masz racje i nie (jaka jasna odpowiedz :P). Jesli cos co operauja na komendach “wie” tylko o jakiejs instancji komendy i ja ma wywoalc, obsluzyc, to prostsze rozwiazanie jest wlasciwoscia jako skladowa klasy. Jesli korzystasz z komend poza takim “scopem” (na przyklad przeczesujesz wszystkie komendy z AppDomain i szukasz takich TYPOW komend co musza miec Nazwe ale nie IlosciKredek), to atrybut jest sensowniejszym rozwiazaniem (nie trzba kazdego typu implementujacego interfejsc klase instancjowac i wyciaac wlasciwosc).
    b. Zgadam sie ze od strony czysto logicznej “wymagalnosc” pol itp jest cecha typu samego z siebie a nei konkretnych instancji.
    c. W przypadkach takich tooli przy pierwszej implementacji zazwyczaj stosuje rozwiazania szybsze w kodowaniu i przechodze do bardziej skomplikowancyh jest wymaga tego potrzeba funkcjonalna lub moja-wewnetrzna-porzadkowania-kodu ;-) Wiec w tym przypadku jesli mialbym zrobic iles tam komend, to dalbym wlasciwosc. Lecz jesli tego “API” mialby ktos uzywac w kodzie, to postawil bym bez wachania na atrybut :-)
    d. Przyklad kodu “o co mo chodzi”:
    -v.A————————
    static void Main() {
     var c = new BarCmd();
     DoIt(c);
    }
    static void DoIt(ICmd cmd) {
     Console.WriteLine(cmd.Name);
    }
    interface ICmd {
     string Name { get; }
     void Execute();
    }
    class FooCmd: ICmd {
     public string Name { get { return “foo-cmd”; } }
     public void Execute() { Thread.Sleep(1000); }
    }
    class BarCmd: ICmd {
     public string Name { get { return “bar-cmd”; } }
     public void Execute() { Thread.Sleep(100); }
    }
    -v.B————————
    static void Main() {
     var c = new BarCmd();
     DoIt(c);
    }
    static void DoIt(ICmd cmd) {
     var a = (CmdNameAttribute)cmd.GetType().GetCustomAttributes(typeof(CmdNameAttribute), false).First();
     Console.WriteLine(a.Name);
    }
    class CmdNameAttribute: Attribute {
     public string Name { get; private set; }
     public CmdNameAttribute(string name) { Name = name; }
    }
    interface ICmd {
     void Execute();
    }
    [CmdName(“foo-cmd”)]
    class FooCmd: ICmd {
     public void Execute() { Thread.Sleep(1000); }
    }
    [CmdName(“foo-cmd”)]
    class BarCmd: ICmd {
     public void Execute() { Thread.Sleep(100); }
    }
    —————————

  4. Wojciech Gebczyk on

    Dodam jeszcze ze czesciej powstaja “toole”, ktore sa uzywane 1-2 razy i maja nazwy typu “WindowsApplication12” czy “ConsoleApplication17” ;-) niz te o dobrej nazwie i dbalosci o “design”

  5. Dzieki za przyklad.
    A co do integracji z VS: od kilku tygodni uzywam Visual SVN i jestem zadowolony. (Nie)stety płatne.