k47.cz
mastodon twitter RSS
bandcamp explorer

Projekt Chanminer - implementace

21. 10. 2011 (před 11 lety) — k47 (CC by-nc-sa)

Chanminer je napsaný ve Scale a je tvořen mnoha aktorovými pipeline. Pro každý board (jako např. /g/) na každém chanu (jako například 4chan) existuje jedna kompletní pipeline, která ústí do globálního úložiště.


Schématicky vypadá tok zpráv v systému následovně:

╭─┤Board├────────╮
│ ╭────────────╮ │
│ │ httpClient │ │
│ ╰─────┬──────╯ │
│       ∇        │
│ ╭────────────╮ │
│ │   parser   │ │
│ ╰─────┬──────╯ │
│       ∇        │
│ ╭────────────╮ │  ╭─────────────────╮
│ │   buffer   ├───>│ ImageDownloader │
│ ╰─────┬──────╯ │  ╰────────┬────────╯
╰───────│────────╯           │
        ∇                    │
  ╭────────────╮             │
  │   storage  │<────────────╯
  ╰────────────╯

HttpClient periodicky stahuje první stranu každého boardu. Využívá při tom HTTP hlavičku Last-Modified If-Modified-Since, aby mohl plánovat požadavky jenom když jsou skutečně potřeba a šetřit tak datové přenosy. Na nejrušnějších boardech přibývá několik postů za vteřinu, u těch nejpomalejších může trvat hodinu něž někdo něco pošle.

Parser je srdce Chanmineru, které ze stažené stránky extrahuje jednotlivé posty. Stažené HTML je nejdřív naprasováno knihovnou tagsoup do DOMu Scala.xml, ze kterého se pak jednotlivé informace extrahují xpath-like výrazy. Napsat každý parser není tak úplně jednoduché: jednak se spoléhá na velice slabě strukturovaná data, která vysloveně nejsou určena pro strojové zpracování a extrakci informací. Formát se navíc kdykoli může změnit a má spoustu drobných nuancí. Navíc standardní Scalovská knihovna pro XML není nic moc a někdy se s ní pracuje vyloženě špatně.

Buffer je přestupní stanice mezi parserem a databází. Posílají se do něj extrahované posty, buffer filtruje duplicity (některé posty extrahované z jedné stránky často byly získány už z minulého požadavku, na pomalých boardech je obvyklé, že stránka obsahuje přes 80% duplicit) a bufferuje unikáty. Když velikost bufferu dosáhne určité meze, pošle svůj obsah do Storage aktoru. Buffer původně vznikl jako optimalizace, aby se do databáze zapisovalo jenom ve velkých dávkách. Až později se ukázalo, že se hodí i pro různé statistiky.

ImageDownloader se stará o stahování obrázků a/nebo jejich náhledů podle různých pravidel (všechno, nic, jenom náhledy, obrázek pokud je menší než 1MB jinak náhled atd).

Storage je pak poslední článek řetězu do kterého ústí všechny pipeline a který ukládá zprávy do MySQL databáze, se kterou komunikuje pomocí knihovny ScalaQuery


Hlavní úložiště zpráv je jedna gigantické tabulka, která se pro tenhle účel krásně hodí, protože všechna data jsou plochá.

Ale nové zprávy nejdou přímo tam, ale nejdřív se uloží do jiné tabulky, která slouží jako buffer a a odtud se v dávkách po milionu přesypou do hlavní tabulky. Tohle podivné uspořádání vzniklo jako další předčasná optimalizace (buffer nemá žádné indexy, takže by se do něj mělo zapisovat velice rychle), ale překvapivě našlo uplatnění. Manipulace s hlavní tabulkou, která má přes 20 GB obvykle trvá celou věčnost. Změna nebo přidání sloupce zabere několik hodin. Takže, když provádím nějakou takovouhle monster-operaci s hlavní tabulkou, Chanminer může nerušeně plnit buffer, aniž by se nechal znepokojovat metamorfózou hlavního úložiště.


Aplikace využívá dva typicky Scalovké vzory:

  1. Cake Pattern pro depandency injection.
  2. Stackable trait pattern pro výsledné poskládání všech boardů

Základem je trait Boards, který tvoří kontejner celé aplikace: definuje závislosti (storage atd.) využívané všemi boardy a abstraktní vnitřní trait Board představující aktorovou pipeline jednoho boardu.

trait Boards {
  def boards: List[Board] = Nil // každý mixnutý trait do téhle funkce přidá vlastní boardy přes stackable trait pattern
  val storage: Storage // dependency
  val logger: Logger   // dependency
  val imageDownloader: ImageDownloader

  trait Board {
    val httpClient: HttpClient = new HttpClient
    val parser: Parser
    val buffer: Buffer = new Buffer

    class HttpClient extends Actor { ... }
    class Parser extends Actor { ... }
    class Buffer extends Actor { ... }
  }
}

Každý chan si podědí kontejner Boards do vlastního traitu a uvnitř si vytvoří vlastního potomka traitu Board a v něm vlastní Parser, případně přepíše a nastaví všechno, co je potřeba pro ten který chan. Pak vytvoří instance všech boardů a připojí je do metody boards pomocí stackable trait pattern.

Cake Pattern, jak je popsán v původním Martinově článku používá self typy pro vyznačení závislosti na jiné službě: trait 4chanBoards { this: Boards => ... }. Chanminer rovnou dědí, protože self typy by znemožnily použití stackable trait pattern. Z celého složitého Cake patternu tedy vzal za vlastní jenom myšlenku vnějšího kontejneru v němž jsou všechny závislosti deklarovány jako abstract val.

trait `4chanBoards` extends Boards {

  private val _boards = List("3", "a", "_", "c", "cm", "d", "e", ... "x").map(new `4chanBoard`(_))
  abstract override def boards = _boards ::: super.boards // stackable traits

  class `4chanBoard`(board: String) extend Board {
    override val httpClient = new HttpClient { ... }
    val parser = new Parser { ... }
  }

}

trait `7chanBoards` extends Boards {

  private val _boards = List("777", "7ch", "b", "banner", "fl", ... "ss", "unf", "v").map(new `7chanBoard`(_))
  abstract override def boards = _boards ::: super.boards

  class `7chanBoard`(board: String) extend Board { ... }
}

Pak mám připraveny dvě prostředí: testovací a produkční.

trait ProductionBoards extends Boards with ImageDownloaderComponent {
  val storage = new MySQLStorage()
  val logger = new Logger()
  val imageDownloader = new ImageDownloader(directory, acceptRules)
}

trait TestBoards extends Boards with ImageDownloaderComponent {
  val storage = new DummyStorage
  val logger = new PrintlnLogger
  val imageDownloader = new DummyImageDownloader
}

Výsledný program získám tak, že k danému prostředí mixnu všechny potřebné chany:

object App extends ProductionBoards with `4chanBoards` with `7chanBoards`

A díky stackable traits jsou v metodě App.boards naštosované instance všech boardů. Pak už stačí nastartovat aktory a systém jede.


Příště konečně ukážu nějaké zajímavé poznatky získané z té hromady bezcenných dat.

píše k47, ascii@k47.cz