콘텐츠로 이동

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가 보내는 알림이다. 마지막 것은 답할 요청이 없다.

export class Authenticate {
  constructor(readonly playerId: string) {}
}

export class Authenticated {
  constructor(readonly playerId: string) {}
}

// Pushed by the player to its own connection, with no request to answer.
export class NicknameChanged {
  constructor(readonly nickname: string) {}
}

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

받는 쪽 process

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

3.1 묶기

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

// Binds each authenticated player to this connection. Packets addressed to
// that player's Actor reach it, and the player can push to this connection.
@Injectable()
@ZLinkPacket(PacketNames.authenticate)
class AuthenticateHandler {
  constructor(@Inject(ZLINK_ACTOR_MANAGER) private readonly players: ZLinkActorManager) {}

  async handle(
    context: ZLinkSessionContext,
    _dispatch: ZLinkSessionDispatchContext,
    payload: ZLinkMessage
  ): Promise<void> {
    const message = payload.decode(Authenticate);

    // A returning client finds its existing player rather than a new one.
    const result = await this.players
      .getOrCreate(message.playerId, TutorialNames.playerActorType)
      .inMesh(TutorialNames.mesh)
      .request(new CreatePlayer(message.playerId))
      .timeout(10_000)
      .submit();

    if (result.status === 'rejected') {
      throw new Error(`Player '${message.playerId}' creation was rejected.`);
    }

    const bound = await context.actors.bindOrGet(result.actor);

    await context.client.reply(new Authenticated(bound.actorId)).submit();
  }
}

같은 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이 다룬다.

// A packet with an Actor slot selects its binding. A packet without one
// can use the connection only when exactly one Actor is bound.
const bound = this.context.actors.bound;
const actor = dispatch.actor ?? (bound.length === 1 ? bound[0] : undefined);
if (!actor) {
  throw new Error(
    bound.length === 0
      ? 'Authenticate an Actor before sending player packets.'
      : 'Select an Actor handle when more than one Actor is bound.'
  );
}

await actor.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.
try {
  await player.context.boundSession.send(new NicknameChanged(player.nickname)).submit();
} catch (error) {
  if (
    !(error instanceof ZLinkFrameworkException) ||
    error.kind !== ZLinkFrameworkErrorKind.InvalidOperation
  ) {
    throw error;
  }
}

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

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

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

접속하는 쪽 process

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

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

const handles = new Map<string, ZlinkStreamActor>();
const boundNotice = connector.onActorBound((actor) => {
  handles.set(actor.actorId, actor);
  console.log(`actor bound: ${actor.actorId}`);
});
const unboundNotice = connector.onActorUnbound((actor) => {
  handles.delete(actor.actorId);
  console.log(`actor unbound: ${actor.actorId}`);
});
// Two characters share this connection. Each authentication binds one Actor.
const authenticated = await connector
  .request(new Authenticate('p1'))
  .timeout(5_000)
  .submit<Authenticated>();

console.log(`bound player: ${authenticated.playerId}`);

// With only p1 bound, a connector-level packet needs no Actor handle.
let resolveSingle!: (message: ZlinkStreamMessage<NicknameChanged>) => void;
const singlePush = new Promise<ZlinkStreamMessage<NicknameChanged>>((resolve) => {
  resolveSingle = resolve;
});
const receiveSingle = connector.on<NicknameChanged>(PacketNames.nicknameChanged, resolveSingle);
await connector.send(new ChangeNickname('speedy')).submit();
const pushedSingle = await singlePush;
console.log(`pushed: ${pushedSingle.payload.nickname}, actor: ${pushedSingle.actorId}`);
receiveSingle.dispose();

// A second Actor on the same connection makes the handle explicit.
const authenticatedP2 = await connector
  .request(new Authenticate('p2'))
  .timeout(5_000)
  .submit<Authenticated>();

console.log(`bound player: ${authenticatedP2.playerId}`);

const player1 = handles.get(authenticated.playerId);
if (!player1) throw new Error('Player Actor p1 was not bound.');
const player2 = handles.get(authenticatedP2.playerId);
if (!player2) throw new Error('Player Actor p2 was not bound.');
console.log(`actor handle: ${player1.actorId}`);
console.log(`actor handle: ${player2.actorId}`);

// Each callback receives only the push for its handle's Actor.
let resolveChanged1!: (message: ZlinkStreamMessage<NicknameChanged>) => void;
let resolveChanged2!: (message: ZlinkStreamMessage<NicknameChanged>) => void;
const changed1 = new Promise<ZlinkStreamMessage<NicknameChanged>>((resolve) => {
  resolveChanged1 = resolve;
});
const changed2 = new Promise<ZlinkStreamMessage<NicknameChanged>>((resolve) => {
  resolveChanged2 = resolve;
});
const receive1 = player1.on<NicknameChanged>(PacketNames.nicknameChanged, resolveChanged1);
const receive2 = player2.on<NicknameChanged>(PacketNames.nicknameChanged, resolveChanged2);

// Connector-level callbacks see every push and can tell the Actors apart.
let resolveActorIds!: () => void;
const actorIdsReceived = new Promise<void>((resolve) => {
  resolveActorIds = resolve;
});
let actorIdsSeen = 0;
const receiveActorId = connector.on<NicknameChanged>(PacketNames.nicknameChanged, (message) => {
  console.log(`received actor id: ${message.actorId}`);
  if (++actorIdsSeen === 2) resolveActorIds();
});

// Each handle sends to its own player over the same connection.
await player1.send(new ChangeNickname('speedy-p1')).submit();
await player2.send(new ChangeNickname('speedy-p2')).submit();

const pushed1 = await changed1;
const pushed2 = await changed2;
await actorIdsReceived;
console.log(`pushed: ${pushed1.payload.nickname}, actor: ${pushed1.actorId}`);
console.log(`pushed: ${pushed2.payload.nickname}, actor: ${pushed2.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. 관련 문서