Retour au cours

games / lua

Concevoir un DSL en Lua

Leçon 171 exercice

Explication

Ce que vous allez apprendre

  • Comprendre ce qu'est un DSL (Domain Specific Language) et pourquoi Lua s'y prête bien
  • Utiliser le sucre syntaxique f{...} pour écrire une configuration déclarative
  • Construire un DSL "builder" fluide en faisant retourner self par chaque méthode
  • Écrire un mini-DSL de validation de données façon schéma
  • Toujours prévoir un moyen d'inspecter ce qu'un DSL a réellement construit

Dans quel contexte ?

Un studio veut que ses game designers, qui ne sont pas tous programmeurs Lua expérimentés, puissent définir de nouvelles routes de commandes ou des règles de configuration serveur sans écrire du code Lua impératif classique. Un DSL bien conçu transforme une déclaration presque lisible en langage naturel en une vraie structure Lua exploitable par le moteur, réduisant la barrière d'entrée pour ces contributeurs non spécialistes.

Technique Lua exploitéeCe qu'elle permet dans un DSL
f{...} (sans parenthèses)Syntaxe de configuration déclarative
return self dans une méthodeChaînage fluide façon "builder"
Métatables (__index, __call)Comportement personnalisé transparent

Astuce

Toujours écrire une fonction de "dump" qui affiche la structure réelle produite par ton DSL. Sans elle, déboguer un DSL expressif revient à deviner ce qu'il a construit, ce qui devient vite intenable dès que la configuration grandit.

Qu'est-ce qu'un DSL, et pourquoi Lua s'y prête particulièrement bien

Un DSL (Domain Specific Language, "langage dédié à un domaine") est un mini-langage conçu pour exprimer un besoin précis de façon lisible, plutôt que d'utiliser la syntaxe générale complète du langage hôte — par exemple, décrire une configuration de serveur ou un ensemble de routes HTTP d'une manière qui ressemble presque à un format déclaratif dédié. Lua se prête naturellement à cet exercice grâce à deux caractéristiques syntaxiques particulières, déjà vues séparément dans ce cours mais combinées ici pour un usage précis.

Le sucre syntaxique f{...} : la base de tout DSL déclaratif

Lua permet d'appeler une fonction avec une seule table en argument sans écrire les parenthèses (Config{...} équivaut à Config({...})). Ce détail syntaxique, presque anodin en apparence, est en réalité l'ingrédient central qui permet d'écrire des configurations qui ressemblent à un format de données déclaratif plutôt qu'à du code impératif classique.

Les métatables au service d'un style "fluide"

En faisant retourner self par chaque méthode d'un objet (un pattern déjà vu dans la leçon sur la programmation orientée objet), on obtient un DSL "builder" où les appels s'enchaînent naturellement (router:get(...):post(...)), lisible presque comme une description de haut niveau du comportement souhaité, plutôt que comme une suite d'instructions séparées.

Le piège à éviter : un DSL trop opaque

Plus un DSL est expressif et "magique", plus il devient difficile de comprendre exactement ce qu'il produit réellement en cas de problème. La bonne pratique consiste à toujours prévoir un moyen d'inspecter ce que le DSL a construit (une fonction de "dump" qui affiche la structure réelle obtenue) — sans cela, déboguer un DSL revient à deviner, ce qui devient vite insoutenable dès que la configuration grandit.

Le lien avec la leçon suivante

Un DSL de validation (comme le schéma de données présenté ici) centralise des règles qui, sans lui, se disperseraient dans le code appelant — une préoccupation de fiabilité qui rejoint directement le sujet des tests unitaires abordé ensuite : un comportement centralisé et prévisible est bien plus facile à tester qu'une logique éparpillée.

Commandes & code

Concevoir un DSL en Lua

Lua se prête naturellement à l'écriture de DSL (Domain Specific Language) grâce à sa syntaxe de table permissive et ses métatables.

lua
-- Syntaxe Lua exploitable pour un DSL : appel de fonction avec une seule table = pas besoin de parenthèses
-- f{...} est équivalent à f({...})
local function Config(t) return t end

local serverConfig = Config {
    name = "Technologik RP",
    maxPlayers = 64,
    difficulty = "hard",
}
-- Résultat : une vraie table Lua, lisible comme un fichier de config déclaratif, sans parser custom
lua
-- DSL de définition de routes HTTP façon "builder", en s'appuyant sur les métatables pour le chaînage
local Router = {}
Router.__index = Router

function Router.new()
    return setmetatable({ routes = {} }, Router)
end

function Router:get(path, handler)
    table.insert(self.routes, { method = "GET", path = path, handler = handler })
    return self   -- retourne self : permet le chaînage fluide façon DSL
end

function Router:post(path, handler)
    table.insert(self.routes, { method = "POST", path = path, handler = handler })
    return self
end

-- Usage : lecture quasi déclarative, comme un mini-langage de routage
local router = Router.new()
    :get("/players", function() return "liste des joueurs" end)
    :post("/players", function() return "création joueur" end)
    :get("/players/:id", function(id) return "détail joueur " .. id end)

for _, route in ipairs(router.routes) do
    print(route.method, route.path)
end
lua
-- DSL déclaratif avancé avec validation intégrée : schéma de données façon "mini-JSON Schema"
local function Field(type_, opts)
    opts = opts or {}
    return { type = type_, required = opts.required or false, default = opts.default }
end

local PlayerSchema = {
    name = Field("string", { required = true }),
    level = Field("number", { default = 1 }),
    vip = Field("boolean", { default = false }),
}

local function validate(schema, data)
    local result = {}
    for fieldName, def in pairs(schema) do
        local value = data[fieldName]
        if value == nil then
            if def.required then
                error(string.format("Champ requis manquant: '%s'", fieldName))
            end
            value = def.default
        elseif type(value) ~= def.type then
            error(string.format("Type invalide pour '%s': attendu %s, reçu %s", fieldName, def.type, type(value)))
        end
        result[fieldName] = value
    end
    return result
end

local player = validate(PlayerSchema, { name = "Alice", level = 42 })
print(player.name, player.level, player.vip)   -- Alice  42  false (valeur par défaut appliquée)
lua
-- Piège niveau expert : un DSL trop "magique" complique le débogage. Toujours prévoir un mode de dump/inspection.
local function dumpDSL(t, indent)
    indent = indent or ""
    for k, v in pairs(t) do
        if type(v) == "table" then
            print(indent .. tostring(k) .. ":")
            dumpDSL(v, indent .. "  ")
        else
            print(indent .. tostring(k) .. " = " .. tostring(v))
        end
    end
end

dumpDSL(serverConfig)   -- permet de vérifier ce que le DSL a réellement produit, sans deviner

Résumé

  • La syntaxe f{...} (appel avec une seule table) est la base de la plupart des DSL déclaratifs en Lua.
  • Les métatables permettent de construire des DSL fluides façon "builder" via le chaînage de méthodes (return self).
  • Un DSL de validation (type schema) centralise les règles métier au lieu de les disperser dans le code appelant.
  • Un DSL doit toujours rester inspectable (fonction de dump) pour éviter de déboguer à l'aveugle.

Exercices pratiques

1 disponible
1

Mission : le routeur DSL qui perd une route en silence

Objectif : Diagnostiquer un bug de chaînage dans un DSL de type builder et renforcer sa capacité d'inspection pour les game designers non-développeurs.

Contexte

Le studio a mis en place un DSL de routage façon builder pour ses game designers, mais un designer signale que sa route "POST /players" a disparu :

lua
local Router = {}
Router.__index = Router

function Router.new()
  return setmetatable({ routes = {} }, Router)
end

function Router:get(path, handler)
  table.insert(self.routes, { method = "GET", path = path, handler = handler })
  -- oubli : pas de "return self" ici
end

function Router:post(path, handler)
  table.insert(self.routes, { method = "POST", path = path, handler = handler })
  return self
end

local router = Router.new()
  :get("/players", function() end)
  :post("/players", function() end)   -- erreur: attempt to call a nil value (method 'post')

Le designer ne comprend pas pourquoi enchaîner :post() juste après :get() fait planter le script.

Résoudre l’exercice →