пятница, 8 августа 2008 г.

CLI, as I use it. Part 2. awk.

Когда речь заходит о работе с таблицами, прежде всего в голову приходит конечно же небезызвестный MS Excel, или его аналоги: Open Office Calc, Google Spreadsheets и так далее. Эти средства конечно же неплохо визуализируют данные, и хороши если данные сами по себе представляют для нас какую-либо ценность. Если же нам с этими данными нужно какие-то активные манипуляции провести, выбрать какую-либо их часть, а потом на основе этой выборки, еще чего-нибудь сотворить - все сразу становится сложным, непонятным, и совсем неинтуитивным. Тогда следует вспомнить что и в бинарном xls, и в xml'ном ods все сложности и навороты связаны прежде всего с тем что: нужно сохранять оформление таблиц, нужно где-то сохранять информацию о использовании тех 80% функциональности, которые редко кто использует(Вы часто строите трехмерные диаграммы в офисном пакете? - я, например, последний раз это делал на уроках информатики в школе ... тем не менее если вдруг решитесь - это все богатство легко уместится в xls или ods).

А для представления обычной таблицы, достаточно ведь гораздо меньшего! В простейшем случае строки таблицы могут быть строками текстового файла, а поля в каждой строке разделятся каким-либо символом(или символами), которы никогда в самих полях не встретится. Для более сложных случаев можно использовать csv.

Подобные текстовые таблицы встречаются в unix-системах на каждом шагу, наиболее известным примером может быть файл /etc/passwd.

Как я уже писал в первой части для работы с подобными таблицами удобной может быть утилита awk(1).

awk - язык програмирования, который неплохо подходит для обработки форматированных текстовых данных. Первая версия интерпретатора awk появилась еще на заре unix'состроения и с тех пор ту или иную реализацию можно найти практически в любой unix, или unix-подобной системе. Насколько я понимаю, в современных linux'ах awk встречается в виде gawk - версия awk от проекта GNU; а так же в более легком, но немного урезанном по функциональности варианте - mawk. mawk, например, по-умолчанию устанавливается в Debian. Основная функциональность одинакова во всех возможных вариантах, различия как правило в деталях.


Интерпретатор awk можно вызывать либо с указанием файла сценария:


awk [параметры] -f 'имя файла сценария' [файл(ы)]

или задавая нужные инструкции прямо в командной строке:


awk [параметры] 'сценарий' [файл(ы)]

где "[файл(ы)]" - имя файла или файлов которые awk-сценарий и будет обрабатывать.


awk-сценарий выполняется над каждой строкой входного файла, исключение представляют собой блоки BEGIN{} и END{}, которые выполняются соответственно в начале, и в конце работы сценария (условно говоря - перед началом чтения файла(ов) и после того как прочитана последняя строка).

Каждая обрабатываемая строка, доступна awk-сценарию через переменную $0. Если исходный файл представляет собой текстовую таблицу,разбитую на поля каким-то определенным разделителем, то в переменных $1,$2,...$n окажутся значения этих полей. По-умолчанию, разделителями считаются пробельные символы; задать свой вариант разделителя можно либо в блоке BEGIN, или с помощью параметра -F. Например, логины пользователей из /etc/passwd можно вывести следующим образом:


diesel@indie:~$ awk -F: '{print $1}' /etc/passwd
diesel@indie:~$ awk 'BEGIN{FS=":"} {print $1}' /etc/passwd

Программа на awk представляет собой набор блоков, каждый блок состоит из условия, и списка команд которые будут выполнятся если условие истинно. Команды в блоках заключаются в фигурные скобки. Блок перед которым не стоит никаких условий, например {print $1} из примера выше, будет выполняться на каждой обрабатываемой строке.

В качестве условия может выступать регулярное выражение заключенные в символы // - в таком случае с регулярным выражением будет сравниваться вся входная строка; операции сравнения с регулярным выражением, например $1~/regexp/ или $1!~/regexp/; а так же другие операции отношения, например $1 == "test". Допустим, вывести логины пользователей, которые начинаются с буквы d можно вот таким вот нехитрым образом:
diesel@debian:~$ awk -F: '/^d/{print $1}' /etc/passwd
daemon
diesel

В отличии от shell, или скажем perl, имена переменных в awk не предваряются символом $ . Конструкция вида $a будет трактоваться как "поле, номер которого, содержится в переменной a", Например результат выполнения команды:awk -F: '/^d/{a=1; print $a}' /etc/passwd, будет аналогичен тому что мы получили в предыдущем примере. Существует стандартная переменная NF в которой содержится колличество полей в текущей строке, print $NF напечатает содержимое последнего поля, print $(NF-1) - содержимое предпоследнего, и так далее.

Кроме NF, и FS следует обратить внимание еще на несколько специальных переменных: RS, OFS, ORS. RS(Record Separator) - задает разделитель строк("записей") нашей виртуальной текстовой таблицы. По-умолчанию, - перевод строки, но в блоке BEGIN{} это легко исправить. Допустим у нас есть файл вида:
diesel@debian:~$ cat test
Name: John Smith
Town: New Yourk

Name: Alex Diesel
Town: Nikolaev

В котором разделителем полей является перевод строки(\n), а разделителем строк таблицы - пустая строка(\n\n); Превратить это в более "табличную" форму можно вот так:

diesel@debian:~$ cat test|awk 'BEGIN{RS="\n\n"; FS="\n"} {print $1" "$2}'
Name: John Smith Town: New Yourk
Name: Alex Diesel Town: Nikolaev

Конкатенация строк, как видите, до боли проста, просто записываем подряд все строки которые нам хочется соединить(пробельные символы вне кавычек не учитываются, поэтому print $1 " " $2 сделает ровно тоже самое). Хотя в данном случае было бы неплохо не ставить после каждой переменной один и тот же разделитель, а задать общий разделитель для строк которые мы печатаем в print, для этого можно воспользоваться еще одной специальной переменной: OFS(Output Field Separator):

diesel@debian:~$ cat test|awk 'BEGIN{RS="\n\n"; FS="\n"; OFS=";"} {print $1,$2}'
Name: John Smith;Town: New Yourk
Name: Alex Diesel;Town: Nikolaev

А если мы хотим еще и сохранить исходный разделитель строк(\n\n), то к нашим услугам будет переменная ORS(Output Record Separator):


diesel@debian:~$ cat test|awk 'BEGIN{RS="\n\n"; FS="\n"; OFS=";"; ORS="\n\n"} {print $1,$2}'
Name: John Smith;Town: New Yourk

Name: Alex Diesel;Town: Nikolaev


Как видимо уже стало понятно из примеров, отдельне команды в блоке разделяются точкой с запятой, точка запятая после последней команды в блоке необязательна.

Иногда хочется при выводе результатов произвести некоторые замены, например, нам не очень сильно нужны "Name:" и "Town:", из предидущего примера. Конечно, можно воспользоваться sed'ом:


diesel@debian:~$ cat test|awk 'BEGIN{RS="\n\n"; FS="\n"; OFS=";"} {print $1,$2}' |sed -e 's!Name: !!g; s!Town: !!g'
John Smith;New Yourk
Alex Diesel;Nikolaev

но можно обойтись и awk, благо есть целый набор функций для операций со строками, например gensub:


diesel@debian:~$ cat test|awk 'BEGIN{RS="\n\n"; FS="\n"; OFS=";"} {print gensub("^Name: ","","",$1),gensub("^Town: ","","",$2)}'
John Smith;New Yourk
Alex Diesel;Nikolaev

Функция ожидает указания: того что заменяем, того на что заменяем, опций для замены(здесь фактически можно указать только "g", - глобальная замена в строке - то есть заменять все найденные совпадения, а не только первое), и собственно строки в которой замену проводить, а возвращает строку-результат замены. Если gensub в вашей версии awk нет, скорее всего окажется gsub, gsub не возвращает результата замены, а сохраняет результат в той же самой переменной в которой мы дадим ей строку(возвращает эта функция как раз таки колличество проведенных замен, поэтому и возможности указать"g" в качестве опции не имеет, итого колличество параметров на один меньше), получится несколько длинее, но результат будет достигнут:


diesel@debian:~$ cat test|awk 'BEGIN{RS="\n\n"; FS="\n"; OFS=";"}
{ gsub("^Name: ","",$1);
gsub("Town: ", "", $2);
print $1,$2
}'
John Smith;New Yourk
Alex Diesel;Nikolaev

Фактически, основная сфера использования awk - мелкие однострочники, иногда в состве shell-скриптов, сценариев на awk, занимающих более 20 строк я видел достаточно мало, - сколько-нибудь большие скрипты предпочитают писать на "олдскульном" perl, или "модных" ныне python, и ruby. В составе shell-скриптов иногда хочется передать awk значение какой-нибудь shell-переменной. Самый простой способ(но не единственный) это сделать вот так:


diesel@debian:~$ cat passwd_pattern.sh
#!/bin/bash
awk -F: '$1~/'$1'/{print $1}' /etc/passwd;

В качестве второго $1 подставится значение переменной $1 из shell'а - первый параметр переданный shell скрипту, например:


diesel@debian:~$ ./passwd_pattern.sh ^d
daemon
diesel

как и раньше мы получили логины пользователей которые начинаются с d. Стоит немного подредактировать скрипт, и пользовтелей соответствующих шаблону в системе не окажется:


diesel@debian:~$ cat ./passwd_pattern.sh
#!/bin/bash
awk -F: '$1~/'$1'/{print "userdel " $1}' /etc/passwd;
diesel@debian:~$ ./passwd_pattern.sh ^d
userdel daemon
userdel diesel
diesel@debian:~$ ./passwd_pattern.sh ^d |sh

В awk вобщем-то есть и условный оператор, и разнообразные циклы, и еще много разных функций, обо всем этом рекоменду читать при необходимости man-страницу, там достаточно подробно все рассказано. Пост все-таки про "as I use it", и тот subset awk, который я использую, описан выше. На закуску сегодня будет еще один пример.


Время от времени меня посещают странные идеи заставить мой домашний proftpd писать логи в MySQL, дабы логи эти было потом легче разбирать и выводы строить. Пожалуйста, не надо предлагать мне системы анализа логов - я про них знаю, да и речь сейчас не об этом. Но даже если этот самый proftpd начнет сегодня писать логи в базу, останутся ведь текстовые логи за вчера, и позавчера.... ну вы поняли, которые бы хорошо для статистики тоже в базе иметь. И поможет нам в этом awk. :)

Допустим таблица база данных имеет вот такую структуру:


CREATE TABLE downloads(
id INT NOT NULL AUTO_INCREMENT,
username VARCHAR(30),
filename VARCHAR(256) ,
size BIGINT,
host VARCHAR(30),
ip VARCHAR(16),
action VARCHAR(8),
duration VARCHAR(8),
time timestamp NULL default NULL,
success TINYINT,
PRIMARY KEY (id)
);

Лог, который пишет proftpd по умолчанию выглядит вот таким вот образом:


Sun Jan 13 16:24:12 2008 222 192.168.62.53 40622476 /srv/ftp/debian/debian_main/pool/main/g/gcj-4.1/libgcj-doc_4.1.1-20_all.deb b _ o a ftp 1 * c
Sun Jan 13 16:24:24 2008 11 192.168.62.53 9030846 /srv/ftp/debian/debian_main/pool/main/g/gcj-4.1/libgcj7-0_4.1.1-20_i386.deb b _ o a ftp 1 * c
Sun Jan 13 16:24:24 2008 0 192.168.62.53 80180

А вот небольшой скрипт, который эти непонятные строки, превращает в что-то более полезное и осмысленное:


#!/bin/bash

FILENAME=$1;

if [ x"$FILENAME" == "x" ]; then
echo " Script for converting proftpd logs into mysql queries. Usage: $0 ";
exit;
fi

awk '{
if ( $18 == "c"){
success="1";
}else{
success="0";
}

month=$2;

sub("Jan","01",month);
sub("Feb","02",month);
sub("Mar","03",month);
sub("Apr","04",month);
sub("May","05",month);
sub("Jun","06",month);
sub("Jul","07",month);
sub("Aug","08",month);
sub("Sep","09",month);
sub("Oct","10",month);
sub("Nov","11",month);
sub("Dec","12",month);

time=$1" "$2" "$3" "$4" "$5;
time=$5"-"month"-"$3" "$4;
print "INSERT INTO downloads (username,filename,size,host,ip,action,duration,time,success)"
print "VALUES (\""$14"\",\""$9"\",\""$8"\",\""$7"\",\""$7"\",\"RETR\",\""$6"\",\""time"\",\""success"\");";
}' "$FILENAME"


Работает это все примерно вот так:
diesel@debian:~$ ./proftpd-for-blog.sh test_log
INSERT INTO downloads (username,filename,size,host,ip,action,duration,time,success)
VALUES ("ftp","/srv/ftp/debian/debian_main/pool/main/g/gcj-4.1/libgcj-doc_4.1.1-20_all.deb","40622476","192.168.62.53","192.168.62.53","RETR","222","2008-01-13 16:24:12","0");
INSERT INTO downloads (username,filename,size,host,ip,action,duration,time,success)
VALUES ("ftp","/srv/ftp/debian/debian_main/pool/main/g/gcj-4.1/libgcj7-0_4.1.1-20_i386.deb","9030846","192.168.62.53","192.168.62.53","RETR","11","2008-01-13 16:24:24","0");
diesel@debian:~$ ./proftpd-for-blog.sh test_log | mysql -u root test_for_blog
diesel@debian:~$ mysql -e 'select * from downloads limit 1 \G' -u root test_for_blog
*************************** 1. row ***************************
id: 1
username: ftp
filename: /srv/ftp/debian/debian_main/pool/main/g/gcj-4.1/libgcj-doc_4.1.1-20_all.deb
size: 40622476
host: 192.168.62.53
ip: 192.168.62.53
action: RETR
duration: 222
time: 2008-01-13 16:24:12
success: 0

Да, конечно, INSERT можно(и даже нужно) не повторять на каждой строке, как модифицировать для этого написанное мной, предлагаю подумать самостоятельно. А я спешу откланяться, до новых встречь!

4 комментария:

Shortbread комментирует...

Есть опция --assign=var=val, и не стоит (имхо) колдовать над сборкой скрипта в баше :)

diesel комментирует...

Оно не всегда работает:

diesel@indie:~$ a="test"; awk --assign=a=$a '{print a}' /etc/passwd
awk: unknown option --assign=a=test ignored

есть -v val=val которое работает чаще, хотя лично мне проще "собирать awk в bash'е, как и sed. Если, конечно речь идет о одной строчке. Да, это иногда работает не очень корректно.

Shortbread комментирует...

М. Ну у меня gawk (под винду), может поэтому такое различие.

diesel комментирует...

да, это как раз и есть опция gawk'а. приведенный пример - с OS X, в котором реализация awk'а, если не ошибаюсь, BSD'шная - они немного отличаются - то что будет работать у меня - будет работать и у Вас, обратное - не всегда.