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


前言

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,563评论 18 139
  • https://nodejs.org/api/documentation.html 工具模块 Assert 测试 ...
    KeKeMars阅读 6,293评论 0 6
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,335评论 0 17
  • 前几天阿林来找我,风尘仆仆,坐了两天的车,从老家到城里。晚上到家的时候,他些许扭捏的坐在我们家沙发上,看着...
    yzy_lingo阅读 338评论 0 0
  • 对于南锣鼓巷,当时大抵记得那是外地人必去的一个地方,因为那里有北京现在为数不多的胡同,并有一些过去的老建。老故事在...
    sara王阅读 435评论 1 4