第3章 AIOHTTP 第3章AIOHTTP AIOHTTP是一个基于asyncio开发的Python Web开发框架,其稳定、强大、支持高并发。目前已实现的功能有HTTP服务器、HTTP客户端、WebSocket服务器,以及WebSocket客户端,服务功能还实现了路由、模板、会话(Session)等所有Web开发必备的功能模块。 5min 3.1创建异步Web服务器 AIOHTTP的使用非常简单,只需创建一个aiohttp.web.Application对象,并启动它便可完成一个Web服务器的最基本功能,代码如下: from aiohttp import web #创建一个服务器应用 app = web.Application() #启动服务器应用 web.run_app(app) 启动前需要使用命令pip install aiohttp安装第三方依赖项 aiohttp。因为该服务器暂未编写任何页面请求处理逻辑,所以对其任何页面的访问都将出现404代码(找不到资源),如图31所示。 图31空服务器示例 若要让其可以处理请求,则需要创建一个请求处理函数,该函数是一个异步函数,接收的一个传入参数是请求对象(aiohttp.web.Request),返回一个响应对象(aiohttp.web.Respone),并将该函数映射到服务器指定的路径上,代码如下: """ 第3章/aiohttp_simple_server/server.py """ from aiohttp import web #请求处理函数,接收一个参数为请求对象,返回信息将被传到浏览器端 async def hello(request: web.Request): return web.Response(text="Hello, world") #创建一个服务器应用 app = web.Application() #将 hello 处理函数映射到网站根路径 / app.router.add_get("/", hello) #启动服务器应用 web.run_app(app) 图32AIOHTTP Hello,world示例 在该示例中,将hello函数映射到网站根路径上,这时访问网站主页,将看到页面中显示信息 Hello, world,结果如图32所示。 接下来为该服务器配置容器化支持,在项目目录下创建 Dockerfile 文件,在其中输入如下代码: #第3章/aiohttp_simple_server/Dockerfile FROM python:3.8-slim RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple aiohttp 脚本指定该容器基于 python:3.8slim,这是一个功能完整但轻量级的 Linux 环境,在第2行使用清华镜像安装项目依赖项 aiohttp。 在项目目录下创建一个 dockercompose.yml 文件,在其中输入如下代码: #第3章/aiohttp_simple_server/docker-compose.yml version: "3" services: web: build: . ports: - "8080:8080" #如果指定该容器,则会在意外停止后自动重启 restart: always #配置路径映射,将当前目录映射到容器中的 /opt 目录 volumes: - ".:/opt/" #指定容器中应用程序的工作目录为 /opt working_dir: "/opt/" #配置容器启动时执行的命令,这里我们启动服务器 command: python3 ./server.py 所有工作已经准备就绪,下面在终端里键入dockercompose upd以自动构建镜像并启动,如图33所示。 图33启动Docker服务 4min 3.2路由 路由(route)是一种将路径映射到处理函数的机制,可以一个一个地单独配置,示例代码如下: """ 第3章/aiohttp_route/config_route_one_by_one.py """ from aiohttp import web async def hello(request: web.Request): return web.Response(text="Hello, world") app = web.Application() app.router.add_get("/", hello) web.run_app(app) 可以批量配置,代码如下: """ 第3章/aiohttp_route/config_routes.py """ from aiohttp import web async def hello(request: web.Request): return web.Response(text="Hello, world") async def users(req): return web.Response(text="All users are here") app = web.Application() app.add_routes([ web.get("/", hello), web.get("/users", users) ]) web.run_app(app) 也可以通过装饰器进行配置,代码如下: """ 第3章/aiohttp_route/config_route_with_decorator.py """ from aiohttp import web routes = web.RouteTableDef() @routes.get('/') async def hello(request): return web.Response(text="Hello, world") app = web.Application() app.add_routes(routes) web.run_app(app) 还可以通过路由配置接收其他的HTTP方法,代码如下: @routes.post('/save_user_info') async def save_user_info(req): return web.Response(text="Saved") 如果仅仅支持配置路径映射,那么适用的场景就太少了,AIOHTTP的路由还支持路径参数。例如,在实现一个博客类的网站时,通常会根据文章的id来定位该篇文章,为了对搜索引擎更友好,我们还会将文章的地址静态化,将类似http://xxx.com?p=12这样的路径改为http://xxx.com/p/12,这就是服务器端开发常用的伪静态技术。代码如下: """ 第3章/aiohttp_route/pages.py """ from aiohttp import web routes = web.RouteTableDef() @routes.get('/p/{page_id}') async def page(req: web.Request): return web.Response(text=f"Page id is {req.match_info['page_id']}") @routes.get('/') async def hello(request): return web.Response(text="Hello, world") app = web.Application() app.add_routes(routes) web.run_app(app) 图34解析路径参数 访问页面结果如图34所示。 此外还可以使用正则表达式对路径中的参数进行约束,如果我们想让page_id只接收数字类型的参数,则可对代码进行修改,代码如下: @routes.get(r'/p/{page_id:\d+}') async def page(req: web.Request): return web.Response(text=f"Page id is {req.match_info['page_id']}") 本节中我们一共写了4个单独的示例文件,如图35所示。 接下来我们打算用一个 dockercompose 配置文件一键启动所有示例,并运行在不同的端口上,此时需要在项目目录中创建文件 Dockerfile 和 dockercompose.yml,文件结构如图36所示。 在Dockerfile中输入代码如下: #第3章/aiohttp_route/Dockerfile FROM python:3.8-slim RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple aiohttp 图35所有示例文件 图36Docker配置文件所在位置 在dockercompose.yml 中输入代码如下: #第3章/aiohttp_route/docker-compose.yml version: "3" services: config_route_one_by_one: build: . ports: - "8080:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./config_route_one_by_one.py config_route_with_decorator: build: . ports: - "8081:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./config_route_with_decorator.py config_routes: build: . ports: - "8082:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./config_routes.py pages: build: . ports: - "8083:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./pages.py 在该配置文件中创建了4个服务,将端口分别映射在本机的8080、8081、8082、8083端口,启动后可以分别通过这4个端口访问4个不同的示例。 3min 3.3静态文件处理 一个完整的网站除了服务器应用之外,一定会存在很多静态文件,例如图片、js脚本等。 在大型网站的部署上,静态资源会单独使用CDN进行管理,所以应用层可以不用考虑这个问题,直接通过CDN去引用静态资源文件即可。 在中小型网站的部署上,传统阻塞型IO编程模式下,由于应用服务器并不擅长处理高并发请求,一般来说会把应用服务器与基础HTTP服务器(如: Apache或者Nginx)集成并把静态文件的处理工作交给基础HTTP服务器。但在异步IO编程模型下,完全可以直接使用Python来处理静态资源请求。 AIOHTTP已经内置了静态资源文件处理的功能,因此可以直接使用,非常方便,代码如下: """ 第3章/aiohttp_static_resource/server.py """ from aiohttp import web import os routes = web.RouteTableDef() @routes.get('/') async def hello(request): return web.Response(text="Hello, world") app = web.Application() app.add_routes(routes) #配置一个静态文件目录 app.router.add_static( "/static", os.path.join(os.path.dirname(__file__), 'static') ) web.run_app(app) 配置一个静态文件目录,通过网站的 /static 路径进行访问,对应的文件都配置在项目目录下的 static 目录中,项目结构如图37所示。 图37项目文件结构 图38静态文件访问结果 启动服务器后可通过浏览器访问地址http://127.0.0.1:8080/static/index.html,结果如图38所示。 与该项目相关的 Dockerfile 文件内容如下: #第3章/aiohttp_static_resource/Dockerfile FROM python:3.8-slim RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple aiohttp 与该项目相关的 dockercompose.yml 文件内容如下: #第3章/aiohttp_static_resource/docker-compose.yml version: "3" services: web: build: . ports: - "8080:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./server.py 9min 3.4模板渲染 如果每个页面都只是直接使用字符串拼接而成,那么实际的工作量也是很大的,所以人们发明了模板技术,用现成的HTML文件经过处理(渲染),并将处理之后的结果返回给浏览器端就可以大大节省开发成本。 在AIOHTTP服务端应用开发时,可配套使用 aiohttp_jinja2 库实现模板渲染,通过命令pip install aiohttp_jinja2进行安装。首先需要指定模板文件的根目录,并将模板系统与服务端应用集成,代码如下: aiohttp_jinja2.setup( app, loader=jinja2.FileSystemLoader( os.path.join(os.path.dirname(__file__), "tpls") ) ) 然后在需要渲染的请求处理函数前添加装饰器以指定要渲染的模板的路径,代码如下: @routes.get('/') @aiohttp_jinja2.template("index.html") async def hello(request): return dict() 完整的代码如下: """ 第3章/aiohttp_template/server.py """ from aiohttp import web import jinja2 import aiohttp_jinja2 import os app = web.Application() aiohttp_jinja2.setup( app, loader=jinja2.FileSystemLoader( os.path.join(os.path.dirname(__file__), "tpls") ) ) routes = web.RouteTableDef() @routes.get('/') @aiohttp_jinja2.template("index.html") async def hello(request): return dict() app.add_routes(routes) web.run_app(app) 项目文件结构如图39所示。 其中模板文件 index.html 的内容如下: Title Hello World 启动服务器后,最终浏览器运行结果如图310所示。 图39项目文件结构 图310模板渲染结果 如果仅仅读取静态文件并且呈现出来,那么模板技术显得没什么用处。而实际上,模板技术能够允许我们从后端传参数给模板并最终由模板引擎渲染成页面,例如从数据库中读取用户的信息并传给模板。 若要从Python代码中向模板文件传参数,只需请求处理函数返回一个字典,代码如下: @routes.get('/') @aiohttp_jinja2.template("index.html") async def hello(request): return dict(name="小云", age=20) 模板文件中使用参数的代码如下: Title 最终页面渲染的结果如图311所示。 此外,为了减少不必要的重复性工作,模板还支持继承、循环、条件渲染等。下面我们以常规网站页面为例,深入了解更多与模板引擎使用相关的知识。 实现效果如图312所示。 图311向模板传参数 图312通用网站框架结构 主页按钮背景和页面标题会随着页面的变化而变化,在博客页面还渲染了一个列表,如图313所示。 图313博客页面示例 在该项目的实现过程中,还使用了前端界面框架Bootstrap,接下来一步一步实现这个常规网站的基本框架。 使用 PyCharm 创建一个新的项目,并命名为 aiohttp_template_pages,在终端中使用npm init命令初始化 npm 环境(使用该命令之前需要先安装 nodejs,到https://nodejs.org下载并安装)。所有选项均选择默认即可(在所有遇到需要输入信息时单击回车键直到完成),如图314所示。 图314初始化npm项目 命令执行结束后将在项目目录下创建一个 package.json 文件,如图315所示。 通过npm命令npm installsave jquery bootstrapregistry=https://registry.npm.taobao.org安装前端依赖项 jQuery 和Bootstrap,在执行该命令时可指定使用淘宝的仓库,这样速度更快,执行完毕后,将在node_modules 目录下新增bootstrap和jquery两个目录,如图316所示。 图315package.json文件 图316安装的前端依赖项 图317模板文件 建立tpls目录,用于存放模板文件,在其中创建layout.html、index.html、news.html、blog.html 4个文件,结构如图317所示。 其中blog.html、index.html、news.html 3个模板分别对应3个页面,这3个页面的框架布局是一样的,所以我们将框架抽象出来并创建一个父级模板 layout.html。 其中模板 layout.html 文件的代码如下: {{ title }}
{% block body %} {% endblock %}
模板index.html的代码如下: {% extends "layout.html" %} {% block body %} 这是首页 {% endblock %} 接下来创建 server.py 文件,用于配置服务器,其代码如下: """ 第3章/aiohttp_template_pages/server.py """ from aiohttp import web import jinja2 import aiohttp_jinja2 import os app = web.Application() aiohttp_jinja2.setup( app, loader=jinja2.FileSystemLoader( os.path.join(os.path.dirname(__file__), "tpls") ) ) routes = web.RouteTableDef() @routes.get('/') @aiohttp_jinja2.template("index.html") async def index(request: web.Request): return dict(title="首页", request=request) @routes.get('/news') @aiohttp_jinja2.template("news.html") async def news(request: web.Request): return dict(title="动态", request=request) @routes.get('/blog') @aiohttp_jinja2.template("blog.html") async def blog(request: web.Request): return dict( title="博客", request=request, #向模板传递数组 posts=[ {"title": "文章1", "content": "内容1"}, {"title": "文章2", "content": "内容2"} ] ) app.add_routes(routes) app.router.add_static( "/node_modules", os.path.join(os.path.dirname(__file__), "node_modules") ) web.run_app(app) 其中/blog页面向模板传递了数组,则对应的模板代码如下: {% extends "layout.html" %} {% block body %} 这是博客页面
{% for p in posts%}
{{ p.title }}
{{ p.content }}
{% endfor %}
{% endblock %} 与该项目对应的Dockerfile文件代码如下: #第3章/aiohttp_template_pages/Dockerfile FROM python:3.8-slim RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple aiohttp RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple aiohttp_jinja2 与该项目对应的 dockercompose.yml 代码如下: #第3章/aiohttp_template_pages/docker-compose.yml version: "3" services: web: build: . ports: - "8080:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./server.py 6min 3.5处理表单提交 处理表单提交是一个Web服务器最基本的功能,AIOHTTP也提供了强大的表单处理功能,首先看一下效果,如图318所示。 图318表单页面渲染效果 在表单中输入信息后提交则可呈现结果页面,如图319所示。 图319表单提交后呈现结果 这个项目使用了Bootstrap 来搭建前端页面,同样的使用流程不再赘述,这里只讲解最核心的处理逻辑。 在首页中编写一个表单,代码如下: {% extends "layout.html" %} {% block body %}
登录
{% endblock %} 该模板所继承的父级模板源码如下: {{ title }} {% block body %} {% endblock %} 与/login页面对应的处理函数源码如下: @routes.post("/login") @aiohttp_jinja2.template("login.html") async def login(request: web.Request): #读取表单数据 user = await request.post() return dict(title="登录结果", user=user) 通过 request.post 函数解析前端以 post 方式传来的数据,并将数据传给模板,由模板进行渲染,与 /login 对应的模板文件源码如下: {% extends "layout.html" %} {% block body %}
接收的数据
姓名 {{ user.name }}
年龄 {{ user.age }}
{% endblock %} 6min 3.6文件上传 本节计划实现一个上传图片后将此图片呈现在页面中的过程。 上传表单如图320所示。 图320文件上传表单 选择一张图片,上传后效果如图321所示。 图321文件上传后的效果 上传文件表单的源码如下: {% extends "layout.html" %} {% block body %}
上传图片
{% endblock %} 则对应的服务端处理逻辑源码如下: """ 第3章/aiohttp_upload_file/server.py """ from aiohttp import web import os, datetime, aiofile, aiohttp_jinja2, jinja2 APP_ROOT = os.path.dirname(__file__) app = web.Application() aiohttp_jinja2.setup( app, loader=jinja2.FileSystemLoader( os.path.join(os.path.dirname(__file__), "tpls") ) ) routes = web.RouteTableDef() @routes.get('/') @aiohttp_jinja2.template("index.html") async def index(request: web.Request): return dict(title="首页") @routes.post("/upload") @aiohttp_jinja2.template("upload.html") async def login(request: web.Request): #解析前端上传的数据 data = await request.post() #根据表单字段名从上传数据中取得文件对象,与对应 file_object = data['file'] #用当前时间当作要保存的文件名 file_name = f"{datetime.datetime.now().timestamp()}" #以写入二进制数据(wb)的方式打开文件 file = await aiofile.open_async( os.path.join(APP_ROOT, "uploads", file_name), "wb" ) #将数据读取出来并保存到目标文件中 await file.write(file_object.file.read()) #关闭文件IO await file.close() #将该文件在网站中的路径传给模板 return dict(title="上传结果", file_path=f"/uploads/{file_name}") app.add_routes(routes) app.router.add_static("/node_modules", os.path.join(APP_ROOT, "node_modules")) app.router.add_static("/uploads", os.path.join(APP_ROOT, "uploads")) web.run_app(app) 实现的基本逻辑是先解析上传的数据,从数据库获得文件对象,并将文件对象的内容读取出来,再以写入二进制数据的方式保存到指定的文件中,最后将保存后的文件在网站中的路径传给模板,由模板渲染出来。这里我们使用当前时间对文件进行重命名,目的是保证上传的文件名不重复,在实际使用中,这种方式并不是最可靠的,在高并发的情况下,有可能存在同一时间上传多个文件,所以在实际开发工作中,为了保证文件名的唯一性,我们可以把时间与用户id关联起来形成真正唯一的文件名。保证文件名唯一的方式还有很多种,例如使用uuid,不管使用哪种方式,我们的目标是保证文件名唯一。 在这段代码里用到了aiofile,这是在1.5节文件异步IO中所实现的库,如果还有印象可重复利用该代码,如果没有印象需要回去再看看那一节。 4min 3.7Session 会话(Session)是一种在服务器端缓存用户数据的机制,主要用于保存用户的登录信息。如果要在AIOHTTP中支持Session,需要第三方库 aiohttp_session,用命令pip install aiohttp_session进行安装。 在使用Session功能之前需要先为应用配置Session,代码如下: aiohttp_session.setup(app, aiohttp_session.SimpleCookieStorage()) 然后在请求处理函数中使用Session的代码如下: session = await aiohttp_session.get_session(request) 可运行的完整示例代码如下: """ 第3章/aiohttp_session_counter/server.py """ from aiohttp import web import aiohttp_session routes = web.RouteTableDef() @routes.get('/') async def index(request: web.Request): session = await aiohttp_session.get_session(request) session['count'] = (session['count'] if 'count' in session else 0) + 1 return web.Response(text=f"Count is {session['count']}") app = web.Application() app.add_routes(routes) aiohttp_session.setup(app, aiohttp_session.SimpleCookieStorage()) web.run_app(app) 这段程序实现了当前用户的访问计数,用户每刷新一次页面,则计数加1,如图322所示。 图322Session计数器 如果希望以加密的方式在浏览器与服务器之间传输 Session ID,则可使用 aiohttp_session.cookie_storage.EncryptedCookieStorage 进行实现,代码如下: """ 第3章/aiohttp_ssession_counter/server.py """ import base64 from aiohttp import web from aiohttp_session import setup, get_session from aiohttp_session.cookie_storage import EncryptedCookieStorage from cryptography import fernet routes = web.RouteTableDef() @routes.get('/') async def index(request: web.Request): session = await get_session(request) session['count'] = \ (session['count'] if 'count' in session else 0) + 1 return web.Response(text=f"Count is {session['count']}") app = web.Application() app.add_routes(routes) fernet_key = fernet.Fernet.generate_key() #secret_key 必须是 URL安全的、base64 编码的32字节数据 #URL安全是指不能包括 '+' 与 '/',在实际使用中会使用 '-' 代替 '+',使用'_' 代替 '/' secret_key = base64.URLsafe_b64decode(fernet_key) setup(app, EncryptedCookieStorage(secret_key)) web.run_app(app) 与该示例配套的Dockerfile内容如下: #第3章/aiohttp_ssession_counter/Dockerfile FROM python:3.8-slim RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \ aiohttp cryptography aiohttp_session 配套的 dockercompose.yml 文件内容如下: #第3章/aiohttp_ssession_counter/docker-compose.yml version: "3" services: web: build: . ports: - "8080:8080" restart: always volumes: - ".:/opt/" working_dir: "/opt/" command: python3 ./server.py 3.8HTTP客户端 AIOHTTP库实现了完整而健壮的HTTP客户端。在1.8节异步HTTP客户端中,我们已经介绍过如何实现HTTP客户端,但是HTTP的标准非常多,如果按HTTP协议去逐个实现,工作量也是非常庞大的。但如果使用AIOHTTP来作为开发框架,则可以直接使用已经实现的、功能完整的HTTP客户端。 在不使用AIOHTTP作为Web开发框架的情况下,是否也可以使用AIOHTTP的客户端库呢?当然可以,AIOHTTP的客户端库是一套功能完整的、独立实现的库,可以应用在任何Python异步编程中。 接下来使用AIOHTTP的客户端去连接第3章/aiohttp_session_counter项目,客户端代码如下: session = aiohttp.ClientSession() #创建客户端会话上下文 response = await session.get('http://127.0.0.1:8080') #连接服务器 print(f"[{time.strftime('%X')}] {await response.text()}") #读取返回数据 await session.close() #关闭会话 AIOHTTP的客户端还可以模拟Cookie机制,这意味着AIOHTTP客户端可以很方便地用于模拟登录等其他复杂的应用场景,下面代码演示了AIOHTTP客户端对服务器端会话(Session)机制的支持: """ 第3章/aiohttp_client/app.py """ import asyncio, time import aiohttp async def main(): session = aiohttp.ClientSession( #使用最简单的 Cookie 机制 cookie_jar=aiohttp.CookieJar(unsafe=True) ) for i in range(10): response = await session.get('http://127.0.0.1:8080') print(f"[{time.strftime('%X')}] {await response.text()}") #每次请求后休眠 1s await asyncio.sleep(1) await session.close() if __name__ == '__main__': asyncio.run(main()) 代码运行的结果如图323所示。 图323AIOHTTP客户端对服务器端Session的支持 在使用AIOHTTP客户端时,只需创建一个ClientSession实例,之后可以使用该实例去执行所有的请求任务,强烈推荐这种方法,如果是同时请求多个网站,则需要分别为每个网站创建单独的ClientSession实例。 3.9HTTPS支持 互联网技术已经发展了很多年,至今在世界范围内有无数的网站,今天人类的生活已经很难完全脱离互联网。在我们享受科技的同时,也担心隐私泄露等问题,作为一家网站,有义务保护用户隐私,所以今天的世界要求每一家网站都通过HTTPS来服务用户。 每一家云主机服务商都提供免费的HTTPS证书,通常有效期为一年,到期后网站维护人员应重新申请证书并进行部署。所以将网站HTTPS化并不会增加成本,作为网站的运营者和开发者,更加有义务为用户提供安全的服务。 如果网站以HTTP直接部署,则用户在访问时浏览器会提示用户这是不安全的链接,如图324所示。 用户在访问这类网站时就会明白,隐私信息可能会泄露给第三方(例如木马、监控软件等),遇到这种情况用户一般会直接离开该网站,对于网站来说,也就会遇到极大的推广障碍。如果采用的是HTTPS的安全链接,则浏览器会提示安全或者不提示,如图325所示。 图324不安全的链接 图325安全链接 在AIOHTTP框架中,配置支持HTTPS非常方便,示例代码如下: """ 第3章/aiohttps/server.py """ from aiohttp import web import ssl, os routes = web.RouteTableDef() @routes.get('/') async def index(request: web.Request): return web.Response(text="Hello World") app = web.Application() app.add_routes(routes) ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) #加载网站证书文件 ssl_context.load_cert_chain( os.path.join(os.path.dirname(__file__), "ssl", "cert.pem"), os.path.join(os.path.dirname(__file__), "ssl", "cert.key"), ) web.run_app(app, port=443, ssl_context=ssl_context) 在该示例中,使用两个文件,一个是证书文件,另一个是密钥文件,在你申请HTTPS证书完成后可以获得这两个文件。 AIOHTTP创建的HTTPS服务器默认使用端口8443,而HTTPS的默认端口号是443,所以如果想使用HTTPS默认端口(访问时域名后可省略端口号),须手动指定使用443端口。