Käsittelen tässä artikkelissa torrent-tiedostojen parsimista sillä oletuksella, että käytössä ei ole mitään valmista kirjastoa niiden käsittelyyn.

Perinteinen (huono) tapa parsia dataa on käyttää indexOf()- ja strpos()-tyyppisiä funktioita. Tästä syntyy monimutkaisia for/while-lauserakenteita ja helposti hajoavia oletuksia siitä, että datan joukosta löytyy tiettyjä merkkejä tietyistä kohdista.

Toinen tapa on toteuttaa tilakone, joka luuppaa dataa tavu tavulta eteenpäin ja pitää yllä tilamuuttujia siitä, missä kohdassa ollaan. Tilakoneesta tulee kovin monimutkainen, jos tietorakenteet voivat olla rekursiivisia.

Kolmas tapa, jota demonstroin tässä, on hyödyntää Scalan pattern matching -ominaisuutta sekä rekursiivisia funktioita datan parsimiseen. Tämä on siis esimerkki funktionaalisesta ohjelmoinnista. Haastan proseduraaliset ohjelmoijat toteuttamaan vastaavan parserin nätimmin!

Torrent-tiedostoformaatti

Torrent-tiedostot ovat melko yksinkertaisia kenttäkokoelmia. Kullakin kentällä on nimi ja sitä vastaava arvo. Arvo voi olla merkkijono, numero, lista tai sisäkkäinen kenttäkokoelma. Merkkijonot on UTF-8-koodattu. Poikkeuksena on pieces-kenttä, joka sisältää alkuperäisen tiedoston SHA1-hashit tavuina.

Oheinen koodinpätkä lukee torrent-tiedoston muistiin, purkaa sen sisältämät datakentät sekä tulostaa ne ruudulle. Omasta mielestäni se on suhteellisen nättiä koodia, vaikka onkin ensimmäinen oikea Scala-ohjelmani. :-)

Huomaa, että ohjelmassa ei ole yhtäkään for- tai while-looppia muualla kuin infon tulostuksessa. Koko logiikka on tehty pelkästään match..case-lauseilla, eikä muuttujiakaan tarvita muuhun kuin metodien palauttamien tuple-paluuarvojen purkamiseen.

import java.io._

class TorrentFile(input: InputStream) {

  def decodeInt(num: Int, data: Seq[Byte]): (Int, Seq[Byte]) = data match {
    // Integer digits end with 'e'.
    case 'e' :: rest => (num, rest)
    case digit :: rest if digit.toChar.isDigit => decodeInt(num * 10 + (digit-'0'), rest)
  }

  def decodeString(len: Int, data: Seq[Byte], encoding: String): (String, Seq[Byte]) = data match {
    // String length ends with ':' and string content starts.
    case ':' :: rest =>
      var (str, rest2) = rest.splitAt(len)
      (new String(str.toArray, 0, len, encoding), rest2)
    case digit :: rest if digit.toChar.isDigit => decodeString(len * 10 + (digit-'0'), rest, encoding)
  }

  def decodeList(list: List[Any], data: Seq[Byte], encoding: String): (Seq[Any], Seq[Byte]) = decodeNext(data, encoding) match {
    // List ends with 'e' (None from decodeNext).
    case (None, rest) => (list, rest)
    case (item, rest) => decodeList(item :: list, rest, encoding)
  }

  def decodeDict(dict: Map[String, Any], data: Seq[Byte], encoding: String): (Map[String, Any], Seq[Byte]) = decodeNext(data, encoding) match {
    // Dictionary ends with 'e' (None from decodeNext).
    case (None, rest) => (dict, rest)
    case ("pieces", rest) =>
      var (value, rest2) = decodeNext(rest, "ISO-8859-1")
      decodeDict(dict + (("pieces", value)), rest2, encoding)
    case (name, rest) =>
      var (value, rest2) = decodeNext(rest, encoding)
      decodeDict(dict + ((name.asInstanceOf[String], value)), rest2, encoding)
  }

  def decodeNext(data: Seq[Byte], encoding: String): (Any, Seq[Byte]) = data match {
    case 'i' :: rest => decodeInt(0, rest)
    case 'l' :: rest => decodeList(List(), rest, encoding)
    case 'd' :: rest => decodeDict(Map[String, Any](), rest, encoding)
    case 'e' :: rest => (None, rest) // End of dictionary or list.
    case x :: rest if x.toChar.isDigit => decodeString(x - '0', rest, encoding)
  }

  def readBytes(buf: List[Byte]): Seq[Byte] = input.read() match {
    // Read bytes until EOF (-1).
    case -1 => buf.reverse
    case x => readBytes(x.toByte :: buf)
  }

  // Read bytes from file and decode the main dictionary.
  var (data, _) = decodeNext(readBytes(List[Byte]()), "UTF-8")

  // Assign some helpful shortcuts with nicer types.
  val metainfo = data.asInstanceOf[Map[String, Any]]
  val info = metainfo("info").asInstanceOf[Map[String, Any]]
  val announce = metainfo("announce")
  val trackers = for { urls <- metainfo("announce-list").asInstanceOf[List[List[String]]]; url <- urls } yield url
  val pieces = info("pieces").asInstanceOf[String]
}

object Torrent {
  // Use Torrent.load() to load and parse a torrent file.
  def load(filename: String) = new TorrentFile(new FileInputStream(filename))

  // Call on command line as: scala Torrent <filename>
  def main(args: Array[String]) {
    val torrent = Torrent.load(args(0))

    println("Name: " + torrent.info("name"))
    println("Length: " + torrent.info("length"))
    println("Announce: " + torrent.announce)
    for (url <- torrent.trackers) println("Tracker: " + url)
    println("Piece-Length: " + torrent.info("piece length"))
    println("Pieces: " + torrent.pieces.length / 20)
  }
}

Itselläni alkoi muuten jo tökkiä Scalan vahva tyypitys tätä koodatessa. Tyypityksen vuoksi tarvitaan ikäviä asInstanceOf-typecasteja, kun käsitellään geneerisiä tietorakenteita.

Pythonissa voisi sanoa esimerkiksi print(metainfo'info'), mutta Scalassa tarvitaan println(metainfo("info").asInstanceOfMap[String, Any]), jotta tyypitys menee oikein. Tuo henkii juuri sellaista Javamaista monimutkaisuutta, josta haluaisin päästä eroon. Vahvalla tyypityksellä ei edes voiteta mitään, kun objektit luodaan kuitenkin dynaamisesti käyttäjän antaman syötteen mukaan ja ne menevät väärin jos menevät.

Published 30.11.2009