Computer >> คอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

การสร้างเทมเพลต Lexer ของเราเองใน Ruby

สวมชุดดำน้ำและแพ็คลายฉลุของคุณ เรากำลังดำดิ่งสู่เทมเพลตวันนี้!

ซอฟต์แวร์ส่วนใหญ่ที่แสดงหน้าเว็บหรือสร้างอีเมลใช้เทมเพลตเพื่อฝังข้อมูลตัวแปรลงในเอกสารข้อความ โครงสร้างหลักของเอกสารมักถูกตั้งค่าในเทมเพลตแบบคงที่พร้อมตัวยึดตำแหน่งสำหรับข้อมูล ข้อมูลตัวแปร เช่น ชื่อผู้ใช้หรือเนื้อหาของหน้าเว็บ จะแทนที่ตัวยึดตำแหน่งในขณะที่แสดงหน้าเว็บ

สำหรับการดำดิ่งสู่การสร้างเทมเพลต เราจะใช้ชุดย่อยของ Mustache ซึ่งเป็นภาษาเทมเพลตที่มีอยู่ในภาษาโปรแกรมหลายภาษา ในตอนนี้ เราจะตรวจสอบวิธีการสร้างเทมเพลตต่างๆ เราจะเริ่มมองหาการต่อสตริง และลงเอยด้วยการเขียน lexer ของเราเองเพื่อให้มีเทมเพลตที่ซับซ้อนมากขึ้น

การใช้ Native String Interpolation

เริ่มต้นด้วยตัวอย่างน้อยที่สุด ใบสมัครของเราต้องการข้อความต้อนรับที่มีชื่อโครงการ วิธีที่รวดเร็วที่สุดในการทำเช่นนี้คือการใช้คุณสมบัติการแก้ไขสตริงในตัวของ Ruby

name = "Ruby Magic"
template = "Welcome to #{name}"
# => Welcome to Ruby Magic

ยอดเยี่ยม! นั่นก็ทำได้ แต่ถ้าเราต้องการใช้เทมเพลตซ้ำหลายครั้ง หรือให้ผู้ใช้อัปเดตเทมเพลตได้

การแก้ไขจะประเมินทันที เราไม่สามารถใช้แม่แบบซ้ำได้ (เว้นแต่เราจะกำหนดใหม่ - วนซ้ำ) และเราไม่สามารถจัดเก็บ Welcome to #{name} เทมเพลตในฐานข้อมูลและเติมข้อมูลในภายหลังโดยไม่ต้องใช้ eval . ที่อาจเป็นอันตราย ฟังก์ชัน

โชคดีที่ Ruby มีวิธีการแก้ไขสตริงที่ต่างออกไป:Kernel#sprintf หรือ String#% . วิธีการเหล่านี้ช่วยให้เราได้รับสตริงการสอดแทรกโดยไม่ต้องเปลี่ยนเทมเพลตเอง ด้วยวิธีนี้ เราสามารถใช้เทมเพลตเดิมซ้ำได้หลายครั้ง นอกจากนี้ยังไม่อนุญาตให้เรียกใช้รหัส Ruby โดยพลการ มาใช้กันเถอะ

name = "Ruby Magic"
template = "Welcome to %{name}"
 
sprintf(template, name: name)
# => "Welcome to Ruby Magic"
 
template % { name: name }
# => "Welcome to Ruby Magic"

The Regexp แนวทางการทำเทมเพลท

แม้ว่าวิธีแก้ปัญหาข้างต้นจะได้ผล แต่ก็ไม่สามารถพิสูจน์ได้ และเปิดเผยฟังก์ชันการทำงานมากกว่าที่เราต้องการ มาดูตัวอย่างกัน:

name = "Ruby Magic"
template = "Welcome to %d"
 
sprintf(template, name: name)
# => TypeError (can't convert Hash into Integer)

ทั้ง Kernel#sprintf และ String#% อนุญาตให้ใช้รูปแบบพิเศษในการจัดการข้อมูลประเภทต่างๆ ทั้งหมดไม่เข้ากันกับข้อมูลที่เราส่ง ในตัวอย่างนี้ เทมเพลตต้องการจัดรูปแบบตัวเลขแต่ส่งผ่าน Hash ทำให้เกิด TypeError .

แต่เรามีเครื่องมือไฟฟ้ามากกว่าในโรงเก็บของ:เราสามารถใช้การแก้ไขของเราเองโดยใช้นิพจน์ทั่วไป การใช้นิพจน์ทั่วไปช่วยให้เรากำหนดไวยากรณ์ที่กำหนดเองได้ เช่น สไตล์ที่ได้รับแรงบันดาลใจจากหนวด/แฮนด์บาร์

name = "Ruby Magic"
template = "Welcome to {{name}}"
assigns = { "name" => name }
 
template.gsub(/{{(\w+)}}/) { assigns[$1] }
# => Welcome to Ruby Magic

เราใช้ String#gsub เพื่อแทนที่ตัวยึดตำแหน่งทั้งหมด (คำในวงเล็บปีกกาคู่) ด้วยค่าใน assigns กัญชา. หากไม่มีค่าที่สอดคล้องกัน วิธีการนี้จะลบตัวยึดตำแหน่งโดยไม่ต้องใส่อะไรเลย

การแทนที่ตัวยึดตำแหน่งในสตริงเช่นนี้เป็นวิธีที่ใช้ได้สำหรับสตริงที่มีตัวยึดตำแหน่งสองสามตัว อย่างไรก็ตาม เมื่อทุกอย่างซับซ้อนขึ้นเล็กน้อย เราก็พบปัญหาอย่างรวดเร็ว

สมมติว่าเราจำเป็นต้องมีเงื่อนไขในเทมเพลต ผลลัพธ์ควรแตกต่างไปตามค่าของตัวแปร

Welcome to
{{name}}!
 
{{#if subscribed}}
  Thank you for subscribing to our mailing list.
{{else}}
  Please sign up for our mailing list to be notified about new articles!
{{/if}}
 
Your friends at
{{company_name}}

นิพจน์ทั่วไปไม่สามารถจัดการกับกรณีการใช้งานนี้ได้อย่างราบรื่น หากคุณพยายามมากพอ คุณอาจจะยังแฮ็กข้อมูลบางอย่างร่วมกันได้ แต่ ณ จุดนี้ จะดีกว่าที่จะสร้างภาษาเทมเพลตที่เหมาะสม

การสร้างภาษาเทมเพลต

การใช้ภาษาเทมเพลตนั้นคล้ายกับการนำภาษาโปรแกรมอื่นๆ ไปใช้ เช่นเดียวกับภาษาสคริปต์ ภาษาเทมเพลตต้องมีองค์ประกอบสามอย่าง:lexer, parser และ interpreter เราจะดูสิ่งเหล่านี้ทีละตัว

เล็กเซอร์

งานแรกที่เราต้องจัดการเรียกว่า tokenization หรือ lexical analysis กระบวนการนี้คล้ายกับการระบุหมวดหมู่คำในภาษาธรรมชาติมาก

ยกตัวอย่างเช่น Ruby is a lovely language . ประโยคประกอบด้วยห้าคำในประเภทที่แตกต่างกัน ในการระบุว่าเป็นหมวดหมู่ใด คุณจะต้องใช้พจนานุกรมและค้นหาทุกหมวดหมู่ของคำ ซึ่งจะส่งผลให้รายการมีลักษณะดังนี้:คำนาม , กริยา , บทความ , คำคุณศัพท์ , คำนาม . การประมวลผลภาษาธรรมชาติเรียกว่า "ส่วนของคำพูด" ในภาษาที่เป็นทางการ เช่น ภาษาโปรแกรม เรียกว่า โทเค็น .

lexer ทำงานโดยการอ่านเทมเพลตและจับคู่กระแสข้อความกับชุดนิพจน์ทั่วไปสำหรับแต่ละหมวดหมู่ตามลำดับที่กำหนด รายการแรกที่ตรงกันจะกำหนดหมวดหมู่ของโทเค็นและแนบข้อมูลที่เกี่ยวข้องเข้าไป

ด้วยทฤษฎีเล็กๆ น้อยๆ นี้ เรามาปรับใช้ lexer สำหรับภาษาเทมเพลตของเรา เพื่อให้ง่ายขึ้นอีกเล็กน้อย เราใช้ StringScanner โดยกำหนดให้ strscan จากไลบรารีมาตรฐานของ Ruby (อีกอย่าง เรามีบทนำที่ยอดเยี่ยมสำหรับ StringScanner ในฉบับก่อนหน้าของเรา) ขั้นแรก เรามาสร้างเวอร์ชันขั้นต่ำที่ระบุว่าทุกอย่างเป็น CONTENT .

เราทำได้โดยการสร้าง StringScanner . ใหม่ อินสแตนซ์และปล่อยให้มันทำงานโดยใช้ until ลูปที่หยุดเมื่อสแกนเนอร์ถึงจุดสิ้นสุดของสตริงเท่านั้น

ตอนนี้เราปล่อยให้มันตรงกับทุกตัวอักษร (.* ) ข้ามหลายบรรทัด (m ตัวแก้ไข) และส่งคืน CONTENT . หนึ่งรายการ โทเค็นสำหรับมันทั้งหมด เราแสดงโทเค็นเป็นอาร์เรย์ที่มีชื่อโทเค็นเป็นองค์ประกอบแรกและข้อมูลใดๆ เป็นองค์ประกอบที่สอง lexer พื้นฐานของเรามีลักษณะดังนี้:

require 'strscan'
 
module Magicbars
  class Lexer
    def self.tokenize(code)
      new.tokenize(code)
    end
 
    def tokenize(code)
      scanner = StringScanner.new(code)
      tokens = []
 
      until scanner.eos?
        tokens << [:CONTENT, scanner.scan(/.*?/m)]
      end
 
      tokens
    end
  end
end

เมื่อรันโค้ดนี้ด้วย Welcome to {{name}} เราได้รับรายการ CONTENT . หนึ่งรายการอย่างแม่นยำ โทเค็นพร้อมรหัสทั้งหมดที่แนบมาด้วย

Magicbars::Lexer.tokenize("Welcome to {{name}}")
=> [[:CONTENT, "Welcome to {{name}}"]]

ต่อไป มาตรวจหานิพจน์กัน ในการทำเช่นนั้น เราแก้ไขโค้ดภายในลูปเพื่อให้ตรงกับ {{ และ }} เป็น OPEN_EXPRESSION และ CLOSE .

เราทำเช่นนี้โดยเพิ่มเงื่อนไขที่ตรวจสอบกรณีต่างๆ

until scanner.eos?
  if scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
  elsif scanner.scan(/}}/)
    tokens << [:CLOSE]
  elsif scanner.scan(/.*?/m)
    tokens << [:CONTENT, scanner.matched]
  end
end

ไม่มีค่าเพิ่มในการติดปีกนกกับ OPEN_EXPRESSION และ CLOSE โทเค็นดังนั้นเราจึงปล่อยพวกเขา เป็น scan การโทรเป็นส่วนหนึ่งของเงื่อนไขแล้ว เราใช้ scanner.matched เพื่อแนบผลการแข่งขันล่าสุดกับ CONTENT โทเค็น

ขออภัย เมื่อเรียกใช้ lexer อีกครั้ง เรายังคงได้รับ CONTENT เพียงรายการเดียว โทเค็นเหมือนก่อน เรายังต้องแก้ไขนิพจน์สุดท้ายเพื่อให้ตรงกับทุกนิพจน์ที่เปิดอยู่ เราทำสิ่งนี้โดยใช้ scan_until ด้วยจุดยึดหัวมองที่เป็นบวกสำหรับเหล็กจัดฟันแบบลอนคู่ที่หยุดเครื่องสแกนตรงหน้าพวกเขา ตอนนี้โค้ดของเราในลูปมีลักษณะดังนี้:

until scanner.eos?
  if scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
  elsif scanner.scan(/}}/)
    tokens << [:CLOSE]
  elsif scanner.scan_until(/.*?(?={{|}})/m)
    tokens << [:CONTENT, scanner.matched]
  end
end

เรียกใช้ lexer อีกครั้ง ส่งผลให้มีสี่โทเค็น:

Magicbars::Lexer.tokenize("Welcome to {{name}}")
=> [[:CONTENT, "Welcome to "], [:OPEN_EXPRESSION], [:CONTENT, "name"], [:CLOSE]]

lexer ของเราดูค่อนข้างใกล้เคียงกับผลลัพธ์ที่เราต้องการ อย่างไรก็ตาม name ไม่ใช่เนื้อหาปกติ มันเป็นตัวระบุ! สตริงระหว่างวงเล็บปีกกาคู่ควรได้รับการปฏิบัติที่แตกต่างจากสตริงภายนอก

เครื่องรัฐ

ในการทำเช่นนี้ เราเปลี่ยน lexer ให้เป็นเครื่องของรัฐที่มีสถานะต่างกันสองสถานะ มันเริ่มต้นใน default สถานะ. เมื่อถึง OPEN_EXPRESSION token จะย้ายไปที่ expression รัฐและอยู่ที่นั่นจนกว่าจะเจอ CLOSE โทเค็นซึ่งทำให้เปลี่ยนกลับไปเป็น default รัฐ.

เราใช้เครื่องสถานะโดยเพิ่มวิธีการบางอย่างที่ใช้อาร์เรย์เพื่อจัดการสถานะปัจจุบัน

def stack
  @stack ||= []
end
 
def state
  stack.last || :default
end
 
def push_state(state)
  stack.push(state)
end
 
def pop_state
  stack.pop
end

state method จะคืนค่าสถานะปัจจุบันหรือ default . push_state ย้าย lexer ไปสู่สถานะใหม่โดยเพิ่มลงในสแต็ก pop_state ย้าย lexer กลับสู่สถานะก่อนหน้า

ต่อไป เราแยกเงื่อนไขภายในลูปและห่อด้วยเงื่อนไขที่ตรวจสอบสถานะปัจจุบัน ขณะที่อยู่ใน default รัฐ เราจัดการทั้ง OPEN_EXPRESSION และ CONTENT โทเค็น นี่ก็หมายความว่านิพจน์ทั่วไปสำหรับ CONTENT ไม่ต้องการ }} มองไปข้างหน้าอีกต่อไปดังนั้นเราจึงวางมัน ใน expression รัฐ เราจัดการ CLOSE โทเค็นและเพิ่มนิพจน์ทั่วไปใหม่สำหรับ IDENTIFIER . แน่นอน เรายังใช้การเปลี่ยนสถานะด้วยการเพิ่ม push_state โทรไปที่ OPEN_EXPRESSION และ pop_state โทรไปที่ CLOSE .

if state == :default
  if scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
    push_state :expression
  elsif scanner.scan_until(/.*?(?={{)/m)
    tokens << [:CONTENT, scanner.matched]
  end
elsif state == :expression
  if scanner.scan(/}}/)
    tokens << [:CLOSE]
    pop_state
  elsif scanner.scan(/[\w\-]+/)
    tokens << [:IDENTIFIER, scanner.matched]
  end
end

เมื่อมีการเปลี่ยนแปลงเหล่านี้ lexer ก็สร้างโทเค็นให้กับตัวอย่างของเราได้อย่างเหมาะสม

Magicbars::Lexer.tokenize("Welcome to {{name}}")
# => [[:CONTENT, "Welcome to "], [:OPEN_EXPRESSION], [:IDENTIFIER, "name"], [:CLOSE]]

ทำให้ยากขึ้นสำหรับตัวเราเอง

มาดูตัวอย่างขั้นสูงกันดีกว่า อันนี้ใช้หลายนิพจน์เช่นเดียวกับบล็อก

Welcome to {{name}}!
 
{{#if subscribed}}
  Thank you for subscribing to our mailing list.
{{else}}
  Please sign up for our mailing list to be notified about new articles!
{{/if}}
 
Your friends at {{company_name}}

ไม่น่าแปลกใจเลยที่ lexer ของเราไม่สามารถแยกวิเคราะห์ตัวอย่างนี้ได้ เพื่อให้ใช้งานได้ เราต้องเพิ่มโทเค็นที่หายไป และทำให้จัดการเนื้อหาหลังนิพจน์สุดท้าย รหัสภายในลูปมีลักษณะดังนี้:

if state == :default
  if scanner.scan(/{{#/)
    tokens << [:OPEN_BLOCK]
    push_state :expression
  elsif scanner.scan(/{{\//)
    tokens << [:OPEN_END_BLOCK]
    push_state :expression
  elsif scanner.scan(/{{else/)
    tokens << [:OPEN_INVERSE]
    push_state :expression
  elsif scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
    push_state :expression
  elsif scanner.scan_until(/.*?(?={{)/m)
    tokens << [:CONTENT, scanner.matched]
  else
    tokens << [:CONTENT, scanner.rest]
    scanner.terminate
  end
elsif state == :expression
  if scanner.scan(/\s+/)
    # Ignore whitespace
  elsif scanner.scan(/}}/)
    tokens << [:CLOSE]
    pop_state
  elsif scanner.scan(/[\w\-]+/)
    tokens << [:IDENTIFIER, scanner.matched]
  else
    scanner.terminate
  end
end

โปรดทราบว่าลำดับของเงื่อนไขมีความสำคัญในระดับหนึ่ง มีการกำหนดนิพจน์ทั่วไปแรกที่ตรงกัน ดังนั้น สำนวนที่เจาะจงกว่าจึงต้องมาก่อนนิพจน์ทั่วไป ตัวอย่างที่สำคัญของสิ่งนี้คือคอลเล็กชันโทเค็นเปิดเฉพาะสำหรับบล็อก

เมื่อใช้ lexer เวอร์ชันสุดท้าย ตัวอย่างจะแปลงเป็นสิ่งนี้:

[
  [:CONTENT, "Welcome to "],
  [:OPEN_EXPRESSION],
  [:IDENTIFIER, "name"],
  [:CLOSE],
  [:CONTENT, "!\n\n"],
  [:OPEN_BLOCK],
  [:IDENTIFIER, "if"],
  [:IDENTIFIER, "subscribed"],
  [:CLOSE],
  [:CONTENT, "\n  Thank you for subscribing to our mailing list.\n"],
  [:OPEN_INVERSE],
  [:CLOSE],
  [:CONTENT, "\n  Please sign up for our mailing list to be notified about new articles!\n"],
  [:OPEN_END_BLOCK],
  [:IDENTIFIER, "if"],
  [:CLOSE],
  [:CONTENT, "\n\nYour friends at "],
  [:OPEN_EXPRESSION],
  [:IDENTIFIER, "company_name"],
  [:CLOSE],
  [:CONTENT, "\n"]
]

เมื่อเสร็จแล้ว เราได้ระบุโทเค็นที่แตกต่างกันเจ็ดประเภท:

โทเค็น ตัวอย่าง
OPEN_BLOCK {{#
OPEN_END_BLOCK {{/
OPEN_INVERSE {{else
OPEN_EXPRESSION {{
CONTENT สิ่งที่อยู่นอกนิพจน์ (HTML ปกติหรือข้อความ)
CLOSE }}
IDENTIFIER ตัวระบุประกอบด้วยอักขระ Word ตัวเลข _ และ -

ขั้นตอนต่อไปคือการใช้ parser ที่พยายามหาโครงสร้างของสตรีมโทเค็นและแปลเป็นแผนผังไวยากรณ์ที่เป็นนามธรรม แต่นั่นเป็นอีกครั้ง

ถนนข้างหน้า

เราเริ่มต้นการเดินทางสู่ภาษาเทมเพลตของเราโดยดูวิธีต่างๆ ในการใช้ระบบเทมเพลตพื้นฐานโดยใช้การแก้ไขสตริง เมื่อถึงขีดจำกัดของแนวทางแรก เราก็เริ่มใช้ระบบเทมเพลตที่เหมาะสม

สำหรับตอนนี้ เราได้ใช้ lexer ที่วิเคราะห์เทมเพลตและค้นหาโทเค็นประเภทต่างๆ ใน Ruby Magic ฉบับต่อไป เราจะเดินทางต่อโดยใช้โปรแกรมแยกวิเคราะห์และล่ามเพื่อสร้างสตริงที่สอดแทรก