Pull to refresh

Знакомство с Go — пишем граббер веб страниц с многопоточностью и блудницами

Reading time 11 min
Views 70K
Про язык Go от команды Google слышали, наверное, все. А вот пробовали далеко не все, и очень зря — общение с сусликами Go это море удовольствия, в чем я недавно убедился на собственном опыте.
Начинать знакомство с новым языком забавнее всего на жизненном примере, поэтому я, не долго думая, взял первую попавшуюся задачу “из жизни, самой первостепенной важности”:

Есть в интернете сайт http://vpustotu.ru на котором любой желающий может анонимно высказаться о наболевшем. Все высказывания (в дальнейшем буду называть их “цитатами”) сначала попадают в модерацию (аналог “бездны” башорга), где посетители могут оценить полет мысли и проголосовать за цитату в стиле “Ого!” или “Ерунда!”. На странице модерации (http://vpustotu.ru/moderation/) нам показывают случайную цитату, ссылки голосования и ссылку “Еще”, которая ведет на эту же страницу. Пощелкайте, это все очень просто.

И вот возникла задача – срочно, под покровом темноты, загрузить себе полный дамп всех цитат на модерации для дальнейшего секретного исследования. Не будем оценивать житейскую ценность и степень идиотизма задачи, а рассмотрим её с технической точки зрения:

В разделе модерации нет прямых ссылок на определенную цитату, единственный способ получить новую цитату – обновить страницу (или перейти по ссылке “еще”, что одно и тоже). Причем вполне возможны повторы, что легко обнаруживается после пары минут агрессивного кликинга.

Таким образом нужна программа, которая:

  • Должна последовательно обновлять и парсить (разбирать) страницу, записывая цитату.
  • Должна уметь отбрасывать дубликаты.


Логично, что мы понятия не имеем все ли цитаты загружены, но об этом можно косвенно догадаться по большому количеству повторно полученных цитат подряд. Поэтому дополним:

  • Должна останавливаться не только по команде, но и по достижению определенного числа “повторов”, например 500!
  • Так как это, скорее всего, займет некоторое время: необходимо уметь продолжить “с места на котором остановились” после закрытия.
  • Ну и раз уж все-таки это надолго – пусть делает свое грязное дело в несколько потоков. Хорошо-бы в целых 4 потока (или даже 5!).
  • И отчитывается об успехах в консоль каждые, скажем, 10 секунд.
  • А все эти параметры пускай принимает из аргументов командной строки!


Ну, вроде все понятно. Пусть программа ведет два файла – с цитатами и с некими хешами этих цитат, чтобы не повторяться, и перечитывает файл в начале каждого запуска. Ну а дальше в цикле разбирает страницу, выдергивая все новые и новые откровения, пока не получит ctrl-c по лбу или же не встретит определенное количество повторов. Задача ясна, план есть – поехали!

Открываем любимый редактор создаем новый проект. Изначально код пустой программы выглядит примерно так:
package main
 
import (
    "fmt"
)
 
func main() {
    fmt.Println("Hello World!")
}

Начинаем идти по списку и по порядку выполнения программы:

У нас есть несколько параметров вроде количества “потоков”, имен файлов и прочее. Всю эту красоту достать из командной строки поможет встроенный пакет “flag“. Добавим его в список импорта и, заодно, объявим переменные-параметры:
import (
    "flag"
    "fmt"
)
 
var (
    WORKERS       int             = 2            //кол-во "потоков"
    REPORT_PERIOD int             = 10           //частота отчетов (сек)
    DUP_TO_STOP   int             = 500          //максимум повторов до останова
    HASH_FILE     string          = "hash.bin"   //файл с хешами
    QUOTES_FILE   string          = "quotes.txt" //файл с цитатами
)

Тип переменной в Go пишется после её названия что может быть слегка непривычно. Также мы сразу инициализируем значения по умолчанию, чтобы было все удобно и в одном месте.

Вещи вроде разбора аргументов принято делать в функции init(), так и поступим:
func init() {
    //Задаем правила разбора:
    flag.IntVar(&WORKERS, "w", WORKERS, "количество потоков")
    flag.IntVar(&REPORT_PERIOD, "r", REPORT_PERIOD, "частота отчетов (сек)")
    flag.IntVar(&DUP_TO_STOP, "d", DUP_TO_STOP, "кол-во дубликатов для остановки")
    flag.StringVar(&HASH_FILE, "hf", HASH_FILE, "файл хешей")
    flag.StringVar(&QUOTES_FILE, "qf", QUOTES_FILE, "файл записей")
    //И запускаем разбор аргументов
    flag.Parse() 
}

UPD: Хабраюзер Forked в комментарии популярно объяснил почему вызывать функцию flag.Parse() в init — плохая привычка. Спасибо ему за это — отныне я буду делать это в main() чего и вам советую, а этот пример останется тут в назидание что «фу так делать!»

Воспользуемся функциями IntVar (для чисел) и StringVar (для строк) из пакета flag – они читают заданный ключ из аргументов командной строки и передают его в переменную. Если ключ не указан то берется значение по умолчанию. Синтаксис у функций одинаковый:
flag.StringVar( &имя_переменной, ключ ком. строки, значение по умолчанию , описание ключа)

Обратите внимание, что мы передаем указатель на переменную (символ &) чтобы функция могла её модифицировать при надобности. Также интересен параметр “описание ключа” – дело в том, что пакет flag автоматически создает нам легенду аргументов доступную по ключу “-h”. Можно прямо сейчас запустить программу и убедиться в этом:
C:\Go\src\habratest>habratest.exe -h
Usage of habratest.exe:
  -d=500: кол-во дубликатов для остановки
  -hf="hash.bin": файл хешей
  -qf="quotes.txt": файл записей
  -r=10: частота отчетов (сек)
  -w=2: количество потоков

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

Теперь подумаем про саму программу — она должна читать страницу, разбирать ее, записывать результаты и анализировать прогресс. Да еще и в несколько «потоков» (сразу определимся — потоком я называю thread а не stream. Ну так, на всякий случай). Ха!

Многозадачность в Go реализуется гоурутинами (goroutine), то есть любую функцию мы можем запустить «в фоне» подставив перед ней ключевое слово «go»: она запускается и выполнение программы сразу продолжается, это примерно как запуск команды с & на конце в linux — мы сразу можем делать что-то еще:
//Например если есть ...
func GadgetUmbrella() {
	//...большой и сложный расчет...
}
//...и мы не хотим ждать пока он закончится,  мы просто пишем: 
 go GadgetUmbrella()
 fmt.Println("мы падаем!")
//И сообщение о прискорбном событии будет выведено на экран сразу же, а не после завершения функции GadgetUmbrella()

Вообще гоурутины это не потоки в чистом виде, там все гораздо интереснее, но такие вещи уже явно выходят за рамки нашего задания.

Предлагаю вынести в отдельный «поток» функцию которая будет в бесконечном цикле осуществлять загрузку и разбор страницы и отдавать готовую цитату, назовем её «граббер». Пусть себе крутится на заднем фоне в нескольких экземплярах, а мы из основной программы будем в цикле ловить эти цитаты, делать с ними что нужно, и, в случае наступления определенных условий, сворачивать работу. Звучит подозрительно просто, особенно для тех, кто уже имеет опыт многопоточного программирования и уже готов в ужасе кричать про многочисленные нюансы совместного доступа и синхронизации.

Но на деле все действительно просто, потому что еще одной замечательной особенностью Go являются Каналы (channels). Упрощенно канал легко представить себе как трубу, в которую с одной стороны закидывают значение а с другой его ловят. Каналы типизированы и требуют инициализации — вкратце работать с ними так:
func send( c chan int) {
   с <- 15    // отправляем число в канал
}

func main() {
	ch := make(chan int) //создаем канал для передачи целых чисел (int)
	go send(ch)
	b:= <-ch   //читаем число из канала
    fmt.Println(b)
}

Обратите внимание, что я написал именно go send(ch) а не просто send(сh). Дело в том, что по умолчанию передача и получение данных из канала блокируют вызвавшую их подпрограмму до тех пор, пока другой конец не будет готов обработать данные. Это такой замечательный инструмент синхронизации.
Получается, что если убрать "go" то send() выполнится в главном потоке и заблокирует его, потому что будет ждать пока кто-то не будет готов забрать наше число 15. Забираем мы его в следующей строке, но на нее уже никогда не будет передано управление. Deadlock, все в печали.
А вот в случае "go send()" все пройдет гладко, потому что send() заблокируется, но в своем потоке, и будет стоять до тех пор пока мы не произведем чтение в другом — а мы делаем это весьма скоро и обмен данными происходит успешно.
Так же, если убрать запись в канал в функции send(), то мертво встанет уже наоборот главная функция на строчке b:= <-c, потому как получать ей будет нечего.
Каналы это first-class объекты, соответственно их можно создавать, передавать, возвращать и присваивать как будет удобно.

У нас отправлять данные будет «граббер» а получать мы их будем в основном цикле.

Цитата в коде страницы просто лежит в div с классом «fi_text». Для того чтобы её достать в «граббере» воспользуемся пакетом goquery — он позволяет разбирать html-страницу и обращаться к её содержимому посредством селекторов а-ля jQuery. Этого пакета нет в стандартной поставке, поэтому его нужно предварительно установить:
#в консоли выполняем:
go get github.com/opesun/goquery

И в раздел импорта в коде добавляем пакеты «github.com/opesun/goquery», «strings» и «time» — последний нам понадобится для задержки, не будем же мы постоянно дергать сервер своими запросами (ну, и так и так будем, но вы меня поняли):
import (
	"flag"
	"fmt"
	"github.com/opesun/goquery"
	"strings"
	"time"
)


Ближе к делу — пишем код «граббера»:
func grab() <-chan string {  //функция вернет канал, из которого мы будем читать данные типа string
	c := make(chan string) 
	for i := 0; i < WORKERS; i++ { //в цикле создадим нужное нам количество гоурутин - worker'oв
		go func() { 
			for { //в вечном цикле собираем данные
				x, err := goquery.ParseUrl("http://vpustotu.ru/moderation/")
				if err == nil {
					if s := strings.TrimSpace(x.Find(".fi_text").Text()); s != "" {
						c <- s //и отправляем их в канал
					}
				}
				time.Sleep(100 * time.Millisecond)
			}
		}()
	}
	fmt.Println("Запущено потоков: ", WORKERS)
	return c
}

Код очень простой и почти не требует комментариев.
Мы используем замыкание, чтобы создать нужное нам количество «гоурутин» выполняющих анонимную функцию, которая постоянно отправляет данные в канал, возвращаемый grab(). Такой паттерн называют генератор.
Почему-то x.Find(".fi_text").Text() возвращала мне содержимое нужного элемента с пробелами в начале и на конце, поэтому, не долго думая, очищаем её функцией TrimSpace из стандартного модуля strings.

Итак генератор готов, можно убедиться что он работает модифицировав функцию main():
func main() {
	quote_chan := grab()
	for i := 0; i < 5; i++ { //получаем 5 цитат и закругляемся
		fmt.Println(<-quote_chan, "\n")
	}
}

Запускаем и видим что все идет по плану: откровения льются в наш канал широким потоком!


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

  • собирает цитаты.
  • проверяет их на уникальность сравнивая хеш цитаты с уже имеющимися
  • сохраняет цитату и хеш в файлы в случае новой цитаты
  • следит за количеством повторов подряд
  • отчитывается каждые REPORT_PERIOD секунд
  • ну и так как цикл бесконечный, будем ловить ctrl-c чтобы корректно выйти из него по команде пользователя.


Со сбором у нас проблем уже нет, определимся с хешами.
Для простоты я предлагаю брать md5 от цитаты. Хранить хеши будем в map(встроенная структура для key-value хранилищ), таким образом их легко и быстро будет искать. Проверить уникальность, подсчитать статистику и повторы — это уже дело техники. Работа с файлами в Go ничем не отличается от других языков, так что тут тоже никаких проблем.

Отчет по времени можно реализовать с помощью Ticker'a из стандартного пакета "time". Это простейший таймер, который будет срабатывать через заданный промежуток времени и посылать некое значение в свой канал — достаточно только следить за каналом и при поступлении данных выводить статистику.

А ловить команду на завершение мы будем с помощью пакета "os/signal", позволяющего повесить на определенные сигналы нотификатор, посылающий в канал уведомления о событиях.

План готов — но есть один нюанс: у нас получается три разных канала, из которых мы хотим получать данные, однако ранее говорилось, что при ожидании чтения поток блокируется, таким образом мы сможем ждать информации максимум по одному каналу одновременно.
Но go не зря ест свое процессорное время — еще одним замечательным инструментом является конструкция select.
Select позволяет ожидать данных из неограниченного количества каналов, блокируя выполнение только в случае прихода очередных данных, на время их обработки. То что нам нужно!

Приступим к коду! Для начала добавим необходимые пакеты в секцию импорта, теперь она будет выглядеть так:
import (
	"flag"
	"fmt"
	"github.com/opesun/goquery"
	"strings"
	"time"
	//Пакеты, которые пригодятся для работы с файлами и сигналами:
	"io"
	"os"
	"os/signal"
	//А вот эти - для высчитывания хешей:
	"crypto/md5"
	"encoding/hex"
)

Да, немало всего… Учитывая статическую линковку Go размер исполняемого файла должен получиться впечатляющим!
Но не время грустить, объявим хранилище для хешей:
var (
	...
	used          map[string]bool = make(map[string]bool) //map в котором в качестве ключей будем использовать строки, а для значений - булев тип.
)

И, наконец, наша основная функция превращается в:
func main() {
	//Открываем файл с цитатами...
	quotes_file, err := os.OpenFile(QUOTES_FILE, os.O_APPEND|os.O_CREATE, 0666)
	check(err)
	defer quotes_file.Close()
	
	//...и файл с хешами
	hash_file, err := os.OpenFile(HASH_FILE, os.O_APPEND|os.O_CREATE, 0666)
	check(err)
	defer hash_file.Close()

	//Создаем Ticker который будет оповещать нас когда пора отчитываться о работе
	ticker := time.NewTicker(time.Duration(REPORT_PERIOD) * time.Second)
	defer ticker.Stop()

	//Создаем канал, который будет ловить сигнал завершения, и привязываем к нему нотификатор...
	key_chan := make(chan os.Signal, 1)
	signal.Notify(key_chan, os.Interrupt)

	//...и все что нужно для подсчета хешей
	hasher := md5.New()
	
	//Счетчики цитат и дубликатов
	quotes_count, dup_count := 0, 0

	//Все готово, поехали!
	quotes_chan := grab()
	for {
		select {
		case quote := <-quotes_chan: //если "пришла" новая цитата:
			quotes_count++
			//считаем хеш, и конвертируем его в строку:
			hasher.Reset()
			io.WriteString(hasher, quote)
			hash := hasher.Sum(nil)
			hash_string := hex.EncodeToString(hash)
			//проверяем уникальность хеша цитаты
			if !used[hash_string] {
				//все в порядке - заносим хеш в хранилище, и записываем его и цитату в файлы
				used[hash_string] = true
				hash_file.Write(hash)
				quotes_file.WriteString(quote + "\n\n\n")
				dup_count = 0
			} else {
				//получен повтор - пришло время проверить, не пора ли закругляться?
				if dup_count++; dup_count == DUP_TO_STOP {
					fmt.Println("Достигнут предел повторов, завершаю работу. Всего записей: ", len(used))
					return
				}
			}
		case <-key_chan: //если пришла информация от нотификатора сигналов:
			fmt.Println("CTRL-C: Завершаю работу. Всего записей: ", len(used))
			return
		case <-ticker.C: //и, наконец, проверяем не пора ли вывести очередной отчет
			fmt.Printf("Всего %d / Повторов %d (%d записей/сек) \n", len(used), dup_count, quotes_count/REPORT_PERIOD)
			quotes_count = 0
		}
	}
}

Код абсолютно прозрачен, остановимся только на открытии файлов:
Функция check() не является стандартной — она тут для удобства проверки результатов открытия файла на ошибки. Вот её код, поместим куда-нибудь перед main():
func check(e error) {
	if e != nil {
		panic(e)
	}
}

Еще один интересный момент: мы «сразу» вызываем закрытие файла, хотя потом собираемся с ним работать:
defer quotes_file.Close()
...
defer hash_file.Close()
...
//точно также мы и с тикером поступаем:
defer ticker.Stop()

Суть в том, что запустив функцию с defer мы откладываем её выполнение: она выполнится только перед тем, как завершится функция из которой был вызов defer (проще говоря — прямо перед return «родителя»).

Можно уже запускать и радоваться выполнению «ужасно нужной миссии», но осталась еще одна маленькая деталь — нужно написать функцию чтения хешей из файла, на случай если мы захотим запустить программу повторно но не желаем видеть в результирующем файле дубликаты. Вот один из способов, как это можно сделать:
func readHashes() {
	//проверим файл на наличие
	if _, err := os.Stat(HASH_FILE); err != nil {
		if os.IsNotExist(err) {
			fmt.Println("Файл хешей не найден, будет создан новый.")
			return
		}
	}

	fmt.Println("Чтение хешей...")
	hash_file, err := os.OpenFile(HASH_FILE, os.O_RDONLY, 0666)
	check(err)
	defer hash_file.Close()
	//читать будем блоками по 16 байт - как раз один хеш:
	data := make([]byte, 16)
	for {
		n, err := hash_file.Read(data) //n вернет количество прочитанных байт, а err - ошибку, в случае таковой.
		if err != nil {
			if err == io.EOF {
				break
			}
			panic(err)
		}
		if n == 16 {
			used[hex.EncodeToString(data)] = true
		}
	}

	fmt.Println("Завершено. Прочитано хешей: ", len(used))
} 

Вот и все, осталось только не забыть поместить вызов readHashes() в начало main()
func main() {
	readHashes()
	...


Готово! Боевые запуски:


Результаты работы:


Создание файлов работает, перечитывание хешей при повторном запуске тоже. По команде останавливается, да и сама тоже умеет. Исполняемый файл, конечно, великоват — но и Go с ним.
Наша программа для выгрузки всякой ерундыочень важных данных в ночи готова и делает что мы от нее хотели.
Конечно, есть еще огромный список улучшений, но для простого выполнения боевой задачи этого кода более чем достаточно!

Код программы целиком на Pastbin.com

Если вас заинтересовал Go и его возможности — вот несколько мест, которые стоит посетить:

tour.golang.org — Онлайн тур по языку, редактирование кода прямо в браузере — очень увлекательно.

gobyexample.com — Примеры решения типовых задач

golang.org/ref/spec — Спецификация языка

golang.org/doc/effective_go.html — Статья на тему «а теперь мы со всей этой фигней попробуем взлететь» — как правильно взлетать и летать на Go

Группы Google посвященные Go — "https://groups.google.com/forum/#!forum/golang-nuts" и "https://groups.google.com/forum/#!forum/golang-ru"

godoc.org — Поиск по пакетам Go и их документации, причем как по встроенным так и по сторонним, отличная антивелосипедная вещь!

Удачи!
Tags:
Hubs:
+70
Comments 30
Comments Comments 30

Articles