从零开始搭建一个简易的服务器(一)

作者: wierton | 来源:发表于2016-08-26 11:44 被阅读3751次

    前言

    其实大家大可不必被服务器这三个字吓到,一个入门级后端框架,所需的仅仅是HTTP相关的知识与应用这些知识的编程工具。据本人的经验,绝大多数人拥有搭建后端所涉及到的基础理论知识,但是缺乏能将之应用出去的工具,而本文即是交给读者这样一个工具,并能够运用之来实现一个可用的后端。

    本文以基础理论知识的运用为主,并不会在服务器的稳定性安全性上做探究,同时为了避免大家在实现中被各种编程语言的独有特性所困扰,本文选用选Python作为编程语言,并会附上详细的代码。

    一、最初的尝试

    超文本传输协议HyperText Transfer Protocol)是迄今为止互联网应用最为广泛的协议,平时大家在浏览器上浏览网页,逛淘宝,刷博客,上知乎均是基于这种协议。

    在互联网七层架构中HTTP位于TCP/UDP之上,这意味着我们我们可以在TCP/UDP层收发HTTP层的数据,而能够帮助我们在TCP/UDP层收发数据的最原始的一个工具------套接字

    几乎每一门编程语言都会原生支持套接字,所以本文选用套接字讲解,而非python语言本身拿手的第三方库,套接字与基础知识之间直接对接,这样不仅简化学习成本,同时易于读者从底层了解学习HTTP,也便于理解各种第三方库的实现机理,可谓一举三得。

    在套接字的帮助下,我们可以写下第一个服务器端的框架:

    #coding=utf-8
    import re
    from socket import *
    
    def handle_request(request):
        return 'Welcome to wierton\'s site'
    
    s = socket(AF_INET, SOCK_STREAM)
    s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    
    s.bind(('127.0.0.1', 8080))
    s.listen(10)
    while 1:
        conn,addr = s.accept()
        print("connected by {}".format(addr))
        recv_data = conn.recv(64*1024)
        resp_data = handle_request(recv_data)
        conn.sendall(resp_data)
        conn.close()
    s.close()
    

    上述框架能够干嘛呢?想要实验上述代码的效果,你只要在浏览器中输入127.0.0.1:8080,然后你就会看到一行字符串Welcome to wierton's site.,如图:

    怎么样,是不是很有成就感,你的代码“成功”响应了浏览器的请求并回复了一个你设定好的字符串。

    或许新入门的你对上述代码有所疑惑,不着急,我们来慢慢过一遍上述代码。

    s = socket(AF_INET, SOCK_STREAM)创建一个流式套接字用于TCP通信

    s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)设定当前套接字,使其允许被复用

    s.bind(('127.0.0.1', 8080))将当前套接字绑定到ip地址为127.0.0.1,端口号为8080的连接上

    注:虽然HTTP默认端口为80,但在linux下,监听80号端口需要root权限。

    s.listen(10)监听当前套接字,设定并发数为10,即在多客户端并发请求时,第11个及其以后的连接请求会被拒绝

    conn,addr = s.accept()响应一个连接请求

    recv_data = conn.recv(64*1024)接收来自客户端的数据,并设置缓冲区大小为64KB

    resp_data = handle_request(recv_data)处理请求内容,并生成回复字串

    conn.sendall(resp_data)发送回复字串

    conn.close()关闭与当前客户端的连接

    二、加入HTTP header

    有了上述demo的基础,或许很多人会想,我是不是只要将自己的东西填入handle_request中就行了呢?诚然如此,但我们似乎还缺一点:如何区分浏览器申请的资源,即怎么知道浏览器要的是a.png还是b.txt

    不着急,我们先来普及一下url基本知识:

    首先一个url通常有这样的结构:http[s]://domain-name/path?query-string,例如:http://a.somesite.com/login.do?username=wierton&passwd=123456

    其中http/httpsdomain-name含义自不用说,path指申请资源的完整路径名,query-string格式一般是数个键值对,键值对之间用&连接,键与值之间用=连接,例如:?username=wierton&password=123456,那如果键或值中需要使用&、=这两个特殊符号呢?这时候就要动用url编码了,其中=号对应编码%3D,&号对应编码%26,因此我们只要在键值对中需要这两个符号的地方将其替换为对应的url编码即可。

    有些url中还会有特殊符号#,其具体用途参见这里

    上述内容如何对应到TCP连接中收到的数据呢?我们可以做如下一个简单的实验,只需将之前的代码略作修改,在函数handle_request的第一行加上print(request),修改后代码如下:

    #coding=utf-8
    import re
    from socket import *
    
    def handle_request(request):
        print(request)
        return 'Welcome to wierton\'s site'
    
    s = socket(AF_INET, SOCK_STREAM)
    s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    
    s.bind(('127.0.0.1', 8080))
    s.listen(10)
    while 1:
        conn,addr = s.accept()
        print("connected by {}".format(addr))
        recv_data = conn.recv(64*1024)
        resp_data = handle_request(recv_data)
        conn.sendall(resp_data)
        conn.close()
    s.close()
    

    运行代码,并在浏览器中输入127.0.0.1:8080/login.do?username=wierton&passwd=
    123456,查看代码的输出,我们可以看到如下内容:

    GET /login.do?username=wierton&passwd=123456 HTTP/1.1
    Host: 127.0.0.1:8080
    Connection: keep-alive
    Upgrade-Insecure-Requests: 1
    User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, l
    ike Gecko) Chrome/52.0.2743.116 Safari/537.36
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp
    ,*/*;q=0.8
    Accept-Encoding: gzip, deflate, sdch
    Accept-Language: zh-CN,zh;q=0.8
    

    容易发现,url中域名之后的内容被原封不动的放在第一行GET字符串之后。那么代码收到的除第一行外的这么多数据又是什么?有何用处?

    一个完整的HTTP请求应至少包含一个完整的HTTP header,有时header后面还会附上data段(如POST请求中),上面代码收到的即是一个HTTP header,而一个HTTP header的第一行一般形如method path[?query-string] HTTP/versionmethod可为GET、POST、PUT、HEAD、DELETE、CONNECT、TRACE、OPTIONS,不过一般常用的只有两个GETPOSTpath表示申请服务器资源的完整路径名,路径名之后有时会附带query-string,两者之间以符号?分隔,version表示协议的版本,目前常用的是HTTP/1.1

    第一行结束后,会跟上一个\r\n作为换行符(注意:是\r\n而非\n),然后紧接着便是一行行由冒号分割开的键值对(关于这些键值对的较为详细的含义可以参见这里),其中本文关注的字段有Host、Connection、User-Agent,同样,这些键值对之间也是以\r\n作为分隔符(换行符)。当然键值对的末尾还得加上一个空白行(\r\n),以区分开HTTP头与主体数据。

    \r\n英文缩略为CRLF,在早期显示器中,光标移动\r\n是两个分开的操作\r代表光标移回行首,\n代表光标移动到下一行水平坐标不变的位置,也就是说现在的一个字符\n其实在早期是由两个字符\r\n组成的,同时windows下至今沿用\r\n作为换行符。

    作为服务器,在拿到这一串header之后,首先要做的无疑是解析header,分割开键与值,并最好能将键值对存到Python的字典中去,如下便是将这些信息提取出来的代码:

    #coding=utf-8
    import re
    
    def parse_header(raw_data):
        if not '\r\n\r\n' in raw_data:
            print('Unable to parse the data:{}.'.format(raw_data))
            return False
        proto_headers, body = raw_data.split('\r\n\r\n', 1)
        proto, headers = proto_headers.split('\r\n', 1)
        ma = re.match(r'(GET|POST)\s+(\S+)\s+HTTP/1.1', proto)
        if not ma:
            print('unsupported protocol')
            return False
        method, path = ma.groups()
        if path[0] == '/':
            path = path[1:]
        lis = path.split('?')
        lis.append('')
        rfile, query_string = lis[0:2]
        params = [tuple((param+'=').split('=')[0:2])
                for param in query_string.split('&')]
        
        ma_headers = re.findall(r'^\s*(.*?)\s*:\s*(.*?)\s*\r?$', headers, re.M)
        headers = {item[0]:item[1] for item in ma_headers}
        print("version\t: 1.1")
        print("method\t: {}".format(method))
        print("path\t: {}".format(rfile))
        print("params\t: {}".format(params))
        print("headers\t: {}".format(headers))
    

    直接甩出这么一堆代码,或许你有点懵逼,不着急,我们来慢慢分析一下这段代码,也许分析完,你就能写出比这更优的代码。

    首先我们对客户端传来的数据做如下标准化假设:

    • 换行符:在正式数据之前,换行符均为\r\n
    • 数据格式:first-line + key-value-pairs + \r\n + body
    • 首行:(GET|POST) path?params HTTP/1.1
    • 即只接受GET和POST两种方法,同时只接受1.1版的HTTP协议。
    • 键值对:key : value + \r\n
    • 数据主体:body可为空

    那么对于标准假设外的请求,采取一律拒绝掉的策略,基于此假设,我们再来回顾这段代码:

    if not '\r\n\r\n' in raw_data:如果不存在空白行,拒绝请求

    proto_headers, body = raw_data.split('\r\n\r\n', 1)将原始数据以空白行分割为headerbody两块

    proto, headers = proto_headers.split('\r\n', 1)将头中的第一行与键值对分割开

    ma = re.match(r'(GET|POST)\s+(\S+)\s+HTTP/1.1', proto)按标准假设匹配第一行,如果不能成功匹配,则拒绝请求

    method, path = ma.groups()将正则表达式匹配到的分组内容提取出来,分别为methodpath[?query-string]

    if path[0] == '/': path = path[1:]将路径首部的'/'去掉,这一步是为后期做准备,即将客户端申请的绝对路径转化为服务器工作目录的相对路径(这里为了安全起见还可以对路径进行判断,即最终路径如果不是落在工作目录内,就拒掉请求)

    lis = path.split('?'); lis.append(''); rfile, query_string = lis[0:2]以?将路径与query-string分割开

    params = [tuple((param+'=').split('=')[0:2]) for param in
    query_string.split('&')]
    这里使用生成器来简化代码,将其展开的话意思就是将query_string按&分割成若干个token,每个token按=分割成前后两部分(为了防止某些token没有=,这里将token加上=在分割),并转化为一个元组塞到列表中,最终返回这个列表

    ma_headers = re.findall(r'^\s*(.*?)\s*:\s*(.*?)\s*\r?$', headers, re.M)
    headers = {item[0]:item[1] for item in ma_headers}
    这里用正则表达式来匹配headers数据,并利用正则表达式的分组功能,将结果用生成器打包成一个字典

    运行上述代码,对如下数据进行解析:

    GET /login.do?username=wierton&passwd=123456 HTTP/1.1
    Host: 127.0.0.1:8080
    Connection: keep-alive
    Upgrade-Insecure-Requests: 1
    User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, l
    ike Gecko) Chrome/52.0.2743.116 Safari/537.36
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp
    ,*/*;q=0.8
    Accept-Encoding: gzip, deflate, sdch
    Accept-Language: zh-CN,zh;q=0.8
    

    得到结果如下:

    version : 1.1
    method  : GET
    path    : login.do
    params  : [('username', 'wierton'), ('passwd', '123456')]
    headers : {'Accept-Language': 'zh-CN,zh;q=0.8', 'Accept-Encoding': 'gzip, deflate, sdch', 'Connection': 'keep-alive', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, l', 'Host': '127.0.0.1:8080', 'Upgrade-Insecure-Requests': '1'}
    

    本节到此为止,下节会介绍如何将请求回复这一过程封装,并利用正则表达式分解不同请求,将其引流至不同的handler。


    注:文中涉及的代码均在python2.7下运行通过。

    相关文章

      网友评论

      • 12e9f11a1d1a:刚好也要搭建一个服务器
      • 帅气影中人:感谢笔者,有个小问题:
        在Python3.0后,`conn.sendall(resp_data)`会报错,提示需要字节而不是字符,改成`conn.sendall(resp_data。encode())`即可
        wierton:谢谢提醒,不过文中涉及的代码是在python2.7下运行通过的,并没有考虑python3的兼容性,注明信息已添加在文末。

      本文标题:从零开始搭建一个简易的服务器(一)

      本文链接:https://www.haomeiwen.com/subject/lteosttx.html