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

前言

其实大家大可不必被服务器这三个字吓到,一个入门级后端框架,所需的仅仅是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。


本文如有不当或错误之处,欢迎在评论区指出(但拒绝回复攻击性、侮辱性言论),转载时请注明出处。

  • 27
    点赞
  • 164
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值