콘텐츠로 이동

Session과 Actor 연결

가이드 홈 | 이전: STREAM | 다음: Location

이 장을 읽고 나면

외부 client의 연결 하나를 Actor 하나에 묶고, 그 Actor가 같은 연결로 알림을 보낼 수 있다. 이 장의 코드는 예제 저장소의 tutorial README에서 가져왔으며, README의 「내려받기와 설치」·「빌드」·「실행」 절을 따르면 아래 결과를 재현할 수 있다.

STREAM의 session은 연결이 끊기면 함께 끝난다. 플레이어의 상태는 그보다 오래 남아야 하고, 그 상태를 맡는 것이 Actor다. 이 장은 그 둘을 묶는다. 묶은 뒤에는 session이 다루지 않은 packet이 그 Actor로 간다. Actor는 같은 연결로 보낼 수 있다.

1. Actor와 session을 묶는 역할

Actor와 session을 묶으면 수명이 다른 연결과 Actor가 서로 이어진다. 같은 플레이어가 끊었다 다시 접속하면 연결은 새로 만들어지고 Actor는 이전 것이 그대로 남아 있다. 그래서 연결이 자기 Actor를 가리키게 해 두고, 그 뒤의 packet은 그 Actor가 받는다.

↗ 크게 보기

▶ 둘러보기에서 직접 눌러 보기 — 로비와 던전

묶은 뒤에도 session은 사라지지 않는다. session이 아는 packet은 session이 먼저 처리하고, 모르는 것만 Actor로 간다.

2. 주고받을 packet을 정한다

인증 요청과 그 답, 그리고 Actor가 보내는 알림이다. 마지막 것은 답할 요청이 없다.

public record Authenticate(String playerId) {}

public record Authenticated(String playerId) {}

// Pushed by the player to its own connection, with no request to answer.
public record NicknameChanged(String nickname) {}

3. 받는 쪽 — 연결을 Actor에 묶는 node

받는 쪽 process

이 절의 코드는 STREAM에서 stream node를 등록한 process에 들어간다. 등록할 때 actor dispatch를 켜 두어야 이 장의 relay가 동작한다.

3.1 묶기

client가 자기 id를 보내면, 그 id의 Actor를 찾거나 만들어 이 연결에 묶는다.

// Binds each authenticated player to this connection. The packet's Actor slot
// chooses which player receives a later packet, and each can push to the client.
public final class AuthenticateHandler
        implements ZLinkTypedSessionPacketHandler<ZLinkSessionContext, Contracts.Authenticate> {

    private final ZLinkActorManager players;

    public AuthenticateHandler(ZLinkActorManager players) {
        this.players = players;
    }

    @Override
    public Class<Contracts.Authenticate> messageType() {
        return Contracts.Authenticate.class;
    }

    @Override
    public CompletionStage<Void> handle(
            ZLinkSessionContext context,
            ZLinkSessionDispatchContext dispatch,
            Contracts.Authenticate message) {
        // A returning client finds its existing player rather than a new one.
        return players.getOrCreate(message.playerId(), "player")
                .inMesh("game")
                .request(new Contracts.CreatePlayer(message.playerId()))
                .timeout(Duration.ofSeconds(10))
                .submit()
                .thenCompose(result -> context.actors().bindOrGet(resolve(result)))
                .thenCompose(
                        bound ->
                                context.client()
                                        .reply(new Contracts.Authenticated(bound.actorId()))
                                        .submit());
    }

    private static ActorRef resolve(ZLinkActorCreateResult result) {
        if (result instanceof ZLinkActorCreateResult.Existing existing) {
            return existing.actor();
        }
        if (result instanceof ZLinkActorCreateResult.Created created) {
            return created.actor();
        }
        throw new IllegalStateException("Player creation was rejected.");
    }
}

같은 id로 다시 접속하면 있던 Actor에 묶인다. 그래서 만들기와 찾기를 한 호출로 처리한다.

3.2 남은 packet을 넘기기

session이 처리하지 못한 packet을 묶인 Actor로 보낸다. 대상은 packet마다 dispatch context의 Actor가 정한다 — client가 Actor handle로 보낸 packet은 그 Actor를 가리킨다. Actor handle 없이 보낸 packet은 Actor가 비어 있으므로, 이 연결에 묶인 Actor가 하나이면 그 Actor로 보낸다. 묶인 Actor가 없으면 보낼 곳이 없으므로 인증이 먼저라는 것이 여기서 강제된다. client 쪽 구분은 packet 수신 §8이 다룬다.

ZLinkSessionActor player = dispatch.actor();
if (player == null) {
    var bound = context.actors().bound();
    player =
            switch (bound.size()) {
                case 1 -> bound.getFirst();
                case 0 ->
                        throw new IllegalStateException(
                                "Authenticate an Actor before sending player packets.");
                default ->
                        throw new IllegalStateException(
                                "Select an Actor handle when more than one Actor is bound.");
            };
}
return player.relay(dispatch, payload);

넘어간 packet은 그 Actor의 handler가 받는다 — Actor에서 사용한 것과 같은 handler다. 같은 handler가 mesh 호출로도 STREAM relay로도 호출된다.

3.3 Actor가 그 연결로 보내기

Actor는 자기에게 묶인 연결을 알고 있다. 응답이 아니라 스스로 보내는 알림이다.

// Pushes over the connection bound to this player. The same handler also runs
// on an HTTP path with no bound connection, where push ends with InvalidOperation.
// Rename is already complete, so only that failure is discarded.
return player.context()
        .boundSession()
        .send(new Contracts.NicknameChanged(player.nickname()))
        .submit()
        .exceptionally(
                error -> {
                    if (error instanceof ZLinkFrameworkException framework
                            && framework.kind()
                                    == ZLinkFrameworkErrorKind.INVALID_OPERATION) {
                        return null;
                    }
                    throw new CompletionException(error);
                });

묶인 연결이 없으면 push는 InvalidOperation으로 끝난다

같은 handler는 mesh 호출로도 불리며, 그때는 묶인 연결이 없다. 이 경우 push는 호출의 terminal에서 InvalidOperation으로 끝나며, 언어별 비동기 완료 수단으로 그 실패를 관찰한다. 위 코드는 rename을 이미 끝낸 뒤 push하므로 그 실패만 받아 버린다.

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

접속하는 쪽 process

이 절의 코드는 STREAM에서 연결을 연 process에 들어간다. 앞 절과 짝을 이룬다.

인증하고, 알림을 받는다. 두 번째 호출은 답을 기다리지 않는 단방향인데도 뒤에 값이 온다 — 그것이 Actor가 보낸 알림이다.

AutoCloseable boundNotice =
        connector.onActorBound(
                actor -> {
                    System.out.println("actor bound: " + actor.actorId());
                    return java.util.concurrent.CompletableFuture.completedFuture(null);
                });
AutoCloseable unboundNotice =
        connector.onActorUnbound(
                actor -> {
                    System.out.println("actor unbound: " + actor.actorId());
                    return java.util.concurrent.CompletableFuture.completedFuture(null);
                });
// With one Actor bound, the connector can send without an Actor handle.
Contracts.Authenticated authenticatedP1 =
        connector
                .request(new Contracts.Authenticate("p1"))
                .timeout(Duration.ofSeconds(5))
                .submit(Contracts.Authenticated.class)
                .toCompletableFuture()
                .join();

System.out.println("bound player: " + authenticatedP1.playerId());

CompletableFuture<ZLinkStreamMessage<Contracts.NicknameChanged>> singleChanged =
        new CompletableFuture<>();
AutoCloseable singleReceive =
        connector.on(
                Contracts.NicknameChanged.class,
                message -> {
                    singleChanged.complete(message);
                    return CompletableFuture.completedFuture(null);
                });
connector
        .send(new Contracts.ChangeNickname("speedy"))
        .submit()
        .toCompletableFuture()
        .join();
ZLinkStreamMessage<Contracts.NicknameChanged> singlePush = singleChanged.join();
System.out.println(
        "pushed: " + singlePush.payload().nickname() + ", actor: " + singlePush.actorId());
singleReceive.close();

// A second Actor on the same connection calls for explicit handles.
Contracts.Authenticated authenticatedP2 =
        connector
                .request(new Contracts.Authenticate("p2"))
                .timeout(Duration.ofSeconds(5))
                .submit(Contracts.Authenticated.class)
                .toCompletableFuture()
                .join();
System.out.println("bound player: " + authenticatedP2.playerId());

ZLinkStreamActor playerP1 = connector.actor(authenticatedP1.playerId()).orElseThrow();
ZLinkStreamActor playerP2 = connector.actor(authenticatedP2.playerId()).orElseThrow();
System.out.println("actor handle: " + playerP1.actorId());
System.out.println("actor handle: " + playerP2.actorId());

// Register on each handle before sending. Each callback sees only that
// Actor's pushes, although both use the same connection and packet type.
CompletableFuture<ZLinkStreamMessage<Contracts.NicknameChanged>> changedP1 =
        new CompletableFuture<>();
CompletableFuture<ZLinkStreamMessage<Contracts.NicknameChanged>> changedP2 =
        new CompletableFuture<>();
AutoCloseable receiveP1 =
        playerP1.on(
                Contracts.NicknameChanged.class,
                message -> {
                    changedP1.complete(message);
                    return CompletableFuture.completedFuture(null);
                });
AutoCloseable receiveP2 =
        playerP2.on(
                Contracts.NicknameChanged.class,
                message -> {
                    changedP2.complete(message);
                    return CompletableFuture.completedFuture(null);
                });

// Connector-level callbacks can distinguish the same pushes by ActorId.
AutoCloseable receiveActorIds =
        connector.on(
                Contracts.NicknameChanged.class,
                message -> {
                    System.out.println("received actor id: " + message.actorId());
                    return CompletableFuture.completedFuture(null);
                });

// The handle addresses this player directly. Its handler pushes the
// result back over the same connection.
playerP1.send(new Contracts.ChangeNickname("speedy-p1"))
        .submit()
        .toCompletableFuture()
        .join();
playerP2.send(new Contracts.ChangeNickname("speedy-p2"))
        .submit()
        .toCompletableFuture()
        .join();

ZLinkStreamMessage<Contracts.NicknameChanged> pushedP1 = changedP1.join();
System.out.println(
        "pushed: " + pushedP1.payload().nickname() + ", actor: " + pushedP1.actorId());
ZLinkStreamMessage<Contracts.NicknameChanged> pushedP2 = changedP2.join();
System.out.println(
        "pushed: " + pushedP2.payload().nickname() + ", actor: " + pushedP2.actorId());

5. 실행 결과

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

dotnet run --project StreamClient/StreamClient.csproj
# connected: True
# round trip: 56ms
# bound player: p1
# pushed: speedy

pushed 줄은 client가 요구하지 않은 응답이다. client는 이름을 바꾸라고 보냈을 뿐 답을 요구하지 않았다. 돌아온 것은 player가 스스로 보낸 알림이다. 그 사이에 packet은 session을 지나 Actor로 갔고, Actor가 같은 연결로 되돌려 보냈다.

6. 연결이 끊긴 뒤 — session은 끝나고 Actor는 남는다

session은 끝나고 Actor는 남는다. 같은 id로 다시 접속하면 그 Actor에 다시 묶인다.

끊김을 Actor에게 알리는 callback, 묶음이 다른 연결로 옮겨 갈 때의 규칙, 그리고 Actor가 다른 node로 이동하는 중의 처리는 Session 묶음의 동작 원리가 다룬다.

7. 관련 문서