torsdag 1. mars 2012 Clojure Polyglot XML
OPUS POLYGLOTIS II, Act 4
Tagline: "Groteskt vakkert!"
På scenen: Clojure
Jeg har presentert 3 ulike programmer som gjør det samme - i Python, Ruby og Rebol – og her kommer den aller siste implementasjonen for denne gang.
Clojure er et herlig språk, men det kan være vanskelig å lese koden – spesielt når man ikke har skrevet den selv. Jeg vil likevel anbefale deg som er interessert i programmeringsspråk å ta en titt, og kanskje forsøke å sammenligne fremgangsmåten med de andre løsningene. Men jeg kommer ikke til å forklare alle detaljene, så hvis du vil at jeg skal utdype noe av koden ytterligere får du legge igjen en kommentar.
I denne løsningen har jeg valgt å bruke multimethods for dispatch, dvs. for å velge hvilken strategi jeg skal bruke (les introduksjonen om du ikke vet hva programmet skal gjøre). Multimethods brukte jeg også nylig i artikkelen Template Method del 4: Multippel arv, og lengre tilbake i tid viste jeg teknikken i posten 1-2-3 Dispatch.
Jeg definerer også et par datatyper med DEFRECORD, og det er en av disse datatypene multimetodene fungerer på. I prinsippet er denne formen for dispatch det samme som jeg implementerte i Ruby og Rebol, bare at det er innbakt i språket, og at jeg dermed slipper å gjøre så mye av jobben selv.
Clojure-koden viser også litt interop med noen Java-klasser; jeg bruker java.util.Scanner til å sjekke om en streng er et tall (muligens overkill, men jeg hadde lyst til å teste dette objektet som jeg aldri før hadde benyttet).
Og så har jeg laget en aldri så liten regex table lexer til å parse CSV-filen. Geeks synes det er gøy med lexing!
Først deklarerer jeg et namespace, og inkluderer bibloteker jeg trenger i programmet:
22 (ns polyglotis 23 (:use clojure.contrib.json) 24 (:require clojure.string) 25 (:require [clojure.xml :as xml]) 26 (:import [java.util Scanner Locale]))
Og så definerer jeg datatypene jeg skal jobbe med:
28 (defrecord OpusData [headers records]) 29 30 (defrecord OpusFormatResult [format data output])
Så kommer lexeren. Og dette er ganske vakkert, synes jeg selv. TOKENIZE splitter opp en semikolonseparert tekststreng ved hjelp av tre regulære uttrykk:
32 (defn get-first-token [s] 33 (first (or (re-seq #"^([^;\"]+)" s) ; unqouted 34 (re-seq #"^\"([^\"]*)\"" s) ; quoted 35 (re-seq #"^;" s)))) ; separator 36 37 (defn tokenize 38 ([input] (tokenize input [])) 39 ([input tokens] 40 (if-let [token (get-first-token input)] 41 (if (= token ";") 42 (recur (subs input 1) 43 tokens) 44 (recur (subs input (-> token first count)) 45 (conj tokens (second token)))) 46 tokens)))
Jeg trenger også en liten funksjon som kan splitte linjer; igjen bruker jeg regex, og sørger for å håndtere både unix og windows-format:
48 (defn split-lines [text] 49 (clojure.string/split text #"\r?\n"))
Nå bruker jeg alt det jeg har laget til å definere en funksjon som tar inn et filnavn, leser filen, splitter den opp i linjer, splitter linjene opp i tokens, og returnerer en OpusData-instans:
51 (defn get-csv-data [filename] 52 (let [data (->> filename ; filename 53 slurp ; Read the file content 54 split-lines ; Split on rows/newline 55 (map tokenize))] ; Split on fields 56 57 ; Create record of headers and rows 58 (OpusData. (first data) (rest data))))
Hvis jeg nå evaluerer (get-csv-data "data.csv") i REPL'en, vil det gi følgende output (data.csv er den testfilen jeg har brukt gjennom hele denne bloggserien):
60 ; (get-csv-data "data.csv") 61 #:polyglotis.OpusData{ :headers ["Field 1" "Field 2" "Field 3"], 62 :records (["Value 1" "Value 2" "123"] 63 ["Value A" "Value B" "456"] 64 ["Value x" "Value y;;" "789.45"])}
Nå har vi kommet til multimetodene. Først definerer jeg APPLY-FORMAT. Når denne blir kalt så vil den velge en funksjon basert på :format-verdien til argumentet sitt (argumentet kommer til å være av typen OpusFormatRecord, definert tidligere):
69 (defmulti apply-format :format)
Implementasjonen av det første konkrete formatet ser slik ut:
71 (defmethod apply-format :yaml [csv] 72 (assoc csv :output "YAML not yet supported!"))
YAML-metoden setter :output-feltet i csv-objektet til "YAML not yet supported".
Da er vi klare for litt mere funky greier – her er definisjonen av XML-formatet. Begynn med å lese kommentarlinjene mot slutten av methode, og se om du kan nøste deg bakover og forstå hvordan det fungerer:
75 (defmethod apply-format :xml [csv] 76 (let [headers (->> csv :data :headers 77 (map #(.replace % \space \_))) 78 79 xml-str #(with-out-str (xml/emit-element %)) 80 81 <tag> (fn [k v] 82 {:tag (if (keyword? k) k (keyword k)) 83 :content (if (seq? v) (vec v) [v])}) 84 85 row-<tag> #(->> % (map <tag> headers) 86 (<tag> :record))] 87 88 (->> csv :data :records ; Pluck rows from CSV 89 (map row-<tag>) ; Create tags from them 90 (<tag> :records) ; Wrap in a <records> tag 91 xml-str ; Convert to a string 92 (assoc csv :output)))) ; Update output field
Clojure har ganske grei støtte for å konvertere hash-maps (disctionaries, om du foretrekker det navnet) til XML, og dette benytter jeg her.
Og da har vi bare ett format igjen, nemlig JSON:
100 (defmethod apply-format :json [csv] 101 (let [headers (-> csv :data :headers reverse) 102 103 ; Fn: Coerse to Int or Float if possible 104 coerse-num #(let [s (Scanner. %)] 105 (.useLocale s (Locale. "en" "US")) 106 (cond 107 (.hasNextInt s) (.nextInt s) 108 (.hasNextFloat s) (.nextFloat s) 109 :else %)) 110 111 ; Fn: For all rows, for all fields, coerse num 112 coerse-num-all (partial map 113 (partial map coerse-num)) 114 115 ; Fn: Convert seq into map with headers as keys 116 mapify-row (comp (partial zipmap headers) reverse) 117 118 ; Fn: Convert all seqs to maps 119 mapify-row-all (partial map mapify-row)] 120 121 (->> csv :data :records ; take the rows 122 coerse-num-all ; coerse the numbers 123 mapify-row-all ; make maps 124 json-str ; convert maps to json 125 (assoc csv :output)))) ; update output field
Clojure har enda bedre støtte for JSON enn for XML. Igjen konverterer jeg dataene mine til hash-maps, og funksjone JSON-STR konverterer hash-maps til JSON. Dette er en generell teknikk innen funksjonell programmering: Omstrukturer dataene dine til noe som passer for det du skal løse, og så blir selve løsningen ganske enkel.
Den siste funksjonen jeg definerer knytter sammen alt jeg har laget til nå. Den oppretter et OpusFormatRecord-objekt hvor dataene fra en CSV-fil mates inn vha. GET-CSV-DATA funksjonen. Jeg kaller APPLY-FORMAT, som vil kalle den riktige format-metoden, plukker resultatet og skriver det ut.
129 (defn main [out-format csv-file] 130 (->> 131 ; Create a record with CSV data and format info 132 (OpusFormatResult. (keyword out-format) 133 (get-csv-data csv-file) 134 nil) 135 136 apply-format ; Dispatch to correct format def. 137 :output ; Pluck the output string 138 println)) ; Print it
Til slutt kaller jeg MAIN og sender inn kommandolinje-argumentene.
141 ; Kick it all off !!! 142 (main (first *command-line-args*) 143 (second *command-line-args*))
Og det var det :P
Multimetoder er ideelt for denne typen dispatch mellom ulike strategier, men bortsett fra det kan jeg ikke peke på noen andre fordeler med Clojure. Annet enn at det er veldig gøy å kode Clojure da!
Da jeg implementerte dette her gikk forresten koden gjennom ganske mange refaktureringer. Selv den enkleste Clojure-funksjonen kan skrives på hundrevis av ulike måter, og det er vanskelig å bestemme seg for hva man liker best, hva som kommuniserer godt, hva som er enklest å vedlikeholde og så videre. Jeg skrev litt mer om dette problemet i Er Lisp's fleksibilitet et problem? tidligere i år.
Nå tror jeg vi lar dette polyglotiske opuset ligge. Det har sansynligvis vært mer interessant for meg selv enn for mine lesere, men jeg håper(?) noen har satt pris på å få se noen tilnærmet fullverdige men likevel små program skrevet i diverse språk.
Hei då!