Motivace
Ze zvědavosti jsem nedávno na svém počítači podstoupil povýšení verze okenního správce KDE (desktopového prostředí, dle některých) na verzi 4.1 (tehdy ještě ve verzi beta). Tato operace sice byla z hlediska administrátora poněkud strastiplná, jelikož se jednalo o instalaci „from scratch“, nicméně o tom jindy a jinde; z hlediska uživatelského jsem byl s novým prostředím spokojen, až na pár malých zádrhelů.
Jedním z těchto zádrhelů byla mnou hojně používaná aplikace akregator, program pro shromažďování RSS feedů. Jednak tato aplikace při čtení či updatování feedů s netriviálním množstvím článků (cca 20 000) byla schopná vytížit procesor na několik dlouhých minut, jednak při svém počínání sebrala něco přes půlgiga operační paměti. Diskusí jsem zjistil, že obdobnými symptomy trpí i jiné aplikace pro čtení RSS.
Inu rozhodl jsem se, že nebudu zkoumat jiné RSS programy či opravovat akregator, místo toho si položme otázku: jak těžké může být napsat v Perlu vlastní RSS čtečku, takovou, jakou potřebujeme?
Architektura
Perl je jazyk pro lepení, budeme se tedy snažit pro naši aplikaci využít co nejvíce existujícího, a to pak vhodně slepit dohromady.
Jednou z věcí na prvních místech je určitě databáze článků, respektive informací o nich. Potřebujeme především rychle vybírat, řadit (např. podle času) a k zahození není ani decentní rychlost zápisu. Proto nám ze hry vypadávají řešení typu hash uchovaný na disku, potřebujeme opravdovou databázi. Slibnou se mi zdála knihovna SQLite, anžto poskytuje mocné SQL rozhraní a zároveň není nutné instalovat a spouštět zvláštní server. K SQLite existuje krom klasického rozhraní přes DBI a DBD také šikovný modul SQLite::DB
, volba tedy padla na něj.
Dalšími daty, která musíme uchovávat na disku, je seznam RSS kanálů. Tuto informaci bychom mohli také nacpat do SQL databáze, ale kvůli snadnějšímu editování uživatelem jsem se rozhodl pro – mezi perlisty oblíbený – jazyk YAML.
RSS kanály budeme z internetu tahat pomocí mašinérie LWP. Stažená data pak rozparsujeme pomocí modulu XML::RSS
a posléze jimi naplníme databázi. Tuto operaci jsem se rozhodl přenechat separátnímu skriptu – tedy ne GUI aplikaci. Nemusíme tak vynalézat kolo (v tomto případě cron), pokud chceme kanály kontrolovat periodicky. Navíc odpadne práce s thready v GUI a také nějaké ty widgety.
Samotnou čtečku (tedy GUI aplikaci) vyrobíme v prostředí Perl/Tk. Toto prostředí se možná zdá graficky chudší (co do vzhledu), ale má širokou podporu, a také při práci s ním cítíme, že pracujeme v Perlu a nikoliv s perlovským interfacem pro jazyk C (jako je tomu například u knihovny GTK+).
Samotný design GUI aplikace jsem ponechal v konzervativním duchu. (Čtěte: prachsprostě okopíroval z akregatoru.) Obrazovka je svisle rozdělena na dvě části, v levé je seznam RSS kanálů. Pravá část je tentokrát horizontálně rozdělena, v horní části je seznam článků a v dolní je popis aktuálně vybraného článku. Po vzoru Norton (total, midnight, …) commanderu je také možné pohybovat se tabulátorem mezi seznamem kanálů a článků, šipkami pak v aktuálním seznamu. Článek lze otevřít v externím prohlížeči klávesou enter nebo dvojitým kliknutím.
Zbývá otázka, jak se GUI aplikace dozví o tom, že stahovací skript updatoval databázi. Rozhodl jsem se pro nejjednodušší řešení: killall -USR1
. Toto řešení sice není přenosné na některé operační systémy, ale nešť.
Databáze
Rozhraní modulu SQLite::DB
je jednoduché, ale funkční.
# připojení databáze ze souboru my $sql = SQLite::DB->new('rss.sqlite'); $sql->connect() or die $sql->get_error; # pro bezpečnější provádění více updatů najednou můžeme zapnout # transakční režim $sql->transaction_mode(); # příkazy SQL vyjma select provedeme takto $sql->exec('insert into articles … ', parametry …) or die $sql->get_error; # v transakčním režimu ukončíme transakci takto $sql->commit() or die $sql->get_error; # u příkazu select si také poručíme, jakou funkcí se má výsledek # zpracovat $sql->select('SELECT * FROM articles', sub { # st je objekt z rodiny modulů DBI, práci # s ním viz dokumentace k DBI my $st = shift; while (my $r = $st->fetchrow_hashref) { print Dumper($r); } } ) or die $sql->get_error; # pokud select vrací jen jeden řádek, můžeme použít zkratku my $r = $sql->select_one_row('select id from articles where … print $r->e_13;
Modul při chybách bohužel negeneruje výjimky, je potřeba tedy důsledně kontrolovat návratové hodnoty funkcí. Lze také použít wrappery pomocí modulu Fatal
, tím ale přijdeme o cennou zprávu funkce get_error
. Zřejmě by šlo tento nedostatek nějak ošetřit, ale SQL příkazů používáme poměrně málo, tudíž to nestojí za řeč.
Po několika iteracích jsem dospěl k následující struktuře databáze. Databáze obsahuje jednu tabulku articles
. V této tabulce máme primární číselný klíč id
. Dále pak řetězcové položky feed
, title
, link
, author
a description
. Čas článku uchováváme číselně (sekundy od začátku epochy) jako time
a nakonec máme flag (0/1), zda už článek byl přečten v poli read
.
Přes SQL indexy nejsem příliš kovaný, ale přišlo mi rozumné krom primárního indexu přes id
mít ještě několik navíc. Jednoznačný (unique) index přes pole feed
a link
nám zajistí, že jeden článek nebude v databázi nikdy dvakrát. (S tímto lze trochu šoupat, v závislosti na subjektivní představě jednoznačné identifikace článku. Někdo může chtít, aby článek byl „jiný“, když se změní jeho čas, či název.) Pro rychlé vyhledávání jsem ještě zvolil dva indexy přes pole time
, respektive feed
a pak ještě jeden přes dvojici polí feed
read
pro rychlé sečtení počtu nepřečtených článků.
Vytvoření struktury databáze přenecháme samostatnému skriptu dbsetup.pl
. Jeho obsahem jsou pouze příslušné příkazy create
, není tedy zapotřebí se jím dále zabývat.
Stahování a parsování kanálů
Seznam kanálů budeme uchovávat ve zvláštním souboru ve formátu YAML. Struktura YAML není nijak složitá a narozdíl od jiných formátů, např. XML, ji může lidská bytost snadno upravovat.
--- root: root.clanky: http://rss.root.cz/2/clanky/ root.zpravicky: http://rss.root.cz/2/zpravicky/ …
Po načtení takovéhoto souboru pomocí YAML::LoadFile
dostaneme referenci na hash, jehož klíče jsou řetězce před dvojtečkou a hodnoty řetězce za ní. Rozhodl jsem se podporovat skupiny RSS kanálů, tudíž „root“ je takovou skupinou a „root.clanky“ je elementem v ní. (Tečková konvence je defaultní pro hierarchické seznamy v Tk. V případě nutnosti lze samozřejmě vše předělat na jinou konvenci.) Procházet takovýto hash je pak věcí jednoho cyklu for
.
Uvnitř tohoto cyklu nejprve stáhneme požadovanou stránku ze sítě internet. Pro tyto účely se hodí knihovna a modul LWP. LWP toho umí mnohem více, ale pro naše účely stačí následující:
my $agent = LWP::UserAgent->new(env_proxy => 1); my $reply = $agent->get($url); if (not $reply->is_success) { # ošetříme mezní případy }
Parametrem env_proxy
si vyžádáme nastavení proxy serverů z proměnných prostředí. Je také možné nastavit případnou autentizaci (jméno/heslo). Po provedení této sekvence příkazů máme obsah odpovědi přístupný jako $reply->content
, což můžeme rovnou podstrčit ke zpracování modulu XML::RSS
.
my $rss = XML::RSS->new(); eval { $rss->parse($reply->content); }; if ($@) { # opět ošetříme případné problémy } for my $itemref (@{$rss->{items}}) { …
(Bohužel se ukazuje, že některé RSS kanály velmi často obsahují XML soubor, který není well-formed, a tedy je odmítán. Do budoucna by to chtělo nějaký liberálnější parser, náměty prosím v diskusi.)
Další zpracování by v ideálním světě bylo poměrně triviální, a sice naplnění databáze z polí jako $itemref->{title}
. Jsou tu ale dvě překážky. Jednou z nich je čas článku (pole time
v databázi). V klasické specifikaci RSS je použit formát dle RFC pro e-mail, tedy řetězec typu Mon, 28 Jul 2008 11:10:35 +0200
. S tímto formátem si poradí modul z rodiny DateTime, přesněji DateTime::Format::Mail
. Druhým problémem je velká nekonsistence formátů a typů polí pro RSS, dalo by se říci, že každý pes jiná ves, je tedy potřeba nějakým způsobem vyzkoušet alespoň ty nejběžnější alternativy.
U samotného konce ještě rozlišujeme případ, kdy článek již v databázi existuje. V tom případě použijeme příkaz update
namísto normálního insert
, abychom nepřepsali pole read
a nezměnili tak přečtený článek na nepřečtený.
Celý skript běží v transakčním režimu a commit se provede teprve po úspěšném stažení všech RSS kanálů. V této chvíli také pomocí signálu USR1
dáme na vědomí GUI aplikaci, že se databáze změnila.
GUI
V této části zabírá největší díl práce s widgety, vlastní logika skriptu je poměrně strohá. I tak se ale celý skript s rezervou vešel do cca 350 řádků, nelze tedy hovořit o žádné hydře.
Práce s prostředím Perl/Tk je, jako u každého GUI prostředí, rozdělena na inicializační část, kde sestavíme widgety, a na zavolání funkce MainLoop
, která se stará o zpracování událostí. Některé události, například že byl uživatelem vybrán článek, lze nasměrovat na námi definované funkce.
Techniku sestavení widgetů nám ukáže příklad:
my @popts = qw/-fill both -expand 1/; my @hopts = qw/-header 1 -scrollbars ose/; $mw = Tk::MainWindow->new(-title => 'RSS'); my $pw = $mw->Panedwindow(-orient => 'horizontal')->pack(@popts); my $leftp = $pw->Frame; $feeds = $leftp->Scrolled('Tree', -columns => 3, @hopts )->pack(@popts); $pw->add($leftp);
Nejprve je sestaveno hlavní okno aplikace $mw
. Parametry konstruktorů se v Perl/Tk předávají hojně jako hashové položky volba => hodnota
, přičemž prvním znakem názvu volby je spojovník (dash, -
). Tato konvence je převzata z originálního využití Tk v jazyce Tcl.
Další widgety jsou sestaveny pomocí volání parent->TypWidgetu(volby)
. Výsledkem tohoto volání je nový widget. Fukce pack
má jako návratovou hodnotu volaný widget – volání lze tedy zřetězit do idiomu
my $widget = parent->TypWidgetu(volby)->pack(volby);
Pomocí zmíněné funkce pack
voláme takzvaný geometry manager (jehož jméno je pack). Geometry manager má na starosti umístění našeho widgetu v prostoru nadřazeného widgetu (např. v hlavním okně). Pack je velmi jednoduchý a univerzální, existují však i jiní geometry manageři, například grid nebo form. Pomocí voleb -fill both -expand 1
říkáme, že chceme widget roztáhnout do všech směrů tak, aby vyplnil celou nevyužitou plochu v nadřazeném okně. Dalšími možnostmi můžeme například widget přilepit na jednu stranu okna, atp.
Widget Panedwindow
umožňuje rozdělení okna na menší části, přičemž poměr částí lze měnit „šoupátkem“ (sash handle). Jednotlivé rozdělené části pak naplníme widgetem Frame
, což je jakýsi univerzální kontejner.
Hierarchický seznam RSS kanálů s „rozklikávacími“ skupinami reprezentujeme widgetem Tree
. Tento widget umí informace organizovat do sloupců, bohužel u těchto nelze uživatelem měnit velikost. Na pomoc máme ale fakt, že objektem v Tree
může být jakýkoliv widget, nejen text, tudíž do záhlaví si dosadíme widgety ResizeButton
, které vše zařídí za nás. Samotný obsah tabulky lze naplnit metodou itemCreate
, kterou navíc lze použít i k přepsání již existujícího políčka. Hierarchii ve stromu specifikujeme právě pomocí tečkové notace.
Aby náš widget Tree
šlo scrollovat, obalíme jej do tzv. mega-widgetu Scrolled
. Tento obsahuje jak vlastní scrollbary, tak námi definovaný (obecně jakýkoliv) widget. Navíc zařídí zpracování příslušných událostí, aby scrollbary skutečně posouvaly obsah widgetu.
Pro seznam článků použijeme HList
, menšího bratra widgetu Tree
, který umí vše, pouze ne „rozklikávat“ skupiny, což u článků nepotřebujeme.
Různých barev pro přečtené a nepřečtené články, jakož i zarovnání čísel doprava docílíme definováním a aplikováním objektu ItemStyle
.
Samotný jeden článek, respektive informace o něm, zobrazujeme ve widgetu ROText
. Klasický Text
obsahuje velmi mocný textový editor s podporou stylů textu a dalších příjemných vlastností. Widget ROText
je pak totéž, pouze jsou vypnuté funkce editování textu uživatelem. Za zmínku stojí, že do tohoto widgetu lze po provedení příslušné operace tie
tisknout jako do souboru (např. funkcí print
), tuto možnost ale nevyužíváme a voláme přímo funkce jako delete
a insert
.
Chceme-li, aby naše widgety také reagovaly na uživatele, je potřeba připojit některé události na námi definované funkce. Propojení kliknutí na seznam lze provést pomocí volby -browsecmd
, dvojité kliknutí pak pomocí -command
.
$articles->configure( -browsecmd => \&article_changed, -command => \&run_article, ); $feeds->configure(-browsecmd => \&feed_changed);
Propojení klávesy tabulátoru chce trochu vtipu. Standardně je tato klávesa použita na změnu focusu jednotlivých widgetů. Toto chování ale přenáší focus i na nepodstatné widgety, například záhlaví seznamu článků. Nejprve je potřeba tuto standardní činnost odpojit, posléze můžeme nadefinovat naši vlastní. (Stará činnost tabulátoru je stále dostupná přes shift, pouze se focus přenáší v opačném pořadí.)
$mw->bind('all', '<Key-Tab>' => undef); $articles->bind('<Key-Tab>' => sub { $feeds->focus }); $feeds->bind('<Key-Tab>' => sub { $articles->focus });
Zbývá dořešit vlastní logiku skriptu, tj. načítání seznamů a článků. Co se týče seznamu RSS kanálů, použijeme opět načtení ze souboru YAML. Jelikož je tato operace rychlá, můžeme si ji dovolit opakovat při každém updatu pomocí USR1
. Alespoň nebude nutné restartovat aplikaci při změně seznamu kanálů.
U každého RSS kanálu chceme zobrazit celkový počet a počet nepřečtených článků. Pro tento účel použijeme SQL funkci count
:
select count(1) from articles where feed=? # případně: and read='0'
Situace se komplikuje použitím skupin, jelikož u těchto chceme zobrazit sumu příslušného počtu přes všechny členy ve skupině. Navíc si chceme všechny tyto počty pamatovat, abychom nezatěžovali zbytečně databázi při přečtení jednoho článku (tj. snížení počtu nepřečtených o jedničku). Pro tento účel si založíme separátní hash feed_counts
:
$feed_counts{$feed} = [ @counts ];
(pole @counts
obsahuje dva počty z databáze –celkový a nepřečtený), a k tomu funkci, která upraví počty všech nadřazených skupin o daný počet:
sub update_count { my ($feed, @delta_counts) = @_; while ($feed =~ s/\.[^.]+$//) { for my $i (0, 1) { $feed_counts{$feed}->[$i] += $delta_counts[$i]; } } }
Tímto způsobem (a následným updatem políčka ve widgetu) jsme schopni vyřešit oba problémy, tj. sečíst skupiny i rychle měnit počty, pokud se nějaký článek stane přečteným.
Seznam článků představuje další výzvu, abychom si nenaběhli na problém s vytížením procesoru. Vytažení článků z databáze je otázka jednoho příkazu select
, poměrně náročné je ale zpracování polí – převod času, dekódování UTF-8 (vše samozřejmě funguje v unicode), plnění widgetů. Počet článků na jeden select
tedy omezíme na dejme tomu 100. Pokud je celkový počet větší, umístíme na spodek seznamu řádek s popiskem „more“. Je-li vybrán tento řádek (v režimu browse), donačteme dalších 100 článků a situace se opakuje. (Při tom provedeme ještě malý taneček, aby původní „more“ zmizelo, místo něj se objevil první načtený článek a kurzor zůstal na něm.)
Měření času pro jednotlivé operace, např. za účelem zjištění, kolik článků najednou je vhodné načíst, lze provést modulem Time::Hires
. (Rovněž je triviální sestavit skript, který jednorázově naplní databázi sto tisíci články.)
use Time::HiRes qw/gettimeofday tv_interval/; … my $start = [ gettimeofday() ]; $sql->select(… warn tv_interval($start);
Zobrazení jednoho článku (informací o něm) je velmi jednoduché, pouze se skopíruje pole description
z databáze do widgetu ROText
. Spuštění externího prohlížeče pak vyžaduje nejprve označení článku jako přečtený, a to jak v interní struktuře feed_counts
(viz výše), tak zápisem do databáze. Následuje klasický postup přes fork a exec:
my $pid = fork(); if (not defined $pid) { warn "fork: $!"; } elsif ($pid == 0) { CORE::exec( '/opt/seamonkey/bin/seamonkey', '-remote', 'openURL(' . $r->{'link'} . ')' ); die "exec browser: $!"; }
Nutnost vypsat CORE::exec
je dána importem funkce exec
z modulu knihovny SQLite. Správné ošetření ukončení podprocesů obstará funkce nasazená na signál CHLD
, dle vzoru v man perlipc
.
Co se týče signálů, ošetřujeme ještě USR1
, který nám dává vědět, že se změnil obsah databáze s články. V ošetřovací funkci není dobré provádět žádné komplikovanější akce, provedeme pouze naplánování nějaké funkce pomocí Tk na „idle“ stav, tedy když Tk shledá, že nemá co dělat:
sub sig_usr1 { $mw->afterIdle(\&do_refresh); $SIG{USR1} = \&sig_usr1; }
Funkce do_refresh
pak provede výmaz všech seznamů, jejich znovunačtení a umístění kurzoru na původní místo.
Závěr
A to je vše, co je důležité, zbytek je pouze implementační omáčka kolem, kterou lze v případě zájmu studovat nebo upravovat ve skriptech samotných. V dnešním díle jsme si ukázali, že je celkem jednoduché pomocí lepidla zvaného Perl sestavit funkční program pro čtení RSS kanálů.
Tento program jsem sestavil podle svých představ a přání, nicméně malý počet řádků kódu nebrání nikomu si jej příhodně upravit. Čtenárům přeji hodně zábavy, ať již program budou používat ke čtení RSS, či pouze ke studiu Perlu. Jako vždy, jakékoliv reakce jsou vítány.
Kompletní program, šířen pod licencí GPL verze 3: RSS.tar.gz.