Раздел 31 · Janet на практике

Встраивание Janet в программу на C

senior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Встраивание Janet в программу на C

Обратное направление: у тебя есть программа на C (редактор, игровой движок, синтезатор, демон), и ты хочешь дать пользователю возможность её скриптовать. Это ниша, ради которой Janet во многом и создавался.

Представь: ты написал на C синтезатор. Пользователь просит дать ему возможность описывать цепочки эффектов кодом, а не мышкой. Переписывать синтезатор на другой язык из-за этого никто не будет. Вместо этого твоя программа становится хостом: поднимает внутри себя виртуальную машину Janet и выполняет в ней пользовательские скрипты.

Вся картина урока помещается в одну схему. Хост кладёт функции и данные вниз, скрипт выполняется, хост забирает результат наверх:

┌──────────────────────────────┐
│  хост: твоя программа на C   │
└──────────────────────────────┘
    │ janet_cfuns, janet_def         ▲ janet_unwrap_*
    │ (функции и данные вниз)        │ (результат наверх)
    ▼                                │
┌──────────────────────────────┐     │
│  VM Janet: окружение-таблица │ ────┘
│  выполняет скрипт            │  janet_dostring
└──────────────────────────────┘

Каждую стрелку разберём кодом.

Минимальное встраивание

#include <janet.h>
#include <stdio.h>

int main(void) {
    janet_init();
    JanetTable *env = janet_core_env(NULL);

    janet_dostring(env, "(print \"привет из встроенного Janet\")", "встроенный", NULL);

    janet_deinit();
    return 0;
}

Четыре вызова:

  • janet_init поднимает виртуальную машину, один раз на процесс.
  • janet_core_env(NULL) создаёт окружение со стандартной библиотекой.
  • janet_dostring(env, code, source_name, out) компилирует и выполняет строку. Третий аргумент это имя, которое появится в сообщениях об ошибках.
  • janet_deinit освобождает всё.
cc -O2 -o host host.c -ljanet

Заметь: в C-листингах этого урока идентификаторы латиницей. Janet принимает кириллицу в именах всегда, а вот компиляторы C понимают её не все, поэтому кириллица останется только внутри строк с Janet-кодом.

Коды возврата

КодЧто случилось
0успех
1ошибка во время выполнения
2ошибка компиляции

Ошибка в скрипте не роняет программу. Если скрипт вызвал (error "сломалось"), Janet напечатает трассировку в stderr, вернёт 1, и твоя программа продолжит работать. Это ровно то поведение, которое нужно от встроенного языка: кривой пользовательский скрипт не должен убивать приложение.

Значения между C и Janet

Четвёртый аргумент janet_dostring это куда положить результат:

Janet out;
janet_dostring(env, "(+ 20 22)", "встроенный", &out);

printf("тип: %s\n", janet_type_names[janet_type(out)]);   /* number */
printf("значение: %g\n", janet_unwrap_number(out));       /* 42 */

Обрати внимание: имя типа берётся из массива janet_type_names, а не из функции.

Строки распаковываются похоже и приходят завершёнными нулём, так что годятся для %s:

Janet s;
janet_dostring(env, "(string \"Janet \" janet/version)", "встроенный", &s);
printf("%s\n", (const char *)janet_unwrap_string(s));     /* Janet 1.41.2 */

Семейства функций, которые понадобятся:

  • janet_wrap_* упаковывает значение C в Janet: janet_wrap_number, janet_wrap_nil, janet_wrap_table.
  • janet_unwrap_* распаковывает обратно.
  • janet_c*v собирает значение из строки C: janet_cstringv, janet_ckeywordv.

Свои функции в скрипте

Чтобы скрипт мог управлять приложением, зарегистрируй C-функции в его окружении тем же способом, что и в нативном модуле:

static double volume = 0.5;

static Janet cfun_set_volume(int32_t argc, Janet *argv) {
    janet_fixarity(argc, 1);
    volume = janet_getnumber(argv, 0);
    return janet_wrap_nil();
}

static const JanetReg app_funs[] = {
    {"set-volume", cfun_set_volume, "(set-volume x)"},
    {NULL, NULL, NULL}
};

/* в main, после janet_core_env: */
janet_cfuns(env, "app", app_funs);

Префикс в janet_cfuns не попадает в имя. Второй аргумент это имя для реестра, а не префикс символа. В окружение имена кладутся голыми:

(set-volume 0.8)          # так работает
(app/set-volume 0.8)      # unknown symbol

В нативном модуле из прошлого урока префикс g/ брался вовсе не отсюда, его давал import с опцией :as g. При встраивании импорта нет, поэтому и префикса нет. Хочешь пространство имён, включай его прямо в имя: {"app/set-volume", ...}.

Данные из C в скрипт

Отдельные значения удобно класть в окружение через janet_def:

JanetTable *config = janet_table(2);
janet_table_put(config, janet_ckeywordv("name"), janet_cstringv("моё-приложение"));
janet_table_put(config, janet_ckeywordv("version"), janet_wrap_number(3));

janet_def(env, "config", janet_wrap_table(config), "Конфигурация от хоста.");

Скрипт видит это как обычное определение:

(printf "%V версии %p" (config :name) (config :version))
# моё-приложение версии 3

Четвёртый аргумент janet_def это докстрока, и она работает: (doc config) её покажет.

Песочница

Пользовательскому скрипту нужна песочница. Доступа ко всей стандартной библиотеке у него быть не должно: читать файлы и запускать процессы ему ни к чему.

Решение простое: не давай ему полное окружение. Собери своё, положив только разрешённое.

Сначала на самом Janet

Механику удобнее понять без C: тут её можно потрогать в REPL. Окружение это обычная таблица, поэтому песочница строится вручную:

(defn make-sandbox [allowed]
  (def sandbox @{})
  (each name allowed
    (when-let [binding (get root-env name)]
      (put sandbox name binding)))
  sandbox)

(def sb (make-sandbox ['+ '* 'print]))

Выполнение в этом окружении идёт через compile из урока про окружения:

(defn sandbox-eval [sandbox form]
  (def result (compile form sandbox))
  (if (function? result)
    (let [[ok value] (protect (result))]
      (if ok [:ok value] [:error value]))
    [:error (get result :error)]))

(sandbox-eval sb '(* (+ 1 2) 3))          # (:ok 9)
(sandbox-eval sb '(os/shell "echo"))      # (:error "unknown symbol os/shell")

Схема из трёх шагов: урезанное окружение, компиляция в нём, перехват ошибок. Обрати внимание, что ошибки бывают двух видов и обрабатываются в разных местах: компиляция вернула не функцию, а таблицу с описанием, а выполнение упало уже внутри protect.

Теперь перекладываем в C

Со стороны хоста те же три шага выглядят так:

JanetTable *sandbox = janet_table(0);
janet_def(sandbox, "+", janet_resolve_core("+"), NULL);
janet_def(sandbox, "print", janet_resolve_core("print"), NULL);

janet_dostring(sandbox, "(print (+ 1 2))", "песочница", NULL);        /* 3 */
janet_dostring(sandbox, "(os/shell \"rm -rf /\")", "песочница", NULL); /* 2 */

Последний вызов вернёт 2, ошибку компиляции unknown symbol os/shell. Опасной функции в окружении нет, а значит скрипт до неё не дотянется.

Пособирай окружение сам и посмотри, что проходит:

Белый список, а не чёрный. Перечисляй разрешённое, а не запрещённое. Список запретов всегда неполон: кроме os/shell есть os/execute, file/open, net/connect, ffi/native, require, и любая забытая функция это дыра. Тот же принцип мы формулировали в разделе про безопасность: валидация по белому списку, отказ по умолчанию.

Обрати особое внимание на require: его тоже надо держать вне песочницы, иначе скрипт подтянет что угодно из файловой системы и обойдёт весь твой белый список за одну строку.

Образы для быстрого старта

Последний трюк хоста. Если скриптовый слой при каждом запуске приложения делает одну и ту же дорогую подготовку (компилирует грамматики, строит таблицы, загружает модули), результат можно сохранить один раз и потом загружать готовым:

Сам Janet устроен так же: его стандартная библиотека вкомпилирована в исполняемый файл как образ, и потому интерпретатор стартует за миллисекунды. Со стороны C загрузка образа делается через janet_unmarshal: это пара к janet_dostring, только вместо прогона скриптов подготовки хост восстанавливает готовое состояние из байтов.

Что запомнить

  • Минимум это janet_init, janet_core_env, janet_dostring, janet_deinit.
  • Коды возврата: 0 успех, 1 ошибка выполнения, 2 ошибка компиляции. Ошибка скрипта программу не роняет.
  • Обмен значениями идёт через janet_wrap_*, janet_unwrap_*, janet_c*v. Имя типа лежит в массиве janet_type_names.
  • janet_cfuns кладёт имена голыми, префикс надо писать прямо в имени.
  • janet_def кладёт значение и докстроку в окружение.
  • Песочница это урезанное окружение, а не список запретов. require держи снаружи.
  • Схема песочницы одна и та же на Janet и в C: своё окружение, компиляция в нём, перехват обоих видов ошибок.
  • Образ ускоряет старт хоста, потому что дорогая подготовка делается один раз.

Упражнения

Дальше

Граница с C пройдена в обе стороны. Дальше три прикладных урока: тестирование и отладка, скриптинг и макросы, которые пишут определения.

домашка

Домашка