Эта страница переведена с английского. Читать страницу на английском
Запуск приложения 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.jsonacme.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), сервер записывает в лог причину, а каждый guardLicensedотказывает с сообщением "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)Что сервер хранит для каждого пользователя и где:
| Состояние | Где | Общее |
|---|---|---|
| Счётчики ограничения частоты | RateStore | RedisStore |
| Сохранённые представления сетки и раскладки dock | DataGridViewStore | RedisStore |
| Тайм-аут бездействия | браузер (все вкладки приложения) | ничего на сервере |
| Вход и повторная аутентификация | прокси единого входа | ничего на сервере |
| Причина для чувствительного экрана | 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 | Уровень | Поля |
|---|---|---|
request | info | method, path (без строки запроса), status, ms и, когда известно: kind, screen, action, user, tenant, ok, error |
run | info | код приложения: действие, источник данных (выборка сетки, поиск строки, хранилище представлений), построитель экрана |
interceptor | debug | name (имя типа или 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).
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-части каждый аргумент экранируется, а без записи.htmlHTML это экранированный текст. Из тем удаляются переводы строк, а адреса разбираются, поэтому данные не могут добавить заголовки или получателей. Mail::transitionописывает переход, который вызов только что выполнил черезWorkflow::apply(запись, из, в, кто и комментарий), встроенным шаблономzeyn.mail.transition. Showcase уведомляет руководителя, когда заявка на отпуск переходит в другое состояние.
| Ключ | Значение |
|---|---|
mail.from | Отправитель, например Northwind <no-reply@example.com> (обязателен) |
mail.transport | smtp, file (файл .eml на каждое сообщение, для разработки и тестов) или log (строка лога с числом получателей и размером, никогда не адреса и не содержимое; по умолчанию) |
mail.smtp.host, mail.smtp.port | SMTP-сервер |
mail.smtp.tls | starttls (по умолчанию), tls (неявный TLS, обычно порт 465) или none (только локальные релеи) |
mail.smtp.user, mail.smtp.password | Учётные данные; читайте пароль из источника секретов, например из окружения |
mail.file.dir | Каталог транспорта file |
TLS использует rustls с корневыми сертификатами Mozilla (webpki-roots).