CyberTrainРуководстваКак это работает

Как это работает

На этой странице объясняется, как CyberTrain-приложение собирается и запускается: код, генерируемый при сборке, представления, интерпретируемые во время запроса, HTTP-сервер, SQLite через FFI и цикл разработки, а затем таблица отличий от Rails. Раздел о производительности на главной странице сравнивает результат с тем же блогом, написанным на Rails 8.1. Методика, все цифры и способ повторить измерение — в docs/benchmark.md.

Генерация кода при сборке

spin run gen (bin/gen.rb) выполняет DSL create_table из db/schema.rb и DSL Routes.draw из config/routes.rb, чтобы получить данные таблиц и маршрутов. Отдельно он проводит лексическое сканирование (регулярными выражениями, а не парсером — Spinel не предоставляет его во время выполнения) файлов app/controllers/**/*.rb и app/models/**/*.rb в поисках присваиваний @ivar =, имён before_action/after_action/rescue_from ... with: и def без аргументов.

По этим данным он пишет в gen/ простой, читаемый Ruby:

  • gen/models/<name>.rb: аксессоры, приведения типов, finder-методы, ассоциации, выведенные из внешних ключей;
  • gen/routes.rb: таблица маршрутов, диспетчер, хелперы *_path/*_url;
  • gen/controllers.rb: view_assigns и run_callback для каждого контроллера;
  • gen/migrations.rb;
  • gen/views.rb: таблица встроенных представлений, пустая, если сборка не для развёртывания;
  • gen/app.rb: манифест require_relative.

gen/ коммитится, а не попадает в gitignore: у spin build и spin test нет хука, чтобы сначала выполнить генерацию, поэтому закоммиченный результат уже должен быть тем, что компилируется. CI заново запускает spin run gen и падает при наличии различий, так что устаревший сгенерированный код не может попасть в основную ветку.

Представления интерпретируются

Файлы app/views/**/*.html.erb выглядят как ERB из Rails, но во время запроса разбираются в AST, который затем обходит интерпретатор, проходящий по дереву. В Spinel нет eval, поэтому это замкнутая грамматика (литералы, обращения к @ivar/локальным переменным, вызовы через фиксированные таблицы диспетчеризации для каждого типа, if/unless, each/each_with_index/блок одного хелпера), а не настоящий Ruby. Полная грамматика, хелперы и пробелы описаны в справочнике по языку шаблонов.

Откуда берётся исходный текст, зависит от режима:

  • В режиме разработки (и в тестах) движок читает app/views/ с диска, а в режиме разработки заново разбирает шаблон, когда тот меняется, поэтому правки представлений не требуют пересборки.
  • cybertrain build запускает spin run gen -- --embed-views, который записывает исходный текст каждого шаблона в gen/views.rb как таблицу строк для этого единственного spin build, а затем снова запускает spin run gen, чтобы вернуть закоммиченную пустую таблицу.
  • Продакшен-бинарник отображает только эту таблицу (разбирая каждый шаблон один раз) и отказывается запускаться, если она пуста (error: views are not embedded in this binary).

HTTP-сервер

Cybertrain::Server — HTTP/1.1 на обычном Ruby: TCPServer#accept плюс один зелёный поток Spinel на соединение (планирование M:N, без GVL), типизированные Request/Response, без слоя Rack. TLS/HTTP/2 оставлены обратному прокси. SPINEL_WORKERS по умолчанию равен 1: спайки показали, что при такой нагрузке, ограниченной вводом-выводом, на 100 одновременных соединениях это быстрее и без зависаний.

Сервер вызывает HttpHandler (call(request) -> Response); ContextHandler связывает его со стеком middleware, поэтому HTTP-слой (cybertrain/http/, а также Params и Logger) не зависит от остального фреймворка; это обеспечивает script/check-http-layer в CI.

SQLite через FFI

Модели общаются с SQLite напрямую через ffi_func/ffi_lib Spinel — без расширения на C, без гема. Каждый запрос использует привязку параметров, а не интерполяцию. Сегодня это единственный адаптер (PostgreSQL через FFI libpq отмечен как возможная будущая работа).

Пул (SizedQueue, по умолчанию 4 соединения) выдаёт соединения с:

  • WAL;
  • тайм-аутом занятости около пяти секунд (обработчик занятости повторяет попытку каждые 100 мкс, тогда как busy_timeout SQLite засыпает на миллисекунду или больше);
  • до 64 кэшированных подготовленных выражений;
  • включёнными внешними ключами.

Как транзакция берёт одно из этих соединений и что на нём выполняется: Транзакции.

Цикл разработки

cybertrain server (spin run gen, затем spin run NAME) в development (CYBERTRAIN_ENV по умолчанию, кроме бинарного файла из cybertrain build) также опрашивает app/**/*.rb, config/**/*.rb, db/schema.rb и gen/**/*.rb каждые полсекунды. Представления исключены (движок сам их перезагружает), как и gen/views.rb, пустой в режиме разработки.

Изменение запускает spin run gen && spin build NAME -- --incremental в фоне: инкрементальная сборка Spinel, которая перекомпилирует только те единицы C, которые затронула правка. Релиз Spinel без --incremental отвергает этот флаг, и тогда цикл собирает без него.

Успех запрашивает перезапуск (тот же флаг, что устанавливает внешний kill -HUP): поток-монитор останавливает слушатель и дожидается завершения открытых запросов, затем главный поток делает execv нового бинарного файла на том же порту и с тем же PID, незаметно для клиента посреди сессии. Неудачная сборка продолжает обслуживать старым бинарным файлом и показывает вывод компилятора баннером в каждом HTML-ответе. Пересборки пропускаются, пока cybertrain build держит tmp/cybertrain-build.lock. Ничего из этого не загружается в production.

Отличия от Rails

Различия между Rails и CyberTrain (полная таблица из README)
RailsCyberTrain
rails consoleКонсоли нет — в Spinel нет eval
rails runner, lib/tasks/*.rakeCybertrain::Main.command("name", "description") { |argv| ...; 0 } в bin/<app>.rb: ./<app> name ARGS запускает её с подключённой базой данных
Правите код — запущенное приложение подхватывает измененияRuby требует пересборки; в режиме разработки cybertrain server делает это за вас (пересборка, остановка, execv)
Правите представление — перезагрузка не нужнаТак же — в режиме разработки представления разбираются с диска при каждом запросе
def newdef new_action (new затенил бы Klass.new(ctx))
before_action { do_thing } (неявный self)before_action { |c| c.do_thing } — для сохранённого блока нет instance_exec, поэтому колбэки получают контроллер/запись явно
resources :posts do resources :comments endresources :posts do |posts| posts.resources :comments end — вложенные блоки получают маппер явно
ERB компилируется в метод; любой объект, любой методИнтерпретируется по фиксированной грамматике/таблице диспетчеризации (Язык шаблонов)
Сессия хранит любой сериализуемый через Marshal объектЗначения сессии — только строки (String), cookie с подписью HMAC
Множество адаптеров баз данныхТолько SQLite, через FFI
Article.transaction { ... } возвращает значение блока; raise ActiveRecord::Rollback; точки сохранения requires_new: trueArticle.transaction { |c| ... } передаёт в блок соединение и возвращает nil; выходят из неё, возбуждая исключение; вложенные вызовы присоединяются к внешней транзакции, точек сохранения нет (Транзакции)
has_many :through, includes, dependent:, enum, STI, полиморфные ассоциацииНе реализовано; ассоциации выводятся только из внешних ключей схемы
pluck(:a), sum(:a), group(:a).count, upsert, update_counters, where("title LIKE ?", "%#{q}%")Типизированные варианты, потому что у метода Spinel один тип возвращаемого значения: pluck_int и ему подобные, sum_int, count_by, upsert, increment(:col, by: n), where_like (Запросы помимо where)
Любое имя столбца; ассоциация может затенить errorsТолько идентификаторы ASCII; столбец, названный как метод фреймворка или ключевое слово Ruby, генерируется как <column>_column, конфликтующая ассоциация — под запасным именем (Имена столбцов)
namespace, форматы/respond_to, constraints, mountНе реализовано — плоские имена, только render json:
rescue StandardError перехватывает неудачный JSON.parseПод Spinel JSON::ParserError не является StandardError: коду приложения нужно rescue JSON::ParserError, StandardError. Сервер перехватывает его в крайнем случае и отвечает 500
skip_forgery_protection в API-контроллереCSRF выполняется до маршрутизации, поэтому API исключается по пути, c.csrf_exempt_prefixes = ["/api/"], и сам аутентифицирует вызывающих (Конфигурация)
Rack оставляет тело запроса в gzip как естьТело с Content-Encoding: gzip — это request.decoded_body, распакованное с ограничением в 10 МиБ; повреждённое или слишком большое — 400; request.body остаётся как отправлено (Строки запроса и тела)
order("lower(title)"), order("posts.title"), order(params[:sort])order принимает только списки column [ASC|DESC]; данные запроса проходят через список разрешённых значений, сырой SQL — через order_sql (Порядок). Некорректные или слишком глубоко вложенные параметры — 400 (Строки запроса и тела)
Полный бэктрейс при исключенииКласс, сообщение, строка запроса, имя/строка шаблона — Spinel не предоставляет бэктрейсов
Rack, его middleware и любой гем в GemfileНет совместимости с Rack; небольшой фиксированный набор middleware; собственный spin-index Spinel, ограниченный тем, что компилируется в рамках его подмножества Ruby
minitest / RSpecCybertrain::Test — раннеры на основе рефлексии не могут работать при AOT-компиляции
bin/rails generate authentication, has_secure_password + bcryptcybertrain generate authentication пишет ту же структуру плюс страницу регистрации; пароли используют PBKDF2-HMAC-SHA256 (bcrypt потребовал бы библиотеку на C; String#crypt на macOS не поддерживает bcrypt); сброса пароля нет (Добавление входа)
Active Storage, has_one_attached :covercybertrain generate attachment пишет пять столбцов cover_* и методы модели; файлы лежат на локальном диске в storage/uploads/ и отдаются через send_blob; один файл на поле, без вариантов и прямых загрузок (Добавление обложки)
skip_before_action :xТо же самое для именованных before-колбэков (у колбэка-блока нет имени, которое можно пропустить); skip_after_action нет
resource :sessionТе же шесть маршрутов к SessionsController; вложение в него не поддерживается
get "/x", затем post "/x"Оба допустимы: выведенное имя второго маршрута уже занято, поэтому он остаётся без имени, как в Rails; повторяющийся as: по-прежнему ошибка
Time.zone = "Asia/Tokyo", time.in_time_zone, данные часовых поясов из ActiveSupportCybertrain::TimeZone.find("Asia/Tokyo") читает системные файлы TZif (/usr/share/zoneinfo или TZDIR) и преобразует целые секунды эпохи Unix (utc_offset_at, utc_to_local, local_to_utc); Time.zone на запрос и l(time, zone:) пока нет (Часовые пояса)
Net::HTTP, FaradayCybertrain::HttpClient для вебхуков и API: каждое ожидание ограничено, непубличные адреса назначения по умолчанию отвергаются, без редиректов и keep-alive (см. Исходящий HTTP); Net::HTTP из Spinel блокирует весь сервер на зависшем собеседнике
Action Mailer (deliver_later, классы мейлеров, представления)Cybertrain::Mailer.deliver(to:, subject:, body:) отправляет простой текст по SMTP (STARTTLS, неявный TLS или открытым текстом на loopback; AUTH PLAIN; каждое ожидание ограничено); без шаблонов, HTML, вложений и очереди (см. Отправка электронной почты)
Active Job, perform_later, solid_queue, повторяющиеся задачиCybertrain::Job.handle(kind) и Job.enqueue(kind, payload, run_at:) (полезная нагрузка String, повтор с нарастающей задержкой), Cybertrain::Scheduler.every(seconds, name); один поток в обслуживающем процессе (Фоновая работа)
Клиент ActionCable, faye-websocket, websocket-client-simpleCybertrain::WebSocket.subscribe(name, url) держит поток подключённым, пока работает сервер (переподключения, пинги, обнаружение тишины); WebSocket::Client — для одного синхронного соединения; только исходящие (Потребление потоков WebSocket)
config.filter_parameters += [:passw, /\Apin\z/, ->(k, v) { ... }]config.filter_parameters — массив строк, сопоставляемых по подстроке без учёта регистра (без Regexp и лямбд); применяется к логу запросов и странице ошибок в режиме разработки (Фильтруемые параметры)
get "up" => "rails/health#show" (маршрут к контроллеру)GET /up встроен и получает ответ до статических файлов, сессии и маршрутизатора, поэтому никакой before_action не выполняется; он проверяет базу данных и обработчик фоновых задач: 200 ok или 503 (Эксплуатация)
Задачи bin/rails db:... для резервных копий (встроенных нет)./<app> db backup PATH / db backup DIR/ --keep N (VACUUM INTO, режим 0600, переименование на место) и ./<app> db vacuum
ActionCable (сервер), asset pipeline, i18nНе сделано (за рамками MVP, docs/design.md §2.3)

Список не исчерпывающий: более полная запись в docs/design.md §8 («What was deliberately left out»).