onsdag 22. februar 2012 Polyglot Ruby
OPUS POLYGLOTIS II, Act 2
Tagline: "Noen har lest Gang of Four"
På scenen: Ruby
I forrige post så du en Python-løsning som var grei nok, men den hadde et par problem. Koden manglet struktur – det var bare en rekke funksjoner i toppnivået. Og for å legge til flere formater, en endring man må kunne forvente, måtte man modifisere dispatch-koden. Det er noe man generelt bør unngå.
I dag presenterer jeg en løsning på den samme oppgaven i Ruby. Den er objektorientert, og benytter et kjent design pattern for å fikse de nevnte problemene.
La oss begynne å kode. Jeg lager en modul jeg kaller OpusPolyglotis. I den lager jeg en ny modul jeg rett og slett kaller Template.
22 module OpusPolyglotis 23 24 module Template 25 def initialize headers 26 @headers = headers 27 end 28 def header ; '' ; end 29 def row r ; '' ; end 30 def footer ; '' ; end 31 def all_rows rs 32 rs.map{|r| row r}.join '' 33 end 34 end
Template danner basisen for et format. Den har en konstruktør som tar inn headers fra csv-filen, og flere metoder som vil bli brukt til å generere output. header, row og footer har default-implementasjone som bare returnerer en tom streng.
PS: Det er kanskje ikke akkurat Template Method Pattern dette her, men måten jeg bruker det på er ikke så forferdelig langt unna heller.
Vi er fortsatt inne i OpusPolyglotis-modulen, og nå kommer et lite, ideomatisk Ruby-triks som oppretter en variabel i selve OpusPolyglotis. Du kan tenke på formatters omtrent som en statisk variabel i en C#-klasse. Her vil jeg etterhvert laste inn de ulike format-definisjonene.
36 class << self 37 attr_accessor :formatters 38 end 39 self.formatters = {}
Fortsatt i OpusPolyglotis oppretter jeg så en klasse jeg kaller Formatter. Dette er selve kjernen i programmet. I konstruktøren leser jeg en csv-fil ved hjelp av Ruby's utmerkede csv-støtte. Metoden format_as velger ut en format-definisjon fra formatters-variabelen, oppretter en instans av den, og produserer resultatet. Som du skjønner forventer denne koden at formatters inneholder klasser som implementerer metodene jeg definerte i Template-modulen.
41 require 'csv' 42 43 class Formatter 44 def initialize csvfile 45 data = CSV.read csvfile, :col_sep => ';' 46 @headers = data.first 47 @rows = data.drop 1 48 end 49 50 def format_as key 51 formatter_class = OpusPolyglotis.formatters[key] 52 f = formatter_class.new @headers 53 f.header + f.all_rows(@rows) + f.footer 54 end 55 end 56 57 end # module OpusPolyglotis
La meg bruke et diagram til å illustrere hva jeg har laget og hvor jeg vil hen:
Jeg kommer til å opprette tre nye klasser som implementerer hvert av formatene jeg skal støtte. Disse vil arve egenskapene til Template-modulen – ikke gjennom klassisk arv; formatene vil bruke Template som en mixin (ikke at det er sentralt for løsningen, men det er ganske praktisk likevel).
De forskjellige formatene vil også registrere seg selv i formatter-variabelen. Dermed trenger jeg ikke endre dispatch-koden i Formatter-klassen, som jo bare henter ut definisjonen fra denne variabelen.
La oss ta en titt på den enkleste format-definisjonen:
59 module OpusPolyglotis 60 class YamlFormatter 61 include Template 62 def header 63 'Yaml not yet implemented!' 64 end 65 end 66 67 self.formatters['yaml'] = YamlFormatter 68 end
Som du ser "åpner jeg opp igjen" OpusPolyglotis-modulen, slik at jeg får plassert format-definisjonen i det "namespacet". Dette er ikke essensielt, men ryddig. Jeg lager en klasse som så inkluderer Template. Jeg overstyrer en av funksjonene fra Template, nemlig header, som nå bare vil returnere "YAML not yet implemented!".
Og så til den sentrale linjen under klassedefinisjonen: Her legger jeg inn selve klassen YamlFormatter som en verdi i formatters, og bruker "yaml" som nøkkel. Neat! Nå kan jeg hente ut de definerte formatene hvor som helst og studere dem ved å si OpusPolyglotis.formatters.
Jeg tror du skjønner hvordan det henger sammen nå, og jeg kan vise deg XmlFormatter:
73 module OpusPolyglotis 74 class XmlFormatter 75 include Template 76 77 def header; "<records>" ; end 78 def footer; "\n</records>" ; end 79 80 def row r 81 "\n\t<record>#{ 82 @headers. 83 map{|h| h.gsub /\ /, '_' }. 84 zip(r). 85 map{|kv| "\n\t\t<#{kv[0]}>#{kv[1]}</#{kv[0]}>"}. 86 join("") 87 }\n\t</record>" 88 end 89 end 90 91 self.formatters['xml'] = XmlFormatter 92 end
Denne gangen overstyrer jeg header- og footer-metodene til å returnere henholdsvis start- og slutt-tag for XML-dokumentet. Og så bruker jeg metoder jeg er veldig glad i – map, zip og join – til å lage et record-element i row-metoden. Ser du hvor enkel og elegant formatdefinisjonen har blitt fordi jeg baserer meg på en felles Template modul?
Zip er forresten en metode jeg kanskje ikke har snakket om før. Dictionary.com sier: "A function that takes two lists and returns a list of pairs. The idea can easily be extended to take N lists and return a list of N-tuples." Jeg bruker den til å "pare opp" en og en header med de tilhørende feltverdiene i raden. Zip er en kjekk funksjon fra den funksjonelle paradigmen, men ikke så vanlig som map, filter og fold.
Og så – hvis du henger med fortsatt – tar vi en titt på implementasjonen av JSON-formatet:
100 module OpusPolyglotis 101 class JsonFormatter 102 include Template 103 104 def header; '[' ; end 105 def footer; ']' ; end 106 107 def row r 108 '{' + get_fields(r) + "}" 109 end 110 111 def get_fields r 112 (@headers.zip(r).map do |key_val| 113 '"' + key_val[0] + '":' + format_value(key_val[1]) 114 end).join ', ' 115 end 116 117 def format_value v 118 return v if /^\d+.?\d*$/ =~ v 119 return '"' + v + '"' 120 end 121 122 # override all_rows to add newline between rows 123 def all_rows rs 124 rs.map{|r| row r}.join "\n" 125 end 126 end 127 128 self.formatters['json'] = JsonFormatter 129 end
Ganske likt som for XML egentlig: zip, map og join bygger opp den nødvendige strengen. Som jeg gjorde i Python-løsningen må jeg også her validere om en verdi er et gyldig tall (metoden format_value). I så fall skal det ikke ha hermetegn.
Legg merke til at jeg også overstyrer Template's all_rows-metode, noe jeg ikke trengte i XML. Dette gjør jeg bare for at output skal bli litt finere – jeg putter nemlig inn et linjeskift mellom JSON-objektene.
Det eneste som gjenstår nå er plukke opp argumentene til programmet, og å generere og printe ut formatet. I Ruby lager vi typisk ikke main-metoder, men konstruerer et lite idiom som sjekker om det er denne filen som har blitt eksekvert (de magiske __FILE__ og $0 variablene). Og så er vi i mål!
131 if __FILE__ == $0 132 some_format, csvfile = ARGV 133 formatter = OpusPolyglotis::Formatter.new csvfile 134 puts formatter.format_as some_format 135 end
Det ble litt mere kode enn i act 1, men det ble en fin og anvendelig struktur. Strategiene (ja, dette er Strategy Pattern) er self contained, og nye formater kan legges til helt uten å redigere eksisterende kode. Bruken av en Template-modul til strategiene gjorde også at selve koden for å generere de ulike formatene ble ganske ryddig.
Jeg er altså ganske fornøyd med denne løsningen. Følg med i neste episode, hvor det godt kan hende jeg roter det litt til igjen! ;)