Серия: redb ecosystem / redb.Route redb.Tsak
Есть у интеграционного кода одна неприятная особенность. Написать пару маршрутов — «принял HTTP, положил в базу, отдал обратно» — дело на полчаса. А вот довести это до состояния, когда оно крутится в проде, само поднимается, показывает метрики, умеет останавливать/запускать отдельные куски руками и разворачивается без пересборки — это обычно совсем другая история и совсем другой стек.
В этой статье я покажу, что в связке redb.Route + redb.Tsak это буквально один и тот же код. Мы:
-
напишем два простейших маршрута (POST — сохранить, GET — достать по параметру) на redb.Route;
-
сделаем свой воркер для отладки — обычное консольное приложение, которое можно гонять под F5 с точками останова;
-
а потом, не меняя ни строчки в маршрутах, упакуем всё в модуль и закинем в Tsak — и тот же крошечный проект получит дашборд, hot-reload, управление контекстами и энтерпрайз-деплой (докером и без докера).
Проект целиком лежит в репозитории (redb.Route/demos/EchoWorkerDemo), и я вставлю его сюда полностью — можно копировать и повторять.
Цикл про redb и redb.Route. Это продолжение серии, свежие статьи — сверху:
redb.Route — уходим от MassTransit, идём к Apache Camel: Kafka, Scatter‑Gather и транзакции
Apache Camel под .NET: HTTP-коннектор без ASP.NET MVC + Content-Based Router
redb.Route — Apache Camel для .NET, который мы написали потому что выхода другого не было
Полный список — в профиле. Исходники: github.com/redbase-app. Про саму БД redb: redb.ru.
Здесь — вводная про воркеры и деплой; про кластер, координатор и failover будет отдельно.
Что вообще делаем
Мини-сервис заметок. Две ручки на одном HTTP-порту:
-
POST /api/notesс телом{"tag":"work","text":"hello"}— сохранить заметку в базу redb (на SQLite); -
GET /api/notes?tag=work— вернуть заметки с этим тегом; выборка — серверным запросомWhere(...).ToListAsync(), параметр берётся прямо из query-строки.
Ничего лишнего. Задача статьи не в бизнес-логике, а в том, чтобы показать жизненный цикл: как один и тот же набор маршрутов сначала живёт в отладочной консоли, а потом — в энтерпрайз-рантайме.
Структура проекта: два проекта, одна точка входа
Раскладка такая:
EchoWorkerDemo/├─ EchoModule/ <- проект 1: модуль (class library → EchoModule.tpkg)│ ├─ InitRoute.cs <- main(IRouteContext): два маршрута + класс Note│ ├─ manifest.json <- { Name, Version, EntryPoints: ["EchoModule.dll"] }│ ├─ EchoModule.config.json <- имя контекста (ContextName) + AutoStart│ └─ EchoModule.csproj <- ссылки на коннекторы + таргет PackTpkg└─ EchoWorker/ <- проект 2: отладочный хост (exe) ├─ Program.cs <- redb на SQLite + вызов InitRoute.main + Start └─ EchoWorker.csproj
Идея, вокруг которой всё вертится: точка входа одна — InitRoute.main(IRouteContext). Её вызывает и наш отладочный воркер, и настоящий Tsak-рантайм. Код маршрутов живёт ровно в одном месте — в EchoModule. Отладочный EchoWorker — это просто «обвязка», которая поднимает базу и дёргает ту же main.
Почему два проекта, а не один? Технически можно и в один (exe тоже даёт DLL, а Tsak ищет InitRoute.main рефлексией в любой сборке). Но для наглядности две роли лучше развести: EchoModule — это то, что мы отгружаем (пакуется в .tpkg), а EchoWorker — то, чем отлаживаем. Модуль не содержит ни строчки хост-кода — и это правильно: в проде базу ему даст Tsak.
Проект 1: модуль с двумя маршрутами
Вот он целиком — EchoModule/InitRoute.cs:
using System.Text.Json;using redb.Core; // IRedbService, Query, SaveAsync, SyncSchemeAsyncusing redb.Core.Attributes; // RedbSchemeusing redb.Core.Models.Entities; // RedbObject<T>using redb.Route.Abstractions; // IRouteContext, IExchangeusing redb.Route.Core; // RouteContextusing redb.Route.Http; // HttpComponent, SharedHttpServerManagerusing redb.Route.RedbCore.Extensions; // ProcessWithRedb, GetRedbServicenamespace EchoModule;/// <summary>/// Tsak module entry point./// The worker discovers it by convention — a public static class named InitRoute/// with a public static main(IRouteContext) — and calls it once when the module/// loads. The debug host (EchoWorker/Program.cs) calls the very same method, so the/// route code below lives in exactly one place.////// Two minimal endpoints on the shared HTTP server (port 5099), backed by redb/SQLite:/// POST /api/notes body {"tag":"work","text":"hello"} -> save one note/// GET /api/notes?tag=work -> list notes with that tag/// </summary>public static class InitRoute{ private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; public static IRouteContext main(IRouteContext context) { // redb schema for Note. Idempotent — safe to call every load. The worker // (or the debug host) has already brought redb + SQLite up by now. context.GetRedbService().SyncSchemeAsync<Note>().GetAwaiter().GetResult(); // One shared HTTP server; both routes below bind to it. context.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() }); ((RouteContext)context).AddRoutes(r => { // --- POST /api/notes — save one note --- r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=POST") .RouteId("notes-post") .ConvertBody<string>() // HTTP body -> string (JSON) .ProcessWithRedb(async (db, ex, ct) => { var note = JsonSerializer.Deserialize<Note>(ex.In.Body?.ToString() ?? "{}", Json) ?? new Note(); var obj = new RedbObject<Note> { name = $"note:{note.Tag}", Props = note }; await db.SaveAsync(obj); // one insert into redb (SQLite) Reply(ex, new { saved = true, id = obj.id }); }).Log("Save ${body}"); // --- GET /api/notes?tag=work — list by tag --- r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=GET") .RouteId("notes-get") .ProcessWithRedb(async (db, ex, ct) => { // ?tag=... arrives as the header redbHttp.QueryParam.tag var tag = ex.In.Headers.TryGetValue("redbHttp.QueryParam.tag", out var t) ? t?.ToString() ?? "" : ""; // Server-side filter: the GET parameter goes straight into Where(...). var found = await db.Query<Note>() .Where(n => n.Tag == tag) .ToListAsync(); Reply(ex, found.Select(o => new { o.Props.Tag, o.Props.Text })); }).Log("Load ${header.redbHttp.QueryParam.tag}"); }); return context; } // inOut=true -> whatever the body is at the end becomes the HTTP response. private static void Reply(IExchange ex, object body) { ex.In.ContentType = "application/json"; ex.In.Body = JsonSerializer.Serialize(body); }}/// <summary>Persisted note. [RedbScheme] marks the class as a redb schema.</summary>[RedbScheme]public sealed class Note{ public string Tag { get; set; } = ""; public string Text { get; set; } = "";}
Разберём по косточкам — тут каждая строчка что-то значит.
Точка входа main
public static IRouteContext main(IRouteContext context)
Это и есть контракт модуля Tsak. Никаких атрибутов, никаких интерфейсов — соглашение об именах: публичный статический класс InitRoute, публичный статический метод main, принимающий IRouteContext. Tsak при загрузке сборки сканирует её рефлексией, находит этот метод и вызывает его, передавая контекст. Всё, что вы навесите на context — компоненты, маршруты, слушатели — станет частью рантайма.
Важно: это работает и в отладочном воркере. Там мы сами создадим RouteContext и сами вызовем InitRoute.main(ctx). Один и тот же метод, две среды запуска.
Схема данных
context.GetRedbService().SyncSchemeAsync<Note>().GetAwaiter().GetResult();
Note помечен атрибутом [RedbScheme] — значит, это persistable-класс redb. SyncSchemeAsync<Note>() заводит для него схему (идемпотентно — вызывать можно при каждой загрузке). GetRedbService() достаёт IRedbService из контекста: в Tsak базу уже подняли до вызова модуля, в отладочном воркере — мы поднимем её сами до main. В обоих случаях к моменту вызова redb доступен.
Обратите внимание: модуль не конфигурирует базу. Он не знает и не должен знать, SQLite там, Postgres или MSSQL. Он просто просит IRedbService у контекста. Провайдер — забота хоста.
Один HTTP-сервер, два маршрута
context.AddComponent(new HttpComponent { ServerManager = new SharedHttpServerManager() });
SharedHttpServerManager — это общий HTTP-сервер: несколько маршрутов могут висеть на одном порту. У нас оба маршрута — на 5099.
r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=POST")r.From("http:0.0.0.0:5099/api/notes?inOut=true&methods=GET")
Оба слушают один и тот же путь /api/notes, но разъезжаются по методу через ?methods=POST / ?methods=GET. Сервер матчит входящий запрос по паре «путь + метод»: POST уедет в первый маршрут, GET — во второй, а, скажем, PUT /api/notes вернёт честный 405 Method Not Allowed (путь есть, метод не тот). inOut=true означает «синхронный запрос-ответ»: то, что окажется в теле обмена на выходе маршрута, станет HTTP-ответом.
POST: сохранить
.ConvertBody<string>().ProcessWithRedb(async (db, ex, ct) =>{ var note = JsonSerializer.Deserialize<Note>(ex.In.Body?.ToString() ?? "{}", Json) ?? new Note(); var obj = new RedbObject<Note> { name = $"note:{note.Tag}", Props = note }; await db.SaveAsync(obj); Reply(ex, new { saved = true, id = obj.id });}).Log("Save ${body}");
ConvertBody<string>() превращает тело HTTP-запроса (байты) в строку. ProcessWithRedb — это шаг обработки, в который redb.Route сам прокидывает IRedbService (db): под капотом он берёт per-exchange DI-скоуп, если тот есть, иначе — синглтон контекста. Дальше — обычный C#: десериализовали JSON в Note, завернули в RedbObject<Note>, сохранили одним SaveAsync, вернули { saved, id }.
Хвостик .Log("Save ${body}") — это шаг логирования с подстановкой: ${body} подставит текущее тело обмена. Синтаксис ${...} в redb.Route умеет доставать body, заголовки (${header.X}) и прочее — удобно для трассировки прямо в DSL.
GET: достать по параметру
var tag = ex.In.Headers.TryGetValue("redbHttp.QueryParam.tag", out var t) ? t?.ToString() ?? "" : "";var found = await db.Query<Note>() .Where(n => n.Tag == tag) .ToListAsync();Reply(ex, found.Select(o => new { o.Props.Tag, o.Props.Text }));
Query-параметры HTTP redb.Route раскладывает по заголовкам с префиксом redbHttp.QueryParam. — так что ?tag=work приезжает как заголовок redbHttp.QueryParam.tag. Достаём значение и кладём его прямо в серверный запрос:
db.Query<Note>().Where(n => n.Tag == tag).ToListAsync()
Это не выборка «всё в память, потом отфильтровать» — это запрос на стороне базы: лямбда n => n.Tag == tag компилируется в условие по полю Note.Tag. Результат — коллекция RedbObject<Note>, поэтому к полям обращаемся через .Props.
Хвост .Log("Load ${header.redbHttp.QueryParam.tag}") логирует, по какому тегу пришёл запрос.
Вот и весь модуль. Два маршрута, класс данных, ноль инфраструктуры. Теперь научимся его запускать и отлаживать.
Проект 2: свой воркер для отладки
Чтобы гонять маршруты под отладчиком, не поднимая никакого Tsak, сделаем крошечное консольное приложение. Оно повторяет ровно то, что делает Tsak-воркер при загрузке модуля, но в минимальном объёме. Вот EchoWorker/Program.cs целиком:
// ============================================================================// EchoWorker — a debug host for the EchoModule Tsak module.//// It reproduces, in the smallest possible way, what the Tsak worker does:// 1) stand redb up on SQLite (Free tier — the worker's default),// 2) create the redb system tables once,// 3) hand a RouteContext to EchoModule.InitRoute.main — the SAME entry point// the worker calls, so no route code is duplicated here.//// Run it, then (PowerShell — JSON in single quotes; cmd.exe needs \" escaping instead):// POST: curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'// GET: curl.exe "http://localhost:5099/api/notes?tag=work"// ============================================================================using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Logging;using redb.Core; // IRedbServiceusing redb.Core.Extensions; // AddRedbusing redb.Core.Models.Configuration; // PropsSaveStrategyusing redb.SQLite.Pro.Extensions; // UseSqlite (tier-agnostic: AddRedb -> Free)using redb.SQLite.Data; // SqliteDataSource.NativeExtensionPathusing redb.Route.Core; // RouteContextnamespace EchoWorker;public static class Program{ public static async Task Main(string[] args) { // Free SQLite needs the native redb extension. The packaged Tsak worker ships // it; running from source we point at the one built under redb.SQLite/native/build. SqliteDataSource.NativeExtensionPath ??= ResolveNativeExtension(); // DI: console logging + redb on SQLite (single-file DB next to the exe). var services = new ServiceCollection(); services.AddLogging(b => b .AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; }) .SetMinimumLevel(LogLevel.Information)); services.AddRedb(o => o .UseSqlite("Data Source=echo_demo.db") .Configure(c => c.PropsSaveStrategy = PropsSaveStrategy.DeleteInsert)); var sp = services.BuildServiceProvider(); // Create the redb system tables once (the worker does this on boot). // ensureCreated: true builds the base tables on a fresh SQLite file. await sp.GetRequiredService<IRedbService>().InitializeAsync(ensureCreated: true); // Build a route context over that provider and call the module entry point. var ctx = new RouteContext(sp, contextId: "echo-worker"); ctx.AddService(typeof(ILoggerFactory), sp.GetRequiredService<ILoggerFactory>()); EchoModule.InitRoute.main(ctx); // <- the exact method the Tsak worker calls await ctx.Start(); Console.WriteLine(); Console.WriteLine("EchoWorker running: http://localhost:5099/api/notes"); Console.WriteLine(" POST {\"tag\":\"work\",\"text\":\"hello\"} -> save"); Console.WriteLine(" GET ?tag=work -> list by tag"); Console.WriteLine("Ctrl+C to exit."); Console.WriteLine(); var stop = new ManualResetEventSlim(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); }; stop.Wait(); await ctx.DisposeAsync(); } // Walk up from the app dir to the repo's built Free SQLite native extension. // Returns null when running from a packaged worker (it resolves the extension itself). private static string? ResolveNativeExtension() { var suffix = OperatingSystem.IsWindows() ? ".dll" : OperatingSystem.IsMacOS() ? ".dylib" : ".so"; for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) { var candidate = Path.Combine(dir.FullName, "redb.SQLite", "native", "build", "redb" + suffix); if (File.Exists(candidate)) return candidate; } return null; }}
Что здесь происходит, по шагам.
1. Нативное расширение SQLite. redb на SQLite в бесплатном тире использует нативное загружаемое расширение (redb.dll / .so / .dylib) — в нём живёт часть машинерии запросов. В упакованном Tsak-воркере это расширение едет в комплекте. А когда мы запускаем из исходников, надо показать путь к собранному бинарю — ResolveNativeExtension() просто идёт вверх по дереву каталогов и ищет redb.SQLite/native/build/redb.dll. Одна честная деталь, которую в статье лучше не прятать: без этого расширения бесплатный SQLite-провайдер не заведётся.
2. DI и база. Обычный ServiceCollection: логирование в консоль + AddRedb(...).UseSqlite("Data Source=echo_demo.db"). UseSqlite тир-агностичен: AddRedb даёт Free, AddRedbPro — Pro; тир переключается без правки using-ов. Это ровно тот же тир (Free/SQLite), что Tsak-воркер поднимает по умолчанию — то есть отладка совпадает с боевым.
3. Системные таблицы. InitializeAsync(ensureCreated: true) создаёт базовые таблицы redb на свежем файле. В Tsak это делает сам воркер на старте; в отладочном хосте — делаем мы.
4. Контекст и вызов модуля. Создаём RouteContext поверх нашего провайдера и зовём EchoModule.InitRoute.main(ctx). Это та же самая точка входа, что дёрнет Tsak. Никакого дублирования маршрутов — весь код в модуле.
5. Старт и ожидание. ctx.Start() поднимает HTTP-сервер на 5099, дальше висим до Ctrl+C.
Файлы проектов
EchoModule/EchoModule.csproj — библиотека + таргет упаковки в .tpkg:
<Project Sdk="Microsoft.NET.Sdk"> <!-- Project 1 of 2: the Tsak MODULE. A class library. Its DLL is what gets packed into EchoModule.tpkg and hot-loaded by the Tsak worker. It contains ONLY route code — no host, no DB provider: the worker supplies redb + SQLite at runtime. --> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <RootNamespace>EchoModule</RootNamespace> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> </PropertyGroup> <!-- The connectors the worker already ships in its shared Libs. We compile against them but the .tpkg carries only EchoModule.dll (see PackTpkg). --> <ItemGroup> <ProjectReference Include="..\..\..\src\redb.Route\redb.Route.csproj" /> <ProjectReference Include="..\..\..\src\redb.Route.Core\redb.Route.Core.csproj" /> <ProjectReference Include="..\..\..\src\redb.Route.Http\redb.Route.Http.csproj" /> <ProjectReference Include="..\..\..\..\redb.Core\redb.Core.csproj" /> </ItemGroup> <!-- After build: zip manifest.json + the module DLL into output/EchoModule.tpkg. --> <PropertyGroup> <TsakModuleName>EchoModule</TsakModuleName> </PropertyGroup> <Target Name="PackTpkg" AfterTargets="Build"> <PropertyGroup> <_TpkgStaging>$(IntermediateOutputPath)tpkg</_TpkgStaging> <_TpkgFile>$(MSBuildThisFileDirectory)output\$(TsakModuleName).tpkg</_TpkgFile> </PropertyGroup> <RemoveDir Directories="$(_TpkgStaging)" /> <MakeDir Directories="$(_TpkgStaging)" /> <MakeDir Directories="$(MSBuildThisFileDirectory)output" /> <Copy SourceFiles="$(MSBuildThisFileDirectory)manifest.json" DestinationFolder="$(_TpkgStaging)" /> <Copy SourceFiles="$(TargetPath)" DestinationFolder="$(_TpkgStaging)" /> <!-- {ModuleName}.config.json gives the module a named context (ContextName). --> <Copy SourceFiles="$(MSBuildThisFileDirectory)$(TsakModuleName).config.json" DestinationFolder="$(_TpkgStaging)" Condition="Exists('$(MSBuildThisFileDirectory)$(TsakModuleName).config.json')" /> <ZipDirectory SourceDirectory="$(_TpkgStaging)" DestinationFile="$(_TpkgFile)" Overwrite="true" /> <Message Importance="high" Text="Packed $(TsakModuleName) -> $(_TpkgFile)" /> </Target></Project>
EchoWorker/EchoWorker.csproj — консольный exe, ссылается на модуль:
<Project Sdk="Microsoft.NET.Sdk"> <!-- Project 2 of 2: the DEBUG HOST. A tiny console app. It stands redb up on SQLite (the same Free tier the Tsak worker uses by default), then calls EchoModule.InitRoute.main(ctx) — the exact method the worker calls. Run/F5 this to debug the route without Tsak. --> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <RootNamespace>EchoWorker</RootNamespace> </PropertyGroup> <ItemGroup> <ProjectReference Include="..\EchoModule\EchoModule.csproj" /> <!-- UseSqlite is tier-agnostic: AddRedb -> Free (what Tsak uses by default). --> <ProjectReference Include="..\..\..\..\redb.SQLite.Pro\redb.SQLite.Pro.csproj" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.3" /> </ItemGroup></Project>
Здесь важная деталь про зависимости. Отладочный хост ссылается на провайдер SQLite (redb.SQLite.Pro) — потому что базу поднимает он. А сам модуль на провайдер БД не ссылается: в Tsak базу даёт воркер. Поэтому в .tpkg уедет только EchoModule.dll (см. таргет PackTpkg — он кладёт в архив ровно целевую DLL, манифест и конфиг, не таща зависимости). Коннекторы redb.Route.* и redb.Core уже есть в воркере — поэтому пакет остаётся лёгким.
Запускаем и щупаем
dotnet run --project EchoWorker
И в другом окне дёрнем ручки. Тут важная тонкость Windows: экранирование JSON в curl разное в cmd и в PowerShell. В PowerShell форма с \" не работает — вы поймаете '\' is an invalid start of a property name. Поэтому даю оба варианта.
cmd.exe (JSON в двойных кавычках, внутренние экранируем через \"):
curl -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d "{\"tag\":\"work\",\"text\":\"hello\"}"curl "http://localhost:5099/api/notes?tag=work"
PowerShell (JSON в одинарных кавычках — внутренние двойные экранировать не надо; и обязательно curl.exe, иначе curl — это алиас Invoke-WebRequest):
curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"home","text":"other"}'curl.exe "http://localhost:5099/api/notes?tag=work"
PowerShell без curl (нативно):
Invoke-RestMethod -Method Post -Uri http://localhost:5099/api/notes -ContentType 'application/json' -Body '{"tag":"work","text":"hello"}'Invoke-RestMethod "http://localhost:5099/api/notes?tag=work"
Ответы:
{"saved":true,"id":1000019}{"saved":true,"id":1000022}[{"Tag":"work","Text":"hello"}]
GET с ?tag=home вернёт другую заметку, а PUT /api/notes — 405. Всё работает, всё под отладчиком: ставьте точку останова внутри ProcessWithRedb, шлите curl — и вы прямо в обработчике, с живым db и ex.
Вот это и есть отладочная зона: обычное консольное приложение, F5, точки останова, живая SQLite-база рядом с exe. Никакого рантайма, никакого докера — голый цикл «поправил → запустил → потыкал curl».
Смотрите: тот же код — и это уже энтерпрайз
А теперь самое приятное. Мы не трогаем маршруты вообще. Берём ту же EchoModule.dll, добавляем два маленьких файла-описателя, пакуем в .tpkg — и отдаём в Tsak. Один шаг — и наш сервис из «консольки под F5» получает дашборд, метрики, hot-reload и управление на лету. Кода при этом ноль нового.
Что такое .tpkg
.tpkg — это просто ZIP-архив с тремя вещами:
-
manifest.json— паспорт модуля; -
EchoModule.dll— собственно сборка сInitRoute.main; -
EchoModule.config.json— конфиг модуля (в первую очередь — имя контекста).
manifest.json:
{ "Name": "EchoModule", "Version": "1.0.0", "EntryPoints": ["EchoModule.dll"], "Dependencies": []}
EchoModule.config.json:
{ "ContextName": "echo", "AutoStart": true}
Про ContextName — отдельно, потому что это грабли, на которые легко наступить. Если имя контекста не задать, Tsak поднимет модуль в анонимном контексте с автоматически сгенерированным именем (вида EchoModule_dyn_<дата>_<guid>). Модуль при этом работает, маршруты крутятся — но на странице Endpoints дашборда анонимные контексты по умолчанию не показываются (при этом в общих счётчиках они учитываются — и цифры не сходятся с тем, что видно в списке). Мораль простая: дайте контексту имя через EchoModule.config.json — и он аккуратно появится в списке как echo. Мелочь, а бережёт нервы.
Пакуем
Таргет PackTpkg из .csproj делает всё сам на этапе сборки:
dotnet build EchoModule -c Debug
На выходе — EchoModule/output/EchoModule.tpkg. Заглянем внутрь:
Archive: output/EchoModule.tpkg Length Name--------- ---- 49 EchoModule.config.json 13312 EchoModule.dll 108 manifest.json
Три файла, ~7 КБ. Всё, это наш деплой-артефакт.
Как Tsak находит модули: папки и hot-reload
Это то место, ради которого всё и затевалось. Tsak не «слушает» папку по событиям — внутри работает фоновый сервис HotReloadService, который раз в интервал опрашивает каталоги, перечисленные в конфиге Tsak:Modules:AssemblyPaths.
Схема такая:
-
В конфиге воркера есть список путей
Tsak:Modules:AssemblyPaths. Каталог модулей обычно называетсяmodules. -
Раз в
Tsak:HotReload:ScanIntervalSeconds(по умолчанию 10 секунд) сервис проходит по каждому пути и берёт все файлы.tpkg(и отдельно голые.dll). -
Решение «новый / изменился / удалён» принимается по времени модификации файла:
-
mtime не изменился → пропускаем;
-
новый файл → распаковываем архив, грузим сборку в изолированный контекст загрузки (свой ALC на пакет), находим
InitRoute.mainрефлексией, поднимаем контекст; -
файл изменился → перезагружаем пакет атомарно (снимаем старую версию, ставим новую, сохраняя состояние — например, был ли контекст запущен);
-
файл исчез с диска → выгружаем все модули этого пакета.
-
-
Общие коннекторы (
redb.Route.*,redb.Core) резолвятся из общих библиотек воркера (Libs/shared), поэтому в самом.tpkgих нет.
Практические следствия:
-
Кинули/обновили
EchoModule.tpkgвmodules/— подхватится в течение ~10 секунд, без рестарта. -
Триггер — изменение mtime файла. Обычное копирование mtime обновляет, поэтому «перекопировал» = «перезагрузил».
-
Каждый
.tpkgживёт в своём ALC — версии модулей изолированы друг от друга.
Иными словами, деплой нового модуля или его версии — это копирование одного файла в папку. Дальше Tsak сам.
Дашборд: смотрим и управляем
Поднимаем Tsak (как именно — двумя способами ниже), кладём EchoModule.tpkg в папку модулей, ждём несколько секунд — и открываем веб-дашборд. Наш контекст echo теперь там, с двумя эндпоинтами.
И вот тут — вторая большая разница с отладочной консолью. В консоли мы видели только текстовые логи. В Tsak — то же самое, но:
-
видно контекст
echoи его эндпоинты, тип (HTTP), роль (Consumer), счётчики in/out, ошибки, throughput, uptime, здоровье; -
видно системный контекст
_SYSTEM— это управляющий API самого воркера; -
по каждому эндпоинту можно провалиться в детали (сообщения, байты, среднее время обработки, последние ошибки).
Но главное — этим можно управлять прямо из браузера, не трогая конфиги и не передеплоивая. На детальной странице ноды по нашему контексту echo доступно:
-
контекст целиком — Start / Stop / Restart, а также Reset route states (сброс сохранённых состояний маршрутов);
-
отдельные маршруты — остановить/запустить конкретный
notes-postилиnotes-get, не трогая соседний; -
если в модуле есть расписания (Quartz) — на вкладке планировщика можно ставить джобы на паузу и возобновлять.
И вот важный момент: состояние запоминается. Если вы руками остановили маршрут, то hot-reload при обновлении пакета сам его обратно не поднимет — Tsak уважает ручное решение оператора. То есть остановленный на проде маршрут не «оживёт» внезапно после того, как вы подкатите новую версию .tpkg. Управление — «горячее» (действует сразу) и «липкое» (переживает перезагрузку модуля).
Действия управления в дашборде под ролью администратора — то есть это именно операторская панель, а не просто витрина метрик.
Сравните ощущения: в кастомном воркере вы отлаживаете (F5, брейкпоинты, голая консоль), а в Tsak — эксплуатируете (наблюдаете, управляете, деплоите). Один и тот же код — две среды, две большие разницы.
Порты: что по умолчанию и как настроить
Раз уж мы гоняем это в разных средах, разложим порты по полочкам — иначе легко запутаться, где что живёт.
-
Модуль (наши маршруты) —
5099. Это порт, который мы сами прописали вFrom("http:0.0.0.0:5099/..."). Свой HTTP-сервер модуля. Хотите другой — меняете в коде маршрута. -
Воркер (управляющий API Tsak) —
9090. Это отдельный сервер: health, метрики, управление контекстами, кластерный API. Его дёргает дашборд. Порт берётся из конфига воркера. -
Веб-дашборд — тут внимание. Если ни
ASPNETCORE_URLS, ниKestrelв конфиге веба не заданы, ASP.NET Core берёт свой дефолт —http://localhost:5000. Docker-образы и стартовые скрипты дистрибутива задаютASPNETCORE_URLS=http://localhost:8080(илиhttp://+:8080в контейнере) — поэтому в докере и по инструкции дашборд на 8080. Если запустить веб «голым» без этой переменной — не удивляйтесь, что морда окажется на 5000.
Как задать явно:
# перед запуском веба$env:ASPNETCORE_URLS = "http://localhost:8080"
или в appsettings.json веба:
"Kestrel": { "Endpoints": { "Http": { "Url": "http://localhost:8080" } } }
Короткая шпаргалка: 9090 — API воркера, 5099 — наши ручки, 8080 — дашборд (если задан ASPNETCORE_URLS; иначе фреймворк-дефолт 5000).
Энтерпрайз-деплой №1: Docker (стек одним контейнером)
Самый простой способ поднять всё разом — образ redb-tsak-stack: воркер + дашборд в одном контейнере. Образы лежат на странице пакетов организации — там их несколько: отдельно redb-tsak-worker, redb-tsak-web и объединённый redb-tsak-stack (плюс варианты под разные TFM). Нам для старта хватит stack. Вот docker-compose.yml целиком:
# Minimal redb.Tsak stack — Worker (API) + Blazor dashboard in one container.# Drop a module as a .tpkg into ./modules and the worker hot-loads it.## Start:# docker compose up -d## Dashboard: http://localhost:8080 (login admin / admin)# Tsak API: http://localhost:9090/api/health/live# EchoModule endpoints: http://localhost:5099/api/notes# POST (PowerShell): curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'# POST (cmd.exe): curl -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d "{\"tag\":\"work\",\"text\":\"hello\"}"# GET: curl.exe "http://localhost:5099/api/notes?tag=work"services: tsak: image: ghcr.io/redbase-app/redb-tsak-stack:latest container_name: tsak restart: unless-stopped ports: - "8080:8080" # Blazor dashboard - "9090:9090" # Tsak management REST API (health, cluster, contexts...) - "5099:5099" # the EchoModule's own HTTP server (/api/notes) environment: # redb store on the bundled SQLite — no external dependencies Tsak__Storage__Type: Redb # HMAC secret for API-key auth (any >=16 chars; change for real use) Tsak__Auth__Secret: "demo-secret-change-me-please-0123456789" volumes: # your .tpkg modules — hot-loaded by the worker - ./modules:/app/worker/modules
Что важно:
-
Образ
ghcr.io/redbase-app/redb-tsak-stack:latest— воркер и дашборд в одном контейнере (под супервизором). Хранилище по умолчанию — встроенный SQLite, без внешних зависимостей. -
Порты — те самые три:
8080(дашборд),9090(API воркера),5099(наши ручки). -
Volume
./modules:/app/worker/modules— вот сюда и кладём.tpkg. Внутри стек-образа каталог модулей —/app/worker/modules, и это ровно тот путь, что подхватывает hot-reload. -
Tsak__Auth__Secret— секрет для HMAC-подписи API-ключей; для локального прогона достаточно любого значения ≥16 символов.
Раскладка на хосте:
tsak/├─ docker-compose.yml└─ modules/ └─ EchoModule.tpkg
Поднимаем:
docker compose up -d
Дашборд — http://localhost:8080 (admin / admin). Проверяем ручки (PowerShell):
curl.exe -X POST http://localhost:5099/api/notes -H "Content-Type: application/json" -d '{"tag":"work","text":"hello"}'curl.exe "http://localhost:5099/api/notes?tag=work"
Всё. Тот же модуль, что мы отлаживали в консоли, теперь крутится в контейнере с дашбордом. Обновить модуль — пересобрали .tpkg, перекопировали в modules/, через ~10 секунд подхватится.
Энтерпрайз-деплой №2: без контейнера (standalone-архив)
Не хотите докер — не надо. На странице релизов лежат самодостаточные архивы под платформу — например, тег v3.2.0 с архивами под win-x64, linux-x64 и т.д. (каждый — подписан cosign, рядом лежат .bundle и SBOM для проверки целостности).
Внутри распакованного архива (redb-tsak-<версия>-<платформа>) — такая раскладка:
redb-tsak-3.2.0-win-x64/├─ worker/ <- рантайм: redb.Tsak.Worker.exe (+ Libs/shared с коннекторами, + modules/)├─ web/ <- дашборд: redb.Tsak.Web.exe├─ cli/ <- консольная утилита tsak├─ scripts/ <- start-worker / start-web / start-stack (.bat / .ps1 / .sh)├─ README.txt├─ LICENSE, NOTICE
Запуск — из scripts/:
-
start-stack— поднять воркер и дашборд разом; -
start-worker— только рантайм; -
start-web— только дашборд.
Например, на Windows:
.\scripts\start-stack.ps1
Порты те же: воркер — 9090, дашборд — 8080 (скрипты задают ASPNETCORE_URLS; если запустить web совсем голым мимо скрипта — вспомните про дефолт 5000 из раздела о портах).
Куда класть модуль. В standalone-раскладке модули лежат рядом с воркером — в его каталоге modules/ (путь берётся из Tsak:Modules:AssemblyPaths). Кладём наш EchoModule.tpkg туда:
worker/└─ modules/ └─ EchoModule.tpkg
И Libs/shared руками не трогаем — там общие коннекторы образа/дистрибутива, поверх которых грузится наш лёгкий пакет.
Лицензия: без ключа рантайм стартует в OSS-режиме. Про-фичи (в т.ч. кластер) включаются переменной Tsak__Redb__License__0 с вашим JWT либо правкой worker/appsettings.json перед запуском.
Итого два пути деплоя — контейнер или архив — но модель одна: артефакт .tpkg кладётся в папку модулей, дальше hot-reload.
Две зоны: рабочая и эксплуатационная
Соберём картину целиком.
-
Отладочная зона — проект
EchoWorker. Консольное приложение, F5, точки останова, локальная SQLite рядом с exe, голые логи в консоль. Здесь вы пишете и отлаживаете маршруты. Итерация — «поправил → запустил → потыкал curl». -
Эксплуатационная зона — Tsak (докер или архив). Тот же модуль, но с дашбордом, метриками, hot-reload и управлением на лету. Здесь вы деплоите и рулите. Итерация — «пересобрал
.tpkg→ скопировал вmodules/→ подхватилось».
И ключевое: между этими зонами не меняется ни строчки в маршрутах. InitRoute.main один и тот же. Разница только в обвязке: в отладке её пишете вы (десяток строк Program.cs), в проде — предоставляет Tsak.
Что дальше: кластер (тизер)
Всё, что выше — про один узел. Но Tsak умеет и в кластер: несколько воркеров, один общий дашборд, распределение модулей и маршрутов по узлам, выбор лидера, автоматический перехват при падении узла — и вся топология кластера при этом хранится в самой базе redb, без отдельной инфраструктуры мембершипа (без ZooKeeper/etcd/Consul). Отдельно живёт и «active-passive» для маршрутов: один и тот же маршрут-консьюмер крутится ровно на одном узле, а если тот падает — его подхватывает другой.
Но это — тема следующей статьи. Здесь у нас была вводная: как за вечер собрать два маршрута, сделать себе отладочный воркер, а потом тем же кодом заехать в энтерпрайз-рантайм. Про координатор, лидера и failover поговорим предметно дальше.
Итог
Мы прошли весь путь на одном крошечном примере:
-
написали два маршрута на redb.Route (POST — сохранить в redb/SQLite, GET — достать серверным
Where(...).ToListAsync()); -
сделали свой отладочный воркер — консольку под F5, которая зовёт ту же
InitRoute.main; -
упаковали модуль в
.tpkg(DLL +manifest.json+config.jsonс именем контекста); -
увидели, как Tsak сам подхватывает пакет из папки
modules/по hot-reload; -
порулили из дашборда (старт/стоп/рестарт контекстов, состояние запоминается);
-
разобрались с портами (9090 — API воркера, 5099 — наши ручки, 8080/5000 — дашборд);
-
развернули двумя способами: докером (весь
docker-compose.yml) и без докера (архив с релизов).
Один и тот же код — и удобная отладка, и энтерпрайз-рантайм. Дальше — кластер.
Весь проект: redb.Route/demos/EchoWorkerDemo в репозитории. Копируйте, повторяйте, ломайте.
ссылка на оригинал статьи https://habr.com/ru/articles/1055050/