Sapphire라는 FF14의 서버를 에뮬레이팅 하는 프로젝트가 있다.
개발사가 사용하는 실제 서버 코드와 동일할 수는 없겠지만, 기본적인 처리는 되어 있기에 이를 통해 나만의 세계에서 FF14를 탐험할 수 있다.
에뮬레이팅 된 서버가 클라이언트와 정상적으로 통신하고 있으며, 서버도 적절한 처리를 해 준다는 의미이다.
그리고 난 이 프로젝트에 사용된 Boost.Asio가 매우 궁금해졌다.
이를 처음부터 공부하며 이 프로젝트에 사용된 비동기 통신 개념을 진정 나의 것으로 만들기 위한 첫걸음을 떼려고 한다.
그전에 Boost.Asio에 대해 알 필요가 있다.
이 글에서 그 내용을 설명할 수도 있지만, 매우 잘 설명해 준 어떤 블로그의 글을 발견해 링크를 남긴다.
물론 공식 문서에서 제공하는 설명도 놓칠 수 없을 것이다.
1. Implementation
나는 비동기로 동작하는 에코 서버를 만들어 보기로 했다.
한글 문자열 "테스트"를 주고받을 것이다.
1-1. Server
A. main()
프로그램의 엔트리 포인트.
int main()
{
try
{
boost::asio::io_context io_context;
server s(io_context, 1234);
io_context.run();
}
catch (std::exception& e)
{
std::cerr << "Exception: " << e.what() << "\n";
}
return 0;
}
가장 기본이 되는 io_context
객체를 선언했다
이를 사용할 server
클래스의 객체 s
에 io_context
와 port
번호를 넘겨줘 생성한다.
io_context.run()
을 호출해 OS에 비동기 작업을 시작함을 알린다.
B. Class server
클라이언트와의 연결을 관리할 클래스이다.
여기에선 클라이언트의 접속 요청을 받고 accept
를 해 주게 될 것이다.
class server
{
public:
server(boost::asio::io_context& io_context, short port)
: _acceptor(io_context, tcp::endpoint(tcp::v4(), port))
{
do_accept();
}
private:
tcp::acceptor _acceptor;
void do_accept()
{
_acceptor.async_accept([this](boost::system::error_code ec, tcp::socket socket)
{
if (!ec)
{
std::make_shared<session>(std::move(socket))->start();
std::cout << "Accepted" << "\n";
}
do_accept();
});
}
};
역시 단순한 에코서버라 그런지 허전하다.
tcp::acceptor
를 통해 accept
작업만 수행하고 세션에 모든 걸 던져준다.
accept handler
함수를 따로 구현하지 않고 람다로 넘겨줬다.
C. Class session
session에서 실제로 I/O 처리가 이루어지게 된다.
필요한 기능은 아래와 같다.
- 클라이언트에서 보내는 데이터 읽기
- do_read() - 읽은 데이터 다시 보내기
- do_write()
에코 서버니까 딱 이거만 필요하다.
class session : public std::enable_shared_from_this<session>
{
public:
session(tcp::socket socket) : socket_(std::move(socket))
{
}
void start()
{
do_read();
}
private:
tcp::socket _socket;
enum { max_length = 1024 };
char _data[max_length];
void do_read() { ... }
void do_write(std::size_t length) { ... }
};
아까 server
에서 async_accept()
를 호출할 때 session
을 shared_ptr
로 만들고 바로 start()
를 호출했다.
do_read()
를 호출해서 사이클을 시작하게 된다.
데이터의 자료형은 char
이고 최대 길이는 1024이므로 최대 1024바이트의 데이터를 처리할 수 있다.
실제 I/O 함수들을 보자.
먼저 do_read()
부터.
void do_read()
{
auto self(shared_from_this());
_socket.async_read_some(boost::asio::buffer(_data, max_length),
[this, self](boost::system::error_code ec, std::size_t length)
{
if (!ec)
{
std::cout << "do_read() / Length: " << length << "\n";
std::cout << "Read Data : ";
std::cout.write(_data, length) << "\n";
do_write(length);
}
else if (ec == boost::asio::error::eof)
{
std::cout << "Succesfully Disconnected" << "\n";
}
else
{
std::cerr << "Disconected with Error: " << ec.message() << "\n";
}
});
}
먼저 객체의 수명 관리를 위해 람다 캡처에 넘겨줄 shared_ptr
을 선언한다.
이를 통해 람다식이 사라지기 전까지 객체의 생존이 보장되므로, 작업 중 객체의 소멸을 방지할 수 있다.
async_read_some()
을 사용했는데, 이는 정해진 길이 없이 연속적으로 데이터를 받을 수 있다.
따라서 가변 길이의 스트리밍 데이터를 읽는 데 적합하다.
게임 등에 쓴다면 고정 길이의 패킷을 주고받을 일이 많기 때문에, 고정 길이를 받는 async_read()
가 더 많이 쓰일 것이다.
이번엔 이런 게 있다 정도의 느낌으로 사용해보려고 한다.
아래의 Sapphire 소스코드를 보자.
void Network::Connection::startRecv( int32_t total_bytes )
{
if( total_bytes > 0 )
{
m_recv_buffer.resize( total_bytes );
asio::async_read( m_socket,
asio::buffer( m_recv_buffer ),
m_io_strand.wrap( [ capture0 = shared_from_this() ]( auto && PH1, auto && PH2 )
{
capture0->handleRecv( std::forward< decltype( PH1 ) > ( PH1 ), std::forward< decltype( PH2 ) > ( PH2 ) );
} ) );
}
else
{
m_recv_buffer.resize( m_receive_buffer_size );
m_socket.async_read_some( asio::buffer( m_recv_buffer ),
m_io_strand.wrap( [ capture0 = shared_from_this() ]( auto && PH1, auto && PH2 )
{
capture0->handleRecv( std::forward< decltype( PH1 ) > ( PH1 ), std::forward< decltype( PH2 ) >( PH2 ) );
} ) );
}
}
total_bytes > 0
일 경우는 받을 데이터의 크기가 명확한 상태이기 때문에 고정 길이의 데이터를 받기 위해 async_read()
를 사용하게 되고, 그렇지 않을 경우엔 데이터의 크기를 현재 알 수 없으므로 당장 사용해야 하는 데이터나 조각난 데이터를 받기 위해 async_read_some()
이 사용된다.
다시 돌아와서, 받은 데이터를 실제로 처리하는 부분을 보자.
if (!ec)
{
std::cout << "do_read() / Length: " << length << "\n";
std::cout << "Read Data : ";
std::cout.write(_data, length) << "\n";
do_write(length);
}
else if (ec == boost::asio::error::eof)
{
std::cout << "Disconnected" << "\n";
}
else
{
std::cerr << "Error: " << ec.message() << "\n";
}
에러 코드가 없을 경우 받은 데이터의 길이와 받은 데이터를 출력토록 했다.
만약 ec == eof
면 클라이언트에서 정상적으로 연결을 종료했다는 의미이기 때문에 연결이 끊겼다는 로그만 출력한다.
그 이외의 경우엔 비정상적으로 연결이 종료되었다는 의미가 되므로 그 메시지를 출력한다.
모든 데이터 출력이 끝나면 do_write()
를 호출해 받은 데이터를 다시 클라이언트로 보낸다.
이제 쓰기로 넘어가자.
void do_write(std::size_t length)
{
auto self(shared_from_this());
boost::asio::async_write(_socket, boost::asio::buffer(_data, length),
[this, self](boost::system::error_code ec, std::size_t length)
{
if (!ec)
{
std::cout << "do_write() / Length " << length << "\n";
std::cout << "Write Data : ";
std::cout.write(_data, length) << "\n";
do_read();
}
else if (ec == boost::asio::error::eof)
{
std::cout << "Succesfully Disconnected" << "\n";
}
else
{
std::cerr << "Disconected with Error: " << ec.message() << "\n";
}
});
}
버퍼의 내용은 양쪽이 공유하고 있다.
단순히 _data
의 포인터를 활용할 뿐이며 이 버퍼의 관리는 프로그래머가 명시적으로 해 주어야 한다.
여기서 읽기와 동일하게 쓸 데이터를 출력하고 보낸 후 다시 읽기를 호출해 사이클을 완성한다.
예외 처리의 경우에도 읽기와 동일하다.
이렇게 읽고 > 쓰고 > 읽고 > 쓰고 > ...의 무한한 사이클이 돌아가게 된다.
서버는 이렇게 구현이 끝났다.
1-2. Client
클라이언트의 동작도 별반 다르지 않다. 똑같이 보내고 받고를 반복할 뿐.
int main()
{
try
{
boost::asio::io_context io_context;
tcp::resolver resolver(io_context);
auto endpoints = resolver.resolve("127.0.0.1", "1234");
auto c = std::make_shared<client>(io_context, endpoints);
std::thread t([&io_context]() { io_context.run(); });
while (true)
{
std::string message = "테스트";
c->write(message);
std::this_thread::sleep_for(std::chrono::seconds(1)); // cross flatform
}
t.join();
}
catch (std::exception& e)
{
std::cerr << "Exception: " << e.what() << "\n";
}
return 0;
}
차이점이 있다면 서버 주소를 처리하기 위해 resolver
를 사용하고, 데이터를 보내기 위한 부분이 따로 있다는 점 정도.
A. Class client
서버와의 차이점은 데이터를 쓰기 위해 write()
함수가 추가됐고, 연결을 위한 async_connect()
함수가 추가된 것 말고는 없다고 봐도 좋다.
class client : public std::enable_shared_from_this<client>
{
public:
client(boost::asio::io_context& io_context, const tcp::resolver::results_type& endpoints)
: _io_context(io_context), _socket(io_context)
{
do_connect(endpoints);
}
void write(const std::string& message)
{
boost::asio::post(_io_context,
[this, message]()
{
const bool write_in_progress = !_write_msgs.empty();
_write_msgs.push_back(message);
if (!write_in_progress)
{
do_write();
}
});
}
private:
boost::asio::io_context& _io_context;
tcp::socket _socket;
std::array<char, 1024> _read_msg;
std::deque<std::string> _write_msgs;
// ...
}
보낼 데이터는 _write_msgs
에 저장한다.
양쪽 끝에 데이터를 삽입/삭제해야하는 경우 vector
보다 deque
가 구조 상 I/O 과정에 있어 효율적일 수 있다.
보낼 데이터를 끝에 넣고 do_write()
에서 실제로 보내게 된다.
이제 나머지 함수들을 보자.
void do_connect(const tcp::resolver::results_type& endpoints)
{
boost::asio::async_connect(_socket, endpoints,
[this](boost::system::error_code ec, tcp::endpoint)
{
if (!ec)
{
do_read();
}
});
}
void do_read()
{
_socket.async_read_some(boost::asio::buffer(_read_msg),
[this](boost::system::error_code ec, std::size_t length)
{
if (!ec)
{
std::cout << "Received Msg : ";
std::cout.write(_read_msg.data(), length) << "\n";
do_read();
}
});
}
void do_write()
{
boost::asio::async_write(_socket,
boost::asio::buffer(_write_msgs.front()),
[this](boost::system::error_code ec, std::size_t /*length*/)
{
if (!ec)
{
_write_msgs.pop_front();
if (!_write_msgs.empty())
{
do_write();
}
}
});
}
서버와 동일한 동작을 수행하고 있다. 객체가 생성되면 do_connect()
를 호출해 서버와의 연결을 시도한다.
연결이 되면 main()
의 반복문 안에서 사이클이 시작되게 된다.
쓰고 > 읽고 > 쓰고 > 읽고의 무한 사이클이 클라이언트에서도 시작되었다.
2. Result
이를 실행한 결과는 아래와 같다.
유니코드 전송도 문제 없다.
성공적으로 에코 서버와 그 클라이언트가 완성되었다.
꽤 재밌는 것 같다.
열심히 공부해 보자.
'Study > C++ & C#' 카테고리의 다른 글
[C++] ChatRoom 구현 (0) | 2024.04.09 |
---|---|
[C++] Session 다중 접속 (0) | 2024.04.05 |
[C#] ###Clicker 개선판 (0) | 2024.03.18 |
[C#] 심플한 게임 런처 (0) | 2024.03.06 |
[C#] ###Clicker (0) | 2024.02.29 |