打开浏览器输入一个地址,然后得到一个页面。这几乎是我们每天都会经历的事情,本博客的内容就是带领大家从零开始了解这套机制,力求描述清楚计算机是如何支持我们访问网页的。
假设你能理解电话是如何将声音的震动转换为电信号而后再转回声音信号这个过程,那我就不需要多费口舌,向你过多解释计算机的基本通信原理了,而且大多数时候程序开发人员也无需关心这种数字模拟信号相互转换的原理,但必须关心协议,因此我们的第一个话题将围绕计算机网络通信协议展开。
《TCP/IP详解》开篇的第一句话简明的阐述了协议在计算机通信协议的本质:
Effective communication depends on the use of a common language. This is true for humans and other animals as well as for computers.
有效的沟通建立在通用的语言这一基础之上,人、动物乃至计算机,无一例外。
——《TCP/IP详解》
为了确保计算机之间的通信能够建立在同一套体系之内,在计算机网络发展之初就有先辈们为我们设计了一系列完整的通信协议,以确保我们当今使用的各种软件和硬件产品能够依托网络完成信息交互。TCP/IP协议就是当今计算机世界最主流的网络通信协议,几乎所有的计算机系统都遵循这套协议。TCP/IP协议提供了网络寻址的通用方法和实施标准,各类网络设备厂商依托这套标准连接所有网络设备从而形成互联网。互联网中的应用程序厂商遵循相同的数据传输协议才使得不同的设备之间能够进行传输。最后互联网中的软件产品也必须遵循对应的应用层协议才能保证信息被正确的识别。(如果需要详细了解TCP/IP协议和计算机网络通信的知识可以翻阅《TCP/IP详解》这本书。)
HTTP协议简介
HTTP协议正式我们上面谈到的应用层协议。他的主要作用是约定了一套详细的数据解析规则,以支持客户端和服务器两个网络通信的参与者能够相互传输数据。HTTP协议使用一种一问一答的通信方式,客户端首先需要发送请求,而后服务端处理之后响应客户端的请求。而请求和响应的方式如下图所示。在不考虑传输层协议的情况下这就是HTTP协议的全部。
[图片上传失败...(image-9d517d-1653917607526)]
[图片上传失败...(image-c32042-1653917607526)]
实现HTTP协议
理论上只要我们按照上图中的约定去编写程序,发送上图中规定格式的数据就可以实现http协议了。大多数http服务器都是建立socket传输协议的基础上的。那么我们创建一个简单的socket服务器然后把数据按照http协议的标准传输出去,就可以得到一个http服务器。
为了简化问题和解决时间,这里使用python的来实现这样一个http服务器:
import socket
HOST, PORT = '', 8000
def run():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen(1)
print("http服务器启动 端口 %s", PORT)
while True:
client_conn, client_addr = server.accept()
http_request = client_conn.recv(1024)
print("收到请求:%s", http_request)
http_response = "HTTP/1.1 200 OK\r\n"
http_response += "Content-Type: text/html;charset=utf-8\r\n"
http_response += "\r\n"
http_response += "Hello World!"
client_conn.sendall(http_response.encode("utf-8"))
client_conn.close()
server.close()
if __name__ == '__main__':
run()
上述代码实现的HTTP服务器依照HTTP报文的标准对客户端的请求进行了响应,返回一个响应报文,因为我们的报文返回符合HTTP协议的标准,所以浏览器可以解析和显示我们的内容,尽管目前这个网站只能永远返回Hello World!
实现静态网站
一个永远只能输出“hello world”的网站似乎没有任何意义,为了让我们的http服务器真正有用,我们可以试图改造上面的程序让其可以私服html文件。html文件也叫做网页文件,是一种可以被浏览器解析和展示的专有格式的文件。编写html文件很简单,你只需要花上十几分钟在w3cSchool上面学习一点html(超文本标记语言)的知识,就可以编写自己的html文件。
为了方便检验我们服务器的功能,我们编写了两个html文件并将其放在了一个叫做wwwroot的文件夹内。他们的内容很简单:
首先是index.html文件,他展示一段话告诉用户这里是网站的首页,并且提供了一个超链接可以帮助跳转到"关于我"页面:
首页
你好!欢迎访问首页! 你可以点击这个链接了解我!
然后是about.html文件,他的作用是显示关于我的一些信息,并提供了一个返回到“首页”的超链接。
关于我
我是谁
我是老码农,立志于用最朴素的思想教会大家编写有用的程序!
你可以点击这个链接返回首页
然后我们尝试来升级http服务器,经过一番考虑我们得到了如下程序:
# 静态服务器 可以返回HTML页面
from copyreg import constructor
import socket
import os
HOST, PORT = '', 8000
WEBROOT = './wwwroot'
def run():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(1)
print("http服务器启动 端口 {}".format(PORT))
while True:
client_conn, client_addr = server.accept()
http_request = client_conn.recv(1024)
relative_path = get_relative_path(http_request.decode('utf-8', 'ignore'))
data = load_html(relative_path)
http_response = "HTTP/1.1 200 OK\r\n"
http_response += "Content-Type: text/html;charset=utf-8\r\n"
http_response += "\r\n"
http_response += data
client_conn.sendall(http_response.encode("utf-8"))
client_conn.close()
server.close()
def get_relative_path(http_request):
rows = http_request.split('\r\n')[0]
return rows.split(' ')[1]
def load_html(html_path):
if html_path == '/':
html_path = '/index.html'
if html_path == '/favicon.ico':
return ''
full_path = WEBROOT+html_path
with open(full_path, 'r', encoding='utf-8') as f:
return f.read()
if __name__ == '__main__':
run()
运行这个程序,点击首页的链接你将跳转到about页面,然后再点击回到首页的链接,你将会回到首页。只要编写更多的html文件并将其链接器起来,我们就能得到一个静态网站,而这个网站我们没有用任何其他的技术,完全通过最基础的编程接口来实现,你甚至可以使用C语言去开发具有相同功能的网站。
实现动态网站
在上面的程序中通过解析request数据可以得到请求路径,而根据请求路径去映射本地html文件然后将其作为response的数据部分返回给客户端我们就可以得到一个静态网页伺服器。但是大多数时候人们还希望自己的网站能够根据请求动态响应页面,因此还需要对我们的程序进行进一步升级。
近年来流行一种新的思想用于解释web程序,这种思想的核心是将网络上的所有资源都当成是一种资产,我们可以对这些资产执行查看、创建、修改、删除这四类操作已完成远程资源的管理。HTTP协议为我们考虑到了这类情况,在HTTP协议中,将请求分为了如下几种类型:
- GET——用于请求数据
- POST——用于提交数据
- PUT——用于修改数据
- DELETE——用于删除数据
一个比较典型的例子就是用于客户管理的web程序可以支持用户通过网页查看、创建、修改和删除客户以维护自己的客户资源。我们一般使用GET请求去向服务器请求客户列表和查看客户信息,使用POST请求去创建一个新的客户,使用PUT请求去修改客户的信息,使用DELETE请求去删除某一个客户。近年来很多的web程序都是用这样的形式去进行web开发,他有一个更专业的名字叫做RestFull风格,因此接下来的服务器设计,我们将尽量支持这四种请求类型。
除了要注意请求方式之外,动态网站的请求往往会附带一些用户希望传递给服务器程序的信息。比如当用户想查看年龄35岁以上的客户清单时就必须把年龄大于35这个信息传递给服务器;同样的当用户想要创建一个新的客户时,就必须把客户的姓名、年龄、联系方式等信息传递到服务器。对于这类信息的传递HTTP协议提供了多种方式。
url传参
第一种方式被程序员们称作URL传参,具体的方式就是把信息放置在请求头中的url里面,比如当我想要获取一个名字叫做Andy的用户的信息时,其对应的url可能会被表达成'/customer?name=andy'这样的形式。url后面加上‘?’,然后使用‘属性名称’=‘属性值’的方式进行拼接就可以得到一个带有请求参数的url,如果有多个参数要传递则使用‘&’符号将各组数据隔离开即可。类似于这样:‘/customer?name=andy&age=18’。当然这只是url传参的一种方式,本质上并不是http协议的一部分,但是几乎所有的服务器程序都支持使用这种方式把参数携带在url内,一般情况下这种方式被用在GET请求中。
form-data传递数据
第二种方式被称之为表单数据传参,这种传参方式是通过HTML中的FORM表单来收集用户数据,然后将数据放置在request的请求体(body)中。这个时候的request一般如下面展示的一般:
这样的一个表单用于向服务器发起一个POST请求,并携带客户的姓名和年龄以保证服务器端能够创建这样一个name为andy,年龄为18岁的客户。
事实上,当用户在浏览器点击提交按钮之后,会有这样一个request被传递到服务器:
POST /foo HTTP/1.1
Content-Length: 68137
Content-Type: application/x-www-form-urlencoded
Content-Length: 16
name=andy&age=18
上面的请求在默认情况下被设置为 application/x-www-form-urlencoded 格式,也就是将数据转换为 : "属性1"="值"&"属性2"="值"... 这样的形式。
如果客户想要上传文件,那么使用上面的方式显然就行不通了,http协议提供的解决方案是使用multipart/form-data作为content-type,下面是从MDN中摘抄来的示例:
POST /test.html HTTP/1.1
Host: example.org
Content-Type: multipart/form-data;boundary="boundary"--boundary
Content-Disposition: form-data; name="field1"value1
--boundary
Content-Disposition: form-data; name="field2"; filename="example.txt"文件内容.......
看上去似乎比x-www-form-urlencoded这种格式更加复杂,但是它更加能胜任传输二进制文件这种特殊需求。
使用json或者xml格式传递信息
现下,更多时候传递数据的方式是使用json或者xml格式。json和xml这两种数据格式以一种较稳清晰的结构去定义数据,也有很丰富的配套代码库去完成json和xml的解析。
一段表示customer的json数据一般长这样:
{
"name" : "Andy",
"age" : 18,
}
而使用xml则可以这样表示:
到此为止,我们还没有开始构建我们的动态网站。但
Andy
18
开始规划动态网站的代码
到目前为止我们还没有聊任何有关动态网站如何建立的话题,但我们已经掌握了一些必要的知识。我们知道了GET、PUT、POST、DELETE四种HTTP协议所支持的请求方式,也了解了三种常用的HTTP传递数据的方式。接下来的工作就是尝试去解析request消息的内容,根据不同的请求方式来执行不同的处理逻辑,以及在处理逻辑中提取不同的请求参数。所以我们的代码将作如下规划:
- 程序的主体依旧是一个socket服务器;
- 我们需要一个代码块来专门解析request数据并从中得到各种请求参数;
- 我们还需要一个response生成器专门用来制造各种响应;
- 我们还需要一个基本的路由器,按照请求方法和路径将用户的请求指向对应的处理程序;
- 最后,我们要再次基础上编写一个客户管理的程序并让其能够响应浏览器的请求;
首先是程序的主题,依旧是一个简易的scoket服务器:
# 动态服务器 提供一个客户管理程序
import socket
from sys import argv
from http_request import Request
from router import Router
HOST, PORT = '', 8000
WEBROOT = './wwwroot'
def run():
if len(argv) != 2:
print("请配置程序处理模块并确保启动参数输入正确")
return
_,app = argv
router = Router(app)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(1)
print("http服务器启动 端口 {}".format(PORT))
while True:
client_conn, client_addr = server.accept()
request = Request(client_conn.recv(1024).decode('utf-8'))
response = router.route(request)
client_conn.sendall(response.encode("utf-8"))
client_conn.close()
server.close()
if __name__ == '__main__':
run()
上面的代码使用通过对客户端发送的request消息进行解析得到一个程序内建模的Resquet对象,这将有利于我们在后面的程序中更加方便的使用request数据。
Request解析器的代码如下:
'''
http request 数据解析和封装
'''
class Request:
'''
定义一个Request类来表示客户端的request
'''
def __init__(self,request_raw_data):
'''
request_raw_data 就是原始的request数据
使用method存储 request的请求方法
使用url存储 request的请求路径
使用header字典存储 request的头信息
使用url_params存储 request的url参数信息
使用form_data字典存储 request的表单数据
使用json_data字典存储 request的json数据
使用xml_data字典存储 request的xml数据
'''
self.method = ''
self.url = ''
self.headers = {}
self.url_params = {}
self.form_data = {}
self.json_data = {}
self.__parse__(request_raw_data)
def __parse__(self, raw):
'''
解析response数据
'''
rows = raw.split("\r\n")
self.method, self.url, _ = rows[0].split(' ')
self.url_params = self.parse_url_params()
if '?' in self.url:
self.url = self.url.split('?')[0]
self.parse_headers(rows)
self.form_data = self.parse_form_data(rows)
def parse_headers(self, rows):
'''
解析header字典
'''
header_rows = rows[1:rows.index("")]
for row in header_rows:
k,v = row.split(': ')
self.headers[k.strip()] = v.strip()
def parse_url_params(self):
'''
解析url参数
'''
if '?' in self.url:
paramsStr = self.url.split('?')[-1]
return self.paramsExpressionToDict(paramsStr)
else:
return {}
def parse_form_data(self, rows):
'''
解析formdata
'''
raw_data = rows[rows.index(''):][1]
return self.paramsExpressionToDict(raw_data)
def paramsExpressionToDict(self, paramsStr):
'''
a=xx&b=xx 形式的参数表达式转换为字典
'''
dict_result = {}
for attrExpression in paramsStr.split('&'):
if '=' in attrExpression:
k,v =attrExpression.split('=')
dict_result[k.strip()] = v.strip()
return dict_result
上面的代码实现了request的基本解析,他可能暂时还不太完善。它的主要功能是request字节码数据转换成为一个Response类,并提供header, method, url,url_params, form_data 等基础数据。
接下来实现一个路由方法,来保证程序能够根据请求方法和路径去执行对应的代码:
'''
请求路由处理程序
'''
import importlib
import types
import sys
from http_request import Request
from http_response import build_404_response
class Router:
'''
核心路由器
'''
def __init__(self, app_module):
self.route_table = {}
self.app_module = app_module
self.init_route_tables()
def init_route_tables(self):
'''
初始化路由表
'''
app = importlib.import_module(self.app_module)
attrList = dir(app)
for attrName in attrList:
attr = getattr(app, attrName)
if isinstance(attr, types.FunctionType):
if "method" in attr.__dict__:
method = attr.__dict__['method']
path = attr.__dict__['path']
key = '{}:{}'.format(method, path)
if key in self.route_table.keys():
print("配置了重复的路由 {}".format(key))
sys.exit(1)
self.route_table[key] = attr
def route(self, request:Request):
'''
查询路由表并执行对应的处理程序
'''
key = '{}:{}'.format(request.method, request.url)
if key in self.route_table.keys() :
func = self.route_table[key]
return func.__call__(request)
else:
return build_404_response()
上面的代码创建了一个Route类,Route类中封装了一个叫做init_route_tables的方法,这个方法利用反射的方式动态将我们的业务逻辑代码加载进入程序。并从代码中筛选出来对应的逻辑处理代码放到一个字典中。在需要进行路由的时候,仅需要根据请求方式和路径找到对应的代码片段的指针,然后使用反射执行对应的程序即可!
为了方便逻辑代码的编写,同时为了支持路由表扫描,我们定义了一个帮助模块专门提供装饰器以支持在函数这一级别上进行代码片段的标记。
from functools import wraps
def route(method:str, path:str):
def applies(func):
func.__dict__["method"] = method.upper()
func.__dict__["path"] = path.lower()
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return applies
最后,我们编写业务逻辑实现一个简单的对客户信息进行增删改查的代码,他看上去有点像flask程序:
from helper import route
from models import Customer
from http_request import Request
from http_response import build_404_response, build_json_response
#所有客户
customers = [
Customer("Andy", 28, '[email protected]'),
Customer("Bob", 29, '[email protected]'),
Customer("Tony", 30, '[email protected]'),
Customer("Candy", 19, '[email protected]'),
Customer("Daiv", 22, '[email protected]')
]
@route(method='get', path='/favicon.ico')
def ico(request):
return build_404_response()
@route(method='get', path="/customer/all")
def query_all(request:Request):
global customers
return build_json_response(customers)
@route(method='post', path='/customer')
def create_one(request:Request):
name = request.form_data['name']
age = request.form_data['age']
mail = request.form_data['mail']
global customers
customers.append(Customer(name,age,mail))
return build_json_response(customers)
@route(method="delete", path="/customer")
def delete_by_name(request: Request):
name = request.url_params['name']
remove_list = []
global customers
for cus in customers:
if cus.name == name:
customers.remove(cus)
return build_json_response(customers)
@route(method="put", path="/customer")
def update(request:Request):
name = request.form_data['name']
age = request.form_data['age']
mail = request.form_data['mail']
print(name, age, mail)
global customers
for cus in customers:
if cus.name == name:
customers.remove(cus)
break
customers.append(Customer(name,age,mail))
return build_json_response(customers)
到此为止,我们没有依托任何web框架就完成了一个动态网站!
写在后面
为了帮助大家了解HTTP协议,本博文从零实现了三个版本的HTTP服务器。在动态网站服务器的设计上还存在很多缺陷,但继续写下去网络上也充其量就是多了一个类似于flask框架或者diango框架的web框架,并且还可能会存在一大堆问题,这并没有什么意义。
回到我们的出发点,不依托任何web框架程序从零开始使用最原生的编程接口去实现一个HTTP服务器的目的在于让读者能够了解HTTP协议以及web开发的基本知识,这就像不收些servelet就很难去了解java中的struts/spring等框架。因此,如果有机会,还是推荐大家使用自己擅长的一门语言去实现一下本文中的动态网站,相信会对你的后端开发能力有积极影响!