콘텐츠로 이동

Spot

가이드 홈 | 이전: Channel 메시징 | 다음: Actor

이 장을 읽고 나면

id로 호출하는 상태 객체를 만들고, 그것에 메시지를 보내고 답을 받을 수 있다. 이 장의 코드는 언어별 예제 저장소의 tutorial에서 가져왔다.

Channel 메시징의 호출은 이름을 맡은 node 중 하나가 받았다. 받을 대상이 정해져 있으면 그 경로를 사용할 수 없다. Spot은 id로 찾는 상태 객체이고, 자기 앞으로 온 일을 한 줄로 세워 처리한다. 이 장은 Spot 하나를 만들어 호출하기까지 다루며, 종류별 차이와 lifecycle 전체는 Spot 동작 원리에서 다룬다.

1. Spot의 역할

Spot은 채팅방 하나, 매칭 queue 하나처럼 무언가를 기억하면서 자기 앞으로 온 일을 순서대로 처리하는 단위를 만든다. channel로는 그것을 표현할 수 없다 — channel 호출은 그 이름을 맡은 node 중 하나로 가므로, 같은 방에 보낸 두 메시지가 서로 다른 node에 도착할 수 있다.

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

Spot은 id를 가진다. 같은 id로 보낸 메시지는 언제나 같은 Spot에 도착하고, 그 Spot 안에서 한 번에 하나씩 실행된다. 그래서 Spot의 필드는 lock 없이 다뤄도 된다.

↗ 크게 보기

만드는 쪽도 호출하는 쪽도 node를 고르지 않는다. 그 점이 channel과 같고, 대상이 하나로 정해진다는 점이 channel과 다르다.

2. Location Store — Spot 등록의 선행 조건

Spot은 host가 아니라 id로 호출된다. 그래서 지금 어느 node에 있는지를 적어 둘 곳이 필요하다. 그것이 Location Store이고, Spot을 하나라도 등록하면 반드시 있어야 한다.

// Rooms are addressed by id, not by host, so their current location is
// kept here. Every node reads and writes the same store under the same
// prefix.
builder.addLocationStore(
  new ZLinkRedisLocationStore({
    url: 'redis://127.0.0.1:6379',
    keyPrefix: 'zlink-tutorial-node:location'
  })
);

Relocation Store도 함께 등록한다. Spot factory를 등록하는 것 자체가 조건이라, 이 장처럼 이동을 끄더라도 요구된다.

// Registering any Spot factory requires this store, even with relocation
// turned off: the registration itself is the condition.
builder.addRelocationStore(
  new ZLinkRedisRelocationStore({
    url: 'redis://127.0.0.1:6379',
    keyPrefix: 'zlink-tutorial-node:relocation'
  })
);

호출만 하는 node에는 Location Store만 있으면 된다 — 호출하는 쪽 — Spot을 호출하는 node.

3. 받는 쪽 — Spot을 호스팅하는 node

받는 쪽 process

이 절의 코드는 Spot을 실행하는 process에 들어간다.

3.1 Spot 작성

Spot은 상태를 필드로 들고, 만들어질 때 받은 payload로 자신을 초기화한다.

// A room owns its own state and is addressed by a global SpotId. Messages sent
// to one room run one at a time, so the fields below need no synchronization.
//
// Every Node user Spot names the actor type it can admit. This room admits
// none, so it takes the default type and rejects every join. Actors are a later
// chapter. The scope is TRANSIENT because the Framework builds one instance per
// room.
@Injectable({ scope: Scope.TRANSIENT })
class GameRoom implements ZLinkSpot {
  readonly context!: ZLinkSpotContext;

  private title = 'untitled';
  private readonly chat: string[] = [];

  // Runs before the room accepts any message. Rejecting here means the create
  // call fails and no room exists. Omit this method to accept every request.
  async onCreate(request: ZLinkMessage): Promise<ZLinkSpotCreateResponse> {
    const body = request.decode<OpenRoom>();
    this.title = body.title;
    return { accepted: true };
  }

  append(line: string): void {
    this.chat.push(line);
  }

  state(): RoomState {
    return {
      title: this.title,
      chat: [...this.chat]
    };
  }

  // No actor ever joins this room, so the three membership callbacks below say
  // so and do nothing else.
  async onActorJoin(): Promise<ZLinkSpotActorJoinResult> {
    return { accepted: false };
  }

  async onJoinedActor(): Promise<void> {}

  async onLeaveActor(): Promise<void> {}
}

만들 때 거절하려면 생성 callback에서 거절한다. 그러면 생성 호출이 실패하고 Spot은 남지 않는다. 그 callback을 두지 않으면 모든 생성 요청을 받아들인다.

3.2 handler 작성

handler는 대상 Spot을 첫 인자로 받는다. channel handler와 달리 그 Spot의 상태를 직접 다룬다.

// Spot handlers live in their own classes and take the target room as the first
// argument. The decorator names the room type and the packet, so nothing has to
// be registered again in the module.
@zlinkSpotPacketHandler({
  spot: () => GameRoom,
  packetName: PacketNames.postChat
})
class PostChatHandler implements ZLinkSpotPacketHandler<GameRoom, PostChat> {
  async handle(room: GameRoom, message: PostChat): Promise<void> {
    const line = `${message.playerId}: ${message.text}`;
    room.append(line);
  }
}

// The return value is the reply. This handler only reads.
@zlinkSpotPacketHandler({
  spot: () => GameRoom,
  packetName: PacketNames.getRoomState
})
class GetRoomStateHandler implements ZLinkSpotRequestHandler<GameRoom, GetRoomState, RoomState> {
  async handle(room: GameRoom): Promise<RoomState> {
    return room.state();
  }
}

반환값이 없는 handler는 send로 온 메시지를 받고, 값을 돌려주는 handler는 그 값이 응답이 된다. 두 handler는 같은 Spot에서 한 번에 하나씩 실행된다.

3.3 등록

mesh node가 Object Server 역할을 한 번 고르고, 그 위에 Spot factory를 등록한다.

// A mesh node picks this role once. Keep the builder and reuse it,
// because calling objects().server() a second time is rejected at
// startup.
const objects = mesh.objects().server();

// The stable type is what a caller names when opening a room. Any node
// that registers it is a candidate to host one. Exactly one relocation
// policy is required; moving a live room to another node is a separate
// topic.
objects.addSpotFactory(TutorialNames.gameRoomType, GameRoom, (factory) =>
  factory.disableRelocation()
);

등록할 때 주는 이름이 stable type이다. 호출하는 쪽은 Spot을 만들 때 그 이름을 지정한다. 같은 이름을 등록한 node가 후보가 된다. Spot factory를 등록할 때 이동 정책을 하나 지정하며, 이 장의 코드는 이동을 끈 정책을 지정한다. adapter로 상태를 보존하려면 이 등록에서 preserveStateWith를 지정한다 — Relocation의 adapter가 그 상태를 담고 푸는 코드를 보인다.

4. 호출하는 쪽 — Spot을 호출하는 node

호출하는 쪽 process

이 절의 코드는 방을 만들고 호출하는 다른 process에 들어간다. 앞 절과 짝을 이룬다.

호출하는 node는 Spot을 실행하지 않으므로 factory를 등록하지 않는다. 대신 그 mesh에서 client 역할을 고르고, 위치를 읽을 Location Store를 같은 prefix로 등록한다.

// Rooms are looked up by whoever calls them, so a node that hosts none
// still needs the store, pointed at the same prefix.
builder.addLocationStore(
  new ZLinkRedisLocationStore({
    url: 'redis://127.0.0.1:6379',
    keyPrefix: 'zlink-tutorial-node:location'
  })
);

// client() rules out registering factories. This node creates and calls
// rooms; a node that picked server() runs them.
mesh.objects().client();

4.1 만들기

같은 id에 항상 같은 Spot type을 사용하는 stable type을 지정해 Spot을 만든다. 응답으로 오는 id가 그 뒤 모든 호출의 주소다.

먼저 tutorial의 Server가 game route mesh를 정의한다. mesh 이름은 Spot이 배치될 수 있는 node 집합의 이름이고, inMesh는 route mesh에 참여한 node 가운데 Spot을 배치할 node를 고른다. route mesh를 등록하는 방법은 Channel 메시징의 받는 쪽에 있다.

// Both sides must name the mesh identically, or they never see each other
// as peers. The routing id names this node; without it the Framework assigns
// a generated one, which a caller cannot type into a URL. This local
// example binds and advertises a loopback endpoint.
const mesh = builder
  .addRouteMesh(TutorialNames.mesh)
  .listen('tcp://127.0.0.1:7701')
  .setAdvertiseHost('127.0.0.1')
  .routingId(TutorialNames.serverRoutingId);

그 mesh 안에 Spot을 만드는 호출은 다음과 같다.

map('POST', /^\/rooms$/, async (_params, body) => {
  const request = JSON.parse(body) as { title: string };

  const created = await rooms
    .create(TutorialNames.gameRoomType) // Picks the factory and the candidate nodes.
    .inMesh(TutorialNames.mesh)
    .request(new OpenRoom(request.title)) // Reaches the room's create callback.
    .submit();

  // From here on the room is addressed by this id alone.
  return ok(created.spot.spotId);
});

4.2 호출하기

만들 때 받은 id만 준다. 지금 어느 node에 있는지는 Framework가 찾는다.

map('POST', /^\/rooms\/([^/]+)\/chat$/, async ([roomId], body) => {
  const message = JSON.parse(body) as { playerId: string; text: string };

  // The id is enough; the Framework resolves where the room currently runs.
  await spots.sendToSpot(roomId, new PostChat(message.playerId, message.text)).submit();

  return accepted();
});

답이 필요하면 요청을 보낸다. 대상이 옮겨 가는 중일 수 있으므로 timeout을 함께 준다.

map('GET', /^\/rooms\/([^/]+)$/, async ([roomId]) => {
  const state = await spots
    .requestToSpot(roomId, new GetRoomState())
    .timeout(3000)
    .submit<RoomState>();

  return ok(state);
});

5. 실행 결과

tutorial README의 「실행」 절을 따라 띄운 상태에서 Server와 Client를 실행한 상태에서 Client의 HTTP 표면에 아래 curl 요청을 보내면, 각 HTTP 응답은 curl stdout에 나오고 handler 기록은 Server process의 stdout 또는 server.log에 나온다.

curl -X POST http://127.0.0.1:5080/rooms \
  -H 'Content-Type: application/json' -d '{"title":"lobby"}'
# "8213420c-03ff-47a6-8410-ddfb3df26660"

curl -X POST http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660/chat \
  -H 'Content-Type: application/json' -d '{"playerId":"p1","text":"hello"}'
# 202

curl http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660
# {"title":"lobby","chat":["p1: hello"]}

id는 Framework가 만든다. 두 번째 호출은 응답을 기다리지 않고, 세 번째는 방이 만든 답을 받는다. 방은 두 호출 사이에 두 호출 사이에도 상태를 유지한다.

6. Spot의 다른 종류

이 장이 만든 것은 application이 명시적으로 만드는 Spot이고, 대부분의 방·스테이지·존이 여기 해당한다. 그 밖에 Object Server가 시작할 때 Framework가 만드는 것과, 첫 메시지가 도착할 때 만들어지는 것이 있다. 생성 시점과 받는 lifecycle callback이 서로 다르다.

▶ 둘러보기에서 직접 눌러 보기 — Instance Spot

종류별 차이와 lifecycle 전체는 Spot이 다룬다.

7. 관련 문서

  • id로 호출하는 또 다른 단위 — Actor
  • 이름으로 호출하는 경로 — Channel 메시징
  • Location Store가 무엇을 적어 두는가 — Channel 동작 원리
  • 이 장 코드의 실행본 — framework/languages/node/tutorial