A well-designed Domain Specific Language (DSL) can help you be more productive as a developer, thus making you, your team and your clients happier. In this post, I’ll guide you through the design and creation of a simple DSL to create EPUB files. We’ll start with a regular API and refactoring until we get to a DSL solution.
A short into to DSLs
At it’s very core, DSL is a fancy term for a very simple language designed to solve something in particular. It’s domain-specific because it works in a very particular use-case, the most common ones being configuration files and APIs. If you are a Ruby developer then you have most likely used a DSL already. RSpec is one of the most popular:
describe "Something" do
subject { SomeClass.new }
it { is_expected.not_to be_nil }
it "passes" do
subject.greet eq "Hello!"
end
end
That code is in a language designed to helps us write tests in a more natural way following the BDD testing methodology.
The result is code that is more understandable to you as a human — programmer or otherwise. Even if you’ve never used Ruby before, or don’t know about RSpec, you get an idea of what it is, it describes the functionality of Something.
The biggest drawback of DSLs is that you need to learn a new language every time — it’s easier to always use the same interface for all libraries. The advantage, though, is that the API is much more friendly and easier to use in the long-run. It’s an investment, the easiest the library, the lesser bugs consumers have, and everyone loves having less bugs. 🙂
So let’s get starting building a DSL. I’ll guide you through the design and creation of a simple DSL to create EPUB files. Starting with a regular API, we’ll refactor until we get to a DSL solution.
The design
EPUB is a format for digital books used by iOS and macOS. It’s basically a bunch of HTML files zipped together, following certain naming rules and ceremony. Without getting too deep into the file format specification, let’s just assume for now that all EPUBs must have a title, a description and at least one chapter. Initially, one could think of an API design as follows:
generator = EPUBGenerator.new(title: "My Awesome Book", description: "An awesome book, really.")
generator.chapter = Chapter.new(title: "Chapter 1", contents: "Once upon a time...")
book_path = generator.generate
puts "The book was created, it now lives in #{book_path}"
That looks good, right? If the problem is that simple, then we are done. But what if the generator needs more than just a title and a description. Let’s say we now also need an author and a URL. We could just add more arguments:
generator = EPUBGenerator.new(title: "My Awesome Book", description: "An awesome book, really.", author: "Federico Ramirez", url: "https://blog.beezwax.net")
You might say “Meh it’s not that bad”. And you would be right! But we are taking an unnecessary risk, four arguments for a method is a red flag — it can get out of hand quite easily.
There are many ways to solve that issue, the most common of which is to “extract it into an object”. Let’s create a Book
model. We just add the arguments as attributes, make sure the data is always consistent and just inject that object into our generator. Now our code is not only more solid and easier to maintain, but we have the added benefit of testability.
Now we are done… well, not really. Consider now that our EPUB generation library is a Ruby gem. We’ll force all our users to know all the class names: EPUBGenerator
, Chapter
and Book
.
If the library is this small, it’s not really a big deal. If we know we’ll need to expose the user to more classes, then we might want to consider a better solution. This is where a DSL comes handy.
A DSL gives us yet another layer of abstraction. In this example, with a single class name, the user can easily use the library to create a new EPUB:
generator = EPUBGenerator do |g|
g.title "My Awesome Book"
g.description "An awesome book, really."
g.author "Federico Ramirez"
g.url "https://blog.beezwax.net"
end
The way that looks is arbitraty, that’s just a common format for DSLs. With domain-specific languages it’s easier to start with “how it looks” and then move into the implementation, as the other way around might be harder if you have never made a DSLs before.
Now that’s a good enough solution. The code is simple and easy to read. We are still missing a few things though. What would a chapter definition look like? Easy!
generator = EPUBGenerator do |g|
g.title "My Awesome Book"
# ...
g.chapter do |c|
c.title "Chapter 1"
c.contents "Lorem ipsum dolor sit amet..."
end
end
You start to notice a pattern here, if chapters needed some dependency, we just pass a new block:
generator = EPUBGenerator do |g|
g.title "My Awesome Book"
# ...
g.chapter do |c|
c.title "Chapter 1"
#...
c.footnote do |f|
f.contents "Hello! I'm a footnote."
end
end
end
Good! We now have our general design, let’s make it happen!
The implementation
Ruby’s yield
is what makes it so easy to write DSLs. You can think of it as a function which gets called with whatever arguments we give it.
class EPUBGenerator
def self.generate
book = Book.new
yield book
generator = Generator.new(book)
generator.generate
end
end
In the code above, pass book
, an instance of Book
to a block of code. We don’t know what the code-block will do with it, that responsibility is up to the caller. The generate
method call looks like this:
generator = EPUBGenerator.generate do |book|
puts "I have a book! #{book}"
end
We’ve abstracted away the Book
class name dependency! We’ve also reduced the ceremony for creating books, it’s much simpler now. Let’s repeat this process of yieding code blocks for the Book
model:
class Book
attr_reader :chapters
def initialize
@chapters = []
end
# getter/setter
def title(text = nil)
return @title if text.nil?
@title = text
end
def chapter
chapter = Chapter.new
yield chapter
chapter.id(chapters.count + 1)
chapters << chapter
end
end
Nice! Our generator now looks like this:
generator = EPUBGenerator.generate do |b|
b.title "My Awesome Book"
b.chapter do |c|
# ... do something with chapter object
end
end
We are still lacking functionality, but the important thing is to realize that every time we write b.<something>
in the generator, we are actually calling a method on a book instance.
That’s it! The hard part is done! From now on, it’s quite straightforward to implement the missing functionality. For the sake of completeness, let’s make another model, the Chapter
:
class Chapter
attr_reader :title
def initalize
@title = "Not defined"
end
def title(text = nil)
return @title if text.nil?
@title = text
end
end
The generator can now add titles to chapters:
generator = EPUBGenerator.generate do |b|
b.title "My Awesome Book"
b.chapter do |c|
c.title "Chapter 1"
end
end
Wrapping up
We’ve built our own DSL. And it wasn’t even hard! If you are curious and want the full source code, you can see a fully working gem on GitHub. The complete DSL looks like this:
path = Epubber.generate do |b|
b.title 'My First EPUB book'
b.author 'Ramirez, Federico'
b.description 'This is an example EPUB'
b.url 'http://my-url.com'
b.cover do |c|
c.file File.new('my-image.jpg')
end
b.introduction do |i|
i.content '<p>This is an introduction.</p>'
end
b.chapter do |c|
c.title 'Chapter 1'
c.content '<p>This is some content!</p>'
end
b.chapter do |c|
c.title 'Chapter 2'
c.content '<p>Some more content this is.</p>'
end
end
BONUS TIP
Yielding blocks is used everywhere in Ruby. It is particularly useful for making sure resources are beeing handled properly, the most common example is file manipulation. In order to write to a file we have to open it for writing, write stuff, and then close it.
file = open_file('my_file.txt', 'w')
file.write("Something")
file.close
If we forget to close the file, we won’t get any errors, but it might lead to unexpected behavior. That’s not good, we want all our users to always close the file after they write to it. We can easily solve this with yield
:
def with_file(file)
file = open_file(file, 'w')
yield file
file.close
end
# No way to leave the file open! I don't need to
# know any rules about using this resource either!
with_file 'my_file.txt' do |file|
f.write "Something!"
end