콘텐츠로 이동

STREAM

가이드 홈 | 이전: Actor | 다음: Session과 Actor 연결

이 장을 읽고 나면

mesh 밖의 프로그램이 연결 하나로 접속해 요청을 보내고 답을 받게 할 수 있다. 이 장의 코드는 예제 저장소의 tutorial README에서 가져왔으며, README의 「내려받기와 설치」·「빌드」·「실행」 절을 따르면 아래 결과를 재현할 수 있다.

STREAM node에 연결하는 client는 언어별 stream connector를 사용한다. Unity·Unreal·browser·Node·.NET·Java·C++ client의 설치 범위는 Client stream connector에 있고, 사용하는 언어의 stream connector 가이드가 연결과 packet 처리 방법을 다룬다.

지금까지의 호출은 모두 mesh 안의 node끼리였다. 게임 client나 application은 mesh 밖에 있고, Framework를 참조하지도 않는다. STREAM은 그런 프로그램이 접속하는 자리이고, 연결 하나가 열려 있는 동안 양쪽이 서로에게 보낸다. 이 장은 연결을 받아 packet 하나에 답하기까지 다루며, 그 연결을 Actor에 묶는 것은 Session과 Actor 연결이 다룬다.

1. STREAM의 역할

STREAM은 mesh 밖의 client가 연결 하나로 mesh와 packet을 주고받는 경계를 맡는다. channel도 Spot도 Actor도 mesh 안에서 호출하는 경로다. 호출하는 쪽이 mesh의 구성원이어야 하고, Framework를 참조해야 한다. 플레이어의 기기에서 실행되는 client는 그 조건을 충족하지 못한다.

STREAM은 그 경계를 하나의 연결로 좁힌다. client는 주소 하나와 packet 이름만 알면 되고, mesh가 몇 개의 node로 되어 있는지는 모른다.

↗ 크게 보기

연결 하나에 session 객체 하나가 대응한다. 그 연결로 들어온 packet은 그 session에서 한 번에 하나씩 처리되므로, session의 필드는 그 연결의 상태를 담는 자리로 사용할 수 있다.

2. 주고받을 packet을 정한다

mesh 호출의 계약과 다르지 않다. 다만 이 packet은 mesh 밖의 client와 주고받으므로, client가 사용하는 이름과 같아야 한다.

// Exchanged over the external TCP connection, not between mesh nodes. The
// stream codec wants 64-bit integers as decimal strings, so the timestamp is
// carried as text rather than as a long.
public sealed record Ping(string SentAtUnixMs);

public sealed record Pong(string SentAtUnixMs);

3. 받는 쪽 — 연결을 받는 node

받는 쪽 process

이 절의 코드는 연결을 받는 process에 들어간다.

3.1 session 작성

session은 연결 하나를 대표한다. 연결·해제·오류와 packet 도착이 모두 callback으로 오고, 같은 연결의 callback은 순서대로 실행된다.

// One connected game client. Callbacks for the same connection run in order.
public sealed class GameSession(IZLinkSessionContext context, ILogger<GameSession> logger)
    : IZLinkSession
{
    public IZLinkSessionContext Context { get; } = context;

    // Session handlers are not discovered by scanning, and the packet name must
    // match what the client sends. Without this, no packet is ever handled.
    public void Configure()
    {
        Context.Handlers.AddHandler<PingHandler>(nameof(Ping));
        Context.Handlers.AddHandler<AuthenticateHandler>(nameof(Authenticate));
    }

    public ValueTask OnConnectedAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("client connected: {SessionId}", Context.SessionId);
        return ValueTask.CompletedTask;
    }

    public ValueTask OnDisconnectedAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("client disconnected: {SessionId}", Context.SessionId);
        return ValueTask.CompletedTask;
    }

    public ValueTask OnErrorAsync(ZLinkStreamError error, CancellationToken cancellationToken)
    {
        logger.LogWarning("stream error on {SessionId}: {Error}", Context.SessionId, error);
        return ValueTask.CompletedTask;
    }

    // Every inbound packet arrives here first. Registering a handler is not
    // enough on its own; this method is what routes the packet to it.
    public async ValueTask OnDispatchAsync(
        ZLinkSessionDispatchContext dispatch,
        ZLinkMessage payload,
        CancellationToken cancellationToken
    )
    {
        if (await Context.Handlers.TryHandleAsync(dispatch, payload, cancellationToken))
            return;

        // A slotted packet uses its dispatch Actor. An unslotted packet can use
        // the connection only while exactly one Actor is bound.
        var actor =
            dispatch.Actor
            ?? (
                Context.Actors.Bound.Count switch
                {
                    1 => Context.Actors.Bound.Single(),
                    0 => throw new InvalidOperationException(
                        "Authenticate an Actor before sending player packets."
                    ),
                    _ => throw new InvalidOperationException(
                        "Select an Actor handle when more than one Actor is bound."
                    ),
                }
            );

        await actor.RelayAsync(payload);
    }
}

packet이 도착하는 callback이 모든 처리의 시작점이다. handler를 등록해 두더라도 이 callback이 그쪽으로 넘기지 않으면 아무것도 처리되지 않는다.

handler를 등록하는 자리가 언어마다 다르다

.NET과 Node는 session 쪽에서 등록하고, Java와 Kotlin은 stream node를 등록할 때 함께 등록한다. C++에는 등록 표면이 아예 없다 — 모든 packet이 하나의 callback으로 오고, 이름으로 처리를 나누는 것은 application의 코드다.

3.2 packet handler 작성

값을 돌려주는 대신 답을 직접 사용한다. 요청으로 온 packet에는 reply로 답하고, 기다리는 요청이 없는 client에 먼저 보낼 때는 send를 사용한다.

// The first type argument is the session context, not the session class.
public sealed class PingHandler : IZLinkSessionPacketHandler<IZLinkSessionContext, Ping>
{
    public ValueTask HandleAsync(
        IZLinkSessionContext context,
        ZLinkSessionDispatchContext dispatch,
        Ping message,
        CancellationToken cancellationToken
    )
        // Reply answers a request. To push to a client that is not waiting for
        // one, use Client.Send instead.
        =>
        context.Client.Reply(new Pong(message.SentAtUnixMs)).Async();
}

3.3 등록

stream node는 포트를 열고 session type 하나를 받는다. mesh node와는 별개의 등록이다.

// The port game clients connect to. One session type per stream node, and
// actor dispatch must be on for a session to relay to its player.
options
    .AddStreamNode("client-stream")
    .Bind("tcp://127.0.0.1:7301")
    .EnableActorDispatch()
    .AddSession<GameSession>();

4. 접속하는 쪽 — mesh 밖의 client

접속하는 쪽 process

이 절의 코드는 mesh 밖에서 붙는 process에 들어간다. 앞 절과 짝을 이룬다.

이 process는 Framework를 참조하지 않는다. 별도로 배포되는 connector 하나만 참조한다.

// A game client outside the mesh. It references the connector only, never the
// Framework, and speaks to the port the stream node opened.
await using var connector = ZlinkStreamConnectorFactory.Create(
    new ZlinkStreamConnectorOptions
    {
        Endpoint = new Uri("tcp://127.0.0.1:7301"),
        ConnectTimeout = TimeSpan.FromSeconds(5),
        RequestTimeout = TimeSpan.FromSeconds(5),
        DispatchMode = ZlinkStreamDispatchMode.Immediate,
    }
);

await connector.Connect.Async();
Console.WriteLine($"connected: {connector.IsConnected}");

// A request waits for its reply. Use Send for one-way traffic; the server then
// answers with Client.Send rather than Reply.
var sentAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var pong = await connector
    .Request(new Ping(sentAt.ToString()))
    .Timeout(TimeSpan.FromSeconds(5))
    .Async<Pong>();

var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - long.Parse(pong.SentAtUnixMs);
Console.WriteLine($"round trip: {elapsed}ms");

Node의 connector는 WebSocket으로 붙는다

네 언어의 connector는 TCP를 사용하고 tcp://를 적는다. Node의 connector는 브라우저용으로 배포되어 WebSocket을 쓰므로, stream node와 client가 둘 다 ws://를 적는다.

5. 실행 결과

tutorial README의 「실행」 절을 따라 띄운 상태에서 Server를 실행한 상태에서 아래 명령으로 StreamClient를 시작하면, StreamClient가 Server의 stream endpoint에 보낸 request와 받은 응답은 StreamClient process의 stdout에 나오고 연결·handler 기록은 Server process의 stdout 또는 server.log에 나온다.

dotnet run --project StreamClient/StreamClient.csproj
# connected: True
# round trip: 56ms

connected가 참이면 연결이 맺어졌다는 뜻이다. 왕복 시간은 client가 보낸 시각을 server가 그대로 돌려주어 잰 값이다. 이 출력으로 mesh 밖과 안이 이어진 것을 확인한다.

6. mesh 호출과의 차이 — 연결 자체가 대상이다

mesh 호출은 호출하는 쪽이 mesh의 구성원이다. 대상은 이름이나 id다. STREAM은 연결 자체가 대상이다. 그래서 client를 고르는 일이 없다. 반대로 server가 먼저 보낼 수 있다.

연결이 끊기면 그 session은 끝난다. 연결 너머의 상태를 이어 가려면 그 연결을 Actor에 묶는다 — Session과 Actor 연결이 다룬다.

7. 관련 문서