Что, если вы можете придумать свой собственный язык?
Представьте: вы пишете код, который читается как обычный английский текст. Никаких лишних скобок, никаких for и if, только чистые, понятные инструкции. Звучит как фантастика? А вот и нет. Это DSL (Domain-Specific Language) — предметно-ориентированный язык, созданный под конкретную задачу.
На PythonLib мы часто говорим о том, как сделать код чище и выразительнее. Сегодня мы зайдём с неожиданной стороны: возьмём JavaScript и превратим его в инструмент для создания собственных мини-языков. Зачем? Чтобы ваши конфигурации перестали быть «кашей», а бизнес-логика зазвучала как музыка.
Готовы стать лингвистом в мире программирования? Поехали!
DSL: что это и зачем оно вам?
DSL (предметно-ориентированный язык) — это язык программирования (или его синтаксическое расширение), созданный для решения задач из одной конкретной области. В отличие от универсальных языков (C++, Python, Java), DSL не пытается объять необъятное. Его задача — быть максимально выразительным в своей нише.
Примеры из жизни:
- SQL — язык для работы с базами данных.
- HTML — язык разметки веб-страниц.
- Regex — язык для поиска по шаблону.
- Makefile — язык описания сборки проектов.
А теперь представьте, что вы можете создать такой же удобный язык для своей задачи: для описания бизнес-правил, для конфигурации ботов, для построения сложных запросов к API. Создание DSL на JavaScript — это суперсила, которая делает код самодокументируемым и снижает порог входа для новых разработчиков.
Внутренние и внешние DSL: два пути к одной цели
Внутренний DSL (Embedded DSL)
Это надстройка над существующим языком. Вы используете синтаксис JavaScript, но строите из него «кирпичики» нового языка. Самый популярный пример — Fluent API (цепочки методов).
Плюсы: не нужно писать парсер, используете все возможности хост-языка (отладка, типы, инструменты).
Минусы: ограничены синтаксисом JavaScript (никаких новых операторов).
Внешний DSL
Вы придумываете свой синтаксис с нуля, а затем пишете парсер (или компилятор), который превращает его в JavaScript.
Плюсы: полная свобода синтаксиса, можно сделать очень лаконичный язык.
Минусы: нужно писать и поддерживать парсер, сложнее с отладкой.
Для большинства проектов DSL на JavaScript начинается с внутреннего подхода — он проще, и его можно внедрить уже сегодня.
Пишем первый DSL: от конфигурации к поэзии
Давайте создадим внутренний DSL для описания маршрутов в веб-приложении. Вместо того чтобы писать:
const routes = [
{ path: '/users', handler: usersHandler, method: 'GET' },
{ path: '/users/:id', handler: userById, method: 'GET' },
{ path: '/users', handler: createUser, method: 'POST' }
];
Мы хотим писать так:
const router = new Router();
router
.get('/users', usersHandler)
.get('/users/:id', userById)
.post('/users', createUser);
Уже лучше, правда? Но это только начало. Настоящий DSL должен быть ещё выразительнее.
Шаг 1: Fluent API (Цепочки методов)
Самый простой способ сделать предметно-ориентированный язык — вернуть this из каждого метода.
class Router {
constructor() {
this.routes = [];
}
get(path, handler) {
this.routes.push({ path, handler, method: 'GET' });
return this; // ключ к цепочке
}
post(path, handler) {
this.routes.push({ path, handler, method: 'POST' });
return this;
}
delete(path, handler) {
this.routes.push({ path, handler, method: 'DELETE' });
return this;
}
// ... и так далее
}
Теперь мы можем писать:
const appRouter = new Router()
.get('/', homePage)
.get('/about', aboutPage)
.post('/contact', contactForm);
Это уже DSL на JavaScript — простой, но эффективный.
Шаг 2: Добавляем контекст и вложенность
Настоящая магия начинается, когда мы добавляем вложенные блоки. Представьте, что мы хотим описывать конфигурацию игры:
const gameConfig = new GameConfig()
.level('forest')
.enemy('goblin', { hp: 10, damage: 2 })
.enemy('orc', { hp: 20, damage: 5 })
.treasure('gold', 100)
.level('dungeon')
.enemy('skeleton', { hp: 15, damage: 3 })
.treasure('sword', 1);
Как это реализовать? Используем контекстный менеджер:
class GameConfig {
constructor() {
this.levels = {};
this.currentLevel = null;
}
level(name) {
if (this.currentLevel) {
this.levels[this.currentLevel.name] = this.currentLevel;
}
this.currentLevel = { name, enemies: [], treasures: [] };
return this;
}
enemy(type, stats) {
if (this.currentLevel) {
this.currentLevel.enemies.push({ type, ...stats });
}
return this;
}
treasure(item, quantity) {
if (this.currentLevel) {
this.currentLevel.treasures.push({ item, quantity });
}
return this;
}
build() {
if (this.currentLevel) {
this.levels[this.currentLevel.name] = this.currentLevel;
}
return this.levels;
}
}
// Использование:
const config = new GameConfig()
.level('forest')
.enemy('goblin', { hp: 10 })
.treasure('gold', 100)
.level('dungeon')
.enemy('skeleton', { hp: 15 })
.build();
console.log(config);
// {
// forest: { name: 'forest', enemies: [ { type: 'goblin', hp: 10 } ], treasures: [ { item: 'gold', quantity: 100 } ] },
// dungeon: { name: 'dungeon', enemies: [ { type: 'skeleton', hp: 15 } ], treasures: [] }
// }
Обратите внимание: мы использовали отступы для наглядности, но в реальном коде это работает благодаря возврату this. Этот паттерн называется Builder Pattern и является основой многих внутренних DSL.
Идём дальше: создаём внешний DSL
Иногда возможностей JavaScript не хватает. Хочется писать не цепочки, а настоящие предложения. Например, для описания бизнес-правил:
RULE "