В третото домашно ще си имплементираме паяче, което броди по всеобхватния Web, започвайки от зададена му страница и продължавайки по нейните линкове.
Ще се възползваме от това, че нашият паяк има много крака, за да имплементираме това бродене конкурентно и паралелно, използвайки Future
. След като страницата бъде извлечена, ще я предадем на процесор, който да я обработи по определен начин (отново конкурентно) и който ще ни генерира резултат за тази страница. Накрая моноидно ще съберем всички резултати от всички страници.
HTTP е синхронен request -> response протокол, което перфектно пасва на модела на Future-ите. Всеки response на заявка към определен URL (или по-точно, към определен ресурс), се състои от:
- status код, изразяващ резултата от обработката на заявката,
- списък от header-и под формата на ключ -> стойност, описващи response-а,
- тяло, съдържащ резултатно съобщение, например заявената web страница.
Това ще описваме чрез следния тип:
trait HttpResponse {
def status: Int
def headers: Map[String, String]
def bodyAsBytes: Array[Byte]
def body: String = ...
def contentType: Option[ContentType] = ...
def isSuccess: Boolean = 200 <= status && status < 300
def isClientError: Boolean = 400 <= status && status < 500
def isServerError: Boolean = 500 <= status && status < 600
}
Status кода е число между 100 и 599, като всеки си има различен смисъл и са подредени по категории. Дефинирали сме проверка за три от категориите – дали е статус за успех, статус за клиентска грешка или статус за сървърна грешка. Най-често успех се обозначава чрез 200.
Тялото представяме чрез масив от байтове, но за улеснение, в случаите когато очакваме то да е текст, сме имплементирали метод body
, който превръща байтовете към низ
Един специален header, за който сме създали специален тип, е Content-Type
:
case class ContentType(mimeType: String, charset: Option[Charset])
Ако е зададен, той съдържа типа на съобщението, съдържащо се в body-то (HTML, PNG картинка, текст и т.н.) и символна кодировка, ако то е текстово. В companion обекта на ContentType
сме описали MIME типа на HTML (text/html
) и текстовите документи.
Един HTTP клиент, който поддържа единствено извличане на HTTP ресурси, изглежда по следния начин:
trait HttpClient {
def get(url: String): Future[HttpResponse]
}
Предоставили сме ви имплементация на такъв клиент чрез библиотеката async-http-client в класа AsyncHttpClient
. Характерното за него е, че той имплементира така наречения reactor pattern, който ни позволява да използваме неблокиращ вход/изход, с което една нишка може да се грижи за множествено мрежови комуникации без да бъде блокирана.
get
методът извършва заявка към съответния ресурс и връща Future
с получения response.
В math
пакета може да откриете имплементацията на моноид, която постигнахме по време на лекциите.
В Spidey.scala
ще намерите следното:
case class SpideyConfig(maxDepth: Int,
sameDomainOnly: Boolean = true,
tolerateErrors: Boolean = true,
retriesOnError: Int = 0)
class Spidey(httpClient: HttpClient)(implicit ex: ExecutionContext) {
def crawl[O : Monoid](url: String, config: SpideyConfig)
(processor: Processor[O]): Future[O] = ???
}
Вашата първа и основна задача е да имплементирате методът crawl
. Той има множествено входни параметри/конфигурации, затова помислете добре как ще разбиете имплементацията му на малки части.
SpideyConfig
осигурява конфигурационни параметри за начина на работа на crawl
, но за основната имплементацията ще се съсредоточим само върху maxDepth
, останалите ще разгледаме като допълнение по-късно.
Целта на crawl
е да обходи всички линкнати ресурси, започвайки от url
и стигайки до дълбочина maxDepth
линка от него, да изпрати HttpResponse
-а, получен от всеки от тях, към подадения процесор, и да комбинира (слее) резултати от тип O
, генерирани от всяко извикване на процесора.
Процесорите имат следния интерфейс:
trait Processor[O] {
def apply(url: String, response: HttpResponse): Future[O]
}
Пълните изисквания към crawl
са следните:
-
Първият ресурс, който извлича, е
url
. Той е на дълбочина 0 от себе си, всички негови линкове са на дълбочинно ниво 1, всички тяхни на 2 и т.н. -
Ще обработваме само HTTP линковете. За да проверите дали даден линк е валиден HTTP линк използвайте
HttpUtils.isValidHttp
. -
Всеки един ресурс трябва да бъде извлечен най-много веднъж, тоест ако вече е бил срещнат на по-ранно ниво да не се повтаря неговото извличане. За да постигнем това лесно чрез
Future
ще искаме да имаме конкурентност в извличането и обработването на ресурсите само в едно и също дълбочинно ниво. Тоест първо извличваме и обработвамеurl
, след това всички адреси/ресурси на ниво 1, след като те са готови, всички на ниво 2 (без тези, които вече са били обработени на ниво 0 или 1), и т.н. Това жертва конкурентността в определени моменти (например една много бавна заяка би отложила минаването към обработка към следващото ниво), но пълна конкурентност би изисквала допълнителна синхронизация с други примитиви (като напримерAtomicReference
или актьор). -
Ще обработваме всички типове ресурси, но ще извличаме линкове само от HTML ресурсите. За да извлечете всички линкове от HTML страница използвайте
HtmlUtils.linksOf
. За всички останали типове считаме, че не съдържат линкове. -
Обработката на
HttpResponse
от процесор започва веднага щом response-ът бъде получен. -
Всяко извикване на
apply
на процесор генерира резултат от типO
, който е моноиден. Резултатът отcrawl
трябва да е моноидното събиране на всички обекти от типO
, като редът, в който ще бъдат събрани, трябва да съвпада с реда, върнат отHtmlUtils.linksOf
. Използвайте методът наList
distinct
за премахване на повторенията.Като пример, един възможен процесор може да брои честотата на всяка дума в страницата. Тогава типът
O
ще бъдеWordCount
, който на всяка дума съпоставя бройка. Крайнитът резултът отcrawl
ще бъде честотата на всяка от срещнатите думи във всички посетени страници.
Съвет: Помислете за помощен тип, в който да съхранявате резултатите от обработката на даден URL, докато чакате обработката на останалите.
crawl
може допълнително да бъде настройвано със следните параметри:
sameDomainOnly
– ако еtrue
, то се следват само линкове към същия домейн катоurl
. ИзползвайтеHttpUtils.sameDomain
за да проверите дали два URL-а имат един и същи домейн.tolerateErrors
– при стойностfalse
първият fail-налFuture
в цялата композиция наcrawl
трябва да доведе до fail резултат отcrawl
със същата грешка. При стойностtrue
, при fail, независимо дали отHttpClient.get
илиprocessor.apply
, считаме, че за този ресурс сме получили моноидна нула (identity
на моноид) и че той няма никакви линкове и не прекъсваме изчислението на останалите ресурси.retriesOnError
– ако е по-голямо от 0, то при fail наFuture
-а отHttpClient.get
или при негов успех, но със status код, който е server error (между 500 и 599), автоматично опитваме отново да извършим request-а чрез повторно извикване наHttpClient.get
. Това повтаряме до успех или до най-многоretriesOnError
пъти. При краен неуспех връщаме резултатът наHttpClient.get
от последнияretry
.
Последната стъпка е имплементирането на самите процесори, които обработват уеб страниците/ресурсите. Всеки процесор генерира определен тип. За всеки от тях трябва да реализирате и моноидната имлементация на този тип. Ще искаме от вас да създадете три процесора:
WordCounter
генерира резултат от тип WordCount
, съпоставящ всяка дума на брой срещания.
За успешните response-и (status код между 200 и 299), които са HTML страници (text/html
) или страници с чист текст (text/plain
), ще искаме да преброим колко пъти се среща всяка дума в тях. За да извлечете текста от HTML документ използайте HtmlUtils.toText
. За да вземете списък от всички думи в даден текст използвайте WordCount.wordsOf
.
За неуспешните response-и връщайте WordCount
с празен map.
FileOutput
записва ресурса във файл с уникално-генерирано име, ако response-ът е бил успешен. Резултатният тип е SavedFiles
, който съпоставя за всеки файл къде по файловата система и бил записан. Очевидно, едно извикване на apply
ще генерира SavedFiles
с най-много един елемент в своя map.
FileOutput
прима targetDir
за това къде да бъдат записвани файловете. Името на файл за определен URL може да генерирате чрез generatePathFor
.
За да извършите самото записване използвайте Java функцията Files.write
. Извикването на тази функция блокира текущата нишка докато резултатът не бъде записан успешно на файловата система. Практика е операциите, които блокират нишки, или които се изпълняват продължително време, да бъдат стартирани върху създаден за тях отделен pool от нишки, за да не пречат на другите асинхронни операции. Затова FileOutput
приема и ExecutionContext
, в който да бъде извършено записването.
BrokenLinkDetector
генерира списък от всички URL-и, за които сме получили отговор със статус код 404 (Not Found). Така можем да намерим всички счупени линкове на даден сайт.
Тази част няма да оценяваме, но е добро упражнение за вас да тествате това, което сте реализирали.
В SpideyApp
сме започнали имплементацията на приложение, което приема настройки от потребителя и изпълнява някой от реализираните процесори върху зададен URL и неговите линкове. Като упражнение може да опитате да реализирате приложението, загатното в текста на printUsage
.
Забележете, че създаваме два ExecutionContext
-а – един default-тен и един, които да бъде използван за FileOutput
.
Аргументите към приложението се подават на args
масива на main
. За да изпълните приложението и да ги подадете имате няколко варианта:
-
Ако изполвате IntelliJ IDEA от
Run -> Edit Configurations
може да зададетеProgram arguments
заSpideyApp
-
може да стартирате приложението през
sbt
сsbt "run <аргументи>"
(или директноrun <аргументи>
, ако сте вече вsbt
) -
Може да използвате
sbt
plugin-аassembly
, който създава изпълним.jar
файл чрезsbt assembly
. Вproject/plugins.sbt
може да видите как сме го добавили към текущия проект. Генерираният.jar
се появява вtarget/scala-2.12
и може да се изпълни чрез:java -jar target/scala-2.12/spiders-from-mars-assembly-0.1.jar <аргументи>
За да тествате Future
-ите имате няколко възможни подхода
- Да изполвате
Await.result
във вашите тестове, който блокира нишката на теста докато не се върне резултат, след което да проверите дали резултатът е очакваният. - Да използвате
ScalaFutures
разширенията към ScalaTest. - Да използвате async разширенията към ScalaTest
Препоръчваме ви 2 или 3. За подходящи тестове на имплементираната от вас функционалност ще ви дадем до 2 бонус точки.
В това домашно отново ще следим за стил.
Общият брой точки от него е 6 (или 8 с бонус точките).