Realtime
A broadcast csatorna egy notificationt a recipiens élő kapcsolatára kézbesít, abban a pillanatban, amikor történik: egy "poke" a durable database csatorna tetején, nem annak helyettesítője. Ez az oldal a kapcsolatokat tartó gatewayről szól.
Miért külön gateway
A notificationök küldése bármely processből történhet, gyakran a workerből, egy listenerből. A kliens-kapcsolatok tartása más feladat, más skálázási alakkal: sok, hosszú élettartamú stream, egy csatlakozott userenként, aminek túl kell élnie a példányok szabad jövés-menését. A framework ezeket szétválasztja: a notify/broadcast egy pici publisher, amit bármely process importálhat (szerver nélkül, állapot nélkül); a realtime egy dedikált binary (cmd/realtime, az add realtime scaffoldolja), ami csak kapcsolatokat tart és relay-eli, amit publikálnak.
A driver-seam szándékos: a notify/broadcast-on és a realtime-on kívül semmi nem importálja közvetlenül a centrifuge-ot, így egy másik transport később lecserélheti anélkül, hogy az alkalmazáskódhoz nyúlna.
Publikálás
import "github.com/gp-system/notify/broadcast"
pub := broadcast.MustNewPublisher(ctx, cfg.Realtime.BroadcastValkey(), cfg.Realtime.Broadcast)
notifier := notify.NewHub(
notify.NewMailChannel(mailer),
notifydatabase.NewChannel(store),
notify.NewBroadcastChannel(pub, cfg.Realtime.Broadcast.ChannelPrefix),
)
A Publisher egy publish-only centrifuge.Node, ami osztozik a projekt Valkey brokerén; sosem fogad kapcsolatot, így egy worker-process semmi mástól nem függ, csak egy hálózati klienstől. A publikálás fire-and-forget: egy sikertelen publish visszaadódik a hívónak, nincs újrapróbálva: a durability a database csatornából jön, egy kliens, aki lemarad egy élő push-ról, reconnectkor onnan backfillel. Az insert-majd-publish kontraktust maga a notify.Hub.Send már kikényszeríti (database csatorna a broadcast előtt, ugyanaz a delivery ID mindkettőben).
A gateway
A cmd/realtime nem hordoz üzleti függőséget (nincs adatbázis-pool, nincs outbox), pont ezért skálázódik szabadon. WebSocketet exponál egy HTTP-streaming/SSE fallbackkal (mindkettő a centrifugal/centrifuge-on keresztül, ami a Centrifugo és a Grafana Live mögötti library) a /realtime/* alatt, minden kapcsolat JWT-jét a connect frame-ben authentikálja (nincs külön /broadcasting/auth endpoint; v1-ben az egyetlen privát csatorna egy kapcsolat saját subjectje, így elég egyszer, connectkor authentikálni), és minden kapcsolatot server-side feliratkoztat a saját user:<subject> csatornájára. A gyakori esetben nincs szükség kliens-oldali subscribe-logikára.
A megosztott topic-csatornák opt-in és explicit módon authorizáltak: minden kapcsolat JWT-je egy rbac.Identity-ra oldódik fel, és egy topic csak akkor iratkoztatható fel, ha valamelyik Allow/AllowPrefix callback jótáll érte.
err := realtime.Run(ctx, cfg.Realtime, func(topics *realtime.TopicAuth) error {
topics.Allow("announcements", func(id *rbac.Identity) bool { return true })
topics.Allow("admin-alerts", func(id *rbac.Identity) bool { return id.HasRole("admin") })
return nil
})
Egy topic, aminek nincs regisztrált Allow callbackje, sosem iratkoztatható fel.
Ha egy csatorna-család teljes neve csak futásidőben ismert (egy dinamikus
erőforrás soronként: egy tenant, egy projekt, egy repository), az Allow-val
egyenként, induláskor való elnevezés nem lehetséges: helyette AllowPrefix
használandó, aminek a callbackje megkapja a teljes topic stringet, hogy meg
tudja állapítani, épp melyik konkrét instance-t kérik.
topics.AllowPrefix("project:", func(id *rbac.Identity, topic string) bool {
projectID, _ := strings.CutPrefix(topic, "project:")
return projectAccess.CanView(id.Subject, projectID)
})
Egy pontos Allow-egyezés mindig elsőbbséget élvez egy illeszkedő
AllowPrefix-szel szemben; a prefixek közül az elsőként regisztrált,
illeszkedő nyer.
Kliens-szerver RPC
A gateway alapból kizárólag szerver→kliens: a kapcsolat a publish-okat kapja, de
nem tud írni semmit. A WithRPC bekapcsolja a fordított irányt is: a kliens egy
method nevet és egy nyers payloadot küld, a szerver egy regisztrált
RPCHandler-rel válaszol, ugyanazzal a JWT-ből feloldott identitással, amit a
TopicAuth callbackek is látnak.
err := realtime.Run(ctx, cfg.Realtime, register, realtime.WithRPC(func(rpc *realtime.RPCRegistry) error {
rpc.Handle("chat.send", func(ctx context.Context, id *rbac.Identity, data []byte) ([]byte, error) {
return chatService.SendViaRPC(ctx, id, data)
})
return nil
}))
Egy methodnak, amire nincs regisztrált handler, a kliens
centrifuge.ErrorMethodNotFound-ot kap. A handler visszatérési hibája
httperr-hez hasonlóan térképeződik le: egy errs.Status-szal ellátott hiba a
státusznak megfelelő centrifuge.Error-t adja (400/422 → ErrorBadRequest,
401/403 → ErrorPermissionDenied, 404 → egy framework-definiálta "not found" hiba),
errs.Public-ja pedig a kliens felé látható üzenet; egy sima, errs.Status
nélküli hiba ErrorInternal-ra esik, belső részlet nélkül.
A gatewaynek nincs adatbázis-hozzáférése (lásd fentebb: "nem hordoz üzleti függőséget"), így egy RPC handler tipikusan egy meglévő HTTP endpointot hív meg (a validáció, a policy és a tranzakció ott van), nem duplikálja az üzleti logikát.
Opciók és a middleware-lánc
A realtime.Run a server.Run-nal azonos stílusú opciókat fogad:
| Opció | Hatás |
|---|---|
WithCloser(name, fn) | takarítás graceful shutdownkor, miután minden tartott kapcsolat lekapcsolódott (LIFO; a telemetria utolsóként flush-ol) |
WithMiddleware(...func(http.Handler) http.Handler) | net/http middleware-ek fűzése a framework defaultjai után, a transport-endpointok elé; ugyanaz a kontraktus, mint a server.WithMiddleware-é |
WithRPC(register) | kliens által hívható RPC-metódusok (lásd fent) |
WithHTTPServer(func(*http.Server)) | escape hatch a http.Server azon mezőihez, amiket a framework nem exponál |
WithoutTelemetry() | kihagyja a telemetry.Setup-ot és az otel middleware-t; tesztekhez, vagy ha a processz maga konfigurálja a telemetriát |
A transport-endpointok körüli framework-lánc a szerver-chassis-éval van összehangolva, ugyanazokból az exportált gpsystem/httpmw építőkockákból, legkülsőtől befelé:
- CORS, streaming preset (
httpmw.CORSStreaming): az API-presettel szemben a credentialök engedélyezettek (egyes streaming-setupok cookie-val autentikálnak), ezért az originenkénti döntést a lentebb leírt origin-ellenőrzés hozza meg wildcard helyett. - request id (
httpmw.RequestID): minden válaszX-Request-Idfejlécet hordoz, az API-oldallal azonos szemantikával. - otel (
httpmw.Otel("realtime")): a gateway spanje; a/realtime/healthzki van szűrve a trace-elésből,WithoutTelemetrymellett pedig a teljes bejegyzés kimarad. - Sentry request hub (
httpmw.RequestHub): kérésenkénti breadcrumb-izoláció. - panic recovery (
httpmw.Recoverer): a HTTP-rétegben történő panicból sima 500 lesz, plusz egy stacket hordozó error-log; a gatewaynek nincs Problem-írója, és a streaming kliensek bármely 5xx-re amúgy is újracsatlakoznak.
A WithMiddleware extrák ezeken belül futnak, közvetlenül a transport-endpointok előtt. Access log szándékosan nincs: a kapcsolat-endpointok hosszú életű streameket tartanak, egy kérésenkénti rekord félrevezető lenne.
Skálázás: a példányok szabadon jönnek-mennek
Minden gateway-példány stateless: csak azokat a csatornákat tartja, amikre a saját kapcsolatainak szükségük van, és a közös Valkey broker minden érintett példányhoz eljuttat egy publish-t, bármely processből, sticky session-igény nélkül.
- Felskálázás:
docker compose up -d --scale realtime=N; egy új példány nulla állapottal indul, és csak akkor gyűlnek a szubszkripciói, amikor kliensek landolnak rajta. - Leskálázás / rolling restart: a
SIGTERMbekapcsol egy draining flaget (a/realtime/healthzés az új connect-kísérletek 503-at adnak, így a load balancer health checkje kiejti a példányt), anode.Shutdownminden tartott kapcsolatot reconnect-jogosult kóddal zár, a closerek lefutnak, a telemetria flush-öl. A kliens saját reconnect-logikája (backoff, beépítve a JS kliensbe) egy túlélő példányra viszi, ami frissen feliratkoztatja; ami a rés alatt publikálódott, azt a database csatornából állítja helyre, nem a gateway replay-eli. - Crash: a kliens oldala ugyanaz, mint egy graceful restartnál, csak a drain lépés nélkül.
- Valkey restart: a reconnect és az újra-subscribe a
centrifugedolga; a publikálás a kiesés alatt logolva és eldobva (ugyanaz a fire-and-forget kontraktus), ugyanúgy helyreállítva, mint egy kliens saját reconnectje alatt lemaradt publish.
Config
| Változó | Alapérték | Jelentés |
|---|---|---|
REALTIME_LISTEN_ADDR | :3000 | konténer-belső listen address |
REALTIME_DRAIN_TIMEOUT | 20s | graceful-shutdown budget; tartsd a deployment stop-grace-periódusa alatt |
REALTIME_READ_HEADER_TIMEOUT | 5s | Slowloris-védelem a connect handshake-en |
REALTIME_CHANNEL_PREFIX | a projekt neve | Valkey csatorna-névtér, közös minden notify/broadcast.Publisher-rel |
REALTIME_MAX_CONNECTIONS | 0 (korlátlan) | példányonkénti stream-plafon; felette a connect-kísérletek 503-at kapnak |
VALKEY_* | örökölt | újrahasznosítva a queue.Config-ból: a gateway brokere ugyanaz a Valkey, amit a queue használ |
JWT_* | örökölt | újrahasznosítva az auth.Config-ból: ugyanaz a token-issuer, amiben minden modul megbízik |
CORS_ORIGINS | * | közös a server.Config-gal; a gateway origin-politikáját vezérli mindhárom transporton (websocket, http_stream, sse) és a CORS headereken egyaránt |
Lásd az add realtime referenciát a scaffoldolt elemekért (a binary, a compose.yml Traefik-routolt service-e, a .env.example), és az add notification oldalt a broadcast csatorna egy notification Via-jába drótozásához.
Origin-ellenőrzés
A CORS_ORIGINS egyetlen szabályt ad mindhárom transportnak: a same-origin
kapcsolatok (az Origin hostja megegyezik a kérés Host fejlécével, a dokumentált
production-felállás) és az Origin fejléc nélküli kliensek (szerver-szerver
hívások, health check-ek) mindig átmennek, függetlenül a listától; minden más
esetben a lista dönt (* mindent enged, egyébként pontos egyezés vagy *-ot
tartalmazó minta, pl. https://*.example.com). Idegen origin elutasításakor a
gateway Warn szinten logol.
Fejlesztésben gyakori, hogy a frontend és a gateway más originről szolgál ki (pl.
egy next dev a hoston, http://localhost:3000, a gateway pedig
http://<app>.localhost mögött Traefikkel): ilyenkor a CORS_ORIGINS-nek
tartalmaznia kell a frontend originjét (vagy dev módban maradhat *), különben a
websocket handshake 403-at kap.
Kliens
import { Centrifuge } from 'centrifuge'
const client = new Centrifuge('wss://your-app.example/realtime/connection/websocket', {
getToken: async () => fetchAccessToken(), // ugyanaz a JWT, amit az API használ
})
client.on('publication', (ctx) => {
// ctx.data: { id, name, payload, at }, az id visszaköti a database csatorna sorához
})
client.connect()
A centrifuge-js kezeli a reconnect-backoffot és a token-refresht; a server-side subscription a recipiens saját csatornájára azt jelenti, hogy a kliensnek nincs szüksége explicit subscribe hívásra a neki szóló notificationökhöz. Reconnectkor kérd le az olvasatlan notificationöket a saját modulod inbox-endpointjából (a database csatornára építve), hogy backfilleld, ami a kapcsolat megszakadása alatt lemaradt. A socket egy élő poke, a database az igazságforrás.
Ha a gateway WithRPC-vel regisztrált egy methodot, a kliens client.rpc(...)-vel hívja:
const reply = await client.rpc('chat.send', { body: 'szia' })
// reply.data: a handler nyers válasza, JSON.parse a hívó dolga
Egy sikertelen hívás a centrifuge.Error-t (code, message) dobja: lásd fentebb a "Kliens-szerver RPC" szekciót a hibatérképért.
Kapcsolódó oldalak
notify/broadcast: a publish-only oldal, amit bármely process importálhat.auth/rbac: azIdentity, amire egy kapcsolat JWT-je feloldódik, és amit azAllow/AllowPrefixellenőriz.- Alkalmazás-életciklus: a közös
app.Runváz, amire arealtime.Runépül.