brunch

You can make anything
by writing

C.S.Lewis

by 앵버박사 Mar 02. 2016

처음 만드는 온라인 게임 03-02

Python HTTP server 개발



URL 라우팅



계속해서 우리의 웹 서버를 발전시켜 보겠습니다. 가장 필요하면서 핵심인 URL  라우팅인데요, 이게 뭘 하려고 하는 것인가 하면 예로 클라이언트가 http://simple-online-game.com/start 라고 호출했을 경우 특정 디렉터리 하위에 있는 start.py 파일 내에 있는 함수에서 이 요청을 처리하도록 라우팅( 경로 배정 ) 하는  것입니다. 이렇게 처리하지 않으면 현재 버전의 우리 서버는 어떤 요청이 들어오던 한 가지  처리밖에 하지 못 하는 쓸모없는 서버가 되어버립니다.


저번 글에서 URL 라우팅을 위해 simple_router.py 를 만들고, simple_httpd.py 내의 make_server에 simple_router.route 함수를 넘겨 요청이 들어오면 이 함수에서 처리할 수 있도록 설정했었습니다. 그럼 먼저 route 함수가 어떤 모양을 하고 있는지 살펴보겠습니다.




def route( environ, start_response ):

    ...




저번 글에서 봤던 hello_world 함수와 같은 구조입니다. 당연히 WSGI 규약에 맞게 같은 구조를 하고 있습니다. 그렇다면 어떻게 URL 라우팅을 할 수 있을까요? route 함수의 파라미터로 들어온 environ에서 요청 URL을 가져와 분석하고 그에 알맞은 라우팅을 하면 됩니다. URL 중 앞의 프로토콜이라던지 호스트 부분은 필요 없고, 뒤에 리소스를 나타내는 부분( 위에서 예로 들었던 URL 중 /start )이 필요한데 이 부분은 environ에 PATH_INFO라는 이름으로 저장되어있습니다.


이렇게 가져온 PATH 정보를 가지고 알맞은 .py 파일을 불러와 처리해야 합니다. 다른 파이썬 파일을 불러오기 위해서는 import_module 이라는 파이썬 모듈이 필요합니다.




from importlib import import_module


"""

폴더 구조

simple

    └ online

        └ game.py

"""

import_module( 'simple.online.game' )




import_module의 파라미터에 목표로하는 .py 파일이 있는 경로를 적습니다. 단, 경로 구분은 "/"가 아닌 "."으로 대신합니다. 이렇게 실행된 import_module은 해당 파일( 모듈 ) 내부의 클래스/함수/변수 등에 바로 접근할 수 있게 됩니다. 이를 이용해서 route 함수를 더 꾸며보겠습니다.




from io import StringIO

from importlib import import_module


base_package = 'cgi-bin'

encoding = 'utf-8'


class Response:

    def __init__( self, stdout ):

        self.stdout = stdout

    def write( self, contents ):

        print( contents, file = self.stdout )


def route( environ, start_response ):

    stdout = StringIO()

    path = environ.get( 'PATH_INFO' )[ 1 : ]

    import_module( base_packge + '.' + path.replace( '/', '.' ) ).process( environ.get( 'REQUEST_PAYLOAD' ), Response( stdout ) )

    start_response( '200 OK', [ ( 'Content-Type', 'text/json; charset=' + encoding ) ] )

    return [ ( stdout.getvalue().encode( encoding ) ) ]




좀 복잡해졌네요. import_module 근처에 있는 내용부터 풀어보겠습니다. 먼저 import_module( ... )을 호출한 후 그 리턴 값으로 process 함수를 호출하고 있습니다. 어디서 갑자기 나타난 건가 싶지만 사실 이건 제가 만든 일종의 규약입니다. 이 서버에서 라우팅 당해 사용되려면 타겟 xxx.py 파일 내에 process( request, response ): 형식으로 함수를 하나 구현해야만 합니다. 따라서 어떤 모듈이 import 될지는 모르겠지만 그 안에 process 함수를 가지고 있다는 것은 자명하기 때문에( 제가 만든 프로젝트이니 무조건 이렇게 가정하고 별도 체크 로직 없이 진행합니다^^; ) process 함수를 호출하고 인자를 넘겨줍니다. 인자로는 우리가 이 전 글의 simple_httpd.py 에서 처리했던 REQUEST_PAYLOAD 와 직접 만든 Response 객체를 넘겨줍니다. 라우팅 된 모듈의 process 함수에서 Request 정보를 이용해서 알맞은 일을 처리하고 Response 객체에 응답 결과를 작성해 줄 것입니다.



xxx.py


import json


def process( request, response ):

    result = select_users( request[ 'name' ] )

    response.write( json.dumps( result ) )


# select_users 는 설명을 위한 임의의 함수입니다.




StringIO는 문자열을 파일 객체처럼 다룰 수 있게 해주는 파이썬 IO  함수입니다. 이것을 사용하는 이유는 우리가 만든 Response 객체의 write 메서드에서 print 함수의 file 인자에 넘겨주기 위함입니다. 전달한 Response 객체의 write 함수를 통해 응답결과를 작성하면 StringIO() 를 통해 생성된 문자열 파일 객체에 응답결과가 저장되고, 이 문자열 파일 객체의 getvalue 메서드를 호출하여 리턴된 값을 클라이언트로 전송하게 됩니다.



정적 리소스 처리



이제 한 가지만 더 손보면 우리의 HTTP 웹 서버가 완성됩니다. 바로 정적 리소스를 처리하는 일입니다. 이건 또 무슨 말인가 하면 방금까지는 비즈니스 로직을 처리할 수 있게 특정 파일의 process 함수를 호출하여 응답결과를 받아 클라이언트에  전달하였습니다. 하지만 클라이언트가 단순히 정적 리소스( html, css, js, jpg, gif 등 )를  요청했을 경우, 예로 http://simple-online-game/index.html 와 같은 요청도 처리할 수 있어야 합니다. 사실 이게 안되면 말짱 꽝이죠.


그럼 어떻게 해야 이런 정적 리소스를 분별하여 처리할 수 있을 까요? 포인트는 바로  확장자입니다. 이런 정적 파일들은 확장자를 통해 구별할 수 있습니다. 브라우저에서 지원하는 확장자 셋을 가지고 있다가 PATH 정보에서 확장자를 구해 이와 비교하여 알맞은 확장자 라면 해당 경로대로 파일을 찾아와 파일내용을 클라이언트에 보내면 됩니다.


자, 그럼 소스코드를 한번 볼까요?




default_page = 'index.html'

extensions_map = {

    'html' : 'text/html',

    'htm' : 'text/html',

    'ico' : 'image/x-icon',

    'js' : 'text/javascript',

    'css' : 'text/css',

    'jpg' : 'image/jpeg',

    'png' : 'image/png',

    'gif' : 'image/gif',

    'mp4' : 'video/mp4',

    'avi' : 'video/avi'

}


def route( environ, start_response ):

    stdout = StringIO()

    path = environ.get( 'PATH_INFO' )[ 1 : ] or default_page

    extension = path[ path.rfind( '.' ) + 1 : ]


    if extension in extensions_map.keys():

        return do_static( path, extension, start_response )


    return do_dynamic( path, environ, start_response, stdout )


def do_static( path, extension, start_response ):

    ...


def do_dynamic( path, environ, start_response, stdout ):

    import_module( base_package + '.' + path.replace( '/', '.' ) ).process( environ.get( 'REQUEST_PAYLOAD' ), Response( stdout ) )

    start_response( '200 OK', [ ( 'Content-Type', 'text/json; charset' + encoding ) ] )


    return [ stdout.getvalue().encode( encoding ) ]




우선 default_page 를 정의해서 PATH 정보가 없을 경우 index.html 로 지정하였습니다. 그리고 확장자를 분리하여 미리 정의해 놓은 확장자 셋( extensions_map ) 내에 포함되면 정적 리소스로 처리( do_static )하고 아니면 기존 처리 방식( do_dynamic )으로 진행합니다. 확장자 셋은 확장자가 KEY가 되고, 브라우저가 인식하는 Content-Type을 값으로 하는 딕셔너리로 구성하였습니다.


그럼 계속해서 do_static을 완성해보겠습니다.




import os, time, sys


sys.path.append( './' )


weekdayname = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' ]

monthname = [ None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]


def do_static( path, extension, start_response ):

    try:

        f = open( path, 'rb' )

    except OSError:

        start_response( '404 File not found', [] )

        return []


    try:

        fs = os.fstat( f.fileno() )

        start_response( '200 OK', [ ( 'Content-Type', get_ctype( extension ) ), ( 'Content-Length', str( fs[ 6 ]) ), ( 'Last-Modified', date_time_string( fs.st_mtime ) ) ] )

        return f.readlines()

    except:

        f.close()

        raise


def get_ctype( extension ):

    return extensions_map.get( extension, '' )


def date_time_string( self, timestamp=None ):

    if timestamp is None:

        timestamp = time.time()


    year, month, day, hh, mm, ss, wd, y, z = time.gmtime( timestamp )


    s = '%s, %02d %3s %4d %02d:%02d:%02d GMT' % (

        weekdayname[ wd ],

        day, monthname[ month ], year,

        hh, mm, ss)


    return s




정적 리소스의 경우 클라이언트에 파일 내용을 그대로 내려주고 Content-Type을 통해 브라우저가 인식하게 하는 방식입니다. 따라서 해당 경로의 파일을 열어 readlines() 로 파일내용을 전달하고, start_response 객체에 Content-Type과 Content-Length 그리고 새롭게 수정된 파일은 브라우저가 캐시를 사용하지 않도록 하기 위한 Last-Modified 날짜를 보내줍니다. 파일의 수정된 날짜를 구하기 위해 파이썬의 os.fstat 를 사용하여 파일 정보를 가져왔습니다. 그리고 파일이 존재하지 않는 경우 404 File not found 를 전달합니다.


이것으로 파이썬으로 만든 HTTP 웹 서버가 완성되었습니다. 파이썬에서 기본으로 제공하는 WSGI 구현체를 이용하여 멀티 쓰레드에 GET / POST 요청도 처리할 수 있고, 로깅 / URL 라우팅 / 정적 리소스  처리까지 기본적인 웹 서버 & WAS 가 하는 일을 처리할 수 있는 훌륭한 서버가 되었습니다. 다음 글에서는 클라이언트와 서버 간의 게임 데이터를 주고받기 위해 웹 소켓 서버를 개발해보도록 하겠습니다.




simple_router.py ( all source code )


import os, time, sys

from io import StringIO

from importlib import import_module


sys.path.append( './' )


base_package = 'cgi-bin'

encoding = 'utf-8'

default_page = 'index.html'

extensions_map = {

    'html' : 'text/html',

    'htm' : 'text/html',

    'ico' : 'image/x-icon',

    'js' : 'text/javascript',

    'css' : 'text/css',

    'jpg' : 'image/jpeg',

    'png' : 'image/png',

    'gif' : 'image/gif',

    'mp4' : 'video/mp4',

    'avi' : 'video/avi'

}

weekdayname = [ 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' ]

monthname = [ None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]


class Response:

    def __init__( self, stdout ):

        self.stdout = stdout

    def write( self, contents ):

        print( contents, file = self.stdout )


def route( environ, start_response ):

    stdout = StringIO()

    path = environ.get( 'PATH_INFO' )[ 1 : ] or default_page

    extension = path[ path.rfind( '.' ) + 1 : ]


    if extension in extensions_map.keys():

        return do_static( path, extension, start_response )


    return do_dynamic( path, environ, start_response, stdout )


def do_static( path, extension, start_response ):

    try:

        f = open( path, 'rb' )

    except OSError:

        start_response( '404 File not found', [] )

        return []


    try:

        fs = os.fstat( f.fileno() )

        start_response( '200 OK', [ ( 'Content-Type', get_ctype( extension ) ), ( 'Content-Length', str( fs[ 6 ]) ), ( 'Last-Modified', date_time_string( fs.st_mtime ) ) ] )

        return f.readlines()

    except:

        f.close()

        raise


def do_dynamic( path, environ, start_response, stdout ):

    import_module( base_package + '.' + path.replace( '/', '.' ) ).process( environ.get( 'REQUEST_PAYLOAD' ), Response( stdout ) )

    start_response( '200 OK', [ ( 'Content-Type', 'text/json; charset' + encoding ) ] )


    return [ stdout.getvalue().encode( encoding ) ]


def get_ctype( extension ):

    return extensions_map.get( extension, '' )


def date_time_string( self, timestamp=None ):

    if timestamp is None:

        timestamp = time.time()


    year, month, day, hh, mm, ss, wd, y, z = time.gmtime( timestamp )


    s = '%s, %02d %3s %4d %02d:%02d:%02d GMT' % (

        weekdayname[ wd ],

        day, monthname[ month ], year,

        hh, mm, ss)


    return s





Contents


01 주제 / 대상 독자 / 개발 원칙

02 요구사항 정의 / 설계

03 Python HTTP Server 개발

    03-01 멀티 쓰레드 지원 / POST 메서드 처리 / 로깅

    03-02 URL 라우팅 / 정적 리소스 처리


04 Python web socket server 개발
    04-01 웹 소켓 개요

    04-02 웹 소켓 서버 구현

05 클라이언트 개발

    05-01 캐릭터 스프라이트 개발

    05-02 클라이언트 로직

    05-03 서버 로직



매거진의 이전글 처음 만드는 온라인 게임 03-01
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari