콘텐츠로 이동

TicTacToe 따라 읽기

가이드 홈 | 이전: Bingo 따라 읽기 | 다음: SupportChat 따라 읽기

이 장을 읽고 나면

TicTacToe 샘플을 편집기에 열고, HTTP로 room을 만드는 곳부터 두 player가 방을 나가고 Actor가 정리되는 곳까지 코드를 따라갈 수 있다. 이 장의 코드는 언어별 예제 저장소의 TicTacToe 샘플에서 가져온다.

샘플 고르기가 이 샘플이 무엇을 보여 주는지 소개했다. 이 장은 그 소개 다음에 읽는 자리다 — 역할과 코드 위치, 주요 시나리오의 메시지 흐름, 각 흐름에 등장하는 framework 기능과 그것을 설명하는 장을 소스가 놓인 순서대로 따라간다. 이 장은 TicTacToe 샘플의 역할과 코드 위치, 주요 메시지 흐름, 실행 검증을 소스 순서대로 설명한다. 요구사항, 메시지 계약과 검증 기준은 TicTacToe 시나리오에서 참고한다.

1. 이 샘플이 보여 주는 것

별도 Session 서버가 없다. Play가 STREAM 연결, player Actor, Entry Spot과 room Spot을 함께 소유하고, Api는 HTTP room 생성과 인증만 맡는다. 서버 사이의 연결은 Location Store로 자동 해석하지 않고 runner가 준 endpoint로 코드에서 직접 연결한다. handler도 scan에 맡기지 않고 등록 코드에 하나씩 적는다.

↗ 크게 보기

수동인 것은 node 사이의 연결이다. room과 Actor가 지금 어느 node에 있는지는 여기서도 Location Store가 해석한다. host는 Play A에, guest와 observer는 Play B에 접속하므로, room을 소유한 node와 Actor가 만들어진 node가 다른 경우가 한 판 안에서 반드시 생긴다 — 그 경우를 framework가 어떻게 처리하는지가 이 샘플의 두 번째 주제다.

2. 역할과 코드 위치

역할 process 수 소유하는 것 코드 위치
Api 2 HTTP room 생성, token 검증 Server/Api
Play 2 STREAM session, Entry Spot, player Actor, room Spot, milestone publish Server/Play/Infrastructure
Client 3 host·guest·observer 시나리오와 self-check Client

board, turn, 승·무 판정은 Server/Play/Domain에 있고 framework type을 참조하지 않는다. 메시지는 shared의 JSON 계약이 정한다.

3. 서버 구성 — 수동 연결과 수동 등록

Play는 stream node, API channel client와 route mesh를 등록하고, mesh의 object server에 Entry Spot, player Actor factory와 room Spot factory를 등록한다. API channel과 mesh의 상대 endpoint는 설정에서 읽어 직접 연결한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/PlayServer.java

var apiClient = options.addClientServerChannel(SampleNames.ApiChannel).client();
for (String endpoint : settings.apiChannelEndpoints()) {
    // Api A와 Api B를 모두 수동 등록하고 request마다 가용 endpoint를 선택한다.
    apiClient.connect(endpoint);
}
ZLinkMeshNodeBuilder node = options.addRouteMesh(SampleNames.SpotMesh);
String routeEndpoint =
        settings.routeEndpoint().isBlank()
                ? settings.spotEndpoint()
                : settings.routeEndpoint();
node.listen(routeEndpoint)
        .setRoutingId(RoutingId.from("tictactoe-" + settings.nodeId()));
node.channelName(SampleNames.PlayNode).server();
node.peerConnections().connect(settings.peerSpotEndpoint());
node.objects()
        .server()
        .addEntrySpot(PlayEntrySpot.class)
        .addSpotFactory(
                "tictactoe.game",
                TicTacToeGame.class,
                factory -> factory.disableRelocation())
        .addActorFactory(
                SampleNames.PlayActor,
                PlayActor.class,
                PlayActorFactory.class,
                factory -> factory.preserveStateWith(PlayActorRelocationAdapter.class));
options.addStreamNode(SampleNames.PlayStream)
        .bind(settings.playEndpoint())
        .enableActorDispatch()
        .registerSession(PlaySession.class)
        // request: STREAM AuthenticateReq를 처리하고 AuthenticateRes를 reply한다.
        .addSessionPacketHandler(AuthenticatePlaySessionHandler.class);

player Actor factory는 상태를 보존하는 adapter를 지정하고, room Spot factory는 relocation을 끈다. Api 쪽도 같은 방식으로 Play의 mesh endpoint에 연결한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/api/ApiServer.java

for (String endpoint : settings.spotEndpoints()) {
    mesh.peerConnections().connect(endpoint);
}

수동 연결에서도 routing id를 함께 적는다 — framework는 endpoint를 Location Store의 descriptor와 대조해 상대가 기대한 node인지 확인한다. 연결과 discovery의 차이는 Channel 동작 원리가 다룬다.

handler는 scan에 맡기지 않고 Spot과 session의 handler registry에 packet 이름과 함께 직접 등록한다 — 위 host 구성과 각 Spot의 configure callback에 그 등록이 있다. managed 언어에서 이렇게 등록하는 샘플은 TicTacToe뿐이다. 등록 변형은 Handler와 메시지 처리가 다룬다.

4. room 생성 — HTTP 요청에서 Spot Create

client는 HTTP로 room을 만든다. Api의 HTTP handler는 Spot manager에 room Spot 생성을 요청하고, framework가 발급한 RoomId와 Play endpoint 목록을 응답한다.

↗ 크게 보기

이 흐름에서 API는 room의 실행 위치를 정하지 않고 RoomId와 필요한 접속 정보를 응답한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/api/handlers/CreateGameHttpHandler.java

return spots.create("tictactoe.game") // 이 stable type을 등록한 node가 후보가 된다.
        .inMesh(SampleNames.SpotMesh) // Spot을 만들 mesh를 고른다.
        .request(new TicTacToeGameCreateReq(gameName, SampleNames.RequiredLevel))
        .timeout(SampleNames.RequestTimeout)
        .submit() // Java의 비동기 완료 terminal이다.

응답의 Play endpoint는 client가 접속할 곳이지 room의 owner가 아니다. Api는 어느 Play에 room을 만들지 정하지 않으며, 만들어진 위치는 Location Store에 기록된다. User Spot 생성은 Spot이, 생성 시점과 callback은 활성화와 수명이 다룬다.

5. 인증과 bind — Play가 session도 소유한다

client는 응답에서 고른 Play endpoint에 STREAM으로 접속해 인증한다. session handler는 Api에 token을 검증시킨 뒤 player Actor를 만들거나 찾고, 현재 session에 묶는다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/sessions/handlers/AuthenticatePlaySessionHandler.java

.thenCompose(
        authenticated ->
                actors.getOrCreate(
                                authenticated.player().actorId(),
                                SampleNames.PlayActor)
                        .request(new PlayerActorCreateReq(authenticated.player()))
                        .submit()
                        .thenCompose(
                                result -> {
                                    ActorRef resolvedActor = requireActor(result);
                                    return context.actors()
                                            .bind(requireActor(result))
                                            .thenCompose(
                                                    boundActor -> {
                                                        if (!boundActor
                                                                .ref()
                                                                .equals(
                                                                        resolvedActor)) {
                                                            throw new IllegalStateException(
                                                                    "Bound ActorRef"
                                                                            + " does"
                                                                            + " not match"
                                                                            + " the resolved"
                                                                            + " ActorRef"
                                                                            + " for '"
                                                                            + authenticated
                                                                                    .player()
                                                                                    .actorId()
                                                                            + "'.");
                                                        }
                                                        if (result
                                                                instanceof
                                                                ZLinkActorCreateResult
                                                                        .Existing) {
                                                            LOGGER.info(
                                                                    "tictactoe-lifecycle"
                                                                            + " actor-bound"
                                                                            + " actor={}",
                                                                    boundActor
                                                                            .actorId());
                                                        }
                                                        return context.client()
                                                                .reply(
                                                                        new AuthenticateRes(
                                                                                authenticated
                                                                                        .player()))
                                                                .submit();
                                                    });
                                }));

Bingo와 달리 접속 서버와 Actor를 소유한 서버가 같은 process다. 그래도 Actor를 얻는 호출은 어느 node에 만들지 정하지 않는다. 묶기는 Session과 Actor 연결이, 다시 접속한 session이 같은 Actor에 다시 묶이는 규칙은 Session 묶음의 동작 원리가 다룬다.

6. room join — 예약과 다른 node로의 이동

join 요청은 Entry Spot의 actor send handler에 도착한다. handler는 room join을 예약만 하고 끝난다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/handlers/PlayActorJoinGameHandler.java

public final class PlayActorJoinGameHandler {
    @ZLinkSpotActorSend
    public CompletionStage<Void> joinGame(
            PlayEntrySpot entrySpot,
            PlayActor actor,
            ZLinkMessageContext context,
            JoinGameMsg request) {
        actor.trackDeferredJoin(request.roomId());
        actor.context()
                .joinSpot(
                        request.roomId(),
                        new TicTacToeGameJoinReq(request.roomId(), actor.requirePlayer()))
                .timeout(SampleNames.RequestTimeout)
                .defer();
        return CompletableFuture.completedFuture(null);
    }
}

예약된 join이 실행될 때 Actor가 있는 node와 room을 소유한 node가 다르면, framework는 join operation 안에서 Actor를 room의 owner로 옮긴다. 옮기는 동안 application 상태는 player Actor factory에 지정한 adapter가 직렬화한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/actors/PlayActorRelocationAdapter.java

@Override
public CompletionStage<byte[]> capture(
        PlayActor actor, ZLinkRelocationCancellation cancellation) {
    try {
        return CompletableFuture.completedFuture(
                JSON.writeValueAsBytes(
                        new TransferState(
                                actor.joinedRoomId(),
                                actor.playerOrNull(),
                                actor.destroyAfterEntrySpotJoin(),
                                actor.disconnected())));
    } catch (IOException error) {
        return CompletableFuture.failedFuture(error);
    }
}

같은 node에 있으면 adapter는 호출되지 않는다. join이 끝나면 room Spot의 joined callback이 참가자를 등록하고 다른 참가자에게 알린다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java

@Override
public CompletionStage<Void> onJoinedActor(PlayActor actor) {
    TicTacToeGameJoinReq joinRequest = pendingJoins.remove(actor.actorId());
    if (joinRequest == null) {
        throw new IllegalStateException("joined actor does not have a pending admission");
    }
    join(actor, joinRequest.roomId(), joinRequest.player());
    return CompletableFuture.completedFuture(null);
}

예약의 규칙은 Actor membership이, Actor가 옮겨질 때 남는 것과 adapter의 역할은 Relocation이 다룬다.

7. 수 두기와 push

수를 두는 요청은 묶인 Actor로 relay되어 room Spot의 actor request handler에 도착한다. handler는 domain에 수를 반영하고 갱신된 state를 응답한다.

↗ 크게 보기

room Spot의 직렬 handler가 state 갱신과 응답 순서를 하나의 turn 안에서 유지한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/handlers/PlayActorPlaceMarkHandler.java

public final class PlayActorPlaceMarkHandler {
    @ZLinkSpotActorRequest
    public CompletionStage<PlaceMarkRes> placeMark(
            TicTacToeGame spot,
            PlayActor actor,
            ZLinkMessageContext context,
            PlaceMarkReq request) {
        actor.requireJoinedGame();
        PlaceMarkRes response = spot.placeMark(actor, request.cell());
        return CompletableFuture.completedFuture(response);
    }
}

요청한 쪽은 응답으로, 상대는 bound session push로 같은 state를 받는다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java

private void broadcast(GameState state, String excludedActorId) {
    actors.stream()
            .filter(
                    actor ->
                            excludedActorId == null || !actor.actorId().equals(excludedActorId))
            .forEach(
                    actor ->
                            actor.context()
                                    .boundSession()
                                    .send(new GameStateNotify(state))
                                    .submit());
}

turn 제한 시간은 room Spot의 timer가 확인한다. timer는 initialize callback에서 등록하며, tick은 room의 turn 안에서 실행된다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java

@Override
public CompletionStage<Void> onInitialize() {
    // timer: TicTacToeGameTimerHandler가 turn timeout을 주기적으로 확인한다.
    return context.addTimer(
                    "game-tick", GAME_TICK_PERIOD, TicTacToeGameTimerHandler.class, null)
            .thenAccept(timer -> gameTick = timer);
}

잘못된 turn이나 이미 쓴 cell은 application의 거부이므로 typed 오류 응답으로 끝난다. handler 종류는 Handler와 메시지 처리가, timer는 Timer와 worker가 다룬다.

8. milestone의 Logical Multicast

host의 누적 승수가 100이 되면 room Spot이 milestone event를 publish한다. observer는 host와 다른 Play에 접속해 있으므로 room이 observer의 위치를 알 필요가 없도록 channel과 topic으로 발행한다.

↗ 크게 보기

Logical Multicast는 observer의 위치를 알지 않아도 같은 topic을 구독한 대상에게 milestone을 전달한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java

context.outbound()
        .publish(
                SampleNames.PlayNode,
                SampleNames.PlayerMilestoneTopic,
                new PlayerWinMilestoneEvent(
                        after.roomId(), actor.actorId(), player.displayName(), wins))
        .submit();

각 Play의 Entry Spot이 같은 topic을 구독한다. 구독 handler도 packet handler와 같은 registry에 등록한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/handlers/PlayerWinMilestoneEventHandler.java

@ZLinkSpotSubscription(topic = SampleNames.PlayerMilestoneTopic)
public final class PlayerWinMilestoneEventHandler
        implements ZLinkSpotSubscriptionHandler<PlayEntrySpot, PlayerWinMilestoneEvent> {
    @Override
    public CompletionStage<Void> handle(PlayEntrySpot spot, PlayerWinMilestoneEvent event) {
        spot.notifyMilestone(event);
        return CompletableFuture.completedFuture(null);
    }
}

event를 받은 Entry Spot은 관찰을 신청한 Actor마다 notify를 보내고, 각 Actor의 bound session이 그것을 client에 전달한다. observer는 room의 member가 아니며 별도 Spot type도 없다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/PlayEntrySpot.java

public void notifyMilestone(PlayerWinMilestoneEvent event) {
    WinMilestoneNotify payload =
            new WinMilestoneNotify(
                    event.roomId(), event.actorId(), event.displayName(), event.wins());
    for (PlayActor observer : List.copyOf(milestoneObservers)) {
        observer.context().boundSession().send(payload).submit();
    }
}

publish의 정상 완료는 구독자의 처리 완료가 아니다. client는 observer에게 도착한 notify로 전달을 확인한다. pub/sub의 형태는 Channel 동작 원리가 다룬다.

9. disconnect, 재접속과 destroy

연결이 끊기면 framework가 묶인 Actor에 알린다. Actor는 지워지지 않고 room membership도 바뀌지 않는다. 다시 접속해 인증하면 같은 id의 Actor에 새 session이 묶이고, client는 같은 RoomId로 join을 다시 보내 현재 state를 받는다.

↗ 크게 보기

게임이 끝난 뒤 각 player가 leave를 보내면 room Spot은 그 Actor에 destroy 표시를 남기고 room에서 내보낸다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java

public CompletionStage<Void> leaveGame(PlayActor actor, String roomId) {
    if (!this.roomId.equals(roomId)) {
        throw new IllegalStateException("leave request room id does not match game room");
    }
    if (!isTerminal(snapshot())) {
        return CompletableFuture.completedFuture(null);
    }
    actor.markForDestroyAfterRoomLeave();
    return context.leaveActor(actor);
}

Actor는 Entry Spot으로 돌아가고, Entry Spot의 joined callback이 표시를 확인해 destroy를 호출한다.

Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/PlayEntrySpot.java

@Override
public CompletionStage<Void> onJoinedActor(PlayActor actor) {
    if (actor.destroyAfterEntrySpotJoin()) {
        return context.destroyActor(actor)
                .thenRun(
                        () ->
                                LOGGER.info(
                                        "tictactoe-lifecycle actor-destroy-complete actor={}",
                                        actor.actorId()));
    }
    return CompletableFuture.completedFuture(null);
}

끊김 통지는 Session 묶음의 동작 원리가, Entry Spot으로 돌아가는 순서와 destroy는 활성화와 수명이 다룬다.

10. 실행과 검증

runner 하나가 Redis 컨테이너, 서버 process와 client 시나리오를 함께 띄운다.

framework/languages/java/samples/java/TicTacToe/run_sample.sh

client는 room 생성 응답, 인증, join notify, 수 두기 결과, milestone notify와 재접속 뒤의 state를 assertion으로 확인한다. runner는 서버 로그에서 각 Actor의 room leave와 Entry Spot destroy를 확인한다. 확인 항목과 로그 문자열은 TicTacToe 시나리오가 정한다.

11. 관련 문서