Hopp til innholdet

cjohansen.no

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:

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?

Muligens relatert

2006 - 2012 Christian Johansen Creative Commons Lisens