Spot¶
가이드 홈 | 이전: Channel 메시징 | 다음: Actor
다른 언어로 보기 — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
이 장을 읽고 나면
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