Перейти к содержимому

Эксплуатация

Эта страница переведена с английского. Читать страницу на английском

Запуск приложения Zeyn в продакшене: данные о часовых поясах, подписанные лицензии, общее состояние для нескольких серверов, логи и трассировки, PDF-документы и e-mail.

Часовые пояса

TimeZone::named принимает любую зону IANA (Europe/Istanbul, America/New_York, Australia/Sydney, в любом регистре) и фиксированные смещения (UTC+03:00, +05:30).

  • Данные зон это база часовых поясов IANA, встроенная в бинарный файл через jiff (около 200 КБ). Ответы одинаковы на каждой платформе, включая Windows, и системный zoneinfo не читается.
  • История смоделирована: переход Турции на летнее время до 2016 года и её постоянное +03 после, правила США до 2007 года и каждое другое зафиксированное изменение.
  • Время по настенным часам, которого не существует (часы переведены вперёд), отклоняется с LocalTimeError::Skipped. Время, которое наступает дважды (часы переведены назад), означает более ранний момент.
  • Зона читается из встроенного набора при первом использовании её имени и хранится всё время жизни процесса, по несколько килобайт на каждую. Неизвестные имена не сохраняются никогда, поэтому клиенты не могут раздувать кэш.
  • TimeZone::names() это короткий список распространённых зон для выбора зоны в shell. Зона пользователя, которой нет в списке, добавляется в выбор для этого пользователя.
  • Новые правила зон приходят с более новым релизом jiff (cargo update -p jiff-tzdb). Пересоберите и разверните приложение, когда страна меняет свои правила.

Подписанные лицензии

Файл лицензии подписывается ключом ed25519, который есть только у поставщика. Приложение встраивает публичный ключ, поэтому клиент может прочитать лицензию, но не может изменить её или создать новую. Включается Cargo feature signed-licenses.

zeyn license keygen                      # writes zeyn-license.key, prints the public key
zeyn license sign acme.json --out license.json

acme.json содержит поля лицензии:

{ "holder": "Acme", "valid_until": "2027-12-31", "modules": ["pos", "stock"], "max_users": 50 }
const LICENSE_KEY: &str = "f414a89e9dae57598d60141df96dd1c9fb655fdcf49f5eaf24e093fda7d332e3";

App::new("Shop")
    .license(SignedLicenseFile::new("license.json", LICENSE_KEY), Duration::from_secs(3600))
    .feature(Feature::new("pos").guard(Licensed::module("pos")).screen(pos))
  • Файл это JSON, где лицензия лежит текстом в payload, а подпись в signature. Подписывается zeyn-license-v1\n, за которым следует текст payload, поэтому подписанные байты в точности равны сохранённым.
  • Приватный ключ остаётся на машине, где выполняется подпись. keygen никогда не перезаписывает файл ключа, а *.key входит в .gitignore новых проектов. sign читает ключ из --key, из переменной ZEYN_LICENSE_KEY или из zeyn-license.key и отклоняет лицензию без holder или с valid_until, не соответствующим YYYY-MM-DD.
  • Изменённый файл, файл, подписанный другим ключом, или файл, не являющийся лицензией, отклоняется: License::from_signed возвращает LicenseError (BadSignature, InvalidPublicKey, Malformed), сервер записывает в лог причину, а каждый guard Licensed отказывает с сообщением "Your license does not cover this." Файл, который не удаётся прочитать, сохраняет прежнюю лицензию, как любой сбойный источник лицензии.
  • Срок действия проверяет guard при каждом запросе относительно сегодняшней даты пользователя, поэтому истёкшая лицензия блокирует приложение сообщением "Your license has expired." без перезапуска. Продлённый файл читается при следующем обновлении.
  • ed25519 не отличает изменённый файл от файла, подписанного другим ключом; оба дают несовпадающую подпись, и сообщение об этом говорит.
  • max_users переносится и подписывается, но фреймворком не применяется; приложение само решает, что считать пользователем.

Несколько серверов: общее состояние в Redis

С Cargo feature redis RedisStore хранит состояние, которое должно быть одинаковым на каждом экземпляре за балансировщиком нагрузки:

let redis = RedisStore::new(config.require("redis.url")?, "billing").map_err(Error::Internal)?;
App::new("Billing")
    .rate_limit(RateLimit::per_ip(300, Duration::from_secs(60)))
    .rate_store(redis.clone())
    .datagrid_views(redis)

Что сервер хранит для каждого пользователя и где:

СостояниеГдеОбщее
Счётчики ограничения частотыRateStoreRedisStore
Сохранённые представления сетки и раскладки dockDataGridViewStoreRedisStore
Тайм-аут бездействиябраузер (все вкладки приложения)ничего на сервере
Вход и повторная аутентификацияпрокси единого входаничего на сервере
Причина для чувствительного экранаcookie HttpOnlyничего на сервере
Черновики формsessionStorageничего на сервере
Кэш лицензиина процесс, перечитывается из источникакаждый сервер загружает свою
Фоновые задачина процессне общие; см. раздел о задачах в enterprise.md
  • Ограничения частоты это token bucket в одном скрипте Lua, поэтому параллельные запросы на разных серверах никогда не превышают лимит. Время берётся из Redis, а не с серверов приложения. Счётчики истекают через одно окно после последнего использования.
  • Представления это JSON по ключу zeyn:<prefix>:view:<user length>:<user>:<grid>, и они никогда не истекают.
  • Соединение создаётся при первом использовании и восстанавливается само. Вызовы завершаются по тайм-ауту через 500 мс. Когда Redis недоступен, ограничения частоты пропускают запросы, представления не загружаются и не сохраняются (сетка возвращается к собственной раскладке, dock к localStorage), сбой и восстановление логируются по одному разу, а после каждого сбоя Redis оставляют в покое на пять секунд, чтобы запросы не ждали тайм-аута по одному.
  • RateStore::take возвращает future, чтобы хранилища могли обращаться к сети. MemoryRateStore::take остаётся синхронным для прямого использования.
  • TLS (rediss://) не встроен.
  • Тесты выполняются на настоящем сервере, когда задана ZEYN_TEST_REDIS:
  docker run -d --rm --name zeyn-redis -p 6379:6379 redis:7-alpine
  ZEYN_TEST_REDIS=redis://127.0.0.1:6379 cargo test -p zeyn --features redis --test redis
  docker stop zeyn-redis

Логи и трассировки

Фреймворк сообщает через tracing. Сам он никогда ничего не печатает.

  • App::serve и App::serve_until устанавливают subscriber, если приложение не установило его раньше: события идут в стандартный поток ошибок текстовыми строками и фильтруются по log.level (по умолчанию info; подойдёт любой фильтр target из tracing, например warn,zeyn=debug). Приложение, которому нужны строки JSON или другой приёмник, устанавливает свой subscriber до запуска, и Zeyn его не трогает.
  • На уровне info вывод остаётся прежним: адрес прослушивания, предупреждения при запуске (отсутствующие переводы, DevAuth, dev_latency), сбойные источники лицензий и внутренние ошибки. На уровне debug каждый вызов добавляет строку call finished с видом, экраном, действием, id пользователя, id арендатора, исходом, кодом ошибки и миллисекундами.
  • Спаны для бэкендов трассировки:
SpanУровеньПоля
requestinfomethod, path (без строки запроса), status, ms и, когда известно: kind, screen, action, user, tenant, ok, error
runinfoкод приложения: действие, источник данных (выборка сетки, поиск строки, хранилище представлений), построитель экрана
interceptordebugname (имя типа или Interceptor::name), stage (before или after)
  • С Cargo feature otel спаны экспортируются по OTLP/HTTP, когда в Config задан otel.endpoint (http://collector:4318; спаны идут на /v1/traces). otel.service_name называет сервис, по умолчанию это имя приложения. Спаны пакетируются в фоновом потоке и сбрасываются, когда останавливается serve_until.
  • Без экспорта стандартный поток ошибок получает только события, поэтому спаны не строятся, и каждый стоит одну кэшированную проверку. Приложения, смонтированные через App::router в другом сервере, и тесты платят столько же.
  • Что не записывается никогда: значения полей (замаскированные или нет), ввод форм и API, строки запросов, cookie, заголовки и секреты. Тест выполняет сетки, сохранения форм, вызовы API и раскрытия под subscriber уровня trace и проверяет, что замаскированных значений нигде нет. Две вещи остаются на стороне приложения: пути записываются, поэтому не помещайте персональные данные в маршруты, а сообщения Error::Internal логируются как написаны.

PDF-документы

Cargo feature pdf формирует PDF-документы по шаблонам Typst. По умолчанию он выключен, потому что Typst велик (см. цифры ниже).

const INVOICE: PdfTemplate = PdfTemplate::new("invoice", include_str!("../templates/invoice.typ"));

#[action(permission = "sales.order.read")]
pub async fn invoice_data(p: OrderParams, store: Orders, ctx: Ctx) -> Result<InvoiceData> { ... }

Screen::window("Order").body(PdfLink::new("Print invoice", INVOICE, invoice_data, params))

App::new("Shop").service(Pdf::from_config(&config)?)
  • PdfLink помещает на экран кнопку загрузки. GET /_zeyn/pdf открывает экран с его проверками доступа, выполняет действие от имени пользователя (применяются его permission и interceptors, вид вызова Document) и формирует шаблон из того, что вернуло действие. Пользователи, которым нельзя выполнять действие, кнопку не видят.
  • Сетки с exportable() также печатают отчёт-список из Export::Pdf на панели инструментов (GET /_zeyn/grid/export?format=pdf): столбцы, подписи, фильтры и сортировка пользователя, числа по правому краю, повторяющаяся строка заголовка и номера страниц. Отчёт содержит не более 2000 строк (MAX_PDF_ROWS, или лимит экспорта сетки, если он меньше) и сообщает об этом, когда строки были опущены.
  • Данные никогда не становятся кодом Typst. Вывод действия сериализуется в JSON, а шаблон читает его как data (рендерер связывает #let data = json("/data.json") перед шаблоном). Значения вроде #panic(), ] или $x$ печатаются как текст; это проверяет тест. Шаблоны не могут читать ничего другого: ни файлов, ни пакетов.
  • Формирование выполняется в блокирующем потоке. Одновременно формируется не более pdf.max_concurrent документов (от 1 до 64; по умолчанию два, один на одном CPU); остальные ждут своего слота. Кэш мемоизации Typst очищается после каждого документа, поэтому память снова уменьшается.
КлючЗначение
pdf.fontsКаталог дополнительных шрифтов .ttf, .otf, .ttc или .otc, читается один раз при запуске
pdf.max_concurrentЧисло одновременно формируемых документов

Шрифты: Libertinus Serif (обычный, полужирный, курсив, полужирный курсив; SIL Open Font License) встроен и покрывает турецкий; DejaVu Sans Mono встроен как резервный для знаков, которых нет в Libertinus, например ₺. Их лицензии лежат в crates/zeyn/assets/fonts. Математического шрифта нет; добавьте его через pdf.fonts, если шаблоны задают формулы.

Измерено на Windows (релизная сборка, x86_64):

Результат
Размер бинарного файла5.5 МБ без pdf, около 46 МБ с ним (Typst добавляет около 39 МБ, шрифты 1.6 МБ)
Одностраничный документот 4 до 7 мс, менее 1 МБ кучи при формировании, файл 10 КБ
Отчёт-список из 1000 строк (25 страниц)от 0.54 до 0.74 с, пик кучи 51 МБ при формировании, 0 после, файл 0.87 МБ
Первый документ после запускаОколо 42 МБ кучи остаётся под собственные лениво строящиеся таблицы Typst
Шрифты0.25 МБ кучи под индекс шрифтов

Отладочная сборка примерно в десять раз медленнее (отчёт из 2000 строк занял 71 с в showcase).

E-mail

Cargo feature email отправляет уведомления с помощью lettre.

let mailer = Mailer::from_config(&config)?;
App::new("People").mailer(mailer)

#[action(permission = "people.leave.request", audit)]
pub async fn move_leave(r: TransitionRequest<LeaveRequestId>, store: Leaves, mailer: Mailer, ctx: Ctx) -> Result<LeaveState> {
    let state = leave_flow().apply(&ctx, current, &r.transition, r.comment.as_deref())?;
    mailer.send(&ctx, Mail::transition(&ctx, &manager_email, &employee)?)?;
    Ok(state)
}
  • Mailer::send формирует сообщение и помещает его в очередь в памяти (не более 10 000 сообщений), затем возвращается. Фоновая задача zeyn.mail сразу отправляет очередь, повторяет неудавшиеся сообщения с нарастающей задержкой (пять раз начиная с десяти секунд, если Mailer::retry не говорит иное) и просматривает очередь каждую минуту. Сообщение отбрасывается, с ошибкой в логе, после восьми неудачных попыток. Как и любая задача, очередь, не отправленная к моменту остановки процесса, теряется; для надёжной доставки нужна очередь слоя данных на базе базы данных.
  • Сообщения берутся из каталогов i18n: <template>.subject, <template>.text и необязательно <template>.html, с аргументами {name}. Язык получателя совпадает с языком отправителя, если Mail::locale не задаёт иное. У каждого сообщения есть текстовая и HTML-часть; в HTML-части каждый аргумент экранируется, а без записи .html HTML это экранированный текст. Из тем удаляются переводы строк, а адреса разбираются, поэтому данные не могут добавить заголовки или получателей.
  • Mail::transition описывает переход, который вызов только что выполнил через Workflow::apply (запись, из, в, кто и комментарий), встроенным шаблоном zeyn.mail.transition. Showcase уведомляет руководителя, когда заявка на отпуск переходит в другое состояние.
КлючЗначение
mail.fromОтправитель, например Northwind <no-reply@example.com> (обязателен)
mail.transportsmtp, file (файл .eml на каждое сообщение, для разработки и тестов) или log (строка лога с числом получателей и размером, никогда не адреса и не содержимое; по умолчанию)
mail.smtp.host, mail.smtp.portSMTP-сервер
mail.smtp.tlsstarttls (по умолчанию), tls (неявный TLS, обычно порт 465) или none (только локальные релеи)
mail.smtp.user, mail.smtp.passwordУчётные данные; читайте пароль из источника секретов, например из окружения
mail.file.dirКаталог транспорта file

TLS использует rustls с корневыми сертификатами Mozilla (webpki-roots).