k47.cz  — každý den dokud se vám to nezačne líbit
foto Praha výběr povídky kultura
twitter FB RSS

Projekt Chanminer - implementace

21. 10. 2011 — k47 (CC by-nc-sa) (♪)

Cha­n­mi­ner je na­psaný ve Scale a je tvořen mnoha ak­to­ro­vými pi­pe­line. Pro každý board (jako např. /g/) na každém chanu (jako na­pří­klad 4chan) exis­tuje jedna kom­pletní pi­pe­line, která ústí do glo­bál­ního úlo­žiště.


Sché­ma­ticky vypadá tok zpráv v sys­tému ná­sle­dovně:

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

HttpC­li­ent pe­ri­o­dicky sta­huje první stranu kaž­dého boardu. Vy­u­žívá při tom HTTP hla­vičku Last-Mo­di­fied If-Mo­di­fied-Since, aby mohl plá­no­vat po­ža­davky jenom když jsou sku­tečně po­třeba a šetřit tak datové pře­nosy. Na nej­ruš­něj­ších bo­ar­dech při­bývá ně­ko­lik postů za vte­řinu, u těch nej­po­ma­lej­ších může trvat hodinu něž někdo něco pošle.

Parser je srdce Cha­n­mi­neru, které ze sta­žené stránky ex­tra­huje jed­not­livé posty. Sta­žené HTML je nejdřív na­pra­so­váno kni­hov­nou tag­soup do DOMu Scala.xml, ze kte­rého se pak jed­not­livé in­for­mace ex­tra­hují xpath-like výrazy. Napsat každý parser není tak úplně jed­no­du­ché: jednak se spo­léhá na velice slabě struk­tu­ro­vaná data, která vy­slo­veně nejsou určena pro stro­jové zpra­co­vání a ex­trakci in­for­mací. Formát se navíc kdy­koli může změnit a má spoustu drob­ných nuancí. Navíc stan­dardní Sca­lov­ská knihovna pro XML není nic moc a někdy se s ní pra­cuje vy­lo­ženě špatně.

Buffer je pře­stupní sta­nice mezi par­se­rem a da­ta­bází. Po­sí­lají se do něj ex­tra­ho­vané posty, buffer fil­truje du­pli­city (ně­které posty ex­tra­ho­vané z jedné stránky často byly zís­kány už z mi­nu­lého po­ža­davku, na po­ma­lých bo­ar­dech je ob­vyklé, že stránka ob­sa­huje přes 80% du­pli­cit) a bu­f­fe­ruje uni­káty. Když ve­li­kost bu­f­feru do­sáhne určité meze, pošle svůj obsah do Sto­rage aktoru. Buffer pů­vodně vznikl jako op­ti­ma­li­zace, aby se do da­ta­báze za­pi­so­valo jenom ve vel­kých dáv­kách. Až poz­ději se uká­zalo, že se hodí i pro různé sta­tis­tiky.

Image­Down­lo­a­der se stará o sta­ho­vání ob­rázků a/nebo jejich ná­hledů podle růz­ných pra­vi­del (všechno, nic, jenom ná­hledy, ob­rá­zek pokud je menší než 1MB jinak náhled, atd).

Sto­rage je pak po­slední článek řetězu do kte­rého ústí všechny pi­pe­line a který ukládá zprávy do MySQL da­ta­báze, se kterou ko­mu­ni­kuje pomocí knihovny Sca­la­Query


Hlavní úlo­žiště zpráv je jedna gi­gan­tické ta­bulka, která se pro tenhle účel krásně hodí, pro­tože všechna data jsou plochá.

Ale nové zprávy nejdou přímo tam, ale nejdřív se uloží do jiné ta­bulky, která slouží jako buffer a a odtud se v dáv­kách po mi­li­onu pře­sy­pou do hlavní ta­bulky. Tohle po­divné uspo­řá­dání vzniklo jako další před­časná op­ti­ma­li­zace (buffer nemá žádné indexy, takže by se do něj mělo za­pi­so­vat velice rychle), ale pře­kva­pivě našlo uplat­nění. Ma­ni­pu­lace s hlavní ta­bul­kou, která má přes 20 GB ob­vykle trvá celou věč­nost. Změna nebo při­dání sloupce zabere ně­ko­lik hodin. Takže, když pro­vá­dím ně­ja­kou ta­ko­vou­hle mon­ster-ope­raci s hlavní ta­bul­kou, Cha­n­mi­ner může ne­ru­šeně plnit buffer, aniž by se nechal zne­po­ko­jo­vat me­ta­mor­fó­zou hlav­ního úlo­žiště.


Apli­kace vy­u­žívá dva ty­picky Sca­lovké vzory:

  1. Cake Pat­tern pro de­pan­dency in­jection.
  2. Stac­kable trait pat­tern pro vý­sledné po­sklá­dání všech boardů

Zá­kla­dem je trait Boards, který tvoří kon­tej­ner celé apli­kace: de­fi­nuje zá­vis­losti (sto­rage atd.) vy­u­ží­vané všemi boardy a abs­traktní vnitřní trait Board před­sta­vu­jící ak­to­ro­vou pi­pe­line jed­noho 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í kon­tej­ner Boards do vlast­ního traitu a uvnitř si vy­tvoří vlast­ního po­tomka traitu Board a v něm vlastní Parser, pří­padně pře­píše a na­staví všechno, co je po­třeba pro ten který chan. Pak vy­tvoří in­stance všech boardů a při­pojí je do metody boards pomocí stac­kable trait pat­tern.

Cake Pat­tern, jak je popsán v pů­vod­ním Mar­ti­nově článku po­u­žívá self typy pro vy­zna­čení zá­vis­losti na jiné službě: trait 4chanBoards { this: Boards => ... }. Cha­n­mi­ner rovnou dědí, pro­tože self typy by zne­mož­nily po­u­žití stac­kable trait pat­tern. Z celého slo­ži­tého Cake pat­ternu tedy vzal za vlastní jenom myš­lenku vněj­šího kon­tej­neru v němž jsou všechny zá­vis­losti de­kla­ro­vá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ři­pra­veny dvě pro­středí: tes­to­vací a pro­dukč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ý pro­gram získám tak, že k danému pro­středí mixnu všechny po­třebné chany:

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

A díky stac­kable traits jsou v metodě App.boards na­š­to­so­vané in­stance všech boardů. Pak už stačí na­star­to­vat aktory a systém jede.


Příště ko­nečně ukážu nějaké za­jí­mavé po­znatky zís­kané z té hro­mady bez­cen­ných dat.

píše k47 & hosté, ascii@k47.cz