Tartalomjegyzék Bevezető... 1 A probléma aszinkronitás... 1 Mi az az Observable?... 1 Mi az a LINQ?... 3 LINQ vs Rx... 8 Hello Rx... 9 Előkészületek... 9 Hagyományos megközelítés... 14 Rx megközelítés... 23 Összefoglalás... 31 Rx = Adatfolyamok + LINQ + Ütemezők... 32 Előkészületek... 32 Adatfolyamok... 33 LINQ... 49 Ütemezők... 76 Rx + Async... 80 Összefoglalás... 82
Bevezető A probléma aszinkronitás Modern alkalmazások fejlesztésénél (legyen az vékony- vagy vastagkliens, vagy kiszolgáló oldali szoftver) egy bizonyos ponton mindenképpen elérjük a szinkron módon futó kód korlátait ekkor kerülünk szembe az aszinkron és eseményvezérelt programozás nehézségeivel. Ha az alkalmazás megfelelő minőségének megőrzése érdekében az I/O műveleteket, webszolgáltatás-hívásokat, vagy épp egyéb erőforrás igényes műveleteket aszinkron módon kezeljük, a kód lényegesen bonyolultabbá válik. A megszokottól eltérő módszereket kell használnunk a koordinációra, a kivételkezelésre, és felvetődik a megszakíthatóság, valamint a párhuzamosan futó aszinkron feladatok szinkronizációjának problémája is. A Reactive Extensions (a továbbiakban Rx) egy olyan osztálykönyvtár, melynek segítségével aszinkron és/vagy eseményvezérelt programot készíthetünk úgy, hogy az adatfolyamot úgynevezett megfigyelhető (Observable) szekvenciaként reprezentáljuk, melyen LINQ-jellegű műveleteket hajthatunk végre úgy, hogy az esetleges versenyhelyzeteket úgynevezett ütemezőkkel (Scheduler) oldjuk fel. Röviden: Rx = Observables + LINQ + Schedulers Mi az az Observable? Ahhoz, hogy eljussunk az Observable fogalmáig, meg kell ismerkednünk az előtte lévő két lépcsőfokkal: a szinkron és az aszinkron programozás fogalmával. Az esetek túlnyomó részében a kódunk lényegi része úgynevezett szinkron kód. Azaz olyan utasítások, melyek sorban egymást követve hajtódnak végre. Kiadunk egy utasítást, elvégzi a processzor, majd mikor végzett (ez a fontos része a dolognak) továbblép a következő utasításra, és így tovább. A hagyományos konzol alkalmazások tipikusan szinkron utasításokat tartalmaztak olyannyira, hogy ha épp olyan pontjához értünk az alkalmazásnak, hogy a felhasználótól vártunk valamilyen adatbevitelre, akkor amíg a felhasználó nem írt be valamit, addig az egész program állt egy helyben. Ezzel ellentétben az aszinkronitás alapvetően azt jelenti, hogy valami olyan esemény hatására akarunk műveletet végezni, amiről nem tudjuk, hogy pontosan mikor fog bekövetkezni. 1
Ilyen például egy felhasználói felületre kihelyezett gomb Click eseménye, de ilyen például egy webszolgáltatás-hívás visszatérési értéke is. Ez utóbbi esetben ugyan elképzelhető, hogy az utasítások sorrendje szempontjából pontosan tudjuk, hogy az után akarunk bármit is csinálni, miután a szervizhívásnak visszakaptuk az eredményét, azonban itt számolni kell azzal, hogy ez akár másodpercekbe is kerülhet és ha ez szinkron módon lenne megírva, akkor annak a szálnak a végrehajtása ami elindította a szervizhívást addig nem lépne tovább, amíg a szervizhívás vissza nem tér, azaz az a szál blokkolva lenne. Ez pedig különösen kellemetlenül tudja érinteni a felhasználót, ha a UI szálon történik, mert akkor bizony amíg a hívás nem tér vissza, addig az alkalmazás megfagy, válaszképtelenné válik. Tehát annak érdekében, hogy ez ne történjen meg, aszinkron hívást fogunk használni, mely azt jelenti, hogy elindítjuk a hívást, a hívás lényegi része egy külön szálon fut (vagy vár), majd mikor megvan az eredmény értesít egy eseményen keresztül. Ilyenkor a hívás elindítása után rögtön folytatódik az eredeti (hívó) szálon az utasítások végrehajtása, az eredménnyel dolgozó kódunk pedig belekerül a CallBack metódusba vagy Completed eseménykezelőbe. Szerencsére a Microsoft ez utóbbi problémát lényegesen leegyszerűsítette a C# 5.0-ban azzal, hogy bevezette az async és await kulcsszavakat, melyek segítségével úgy tudunk aszinkron hívásokat írni, mintha csak szinkron hívások lennének. Ugyan rengeteg (egyszerű) szituációban ez megkönnyíti az életünket, de amint szeretnénk egy AutoRetry, Timeout, vagy épp Paging funkcionalitást adni egy webszolgáltatás-híváshoz, rögtön állhatunk neki kódolni, mert ez ellen már nem véd meg az, ha elrejtik előlünk a CallBack metódusok megírását. Az eseményeknek azonban van egy komoly problémája, mégpedig, hogy nem megfoghatóak és egy adott eseményre feliratkozott figyelők nyomonkövetése sem triviális. Ezen segít az Observer design pattern azzal, hogy lényegében explicit módon leimplementálja az eseménykezelést. Ezzel azonban sokkal nagyobb kontrollt kapunk a folyamatban, és ami még fontosabb (később meglátjuk miért), megfoghatóvá teszi az eseményeket. Ez a programozási minta alapvetően két interfészből áll. Az egyik az IObservable<T>, mely egy megfigyelhető adatforrást reprezentál. Ez az interfész mindösszesen egy Subscribe() metódust definiál, ami lényegében az eseményes világ += operátorának felel meg. A másik az IObserver<T>, mely egy megfigyelőt reprezentál, amit átadhatunk egy adatforrásra való feliratkozáskor. Ennek az interfésznek az eredeti programozási minta szerint van egy Notify() metódusa, ezen keresztül értesíti az adatforrás a rá feliratkoztatott megfigyelőket. Rx esetében nem a szabvány implementációval kell dolgoznunk, hanem annak egy speciális változatával. Itt az IObserver<T> interfész három metódust definiál, melyek az OnNext(), OnError() és az OnCompleted(). A legelső kerül meghívásra, ha egy új elem jelenik meg az adatfolyamban, a második, ha valami probléma történne, és az utolsó egy lezáró üzenet, melynek hatására a megfigyelés befejeződik, ezzel jelzi az adatforrás, hogy nem fog több értéket szolgáltatni. 2
Az Rx erre a két interfészre épül és ezeknek a segítségével tudja lényegesen leegyszerűsíteni a legkülönfélébb típusú aszinkron adatfolyamok kezelését. Annak érdekében, hogy megértsük miért, meg kell ismerni legalább alap szinten a LINQ-t. Mi az a LINQ? A LINQ (Language Integrated Query) a.net 3.5 óta könnyíti meg a fejlesztők életét [7][8][9], mikor oda kerül a sor, hogy valamilyen adathalmazzal kell dolgozni. Ez a technológia, illetve talán még inkább az általa bevezetett nyelvi elemek ültetik el a C# (illetve Visual Basic) nyelvben a funkcionális programozás csíráit. Nem megyek bele messzemenő részletekig, hogy mit is takar a funkcionális programozás azonban idéznék egy nagyon jó metaforát, melyet Luca Bolognese mondott a 2008-as PDC-n az F#-ról szóló előadásában. "Funkcionális programozásnál a jelszó az, hogy "mit" és nem az, hogy "hogyan". Erre egy nagyon jó példa, hogy ha bemegyünk egy bárba és kérünk egy kávét, akkor a pultosnak nem fogjuk megmondani, hogy őrölje meg a kávét, forralja fel a vizet, készítse elő a cukrot és a tejet, és tegye mindezt párhuzamosan, hanem csak egyszerűen kérünk egy kávét." Hogy egy kicsit komolyabb példán keresztül szemléltessem a gondolatmenetet, gondoljuk el, hogy hogyan oldanánk meg egy olyan feladatot, amiben egy számsorozatból kell kiválasztanunk a páros számokat, majd össze kell adni a kiválasztott számok négyzeteit? Nagy valószínűséggel első körben az 1-1 kódrészlethez hasonló megoldásra jutnánk. 3
1-1 kódrészlet: Páros számok négyzetének összege első megközelítés List<int> számok = new List<int>(); számok.add(1); számok.add(2); számok.add(3); számok.add(4); számok.add(5); számok.add(6); számok.add(7); számok.add(8); számok.add(9); számok.add(10); int akkumulátor = 0; foreach (int szám in számok) if (szám % 2 == 0) akkumulátor += (szám * szám); } } return akkumulátor; Mit lehet ezzel a konkrét kóddal tenni? Hogy tudnánk kicsit tömörebbre, átláthatóbbra varázsolni? Kezdjük például a számok létrehozásánál: 1-2 kódrészlet: Object Initializer var számok = new List<int>() 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; Vegyük sorba milyen érdekességek vannak ebben a sorban. Kezdődik a var kulcsszóval. Mikor egy változót ezzel a kulcsszóval deklarálunk, akkor a fordító a deklaráció egyenlőségjele utáni kifejezésből deríti ki, hogy a változó milyen típusú. Jelen esetben, ha felé vinnénk az egeret, akkor mutatná is rögtön, hogy a számok változó List<int> típusú. Ez a nyelv egy kényelmi kiterjesztése, bár a későbbiekben találkozni fogunk egy olyan képességgel, mely a var kulcsszó nélkül nem volna megvalósítható. Ha továbbmegyünk, egy érdekes szintaktikával találkozunk a példányosításnál. A sor nem ér véget a konstruktorhívással, hanem kapcsos zárójelek között megadjuk a lista elemeit is. Ezt nevezzük Object Initializer szintaktikának. Ugyan a példához nem tartozik hozzá, de ha az előző kettő nyelvi elemet összekombináljuk, akkor megkapjuk az úgynevezett névtelen típusokat. Gondoljunk bele abba a szituációba, amikor egy ciklus kellős közepén arra volna szükségünk, hogy egy átmeneti változóba több különböző információt is elmentsünk. A LINQ előtt ilyenkor jött az, hogy mindegyik részinformációnak létrehoztunk egy 4
változót és azokat használtuk ilyen jellegű átmeneti információk tárolására. Ha azonban névtelen típust használunk, megtehetjük, hogy egy ilyen kódot írunk: var ember = new Név = név, Életkor = kor }; 1-3 kódrészlet: Névtelen típus Itt aztán nézheti a fordító a kifejezés jobb oldalát, nem fogja kitalálni abból sem a változó típusát. Ilyenkor valójában a színfalak mögött a fordító létrehoz egy típust az adott változónak, mely tartalmazza a megadott tulajdonságokat. Menjünk tovább az eredeti példa rövidítésén és tekintsük meg a következő lépést: 1-4 kódrészlet: Páros számok négyzetének összege LINQ megközelítés return számok.where(szám => szám % 2 == 0).Select(szám => szám * szám).sum(); Ha kicsit visszalaolvasunk, akkor láthatjuk, hogy a számok változó egy int típusú lista. Hogyan tudunk rajta Where() metódust hívni, mikor nincs is olyan metódusa a List<T> típusnak? Erre a kérdésre a választ az úgynevezett Extension Method -ok adják. Ha egy tetszőleges típushoz új funkcionalitást akarunk adni, akkor a triviális megoldás, hogy származtatunk belőle egy osztályt, majd abban definiáljuk az extra funkcionalitást. Ezzel azonban két probléma van. Egyrészről elképzelhető, hogy az eredeti típus sealed, azaz már eleve örököltetni sem tudunk belőle, de még ha ez a probléma nem is áll fenn, akkor is ott van az a kényelmetlen tény, hogy onnantól kezdve mindig a saját típusunkat kell használnunk, vagy konverziós metódusokat definiálnunk, mellyel műveletvégzés előtt az eredeti típusból a mi típusunkká alakítjuk az adatot, majd elvégezzük rajta a műveletet és végül a módosított adatot visszakonvertáljuk az eredeti típusba. Nem kell bonyolult dologra gondolni, elképzelhető, hogy van egy egyéni függvényünk string típusú adatok formázására. Amennyiben ezt a függvényt több helyen is fel akarjuk használni, akkor vagy kirakjuk egy statikus osztályba, vagy készítünk egy saját SuperString típust, mely tartalmazza ezt a függvényt. Ez utóbbit a konkrét példánál maradva pont nem tehetnénk meg, mert a String típus sealed, azaz nem lehet belőle örököltetni. Marad a statikus osztályos megoldás, de gondoljuk el, hogy nézne ki az a kód ahol esetleg megpróbálunk egymás után két ilyen függvényt is használni Ezen probléma megoldására vezette be a Microsoft az Extension Methodokat, mellyel tetszőleges osztályhoz (sőt akár interfészhez is) definiálhatunk függvényeket a következő szignatúrával: 5
1-5 kódrészlet: Extension Method szignatúrája public static string SpecialFormat(this string s, int spaces) Ezeket a függvényeket egy statikus osztályba kell beletennünk, és maguknak a függvényeknek is a szignatúrájuk szerint statikusnak kell lenniük. Ez után a varázslat az első paraméternél keresendő. A this kulcsszó után következő típus az, amit ki fogunk ezzel a metódussal terjeszteni. Ezt a metódust csak a kiterjesztett osztály egy példányán lehet meghívni, szóval a szignatúrájával ellentétben pont, hogy nem lesz statikus. Ezen példányra kapunk referenciát az s változó által. A metódus meghívásakor ez a paraméter nem fog látszódni a listában, csak az ez utáni paraméterek (esetünkben az spaces). Használata tehát a következőképpen fog kinézni: defaultstring.specialformat(42); 1-6 kódrészlet: Extension Method használata A LINQ egy olyan mintát használ az extension method-jainál, amivel ezt egy szinttel magasabbra helyezi. Hogy az eredeti példánknál maradjunk, nézzük meg, hogy néz ki a Where() metódus szignatúrája: 1-7 kódrészlet: LINQ Where() Extension Method szignatúrája public static IEnumerable<T> Where<T>(this IEnumerable<T> enumerable, Func<T, bool> predicate) Az egyik, amit érdemes meglátni, hogy az IEnumerable<T> interfészt terjeszti ki és a visszatérési értéke is ugyanilyen típusú. A LINQ szinte mindegyik operátorának szignatúrája így kezdődik, ez pedig azért jó, mert lehetővé teszi a kompozíciót, azaz, hogy az operátorokat tetszőleges hosszan egymás után fűzzük. Ez a jelenlegi példán is jól látszódik, bármiféle köztes változók bevezetése nélkül képesek voltunk egymás után fűzni a Where(), majd a Select() és végül a Sum() függvényeket. A másik jellemző dolog a LINQ operátorokkal kapcsolatban az, hogy a munka lényegi részét ránk bízzák. Ez azt jelenti, hogy például a Where() metódus tartalmazza azt a kódot, hogy végigmenjen a lista elemein, válassza ki valamilyen feltétel alapján, hogy melyik maradjon, melyik ne, és végül adja vissza az eredményül kapott szűrt listát, de egy részét nem tartalmazza a logikának, mégpedig a konkrét feltételvizsgálatot, hogy mi alapján menjen vagy maradjon egy elem. Pontosan ezen okból kifolyólag van a paraméterek között a Func<T, bool> delegált, mely egy olyan metódust reprezentál, ami megkapja a kollekció egy elemét paraméterül, majd csinál vele amit akar és visszaad egy bool értéket, hogy megfelelt, vagy nem felelt meg valamilyen feltételnek. 6
Ha tovább nézzük az eredeti példát, nem is kell sokat olvasnunk máris találunk egy igen érdekes kifejezést a Where() metódusnak (meg a Select()-nek is) paraméterül adva. szám => szám % 2 == 0 1-8 kódrészlet: Lambda kifejezés Ezt nevezzük Lambda kifejezésnek, vagy névtelen metódusnak. Ezzel a kifejezéssel lehetőségünk van arra, hogy anélkül írjunk egy metódust, hogy írnánk egy metódust. Mint azt az imént említettem, nagyon sok olyan szituáció van, amikor egy metódus paraméterül vár egy delegáltat. Ilyenkor normál esetben azt csináljuk, hogy elkészítjük a szükséges metódust, majd átadjuk azt paraméterül a szóban forgó metódusnak. Ennél azonban lényegesen átláthatóbb lehetőséget nyújt a Lambda kifejezés, mellyel helyben tudjuk megadni a szükséges funkcionalitást. A Lambda kifejezések teljes szintaktikája a következőképpen néz ki: (param_1, param_2, param_n) =>... return valami; }; 1-9 kódrészlet: Lambda kifejezés teljes pompájában A nyíl operátor (=>) előtt zárójelben, vesszővel elválasztva a kifejezés paraméterei, majd a nyíl után kapcsos zárójelek között a kifejezés törzse helyezkedik el. A paramétereknek azért nem kell típust írnunk, mert azt a fordító kitalálja abból, hogy milyen delegáltnak adjuk át a kifejezést. Amennyiben csak egy paraméter van, úgy a zárójel elhagyható, ha viszont nincs paraméter, akkor ki kell írnunk egy üres nyitó-csukó zárójelet. A törzs esetében is van egy rövidítési lehetőségünk. Amennyiben a névtelen metódus mindössze egyetlen sorból áll, amely kifejezésnek az eredménye egyben a visszatérési értéke is a metódusnak, akkor nincs szükség se kapcsos zárójelre, se return kulcsszóra. És persze ne feledkezzünk meg arról sem, hogy ha esetleg mégis megvolna az eredeti delegált szignatúrájának megfelelő metódus, akkor elegendő mindössze a metódus nevét átadnunk, nem kell olyan kifejezést írni, hogy Metodus(x => F(x)), hanem elegendő csak annyit írni, hogy Metodus(F). Végül pedig csupán puszta érdekességként megjegyezném, hogy az aktuális példánk párhuzamosítása, mindössze egyetlen extra operátor beszúrásából áll: 7
számok.asparallel().where(szám => szám % 2 == 0).Select(szám => szám * szám).sum(); 1-10 kódrészlet: LINQ párhuzamosítás Az AsParallel() operátornak köszönhetően a teljes lekérdezés párhuzamosan fog futni. Gondoljunk csak bele, ha ezt az újonnan felröppenő igényt az eredeti, hagyományos módon elkészített kódunkban kellene megoldani... A LINQ tehát ezeket a technológiákat és rengeteg előre definiált Extension Methodot jelent több különböző típuson, többek közt például az IEnumerable<T>-n is, mely igazából ugye nem is osztály, hanem interfész, így azonban minden olyan osztály, ami megvalósítja ezt az interfészt, megkapja az extra metódusokat is. Ezen metódusok túlnyomó részének a visszatérési értéke is IEnumerable<T>, azaz kényelmesen egymás után láncolható rengeteg ilyen operátor, melyek egy forráskollekciót szép sorjában egyenként valamilyen logika alapján átalakítanak, átrendeznek vagy szűrnek, majd továbbadják az eredményt a következő operátornak. A LINQ, mint arra utaltam is, nem csak az IEnumerable<T> típushoz ad kiterjesztéseket, hanem egyéb adatforrásokhoz is, mint például relációs adatbázisok (LINQ to SQL) vagy XML dokumentumok (LINQ to XML) és még sok más. Amennyiben pedig az adatforrás történetesen aszinkron események sorozata, akkor eljutunk a LINQ to Events-hez, azaz a Reactive Extensions-höz. LINQ vs Rx A LINQ meglévő kollekciókon tud dolgozni (konkrétan IEnumerable<T> interfészt megvalósító típusokon) és mélyen az alapját az IEnumerable<T> és IEnumerator<T> interfészpáros adja. Vele ellentétben az Rx olyan adatfolyamokon dolgozik, melynek még nem áll rendelkezésre minden eleme és mélyen az alapját az IObservable<T> és IObserver<T> interfészpáros képzi. Míg a LINQ az úgynevezett polling technikával kérdezgeti az IEnumerator<T> objektum MoveNext() metódusán keresztül a listában soron következő elemet, addig az Rx esetében push technikát alkalmazva az IObserver<T> objektum OnNext() eseménye kerül meghívásra, ha egy új elem érkezik az adatfolyamba. Ugyanúgy, ahogy a LINQ valamilyen bemeneti kollekcióból különböző szelekciós, projekciós, csoportosító, összekapcsoló stb. operátorok használatával egy kimeneti kollekciót készít, az Rx valamilyen bemeneti adatfolyamot áttranszformálva egy kimeneti adatfolyamot készít. És végül, míg a LINQ az IEnumerable<T> interfészhez nyújt kiegészítést, addig az Rx az IObservable<T>-hez. 8
Hello Rx Előkészületek Ebben a fejezetben egy életszerűbb, de egyszerű példán keresztül fogom bemutatni, hogy mennyire megkönnyíti az életünket az Rx használata még az új async-await kulcsszavak mellett is. Egy keresőt fogunk készíteni, mely egy szimulált webszolgáltatással fog dolgozni. Az egyszerűség kedvéért nem egy valódi webszolgáltatáshoz fog kapcsolódni az alkalmazás, hanem egy statikus listából fog javaslatokat és találatokat szolgáltatni mesterséges késleltetéssel és véletlenszerű meghibásodással. Rakjuk össze az alkalmazás alapjait, hogy aztán már csak a lényegre kelljen koncentrálni. Töltsük le és telepítsük fel a legújabb Rx-et a http://www.microsoft.com/enus/download/details.aspx?id=30708 címről. Nyissuk meg a Visual Studio 2012-t. Indítsunk egy új projektet (File New Project), majd a felugró ablakban a baloldali sávban válasszuk ki a Visual C# szekción belül a Windows Store-t, jobboldalon pedig a Blank Application-t. Miután elneveztük a projektet, nyomjunk az Ok gombra. Ezzel van is üres alkalmazásunk. Adjuk hozzá a referenciákhoz az Rx-et. Ezt a Project Add reference menüpontból tudjuk megtenni, a felugró ablakon belül baloldalt válasszuk a Windows fülön belül az Extensions menüpontot, majd rakjunk egy pipát a Reactive Extensions for Windows 8 mellé. Ezzel hozzá is adtuk az összes szükséges dll-t a referenciákhoz. Szerezzünk egy listát a leggyakrabban használt magyar szavakról ezekből fogunk keresési javaslatokat komponálni. Egy ilyen lista található például a következő címen: http://en.wiktionary.org/wiki/wiktionary:frequency_lists/hungarian_frequency_list_1-10000. Dolgozzuk fel ezt a listát úgy, hogy eltűnjenek a sorok elejéről a sorszámok és minden sorban csak egy szó legyen! Az ehhez szükséges kód lényegi részét a 2-1-1 kódrészlet mutatja. 9
2-1-1 kódrészlet: Wikipediáról kimásolt szöveg megtisztítása var data = rawdata.split(new string[] Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.substring(x.indexof('.') + 1).Replace(" ", "").Split(',')).SelectMany(x => x).aggregate((accumulator, actual) => accumulator + Environment.NewLine + actual); A következő lépés az ál-webszolgáltatás elkészítése. Két metódusra lesz szükség. Az egyik keresési javaslatokat fog generálni egy megadott szövegre úgy, hogy megpróbálja az utolsó szót az imént feldolgozott lista alapján valami értelmesre kiegészíteni. A másik metódus pedig statikusan visszaadja, hogy mire kerestünk a következő formában: Erre kerestél szöveg} Az ezt a funkcionalitást megvalósító osztály alapját a 2-1-2 kódrészlet mutatja. public class SearchService private IEnumerable<string> _wordlist; public SearchService() Initialize(); } 2-1-2 kódrészlet: Ál-webszolgáltatás alapja private async void Initialize() var txtfile = await StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///SearchEngine_Core/words.txt")); var txt = await FileIO.ReadTextAsync(txtFile); _wordlist = txt.split(new string[] Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.tolower()); } private async Task SimulateTimeOutAndFail() // Simulating long time execution var random = new Random(); await Task.Delay(random.Next(300)); } } // Simulating failure if (random.next(100) < 10) throw new Exception("Error!"); 10
Az Initialize() metódus felel azért, hogy a szövegfájl betöltődjön, és aztán készíthessünk belőle javaslatokat, a SimulateTimeOutAndFail() beszédes nevű metódus pedig egy webszolgáltatás viselkedését próbálja szimulálni a mesterséges késleltetéssel és a véletlenszerű meghibásodással. A következő lépés a két lényegi metódus elkészítése. A találatot adó metódus nagyon egyszerű lesz, csak vissza kell adnia az átadott szöveget megformázva (2-1-3 kódrészlet). 2-1-3 kódrészlet: Keresés eredményének visszaadása public async Task<IEnumerable<string>> GetResultsForQuery(string query) await SimulateTimeOutAndFail(); } return new string[] "Erre kerestél: " + query}; A javaslatok szolgáltatása már egy fokkal trükkösebb, ezt a 2-1-4 kódrészlet mutatja. 2-1-4 kódrészlet: Keresési javaslatok nyújtása public async Task<IEnumerable<string>> GetSuggestionsForQuery(string query) await SimulateTimeOutAndFail(); if (_wordlist!= null) var wordsofquery = query.tolower().split(new char[] ' ' }, StringSplitOptions.RemoveEmptyEntries); var lastwordofquery = wordsofquery.last(); var suggestionsforlastword = _wordlist.where(w => w.startswith(lastwordofquery)); var headofquery = ""; if (wordsofquery.length > 1) headofquery = wordsofquery.take(wordsofquery.length - 1).Aggregate((acc, curr) => acc + ' ' + curr); } return suggestionsforlastword.select( s => headofquery + ' ' + s).take(10); } else return Enumerable.Empty<string>(); 11
Kicsomagoljuk a beírt szövegből az utolsó szót Kikeressük a lehetséges javaslatokat erre azokat a szavakat a hosszú listánkban, ami azzal a karakterlánccal kezdődik Ez után összerakjuk az eredeti szöveget úgy, hogy az utolsó szót (amihez épp javaslatokat gyűjtöttük) kihagyjuk belőle Utolsó lépésként pedig generálunk egy listát, ami a teljes szöveget tartalmazza az összes lehetséges végződéssel Utolsó lépésként készítsük el az alkalmazás felületét. Helyezzünk el a felületen egy TextBox, egy Button, egy ListView és egy TextBlock vezérlőt és adjuk nekik rendre a SearchBox, SearchButton, Suggestions és ErrorLabel neveket. Ezt a 2-1-5 kódrészlet szemlélteti. 12
2-1-5 kódrészlet: Az alkalmazás felületének XAML kódja <Page x:class="searchengine_rx.mainpage" xmlns=http://schemas.microsoft.com/winfx/2006/xaml/presentation xmlns:x=http://schemas.microsoft.com/winfx/2006/xaml xmlns:local="using:searchengine_rx" xmlns:d=http://schemas.microsoft.com/expression/blend/2008 xmlns:mc=http://schemas.openxmlformats.org/markup-compatibility/2006 mc:ignorable="d"> <Page.BottomAppBar> <AppBar IsOpen="True" IsSticky="True"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="20" /> <ColumnDefinition /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <TextBox x:name="searchbox" VerticalAlignment="Center" Grid.Column="1"> <TextBox.RenderTransform> <TranslateTransform Y="-10" /> </TextBox.RenderTransform> </TextBox> <TextBlock x:name="errorlabel" Text="Status" Grid.Column="1" Style="StaticResource BasicTextStyle}" FontSize="12" VerticalAlignment="Center"> <TextBlock.RenderTransform> <TranslateTransform Y="23" /> </TextBlock.RenderTransform> </TextBlock> <Button x:name="searchbutton" Grid.Column="2" Style="StaticResource SearchAppBarButtonStyle}" /> </Grid> </AppBar> </Page.BottomAppBar> <Grid Background="StaticResource ApplicationPageBackgroundThemeBrush}"> <ListView x:name="suggestions" Margin="10" Grid.Row="1" SelectionMode="None" IsItemClickEnabled="True"> <ListView.ItemTemplate> <DataTemplate> <TextBlock FontSize="24" Text="Binding}" /> </DataTemplate> </ListView.ItemTemplate> </ListView> </Grid> </Page> Ezzel meg is van az álszerviz osztály és a felület, most már koncentrálhatunk a lényegre. 13
Hagyományos megközelítés Most, hogy lehelyeztük az alkalmazás alapköveit, vegyük át, hogy mit szeretnénk megvalósítani. Szeretnénk, ha annak hatására, hogy beleír valamit a felhasználó a keresődobozba, elkezdene az alkalmazás nagy erőkkel keresési javaslatokat dobálni neki. Emellett pedig szeretnénk, ha a Keress gombra kattintva már egy valódi keresés valódi találatait adná vissza nekünk. Kis magyarázat az idézőjelekre: a weben található keresőmotorok nem a valódi lehetséges találatok alapján adnak keresési javaslatokat, a javaslatok a felhasználók által beírt kifejezésekből tevődnek össze, míg a találatok már valóban azok az oldalak, melyek a beírt kifejezést valamilyen formában tartalmazzák. Tehát attól még, hogy a kereső egy kifejezésre előhoz nekünk egy keresési javaslatot, az nem jelenti azt, hogy arra fog bármit is találni. Mivel nem egy bonyolult programról van szó, készítsük el a hagyományos módon az alkalmazást, és keressük meg benne a hibákat. Rakjuk bele az oldal konstruktorába a 2-2-1 kódrészletet, és próbáljuk ki működés közben. 2-2-1 kódrészlet: Feliratkozás a felület eseményeire SearchBox.TextChanged += async (s, e) => var query = SearchBox.Text; var service = new SearchService(); var suggestions = await service.getsuggestionsforquery(query); Suggestions.ItemsSource = suggestions; }; SearchButton.Click += async (s, e) => var query = SearchBox.Text; var service = new SearchService(); var results = await searchservice.getresultsforquery(query); Suggestions.ItemsSource = results; }; Mi történik az alkalmazás futása közben? A véletlenszerű késleltetésnek köszönhetően valószínűleg pillanatok alatt szembetaláljuk magunkat azzal a helyzettel, hogy beírjuk, hogy Buda, de mivel minden egyes új karakter leütésekor kérünk az aktuális beírt szövegre keresési javaslatot ami valamilyen késleltetéssel tér vissza megeshet, hogy az első karakterhez ( B ) kapcsolódó javaslatok akkor jönnek meg, mikor már rég beírtuk, hogy Buda, így aztán nem azokat a szavakat fogjuk látni amik Buda -val kezdődnek, hanem azokat, amik B -vel. Emellett, ha ez valóban egy webszolgáltatás hívása lenne, akkor gyakorlatilag feleslegesen küldenénk iszonyatos mennyiségű lekérést a szolgáltatás felé minden egyes leütött karakter után. Ezzel felesleges 14
hálózati forgalmat generálunk, ami feleslegesen eszi a felhasználó akkumulátorát, processzorát és adott esetben a fizetős 3G adatforgalmát. Látván a problémákat (azoknak egy részét), gondoljuk át mire van szükségünk. Szeretnénk a szervizhívást hibabiztosra elkészíteni, azaz számolnunk kell azzal, hogy a szerveren történik valami hiba, vagy a kliensről nem tud kimenni a hívás, vagy túl hosszú ideig tart a művelet. Mindezt, annak érdekében, hogy hibatűrő legyen érdemes becsomagolni úgy, hogy akármilyen hiba is történik, próbálja újra a rendszer még kétszer (összesen maximum háromszor) a hívást. Jó volna, ha lenne egy kis fojtás a hívó oldalon, és nem küldenénk el egy-egy hívást minden egyes leütött karakter után, hanem csak akkor, ha már a felhasználó legalább fél másodperce nem nyomott le billentyűt. Az előzőhöz képest apró optimalizálás, de érdemes arra is figyelni, hogy ha valami okból kifolyólag kétszer egymásután ugyanarra a szövegre szeretne keresni, akkor feleslegesen ne küldjük el másodjára is a hívást. Ilyen akkor lehet, ha beírt valamit a felhasználó, várt egy keveset, beírt még valamit, de közben rájött, hogy mégsem az kell neki és visszatörölte az előző állapotig a beírt szöveget. A fentebb említett fojtás miatt lehet, hogy kétszer egymás után ugyanazt a szöveget kapnánk meg. Vagy egyszerűen csak remegő kézzel nyom a Keresés gombra vagy üt az Enter billentyűre és ezért véletlenül többször is elküldi a hívást. Végül pedig meg kell oldani a versenyhelyzet problémát, azaz, hogy biztosítsuk, hogy valóban mindig az utolsó hívás eredményét lássuk a képernyőn és ne történhessen meg, hogy egy korábban elküldött, de a szerveren hosszabb ideig tartó lekérdezés eredményét később visszakapva inkonzisztenciát okozzunk a felhasználónak, és olyan javaslatokat mutassunk, aminek már semmi köze nincs az éppen beírt keresendő szöveghez. Vegyük ezeket a problémákat egyesével, nézzük meg hogyan lehet megoldani őket, majd azt, hogy miképpen lehet összekombinálni őket. Időtúllépés A C# 5.0-ban bevezetett await kulcsszónak köszönhetően olyan egyszerű, átlátható és rövid kódot tudunk aszinkron műveletekre írni, mintha csak szinkron kódot írnánk. A szervizhívás önmagában csupán egy sor 2-2-2 kódrészlet: await kulcsszó használata aszinkron hívásnál var suggestions = await service.getsuggestionsforquery(query); ám amint egy kicsit bonyolultabb műveletet akarunk ezzel az aszinkron hívással végezni, kénytelenek vagyunk elhagyni ezt a kényelmet. Az időtúllépés logikát a legegyszerűbben a 2-2-3 kódrészlettel lehet megvalósítani: 15
2-2-3 kódrészlet: Időtúllépés implementációja public async Task<IEnumerable<string>> ServiceCall_Timeout(string query) var newtask = service.getsuggestionsforquery(query); var timeouttask = Task.Delay(500); var firsttasktoend = await Task.WhenAny(newTask, timeouttask); } if (newtask == firsttasktoend) return newtask.result; else throw new Exception("Timeout"); Elindítjuk a saját szervizhívásunkat és egy egyszerű aszinkron késleltetést párhuzamosan, megvárjuk amíg valamelyik visszatér, majd leellenőrizzük, hogy a szervizhívás vagy az időzítő tért vissza, azaz, hogy a szervizhívás kifutott e az időből. Újrapróbálás Bár az await kulcsszó megint sokat segít, de a komolyabb kódolás elkerülhetetlen. 2-2-4 kódrészlet: Újrapróbálás implementációja public async Task<IEnumerable<string>> ServiceCall_Retry(string query) bool errorhappened = false; int tries = 0; IEnumerable<string> suggestions = null; do errorhappened = false; try suggestions = await service.getsuggestionsforquery(query); } catch tries++; errorhappened = true; } } while (errorhappened == true && tries < 3); } if (!errorhappened) return suggestions; else throw new Exception("Out of retries"); Mivel újrapróbálásról van szó, ezért a kiinduló gondolat az kell, hogy legyen, hogy valamilyen ciklust kell készíteni. Mivel értelemszerűen csak akkor kell újrapróbálni, ha valami probléma történt, viszonylag gyorsan leszűkül a kör a while ciklus használatára. Innentől kezdve ízlés kérdése, hogy az 16
elől vagy hátul tesztelő verziót használjuk, én az utóbbit választottam. Két feltétele van annak, hogy a ciklusban maradjunk. Az egyik, hogy valami hiba történjen a cikluson belül, a másik pedig, hogy még a megadott próbák száma alatt legyünk (jelenleg ez 3). A hibát egy try-catch blokkal elnyeljük és jelezzük a ciklusnak, hogy hiba történt és növeljük az próbálkozások számát. Érezhető, hogy ebből még nem tudunk tisztán következtetést levonni, mert azért is túljuthatunk a cikluson, mert nem történt probléma, és azért is, mert már háromszor is volt baj. Tehát le kell ellenőriznünk, hogy volt e hiba vagy nem. A fojtás, az egyezés vizsgálat és a versenyhelyzet kezelése már nem ilyen egyszerű feladat. Ezek szűrő jellegű műveletek, azaz különböző feltételektől függően elnyelnek bizonyos hívásokat. A fojtás például biztosítja, hogy egy gyors gépelésnél ne küldjünk hívást minden egyes billentyűleütésre, hanem csak akkor, ha már a felhasználó fél másodperce nem nyúlt a billentyűzethez. Az egyezés vizsgálat kiszűri az egymás után érkező azonos paraméterű hívásokat. A versenyhelyzet kezelés pedig biztosítja, hogy a korábbi, ám hosszú ideig tartó hívás eredménye már ne kerüljön felszínre, amikor van nála frissebb. Ezeknél a műveleteknél tehát egy olyan mintát fogunk követni, hogy lesz egy void metódus és mellette egy CallBack esemény, amiben az eredményeket kapjuk. Az eseményre csak egyszer fogunk feliratkozni, a metódust azonban értelemszerűen többször hívjuk meg. Fojtás private DateTime lastthrottledparameterdate; private int throttleinterval = 500; 2-2-5 kódrészlet: Fojtás implementációja public event Action<IEnumerable<string>> CallBack_Throttle; public async void ServiceCall_Throttle(string query) lastthrottledparameterdate = DateTime.Now; await Task.Delay(throttleInterval); if ((DateTime.Now - lastthrottledparameterdate).totalmilliseconds < throttleinterval) return; var suggestions = await service.getsuggestionsforquery(query); } if (CallBack_Throttle!= null) CallBack_Throttle(suggestions); A metódus hívásakor elmentjük az aktuális időpontot, kivárjuk azt a bizonyos fél másodpercet, majd megvizsgáljuk, hogy az új aktuális időpont és a korábban elmentett között megvan e a fél másodperc 17
különbség, azaz, hogy nem hívták e meg idő közben a metódust még egyszer. Ha nem, akkor elküldjük a szervizhívást, majd az eredményével meghívjuk a CallBack eseményt. Egyezés vizsgálat private string lastdistinctparameter; 2-2-6 kódrészlet: Egyezés vizsgálat implementációja public event Action<IEnumerable<string>> CallBack_Distinct; public async void ServiceCall_Distinct(string query) if (lastdistinctparameter!= query) lastdistinctparameter = query; else return; var suggestions = await service.getsuggestionsforquery(query); } if (CallBack_Distinct!= null) CallBack_Distinct(suggestions); Ennél a műveletnél egy, a tulajdonságok setterében is gyakran használt módszert alkalmazunk. A metódus hívásakor kimentjük a paraméterét egy globális változóba, majd a következő hívásnál ehhez hasonlítjuk az új paramétert és csak akkor engedjük végre a végrehajtást, ha különböznek. Versenyhelyzet kezelése 2-2-7 kódrészlet: Versenyhelyzet kezelésének egy lehetséges implementációja private Task<IEnumerable<string>> lastcall; public event Action<IEnumerable<string>> CallBack_RaceCondition; public async void ServiceCall_RaceCondition(string query) var newcall = service.getsuggestionsforquery(query); lastcall = newcall; await newcall; } if (lastcall == newcall) if (CallBack_RaceCondition!= null) CallBack_RaceCondition(newCall.Result); 18
Ennél a műveletnél megint a Task-okat használjuk fel és az await kulcsszó megfelelő helyen történő felhasználásával trükközünk. A csomagolómetódus meghívásakor elmentjük a szervizhíváshoz tartozó Task objektumot, bevárjuk a hívás eredményét, majd megvizsgáljuk, hogy a kimentett lastcall Task példány még mindig megegyezik e a bevárt newcall Task példánnyal. Ezzel azt tudjuk megvizsgálni, hogy amíg a szervizhívásunk futott, nem esett e be egy újabb hívás, mely érvényteleníti a korábbi hívást. Ezzel tudjuk biztosítani, hogy az utoljára visszakapott eredmény az valóban az utolsó híváshoz tartozzon. Ezek a műveletek egyenként is jó pár sort elfoglalnak, és sajnos az összekapcsolásuk nem oldható meg könnyen, szóval csak elrettentésképpen szemléltetném a 2-2-8 kódrészlettel, hogy ezek a funkciók összegyúrva hogyan néznének ki: 19
// Throttle global variables private DateTime lastthrottledparameterdate; private int throttleinterval = 500; // Distinct global variables private string lastdistinctparameter; // Retry global variables private int retries = 3; // Timeout global variables private int timeoutinterval = 500; // Switch global variables private Task<string> lastcall; 2-2-8 kódrészlet: A hagyományos megközelítés // Callback events public event Action<IEnumerable<string>> CallBack; public event Action<Exception> ErrorCallBack; public async void GetQuerySuggestionsAsync(string query) try // Throttle logic lastthrottledparameterdate = DateTime.Now; await Task.Delay(throttleInterval); if ((DateTime.Now - lastthrottledparameterdate).totalmilliseconds < throttleinterval) return; // Distinct logic if (lastdistinctparameter!= query) lastdistinctparameter = query; else return; 20
var newcall = Task.Run<IEnumerable<string>>(async () => // Retry logic bool errorhappened = false; int tries = 0; IEnumerable<string> suggestions = null; do errorhappened = false; try // Timeout logic var newtask = service.getsuggestionsforquery(query); var timeouttask = Task.Delay(timeoutInterval); var firsttasktoend = await Task.WhenAny(newTask, timeouttask); if (newtask == firsttasktoend) suggestions = newtask.result; else throw new Exception("Timeout"); } catch tries++; errorhappened = true; } } while (errorhappened == true && tries < retries); if (!errorhappened) return suggestions; else throw new Exception("Out of retries"); }); // Switch logic lastcall = newcall; await newcall; } if (lastcall == newcall) if (CallBack!= null) CallBack(newCall.Result); } catch (Exception ex) if (ErrorCallBack!= null) ErrorCallBack(ex); } 21
Látható tehát, hogy annak ellenére, hogy nem is szállt el annyira a fantáziánk, rengeteget kellett kódolnunk ezekért az alapvetőnek vehető feladatokért is. Ha ezen túljutottunk, akkor viszont a felhasználás már egész kényelmesnek mondható, ezt a 2-2-9 kódrészlet szemlélteti. 2-2-9 kódrészlet: A 2-2-8-as kód felhasználása public MainPage() // Initialization this.initializecomponent(); this.loaded += (s, e) => SearchBox.Focus(FocusState.Keyboard); var service = new SearchService(); // Helper method var callback = new Action<CustomGenericAsyncResult<IEnumerable<string>>>(e => if (e.exception!= null) ErrorLabel.Text = e.exception.message; else Suggestions.ItemsSource = e.result; }); // Suggestions var getsuggestionswrapper = new CustomGenericAsyncCall <string, IEnumerable<string>>(service.GetSuggestionsForQuery); SearchBox.TextChanged += (s, e) => getsuggestionswrapper.wrappedcallasync(searchbox.text); getsuggestionswrapper.callback += callback; // Results var getresultswrapper = new CustomGenericAsyncCall <string, IEnumerable<string>>(service.GetResultsForQuery); SearchButton.Click += (s, e) => getresultswrapper.wrappedcallasync(searchbox.text); Suggestions.ItemClick += (s, e) => getresultswrapper.wrappedcallasync(e.clickeditem as string); SearchBox.KeyDown += (s, e) => if (e.key == VirtualKey.Enter) getresultswrapper.wrappedcallasync(searchbox.text); }; }. getresultswrapper.callback += callback; 22
Rx megközelítés Keresési javaslatok Rx-nél mindig adatfolyamokban, csővezetékekben kell gondolkozni aminek van egy eleje ( forrása ), és egy vége ahol kiesik belőle az adat. Közben pedig az adatfolyamot megfigyelhetjük, manipulálhatjuk, szűrhetjük, késleltethetjük stb. Jelen esetben a célunk az, hogy ha a felhasználó beír valamit a keresődobozba, induljon el egy szervizhívás javaslatokért, majd az eredménye jelenjen meg a felületen. Azaz a forrás a szövegdoboz, az szolgáltatja a szervizhívás számára a bemeneti paramétereket. Ahhoz, hogy egy eseményből adatfolyamot készítsünk, az Observable típus statikus FromEventPattern() metódusát fogjuk használni. 2-3-1 kódrészlet: Esemény-feliratkozás Rx módra var querytextchanged = Observable.FromEventPattern(SearchBox, "TextChanged"); Ezen a ponton egy IObservable<EventPattern<object>> típusú adatfolyamunk van, azaz egy olyan lista ami EventPattern<object> típusú elemeket tartalmaz. Ez egy csomagolótípus a hagyományos (object sender, object args) szignatúrájú eseményekre. A TextBox vezérlő TextChanged eseményében az argumentum TextChangedEventArgs típusú, ez azonban sajnos nem tartalmazza a szövegdoboz aktuális tartalmát, így sok hasznát nem tudjuk venni. Jelenleg tehát van egy adatfolyamunk, amiben megjelenik egy elem minden alkalommal, amikor a felhasználó leüt egy billentyűt. A feladat hát az, hogy ezeket az elemeket valami használható információvá alakítsuk. Ezt a Select() operátorral fogjuk tudni megtenni. 2-3-2 kódrészlet: Esemény-feliratkozás Rx módra, LINQ-val tálalva var querytextchanged = Observable.FromEventPattern(SearchBox, "TextChanged").Select(e => SearchBox.Text); Most már egy olyan adatfolyamunk van, amiben megjelenik a szövegdoboz aktuális tartalma minden alkalommal, amikor a felhasználó beleír valamit. Jöhet a fojtás és az ismétlődések szűrése. 23
2-3-3 kódrészlet: Fojtás és egyezés vizsgálat var querytextchanged = Observable.FromEventPattern(SearchBox, "TextChanged").Select(e => SearchBox.Text).Throttle(TimeSpan.FromMilliseconds(100)).DistinctUntilChanged(); A bemeneti paraméter adatfolyama után a következő feladat a szervizhívás. Ez két részből fog állni. Fel kell vértezni magát a szervizhívást Timeout és Retry logikával, majd bele kell fűzni a querytextchanged adatfolyamba. Egy aszinkron hívást a legegyszerűbben a ToObservable() operátorral tudjuk adatfolyammá alakítani. 2-3-4 kódrészlet: Aszinkron művelet Rx adatfolyammá konvertálása var observablesuggestions = service.getsuggestionsforquery("x").toobservable(); Ezzel kapunk egy IObservable<IEnumerable<string>> típusú adatfolyamot, melyben a szervizhívás eredménye fog megjelenni vagy rosszabb esetben egy hibaüzenet, hogy valami hiba történt. Ezt az esetleges hibát, és az időtúllépés esetleges problémáját a Timeout() és a Retry() operátorokkal tudjuk orvosolni. A Timeout() hibát dob, ha az adott hívás kifut az időből, míg a Retry() hiba esetén újrapróbálja a hívást. A Retry() elkapja azt is, ha maga a szervizhívás dob hibát és azt is, ha a Timeout() operátor. Mindezt a 2-3-5 kódrészlet szemlélteti. 2-3-5 kódrészlet: Időtúllépés és újrapróbálás var observablesuggestions = service.getsuggestionsforquery("x").toobservable().timeout(timespan.frommilliseconds(250)).retry(3); A következő lépés, hogy mindezt belefűzzük az eredeti adatfolyamba, és kiváltsuk a szervizhívás jelenlegi példában szereplő X paraméterét. Ahhoz, hogy átlátható maradjon a kód, érdemes egy segédmetódust létrehozni, ami az iménti csomagolást elvégzi egy tetszőleges aszinkron hívásra. 24
2-3-6 kódrészlet: Csomagoló funkció var wrapasynccall = new Func< Func<string, Task<IEnumerable<string>>>, string, IObservable<IEnumerable<string>>>( (asynccall, parameter) => return asynccall(parameter).toobservable().timeout(timespan.frommilliseconds(250)).retry(3); }); Egy kicsit sok a generikus típusparaméter, menjünk rajtuk végig sorjában. A Func<R> típus egy olyan delegáltnak felel meg, ami nem kap paramétert, és a visszatérési típusa R. Van neki azonban még nagyon sok verziója, amikkel a paraméterek típusát tudjuk megadni, azaz egy Func<P, R> egy olyan metódusnak felel meg, ami vár egy P típusú paramétert, a visszatérési típusa pedig R. És ezt lehet fokozni 16 bemeneti paraméterig (P1, P2, P3, ). A jelenlegi példában 3 típusparaméter található, 2 paraméter és a visszatérési típus. Az első típusparaméter egy újabb Func, ami újabb magyarázat nélkül egy string típusú paramétert vár, a visszatérési típusa pedig Task<IEnumerable<string>>, azaz egy aszinkron metódusról van szó, esetünkben ez a szervizhívás lesz. A második típusparaméter egy string. Ezt fogjuk paraméterül átadni az előző paraméterként átadott delegáltnak. A harmadik típusparaméter, azaz a visszatérési típus, pedig az IObservable<IEnumerable<string>>. Ha nem lenne itt a kód többi része, akkor is sejteni lehetne, hogy az első paraméterként átadott aszinkron hívást fogjuk adatfolyammá alakítani. A kód többi része már csak egy Lambda kifejezés, ami ezt a csomagoló metódust reprezentálja. Láthatóan két paramétert kap (név szerint az asynccall és parameter nevű paramétereket), és egy adatfolyamot ad vissza. Látható, hogy a paraméterül kapott aszinkron metódust meghívjuk a szintén paraméterül kapott paraméterrel, majd adatfolyammá alakítjuk a ToObservable() operátorral, és ráakasztjuk a Timeout() és Retry() operátorokat is. A sok szenvedés végül meghozza a gyümölcsét, most már szépen egyetlen sorban bele tudjuk fűzni a felokosított szervizhívást a keresődoboz adatfolyamába. 2-3-7 kódrészlet: Csomagoló funkció felhasználása var observablesuggestions = querytextchanged.select(q => wrapasynccall( service.getsuggestionsforquery, q)); 25
A befűzés a Select() operátorral kezdődik, ám nem azzal fog véget érni. Ez a két sor kód azt eredményezi, hogy minden billentyűlenyomásra (persze lefojtva, megszűrve) elindítunk egy szervizhívást az aktuális tartalmával a keresőmezőnek. Ezzel azonban azt értük el, hogy van egy adatfolyamunk, amin további adatfolyamok jelennek meg, amikben majd végül megjelenik az egyes szervizhívások eredménye. A cél az volna, hogy megszüntessük ezt az adatfolyam az adatfolyamban állapotot és valahogy csak a legutolsó hívás által generált adatfolyamra figyeljünk, ezzel megszüntetve az egyes adatfolyamokban párhuzamosan megjelenő eredmények versenyét. Szerencsére kifejezetten erre a célra lett kitalálva a Switch() operátor, így nem fog nehezünkre esni, megoldani ezt a problémát. 2-3-8 kódrészlet: Versenyhelyzet kezelése var observablesuggestions = querytextchanged.select(q => wrapasynccall(service.getsuggestionsforquery, q)).switch(); Mostanra van egy adatfolyamunk, ami már lényegében mindent tartalmaz, amire csak szükségünk volt. Lefojtottuk és megszűrtük az ismétlődésektől az inputot, és felokosítottuk a szervizhívást. Az utolsó lépés, hogy végre a csővezeték végére álljunk, és megjelenítsük az eredményt. Normál esetben erre a célra a Subscribe() metódust használnánk, azonban itt ezt nem tehetjük meg. Az Rx-es adatfolyamok 3 csatornából állnak: OnNext, OnError és OnCompleted. OnNext eseményből bármennyi lehet egy adatfolyamban élete során, OnError vagy OnCompleted eseményből azonban csak egy. Ha valami hiba történik az adatfolyamban, akkor végigmegy rajta egy OnError üzenet, és lezárul a cső. Ilyen eset márpedig ebben a példában előfordulhat, és nekünk arra van szükségünk, hogy mégis csak megjelenhessen akármennyi OnError esemény és még se álljon le az adatfolyam. A hibákat a Retry() operátorral fogjuk eliminálni. Ez gondoskodik arról, hogy elnyelje az esetleges hibát és újraindítsa az adatfolyamot. Ahhoz pedig, hogy mégis értesüljünk a hibákról is (még mielőtt a Retry() elnyelné) a Do() operátort fogjuk használni. Ezt az operátort az adatfolyam tetszőleges részére beszúrhatjuk és megfigyelhetjük az adatfolyam aktuális állapotát, beleértve az OnNext, OnError és OnCompleted csatornákat. Megint csak a végleges kód szépítése érdekében érdemes bevezetni két segédmetódust: egyet az OnNext csatorna kezelésére, egyet pedig az OnError-éra. 26
2-3-9 kódrészlet: Feliratkozás az OnNext és OnError eseményekre var onnext = new Action<IEnumerable<string>>(values => ErrorLabel.Visibility = Visibility.Collapsed; Suggestions.ItemsSource = values; }); var onerror = new Action<Exception>(error => ErrorLabel.Visibility = Visibility.Visible; ErrorLabel.Text = error.message; }); Ezen két segédmetódus felhasználásával a végleges kódot a 2-3-10 kódrészlet mutatja. 2-3-10 kódrészlet: Végleges Rx kód a keresési javaslatokra var querytextchanged = Observable.FromEventPattern(SearchBox, "TextChanged").Select(e => SearchBox.Text).Throttle(TimeSpan.FromMilliseconds(100)).DistinctUntilChanged(); var observablesuggestions = querytextchanged.select(q => wrapasynccall( service.getsuggestionsforquery, q)).switch().observeondispatcher().do(onnext, onerror).retry(); observablesuggestions.subscribe(); Az ObserveOnDispatcher() operátorra azért van szükség, hogy az utána következő műveletek a Dispatcher-en, azaz a UI szálon fussanak. A Subscribe() hívásra azért van szükség, mert azzal indítjuk be az egész adatfolyamot. A feliratkozás alulról indul el, azaz amikor egy ilyen hosszú operátorlánc végén meghívjuk a Subscribe() metódust, akkor hátulról kezdve elkezdenek feliratkozni egymásra az operátorok, míg nem a sor elejére érnek, ahol megtörténik a SearchBox TextChanged eseményére a feliratkozás és megindul az adatfolyam. Keresési találatok A keresés találatainak megszerzése nagyon hasonló a fentebb leírtakhoz. A különbség csupán annyi, hogy több esemény hatására is elindulhat a keresés: megnyomja a felhasználó a keresés gombot, vagy entert üt a szövegdobozban, vagy kiválaszt egy javaslatot a listából. 27
2-3-11 kódrészlet: Eseményfeliratkozások var searchbuttonclicked = Observable.FromEventPattern(SearchButton, "Click").Select(_ => SearchBox.Text); var enterkeypressed = Observable.FromEventPattern<KeyRoutedEventArgs> (SearchBox, "KeyDown").Where(e => e.eventargs.key == VirtualKey.Enter).Select(_ => SearchBox.Text); var suggestionselected = Observable.FromEventPattern<ItemClickEventArgs>(Suggestions, "ItemClick").Select(e => e.eventargs.clickeditem as string); Ezzel a három adatfolyammal van három azonos típusú forrásunk, melyeken különböző események hatására a keresődoboz tartalma vagy épp egy kiválasztott javaslat jelenik meg. Ezeket a Merge() operátorral tudjuk összefésülni. 2-3-12 kódrészlet: Különböző eseményforrások összefésülése var observableresults = Observable.Merge(searchButtonClicked, enterkeypressed, suggestionselected).distinctuntilchanged().select(q => wrapasynccall(service.getresultsforquery, q)).switch().observeondispatcher().do(onnext, onerror).retry(); Érdemes megfigyelni, hogy a DistinctUntilChanged() operátort itt az összefésülés után használjuk, azaz ha valamit beír a felhasználó és entert nyom és rányom a keresés gombra is, akkor sem küldünk extra szervizhívást. A teljesség kedvéért most is mellékelem a teljes kódot. A kód itt mindenféle segédosztályok nélkül teljesen a XAML mögöttes kódjában, azon belül is az oldal konstruktorában helyezkedik el. A kód itt is az inicializációval kezdődik. 2-3-13 kódrészlet: Inicializáció public MainPage() // Initialization this.initializecomponent(); this.loaded += (s, e) => SearchBox.Focus(FocusState.Keyboard); var service = new SearchService(); 28