Published: 3. 3. 2012   Category: GNU/Linux

Zpracování textu v příkazové řádce

Martin Bruchanov — bruxy@regnet.cz
Installfest 2012

Abstrakt: Práce v příkazové řádce se neobejde bez znalosti některých základních utit, které dokáží značně zjednodušit práci. V textovém prostředí často potřebujeme vyhledávat, vybírat část textu, sloupec, řadit či data dále zpracovávat. Přednáška vás seznámí se základními utilitami a úlohami, se kterými se v prostředí CLI setkáváme každý den.

Proč se patlat v textu na terminálu

Spousta úloh se dá velice efektivně řešit v příkazové řádce, od těch jednoduchý, až po ty komplexnější. GNU/Linux, a samozřejmě i další unixy, nám poskytují soubor utilit, které jsou léty prověřené, dobře zdokumentované, existují k nim tutoriály na internetu a zkušení uživatelé se bez nich neobejdou.

Příklad z praxe: dostal jsem za úkol monitorovat disková zařízení v systému HP-UX a po jednom dni provozu zjistit jaké je jejich zatížení a to porovnat s jiným monitorovacím nástrojem. Data se sbírala utilitou SAR a jejich formát byl následující:

14:31:25   device   %busy   avque   r+w/s  blks/s  avwait  avserv
14:31:45    disk1    4.40    2.33      17     274    5.50   58.52
           disk50    0.30    0.50      12     208    0.00    0.51
           disk51    0.05    0.50       0       0    0.00    8.80
           disk52    2.80    0.50      21     286    0.00    1.44
           disk53    2.45    0.50      16     297    0.00    1.64
           disk54    2.35    0.50      14     313    0.00    1.76
14:32:05    disk1    0.75    0.50       2      43    0.00   12.53
           disk50    0.05    0.50       3      33    0.00    0.29
           disk51    0.05    0.50       0       0    0.00   15.69
           disk52    2.60    0.50      15     227    0.00    1.82
           disk53    2.80    0.50      19     401    0.00    1.60

Na úkolu jsem pracoval s kolegou, který je znalcem Excelu a ten se rozhodl výsledky a grafy měření zpracovat právě v něm. Já jako odpůrce Excelu jsem mu řekl, že mnohem jednodušší bude malý skriptík v perlu, trocha shellu, grepování a gnuplot.

První problém bylo nějak hezky zformátovat data, protože časová značka je vždy na jednom řádku. To se skriptem v perlu udělá poměrně snadno a i zbytek nebyl problém. Za asi 40 minut jsem měl vytvořenou automaticky vygenerovanou statickou HTML stránku i s grafy.

Jak se s úkolem vypořádal odborník na Excel: nejprve si vylámal zuby už na vstupním formátu a poté co jsem mu vypomohl s perlem vytvořený nový formát po několika hodinách dospěl k závěru. Vytvořil XSL dokument, kde se muselo poměrně značnou posloupností klikání filtrovat podle jednotlivých zařízení a generovat grafy pro každý disk zvlášť. Protože mi chtěl dokázat, že danou věc také zvládne, strávil nad tím cca 4 hodiny.

Závěr: pochlubil jsem se, že jsem fakt dobrej :), ale hlavně tím chci naznačit, že CLI přístup k řešení problému má také něco do sebe a mnohdy přináší značné ulehčení práce oproti drahým a mainstreamově rozšířeným nástrojům.

Výhody textových formátů oproti binárním

Přístup unixové filozofie, kdy veškeré konfigurační soubory a dokumenty jsou uloženy v obyčejném textu má něco do sebe. Kromě toho, že se s takovými soubory snadno pracuje, jsou přenositelné, snadno porovnatelné a editovatelné všude je také obrovskou výhodou, že při ztrátě dat, např. chybě na disku se obyčejný text dá ještě zachránit pomocí lowlevel utilit, soubor jehož formát je pro nás lidi jen binární smetí, už většinou nezachráníme. Také si uvědomte, že binární formát, vašeho momentálně oblíbeného programu může mít omezenou životnost a za pár let už si tato data nebudete schopni přečíst, protože hardware už nemáte a výrobce SW zkrachoval a váš nejnovější hyper-OS už dávno není podporován.

Programy pro práci s textem

Podrobněji o jednotlivých utilitách

S některými z nich jste se pravděpodobně už setkali, nebo denně setkáváte, jiné jsou spíše okrajovou záležitostí.

Kódování

Standardní anglická abeceda má 26 základních písmen, v našich končinách se navíc objevují znaku s diakritickými znaménky, ať přímo v češtině a slovenštině, tak např. různé přehlásky v cizích jménech pocházejících z němčiny nebo maďarštiny. Už od doby rozšíření počítačů tak narážíme na to, zapsat text hezky česky občas nejde bez problémů. A to je naše situace ještě mnohem jednodušší než třeba v Izraeli, arabských zemích nebo některých asijských zemí nebo Rusku.

Protože byte má jen 8 bitů, a může tak popsat jen 256 kombinací bylo nutné vytvořit vícebytové kódování: ISO/IEC 10646, které má několik variant, z nichž asi nejčastější se kterým se setkáme je UTF-8.

Příklady:

Podívejte se na reprezentaci následujícího pangramu:
echo -n "Příliš žluťoučký kůň úpěl ďábelské ódy" | hexdump -C
Příklady převodu:
cat ANSI.ANS | recode IBM437..UTF-8 
recode iso-8859-2..utf-8 soubor.txt
recode utf-8..iso-8859-2 soubor.txt

Rozbor znaku α

Zde je příklad znaku– malé řecké písmenko alfa (α). V normě znaků Unicode je v databázi všech znaků uvedeno ke každému mnoho informací: název, kód v UTF-8, UTF-16, UTF-32, zápis jako HTML entity, případně jako kódu v HTML, do bloku jaké abecedy daný znak patří (řecká a koptská), dále že se jedná o malé písmenko a jaká je jeho varianta pro velké a zda je znak použitý při čtení z leva do prava (nebo obráceně).

Při vytváření programu nebo databáze u kterého předpokládáme mezinárodní použití je potřeba myslet na spoustu věcí, které by pak mohli způsobovat problémy.

Příklady jakým způsobem je možné pracovat s Unicode sekvencemi v různých jazycích:

stty -a # je nastaven příznak utf8?
echo -e "\u03B1"; # -e pro escape sekvence
echo -e "\xce\xb1"
printf "\u03B1\xce\xb1\n"
awk 'BEGIN { print "\xce\xb1"; }'
perl -e 'print "\xce\xb1\n";'
perl -e 'binmode STDOUT, ":utf8"; print "\N{U+03b1}\n";'
perl -Mcharnames=:full -e 'binmode STDOUT, ":utf8"; print "\N{GREEK SMALL LETTER ALPHA}\n";'

Kódování UTF-8 je kompatibilní s ASCII. Soubory zobrazíme, i když národní znaky se zobrazí chybně, funkce pro práci s textem, jako zobrazení, spojování, přesuny, které se spoléhají na to, že řetězec je ukončen nulovým znakem pracují téměř bez potíží (pozor na přetečení!). Kde mohou vícebytové znaky způsobovat potíže? Např. funkce které pracují s délkou řetězce počítají vícebytový znak jako několik bytů.

Vyzkoušejte si:

TEXT="Příliš žluťoučký kůň úpěl ďábelské ódy"
echo ${TEXT:7:9} # výběr podřetězce v BASHi
žluťoučký
printf "%.8s\n" "$TEXT" # výpis prvních 8 znaků (chyba!, Příli = 50 c5 99 c3 ad 6c 69 c5)
Příli
echo $TEXT | gawk '{ printf "%.8s\n", $0 }'
Příliš ž

Tabulka koptských a řeckých znaků

A nyní praktická ukázka, chceme vypsat na terminál tabulku koptských a řeckých znaků pomocí BASHe:

for i in `printf "%x " $(eval echo {$((0x370))..$((0x3FF))})`
do
	echo -e "\u$i "
done | fmt -w 30

Podívejme se na předchozí skript podrobněji. Potřebujeme zobrazit znaky, jejichž kód je v rozsahu od 0x370 do 0x3FF a potřebujeme je příkazu echo -e zadat tak, aby je byl schopen interpretovat na obrazovku, tedy \u370, \u371,...

Využijeme matematické funkce interpretované v těchto závorkách: $((...)):

echo $((1+1)) $((0xFFFF)) $((2**4)) $((10/(2+1)))
2 65535 16 3

BASH umožňuje převádět z různých soustav do desítkové. Další užitečnou vlastností jsou sekvence, které lze zadat např. takto: {1..5} nebo {a..e} nebo {1..10..2}:

echo -e {1..5} "\n" {a..e} "\n" {1..10..2}
1 2 3 4 5 
 a b c d e 
 1 3 5 7 9

Takže, jak se zachová výše uvedené echo {$((0x370))..$((0x3FF))}, vypíše na obrazovku:

{880..1023}

Tedy bez toho, aby se daná sekvence rozvinula. Zde nám pomůže příkaz eval, který nejdříve zadaný kód rozvine a pak provede. Protože sekvence je ovšem v desítkové soustavě a my jí potřebujeme v šestnáctkové, je nutné rozvinutá čísla znovu převést zpět, tady nám vypomůže printf "%x ":

printf "%x " $(eval echo {$((0x370))..$((0x3FF))})

Tyto čísla jsou pak vstupními argumenty pro for cyklus. Tak jak je to ve skriptu zadáno, tedy `printf "%x " $(eval echo {$((0x370))..$((0x3FF))})`, shell spustí subshell a vrátí vypsaná data o úroveň výše, vyzkoušejte si následující příklady:

echo Dnes je date +"%d. %m. %Y"
Dnes je date +%d. %m. %Y
echo Dnes je $(date +"%d. %m. %Y")
Dnes je 02. 03. 2012
echo Dnes je `date +"%d. %m. %Y"`
Dnes je 02. 03. 2012

Je jedno zda použijete závorky `...` nebo $(...), ovšem pokud potřebujete shelly vnořovat, je nutné použít druhý způsob.

Samotný for cykluje je jednoduchý, jeho syntaxe je: for proměnná in argumenty; do příkazy; done, také pamatujte na to, že shell při inicializaci zapisuje proměnnou bez prefixu, tedy TEXT="Ahoj", pokud chceme přistupovat k hodnotě proměnné musíme zadávat $TEXT. Zkuste si např. tento for cyklus:

for i in jedna dva tři; do echo $i; done
jedna
dva
tři

Jako argument můžete zadat slova, čísla oddělená mezerou nebo bílým znakem. Můžete zde zadat např: * (hvězdička se rozvina na všechny soubory v aktuálním adresáři) nebo *.jpg, výše popsané sekvence či subshell, který vypisuje nějaké položky. Náš skript je tímto vlastně hotov, jeho výstup pomocí roury přesměřujeme do fmt -w 30, tato utilita zformátuje svůj vstup tak, aby měl maximálně 30 znaků na řádek.

Poznámka k načítání argumentů for cyklem: jako oddělovač jsou standardně použité bílé znaky, tj. mezera, tabulátor \t, návrat vozíku \r a nový řádek \n, pokud chceme použít jiný oddělovač, nastavíme proměnnou prostředí IFS (Internal Field Separator). Např.: IFS=$'\n', pokud chceme jako oddělovač použít pouze nový řádek.

Náhrada znaků

Začneme jednou ze základních utilit pro náhradu a mazání znaků a tou je tr. Bohužel tr pracuje po bytech a tak úvodní příklad se změnou malých písmen na velká nedopadne dobře:

TEXT="Příliš žluťoučký kůň úpěl ďábelské ódy"
echo $TEXT | tr '[:lower:]' '[:upper:]'
PříLIš žLUťOUčKý Kůň úPěL ďáBELSKé óDY
echo $TEXT | tr ž z
Pz�íliz� zzluz�oučký kz�z� úpěl ďábelské ódy

Příklad z praxe. Občas se mi stává, že vykopíruji nějaký text např. z knihy v PDF nebo chci vysázet soubor původně vytvořený ve Wordu pomocí TeXu. Soubor vykopírovaný z knihy zdrojak.c. Problematické v těchto příkladech je to, že v PDF nebo Word u jednoznakových předložek, doplnily jako mezery znak nedělitelné mezery (U+00A0). Pouhým pohledem na text v editoru samozřejmě nic nepoznáme, k problémům dochází až při překladu.

gcc zdrojak.c
zdrojak.c: In function ‘main’:
zdrojak.c:8:1: error: stray ‘\302’ in program
zdrojak.c:8:1: error: stray ‘\240’ in program
zdrojak.c:8:1: error: stray ‘\302’ in program
zdrojak.c:8:1: error: stray ‘\240’ in program

Kompilátor nalezl neplatné znaky, vypsal je v osmičkové notaci a skončil s chybou. Soubor opravíme následovně:

tr '\302\240' '  ' < zdrojak.c > zdrojak2.c
printf "%x %x\n" 0302 0240
sed -e 's/\xc2\xa0/ /g' zdrojak.c > zdrojak2.c

Postup pomocí sedu je podobný, ale sed vyžaduje zadat byty jako hexadecimální čísla, ty si snadno převedeme pomocí printf, pozor na to, aby printf/BASH považoval čísla za osmičková je nutné je zapsat s nulou na začátku.

Vyhledávání

Pro prohledávání obsahu souborů se nejčastěji používá program grep. Odtud pochází podstatné jméno grepování, případně sloveso grepni si :) Jako hledaný výraz můžeme zapsat přímo vzor a nebo regulární výraz (regex). Následující příklady provádí grepování, ačkoliv jsou použité nejrůznější programy:

grep 'regex' soubor
awk '/regex/ { print $0 }' soubor
perl -ne 'print if /regex/' soubor
perl -ne '/regex/ && print' soubor
sed -e '/regex/!d' soubor

Co když chceme v GNU/Linuxu najít soubor, který obsahuje nějaký vzor?

find . -name '*.txt' -exec grep -H 'regex' {}\;

Použijeme program find, zde find prohledává rekurzivně pracovní adresář, a pro každý nalezený soubor spustí příkaz zadaný za parametrem -exec, nalezené jméno souboru se zapisuje jako {} a vložený příkaz se ukončí \;. Parametr -H nutí grep vypisovat název soboru, kde nalezl vzor.

Výběr sloupce

Častým problémem bývá získat z nějakého výpisu pouze sloupec s daty, která jsou pro nás důležitá. K tomu slouží několik utilit:

cut -d: -f 1,3 /etc/passwd # login a UID
ls -l | cut -d' ' -f 6 # velikosti souborů
ls -l | cut -b 27-35,48- | sort -rn  

První příklad je jednoduchý, sloupce /etc/passwd jsou oddělena znakem dvojtečky, pro login a UID vybereme 1. a 3. sloupec. V druhém příkladu chceme získat velikost souboru z výpisu ls -l, tento příklad je chybně, víc mezer vedle sebe je považováno za jednotlivé sloupce, takže 6 sloupec má podle šířky záznamů různé pozice. Poradíme si tak, že z výpisu vybereme jednotlivé byty (třetí příkaz). Mimochodem, pro zjištění velikosti souboru není nutné parsovat výstup ls -l, ale použijeme utilitu stat, která zjisťuje potřebné informace o souboru.

Pro soubory s proměnným formátováním je vhodnější použít následující:

awk  -F: '{print $1 " " $3}' /etc/passwd
perl -F: -ane 'print "@F[0] @F[3]\n"' /etc/passwd
ls -l | awk '{print $5 " " $9}' # problém?

Poslední příkaz je problematický v tom, že v názvu souboru se může objevit mezera, vše za mezerou se pak nezobrazí, jelikož jsme si vyžádali pouze 9. sloupec.

Jeden posluchač navrhoval použít na výběr sloupce regulární výraz a sed, řešení, které mě napadlo je následující:

ls -l | sed -e 's/.* [0-9]\+ \([[:alpha:]]\+ \)\{2\} *\([[:digit:]]\+\).*/\2/'

Jak vidíte regulární výraz je značně krkolomný, sice svůj účel plní, ale doporučuji využít jednoduší způsoby.

Jako oddělovač je možné použít i množinu znaků, zde chceme získat pouze čísla z HTML tabulky:

echo "<tr><td>120.12 Volts</td><td>3.0 Ampers</td></tr>" | awk -F"[> ]" '{ print $3 " " $6}'
echo "<tr><td>120.12 Volts</td><td>3.0 Ampers</td></tr>" | perl -F"[>\s]" -ane 'print "@F[2] @F[5]\n"'

Pozor na perl, ten nebere mezeru jako oddělovač v parametru -F, musíme jí nahradit metaznakem \s, který zastupuje i další bílé znaky [ \t\n\r\f] (bude to v našem konkrétním skriptu dělat nějaké problémy?). Případně můžeme použít [:blank:] pro [ \t]: -F"[>[:blank:]]".

Neinteraktivní proudový editor sed

Neinteraktivní proudový editor slouží k dávkovému zpracování souborů, nejčastěji ho využijeme při nahrazování vzorů, ale je možné v něm připravit i další složitější editační skripty. Sed načítá příkazy z příkazového řádku -e nebo ze souboru -f. Sed pracuje tak, že nejdříve načte první řádek ze vstupu do bufferu a provede zadané příkazy v daném pořadí a vypíše výsledek na standardní výstup. Poté nahradí obsah bufferu dalším řádkem. Sed obvykle vypisuje vše co načte, také na výstup, parametr -n potlačí automatický výpis a sed pak vypisuje jen explicitně zadané. Sed nemá problém se zpracováním textů v kódování UTF-8.

Příklad, předchozí tabulku znaků převedeme do HTML formátu. Skript tbl2html.sed pro sed, který provede převod je následující:

#před 1. řádek zapiš
1i\\<table>\n<tbody> 
#za poslední řádek připoj
$a\<\/tbody>\n<\/table>  
s/^/\t<tr><td>/          #začátek řádku nahraď = před řádek přidá
s/$/<\/td><\/tr>/        #na konec řádku přidej
s/ /<\/td><td>/g         #všechny mezery nahraď oddělovačem sloupců

Poté připojíme do roury příkaz:sed -f tbl2html.sed.

Další užitečné příkazy sedu:

Textové informace operačního systému

V této části se zaměříme na informace, které nám předává GNU/Linux prostřednictvím speciálního oddílu procfs. Zde můžeme nalézt informace o hardware, běhu OS, detailní informace o všech procesech, atd. Informace v této části nemusí být všechny podporované vaší verzí jádra, aktuální dokumentaci si zobrazíte příkazem man 5 proc.

Pozor: některé dále uvedené soubory obsahují jako oddělovač řetězců nulový znak \000, který se na terminálu nezobrazí, využijte tr, např.: cat /proc/$$/cmdline | tr '\0' '\n' | nl -v 0 zobrazí příkazový řádek právě běžícího shellu a u každého řádku pozici parametru.

Měříme rychlost připojení

Praktický příklad pro měření rychlosti síťového rozhraní. Pustíme stahování nějakého velkého souboru ze vzdáleného síťového úložiště a budeme sledovat vytížení síťové karty (kdysi jsem použil na testování silně nestabilní ADSL linky od poskytovatele SkyNet, a. s.)

#!/bin/sh
INT_SEC=5
ETH_DEV=p5p1

get_packets() {
	awk "/$ETH_DEV/ { print \$2 }" /proc/net/dev
}

while true
do
	I1=$(get_packets)
	sleep $INT_SEC
	I2=$(get_packets)
	echo -e $(date +%Y-%m-%d-%H:%M:%S)"\t"$(((I2-I1)/INT_SEC))
done

Pro načítání stavu síťového rozhraní využijeme /proc/net/dev, soubor obsahuje stav síťových rozhraní:

Inter-|   Receive                                                |  Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
    lo: 1026421140 7797753    0    0    0     0          0         0 1026421140 7797753    0    0    0     0       0          0
  p5p1: 109098939400 107475239    0    0    0     0          0         0 58997257910 93492007    0    0    0     0       0          0

V mé distribuci Fedora Core 15 je síťové rozhraní označené p5p1, jiných distribucích to může být obvyklejší eth0, podobný výpis, ale čitelnější s údaji navíc získáte pomocí /sbin/ifconfig. V našem případě potřebujeme znát počet stažených bytů v závislosti na čase, což je 2. sloupec (bashová funkce get_packets). Všimněte si, že příkaz awk obsahuje ve svém zápisu \$2, je to proto, že potřebujeme awk předat obsah proměnné $ETH_DEV, takže kód awk není uzavřený v jednoduchých 'závorkách', ale "dvojitých", text $2 by se tak rozvinul na druhý poziční parametr z příkazové řádky, mi ho ale potřebujeme předat awk, aby vědělo, že načítáme 2. sloupec.

V hlavní smyčce skriptu se pak načítají dva údaje, mezi načteními je pauza dlouhá $INT_SEC. Obě dvě tyto proměnné jsou nastavené na začátku skriptu na jednom místě, při použití skriptu na jiném počítači je pak nutné změnit jenom jeden údaj (označení síťovky). Výpis naměřených dat a časovou značku pak provádí řádek:

	echo -e $(date +%Y-%m-%d-%H:%M:%S)"\t"$(((I2-I1)/INT_SEC))

Vypisujeme datum ve formátu %Y-%m-%d-%H:%M:%S, tedy např. 2012-03-03-10:15:29. Výpočet $(((I2-I1)/INT_SEC)) je proveden v matematickém režímu shellu $((...)), všimněte si, že proměnné zde nemusí mít prefix dolar. Po spuštění skriptu získám tato data:

2012-03-03-10:22:42	254
2012-03-03-10:22:47	551
2012-03-03-10:22:52	642
2012-03-03-10:22:57	235
2012-03-03-10:23:02	165
2012-03-03-10:23:07	137
2012-03-03-10:23:12	321
2012-03-03-10:23:17	526
2012-03-03-10:23:22	265
2012-03-03-10:23:27	365
2012-03-03-10:23:32	114
2012-03-03-10:23:37	329
2012-03-03-10:23:42	292
2012-03-03-10:23:47	714
2012-03-03-10:23:52	298
2012-03-03-10:23:57	275
2012-03-03-10:24:02	2557990
2012-03-03-10:24:07	3417734
2012-03-03-10:24:12	3121275
2012-03-03-10:24:17	3471264
2012-03-03-10:24:22	597316
2012-03-03-10:24:27	135
2012-03-03-10:24:32	157

V jeden moment jsem dal stahovat:

wget ftp://ftp.sh.cvut.cz/debian-cd/debian-cd/current/amd64/iso-dvd/debian-6.0.4-amd64-DVD-1.iso

A nyní příklad, jak si zobrazit tato data do grafu, pomocí Gnuplotu:

set term dumb
set xdata time
set timefmt "%Y-%m-%d-%H:%M:%S"
plot "data2.log" using 1:($2/1024);

  3500 ++-+--+--+-+--+--+--+--+--+-+--+--+--+--+--+-+--+--+--+--+--+-+--+-++
       +     +    +     +     +    +  "data2.log" using 1:($2/1024)  +A    +
       |                                                   A               |
  3000 ++                                                                 ++
       |                                                                   |
  2500 ++                                            A                    ++
       |                                                                   |
       |                                                                   |
  2000 ++                                                                 ++
       |                                                                   |
       |                                                                   |
  1500 ++                                                                 ++
       |                                                                   |
       |                                                                   |
  1000 ++                                                                 ++
       |                                                                   |
   500 ++                                                        A        ++
       |                                                                   |
       +     +    +     +     +    +     +     +    +     +     +    +     +
     0 +A-+A-+A-+A+A-+A-+A-+A-+A-+A+A-+A-+A-+A-+A-+A+--+--+--+--+--+A+A-+-++
     22:40 22:5023:00 23:10 23:2023:30 23:40 23:5024:00 24:10 24:2024:30 24:40

Dotaz: Jak na hromadné přejmenování souborů?

Pro hromadné přejmenování je výhodné použít funkci BASHe pro náhradu podřetězců v proměnné, syntaxe je následující: ${proměnná/vzor/náhrada}. Vyzkoušejte si na daných příkladech.

FILE="jmeno.txt"
NEW_NAME=${FILE/jmeno/name}
echo $NEW_NAME
name.txt

Díky tomu je přejmenování celkem jednoduché, pro jistotu použijeme mv -i, kdy se při výskytu stejného cílového názvu mv dotáže, zda chceme soubor přepsat:

for i in *; do	mv -i $i ${i/jmeno/name}; done

Pokud naše názvy jsou složitější (je nutné využit složitejší regex) můžeme využít sed, zde je předchozí skript s jeho použitím:

for i in *;
do
	new=$(echo $i | sed -e 's/jmeno/name/')
	mv -i $i $new
done