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.
options.add_location_store<fw::redis::redis_location_store_t> ()
.set_connection_string ("127.0.0.1:6379")
.set_key_prefix ("zlink-tutorial-cpp:location:");
Relocation Store도 함께 등록한다. Spot factory를 등록하는 것 자체가 조건이라, 이 장처럼 이동을 끄더라도 요구된다.
// Registering any Spot factory requires this store, even with relocation
// turned off: the registration itself is the condition.
options.add_relocation_store<fw::redis::redis_relocation_store_t> ()
.set_connection_string ("127.0.0.1:6379")
.set_key_prefix ("zlink-tutorial-cpp: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 C++ user Spot names the actor type it can admit. This room admits none,
// so it names the base type and rejects every join. Actors are a later chapter.
class game_room_t : public fw::spot_t<fw::actor_t>
{
public:
explicit game_room_t (fw::spot_context_t context) : _context (std::move (context)) {}
fw::spot_context_t &context () noexcept override { return _context; }
const fw::spot_context_t &context () const noexcept override { return _context; }
// Handlers are member functions named here. A member returning nothing is a
// send handler; one returning a value answers a request with it.
void configure () override
{
_context.handlers ().add_handler<&game_room_t::post_chat> ();
_context.handlers ().add_handler<&game_room_t::get_room_state> ();
}
void post_chat (const post_chat_t &message)
{
auto line = message.player_id + ": " + message.text;
_chat.push_back (line);
}
// The return value is the reply. This handler only reads.
room_state_t get_room_state (const get_room_state_t &) const
{
return room_state_t{_title, _chat};
}
// 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.
fw::task_t<fw::spot_create_response_t> on_create (const fw::message_t &request) override
{
const auto &body = request.decode<open_room_t> ();
_title = body.title;
co_return fw::spot_create_response_t::accept ();
}
// No actor ever joins this room, so the three membership callbacks below say
// so and do nothing else.
fw::task_t<fw::spot_actor_join_result_t> on_actor_join (std::string_view,
const fw::message_t &) override
{
co_return fw::spot_actor_join_result_t ::reject ();
}
fw::task_t<void> on_actor_joined (fw::actor_t &) override { co_return; }
fw::task_t<void> on_leave_actor (fw::actor_t &) override { co_return; }
private:
fw::spot_context_t _context;
std::string _title = "untitled";
std::vector<std::string> _chat;
};
만들 때 거절하려면 생성 callback에서 거절한다. 그러면 생성 호출이 실패하고 Spot은 남지 않는다. 그 callback을 두지 않으면 모든 생성 요청을 받아들인다.
3.2 handler 작성¶
handler는 대상 Spot을 첫 인자로 받는다. channel handler와 달리 그 Spot의 상태를 직접 다룬다.
// Handlers are member functions named here. A member returning nothing is a
// send handler; one returning a value answers a request with it.
void configure () override
{
_context.handlers ().add_handler<&game_room_t::post_chat> ();
_context.handlers ().add_handler<&game_room_t::get_room_state> ();
}
void post_chat (const post_chat_t &message)
{
auto line = message.player_id + ": " + message.text;
_chat.push_back (line);
}
// The return value is the reply. This handler only reads.
room_state_t get_room_state (const get_room_state_t &) const
{
return room_state_t{_title, _chat};
}
반환값이 없는 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.
auto objects = mesh.objects ().server ();
// "game-room" is the stable type 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.add_spot_factory<game_room_t> ("game-room").disable_relocation ();
등록할 때 주는 이름이 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.
options.add_location_store<fw::redis::redis_location_store_t> ()
.set_connection_string ("127.0.0.1:6379")
.set_key_prefix ("zlink-tutorial-cpp: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 집합의 이름이고, in_mesh는 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.
zlink::routing_id_t routing_id = zlink::routing_id_t::from ("game-server-1");
auto mesh = options.add_route_mesh ("game")
.listen ("tcp://127.0.0.1:7401")
.set_routing_id (routing_id)
// Publish the same loopback address that this node listens on.
.set_advertise_host ("127.0.0.1");
그 mesh 안에 Spot을 만드는 호출은 다음과 같다.
// spot_manager_t creates a room; the reply carries the id every later call uses.
class open_room_http_handler_t
{
public:
explicit open_room_http_handler_t (fw::spot_manager_t &rooms) : _rooms (rooms) {}
fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
{
const auto body = nlohmann::json::parse (request.body).get<open_room_t> ();
auto created = co_await _rooms
.create ("game-room") // Picks the factory and the candidate nodes.
.in_mesh ("game")
.creation_request (body) // Reaches the room's create callback.
.async ();
// From here on the room is addressed by this id alone.
co_return fw::http_response_t{200, nlohmann::json (created.spot.spot_id ()).dump ()};
}
private:
fw::spot_manager_t &_rooms;
};
4.2 호출하기¶
만들 때 받은 id만 준다. 지금 어느 node에 있는지는 Framework가 찾는다.
class post_chat_http_handler_t
{
public:
explicit post_chat_http_handler_t (fw::route_client_t &routes) : _routes (routes) {}
fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
{
const auto room_id = request.route_values.at ("roomId");
const auto message = nlohmann::json::parse (request.body).get<post_chat_t> ();
// The id is enough; the Framework resolves where the room currently runs.
co_await _routes.send_to_spot (room_id, message).async ();
co_return fw::http_response_t{202, ""};
}
private:
fw::route_client_t &_routes;
};
답이 필요하면 요청을 보낸다. 대상이 옮겨 가는 중일 수 있으므로 timeout을 함께 준다.
class get_room_state_http_handler_t
{
public:
explicit get_room_state_http_handler_t (fw::route_client_t &routes) : _routes (routes) {}
fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
{
const auto room_id = request.route_values.at ("roomId");
auto state = co_await _routes.request_to_spot (room_id, get_room_state_t{})
.timeout (std::chrono::seconds (3))
.async<room_state_t> ();
co_return fw::http_response_t{200, nlohmann::json (state).dump ()};
}
private:
fw::route_client_t &_routes;
};
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/cpp/tutorial