Opus Polyglotis II: Ruby


onsdag 22. februar 2012 Polyglot Ruby

act2

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:

ruby_objects

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

Oppsummering

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! ;)


comments powered by Disqus