Enda litt mer Ruby-templating
Mer alternativ templating for Ruby og Rails: Denne gangen er det snakk om Liquid - et simplere templating-system for Ruby hvis målgruppe er sluttbrukere og "designere".
I motsetning til DRYML, #haml og andre varianter er ikke Liquid en løsning hvis mål er å erstatte Erb som templatingsystem, ihvertfall ikke sånn jeg forstår det. Det Liquid derimot tilbyr - som de andre løsningene ikke gjør - er et template-system som er trygt nok til å eksponere for sluttbrukere. Dette gjør det ypperlig for templates som det er behov for å endre gjennom et admin-grensensnitt eller lignende.
Mission statement
Fordi den er så god tenkte jeg at vi kunne starte med utviklerens egen motivasjon for Liquid. Hvorfor skal du bruke Liquid?
- You want to allow your users to edit the appearance of your application but don’t want them to run insecure code on your server.
- You want to render templates directly from the database
- You like Smarty-style template engines
- You need a template engine which does HTML just as well as emails
- You don’t like the markup of your current template engine language
Som nevnt over så har Liquid en syntaks som ligner mye på Smartys så vel som template-språket som benyttes i publiseringssystemet eZ Publish:
# array = %w{a, b, c, d, e, f}
{% for item in array limit:2 offset:2 %}
{{ item | upcase }}
{% endfor %}
# Resultat: C D
Bruksområde: E-post
Jeg ble tipset om Liquid da jeg lette etter en løsning på følgende problem: En applikasjon sender et kurant antall e-post til sine brukere, og kunden som eier applikasjonen ønsker fra tid til annen å justere formattering, innhold osv for disse e-postene. Siden e-postene ofte refererer brukerdata og annet trenger altså kunden/sluttbruker å kunne jobbe med mail-templates.
Hvorfor ikke Erb? Eller #haml osv?
I dette tilfellet er det usmart å gi brukerne tilgang til et fullverdig templating-system fordi det er så mye som kan gå galt. Hvis vi er i en Rails-kontekst, hvordan passer du på at en kjip redaktør ikke logger seg inn og legger igjen følgende mailtemplate?
<% User.delete_all %>
Vel, det gjør du ikke, og derfor er det ikke så smart å åpne bakdøra for hvem som helst.
Liquid tilbyr en slags sandkasse der du kan:
- Bruke enkle kontroll-statements
- Aksessere strenger, arrayer, hasher, booleans og numeriske verdier
- Aksessere data i andre objekter (feks ActiveRecord-modellobjekter) etter egen spesifikasjon
Dette betyr at Liquid byr på et miljø som i utgangspunktet er stengt, og som du kan åpne tilgang til etter eget forgodtbefinnende.
Eksempel
La oss se på et fullstendig eksempel, i konteksten av en Rails-applikasjon. Et system har konti, hver konto har prosjekter, og hvert prosjekt har klienter. Enkelt og (antar jeg) velkjent scenario. For enkelte hendelser ønsker vi at systemet skal sende ut e-post til klientene, og kontoeierene skal ha tilgang til å tweake templatene for e-postene, lage nye osv.
Installasjon og oppsett
Først må vi installere Liquid:
ruby script/plugin install liquid
Deretter må du eksponere data for liquid-templates. Den enkleste måten å gjøre dette på er å bruke makroen liquid_methods i ActiveRecord::Base-klassene dine. Denne eksponerer de navngitte metodene for bruk i templates:
class Person < ActiveRecord::Base
liquid_methods :name, :email
end
Denne metoden fungerer greit om du bare skal eksponere noen få metoder ut i templates. Noe mer kontroll får du ved å implementere en to_liquid-metode som returnerer en hash:
class Person < ActiveRecord::Base
has_many :addresses
# ...
def to_liquid
{ :name => name, :email => email, :addresses => addresses }
end
end
Merk at siden addresses er en relasjon vil hash-en vår inneholde en array, men objektene i arrayen er nødt til å implementere to_liquid, eller benytte liquid_methods som over for å kunne refereres.
Vel og bra, men å tilrettelegge for templates i domenemodellen er litt dirty, er det ikke? Med unntak av helt simple tilfeller syns jeg det. Heldig er det da at vi kan implementere vår egen
Liquid::Drop, for eksempel som dette (sakset fra dokumentasjonen):
class ProductDrop < Liquid::Drop
def top_sales
Shop.current.products.find(:all, :order => 'sales', :limit => 10 )
end
end
Med andre ord - fleksibel eksponering for Liquid, og pent adskilt fra domenemodellen.
Templatefiler: .liquid
Etter å ha installert pluginen kan du nå benytte Liquid som ditt template-system, ved å gi filene endelsen .liquid. Enkelt og greit. I vårt eksempel ser vi for oss at sluttbrukere av systemet skal kunne endre og legge til templates etter eget forgodtbefinnende, og da er kanskje ikke filsystemet beste plass å hive templatene på.
Template-strenger fra databasen
Heldigvis er det lett å laste templates fra strenger. Ved å stappe templatene i databasen kan vi jobbe med dem som alle andre AR-objekter, og vi kan fange opp synaksfeil i templatene som valideringsfeil.
En EmailTemplate-klasse
Følgende er en klasse som tilbyr database-backa email-templates, med Liquid i bånn. Noen raske målinger tilsa at det er bedre å parse templaten for så å lagre den serialisert i databasen enn å parse templaten hver gang du henter den fra databasen:
require 'benchmark'
n = 50000
template = "En kort tekst med få oppslag ala {{ user.name }}"
serialized = Marshal.dump(Liquid::Template.parse(template))
Benchmark.bm do |x|
x.report("deserialize: ") { n.times { Marshal.load(serialized) } }
x.report("parse: ") { n.times { Liquid::Template.parse(template) } }
end
user system total real
deserialize: 1.710000 0.030000 1.740000 ( 1.733290)
parse: 13.800000 0.300000 14.100000 ( 14.119359)
Klassen prøver å økonomisere ved å kun parse templaten (eller deserialisere, alt ettersom) én gang for hver gang templaten hentes fra databasen, eller oppdateres. Dette oppnår den ved å cache et brukbart template-objekt i @template.
# == Schema Information
# Schema version: 20081029222305
#
# Table name: email_templates
#
# id :integer(11) not null, primary key
# subject :string(255) not null
# from :string(255) not null
# bcc :string(255)
# cc :string(255)
# body :text not null
# template :text
#
class EmailTemplate < ActiveRecord::Base
### Validation
validates_presence_of :subject, :from, :body
#
# Puts the parse error from Liquid on the error list if parsing failed
#
def after_validation
errors.add :template, @syntax_error unless @syntax_error.nil?
end
### Attributes
attr_protected :template
#
# body contains the raw template. When updating this attribute, the
# email_template parses the template and stores the serialized object
# for quicker rendering.
#
def body=(text)
self[:body] = text
begin
@template = Liquid::Template.parse(text)
self[:template] = Marshal.dump(@template)
rescue Liquid::SyntaxError => error
@syntax_error = error.message
end
end
### Methods
#
# Delivers the email
#
def deliver_to(address, options = {})
options[:cc] ||= cc unless cc.blank?
options[:bcc] ||= bcc unless bcc.blank?
# Liquid doesn't like symbols as keys
options = options.inject({}) { |h,(k,v)| h[k.to_s] = v; h }
ApplicationMailer.deliver_email_template(address, self, options)
end
#
# Renders body as Liquid Markup template
#
def render(options = {})
template.render options
end
#
# Usable string representation
#
def to_s
"[EmailTemplate] From: #{from}, '#{subject}': #{body}"
end
private
#
# Returns a usable Liquid:Template instance
#
def template
@template unless @template.nil?
if self[:template].blank?
@template = Liquid::Template.parse body
self[:template] = Marshal.dump @template
save
else
@template = Marshal.load self[:template]
end
@template
end
end
Merk at vi dobbeltlagrer templaten - en gang som serialisert streng (som raskt kan gjenopprettes som en Liquid::Template) og en gang i klartekst, så brukeren kan endre på den seinere.
Denne kan vi bruke så her:
account = Account.find(1)
template = EmailTemplate.new({
:subject => "Nytt passord",
:from => "meg@minsjappe.com",
:body => "Ditt nye passord er {{account.generated_password}}"
})
template.deliver_to(account.email, { :account => self })
# Alternativt kunne man snudd det på hodet og implementert en deliver i account:
# account.deliver(template)
Det ville ikke vært noe problem å flytte de mail-spesifikke detaljene i klassen over ut i en MailTemplate-modul av noe slag som kunne inkluderes i ActiveRecord::Base-baserte klasser så vel som andre typer klasser som kanskje ble backet av fillagring fremfor database osv.
Jeg ble veldig happy da jeg fant Liquid ettersom det beviselig løser slike type oppgaver veldig veldig godt. Flere som har erfaringer med det?