[C++] Boost.Asio 에코 서버

2024. 3. 29. 17:54·Study/C++ & C#

Sapphire라는 FF14의 서버를 에뮬레이팅 하는 프로젝트가 있다.

개발사가 사용하는 실제 서버 코드와 동일할 수는 없겠지만, 기본적인 처리는 되어 있기에 이를 통해 나만의 세계에서 FF14를 탐험할 수 있다.

에뮬레이팅 된 서버가 클라이언트와 정상적으로 통신하고 있으며, 서버도 적절한 처리를 해 준다는 의미이다.

그리고 난 이 프로젝트에 사용된 Boost.Asio가 매우 궁금해졌다.

이를 처음부터 공부하며 이 프로젝트에 사용된 비동기 통신 개념을 진정 나의 것으로 만들기 위한 첫걸음을 떼려고 한다.

 

 

그전에 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 처리가 이루어지게 된다.

필요한 기능은 아래와 같다.

  1. 클라이언트에서 보내는 데이터 읽기
     - do_read()
  2. 읽은 데이터 다시 보내기
     - 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
'Study/C++ & C#' 카테고리의 다른 글
  • [C++] ChatRoom 구현
  • [C++] Session 다중 접속
  • [C#] ###Clicker 개선판
  • [C#] 심플한 게임 런처
BVM
BVM
  • BVM
    E:\
    BVM
  • 전체
    오늘
    어제
    • 분류 전체보기 (173)
      • Thoughts (14)
      • Study (75)
        • Japanese (3)
        • C++ & C# (50)
        • Javascript (3)
        • Python (14)
        • Others (5)
      • Play (1)
        • Battlefield (1)
      • Others (10)
      • Camp (73)
        • T.I.L. (57)
        • Temp (1)
        • Standard (10)
        • Challenge (3)
        • Project (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • 본 블로그 개설의 목적
  • 인기 글

  • 태그

    db
    bot
    암호화
    boost
    Python
    로깅
    IOCP
    Selenium
    Network
    OSI
    7계층
    discord py
    FF14
    클라우드
    네트워크
    프로그래머스
    c#
    포인터
    JS
    C++
    discord
    베데스다
    서버
    Asio
    cloudtype
    네트워크 프로그래밍
    스타필드
    Dalamud
    docker
    Server
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
BVM
[C++] Boost.Asio 에코 서버
상단으로

티스토리툴바