We’ve looked at building DSLs from the point of view
of the outward syntax—how we use them. What we haven’t done is cover how
they’re structured internally—how we build and integrate them into our
applications.
In general, a DSL is composed of the building blocks shown in figure 1.
A typical DSL is usually split into several distinct parts:
Syntax— This is the core language or the syntax extensions that you create.
API— This is the API used in the DSL; it is usually built specifically to support the DSL and its needs.
Model—
This is the existing code base we reuse in our DSL (usually using a
facade). The difference between the API and the model is that the model
usually represents the notions in our application (such as Customer, Discount, and so on), whereas the API focuses on providing the DSL with convenient ways to access and manipulate the model.
Engine— This is the runtime engine that executes the DSL and processes its results.
The language and the
API can be intrinsically tied together, but there is a fine line
separating the two. The API exposes the operations that DSL users will
use in the application. Usually you’ll expose the domain operations to
the DSL. You express those operations through the language, but the API
is focused on enabling a good syntax for the operations, not on
providing the operations themselves.
We need to understand what features of the
language we can use and what modifications we’re able to make to the
language to better express our intent. Often, this is directly related
to the API that we expose to the DSL. As I mentioned earlier, if you’re
working in a domain-driven design manner, you’re in a good position to
reuse the same domain objects in your DSL Often, though,
the API will be composed of facades over the application, to provide
the DSL with coarse-grained access into the application (fine-grained
control is often too fine grained and is rarely useful in a DSL).
Several times in the past I have
tried to combine different parts of the DSL—typically the syntax and
the API—usually to my regret. It’s important to keep each layer to
itself, because that brings several advantages.
It means you can work on each
layer independently. Enhancing your API doesn’t break the syntax, and
adding a method call doesn’t require dealing with the internals of the
compiler.
You can use the DSL
infrastructure from other languages, as well. Why would you want to do
that? Because this will avoid tying your investment in the DSL into a
single implementation of the syntax, and that’s important. You may want
to have several dialects of a single DSL working against a single
infrastructure, or you may decide that you have hit the limits of the
host language and you need to build an external DSL (or one using a
different host language). You’ll still want to use the same
infrastructure across all of them. Having an infrastructure that is not
tied to a specific language implementation also means that you can use
this infrastructure without any DSL, directly from your application.
A typical example of
using the DSL infrastructure without a DSL language would be an
infrastructure that can also be used via a fluent interface to the
application and via a DSL for external extensibility.
|
The execution engine is
responsible for the entire process of selecting a DSL script and
executing it, from setting up the compiler to executing the compiled
code, from setting up the execution environment to executing the
secondary stages in the engine after the DSL has finished running
(assuming you have a declarative DSL).
Extending the Boo language
itself is probably the most powerful way to add additional functionality
to a DSL, but it’s also the most difficult. You need to understand how
the compiler works, to some extent. Boo was built to allow that, but
it’s usually easier to extend a DSL by adding to the API than by
extending the Boo language. When you need to extend Boo to enrich your
DSL, those extensions will also reside in the engine and will be managed
by it.
The API is part of the DSL.
Repeat that a few times in your head. The API is part of the DSL because
it composes a significant part of the language that you use to
communicate intent.
Having a clear API, one that
reflects the domain you’re working in, will make building a DSL much
easier. In fact, the process of writing a DSL is similar to the process
of fleshing out a domain model or ubiquitous language in domain-driven
design. Like the domain itself, the DSL should evolve with your
understanding of the domain and the requirements of the application.
DSLs and domain-driven design are often seen together, for that matter.
When
sitting down to design a DSL, I take one of two approaches. Either I
let it grow organically, as new needs arise, or I try to think about the
core scenarios that I need to handle, and decide what I want the
language to look like.
There are advantages to
both approaches. The first approach is the one I generally use when I
am building a language for myself, because I already have a fairly good
idea what kind of a language I want.
I use the second approach if
I’m building a DSL for general consumption, particularly to be used by
non-developers. This isn’t to say you need to spend weeks and months
designing a DSL. I still very much favor the iterative approach, but you
should seek additional input before you start committing to a
language’s syntax. Hopefully, this input will come from the expected
audience of the DSL, which can help guide you toward a language that’s
well suited for their needs. Then, once you start, assume that you’ll
not be able to deliver the best result in the first few tries.
If you build a DSL when you’re
just starting to understand the domain, and you neglect to maintain it
as your understanding of the domain and its needs grows, it will sulk
and refuse to cooperate. It will no longer allow you to easily express
your intent, but rather will force you to awkwardly specify your
intentions.