지난 글에서 크로스 플랫폼으로 빌드를 하려면 어떻게 해야 하는지 알아봤습니다. 이번 글에서는 리눅스와 윈도우에서 하나의 코드로 작동할 수 있는 소켓을 만들어 보도록 하겠습니다. 😄

🌳 구성

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
.
├── include/
│   └── yami/
│       └── comm/
│           └── socket.hpp          # 헤더 (interface)
└── src/
    └── comm/
        ├── socket.cpp              # 리눅스, 윈도우 공통 구현
        ├── socket_linux.cpp        # 리눅스 구현부
        └── socket_windows.cpp      # 윈도우 구현부

⚠️ Warning

아래 코드 내용은 전체의 일부분이며 시간이 지남에 따라 수정되었을 수 있습니다. 이곳에서 최신의 전체 코드를 보실 수 있습니다.

CMake

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# CMakeLists.txt

add_executable(test_socket
    src/ds/ring_buffer.cpp
    src/utility/event_loop.cpp
    src/test_socket.cpp
    src/comm/socket.cpp
    src/comm/server.cpp
)

if(WIN32)
    target_link_libraries(test_socket PRIVATE ws2_32)
    target_sources(test_socket PRIVATE
        src/utility/event_loop_windows.cpp
        src/comm/server_windows.cpp
        src/comm/socket_windows.cpp
    )
elseif(UNIX)
    target_sources(test_socket PRIVATE
        src/utility/event_loop_linux.cpp
        src/comm/socket_linux.cpp
        src/comm/server_linux.cpp
    )
endif()

CMake에서 위와 같은 방법으로 플랫폼을 나눌 수 있습니다.

🌟 헤더

윈도우와 리눅스는 소켓을 각각 SOCKET, int 타입으로 사용하고 있습니다. 이러한 데이터 타입을 하나로 통일시키기 위해 typedef 키워드로 먼저 통일을 시켜주었습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// socket.hpp
#if defined(_WIN32)
    #include <winsock2.h>
    typedef SOCKET Socket_t;
    #define YAMI_COMM_INVALID_SOCKET INVALID_SOCKET
    #define YAMI_COMM_SOCKET_ERROR SOCKET_ERROR
    #define YAMI_COMM_SUCCESS (0)

#elif defined(__linux__)
    #include <sys/socket.h>
    typedef int Socket_t;
    #define YAMI_COMM_INVALID_SOCKET (-1)
    #define YAMI_COMM_SOCKET_ERROR (-1)
    #define YAMI_COMM_ERROR (-1)

#endif

// 만약 운영체제 별 상수 값이 다르면 위에서 재정의
#define YAMI_COMM_AF_INET AF_INET
#define YAMI_COMM_AF_INET6 AF_INET6
#define YAMI_COMM_SOCK_STREAM SOCK_STREAM
#define YAMI_COMM_SOCK_DGRAM SOCK_DGRAM
#define YAMI_COMM_SOCK_RAW SOCK_RAW

플랫폼을 전처리기로 나누고, 각 플랫폼에서 사용하는 타입을 Socket_t로 통일 시켜준 모습입니다. 또한 소켓을 생성할 때 사용하는 도메인이나 타입 매크로도 해당 프로젝트에서 하나로 사용할 수 있도록 다시 정의했습니다.

아래에는 통일시킨 타입과 매크로 정의로 작성한 소켓 클래스입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
namespace Yami {
namespace Comm {
    constexpr static std::size_t YAMI_COMM_BUFFER_SIZE = 1024; // 16k

    enum class YAMI_SOCKET_DOMAIN {
        UNKNOWN = -1,
        IPv4 = YAMI_COMM_AF_INET,
        IPv6 = YAMI_COMM_AF_INET6,
    };

    enum class YAMI_SOCKET_TYPE {
        UNKNOWN = -1,
        TCP = YAMI_COMM_SOCK_STREAM,
        UDP = YAMI_COMM_SOCK_DGRAM,
        RAW = YAMI_COMM_SOCK_RAW,
    };
    
    class Socket {
    public:
        Socket(Socket_t = YAMI_COMM_INVALID_SOCKET);
        ~Socket();

        // prevent copy
        Socket(const Socket&) = delete;
        Socket& operator=(const Socket&) = delete;

        // allow move
        Socket(Socket&&);
        Socket& operator=(Socket&&);

        // return native socket
        operator Socket_t();
        
        bool valid();

        bool init(YAMI_SOCKET_DOMAIN, YAMI_SOCKET_TYPE);
        void destroy();
        
        bool setNonblocking();

        DS::RingBuffer& buffer();
        std::size_t send(const char*, const std::size_t);

        YAMI_SOCKET_DOMAIN getDomain();
        YAMI_SOCKET_TYPE getType();
        Yami::Utility::Callback<Socket&> onError;
    private:
        Socket_t _socket;
        YAMI_SOCKET_DOMAIN _domain;
        YAMI_SOCKET_TYPE _type;
        DS::RingBuffer _buffer;
    };

} // namespace Comm
} // namespace Yami

💡 Info

소켓의 초기화, 해제, 그리고 수신 데이터를 받을 수 있는 버퍼로 구성해서 클래스로 만들었습니다.

다음 글에서 운영체제별로 다른 비동기 IO를 핸들링할 수 있는 객체도 만들 예정입니다.

🚀 구현부

공통

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// socket.cpp

#include "yami/comm/socket.hpp"
#include "yami/ds/ring_buffer.hpp"
#include <utility>

namespace Yami {
namespace Comm {
    
    Socket::Socket(Socket_t nativeSocket)
        : _socket(nativeSocket)
        , _buffer(YAMI_COMM_BUFFER_SIZE) {}

    Socket::~Socket() {
        destroy();
    }

    Socket::Socket(Socket&& other) 
        : _socket(std::exchange(other._socket, YAMI_COMM_INVALID_SOCKET))
        , _domain(std::exchange(other._domain, Yami::Comm::YAMI_SOCKET_DOMAIN::UNKNOWN))
        , _type(std::exchange(other._type, Yami::Comm::YAMI_SOCKET_TYPE::UNKNOWN))
        , _buffer(std::move(other._buffer)) {}

    Socket& Socket::operator=(Socket&& other) {
        if(this != &other) {
            destroy();
            _socket = std::exchange(other._socket, YAMI_COMM_INVALID_SOCKET);
            _domain = std::exchange(other._domain, Yami::Comm::YAMI_SOCKET_DOMAIN::UNKNOWN);
            _type = std::exchange(other._type, Yami::Comm::YAMI_SOCKET_TYPE::UNKNOWN);
            _buffer = std::move(other._buffer);
        }
        
        return *this;
    }
    
    Socket::operator Socket_t() {
        return _socket;
    }

    bool Socket::valid() {
        return _socket != YAMI_COMM_INVALID_SOCKET;
    }
    
    DS::RingBuffer& Socket::buffer() { return _buffer; }
    
    YAMI_SOCKET_DOMAIN Socket::getDomain() { return _domain; }
    YAMI_SOCKET_TYPE Socket::getType() { return _type; }

} // namespace Comm
} // namespace Yami

💡 info

리눅스와 윈도우 둘 다 같이 사용할 수 있는 구현을 socket.cpp에 구현했습니다. 이동 생성자와 이동 대입 연산자, 버퍼 정도 같이 사용할 수 있었습니다.

리눅스

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// socket_linux.cpp

#include "yami/comm/socket.hpp"
#include <cerrno>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>

namespace Yami {
namespace Comm {

    bool Socket::init(YAMI_SOCKET_DOMAIN domain, YAMI_SOCKET_TYPE type) {
        // aleady initialized
        if(valid()) return false;

        _socket = socket(
            static_cast<int>(domain),
            static_cast<int>(type),
            0
        );
        if(!valid()) return false;
        
        _domain = domain;
        _type = type;
        return true;
    }
    
    void Socket::destroy() {
        if(valid()) {
            close(_socket);
            _socket = YAMI_COMM_INVALID_SOCKET;
        }
    }
    
    bool Socket::setNonblocking() {
        if(!valid()) return false;

        int flags = fcntl(_socket, F_GETFL, 0);
        if(!(flags != YAMI_COMM_ERROR && fcntl(_socket, F_SETFL, flags | O_NONBLOCK) != YAMI_COMM_ERROR)) return false;

        return true;
    }
    
    std::size_t Socket::send(const char* data, const std::size_t size) {
        std::size_t result = 0;
        ssize_t bytes = 0;
        while(result < size) {
            bytes = ::send(_socket, data + result, size - result, 0);
            if(bytes > 0) {
                result += bytes;
            } else {
                // TODO: 애러 처리
                if(errno == EAGAIN || errno == EWOULDBLOCK) {
                    // 남은 데이터 보관 EPOLLOUT 대기
                    onError(*this);
                    destroy();
                } else if(errno == EINTR) {
                    // 재시도
                    continue; 
                } else {
                    onError(*this);
                    destroy();
                }
            }
        }
        
        return result;
    }
    
} // namespace Comm
} // namespace Yami

💡 Info

TCP는 send()호출 시 요청한 데이터가 한 번에 전송된다는 보장이 없어서 실제 처리한 바이트 수를 반환받아서 확인해야 합니다!

윈도우

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
// socket_windows.cpp

#include "yami/comm/socket.hpp"
#include "yami/utility/event_loop.hpp"

#undef min
#include <cstring>
#include <algorithm>

namespace Yami {
namespace Comm {
    bool WSAHelper(bool);
    
    struct SendContext {
        OVERLAPPED ov;
        Yami::Utility::EventType type;
        WSABUF wsabuf;
        char data[1024];
    };
    
    bool Socket::init(YAMI_SOCKET_DOMAIN domain, YAMI_SOCKET_TYPE type) {
        // aleady initialized
        if(valid()) return false;
        
        if(!WSAHelper(true)) return false;

        _socket = socket(
            static_cast<int>(domain),
            static_cast<int>(type),
            0
        );
        if(!valid()) return false;
        
        _domain = domain;
        _type = type;
        return true;
    }
    
    void Socket::destroy() {
        if(valid()) {
            closesocket(_socket);
            _socket = YAMI_COMM_INVALID_SOCKET;
            
            WSAHelper(false);
        }
    }
    
    bool Socket::setNonblocking() {
        u_long nonblocking = 1;
        return ioctlsocket(_socket, FIONBIO, &nonblocking) == YAMI_COMM_SUCCESS;
    }
    
    std::size_t Socket::send(const char* data, const std::size_t size) {
        std::size_t send = 0;
        DWORD bytesSent = 0;
        int result = YAMI_COMM_SOCKET_ERROR;

        while(send < size) {
            // 현재 사이클에서 보내야 할 데이터의 사이즈
            send += std::min(size - send, YAMI_COMM_BUFFER_SIZE);
            
            SendContext* ctx = new SendContext;
            std::memset(&ctx->ov, 0, sizeof(OVERLAPPED));
            std::memcpy(ctx->data, data, send);
            ctx->wsabuf.buf = ctx->data;
            ctx->wsabuf.len = static_cast<ULONG>(send);
            
            DWORD flags = 0;
            result = WSASend(
                _socket,
                &ctx->wsabuf,
                1,
                &bytesSent,
                flags,
                &ctx->ov,
                nullptr    
            );

            if(result == YAMI_COMM_SOCKET_ERROR) {
                const int err = WSAGetLastError();
                if(err != WSA_IO_PENDING) {
                    onError(*this);
                    destroy();
                    return 0;
                }
            }
        }
        
        return send;
    }
    
    bool WSAHelper(bool init) {
        static std::size_t count = 0;
        
        if(init && count == 0) {
            WSADATA wsaData{};
            int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
            if(result != YAMI_COMM_SUCCESS) {
                return false;
            }
        } else if(!init && count == 1) {
            WSACleanup();
        }
        
        init ? count++ : count--;
        return count > 0;
    }

} // namespace Comm
} // namespace Yami

💡 Info

헤더 파일에 선언되지 않은 구조체나 함수는 .cpp 파일 내에서만 사용 가능 👍

두 플랫폼 비교

구분리눅스윈도우비고
사전 작업없음WSAStartup()/WSACleanup()윈도우는 Winsock을 사용하기 위해 준비 작업이 필요함
위 코드에서 WSAHelper() 함수로 관리
소켓 생성socket()socket()옵션은 플랫폼마다 다르지만, 기본적으로 생성하는 방식은 같음
해제close()closesocket()리눅스: 소켓도 하나의 파일처럼 사용해서 파일을 닫는 함수로 해제
윈도우: 소켓과 파일(핸들)을 구분하여 사용
소켓 옵션setsockopt()ioctlsocket()비동기IO로 전환하기 위해 사용하는데 두 플랫폼 간에 사용하는 함수 다름
비동기IOepollIOCompletionPort (IOCP)리눅스: IO 준비가 되면 신호 전달(신호가 오면 직접 소켓 버퍼에서 데이터를 꺼내야 함)
윈도우: IO 데이터를 이미 보내거나 받고 신호 전달(신호를 받으려면 미리 소켓과 버퍼를 예약해야 함)

📝 마무리

두 플랫폼에서 소켓을 하나의 소스 코드로 사용하기 위해 CMake와 전처리기, typedef를 이용해서 구현까지 해봤습니다. 다음 글에서는 본격적으로 서버 객체를 만들어 통신하기 전 각 플랫폼에서 비동기IO를 핸들링하기 위한 EventLoop 객체를 만들어 보도록 하겠습니다. 😎