Redmine으로 소프트웨어 개발 프로젝트를 관리하고 있으면서 Subversion을 사용중이라면, Redmine 계정과 Subversion 계정이 별도로 관리하는 것이 꽤 번거로울 수 있다. 이런 번거로움을 덜수 있도록 Redmine 계정에 대해서 Subversion 접근권한을 부여하는 방법을 소개한다.

아래의 설정은 아파치 설정파일 내용으로 이와같이 설정하면 Redmine 계정으로 Subversion 접근이 가능하다. 7줄은 Subversion 저장소가 위치할 절대 경로인데, 이 설정으로는 이 디렉토리 하위 디렉토리에 Redmine에서 관리할 저장소를 만들면 된다. 11~14줄은 Redmine DB 접속 정보이다.

<VirtualHost *>
    ServerAdmin webmaster@test.net
    ServerName test.net

    <Location /svn>
        DAV svn
        SVNParentPath /svn

        Auth_MySQL On
        Auth_MySQL_Authoritative on
        Auth_MySQL_DB "redmine"
        Auth_MySQL_Host "localhost"
        Auth_MySQL_User "redmine"
        Auth_MySQL_Password "redmine"

        Auth_MySQL_Password_Table users
        Auth_MySQL_Username_Field login
        Auth_MySQL_Password_Field hashed_password
        AuthMySQL_Empty_Passwords off
        AuthMySQL_Encryption_Types SHA1Sum

        AuthType Basic
        AuthName "Authorization Realm"
        Require valid-user
        AuthUserFile /dev/null
        AuthBasicAuthoritative Off
    </Location>

    ErrorLog /var/log/apache2/error.log

    # Possible values include: debug, info, notice, warn, error, crit,
    # alert, emerg.
    LogLevel warn

    CustomLog /var/log/apache2/access.log combined
</VirtualHost>

여러 프로젝트가 있고, 프로젝트별로 참여자에게만 접근 권한을 줘야하는 상황에는 위의 설정만으로는 부족하다. 그런 경우에는 아래의 설정을 추가하면 해당 프로젝트에 참가하는 계정만 저장소에 접근할 수 있게 할 수 있다. 아래 설정 중의 project_id는 Redmine의 해당 프로젝트 번호로 Redmine DB의 project 테이블을 참고하면 알 수 있다.

Auth_MySQL_Password_Clause " AND status = 1 AND id IN (select user_id from members where project_id = 1)"

또한, Subversion 경로도 아래와 같이 각 프로젝트별로 설정해줘야한다.

SVNPath /svn/project1

위와 같이 한다면, 반드시 프로젝트에 포함되어있는 계정을 Redmine에서 Subversion 저장소 설정 시에 넣어줘야한다는 문제가 남는다. 특정 계정을 사용하기가 번거롭다면 조회용 계정을 하나 만들어서 프로젝트에 포함시킨뒤에 잠궈두고, 다음과 같이 설정을 추가해도 된다. 아래에서 id는 조회용 계정의 번호로 Redmine DB의 members 테이블에서 확인할 수 있다.

Auth_MySQL_Password_Clause " AND (status = 1 OR id = 1) AND id IN (select user_id from members where project_id = 1)"

"패러디 웨이브가 어떤 것인지 빨리 한번 돌려보고 싶은 사람들을 위해서 간단한 사용방법을 적어보려고 한다. 먼저 프로그램및 발표 자료를 첨부한다.

ParodyWave-0.1a.zip 패러디 웨이브 0.1a
websocket.pdf 발표자료

먼저 Node.js 설치가 필요하다. Linux 등을 사용한다면 설치에 큰 어려움이 없겠지만, Windows를 사용한다면, cygwin을 먼저 설치하는 등 손이 좀 갈 것이다. 설치방법은 Node.js 웹사이트를 참고하여 다음과 같이 하면 된다.(Windows에서 설치해보고 싶은 사람은 여기를 참고하자.)

./configure
make
make install

설치가 끝나면 "패러디 웨이브" 파일을 다운로드 받아서 적당한 곳에 풀어놓고 다음과 같이 실행한다.

node WebSocketServer.js

그 뒤에 웹브라우저를 열고, 주소표시줄에 'http://localhost:8888'을 입력해서 다음과 같은 화면이 나오면 성공이다.

혹시 8888포트를 다른 곳에서 사용중이거나 다른 서버를 다른 곳에서 실행해서 접속 정보를 바꾸고 싶다면 다음 코드를 수정하면된다.

WebSocketServer.js 175줄
server.listen(8888);
ParodyWave.html 66줄
ParodyWaveClient.conn = new WebSocket("ws://localhost:8888");


이번에는 마지막으로 클라이언트 부분이다. 이 부분은 HTML5에서 제공하는 WebSocket API대로만 작성하면 되기때문에 크게 어려운 부분은 없다.

<!doctype html> 
<html> 
<head> 
    <meta charset="UTF-8" />
    <title>패러디 웨이브 version 0.1a</title>
	<style type="text/css"> 
		* { margin: 0; padding: 0; font-family: Helvetica; font-size: 9pt; }
		body { width: 100%; height: 100%; color; #444; overflow: hidden; }
		ul { list-style: none; }
		li { padding-left: 5px; font-weight: normal; color:#aaa; border-bottom: 1px dotted #ddd; padding-top: 5px; padding-bottom: 5px; }
		li:first-child { font-weight: bold; color: #000; }
		#thread-list { margin-top: 20px; }
		#thread-list li { border-style: none; display: inline; margin-right: 10px; }
		#welcome { position: fixed; width: 100%; height: 60px; margin-top: 200px; text-align: center; background-color: #888; padding-top: 30px; background: -moz-linear-gradient(center top, #444 0%,#222 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #444),color-stop(1, #222));}
		#home { position: fixed; width: 100%; height: 200px; margin-top: 200px; text-align: center; background-color: #888; padding-top: 30px; background: -moz-linear-gradient(center top, #444 0%,#222 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #444),color-stop(1, #222));}
		#header { display: none; position: fixed; width: 100%; height: 30px; left: 0; top: 0; background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #444),color-stop(1, #222));}
		#header > h1 { color: #fff; font-weight: bold; font-size; 16px; float: left; margin-left: 10px; margin-top: 8px;}
		#join-count { float: right; height: 12px; margin-right: 5px; margin-top: 4px; background-color: #666; color: #ddd; padding: 3px; padding-left: 10px; padding-right: 10px; -webkit-border-radius: 10px 10px; font-size: 10px; background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #111),color-stop(1, #555)); }
		#input-container { position: fixed; width: 90%; height: 30px; bottom: 0; left: 10px; }
		#thread-container { width: 100%; height: 100%; overflow: auto; background-color: #fff; padding: 10px; padding-top: 30px;}
		input[type=text] { border: 1px solid #ccc; height: 25px; font-size: 14px; }
		input[type=button] { padding: 5px; padding-left: 15px; padding-right: 15px; font-size: 14px; border: 1px solid #e0a903; border-radius: 6px 6px; text-shadow: rgba(255,255,255,.5) 0 1px 0; background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #ffc000),color-stop(1, #e0a903)); background: -moz-linear-gradient(center top, #ffc000 0%,#e0a903 100%);}
		#thread > div { display: block; float: left; width: 180px; height: 250px; margin-right: 10px; margin-top: 5px; border: 1px solid #ddd; -webkit-box-shadow: 5px 5px 5px #aaa; -webkit-border-radius: 10px 10px;}
		#thread > div .title { background-color: #eee; padding: 3px; border-bottom: 1px solid #bbb; -webkit-border-top-left-radius: 10px 10px; -webkit-border-top-right-radius: 10px 10px; background: -webkit-gradient(linear, left top, left bottom, color-stop(0, #fff),color-stop(1, #ccc));}
		#thread > div .title .msg-count { float: right; height: 12px; margin-right: 5px; margin-top: 3px; background-color: #fff; border: 1px solid #bbb; color: #444; padding-left: 3px; padding-right: 3px; -webkit-border-radius: 10px 10px; font-size: 10px; }
		#thread > div .title .msg-count:hover { background-color: #444; border: 1px solid #222; color: #ccc; cursor: pointer;  }
		#thread > div .title > h1 { display: inline; text-align: right; margin-right: 10px; text-overflow: ellipsis; font-size: 14px; }
		#thread > div div.article-container { clear: both; padding: 5px; height: 205px; overflow: hidden; }
		#thread > div .article { font-size: 12px; }
		#thread > div .article .before { color: #bbb; }
		#thread > div .article .current { color: #444; font-weight: bold; }
		#thread > div .article hr { stroke-width: 1px; margin-top: 5px; margin-bottom: 5px; }
		#line { width: 90%; }
	</style> 
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script> 
    <script> 
    var ParodyWaveClient = {
        conn: null,
        user: "",
        thread: false,
        line: null,
        writing: false,
        currentValue: "",
		threadStore: null
	}
 
    $(document).ready(function () {
		var docWidth = $(document).width();
		var docHeight = $(document).height() - 30;
		
		$("#thread-container").css("height", docHeight+"px");
		
        $("#new-thread").click(openThread);
        $("#connect").click(function () {
            if (! ParodyWaveClient.conn) login();
            else logout();
        });
    });
 
    window.onunload = function () {
        if (ParodyWaveClient.conn) ParodyWaveClient.conn.close();
    }
    function login() {
        if("WebSocket" in window) {
            debug("connecting...");
            ParodyWaveClient.conn = new WebSocket("ws://localhost:8888");
            ParodyWaveClient.conn.onopen = function () {
                debug("connection established");
                ParodyWaveClient.user = $("#userid").val();
                $("#welcome>input").attr("disabled",true);
                $("#connect").val("LogOut").attr("disabled",false);
                $("#home").show();
				$("#welcome").hide();
				
                sendMsg({cmd: "status"});
                sendMsg({cmd: "list"});
 
				ParodyWaveClient.threadStore = new Array();
				
                setInterval(function() {
                  ParodyWaveClient.conn.send('HB');
                }, 60000);
            }
            ParodyWaveClient.conn.onmessage = onWebSocketMessage;
            ParodyWaveClient.conn.onclose = onWebSocketClose;
        }
        else {
            alert("This web browser doesn't support WebSocket!");
        }
    }
    function logout() {
        ParodyWaveClient.conn.close();
    }
    function openThread() {
        sendMsg({cmd: "open", user: $("#userid").val(), title: $("#title").val()});
    }
    function joinThread(idx) {
        sendMsg({cmd: "join", user: $("#userid").val(), id: idx});
    }
    function debug(str) {
        if ($("#chk-debug").attr("checked"))
            console.log(str);
    }
    function sendMsg(msg) {
        if (ParodyWaveClient.conn) ParodyWaveClient.conn.send(JSON.stringify(msg));
    }
    function onWebSocketMessage(e) {
        console.log(e.data);
        var msg;
        try {
            msg = JSON.parse(e.data);
        }
        catch (e) {
            debug("Invalid message");
            msg = {type: "error"};
        }
        switch (msg.type) {
            case "status":
                $("#join-count").text("Connection: "+msg.connections);
                break;
            case "join":
                if (msg.data) {
                    for (var i = 0 ; i < msg.data.length ; i ++) {
						addThreadData("new", msg.data[i][0], i, msg.data[i][1], msg.data[i][2]);
					}
                }
                $("#input-container").show();
                $("#home").hide();
 
				$("#header > h1").html("패러디 웨이브 version 0.1a");
				$("#header").show();
				
                ParodyWaveClient.currentValue = "";
                
                $("#line").bind("keyup", function (event) {
                    cmd = {cmd: 'update'};
                    if (! ParodyWaveClient.writing) {
                        ParodyWaveClient.writing = true;
                        cmd.cmd = 'new';
                    } else if (event.keyCode == 13 && event.srcElement.value != "") {
                        cmd.cmd = 'submit';
                        event.srcElement.value = "";
                        ParodyWaveClient.writing = false;
                    } else if (ParodyWaveClient.currentValue == event.srcElement.value) return;
                    cmd.value = ParodyWaveClient.currentValue = event.srcElement.value;
                    sendMsg(cmd);
                });
                break;
            case "list":
                $("#thread-list").html("");
                for (var i = 0 ; i < msg.threads.length ; i ++) {
                    if (! msg.threads[i]) continue;
                    $("#thread-list").append("<li><input type='button' onclick='joinThread(" + msg.threads[i][0] + ")' value='"+msg.threads[i][1]+"방에 조인' /></li>");
                }
                break;
            case "update":
                if (typeof msg.value != "string") addThreadData("new", msg.id, msg.line, msg.user, msg.value);
                else addThreadData("update", msg.id, msg.line, msg.user, msg.value);
        }
    }
 
	function addThreadData(type, id, lineNo, user, msg) {
		if (msg == undefined) msg = "<img src='wait.png' align='absmiddle'>";
		if (!$.isArray(ParodyWaveClient.threadStore[id])) {
			ParodyWaveClient.threadStore[id] = new Array();
			ParodyWaveClient.threadStore[id].push(new Array(lineNo, user, msg));
		} else {
			var matchLineNo = false;
			
			for (i = 0; i < ParodyWaveClient.threadStore[id].length; i++) {
				if (ParodyWaveClient.threadStore[id][i][0] == lineNo) { 
					matchLineNo = true;
					ParodyWaveClient.threadStore[id][i][2] = msg;
					break;
				}
			}
			
			if (!matchLineNo) ParodyWaveClient.threadStore[id].push(new Array(lineNo, user, msg));
		}
		
		if (ParodyWaveClient.threadStore[id].length == 1 && type == "new") { // new 
	        $("#thread").append('<div id="fdata'+id+
				'"><div class="title"><img src="person.png" align="absmiddle"><h1>'+ParodyWaveClient.threadStore[id][0][1]+
				'</h1><div class="msg-count" boxid="'+id+'" onclick="toogleHistory(this);">1</div></div><div class="article-container"><ul class="article"><li class="line-'+lineNo+'">'+ParodyWaveClient.threadStore[id][0][2]+
				'</li></ul></div></div>');
		} else {
			for (i = 0; i < ParodyWaveClient.threadStore[id].length; i++) {
				if ($("#fdata"+id+" .article li.line-"+lineNo).length <= 0)
					$("#fdata"+id+" .article").prepend("<li class='line-"+lineNo+"'>"+ParodyWaveClient.threadStore[id][i][2] + "</li>");
				else 
					$("#fdata"+id+" .article li.line-"+lineNo).html(ParodyWaveClient.threadStore[id][i][2]);
			}
			
			$("#fdata"+id+" .title .msg-count").text(ParodyWaveClient.threadStore[id].length);
		}
	}
	
	function toogleHistory(f) {
		var idf = "#fdata"+$(f).attr("boxid")+" .article-container";
		if ($(idf).css("overflow-y") == "auto")
			$(idf).css("overflow-y", "hidden");
		else 
			$(idf).css("overflow-y", "auto");
	}
 
    function onWebSocketClose() {
        debug('Disconnected');
        $("#welcome>input").attr("disabled",false);
        
        // initiate text input
        $("#userid").val("");
        $("#title").val("");
        $("#line").val("");
        $("#connect").val("LogIn")
        
        // close others
        $("#home").hide();
        $("#input-container").hide();
        $("#thread").html("");
        $("#users").text("");
        $("#welcome").show();
 
        // initiate status
        ParodyWaveClient.conn = null;
        ParodyWaveClient.user = "";
        ParodyWaveClient.thread = false;
        ParodyWaveClient.line = null;
        ParodyWaveClient.writing = false;
        ParodyWaveClient.currentValue = false;
        
        // unbind event
        $("#line").unbind();
    }
    </script> 
</head> 
<body> 
    <div id="welcome"> 
 
        <input type="text" id="userid" placeholder="대화명을 입력해주십시오."/> 
        <input type="button" id="connect" value="로그인" /> 
        <input type="checkbox" id="chk-debug" title="Show Debug Message"/> 
    </div> 
 
    <div id="home" style="display:none;"> 
        <input type="text" id="title" placeholder="생성할 방제목을 입력해주십시오."/> 
        <input type="button" id="new-thread" value="Create!" /> 
        <div> 
 
            <ul id="thread-list"></ul> 
        </div> 
    </div> 
 
	<div id="header"> 
		<h1>패러디 웨이브</h1> 
		<div id="join-count"></div> 
	</div> 
 
	<div id="thread-container"> 
    	<div id="thread"></div> 
	</div> 
 
    <div id="input-container" style="display:none;"> 
        <img src="chat.png" align="absmiddle"> <input type="text" id="line"/> 
    </div> 
    <div id="status" style="display:none;"> 
        <p id="users"></p> 
 
    </div> 
</body> 
</html>

login 메소드가 WebSocket 접속을 시도하는 부분인데, 64줄의 조건문으로 웹브라우저가 WebSocket을 지원하는지 여부를 판단할 수 있다. 66줄에는 접속하는 호스트 주소가 들어가는데, 환경에 따라서 적당히 바꾸면 되겠다.

80줄에는 타이머 설정이 있는데, 접속 유지를 위한 HeartBeat 코드가 들어가 있다. 서버에서 임의로 적용해놓은 타임아웃 설정 이외에도 웹브라우저에서 기본으로 적용하는 타임아웃이 있기때문에 사용중에 접속이 끊겨버리는 일을 방지하기 위해서는 이러한 코드가 필요하다. 현재 WebSocket Protocol에는 타임아웃에 대한 처리는 없다.

67, 84, 85줄에서는 각각 open, message, close 이벤트에 대한 핸들러를 지정하고 있다. 역시 대부분의 동작은 message 이벤트 핸들러에서 수행하게 된다.

이전 포스트에서 올린 서버에서 WebSocket 요청을 처리하는 부분에 이어서 이번에는 "패러디 웨이브" 서비스를 제공하는 부분이다. 정리가 제대로 안되서 코드가 보기에 좀 너접하지만, 이것 역시 참고 자료 삼아서 올린다.

ParodyWave.js
var ParodyWave = {
    totalcount: 0,
    clients: [],
    status: function () {
        var response = {type: 'status',
            connections: ParodyWave.clients.length};

        return JSON.stringify(response);
    },
    list: function () {
        var response = {type: 'list'}
        response.threads = ParodyWave.threads.list();

        return JSON.stringify(response);
    },
    newThread: function (title, conn) {
        var response = {type: 'join'};
        if (conn.activity.thread) {
            response = {type: 'error', text: 'already joined.'}
        } else {
            response.thread = conn.activity.thread = ParodyWave.threads.add(title);
        }
        return JSON.stringify(response);
    },
    joinThread: function (id, conn) {
        var response = {type: 'join'};
        if (conn.activity.thread) {
            response = {type: 'error', text: 'already joined.'}
        } else if (ParodyWave.threads.join(id)) {
            response.thread = conn.activity.thread = id;
            response.title = ParodyWave.threads._list[id].title;
            response.data = ParodyWave.threads._list[id]._data;
        } else {
            response = {type: 'warning', text: 'cannot joined'};
        }
        return JSON.stringify(response);
    },
    exit: function (msg, conn) {
        ParodyWave.threads.exit(msg.id);
        conn.activity.thread = null;
    },
    newLine: function (conn) {
        var l = ParodyWave.threads._list[conn.activity.thread].append(conn.activity.id, conn.activity.user);
        conn.activity.line = l;
        return JSON.stringify({type: "update", id: conn.activity.id, line: l, user: conn.activity.user});
    },
    update: function (v, conn) {
        ParodyWave.threads._list[conn.activity.thread].update(conn.activity.line, v);
        return JSON.stringify({type: "update", id: conn.activity.id, line: conn.activity.line, value: v});
    },
    submit: function (conn) {
        conn.activity.line = null;
    },
    notify: function (msg, conn, area) {
        for (var i = 0 ; i < ParodyWave.clients.length ; i ++) {
            var cond;
            switch (area) {
                case -1: // all, exclude same thread
                    cond = (conn.activity.thread !== ParodyWave.clients[i].activity.thread);
                    break;
                case 1: // same thread
                    cond = (conn.activity.thread === ParodyWave.clients[i].activity.thread);
                    break;
                case -2: // all, not in a thread
                    cond = (ParodyWave.clients[i].activity.thread === null);
                    break;
                case 2: // all in a thread
                    cond = (ParodyWave.clients[i].activity.thread !== null);
                    break;
                default:    // all, exclude me
                    cond = (conn !== ParodyWave.clients[i]);
                    break;
            }
            if (cond) ParodyWave.clients[i].write(msg);
        }
    }
};
ParodyWave.threads = {
    _list:[],
    count: 0,
    add: function (title) {
        var th = {}
        th.title = title;
        th.count = 1;
        th._data = [];
        th.append = function (id, user) {
            return th._data.push([id, user, ""]) - 1;
        }
        th.update = function (line, value) {
            th._data[line][2] = value;
        }
        th.get = function (line) {
            return th._data[line][2];
        }
        ParodyWave.threads.count ++;
        return ParodyWave.threads._list.push(th) - 1;
    },
    join: function (id) {
        if (ParodyWave.threads._list[id]) {
            ParodyWave.threads._list[id].count ++;
            return true;
        } else {
            return false;
        }
    },
    exit: function (id) {
        if (ParodyWave.threads._list[id] && -- ParodyWave.threads._list[id].count <= 0) {
            ParodyWave.threads._list[id] = null;
            ParodyWave.threads.count --;
        }
        ParodyWave.notify(ParodyWave.list());
    },
    list: function () {
        var l = [];
        for (var i = 0 ; i < ParodyWave.threads._list.length ; i++) {
            if (ParodyWave.threads._list[i] == null) continue;
            l.push([i, ParodyWave.threads._list[i].title, ParodyWave.threads._list[i].count]);
        }
        return l;
    }
};

exports.service = function(socket) {
    socket.on("open", onOpen);
    socket.on("message", onMessage);
    socket.on("close", onClose);
    socket.on("error", onError);

    function onOpen() {
        socket.activity = {id: null, user: null, thread: null, line: null};
        ParodyWave.clients.push(socket);
        socket.activity.id = ++ParodyWave.totalcount;
        ParodyWave.notify(ParodyWave.status(), socket);
    }

    function onMessage(data) {
        var msg;
        try {
            msg = JSON.parse(data);
        } catch (e) {
            if (data != 'HB') { // HeartBeat
                console.log(e);
                console.log("DATA: " + data);
            }
            return;
        }
        console.log(msg);   // Debug
        switch (msg.cmd) {
            case "user":
                break;
            case "status":
                socket.write(ParodyWave.status(msg));
                socket.write(ParodyWave.list());
                break;
            case "open":
                socket.activity.user = msg.user;
                socket.write(ParodyWave.newThread(msg.title, socket));
                ParodyWave.notify(ParodyWave.list(), socket, -2);
                break;
            case "join":
                socket.activity.user = msg.user;
                socket.write(ParodyWave.joinThread(msg.id, socket));
                ParodyWave.notify(ParodyWave.list(), socket, -2);
                break;
            case "exit":
                ParodyWave.exit(msg, socket);
                break;
            case "new":
                ParodyWave.notify(ParodyWave.newLine(socket), socket, 1);
                if (msg.value) ParodyWave.notify(ParodyWave.update(msg.value, socket), socket, 1);
                break;
            case "update":
                ParodyWave.notify(ParodyWave.update(msg.value, socket), socket, 1);
                break;
            case "submit":
                if (msg.value) ParodyWave.notify(ParodyWave.update(msg.value, socket), socket, 1);
                ParodyWave.submit(socket);
                break;
        }
    }

    function onClose() {
        for (var i = 0 ; i < ParodyWave.clients.length ; i ++) {
            if (socket === ParodyWave.clients[i]) {
                ParodyWave.clients.splice(i, 1);
            }
        }
        var idx = socket.activity.thread;
        ParodyWave.threads.exit(idx);
        ParodyWave.notify(ParodyWave.status(), socket);
    }

    function onError(exception) {
        console.log("ERROR: " + exception);
        for (var i = 0 ; i < ParodyWave.clients.length ; i ++) {
            if (socket === ParodyWave.clients[i]) {
                ParodyWave.clients.splice(i, 1);
            }
        }
    }
};

124~127줄의 부분을 보면 open, message, close, error라는 네가지의 이벤트에 대한 핸들러를 지정하는 부분이 있다. 원래 Node.js에서 WebSocket 접속이 이루어진다고 해도 HTML5 표준에 있는 WebSocket API에 정의된 이런 이벤트가 발생하지는 않지만, 서비스 구현시의 편리를 위해서 클라이언트와 비슷하게 임의로 저 이벤트를 발생하도록 WebSocket 요청 처리부분에서 구현했다. 따라서, 이전 포스트의 프로그램을 사용한다면, 서비스 부분에서는 HTML5에서 사용하는 것처럼 저 네가지 이벤트와 write(), end() 등의 메소드를 사용하면 된다.

지난 주에 'WebSocket & Node.js' 라는 주제로 발표하면서 예제로 준비했던 WebSocket을 사용한 간단한 채팅 프로그램을 여기에 올려보려고한다. 이 프로그램은 Google의 Wave서비스에서 아이디어를 얻어서, 훨씬 적은 부하로 Wave와 비슷한 기능을 구현할 수 있다는 것을 보여주려고 만들었기에 "패러디 웨이브"라고 이름을 지었는데, 어쩌다보니 Wave와는 전혀 다른 모습이 되었다.



사용해보신 분들의 요구 사항도 좀 적용하고 코드를 좀 더 정리해서, 실제로 사용할만한 모양을 좀 만든 뒤에 공개할 생각이었는데, 웹브라우저의 WebSocket 지원에 대한 이야기도 있고해서 정리한다고 시간 보내는 것보다는 기록 삼아서라도 되도록 빨리 공개하는 것이 좋다는 판단에서 그냥 공개하게 됐다.(필요한 사람이 있을까 싶긴 하지만...)

발표에서 이야기했지만, "패러디 웨이브"는 다음과 같이 구성되어 있다.

  • 클라이언트 : ParodyWave.html
  • 서버
    • 웹 소켓 요청 처리 : WebSocket.js
    • "패러디 웨이브" 서비스 : ParodyWave.js

위에서 볼수 있는 것처럼, 프로그램은 서버와 클라이언트로 되어 있고, 서버는 웹소켓 요청을 처리하는 부분과 실제 채팅 서비스를 제공하기 위한 부분으로 나뉘어있다. 각각 파일로 나뉘어 총 3개 파일로 구성되어 있는데, 한 부분씩 세차례에 걸쳐서 여기에 포스트할 예정이다. 오늘은 그 첫번째 부분으로 Node.js로 구현한 웹 소켓 요청을 처리하는 서버 프로그램을 올린다.

웹 소켓 요청 처리 부분은 간단하게나마 HTTP요청도 처리할 수 있도록 되어있고, 웹 소켓 요청의 경우에는 조금만 수정하면 요청 URL에 따라서 여러 서비스도 제공할 수도 있다. 어디까지나 예제 수준의 코드이므로 Node.js를 이용하면 현재의 웹브라우저에서 지원하는 WebSocket을 이런식으로 구현할 수 있다는 참고자료 정도로 보기 바란다.

WebSocketServer.js
var WebSocketServer = function (options) {
    require('http').Server.call(this);
    
    this.on('connection', onConnect);
    this.on('request', onRequest);  // HTTP Request Handler
    this.on('upgrade', onUpgrade);  // WebSocket Request Handler
    
    this.WebSocketResponse75 = [
        'HTTP/1.1 101 Web Socket Protocol Handshake', 
        'Upgrade: WebSocket', 
        'Connection: Upgrade',
        'WebSocket-Origin: {origin}',
        'WebSocket-Location: {protocol}://{host}{resource}',
        '',
        ''
    ]
    this.WebSocketResponse76 = [
        'HTTP/1.1 101 Web Socket Protocol Handshake', 
        'Upgrade: WebSocket', 
        'Connection: Upgrade',
        'Sec-WebSocket-Origin: {origin}',
        'Sec-WebSocket-Location: {protocol}://{host}{resource}',
        '',
        '{data}'
    ]
    
    function onConnect() {
    }
    
    function onRequest(req, res) {
        var fileName = '.'
        if (req.url == '/') fileName += '/ParodyWave.html';
        else fileName += req.url;
        require('fs').readFile(fileName, function (err, data) {
            if (err) {
                res.writeHead(404);
                res.end();
                console.log(fileName);
                return;
            } else {
                res.writeHead(200, {'Content-Length': data.length});
                res.write(data);
                res.end();
            }
        });
    }

    function onUpgrade(req, socket, head) {
        if ("sec-websocket-key1", "sec-websocket-key2" in req.headers) { // 76
            var key = calcResponseKey(req.headers["sec-websocket-key1"],
                                    req.headers["sec-websocket-key2"], head);
            if (key) {
                var res = socket.server.WebSocketResponse76.join('rn')
                            .replace(/{origin}/,req.headers.origin || '')
                            .replace(/{protocol}/,'ws')
                            .replace(/{host}/,req.headers.host || '')
                            .replace(/{resource}/,req.url || '')
                            .replace(/{data}/,key);
            } else {
                socket.end();
                return;
            }
        } else {    // 75
            var res = socket.server.WebSocketResponse75.join('rn')
                        .replace(/{origin}/,req.headers.origin || '')
                        .replace(/{protocol}/,'ws')
                        .replace(/{host}/,req.headers.host || '')
                        .replace(/{resource}/,req.url || '')
        }
        
        try {
            socket.write(res, "binary");
            var client = initiateWebSocket(socket);
            
            var action = require('./ParodyWave');
            action.service(client);
            client.ready(); // "open" event for action
            setTimeout(socket.end, 300 * 1000);
        } catch (e) {
            socket.destroy();
        }
    }

    function calcResponseKey(key1, key2, key) {
        var md5 = require('crypto').createHash('md5');
        [key1, key2].forEach(function (k) {
            var n = parseInt(k.replace(/[^d]/g,'')),
                space = k.replace(/[^ ]/g,'').length;
            
            if (space === 0 || n % space !== 0) {
                return null;
            }
            
            n = parseInt(n/space);
            var result = '';
            result += String.fromCharCode(n >> 24 & 0xFF);
            result += String.fromCharCode(n >> 16 & 0xFF);
            result += String.fromCharCode(n >> 8 & 0xFF);
            result += String.fromCharCode(n & 0xFF);
            
            md5.update(result);
        });
        md5.update(key);
        return md5.digest('binary');
    }
    
    function initiateWebSocket(socket) {
        var c = new process.EventEmitter();
        socket.on("data", onData);
        socket.on("end", function () { socket.end(); });
        socket.on("close", onClose);
        socket.on("error", onError);
        socket.wsBuffer = "";
        
        // WebSocket readyState status
        // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3:CLOSED
        c.readyState = 0;
        c.ready = function() {
            if (c.readyState === 0) {
                c.readyState = 1;
                c.emit("open");
            }
        }
        c.write = function (data) {
          try {
            socket.write("u0000", "binary");
            socket.write(data, "utf8");
            socket.write("uffff", "binary");
          } catch(e) {
            socket.end();
          }
        };
        
        c.end = function () {
            socket.end();
            c.readyState = 2;
        };
        
        return c;
        
        function onData(data) {
            socket.wsBuffer += data;
            
            var chunks = socket.wsBuffer.split("ufffd"),
                count = chunks.length - 1;
            
            for (var i = 0 ; i < count ; i ++) {
                var chunk = chunks[i];
                if (chunk[0] == "u0000") {
                    c.emit("message", chunk.slice(1));
                } else {
                    socket.end();
                    return;
                }
            }
            
            socket.wsBuffer = chunks[count];
        }
        
        function onClose() {
            c.emit("close");
            c.readyState = 2;
        }
        
        function onError(exception) {
            if (c.listeners("error").length > 0) {
                c.emit("error", exception);
            } else {
                throw exception;
            }
        }
    }
};
require("sys").inherits(WebSocketServer, require("http").Server);

var server = new WebSocketServer();
server.listen(8888);

간단한 설명을 덧붙여보자면, 5~6줄에서 HTTP 요청과 WebSocket 요청시에 발생하는 Request 이벤트와 Upgrade 이벤트에 대한 핸들러를 등록한다. 엄연히 구분하자면, Upgrade 이벤트 발생시에 이것이 웹소켓 접속으로 프로토콜을 변경할 것인지 구분하는 과정이 있어야겠지만, 생략했다.

30줄부터 시작되는 onRequest 메소드가 HTTP 요청을 처리하는 부분인데, 웹서버 기능을 보강하려면 여길 고치면 되겠다. 현재는 요청한 url에 아무런 처리도 하지않고 그대로 현재 디렉토리에서 해당 파일을 찾아서 보내준다. 파일이 없을 경우에만 404 에러로 처리한다.

45줄부터 나오는 onUpgrade 메소드는 WebSocket 요청을 처리하는 부분으로 HandShake를 옛 방식과 새 방식을 모두 지원하게 해놓았고, 이 접속을 "패러디 웨이브" 서비스로 연결해주는 기능을 한다. 75줄을 적당히 고치면 여러가지 서비스를 요청 경로에 따라서 제공하는 것도 가능할 것이다.

vmware에서 리눅스를 Guest OS로 사용 중일때, 새 하드디스크를 추가하기 위해서는 리부팅을 해야한다. 하지만, 리부팅을 하지 않고도 디스크를 추가할 수 있는 방법이 있어 소개한다.

  1. 가상 머신의 설정에 들어가서 하드디스크를 추가한다.
  2. 리눅스 콘솔에서 관리자 권한으로 다음을 입력한다. 이때, #은 0,1,2 등의 번호가 되는데, vmware에서 하드디스크 정보에서 볼수 있는 ID값의 첫째자리(인 것으로 생각된)다. 이것을 통해서 OS가 SCSI로 연결된 장치를 재검색하게한다.
    echo "- - -" > /sys/class/scsi_host/host#/scan
  3. 나머지는 리부팅해서 하드디스크를 추가하는 과정과 동일하다.

만약, SCSI 장치 재검색 명령어가 제대로 동작하지 않는다면, 우분투 사용자일 경우 아래 방법으로 같은 결과를 얻을 수 있다.(다른 배포판도 해당 도구가 - rescan-scsi-bus.sh - 존재할지는 모르겠다)

sudo apt-get install scsitools
sudo rescan-scsi-bus.sh

'Operating' 카테고리의 다른 글

Redmine 계정으로 Subversion 인증 연동하기  (0) 2011.02.13
VMWare에서 리부팅없이 새 하드디스크 추가하기  (0) 2010.11.11
LVM 설정  (0) 2010.04.01
MySQL 대소문자 구분  (0) 2010.03.10
리눅스에서 제공하는 LVM을 이용하면, 파티션 용량 증설, 축소, 디스크 이전 등이 간편해진다. 특히, 계속 용량이 증가해야하는 곳에서는 유용할 텐데, 그 사용법을 간단히 정리해보았다.

설치 및 초기 설정 방법

  1. LVM 설정을 위해서 필요한 패키지 설치(우분투 기준)
    # apt-get install lvm2 dmsetup
    # modprobe dm-mod
  2. 파티션 준비
    fdisk에서 파티션을 만들때, 형식(type)을 8e(Linux LVM)으로 지정해준다.
  3. physical volume(PV) 준비

    # pvcreate <파티션 경로 (ex. /dev/sdb1)> => PV 만들기

    참고>

    # pvdisplay => PV 내역 보기 # pvremove <파티션 경로> => PV 삭제

  4. volume group(VG) 생성

    # vgcreate <새 VG 이름> <파티션 경로>

    참고>

    # vgdisplay => VG 정보 보기 # vgscan => VG 찾기 # vgrename <이전 볼륨 이름> <새 볼륨 이름> => VG 이름 변경 # vgremove <볼륨 이름> => VG 삭제

  5. logical volume(LV) 생성
    # lvcreate --name share --size 40G fileserver
    # lvcreate --name share -l100%VG fileserver
    참고>

    # lvdisplay => LV 확인 # lvscan => LV 확인 # lvrename <vg 이름> <이전 이름> <새 이름> => LV 이름 변경 # lvremove /dev/<vg 이름>/<lv 이름> => LV 삭제

기타 다른 작업들

  • 디스크 추가

    # vgextend <vg 이름> <파티션 경로>
    # lvextend -L1.5G /dev/<vg 이름>/<lv 이름>
    # lvreduce -L1G /dev/<vg 이름>/<lv 이름>
    만약 LV에서 XFS를 사용할 경우, 다음과 같이 파티션을 늘린다.

    # xfs_growfs /mnt/raid # xfs_growfs /mnt/raid -D <size>

  • 새 디스크에 기존 내용을 옮기는 경우, 즉 물리적인 디스크가 이전되는 경우
    # pvmove <이전 파티션 경로> <새 파티션 경로>
  • 사용 중인 디스크를 제외하기
    # vgreduce fileserver /dev/sdb1


'Operating' 카테고리의 다른 글

VMWare에서 리부팅없이 새 하드디스크 추가하기  (0) 2010.11.11
LVM 설정  (0) 2010.04.01
MySQL 대소문자 구분  (0) 2010.03.10
Flush DNS cache on Windows  (0) 2010.02.22

MySQL에서 Collation 설정에 따라서 영어 대소문자 구분을 하지 않는 경우가 있다. 예를 들어 다음과 같은 쿼리를 했을 경우, 결과로 "TRUE(1)"를 출력하는 경우가 바로 그런 경우이다.

SELECT 'A' = 'a';

이런 경우에 대소문자를 구분할 필요가 있을 경우에는 "="연산 대신에 "LIKE"연산을 사용하면 된다. 예를 들어 위의 쿼리는 다음과 같이 할 경우, 결과로 "FALSE(0)"를 얻게된다.

SELECT 'A' LIKE 'a';

'Operating' 카테고리의 다른 글

LVM 설정  (0) 2010.04.01
MySQL 대소문자 구분  (0) 2010.03.10
Flush DNS cache on Windows  (0) 2010.02.22
리눅스에서 디바이스의 UUID 확인하기  (0) 2010.02.18

윈도우 PC를 사용하다보면, 종종 DNS 정보가 변경된 후에도 이전 IP로 접속되는 경우가 있다. 이는 윈도우가 한번 얻은 DNS 정보를 캐쉬하기 때문인데, 이는 24시간동안 유지된다고 한다. 컴퓨터를 한번 껐다 켜는 것이 가장 간단한 방법일 수 있겠지만, 컴퓨터를 끌 수 없는 상황에서 캐쉬만 초기화 하기 위한 방법이 있어서 소개한다.

사실, 별로 복잡할 것은 없는데, 커맨드 창을 열고 아래와 같은 명령어를 입력하는 것이 전부이다.

c:\> ipconfig /flushdns

참고로, 현재 캐쉬되어있는 DNS 정보를 보는 명령은 아래와 같다.

c:\> ipconfig /displaydns

'Operating' 카테고리의 다른 글

MySQL 대소문자 구분  (0) 2010.03.10
Flush DNS cache on Windows  (0) 2010.02.22
리눅스에서 디바이스의 UUID 확인하기  (0) 2010.02.18
간단하게 SMTP 서버 테스트 하기  (0) 2009.11.10

최근의 리눅스 배포판들의 fstab파일에는 주로 디바이스 경로가 아니라 UUID값이 들어가 있는데, 이러한 디바이스의 UUID를 확인하기 위해서는 vol_id 명령을 사용한다. 사용법은 아래와 같다.

# vol_id [디바이스 경로]
ex> vol_id /dev/sda1

프로그램을 실행하면 다음과 같은 형식으로 내용이 출력되는데, 'ID_FS_UUID' 항목에 나오는 값이 바로 해당 디바이스의 UUID이다.

ID_FS_USAGE=filesystem
ID_FS_TYPE=xfs
ID_FS_VERSION=1.0
ID_FS_UUID=34694935-eca3-4c9c-85ab-b16ca1bd548b
ID_FS_UUID_ENC=34694935-eca3-4c9c-85ab-b16ca1bd548b
ID_FS_LABEL=
ID_FS_LABEL_ENC=
ID_FS_LABEL_SAFE=

반대로 UUID를 이용해서 디바이스 경로를 알아내기 위해서는 'findfs' 명령을 사용한다. 사용법은 다음과 같으며, 명령을 입력하면 바로 디바이스 경로를 출력한다.

# findfs UUID=[UUID값]
ex> findfs UUID=34694935-eca3-4c9c-85ab-b16ca1bd548b
[추가]

최근 우분투 배포판에서는 vol_id 명령이 빠졌다. 대신 blkid 명령을 사용할 수 있다. 사용법은 다음과 같다.

# blkid
# blkid [디바이스 경로]


+ Recent posts