redb.Route: два маршрута за вечер — от отладочного воркера до энтерпрайза на Tsak

от автора

redb.tsak worker

redb.tsak worker

Серия: redb ecosystem / redb.Route redb.Tsak

Есть у интеграционного кода одна неприятная особенность. Написать пару маршрутов — «принял HTTP, положил в базу, отдал обратно» — дело на полчаса. А вот довести это до состояния, когда оно крутится в проде, само поднимается, показывает метрики, умеет останавливать/запускать отдельные куски руками и разворачивается без пересборки — это обычно совсем другая история и совсем другой стек.

В этой статье я покажу, что в связке redb.Route + redb.Tsak это буквально один и тот же код. Мы:

  1. напишем два простейших маршрута (POST — сохранить, GET — достать по параметру) на redb.Route;

  2. сделаем свой воркер для отладки — обычное консольное приложение, которое можно гонять под F5 с точками останова;

  3. а потом, не меняя ни строчки в маршрутах, упакуем всё в модуль и закинем в Tsak — и тот же крошечный проект получит дашборд, hot-reload, управление контекстами и энтерпрайз-деплой (докером и без докера).

Проект целиком лежит в репозитории (redb.Route/demos/EchoWorkerDemo), и я вставлю его сюда полностью — можно копировать и повторять.

Цикл про redb и redb.Route. Это продолжение серии, свежие статьи — сверху:

Полный список — в профиле. Исходники: 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-строки.

Ничего лишнего. Задача статьи не в бизнес-логике, а в том, чтобы показать жизненный цикл: как один и тот же набор маршрутов сначала живёт в отладочной консоли, а потом — в энтерпрайз-рантайме.

redb.tsak

redb.tsak
redb.trsak.web logs

redb.trsak.web logs
redb.tsak.web dashboard

redb.tsak.web dashboard

Структура проекта: два проекта, одна точка входа

Раскладка такая:

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.

redb.route custom worker

redb.route custom worker

Вот это и есть отладочная зона: обычное консольное приложение, 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 Dashboard — страница Endpoints

Tsak Dashboard — страница Endpoints

И вот тут — вторая большая разница с отладочной консолью. В консоли мы видели только текстовые логи. В Tsak — то же самое, но:

  • видно контекст echo и его эндпоинты, тип (HTTP), роль (Consumer), счётчики in/out, ошибки, throughput, uptime, здоровье;

  • видно системный контекст _SYSTEM — это управляющий API самого воркера;

  • по каждому эндпоинту можно провалиться в детали (сообщения, байты, среднее время обработки, последние ошибки).

Tsak — карточка эндпоинта notes-get с метриками и деталями

Tsak — карточка эндпоинта notes-get с метриками и деталями

Но главное — этим можно управлять прямо из браузера, не трогая конфиги и не передеплоивая. На детальной странице ноды по нашему контексту echo доступно:

  • контекст целиком — Start / Stop / Restart, а также Reset route states (сброс сохранённых состояний маршрутов);

  • отдельные маршруты — остановить/запустить конкретный notes-post или notes-get, не трогая соседний;

  • если в модуле есть расписания (Quartz) — на вкладке планировщика можно ставить джобы на паузу и возобновлять.

И вот важный момент: состояние запоминается. Если вы руками остановили маршрут, то hot-reload при обновлении пакета сам его обратно не поднимет — Tsak уважает ручное решение оператора. То есть остановленный на проде маршрут не «оживёт» внезапно после того, как вы подкатите новую версию .tpkg. Управление — «горячее» (действует сразу) и «липкое» (переживает перезагрузку модуля).

Действия управления в дашборде под ролью администратора — то есть это именно операторская панель, а не просто витрина метрик.

Tsak — детальная страница ноды/контекста echo с кнопками Start/Stop/Restart для маршрутов notes-post / notes-get

Tsak — детальная страница ноды/контекста echo с кнопками Start/Stop/Restart для маршрутов notes-post / notes-get

Сравните ощущения: в кастомном воркере вы отлаживаете (F5, брейкпоинты, голая консоль), а в Tsak — эксплуатируете (наблюдаете, управляете, деплоите). Один и тот же код — две среды, две большие разницы.

redb.tsak.web routes

redb.tsak.web routes
redb.routes console

redb.routes console

Порты: что по умолчанию и как настроить

Раз уж мы гоняем это в разных средах, разложим порты по полочкам — иначе легко запутаться, где что живёт.

  • Модуль (наши маршруты) — 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-workerredb-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 секунд подхватится.

docker compose up — контейнер поднят, дашборд на 8080 с контекстом echo

docker compose up — контейнер поднят, дашборд на 8080 с контекстом echo
docker compose up — контейнер поднят, дашборд на 8080 с контекстом echo

docker compose up — контейнер поднят, дашборд на 8080 с контекстом echo

Энтерпрайз-деплой №2: без контейнера (standalone-архив)

Не хотите докер — не надо. На странице релизов лежат самодостаточные архивы под платформу — например, тег v3.2.0 с архивами под win-x64linux-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 поговорим предметно дальше.

dashboard

dashboard
monitoring

monitoring

Итог

Мы прошли весь путь на одном крошечном примере:

  1. написали два маршрута на redb.Route (POST — сохранить в redb/SQLite, GET — достать серверным Where(...).ToListAsync());

  2. сделали свой отладочный воркер — консольку под F5, которая зовёт ту же InitRoute.main;

  3. упаковали модуль в .tpkg (DLL + manifest.json + config.json с именем контекста);

  4. увидели, как Tsak сам подхватывает пакет из папки modules/ по hot-reload;

  5. порулили из дашборда (старт/стоп/рестарт контекстов, состояние запоминается);

  6. разобрались с портами (9090 — API воркера, 5099 — наши ручки, 8080/5000 — дашборд);

  7. развернули двумя способами: докером (весь docker-compose.yml) и без докера (архив с релизов).

Один и тот же код — и удобная отладка, и энтерпрайз-рантайм. Дальше — кластер.

Весь проект: redb.Route/demos/EchoWorkerDemo в репозитории. Копируйте, повторяйте, ломайте.

ссылка на оригинал статьи https://habr.com/ru/articles/1055050/