近来一个多星期一直在学习py的web编程,从零开始,短暂时间接受的很多知识都需要消化吸收,所以在这里把这个过程梳理一遍,尽量用自己的语言去描述这些知识点。
首先是web编程的必备知识:HTTP协议。超文本传输协议(HTTP),是一种通信协议,按照定义来直接去看容易一头雾水,但其实只需要了解:web服务器和客户端之间交流,必须要遵守统一的规矩,不然就跟你说汉语我说英文一样,互相不知对方在说什么。这个统一的规矩或者格式就是HTTP协议
而服务器和客户端之间的通信方式简而言之就是,客户端给服务器发了一个请求(request),服务器要根据这个request的内容来返回客户端要的东西(response),其他要学习的一切东西都是将这个过程变得更加细化和完备的过程。
从我们平时上网的过程来看,发生了如下的事情:
首先,我们在地址栏里输入了一个url: https://movie.douban.com:443/top250?start=25&filter=
它可以被解析分解为如下的部分:
https
这是我们指定的通信协议,通常有http/https。https是http协议的安全版本,是加密的。
movie.douban.com
这是服务器的主机ip地址(但是其实我们一般看见的都是域名,因为ip地址不好记,所以拿域名指代它。我们输入域名后,电脑会自动到一个叫DNS服务器的地方去查这个域名对应的ip地址)
443
这是指定的服务器端口,与host部分用冒号:分开。http默认80端口,https默认443端口,默认的端口一般不用填写
/top250
这是路径path,指定的是在这个服务器上你需要的文件存放的位置,跟电脑里文件夹的路径是一个道理
start=25&filter=
这是url里传的参数,与path用问号?分隔,它内部的每个参数之间用&符号分隔。(start=25,filter=这样的一对一对的就是“属性=参数”这样的格式,url里传的参数都是用的GET方法,之后会讲到)
嗯。。现在浏览器拿到了我输入的url,它就会解析(解析过程之后讲)我给的这个url,知道了我要拿到指定位置文件的需求。就会给服务器发一个request(是二进制字符串):
’GET /top250?start=25&filter= HTTP/1.1\r\nhost:movie.douban.com\r\n\r\n’
其实它是这样的:
GET /top250?start=25&filter= HTTP/1.1
Host: movie.douban.com
看见的这两行是request请求最基本的部分叫请求行,其实浏览器实际发送request的还有很多东西(
而以上的这些请求行和头部,其实都在上边那个request二进制字符串里,在最后的那\r\n\r\n之前 。请求行和头部完了之后,会有一个\r\n\r\n,表示这里要空一行出来。之后,会再跟上一块内容,叫body。body里存的是通过POST方法提交的参数,这个之后会讲。
以上就是request的全部内容,浏览器把这个request发给了服务器,服务器接收到request,然后拿去解析(解析过程之后讲),解析完之后按照request里的要求,拿出相应的数据,用html模板装好(静态页面直接返回指定文件就好),这一部分就会成为response的body体。response和request的结构一样,都是由 请求行/响应行 + header + 空行 + body组成的。
服务器回复的response如下:
这是响应行,后边还有其他一大堆东西(
# socket.socket类是socket连接的主体对象,客户端和服务器之间两个互相连通的socket是对等的,它类似于一个中枢,信息交换都要通过它
s = socket.socket()
# 因为下面的两个参数是默认值 所以可以不写
# s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
request = 'GET {} HTTP/1.1\r\nhost:{}\r\n\r\n'.format(path, host)
# 浏览器和服务器之间发送的信息是以二进制bytes格式存在的,所以发送前要先将str格式的request转化为bytes
encoding = 'utf-8'
s.send(request.encode(encoding))
s = socket.socket()
s.bind((host, port))
s.listen(3)
# 下边这一步可能会阻塞,这一步对应的是客户端发出的s.connect((host, port))连接请求,这一步完成之后,客户端服务器的通信就已经建立,接下来就可以互相发送request和response等数据了
connection, address = s.accept()
request = connection.recv(1024)
response = b'Hello World!
'
connection.sendall(response)
connection.close()
摘要:listen函数使用主动连接套接口变为被连接套接口,使得一个进程可以接受其它进程的请求,从而成为一个服务器进程。在TCP服务器编程中listen函数把进程变为一个服务器,并指定相应的套接字变为被动连接。
listen函数在一般在调用bind之后-调用accept之前调用,它的函数原型是:
#include<sys/socket.h>int listen(int sockfd, int backlog)
返回:0──成功, -1──失败
当调用listen之后,服务器进程就可以调用accept来接受一个外来的请求。关于accept更的信息,请接着关注本系统文章。
}def parsed_url(url):
# 检查协议
protocol = 'http'
if url[:7] == 'http://':
u = url.split('://')[1]
elif url[:8] == 'https://':
protocol = 'https'
u = url.split('://')[1]
else:
# '://' 定位 然后取第一个 / 的位置来切片
u = url
# 检查默认 path
i = u.find('/')
if i == -1:
host = u
path = '/'
else:
host = u[:i]
path = u[i:]
# 检查端口
port_dict = {
'http': 80,
'https': 443,
}
port = port_dict[protocol]
if host.find(':') != -1:
h = host.split(':')
host = h[0]
port = int(h[1])
# print(protocol, host, path, port)
return protocol, host, port, path
def socket_by_protocol(protocol):
if protocol == 'http':
s = socket.socket()
else:
s = ssl.wrap_socket(socket.socket())
return s
def response_by_socket(s):
response = b''
buffer_size = 1024
while True:
r = s.recv(buffer_size)
response += r
if len(r) == 0:
break
return response
def parsed_response(r):
header, body = r.split('\r\n\r\n', 1)
h = header.split('\r\n')
status_code = h[0].split()[1]
status_code = int(status_code)
headers = {}
for line in h[1:]:
k, v = line.split(': ')
headers[k] = v
return status_code, headers, body
# 复杂的逻辑全部封装成函数
def get(url):
# 首先是浏览器方面接受到我们输入的url,通过parsed_url函数进行解析
protocol, host, port, path = parsed_url(url)
# 解析完之后根据拿到的protocol是http还是https,来判断连接方式是否加密,继而判断是用socket.socket()还是ssl.wrap_socket(socket.socket()) 。ps:用https 的 socket 连接需要 import ssl 并且使用 s = ssl.wrap_socket(socket.socket()) 来初始化
s = socket_by_protocol(protocol)
# 通过从url中拿到的host和port来向服务器发出连接请求,正常情况下服务器会接受请求,建立两者连接
s.connect((host, port))
# 通过通过拿到的path和host,组成request,正式告诉服务器我需要什么,豆瓣电影的这个例子中,path还包含着两个参数start=25和filter=
request = 'GET {} HTTP/1.1\r\nhost:{}\r\n\r\n'.format(path, host)
# request和response都是bytes格式,发送前需要转换
encoding = 'utf-8'
s.send(request.encode(encoding))
# 这里是服务器的工作时间,它会接收到request并解析需求,拿出对应数据组成response返回给可用户端,这个过程在后边会讲
# 客户端拿到了response
response = response_by_socket(s)
# response是bytes格式,解个码先
r = response.decode(encoding)
# 客户端通过parse_response()函数解析response,拿出response的状态码,头部,和body
status_code, headers, body = parsed_response(r)
# 这里是豆瓣电影这个例子比较特殊的一点,它这个网站是加密的。这个步骤的意思是,我们一般人不知道它是加密的https,仍然填写的为http,那么服务器遇到这种情况会返回一个301的状态码,并且在返回的header里会有一个Location: xxxxxxx的条目告诉浏览器应该去访问这个xxxxx地址
if status_code == 301:
url = headers['Location']
return get(url)
return status_code, headers, body
def main():
url = 'http://movie.douban.com/top250?start=25&filter='
status_code, headers, body = get(url)
print(status_code, headers, body)
# 以下 test 开头的函数是单元测试
# parsed_url() 函数很容易出错, 所以我们写测试函数来运行看检测是否正确运行
def test_parsed_url():
http = 'http'
https = 'https'
host = 'g.cn'
path = '/'
test_items = [
('http://g.cn', (http, host, 80, path)),
('http://g.cn/', (http, host, 80, path)),
('http://g.cn:90', (http, host, 90, path)),
('http://g.cn:90/', (http, host, 90, path)),
#
('https://g.cn', (https, host, 443, path)),
('https://g.cn:233/', (https, host, 233, path)),
]
for t in test_items:
url, expected = t
u = parsed_url(url)
# assert 是一个语句, 名字叫 断言
# 如果断言成功, 条件成立, 则通过测试, 否则为测试失败, 中断程序报错
assert u == expected, "parsed_url error, {} || {} || {}".format(url, u, expected)
# 测试是否能正确解析响应
def test_parsed_response():
response = 'HTTP/1.1 301 Moved Permanently\r\n' \
'Content-Type: text/html\r\n' \
'Location: https://movie.douban.com/top250\r\n' \
'Content-Length: 178\r\n\r\n' \
'test body'
status_code, header, body = parsed_response(response)
assert status_code == 301
assert len(list(header.keys())) == 3
assert body == 'test body'
# 测试是否能正确处理 HTTP 和 HTTPS
def test_get():
urls = [
'http://movie.douban.com/top250',
'https://movie.douban.com/top250',
]
for u in urls:
get(u)
def test():
test_parsed_url()
test_get()
test_parsed_response()
if __name__ == '__main__':
# test()
main()
留言板项目
Hello World
留言列表
{}
import socket
import time
import urllib.parse
import json
# 根路径下的视图函数,返回hello world和一个动图
def index():
html = b'HTTP/1.x 200 OK\r\nContent-Type: text/html\r\n\r\nHello World
'
return html
# /doge.gif 路径下的视图函数,返回一个动图
def image():
with open('doge.gif', 'rb') as f:
header = b'HTTP/1.x 200 OK\r\nContent-Type: image/gif\r\n\r\n'
img = header + f.read()
return img
# /time 路径下的视图函数,返回一个时间戳
def time_response(query):
html = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nTime: {}
{}'.format(time.time(), query)
return html.encode('utf-8')
# 这是/message对应的视图函数需要用到的存放已提交数据的处理函数
def save(msgs):
with open('db.db', 'a+') as f:
f.write(json.dumps(msgs))
# 载入已提交数据的函数
def load():
try:
with open('db.db', 'r') as f:
return json.loads(f.read())
except:
return []
# 读取html模板
def template_from(name):
with open(name, 'r', encoding='utf-8') as f:
return f.read()
# 这里我们完成了 响应行 + header + body 这个结构的拼装
def response_with(body='', response_header=None, http_header=None):
# html = 'HTTP/1.x 200 OK\r\n' \
# 'Content-Type: text/html\r\n\r\n' \
# 'Hello World
留言列表{}
{}'.format(msg, form)
h = 'HTTP /1.1 200 OK'
header_dict = {
'Content-Type': 'text/html'
}
header = '\r\n'.join(['{}: {}'.
format(k, v) for k, v in header_dict.items()])
response = h + '\r\n' + header + '\r\n\r\n' + body
return response
# 这是/message对应的视图函数,也是这个项目的核心,其他视图函数只是把现成的文件按照需求返回给客户端就行,留言板则是一个完整的过程:
# 接受请求--->解析请求--->拿出参数--->参数用模板装起来--->与 响应头 和 header 共同组成response发回客户端--->客户端解析并显示
messages = load()
# body默认为空,在GET方法时不会有body传入,POST时才会有,此时它们已经被解析为字典
def message(query, body={}):
print("msg query", query)
print("body, update, ", body)
# 这一步很关键,之前GET方法和POST方法传递参数的路径一直是分开的,大家都是参数,并没有别的什么不同,到了这一步二者终于合并起来
query.update(body)
# 拿到html的form属性名称对应的参数,如果没有该参数,就返回空字符串,就是应对上边豆瓣的例子中'start=25&filter='这样的参数的
m = query.get('neirong', '')
m = urllib.parse.unquote(m)
if len(m) > 0:
messages.append(m)
save(messages)
# 这是一个html分割线的标签,让每一条留言之间能够分开
msg = '
'.join(messages)
# HTTP 响应头
# HTTP 头
# HTTP body
# 载入 模板
form = template_from('message.html')
# 这里需要说明的一点是,我们这个留言板的例子比较简陋,message的html模板其实并不是真正的模板,是写死的,我们这边是用字符串的format格式化输出
# 模拟了一下模板的功能,在模板文件里预留了{},这边读取html文件后打开,把整个html页面视作一个字符串,在其后用format插入了组装好的msg参数
html = response_with(form.format(msg,))
return html.encode('utf-8')
# 这就是用来解析参数的函数,返回结果为一个字典
def parsed_arguments(s):
d = {}
if len(s) < 1:
return d
items = s.split('&')
# ['neirong=nihao']
print('items, ', items, len(items))
for i in items:
k, v = i.split('=')
d[k] = v
return d
# 这是一个在后端相当于调度中枢的函数,从request拿到的path会在这里找到对应的视图函数,且不论是GET方法传的query还是POST方法传的body
# 都会在找到对应额视图函数前被解析成字典。需要再次强调的是,当我们分别用GET和POST方法提交同一个参数'nihao'时,它们的传递路径是不同的:
# GET方法提交,参数会跟在path后边,如127.0.0.1:3004/message?neirong=nihao,如有更多参数,参数间会以 & 分隔,最终会被解析成{'neirong':'nihao'}这样的字典
# POST方法提交,参数在url里看不见,它会在body体里以 neirong=nihao 的形式存在,其他特征同GET方法。
# 所以我们可以知道,两者的形态是一致的,区别仅仅在于传递时的位置不一样,所以我们可以用统一的解析函数来完成对它们的解析:parsed_arguments()
def response_for_path(path, body):
# /message?neirong=nihao
# path = /message
# query = { 'neirong': 'nihao' }
# 解析 query 参数
query = {}
if '?' in path:
path, query = path.split('?')
# neirong=nihao
query = parsed_arguments(query)
# 解析 body 中的 POST 传过来的参数
form = parsed_arguments(body)
r = {
'/': index(),
'/doge.gif': image(),
'/time': time_response(query),
'/message': message(query, form),
}
page404 = b'HTTP/1.x 404 NOT FOUNT\r\n\r\nNOT FOUND
'
# 这一步的意思就是,根据path拿到对应的视图函数,找不着的话就返回404
return r.get(path, page404)
# 整个过程从这里开始梳理
host = ''
port = 3004
# 初始化socket
s = socket.socket()
#绑定主机名和端口
s.bind((host, port))
while True:
# 监听发往指定主机名和端口的连接请求
s.listen(5)
# 接受发来的请求,得到套接字,与客户端建立连接
connection, address = s.accept()
print('connection, address debug',connection, address)
# 接受request
request = connection.recv(1024)
# 发来的request为bytes格式
request = request.decode('utf-8')
print("debug request, ", request,'debug request finished')
# 判断客户端是否发送空请求,如果是直接关掉这个连接,不然会一直卡住
if len(request) == 0:
connection.close()
continue
# 从这一步开始解析发来的request,我们在127.0.0.1:3004/message下打开留言板,输入内容用get方法提交form表单。我们可以在GET方法对应的form
# 的HTML代码中看到,提交内容的对应属性是'neirong',我们填入一个参数'nihao',那么GET方法就会把neirong=nihao这个数据接在path(/message)后提交给服务器
# 即,我我们在留言板里用GET方法对应的form提交'nihao'这个参数的行为,其实等于直接在地址栏里输入127.0.0.1:3004/message?neirong=nihao这样。
# ?是规定的GET方法传参的书写方式,拿它来分隔path和参数,如果有多个参数,'属性=参数'这样的一对一对的会用 & 符号连接起来
# 好了,现在服务器收到的request的第一行 在上边decode前是这样的:GET /message?neirong=%E5%BE%88%E5%A5%BD HTTP/1.1
# decode后是这样的 :GET /message?neirong=nihao HTTP/1.1\r\n 就可以.split()默认的空格来把request分开,取出包含参数的path
path = request.split()[1]
print('ip and request, {}\n{}'.format(address, request))
# 这里提一下GET方法和POST方法的区别:
# 基础的区别就是:get在头部传递,可以在url里显示,post在body里边传递,url里看不见。用法的区别是:get一般是用来传参,post是用来提交数据
# 初始化一个body
body = ''
# 以response中间的空行为分界,把response拆开,拿出body
r = request.split('\r\n\r\n', 1)
print('debug, r', len(r), r[1])
if len(r) > 1:
body = r[1]
print('body ', body)
# 根据拿到的path和body,用response_for_path函数来处理,找到path对应的视图函数,如果找不到,就会返回404,详见前边107行函数代码
response = response_for_path(path, body)
print('response, ', path, response)
# bug记录:这里本来写的是print('response, ', path, response.decode('utf-8')) 本意为打印出response,但是这里会出出现一个问题,即需要发送doge.gif时
# response里边是包含了这个动图的,这个动图在在index()视图函数里是在html标签里链接过来的,会被整个response前的b''转化为二进制
# 在image()视图函数里也是是以rb二进制读格式打开的。且与b''形式的header拼接在一起了,这里再试图decode会将图片也一起decode()成字符串,
# 这显然是不可以的,所以会出错:
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf6 in position 54: invalid start byte
# 然后还会有一些其他严重后果:
# (1)当path = '/'时,只会导致index()视图函数的'标签里链接挂掉,显示结果是'hello world'正常显示,动图会裂掉
# (2)当path = '/doge.gif'时,则会导致链接直接挂掉,代码模拟的服务器程序直接终止,比404还难受。
connection.sendall(response)
connection.close()
留言板
留言
{% for m in msgs %}
{{ m.name }} : {{ m.content }}
{% endfor %}
# encoding: utf-8
from flask import Flask
from flask import render_template
from flask import request
app = Flask(__name__)
# @app.route是一个装饰去,下一行紧跟着的函数是处理这个请求的视图函数,其参数是一个 path 路径,
@app.route('/')
def hello_world():
name = 'gua'
greeting = '你好 {}!
'.format(name)
return greeting
messages = []
# 这是访问 /message 的请求
# methods 参数指定了它能处理的请求方法, 默认是 GET (上面的hello_world就是用的默认)
@app.route('/message', methods=['GET', 'POST'])
def message_view():
# 打印请求的方法 GET 或者 POST
print('请求方法', request.method)
# request.args 是 flask 保存 URL 中的参数的属性
# 访问 http://127.0.0.1:5000/message?a=1
# 会打印如下输出 (ImmutableMultiDict 是 flask 的自定义类型, 意思是不可以改变的字典)
# request ImmutableMultiDict([('a', '1')])
print('request, query 参数', request.args)
args = request.args
# 字典的两种用法:
# d['name'] 这样用的话 如果 name 不存在 会产生异常
# d.get('name', 默认值) 这样的话,如果 name 不存在,则返回默认值
name_1 = args.get('name', '')
content_1 = args.get('content', '')
msg_1 = {
'name': name_1,
'content': content_1,
}
messages.append(msg_1)
print(messages)
# request.args.get('key')
# args.name
# args.name, args.height
# request.form 是 flask 保存 POST 请求的表单数据的属性
print('request POST 的 form 表单数据\n', request.form)
form = request.form
name_2 = form.get('name', '')
content_2 = form.get('content', '')
msg_2 = {
'name': name_2,
'content': content_2,
}
messages.append(msg_2)
# render_template 是一个 flask 内置函数,它的作用是读取并返回 templates 文件夹中的模板文件
# 下边这个msgs变量是一个很关键的节点,py代码收到我们填在表单里的数据,解析完成之后存在messages列表里,
# 然后通过msgs这个连通模板和代码的节点一样的变量,把数据传到模板里,再通过模板把数据表现出来就是我们看到的页面了
# 这个msgs变量是从哪冒出来的呢?我们可以在上边的html模板文件里找到它。
return render_template('message.html', msgs=messages)
# app.run() 开始运行服务器,默认端口是 5000,所以我们访问这个网址就可以打开网站了,http://127.0.0.1:5000/
if __name__ == '__main__':
# debug 模式可以自动加载你对代码的变动, 所以不用重启程序
debug = True
app.run(debug=debug)
至此,关于从客户端到服务器再到客户端,我们传递参数获取页面的整个过程的基本原理已经从底层到框架完全展示了一遍,虽然例子很简单,但是核心很完整。
整个后端程序其实就是,接受从网传来的请求,提交的数据等其他一系列信息,然后在后端代码里解析,然后根据不同的需要,有的要存数据库,有的要表现出来,还有的要从数据库里拿东西 ,然后把需要表达的东西组合起来放到容器里传给模板,模板按照其语法从容器里获取对应的数据,然后表现出来。
只不过规模和复杂程度比这几个例子要大很多很多倍,但是掌握了原理,再去理解一些细节的东西,就很清楚了。