cleanliness is next to domain-specificity
DESCRIPTION
Ben Scofield discusses linguistics, DSLs in Ruby, and how writing domain-specific code improves your softwareTRANSCRIPT
© Copyright 2007 Viget Labs, LLC – www.viget.com4 November 2007
Cleanliness is Next to Domain-SpecificityBen ScofieldSenior DeveloperViget Labs
Part 1: LinguisticsPart 2: Refactoring
The Ruby Community
Interdisciplinaryhttp://www.flickr.com/photos/jesper/1395418767/
Linguistics
Categories
Regional Dialects
(more)
RegionalDialects
Jargons Cants
Pidgins and Creoles
VocabularyGrammar
Ruby Domain-Specific Code
Real DSLs
ActiveRecord
RSpec
Same Grammar,Different Vocabulary
Who Cares?
DSLsIntimidate
and Frighten
DSL
http://www.flickr.com/photos/cwsteeds/58514985/
Write a Parser?No, Thanks.
http://www.flickr.com/photos/rooreynolds/243810988/
http://www.flickr.com/photos/jonosd/498162310/
Change the VocabularyChange the World
Heroes on NBC - Mondays at 9 PM
API vs. Dialect
Why DSanything?
Who Are We?
http://www.oreillynet.com/onlamp/blog/2006/05/sapirwhorf_is_not_a_klingon.htmlhttp://tech.puredanger.com/2006/11/08/does-your-programming-language-affect-how-you-think/http://snakesgemscoffee.blogspot.com/2006_11_01_archive.htmlhttp://talklikeaduck.denhaven2.com/articles/2007/06/11/sapir-whorfhttp://adams.id.au/blog/2007/10/what-is-behaviour-driven-development/http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/75914http://www.weiqigao.com/blog/2007/09/10/an_interesting_experiment_sapir_whorf_hypothesis.htmlhttp://www.ibm.com/developerworks/blogs/page/pmuellr?tag=rubyhttp://intertwingly.net/blog/2007/10/05/NOChttp://brooders.net/category/perl/http://gilesbowkett.blogspot.com/2007/02/sapir-worf-in-action_19.htmlhttp://blogs.msdn.com/daveremy/archive/2005/04/06/sapirwhorfs.aspxhttp://erlangish.blogspot.com/2007/05/shape-of-your-mind.htmlhttp://www.oreillynet.com/onlamp/blog/2006/06/how_does_a_programming_languag.html http://blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/105678
Linguistic Determinism
The Hopi
Linguistic Relativism
Snowqanukkaneqkanevvluknatquiknevlukaniuqanikcaqmuruaneqnutaryukqanisqineqqengarukutvaknavcaqpirtapirtuk...
avalancheblizzarddustingflurryfrosthailhardpackigloo pingo powdersleetslushsnowsnowflakesnowstorm...
http://www.flickr.com/photos/maxhunter/79993854/
Color Perception
http://www.flickr.com/photos/thedeplorableword/140856437/
Direction of CausalityDegree of Influence
RSpec
Sapir-Whorf
Testing is Too Late
Specifications Come First
RSpec Leads You in the Right Direction
DSDs are Built on Linguistic Relativism
Keep Your Head in the Domain
Refactoring
Tastes Vary
?
Finding a Ticket
kayak.com
What Does This Do?
Ruby? Awesome!
#! /usr/bin/rubyrequire 'net/http'require 'rexml/document' require 'uri'
@@port = 80
@@token ='YOUR TOKEN HERE'@@hostname = 'www.kayak.com'
@@sparkleinstance = ''
class Trip def initialize() self.legs = [ ] end attr :price, true attr :url, true attr :legs, trueend # class trip
class Hotel def initialize() end
def to_s() end
attr :name, true attr :stars, true attr :price, true attr :hiprice, true attr :loprice, true attr :url, true attr :phone, true attr :address, true attr :city, true attr :region, true attr :country, trueend # class hotel
class Leg def initialize() end
def to_s() str = '' str << self.airlinecode str << ':' str << self.origin str << '>' str << self.destination str << ':' str << self.depart str << ':' str << self.arrive str << ':' str << self.stops str << ':' str << self.duration str << 'min' end
attr :airlinecode, true attr :depart, true attr :arrive, true attr :stops, true attr :duration, true attr :origin, true attr :destination, trueend # class leg
def getsession(token) sid = nil Net::HTTP.start(@@hostname, @@port) do |http| response = http.get("/k/ident/apisession?token=#{token}") body = response.body xml = REXML::Document.new(body) sid = xml.elements['//sid'].text end return sidend
def start_flight_search(sid, oneway, origin, destination, dep_date, ret_date, travelers) url = "/s/apisearch?basicmode=true&oneway=n&origin=#{origin}&destination=#{destination}&destcode=&depart_date=#{dep_date}&depart_time=a&return_date=#{ret_date}&return_time=a&travelers=#{travelers}&cabin=e&action=doflights&apimode=1&_sid_=#{sid}" return start_search(url)end
def start_hotel_search(sid, citystatecountry, dep_date, ret_date, travelers) csc = URI.escape(citystatecountry) dep_date = URI.escape(dep_date) ret_date = URI.escape(ret_date) url = "/s/apisearch?basicmode=true&othercity=#{csc}&checkin_date=#{dep_date}&checkout_date=#{ret_date}&minstars=-1&guests1=#{travelers}&guests2=1&rooms=1&action=dohotels&apimode=1&_sid_=#{sid}" return start_search(url)end
def start_search(url) searchid = nil Net::HTTP.start(@@hostname, @@port) do |http| response = http.get(url) body = response.body puts body File.open("ksearchid.xml", "w") do |f| f.puts(body) end xml = REXML::Document.new(body) searchid = xml.elements['//searchid'] if searchid searchid = searchid.text else puts "search error:" puts body return nil end end return searchidend
@@results = []@@lastcount = 0
# for debugging only, load results# from a file.def poll_results_file(searchtype) f = File.new("ksearchresults.xml", "r") xmltext = f.read return handle_results(searchtype, xmltext)end
def poll_results(searchtype, sid, searchid, count) url = "" case when searchtype == 'f': url = "/s#{@@sparkleinstance}/apibasic/flight?searchid=#{searchid}&apimode=1&_sid_=#{sid}" when searchtype == 'h': url = "/s#{@@sparkleinstance}/apibasic/hotel?searchid=#{searchid}&apimode=1&_sid_=#{sid}" end more = nil Net::HTTP.start(@@hostname, @@port) do |http| if count url += "&c=#{count}" end response = http.get(url) body = response.body File.open("ksearchbody.xml", "w") do |f| f.puts(body) end more = handle_results(searchtype, body) if more != 'true' # save the body, so we can test without doin # an actual search File.open("ksearchresults.xml", "w") do |f| f.puts(body) end end end return moreend
# process the xml result stringdef handle_results(searchtype, body) xml = REXML::Document.new(body) more = xml.elements['/searchresult/morepending'] @@lastcount = xml.elements['/searchresult/count'].text @@sparkleinstance = xml.elements['/searchresult/searchinstance'].text if more more = more.text end if more != 'true' @@results = [] #puts "count=#{@@lastcount}" if (searchtype == 'f') xml.elements.each("/searchresult/trips/trip") do |e| trip = Trip.new() e.each_element("price") do |t| trip.price = t.text trip.url = t.attribute("url") end e.each_element("legs") do |legs| legs.each_element("leg") do |l| leg = Leg.new l.each_element do |ld| # extract the detail from each leg case when ld.name == 'airline': leg.airlinecode = ld.text when ld.name == 'orig': leg.origin = ld.text when ld.name == 'dest': leg.destination = ld.text when ld.name == 'stops': leg.stops = ld.text when ld.name == 'depart': leg.depart = ld.text when ld.name == 'arrive': leg.arrive = ld.text when ld.name == 'duration_minutes': leg.duration = ld.text end end trip.legs << leg end # leg in legs loop end # legs in trip loop #e.each_element("/searchresult/trips/trip/price") { |p| trip.price = p.text } #puts "trip: #{trip.price}" @@results << trip end # each trip end # if flight search
if (searchtype == 'h') xml.elements.each("/searchresult/hotels/hotel") do |e| hotel = Hotel.new() e.each_element("price") do |t| hotel.price = t.text hotel.url = t.attribute("url") end e.each_element("name") { |t| hotel.name = t.text } e.each_element("address") { |t| hotel.address = t.text } e.each_element("city") { |t| hotel.city = t.text } e.each_element("region_code") { |t| hotel.region = t.text_code } e.each_element("city") { |t| hotel.city = t.text } e.each_element("stars") { |t| hotel.stars = t.text} e.each_element("phone") { |t| hotel.phone = t.text} e.each_element("pricehistoryhi") { |t| hotel.hiprice = t.text} e.each_element("pricehistorylo") { |t| hotel.loprice = t.text} @@results << hotel end # each hotel end # if hotel search end return moreend begin if ARGV.size < 4 || ARGV.size > 5 puts "USAGE:" puts "ksearch f ORIGIN_AIPORT DESTINATION_AIRPORT DEPART_DATE [RETURN_DATE]" puts "ksearch h \"city, RC, CC\" CHECKIN_DATE CHECKOUT_DATE" end
searchtype = ARGV[0]
sid = getsession(@@token); if !sid puts "bad token, sorry" exit 1 end puts "session id = #{sid}" if (searchtype == 'f') searchid = start_flight_search(sid, 'n', ARGV[1], ARGV[2], ARGV[3], ARGV[4], 1) end if (searchtype == 'h') searchid = start_hotel_search(sid, ARGV[1], ARGV[2], ARGV[3], 1) end if !searchid puts "search failed. see error document." exit 1 end puts "search id = #{searchid}" sleep(2)
# now poll results (only gets "top 10" each time) more = poll_results(searchtype, sid, searchid, nil) while more == 'true' do more = poll_results(searchtype, sid, searchid, nil) puts "more to come: #{more} #{@@lastcount} so far" sleep(3) end #one final call to get all results (instead of only 10) #poll_results(searchtype, sid, searchid, @@lastcount) poll_results(searchtype, sid, searchid, 10)
# for load test, skip crap exit 0 @@results.each do |r| if searchtype == 'h' puts "#{r.price} url=#{r.url}" puts "#{r.stars} #{r.name} $#{r.loprice} - $#{r.hiprice}" elsif searchtype == 'f' puts "#{r.price} url=#{r.url}" r.legs.each do |leg| puts " #{leg}" end end end
exit(0) more = poll_results_file(searchtype) @@results.each do |r| puts "#{r.price} #{r.url}" r.legs.each do |leg| puts " #{leg}" end endend
Whoa.
Start at the End
I Want to:find flights from CLT to RDU leaving today
and returning in one week
I Want to:find :flights, :from => :CLT, :to => :RDU, :leaving => Date.today, :returning => Date.today + 7
4840
The Old Waysid = getsession(@@token)searchid = start_flight_search(sid, ‘n’, ‘CLT’, ‘RDU’, Date.today, nil, 1)
more = poll_results('f', sid, searchid, nil)while more == 'true' do more = poll_results('f', sid, searchid, nil) sleep(3)end
def poll_results(searchtype, sid, searchid, count) url = "/s#{@@sparkleinstance}/apibasic/flight?searchid=#{searchid}&apimode=1&_sid_=#{sid}" more = nil Net::HTTP.start(@@hostname, @@port) do |http| if count url += "&c=#{count}" end response = http.get(url) body = response.body File.open("ksearchbody.xml", "w") do |f| f.puts(body) end more = handle_results(searchtype, body) if more != 'true' # save the body, so we can test without doin # an actual search File.open("ksearchresults.xml", "w") do |f| f.puts(body) end end end return moreend
4840
The Old Way, cont.def handle_results(searchtype, body) xml = REXML::Document.new(body) more = xml.elements['/searchresult/morepending'] @@lastcount = xml.elements['/searchresult/count'].text @@sparkleinstance = xml.elements['/searchresult/searchinstance'].text if more more = more.text end if more != 'true' @@results = [] #puts "count=#{@@lastcount}" xml.elements.each("/searchresult/trips/trip") do |e| trip = Trip.new() e.each_element("price") do |t| trip.price = t.text trip.url = t.attribute("url") end e.each_element("legs") do |legs| legs.each_element("leg") do |l| leg = Leg.new l.each_element do |ld| # extract the detail from each leg case when ld.name == 'airline': leg.airlinecode = ld.text #... end end trip.legs << leg end # leg in legs loop end # legs in trip loop #e.each_element("/searchresult/trips/trip/price") { |p| trip.price = p.text } #puts "trip: #{trip.price}" @@results << trip end # each trip end return moreend
40
Outputsession_url = "/k/ident/apisession?token=#{token}"
search_url = "/s/apisearch?basicmode=true&oneway=n&origin=#{origin}&destination=#{destination}&destcode=&depart_date=#{dep_date}&depart_time=a&return_date=#{ret_date}&return_time=a&travelers=#{travelers}&cabin=e&action=doflights&apimode=1&_sid_=#{sid}"
results_url = "/s#{@@sparkleinstance}/apibasic/flight?searchid=#{searchid}&apimode=1&_sid_=#{sid}"
40
Expectationsclass KayakTest < Test::Unit::TestCase def test_find_should_call_out_to_session_endpoint setup_mocks_for_find Kayak.find :flights end private def setup_mocks_for_find response = mock(:body => '<?xml version="1.0"?> <ident> <uid>uid</uid> <sid>0123456789</sid> <token>12345</token> <error></error> </ident>') success = mock() success.expects(:get).with('/k/ident/apisession?token=12345').returns (response) Net::HTTP.expects(:start).at_least_once.yields(success) endend
40
Parsing Responsesclass KayakTest < Test::Unit::TestCase def test_session_response_should_be_parsed_for_session_id setup_mocks_for_find Kayak.find :flights assert_equal '0123456789', Kayak.session_id end private def setup_mocks_for_find response = mock(:body => '<?xml version="1.0"?> <ident> <uid>uid</uid> <sid>0123456789</sid> <token>12345</token> <error></error> </ident>') success = mock() success.expects(:get).with('/k/ident/apisession?token=12345').returns (response) Net::HTTP.expects(:start).at_least_once.yields(success) endend
class Kayak @@session_id = nil @@search_id = nil @@search_options = {} TOKEN = '12345' HOSTNAME = 'www.kayak.com' PORT = 80
class << self def session_id @@session_id end def method_missing(name, *args) @@search_options[name] end def find(type, conditions = {}) session_id ||= initialize_session @@search_options[:origin] = conditions[:from] @@search_options[:destination] = conditions[:to] @@search_options[:depart_date] = conditions[:leaving].strftime('%m/%d/%Y') if conditions[:leaving] @@search_options[:leave_date] = conditions[:returning].strftime('%m/%d/%Y') if conditions[:returning]
search_id ||= initialize_search self end def initialize_search Net::HTTP.start(HOSTNAME, PORT) do |http| response = http.get("/s/apisearch?basicmode=true&oneway=n&destcode=&depart_time=a&... if body = response.body xml = REXML::Document.new(body) @@search_id = xml.elements['//searchid'].text end end end
def initialize_session Net::HTTP.start(HOSTNAME, PORT) do |http| response = http.get("/k/ident/apisession?token=#{TOKEN}") if body = response.body xml = REXML::Document.new(body) @@session_id = xml.elements['//sid'].text end end end end end
First Cut
module Kayak TOKEN = '12345' HOSTNAME = 'www.kayak.com' PORT = 80
class Flight attr_accessor :session, :search_id, :search_options
def method_missing(name, *args) search_options[name] end
def initialize(conditions = {}) session ||= Kayak::Session.new self.search_options = {} self.search_options[:origin] = conditions[:from] self.search_options[:destination] = conditions[:to] self.search_options[:depart_date] = conditions[:leaving].strftime('%m/%d/%Y') if conditions[:leaving] self.search_options[:return_date] = conditions[:returning].strftime('%m/%d/%Y') if conditions[:returning]
Net::HTTP.start(HOSTNAME, PORT) do |http| response = http.get("/s/apisearch?basicmode=true&oneway=n&destcode=&depart_time=a&return_date=... self.search_id = Kayak.retrieve(response, '//searchid') end end
def self.find(args) self.new(args) end end class Session attr_accessor :session_id
def initialize Net::HTTP.start(Kayak::HOSTNAME, Kayak::PORT) do |http| response = http.get("/k/ident/apisession?token=#{Kayak::TOKEN}") self.session_id ||= Kayak.retrieve(response, '//sid') end end end
def self.find(type, *args) case type when :flights Kayak::Flight.find(*args) end end
...
Second Cut
I Want to:find :flights, :from => :CLT, :to => :RDU, :leaving => Date.today, :returning => Date.today + 7
Third Cut
?
Always Room for Improvement
Tips
:symbolignore the colon
I Want to:find :flights, :from => :CLT, :to => :RDU, :leaving => Date.today, :returning => Date.today + 7
Optional Parentheseslooks like a sentence
I Want to:find :flights, :from => :CLT, :to => :RDU, :leaving => Date.today, :returning => Date.today + 7
Blocks for AllIn other languages, you have to specify explicitly that a function can accept another function as an argument. But in Ruby, any method can be called with a block as an implicit argument.
Matz, 2003
*arrays from anything
Optional Bracesnot that common
I Want to:find :flights, :from => :CLT, :to => :RDU, :leaving => Date.today, :returning => Date.today + 7
•Start Modestly• Stay in the Domain•Get Better
56
That’s Ittravel safely
© Copyright 2007 Viget Labs, LLC – www.viget.com4 November 2007
http://www.extendviget.com/http://www.culann.com/