node.js tcp chatting server with QT client

node.js로 구현한 채팅 서버 및 QT로 GUI를 꾸민 클라이언트이다. 사용자 등록, 로그인, 대화상대 추가/삭제, 로그인/아웃 알림 및 메시지 전달 기능이 구현되어 있다.

Current features:

  • enroll user
  • add/remove friend
  • login and retrieve my friend list
  • notify logged in/out
  • deliver chat message

Server

net, Buffer module을 이용해서 작성되었다. 클라이언트와 주고받는 패킷의 정의는 다음과 같다.

The Packet format is : binary data + string

4 byte header (means a length of following message) + message

The message format is :

string|(delimiter)...

제일 먼저 32비트 정수가 포함되고 이후에는 문자열로 구성된 양식이다.

헤더와 메시지를 구분하는 별도 구분은 없다. 문자열의 길이는 이 32비트 정수에 설정 된다.

메시지의 형식은 먼저 사용 목적이 나오고 이후 구분자로 구분된 데이터들이 전송된다.

예를 들어 로그인의 경우라면 전달되는 전체 데이터는 다음과 같게 된다.

ex) Login message
4byte_integerLOGIN|SomeUserId|SomePassWord~

즉, 바이너리 데이터와 문자열이 포함되는 형식인데, 결국 다음과 같은 C 구조체가 전송되는 것과 같다.

typedef struct _PACKET_LOGIN
{
    unsigned int nLen; //4byte, contains 34
    char szMSg [30];
} PACKET_LOGIN;

이러한 구조체 데이터에 nLen 에는 문자열의 길이 (30)이 설정되고 szMSg 에는 "LOGIN|someUserId|SomePassWord~" 문자열이 담겨져서 node.js 서버로 전송되는것과 같다.

그러므로 전체 데이터의 길이는 헤더에 설정된 메시지 길이(30 byte) + 헤더 자체의 길이 (4byte) = 34 byte가 된다.

인터넷에서는 echo 서버, 혹은 단순히 클라이언트의 데이터를 받는 즉시 브로드캐스팅하는 예제를 볼수 있다. 하지만 tcp의 메시지 경계 없음으로 인한 데이터 fragmentation을 고려해줘야 제대로 동작하는 서버를 작성할수 있다.

이 때문에 전달되는 데이터가 완전한 하나의 패킷이 될때, 해당 처리를 해주고 만약 기대하는 길이만큼 전송이 다 되지 않았다면, 지금까지 받은 데이터를 누적시키는 작업이 필요하다.

전달되는 데이터가 모두 문자열이라면, 일반 변수를 사용해서도 가능하겠지만, 지금 구현하는것은 헤더부분이 32비트 정수형이기 때문에 (즉 바이너리 데이터 + 스트링 의 혼합) 뭔가 다른 방법이 필요하다.

이때 node.js 가 제공하는 Buffer 모듈을 사용하면 문자열 데이터 뿐만 아니라, 정수형 데이터도 처리가 가능하다.

소켓으로 데이터가 전달될때의 처리는 다음과 같다.

var accumulatingBuffer = new Buffer(0);
var totalPacketLen = -1;
var accumulatingLen = 0;
var recvedThisTimeLen = 0;

c.on('data', function (data) {
    recvedThisTimeLen = data.length;
    console.log('recvedThisTimeLen=' + recvedThisTimeLen);
    var tmpBuffer = new Buffer(accumulatingLen + recvedThisTimeLen);
    accumulatingBuffer.copy(tmpBuffer);
    data.copy(tmpBuffer, accumulatingLen); // offset for accumulating
    accumulatingBuffer = tmpBuffer;
    tmpBuffer = null;
    accumulatingLen += recvedThisTimeLen;

    if (recvedThisTimeLen < packetHeaderLen) {
        return;
    } else if (recvedThisTimeLen == packetHeaderLen) {
        return;
    } else {
        if (totalPacketLen < 0) {
            totalPacketLen = accumulatingBuffer.readUInt32BE(0);
            console.log('totalPacketLen=' + totalPacketLen);
        }
    }

    while (accumulatingLen >= totalPacketLen + packetHeaderLen) {
        var aPacketBufExceptHeader = new Buffer(totalPacketLen);
        accumulatingBuffer.copy(
            aPacketBufExceptHeader,
            0,
            packetHeaderLen,
            accumulatingBuffer.length
        );

        ////////////////////////////////////////////////////////////////////
        //process packet data
        var stringData = aPacketBufExceptHeader.toString();
        var usage = stringData.substring(0, stringData.indexOf(TCP_DELIMITER));
        console.log('usage: ' + usage);
        //call handler
        serverFunctions[usage](
            c,
            remoteIpPort,
            stringData.substring(1 + stringData.indexOf(TCP_DELIMITER))
        );
        ////////////////////////////////////////////////////////////////////

        var newBufRebuild = new Buffer(accumulatingBuffer.length);
        newBufRebuild.fill();
        accumulatingBuffer.copy(
            newBufRebuild,
            0,
            totalPacketLen + packetHeaderLen,
            accumulatingBuffer.length
        );

        //init
        accumulatingLen -= totalPacketLen + 4;
        accumulatingBuffer = newBufRebuild;
        newBufRebuild = null;
        totalPacketLen = -1;

        if (accumulatingLen <= packetHeaderLen) {
            return;
        } else {
            totalPacketLen = accumulatingBuffer.readUInt32BE(0);
        }
    }
});

Buffercopy 메서드를 사용하여 동적으로 버퍼를 할당하여 데이터를 누적시켰다.

accumulatingBuffer.readUInt32BE(0) 를 사용하여 4바이트 정수값을 읽어서, 헤더를 제외한 메시지의 길이를 구한다.

네트워크 바이트는 빅엔디언인이기 때문에 xxxBE() 함수를 사용한다.

이후 버퍼에서 실제 문자열 메시지를 읽을때는 헤더길이(4)만큼 offset 을 주고 버퍼에 접근하면 된다. 즉,

accumulatingBuffer.copy(
    aPacketBufExceptHeader,
    0,
    packetHeaderLen,
    accumulatingBuffer.length
); // offset header length

이부분이 offset만큼 간격을 두고 버퍼에서 메시지 문자열만을 얻기 위한 부분이다.

이처럼 일반적인 C언어 구조체와 같은 형식도 node.js Buffer 를 사용하면, 자유롭게 연동이 가능하다.

사용자 정보, 대화상대 정보 등은 sqlite 데이터베이스에 저장하는것으로 하였다. 이를 위해 node-sqlite3 가 사용 되었다.

클라이언트로 데이터 전송시에도 헤더 설정이 필요한데, 이때는 writeUInt32BE 함수가 사용되었다.

Client

QT 를 이용하여, 간단한 GUI를 가진 것으로 작성 하였다. node.js서버에서는 클라이언트 요청에 대해 다음과 같이 응답을 해준다.

Responses:

ex) if adding friend is success :
4byte_headerADDFRIEND|OK|friendid|online

ex) if adding friend is failure :
4byte_headerADDFRIEND|FAIL|err-string

아래 스크린샷과 소스를 참고.

blog-image blog-image blog-image blog-image blog-image blog-image blog-image blog-image blog-image

소스는 다음에서 다운로드 할수 있다.

https://github.com/jeremyko/nodeChatServer

https://github.com/jeremyko/nodeChatClient