Lingo: un cadru de microlimbi Go pentru construirea de limbaje specifice domeniului

Limbile specifice domeniului (DSL) sunt limbi mici, concentrate, cu un domeniu îngust de aplicabilitate. DSL-urile sunt adaptate pentru domeniul lor țintă, astfel încât experții în domeniu să poată oficializa idei pe baza cunoștințelor și a experienței lor.

Acest lucru face ca DSL-urile să fie instrumente puternice care pot fi utilizate în scopul creșterii eficienței programatorului, fiind mai expresive în domeniul lor țintă, în comparație cu limbajele de uz general și prin furnizarea de concepte pentru a reduce sarcina cognitivă a utilizatorilor lor.

Luați în considerare problema însumării soldurilor diferitelor conturi bancare într-un fișier CSV. Un exemplu de fișier CSV este furnizat în exemplul de mai jos, unde prima coloană conține numele titularului de cont, iar a doua coloană conține soldul contului.

 name, balance Lisa, 100.30 Bert, 241.41 Maria, 151.13

Puteți rezolva problema însumării soldurilor folosind un limbaj de uz general, cum ar fi Ruby , ca în fragmentul de cod de mai jos. În afară de faptul că codul de mai jos nu este foarte robust, conține o mulțime de boilerplate care sunt irelevante pentru problema în cauză, adică însumarea soldurilor contului.

 #!/usr/bin/env ruby exit ( 1 ) if ARGV . empty? || ! File . exist? ( ARGV [ 0 ]) sum = 0 File . foreach ( ARGV [ 0 ]). each_with_index do | line , idx | next if idx == 0 sum += Float ( line . split ( ',' )[ 1 ]) end puts sum . round ( 2 )

Mai jos este un exemplu de script AWK care rezolvă aceeași problemă. AWK este un DSL care a fost special conceput pentru a rezolva problemele legate de procesarea textului.

 #!/usr/bin/awk -f BEGIN { FS = "," }{ sum += $2 } END { print sum }

Programul Ruby are o dimensiune de 208 de caractere, în timp ce programul AWK are o dimensiune de 56. Programul AWK este de aproximativ 4 ori mai mic decât omologul său Ruby. În plus, implementarea AWK este mai robustă, fiind mai puțin predispusă la erori care pot apărea în fișierul CSV (de exemplu, linii noi goale, câmpuri de date formatate greșit). Diferența semnificativă în ceea ce privește dimensiunea ilustrează faptul că DSL-urile, fiind mai concentrate pe rezolvarea unor probleme specifice, își pot face utilizatorii mai productivi, scutindu-i de sarcina de a scrie cod standard și îngustând focalizarea limbajului asupra problemei în cauză.

Unele DSL-uri populare pe care majoritatea dezvoltatorilor de software le folosesc în mod regulat includ expresii regulate pentru potrivirea modelelor, AWK pentru transformarea textului sau limbajul standard de interogare pentru interacțiunea cu bazele de date.

Provocări la proiectarea limbilor specifice domeniului

Crearea de prototipuri, proiectarea și evoluția DSL-urilor este un proces provocator. Din experiența noastră, acesta este un ciclu explorator în care prototipați în mod constant ideile, le încorporați în limbaj, le încercați în realitate, colectați feedback și îmbunătățiți DSL-ul pe baza feedback-ului.

Atunci când proiectați un DSL, există multe componente care trebuie implementate și evoluate. La un nivel foarte înalt există două componente principale: lexer/parserul de limbă și procesorul de limbaj. Lexer/parser-ul este componenta care acceptă intrarea conform definiției limbii, care este de obicei specificată prin intermediul unei gramatici a limbii. Faza de analizare/lexare produce un arbore de sintaxă care este apoi transmis procesorului de limbaj. Un procesor de limbaj evaluează arborele de sintaxă. În exemplul pe care l-am văzut mai devreme, am rulat atât interpretele Ruby, cât și AWK, furnizând scripturile noastre și fișierul CSV ca intrare; ambii interpreți au evaluat scripturile și această evaluare a rezultat suma tuturor soldurilor contului.

Instrumente precum generatoarele de analizoare pot reduce semnificativ efortul de dezvoltare a lexer/parserului prin generarea de cod. Cadrele DSL sofisticate, cum ar fi JetBrains MPS sau Xtext , oferă, de asemenea, caracteristici care ajută la implementarea suportului pentru limbaj personalizat în IDE-uri. Cu toate acestea, dacă este prezent, suportul pentru construirea procesoarelor de limbaj se limitează de obicei la generarea de funcții de substituenți sau cod standard pentru componentele de limbaj care trebuie completate de către dezvoltatorul DSL. Mai mult, cadrele DSL atât de mari și puternice au, de obicei, o curbă de învățare destul de abruptă, astfel încât probabil că se potrivesc mai bine pentru DSL-uri mai sofisticate, spre deosebire de limbi mici, ușor de încorporat, concentrate, la care numim microlimbi .

În unele situații, poate merita să luați în considerare rezolvarea acestor probleme bazându-vă doar pe formatele standard de schimb de date, cum ar fi .toml , .yaml sau .json ca mijloc de configurare. Similar cu generatoarele de parser, utilizarea unui astfel de format poate ușura o parte din sarcina atunci când vine vorba de efortul de dezvoltare a parserului. Cu toate acestea, această abordare nu ajută atunci când vine vorba de implementarea procesorului de limbaj propriu-zis. În plus, majoritatea formatelor standard de schimb de date sunt limitate în mod inerent la reprezentarea datelor în termeni de concepte simple (cum ar fi liste, dicționare, șiruri și numere). Această limitare poate duce rapid la umflarea fișierelor de configurare, așa cum se arată în exemplul următor.

Imaginați-vă dezvoltarea unui calculator care operează pe numere întregi folosind înmulțirea * , adunarea + . Când utilizați un limbaj de descriere a datelor, cum ar fi YAML, în exemplul de mai jos, puteți vedea că chiar și un termen mic și simplu, cum ar fi 1 + 2 * 3 + 5 , poate fi greu de motivat, iar adăugând mai mulți termeni, fișierul de configurare s-ar umfla rapid. .

 term : add : - 1 - times : - 2 - 3 - 5

Această postare de blog se concentrează pe designul micro-limbilor. Ideea de bază este de a oferi un nucleu de limbaj simplu, extensibil, care poate fi extins cu ușurință cu tipuri personalizate și funcții personalizate; limbajul poate evolua fără a fi nevoie să atingeți analizatorul sau procesorul de limbă. În schimb, designerul DSL se poate concentra doar pe conceptele care ar trebui integrate în DSL prin implementarea interfețelor și „conectarea” acestora în implementarea limbajului de bază.

Lingo: un cadru de microlimbi pentru Go

La GitLab, Go este unul dintre principalele noastre limbaje de programare și unele dintre instrumentele pe care le dezvoltăm necesită DSL-uri proprii, mici, încorporabile, astfel încât utilizatorii să poată configura și interacționa corect cu ele.

Inițial, am încercat să integrăm implementări de limbaje deja existente, încorporabile și extinse. Singura noastră condiție era ca acestea să fie încorporate nativ într-o aplicație Go. Am explorat câteva proiecte excelente gratuite și open-source (FOSS), cum ar fi go-lua, care este Lua VM implementat în Go, go-yeagi care oferă un interpret Go cu care Go poate fi folosit ca limbaj de scripting sau go-zygomys care este un interpret LISP scris în Go. Cu toate acestea, aceste pachete sunt în esență module pentru a integra limbaje de uz general pe deasupra cărora ar putea fi construit un DSL. Aceste module au ajuns să fie destul de complexe. În schimb, am dorit să avem suport de bază pentru a proiecta, implementa, încorpora și evolua DSL-uri nativ într-o aplicație Go care este flexibilă, mică, simplă/ușor de înțeles, evoluat și adaptat.

Căutăm un cadru micro-lingvistic cu proprietățile enumerate mai jos:

  1. Stabilitate: Modificările aplicate DSL-ului nu ar trebui să necesite nicio modificare a implementării lexer/parserului de bază și nici a procesului de limbă.
  2. Flexibilitate/Composabilitate: Noile concepte DSL (tipuri de date, funcții) pot fi integrate printr-un mecanism simplu de conectare.
  3. Simplitate: cadrul lingvistic ar trebui să aibă suficiente caracteristici pentru a oferi o bază suficient de puternică pentru a implementa și a dezvolta un DSL personalizat. În plus, întreaga implementare a cadrului microlingvistic ar trebui să fie în pur Go, astfel încât să fie ușor de încorporat în aplicațiile Go.

Deoarece niciunul dintre instrumentele FOSS disponibile pe care le-am analizat nu a reușit să îndeplinească toate aceste cerințe, am dezvoltat propriul nostru cadru de microlimbi în Go, numit Lingo, care înseamnă „L Limbaje specifice domeniului (DSL) bazate pe L ISP în Go ”. Lingo este complet FOSS și disponibil în depozitul Lingo Git în spațiul gratuit și open source al echipei de cercetare a vulnerabilităților .

Lingo oferă o bază pentru construirea DSL-urilor bazate pe expresii simbolice (expresii S), adică expresii furnizate sub formă de liste imbricate (f ...) unde f poate fi considerat drept substituent care reprezintă simbolul funcției. Folosind acest format, termenul matematic pe care l-am văzut mai devreme ar putea fi scris ca expresie S (+ 1 (* 2 3) 5) .

Expresiile S sunt versatile și ușor de prelucrat datorită uniformității lor. În plus, ele pot fi utilizate pentru a reprezenta atât codul, cât și datele într-o manieră consecventă.

În ceea ce privește proprietățile de stabilitate, flexibilitate și compunebilitate, Lingo oferă un mecanism de plug-in simplu pentru a adăuga funcții noi, precum și tipuri, fără a fi nevoie să atingeți analizatorul de bază sau procesorul de limbă. Din perspectiva analizatorului expresiei S, simbolul actual al funcției este esențial irelevant în ceea ce privește analiza expresiei S. Procesorul de limbaj doar evaluează expresiile S și trimite execuția către implementările de interfață. Aceste implementări sunt furnizate de plug-in-uri bazate pe simbolul funcției.

În ceea ce privește Simplitatea, baza de cod Lingo este de aproximativ 3K linii de cod Go pur, inclusiv lexer/parser, un motor pentru transformarea codului și interpretul/evaluatorul. Dimensiunea mică ar trebui să permită înțelegerea integrală a implementării.

Cititorii care sunt interesați de detaliile tehnice ale Lingo în sine pot arunca o privire pe README.md , unde sunt explicate detaliile implementării și fundamentele teoretice utilizate. Această postare de blog se concentrează asupra modului în care Lingo poate fi folosit pentru a construi un DSL de la zero.

Utilizarea Lingo pentru a proiecta un motor de generare de date

În acest exemplu, proiectăm un motor de generare de date în Go, folosind Lingo ca bază. Motorul nostru de generare de date poate fi folosit pentru a genera date de intrare structurate pentru fuzzing sau alte contexte de aplicație. Acest exemplu ilustrează modul în care puteți utiliza Lingo pentru a crea o limbă și procesorul de limbă corespunzător. Revenind la exemplul de la început, să presupunem că am dori să generăm fișiere CSV în formatul pe care l-am văzut la început, acoperind soldurile conturilor.

 name, balance Lisa, 100.30 Bert, 241.41 Maria, 151.13

Limbajul nostru include următoarele funcții:

  1. (oneof s0, s1, ..., sN) : returnează aleatoriu unul dintre șirurile de parametri sX (0 <= X <= N).
  2. (join e0, e1, ..., eN) : unește toate expresiile argument și concatenează reprezentarea lor eX (0 <= X <= N).
  3. (genfloat min max) : generează un număr flotant aleatoriu X (0 <= X <= N) și îl returnează.
  4. (times num exp) : repetă modelul generat de exp num de ori.

Pentru acest exemplu, folosim Lingo pentru a construi limbajul și procesorul de limbă pentru a genera automat o ieșire CSV pe care o vom trimite înapoi în programele Ruby și AWK pe care le-am văzut în introducere pentru a efectua un test de stres asupra acestora.

Ne referim la noul nostru limbaj/instrument ca Random Text Generator (RTG) .rtg . Mai jos este un exemplu de script script.rtg pe care ne-am dori ca programul nostru să fie digerat pentru a genera aleatoriu fișiere CSV. După cum puteți vedea în exemplul de mai jos, unim sub-șiruri începând cu name, balance după care generăm aleatoriu 10 rânduri de nume și sume de sold. Între timp, generăm, de asemenea, aleatoriu câteva linii goale.

 (join (join "name" "," "balance" "n") (times 10 '(join (oneof "Jim" "Max" "Simone" "Carl" "Paul" "Karl" "Ines" "Jane" "Geralt" "Dandelion" "Triss" "Yennefer" "Ciri") "," (genfloat 0 10000) "n" (oneof "" "n"))))

Motorul nostru preia scriptul de mai sus scris în RTG și generează conținut CSV aleatoriu. Mai jos este un exemplu de fișier CSV generat din acest script.

 name,balance Carl,25.648205 Ines,11758.551 Ciri,13300.558 ...

Pentru restul acestei secțiuni, vom explora modul în care putem implementa un motor de generare de date bazat pe Lingo. Implementarea RTG necesită două ingrediente principale: (1) un tip de date float și un obiect rezultat pentru a integra o reprezentare float și (2) implementări pentru funcțiile times , oneof , genfloat și join .

Introducerea unui tip de date float și a obiectelor rezultat

Lingo face diferența între tipurile de date și obiectele rezultat. Tipurile de date indică modul în care datele sunt menite să fie utilizate, iar obiectele rezultat sunt utilizate pentru a transmite rezultate intermediare între funcții în care fiecare rezultat are un tip unic. În fragmentul de cod de mai jos, introducem un nou tip de date float . Comentariile din fragmentul de cod de mai jos oferă mai multe detalii.

 // introduce float type
var TypeFloatId , TypeFloat = types . NewTypeWithProperties ( "float" , types . Primitive )
// introduce token float type for parser
var TokFloat = parser . HookToken ( parser . TokLabel ( TypeFloat . Name ))

// recognize (true) as boolean
type FloatMatcher struct {}

// this function is used by the parser to "recognize" floats as such
func ( i FloatMatcher ) Match ( s string ) parser . TokLabel {
  if ! strings . Contains ( s , "." ) {
    return parser . TokUnknown
  }

  if _ , err := strconv . ParseFloat ( s , 32 ); err == nil {
	return TokFloat . Label
  }

  return parser . TokUnknown
}
func ( i FloatMatcher ) Id () string {
  return string ( TokFloat . Label )
}

func init () {
  // hook matcher into the parser
  parser . HookMatcher ( FloatMatcher {})
}

În plus, avem nevoie și de un obiect rezultat pe care îl putem folosi pentru a trece în jurul valorii flotante. Aceasta este o implementare a interfeței în care majoritatea numelor de funcții se explică de la sine. Bitul important este funcția Type care returnează tipul de float personalizat pe care l-am introdus în ultimul fragment.

 type FloatResult struct { Val float32 }
// deep copy
func ( r FloatResult ) DeepCopy () eval . Result { return NewFloatResult ( r . Val ) }
// returns the string representation of this result type
func ( r FloatResult ) String () string {
  return strconv . FormatFloat ( float64 ( r . Val ), 'f' , - 1 , 32 )
}
// returns the data type for this result type
func ( r FloatResult ) Type () types . Type   { return custtypes . TypeFloat }
// call-back that is cleaned up when the environment is cleaned up
func ( r FloatResult ) Tidy ()              {}

func ( r FloatResult ) Value () interface {} { return r . Val }
func ( r * FloatResult ) SetValue ( value interface {}) error {
  boolVal , ok := value . ( float32 )
  if ! ok {
    return fmt . Errorf ( "invalid type for Bool" )
  }
  r . Val = boolVal
  return nil
}
func NewFloatResult ( value float32 ) * FloatResult {
  return & FloatResult {
    value ,
  }
}

Implementarea functiilor DSL

Similar cu tipul de date și obiectul returnat, implementarea unei funcții DSL este la fel de simplă ca și implementarea unei interfețe. În exemplul de mai jos implementăm funcția genfloat ca exemplu. Cele mai importante părți sunt funcțiile Symbol() , Validate() și Evaluate() . Funcția Symbol() returnează simbolul funcției care este genfloat în acest caz particular.

Ambele funcțiile Validate() și Evaluate() iau ca parametru mediul env și parametrul Stack stack . Mediul este folosit pentru a stoca rezultate intermediare, ceea ce este util la declararea/definirea variabilelor. stack include parametrii de intrare ai funcției. Pentru (genfloat 0 10000) , stiva ar consta din doi parametri IntResult 0 și 10000 , unde IntResult este un obiect rezultat standard furnizat deja de implementarea de bază a Lingo. Validate() se asigură că parametrul poate fi digerat de funcția în cauză, în timp ce Evaluate() invocă de fapt funcția. În acest caz particular, generăm o valoare float în intervalul specificat și returnăm FloatResult corespunzător.

 type FunctionGenfloat struct {}

// returns a description of this function
func ( f * FunctionGenfloat ) Desc () ( string , string ) {
  return fmt . Sprintf ( "%s%s %s%s" ,
    string ( parser . TokLeftPar ),
    f . Symbol (),
	"min max" ,
	string ( parser . TokRightPar )),
	"generate float in rang [min max]"
}

// this is the symbol f of the function (f ...)
func ( f * FunctionGenfloat ) Symbol () parser . TokLabel {
  return parser . TokLabel ( "genfloat" )
}

// validates the parameters of this function which are passed in
func ( f * FunctionGenfloat ) Validate ( env * eval . Environment , stack * eval . StackFrame ) error {
  if stack . Size () != 2 {
    return eval . WrongNumberOfArgs ( f . Symbol (), stack . Size (), 2 )
  }

  for idx , item := range stack . Items () {
    if item . Type () != types . TypeInt {
	  return eval . WrongTypeOfArg ( f . Symbol (), idx + 1 , item )
	}
  }
  return nil
}

// evaluates the function and returns the result
func ( f * FunctionGenfloat ) Evaluate ( env * eval . Environment , stack * eval . StackFrame ) ( eval . Result , error ) {
  var result float32
  rand . Seed ( time . Now () . UnixNano ())
  for ! stack . Empty () {
    max := stack . Pop () . ( * eval . IntResult )
    min := stack . Pop () . ( * eval . IntResult )

	minval := float32 ( min . Val )
	maxval := float32 ( max . Val )

	result = minval + ( rand . Float32 () * ( maxval - minval ))
  }

  return custresults . NewFloatResult ( result ), nil
}

func NewFunctionGenfloat () ( eval . Function , error ) {
  fun := & FunctionGenfloat {}
  parser . HookToken ( fun . Symbol ())
  return fun , nil
}

Punând totul împreună

După implementarea tuturor funcțiilor, trebuie doar să le înregistrăm/integram ( eval.HookFunction(...) ) pentru ca Lingo să le rezolve corect la procesarea programului. În exemplul de mai jos, înregistrăm toate funcțiile personalizate pe care le-am implementat, adică times , oneof , join , genfloat . Funcția main() din exemplul de mai jos include codul necesar pentru a evalua scriptul nostru script.rtg .

 // register function
func register ( fn eval . Function , err error ) {
  if err != nil {
    log . Fatalf ( "failed to create %s function %s:" , fn . Symbol (), err . Error ())
  }
  err = eval . HookFunction ( fn )
  if err != nil {
    log . Fatalf ( "failed to hook bool function %s:" , err . Error ())
  }
}

func main () {
  // register custom functions
  register ( functions . NewFunctionTimes ())
  register ( functions . NewFunctionOneof ())
  register ( functions . NewFunctionJoin ())
  register ( functions . NewFunctionGenfloat ())
  register ( functions . NewFunctionFloat ())
  if len ( os . Args ) <= 1 {
    fmt . Println ( "No script provided" )
    os . Exit ( 1 )
  }
  // evaluate script
  result , err := eval . RunScriptPath ( os . Args [ 1 ])
  if err != nil {
    fmt . Println ( err . Error ())
    os . Exit ( 1 )
  }

  // print output
  fmt . Printf ( strings . ReplaceAll ( result . String (), " \ n" , " n " ))

  os . Exit ( 0 )
}

Codul sursă pentru RTG este disponibil aici . Puteți găsi informații despre cum să construiți și să rulați instrumentul în README.md .

Cu cca. 300 de linii de cod Go, am proiectat cu succes un limbaj și am implementat un procesor de limbaj. Acum putem folosi RTG pentru a testa robustețea scripturilor Ruby ( computebalance.rb ) și AWK ( computebalance.awk ) pe care le-am folosit la început pentru a însuma soldurile conturilor.

 timeout 10 watch -e './rtg script.rtg > out.csv && ./computebalance.awk out.csv' timeout 10 watch -e './rtg script.rtg > out.csv && ./computebalance.rb out.csv'

Experimentul de mai sus arată că fișierele generate prin intermediul RTG pot fi digerate corect de scriptul AWK, care este mult mai robust, deoarece poate face față tuturor fișierelor CSV generate. În schimb, executarea scriptului Ruby are ca rezultat erori, deoarece nu poate face față în mod corespunzător liniilor noi așa cum apar în fișierul CSV.

Imagine de copertă de Charles Deluvio pe Unsplash

„Un cadru de microlimbi Go pentru construirea de limbi specifice domeniului.” – Julian Thome

Faceți clic pentru a tweet