네트워크 응용 설계 - 채팅 서버

곽태욱·2020년 5월 24일
0

강의 노트

목록 보기
21/22
post-custom-banner

TCP 소켓 생성

# ChatTCPServer.py

import socket

# IPv4를 따르는 TCP 소켓을 생성한다.
serverSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# Address already in use 오류 해결
serverSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# 소켓을 localhost(127.0.0.1) 주소의 20825번 포트에 할당하고 활성화한다.
serverName = "localhost"
serverPort = 20825
serverSocket.bind((serverName, serverPort))
serverSocket.listen(1)

# 소켓이 생성된 곳의 주소와 포트 번호를 출력
print("The server socket is listening on", serverSocket.getsockname())

socket.socket()으로 소켓을 생성할 수 있다. 1번째 인자에는 IP 주소 버전을 명시하는데 socket.AF_INET는 IPv4를 의미하고, socket.AF_INET6은 IPv6을 의미한다. 2번째 인자는 소켓의 종류를 설정할 수 있는데 socket.SOCK_STREAM은 TCP를 의미하고, socket.SOCK_DGRAM은 UDP를 의미한다.

소켓은 클라이언트와 연결이 끊어지고 나면 네트워크 지연 때문에 미처 도달하지 못한 패킷이 있을 수도 있기 때문에 TIME_WAIT 상태에 들어간다. 이 상태에선 해당 포트에 새로운 소켓을 할당(bind)할 수 없다. 운영체제마다 다르지만 TIME_WAIT은 보통 1분 동안 지속된다. 따라서 프로그램을 테스트할 때마다 기다리지 않기 위해 setsockopt 함수로 소켓 설정을 바꿔준다.

그리고 해당 주소, 포트에 서버 소켓을 할당하고 활성화(listen)시킨다.

클라이언트 연결 수신

# ChatTCPServer.py

import threading

# 클라이언트 이름, 주소, 소켓을 저장한 클래스
# 모든 클라이언트 연결 스레드(connectionThread)에서 공유한다.
class ClientsInfo:
    def __init__(self):
        # 구조 : { nickname: [IP, port, connectionSocket], ... }
        self.clientsInfo = {}  
        
        # clientsInfo를 위한 lock
        self.lock = threading.Lock()  

    def addClientInfo(self, nickname, IP, port, connectionSocket):
        self.lock.acquire()
        self.clientsInfo[nickname] = [IP, port, connectionSocket]
        self.lock.release()

    def deleteClientInfo(self, nickname):
        self.lock.acquire()
        del self.clientsInfo[nickname]
        self.lock.release()


# 클라이언트의 정보를 담고 있다.
clientsInfo = ClientsInfo()

# 서버의 메인 스레드는 클라이언트의 연결 요청을 처리한다.
try:
    while True:
    	# 클라이언트의 연결 요청이 들어오면 새로운 소켓이 생성된다.
        (connectionSocket, clientAddress) = serverSocket.accept()
        
        # 이 클라이언트 소켓과 주소를 통해 클라이언트와 정보를 교환한다.
        connectionThread = threading.Thread(
            target=connection, 
            args=(clientsInfo, connectionSocket, clientAddress)
        )
        
        # 서버의 메인 스레드가 종료되면 이 스레드도 종료된다.
        connectionThread.daemon = True
        
        # 스레드를 실행한다.
        connectionThread.start()
# 서버에서 Ctrl+C 가 눌러졌을 때
except KeyboardInterrupt:
    pass

accept() 함수를 통해 클라이언트의 연결 요청을 기다린다. 만약 요청이 오면 해당 클라이언트와 정보를 주고 받을 수 있는 소켓과 해당 클라이언트 주소가 반환된다.

이렇게 반환된 소켓과 주소를 통해 클라이언트와 정보를 교환하는 스레드를 생성한다. 따라서 서버의 메인 스레드는 단순히 클라이언트의 접속 요청만 처리하고, 서버와 클라이언트의 정보 교환은 새로 생성된 스레드에서 이뤄진다.

그리고 만약 서버에서 Ctrl+C가 눌러져 KeyboardInterrupt Exception 이 발생하면 반복문이 끝난다.

클라이언트 연결 종료

# ChatTCPServer.py

# 클라이언트와 연결된 모든 소켓과 서버 소켓을 닫는다.
for clientInfo in clientsInfo.clientsInfo.values():
    clientInfo[2].close()
serverSocket.close()

print("\nBye bye~")

서버 프로세스가 종료되기 전에는 서버에 생성된 모든 소켓을 닫아주는 것이 좋다. 그래서 모든 클라이언트 연결 소켓과 서버 소켓을 닫아주고 서버 프로세스를 종료한다.

클라이언트와 정보 교환

# ChatTCPServer.py

import json

# command list
MAX_USER_IN_CHATROOM = 10
SERVER_VERSION = "1"
CLIENT_VERSION = "1"
CONNECT = "0"
CONNECT_DUPLICATE_NICKNAME = "01"
CONNECT_CHATROOM_FULL = "02"
CONNECT_SUCCESS = "03"
USERS = "1"
USERS_RESPONSE = "10"
WHISPER = "2"
WHISPER_NO_SUCH_USER = "20"
WHISPER_MYSELF = "21"
WHISPER_SUCCESS = "22"
EXIT = "3"
EXIT_BROADCAST = "30"
VERSION = "4"
VERSION_RESPONSE = "40"
RENAME = "5"
RENAME_DUPLICATE_NICKNAME = "50"
RENAME_SUCCESS = "51"
RTT = "6"
RTT_RESPONSE = "60"
CHAT = "7"
CHAT_RESPONSE = "70"
NEW_USER_ENTERS = "8"

# check whether message has banned words
def isValidMessage(message, bannedWords):
    for bannedWord in bannedWords:
        if message.lower().find(bannedWord) >= 0:
            return False
    return True

# function for a connection thread
def connection(clientsInfo, connectionSocket, clientAddress):
    nickname = ""
    bannedWords = ["i hate professor"]
    try:
        request = json.loads(connectionSocket.recv(1024).decode("utf-8"))
        cmd = request["cmd"]
        response = {}

        # receive a request from the client. there are 1 kinds of request.
        # - CONNECT : a client want to connect to the server. (want to enter to a chatroom)
        if cmd == CONNECT:
            nickname = request["nickname"]

            # send a response to the client. there are 3 kinds of response.
            # - CONNECT_DUPLICATE_NICKNAME : The nickname is already used by someone else.
            # - CONNECT_CHATROOM_FULL : The chatroom is full.
            # - CONNECT_SUCCESS : The client has successfully connected to the server
            if nickname in clientsInfo.clientsInfo:
                response["cmd"] = CONNECT_DUPLICATE_NICKNAME
                connectionSocket.send(json.dumps(response).encode("utf-8"))
                connectionSocket.close()
                return
            elif len(clientsInfo.clientsInfo) == MAX_USER_IN_CHATROOM:
                response["cmd"] = CONNECT_CHATROOM_FULL
                connectionSocket.send(json.dumps(response).encode("utf-8"))
                connectionSocket.close()
                return
            else:
                userCount = len(clientsInfo.clientsInfo) + 1
                responseToEveryone = {}
                responseToEveryone["cmd"] = NEW_USER_ENTERS
                responseToEveryone["nickname"] = nickname
                responseToEveryone["userCount"] = userCount
                responseToEveryoneByte = json.dumps(responseToEveryone).encode("utf-8")
                for clientInfo in clientsInfo.clientsInfo.values():
                    clientInfo[2].send(responseToEveryoneByte)

                clientsInfo.addClientInfo(nickname, clientAddress[0], clientAddress[1], connectionSocket)
                response["cmd"] = CONNECT_SUCCESS
                response["serverAddress"] = serverSocket.getsockname()
                response["userCount"] = userCount
                print(nickname, "joined. There are", userCount, "users connected.")
                connectionSocket.send(json.dumps(response).encode("utf-8"))
        else:
            print("Invalid request command. Command:", cmd)
            connectionSocket.close()
            return

        while True:
            # receive a request from the client. there are 7 kinds of requests.
            # - USERS : show the <nickname, IP, port> list of all users
            # - WHISPER : whisper to <nickname>
            # - EXIT : disconnect from server, and quit client process
            # - VERSION : show server's software version (and client software version)
            # - RENAME : change client nickname
            # - RTT : show RTT(Round Trip Time) from the client to the server and back
            # - CHAT : send a chat to all users in the chatroom
            requestString = connectionSocket.recv(1024).decode("utf-8")

            # if the client disconnects server, or connection to the client has been lost
            if requestString == "":
                print("The TCP connection to the clinet(nickname=" + nickname + ") has been lost.")
                break

            request = json.loads(requestString)
            cmd = request["cmd"]
            print("Command", cmd)
            response = {}

            # send a response to the client. there are 1 kinds of response.
            # - USERS_RESPONSE : send the <nickname, IP, port> list of all users
            if cmd == USERS:
                users = []  # [[clientNickname, clientIP, clientPort], ...]
                for clientNickname, clientInfo in clientsInfo.clientsInfo.items():
                    users.append([clientNickname, clientInfo[0], clientInfo[1]])
                response["cmd"] = USERS_RESPONSE
                response["users"] = users
                connectionSocket.send(json.dumps(response).encode("utf-8"))
            # send a response to the client. there are 3 kinds of response.
            # - WHISPER_NO_SUCH_USER : there is no user 'whispering to'
            # - WHISPER_MYSELF : cannot whisper myself
            # - WHISPER_SUCCESS : succeed to whisper
            elif cmd == WHISPER:
                to = request["to"]
                msg = request["msg"]
                if to not in clientsInfo.clientsInfo:
                    response["cmd"] = WHISPER_NO_SUCH_USER
                elif clientsInfo.clientsInfo[to][2] == connectionSocket:
                    response["cmd"] = WHISPER_MYSELF
                elif not isValidMessage(msg, bannedWords):
                    break
                else:
                    responseToReceiver = {}
                    responseToReceiver["cmd"] = CHAT_RESPONSE
                    responseToReceiver["from"] = nickname
                    responseToReceiver["msg"] = msg
                    clientsInfo.clientsInfo[to][2].send(json.dumps(responseToReceiver).encode("utf-8"))
                    response["cmd"] = WHISPER_SUCCESS
                connectionSocket.send(json.dumps(response).encode("utf-8"))
            # send a response to the client. there are 3 kinds of response.
            # - EXIT_BROADCAST : broadcast the fact that a user left the chatroom to all users
            elif cmd == EXIT:
                break
            # send a response to the client. there are 3 kinds of response.
            # - VERSION_RESPONSE : send server version to the client
            elif cmd == VERSION:
                response["cmd"] = VERSION_RESPONSE
                response["serverVersion"] = SERVER_VERSION
                connectionSocket.send(json.dumps(response).encode("utf-8"))
            # send a response to the client. there are 3 kinds of response.
            # - RENAME_DUPLICATE_NICKNAME : cannot change to the <nickname> because the <nickname> is userd by another user
            # - RENAME_SUCCESS : change client nickname
            elif cmd == RENAME:
                newNickname = request["newNickname"]
                if newNickname in clientsInfo.clientsInfo:
                    response["cmd"] = RENAME_DUPLICATE_NICKNAME
                else:
                    clientsInfo.deleteClientInfo(nickname)
                    clientsInfo.addClientInfo(newNickname, clientAddress[0], clientAddress[1], connectionSocket)
                    nickname = newNickname
                    response["cmd"] = RENAME_SUCCESS
                    response["newNickname"] = newNickname
                connectionSocket.send(json.dumps(response).encode("utf-8"))
            # send a response to the client. there are 3 kinds of response.
            # - RTT_RESPONSE : send back a 'beginTime' to the client as it is
            elif cmd == RTT:
                response["cmd"] = RTT_RESPONSE
                response["beginTime"] = request["beginTime"]
                connectionSocket.send(json.dumps(response).encode("utf-8"))
            # send a response to the client. there are 3 kinds of response.
            # - CHAT_RESPONSE : send back a 'msg' to all users in the chatroom as it is
            # - EXIT_BROADCAST : disconnects the client if message from the client has banned words
            elif cmd == CHAT:
                msg = request["msg"]
                if not isValidMessage(msg, bannedWords):
                    break
                else:
                    response["cmd"] = CHAT_RESPONSE
                    response["from"] = nickname
                    response["msg"] = msg
                    responseByte = json.dumps(response).encode("utf-8")
                    for clientInfo in clientsInfo.clientsInfo.values():
                        if clientInfo[2] != connectionSocket:
                            clientInfo[2].send(responseByte)
            # Undefined request comnmand
            else:
                print("Invalid request command. Command:", cmd)
    except Exception as e:
        print(e)

    # disconnect the client and broadcast the fact that a user left the chatroom to all users
    connectionSocket.close()
    clientsInfo.deleteClientInfo(nickname)
    response = {}
    response["cmd"] = EXIT_BROADCAST
    response["nickname"] = nickname
    response["userCount"] = len(clientsInfo.clientsInfo)
    responseByte = json.dumps(response).encode("utf-8")
    for clientInfo in clientsInfo.clientsInfo.values():
        clientInfo[2].send(responseByte)
profile
이유와 방법을 알려주는 메모장 겸 블로그. 블로그 내용에 대한 토의나 질문은 언제나 환영합니다.
post-custom-banner

0개의 댓글