前言
其实大家大可不必被服务器这三个字吓到,一个入门级后端框架,所需的仅仅是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/https
与domain-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/version
,method
可为GET、POST、PUT、HEAD、DELETE、CONNECT、TRACE、OPTIONS
,不过一般常用的只有两个GET
和POST
,path
表示申请服务器资源的完整路径名,路径名之后有时会附带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)
将原始数据以空白行分割为header
与body
两块
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()
将正则表达式匹配到的分组内容提取出来,分别为method
与path[?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下运行通过。
网友评论
在Python3.0后,`conn.sendall(resp_data)`会报错,提示需要字节而不是字符,改成`conn.sendall(resp_data。encode())`即可