Python异步Web编程

异步编程适用于那些频繁读写文件和频繁与服务器交互数据的任务,异步程序以非阻塞的方式执行I/O操作。这样意味着程序可以在等待客户机返回数据的同时执行其他任务,而不是无所事事的等待,浪费资源和时间。

Python和其他许多编程一样,默认不具备异步特性。所幸的是,IT行业的快速发展,技术的不断更新,是我们可以编写异步程序。近年来,对速度的要求越来越高甚至超过了硬件能力。为此,世界各地的组织联合起来发表了《反应式宣言

异步程序的非阻塞模式可以在Web应用程序的上下文中发挥显着的性能优势,有助于解决开发响应式应用程序中的问题。

Python3中加入了一些用于开发异步应用程序的强大模块,本文中将介绍一些工具,特别是与web开发相关的工具。

本文将试着开发一个基于 aiohttp 的简单反应式应用程序,根据用户给定的地理坐标,显示当前太阳系行星的天球坐标。

Python中的异步

对于一些熟悉编写传统python代码的人来说,转换到异步程序可能有些不好接受。Python中的异步程序依赖于 Coroutines(协程) ,它与event loop(事件循环)一同工作,写出的代码像是执行多个小任务的片段。 协程可以看作是在代码中有一些带点函数,这些带点函数又是控制程序回调中的上下文,除了通过上下文交换数据,这些“yield”点还可以暂停和恢复协程执行。

事件循环决定了可以在任何指定时刻运行代码块—它负责协程之间的暂停、恢复和通信。 这意味着不同协程的最终可能以不同于它们之前被安排的顺序执行。 这种不按固定顺序运行不同代码块的想法称为异步。

可以在 HTTP 请求的场景中阐述异步的重要性。设想要向服务器发大量的请求。比如,要查询一个网站,以获得指定赛季所有运动员的统计信息。

我们可以按顺序依次发出每个请求。然而,对于每个请求,可以想象到可能会花一些时间等待上一个请求被发送到服务器,且收到服务器响应。

但是有时,这些无用的花销甚至可能需要几秒钟。因为程序可能会遇到网络延迟,访问数量过多,又或者是对方服务器的速度限制等问题。

如果我们的代码可以在等待服务器响应的同时做其他事情呢?而且,如果它只在响应数据到达后才处理返回数据呢?如果我们不必等到每个单独的请求都完成之后才继续处理列表中的下一个请求,那么我们可以快速地连续发出许多请求。

具有event loop的协程就可以让我们的代码支持以这样的形式运行。

asyncio

asyncio是Python3.4版本引入的标准库,直接内置了对异步IO的支持。使用 asyncio 我们可以通过协程来完成某些任务,创建的协程(使用 asyncio 的语法 asyncio.Task 对象)只有在所有组成协程的任务完成执行后完成。

和其他异步编程语言不同,Python并不强制开发者使用语言自带的事件循环。正如在Python 3.5中async/await是如何工作的指出的,Python协程构建的一个异步API允许我们使用任何事件循环。有些项目实现了完全不同的事件循环,比如curio,或者允许为 asyncio 引入其他的事件循环策略(事件循环策略指是“在幕后”管理事件循环),比如uvloop

使用 asyncio 并行运行两个协程的代码片段,每个协程在一秒钟后打印一条消息:

1# test.py 2import asyncio 3 4async def wait_around(n, name): 5 for i in range(n): 6 print(f"{name}: iteration {i}") 7 await asyncio.sleep(1) 8 9async def main(): 10 await asyncio.gather(*[wait_around(2, "coroutine 0"), wait_around(5, "coroutine 1")]) 11 12loop = asyncio.get_event_loop() 13loop.run_until_complete(main()) 14 15 16 17jhaosMBP:Cenarius jhao$ time python test.py 18coroutine 0: iteration 0 19coroutine 1: iteration 0 20coroutine 0: iteration 1 21coroutine 1: iteration 1 22coroutine 1: iteration 2 23coroutine 1: iteration 3 24coroutine 1: iteration 4 25 26real 0m5.264s 27user 0m0.078s 28sys 0m0.043s

这段代码以异步方式大约5秒执行完毕。事件循环在遇到 asyncio.sleep 协程点时,会跳到其他代码继续执行。使用 asyncio.gather 告诉事件循环要调度两个 wait_around 实例。

asyncio.gather 接收一组“awaitables”(即协程或 asyncio.Task对象),然后返回单个 asyncio.Task对像。其只在所有组成的 tasks/coroutines 完成时才完成。最后两行是 asyncio 的标准用法,用于运行指定的协程程序,直到执行完毕。

协程和函数不同,不会在调用后立即开始执行。await 关键字是用来告诉事件循环调度执行协同程序。

如果去掉 asyncio.sleep 前面的 await。程序几乎会立即完成,因为没有告诉事件循环要执行这个协程,在本例中,使用 await 调用协程使之休眠一段时间。

在了解了Python基本的异步代码之后,下面继续讨论web开发上的异步。

安装aiohttp

aiohttp 是用于处理异步 HTTP 请求的三方库。此外,它还提供了用于web服务的组件。可以通过 pip 安装 aiohttp,它要求Python版本大于3.5.3。

pip install aiohttp

客户端:发送请求

下面的示例演示了如何使用 aiohttp 下载“baidu.com”网站的HTML内容:

1import asyncio 2import aiohttp 3 4 5async def make_request(): 6 url = "https://www.baidu.com" 7 print(f"making request to {url}") 8 async with aiohttp.ClientSession() as session: 9 async with session.get(url) as resp: 10 if resp.status == 200: 11 print(await resp.text()) 12 13loop = asyncio.get_event_loop() 14loop.run_until_complete(make_request()) 15
  • 有几点需要强调:

  • 和前面的 await asyncio.sleep 一样,要获取HTML页面的内容,必须在 resp.text() 前面使用 await 。否则程序打印出来的内容会是这样:

    making request to https://www.baidu.com <coroutine object ClientResponse.text at 0x109b8ddb0>

  • async with 是一个上下文管理器,它接收的是协程而不是函数。在这里的两处使用,是用于在内部自动关闭到服务器的连接释放资源。

  • aiohttp.ClientSession 具有和 HTTP 方法相同的方法,session.get 发送 GET 请求,session.post 发送 POST 请求。

这个例子本身并不比同步HTTP请求有多大性能优势。aiohttp 客户端真正优势在于多个请求并发:

1import asyncio 2import aiohttp 3 4 5async def make_request(session, req_n): 6 url = "https://www.baidu.com" 7 print(f"making request to {req_n} to {url}") 8 async with session.get(url) as resp: 9 if resp.status == 200: 10 print(await resp.text()) 11 12 13async def main(): 14 n_request = 100 15 async with aiohttp.ClientSession() as session: 16 await asyncio.gather(*[make_request(session, i) for i in range(n_request)]) 17 18 19loop = asyncio.get_event_loop() 20loop.run_until_complete(main()) 21

上面代码不是一个一个地发出请求,而是利用 asyncioasycio.gather 实现并发。

Web应用"行星定位"

下面将从头开始,开发一个web应用程序,报告用户所在位置上天空中行星的坐标(天象)。

我们使用Geolocation API来获取用户的当前位置。

PyEphem天象计算

一个天体的天象是指在地球上指定地点和时间观察到在天空中的位置。PyEphem是一个计算精度很高的天文历算Python库。使用 pip 安装:

pip install ephem

使用 Observer 类计算格林威治某个时间点火星的天象(天体的高度和方位):

1import ephem 2import math 3 4 5greenwich = ephem.Observer() 6greenwich.lat = "51.4769" 7greenwich.lon = "-0.0005" 8greenwich.date = "2018/9/22 22:30:00" 9 10mars = ephem.Mars() 11mars.compute(greenwich) 12az_deg, alt_deg = mars.az, mars.alt*convert 13 14convert = math.pi / 180. 15print(f"Mars当前的方位和高度为: {az_deg:.2f} {alt_deg:.2f}")

为了更加方便地获取行星的天象,我们写一个 PlanetTracker 类,带有一个返回指定行星当前高度和方位的方法。单位是度(PyEphem内部默认使用弧度而不是角度来表示度数值)

1# planet_tracker.py 2import math 3import ephem 4 5 6class PlanetTracker(ephem.Observer): 7 8 def __init__(self): 9 super(PlanetTracker, self).__init__() 10 self.planets = { 11 "mercury": ephem.Mercury(), 12 "venus": ephem.Venus(), 13 "mars": ephem.Mars(), 14 "jupiter": ephem.Jupiter(), 15 "saturn": ephem.Saturn(), 16 "uranus": ephem.Uranus(), 17 "neptune": ephem.Neptune() 18 } 19 20 def calc_planet(self, planet_name, when=None): 21 convert = 180. / math.pi 22 if when is None: 23 when = ephem.now() 24 25 self.date = when 26 if planet_name in self.planets: 27 planet = self.planets[planet_name] 28 planet.compute(self) 29 return { 30 "az": float(planet.az) * convert, 31 "alt": float(planet.alt) * convert, 32 "name": planet_name 33 } 34 else: 35 raise KeyError(f"Couldn't find {planet_name} in planets dict")

这样就可以很方便地得到太阳系中其他七颗行星在任意地点的位置:

1tracker = PlanetTracker() 2tracker.lat = "51.4769" 3tracker.lon = "-0.0005" 4print(tracker.calc_planet("mars"))

输出:

{'az': 26.646611886328866, 'alt': -35.063254217502354, 'name': 'mars'}

aiohttp服务端

现在指定一组纬度和经度,我们可以得到行星当前的高度和方位。接下来,建立一个aiohttp服务,接收客户端发送的用户位置,返回其行星天象。

1# aiohttp_app.py 2from aiohttp import web 3 4from planet_tracker import PlanetTracker 5 6routes = web.RouteTableDef() 7 8 9@routes.get("/planets/{name}") 10async def get_planet_ephmeris(request): 11 planet_name = request.match_info['name'] 12 data = request.query 13 try: 14 geo_location_data = { 15 "lon": str(data["lon"]), 16 "lat": str(data["lat"]), 17 "elevation": float(data["elevation"]) 18 } 19 except KeyError as err: 20 # 缺省 格林威治 Observatory 21 geo_location_data = { 22 "lon": "-0.0005", 23 "lat": "51.4769", 24 "elevation": 0.0, 25 } 26 print(f"get_planet_ephmeris: {planet_name}, {geo_location_data}") 27 tracker = PlanetTracker() 28 tracker.lon = geo_location_data["lon"] 29 tracker.lat = geo_location_data["lat"] 30 tracker.elevation = geo_location_data["elevation"] 31 planet_data = tracker.calc_planet(planet_name) 32 return web.json_response(planet_data) 33 34 35app = web.Application() 36app.add_routes(routes) 37 38web.run_app(app, host="localhost", port=8000)

这里,给 get_planet_ephmeris 加上 route.get 装饰器以监听处理 GET 请求。

直接运行此py文件启动应用:

python aiohttp_app.py

成功启动后,在浏览器中访问 http://localhost:8000/planets/mars ,可以看到类似如下的响应内容:

{"az": 98.72414165963292, "alt": -18.720718647020792, "name": "mars"}

你也可以使用 curl 命令进行测试:

1me@local:~$ curl localhost:8000/planets/mars 2{"az": 98.72414165963292, "alt": -18.720718647020792, "name": "mars"}

它响应给我们英国格林威治天文台的火星高度和方位。

也可以通过url参数传入经纬度位置,来获取其他地方的火星天象(注意使用引号将URL括起):

1me@local:~$ curl "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0" 2{"az": 102.30273048280189, "alt": 11.690380174890928, "name": "mars"

这个还没有结束,web.run_app 函数是以阻塞的方式运行应用程序。这显然不是我们想要的方式!

要想以异步的形式运行起来,需要修改一点代码:

1# aiohttp_app.py 2import asyncio 3... 4 5# web.run_app(app) 6 7async def start_app(): 8 _runner = web.AppRunner(app) 9 await _runner.setup() 10 _site = web.TCPSite( 11 _runner, "localhost", 8080 12 ) 13 await _site.start() 14 print(f"Serving up app on localhost:8080") 15 return _runner, _site 16 17loop = asyncio.get_event_loop() 18runner, site = loop.run_until_complete(start_app()) 19try: 20 loop.run_forever() 21except KeyboardInterrupt as err: 22 loop.run_until_complete(runner.cleanup())

注意这里使用的是 loop.run_forever,而不是前面的 loop.run_until_complete。因为这里并不是为了执行一定数量的协程,而是希望我们的服务挂起处理请求,直到使用 ctrl+c 退出,这时才优雅地关闭服务器。

前端 HTML/JavaScript

aiohttp 支持加载HTML和JavaScript文件。但是并不鼓励使用 aiohttp 服务加载CSS和JavaScript等"静态"资源,但是我们这里只是做一个演示程序。

接下来 aiohttp_app.py 添加几行代码。加载JavaScript文件的HTML文件:

1# aiohttp_app.py 2... 3@routes.get('/') 4async def hello(request): 5 return web.FileResponse("./index.html") 6 7 8app = web.Application() 9app.add_routes(routes) 10app.router.add_static("/", "./") 11...

hello 协程监听 localhost:8000/ 上的GET 请求,返回 index.html。该文件位于运行服务的同目录下。项目目录结构:

1--aiphttp_api.py 2--app.js 3--index.html 4--planet_tracker.py

app.router.add_static 这一行行声明在 localhost:8000/ 上设置了一个路由,用于在运行服务器的同目录中加载静态文件。这样浏览器将才能够找到在 index.html 中引用的JavaScript文件。

**注意:**在生产环境中,务必将HTML、CSS和JS文件放到单独的目录中。这样可以避免一些好奇的用户访问服务器上的代码。

index.html 内容非常简单:

1<!DOCTYPE html> 2<html lang='en'> 3 4<head> 5 <meta charset="utf-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1"> 7 <title>Planet Tracker</title> 8</head> 9<body> 10 <div id="app"> 11 <label id="lon">Longitude: <input type="text"/></label><br/> 12 <label id="lat">Latitude: <input type="text"/></label><br/> 13 <label id="elevation">Elevation: <input type="text"/></label><br/> 14 </div> 15 <script src="https://my.oschina.net/app.js"></script> 16</body>

不过,app.js 文件稍微复杂一些:

1var App = function() { 2 3 this.planetNames = [ 4 "mercury", 5 "venus", 6 "mars", 7 "jupiter", 8 "saturn", 9 "uranus", 10 "neptune" 11 ] 12 13 this.geoLocationIds = [ 14 "lon", 15 "lat", 16 "elevation" 17 ] 18 19 this.keyUpInterval = 500 20 this.keyUpTimer = null 21 this.planetDisplayCreated = false 22 this.updateInterval = 2000 // update very second and a half 23 this.updateTimer = null 24 this.geoLocation = null 25 26 this.init = function() { 27 this.getGeoLocation().then((position) => { 28 var coords = this.processCoordinates(position) 29 this.geoLocation = coords 30 this.initGeoLocationDisplay() 31 this.updateGeoLocationDisplay() 32 return this.getPlanetEphemerides() 33 }).then((planetData) => { 34 this.createPlanetDisplay() 35 this.updatePlanetDisplay(planetData) 36 }).then(() => { 37 return this.initUpdateTimer() 38 }) 39 } 40 41 this.update = function() { 42 if (this.planetDisplayCreated) { 43 this.getPlanetEphemerides().then((planetData) => { 44 this.updatePlanetDisplay(planetData) 45 }) 46 } 47 } 48 49 this.get = function(url, data) { 50 var request = new XMLHttpRequest() 51 if (data !== undefined) { 52 url += `?${data}` 53 } 54 // console.log(`get: ${url}`) 55 request.open("GET", url, true) 56 return new Promise((resolve, reject) => { 57 request.send() 58 request.onreadystatechange = function(){ 59 if (this.readyState === XMLHttpRequest.DONE && this.status === 200) { 60 resolve(this) 61 } 62 } 63 request.onerror = reject 64 }) 65 } 66 67 this.processCoordinates = function(position) { 68 var coordMap = { 69 'longitude': 'lon', 70 'latitude': 'lat', 71 'altitude': 'elevation' 72 } 73 var coords = Object.keys(coordMap).reduce((obj, name) => { 74 var coord = position.coords[name] 75 if (coord === null || isNaN(coord)) { 76 coord = 0.0 77 } 78 obj[coordMap[name]] = coord 79 return obj 80 }, {}) 81 return coords 82 } 83 84 this.coordDataUrl = function (coords) { 85 postUrl = Object.keys(coords).map((c) => { 86 return `${c}=${coords[c]}` 87 }) 88 return postUrl 89 } 90 91 this.getGeoLocation = function() { 92 return new Promise((resolve, reject) => { 93 navigator.geolocation.getCurrentPosition(resolve) 94 }) 95 } 96 97 this.getPlanetEphemeris = function(planetName) { 98 var postUrlArr = this.coordDataUrl(this.geoLocation) 99 return this.get(`/planets/${planetName}`, postUrlArr.join("&")).then((req) => { 100 return JSON.parse(req.response) 101 }) 102 } 103 104 this.getPlanetEphemerides = function() { 105 return Promise.all( 106 this.planetNames.map((name) => { 107 return this.getPlanetEphemeris(name) 108 }) 109 ) 110 } 111 112 this.createPlanetDisplay = function() { 113 var div = document.getElementById("app") 114 var table = document.createElement("table") 115 var header = document.createElement("tr") 116 var headerNames = ["Name", "Azimuth", "Altitude"] 117 headerNames.forEach((headerName) => { 118 var headerElement = document.createElement("th") 119 headerElement.textContent = headerName 120 header.appendChild(headerElement) 121 }) 122 table.appendChild(header) 123 this.planetNames.forEach((name) => { 124 var planetRow = document.createElement("tr") 125 headerNames.forEach((headerName) => { 126 planetRow.appendChild( 127 document.createElement("td") 128 ) 129 }) 130 planetRow.setAttribute("id", name) 131 table.appendChild(planetRow) 132 }) 133 div.appendChild(table) 134 this.planetDisplayCreated = true 135 } 136 137 this.updatePlanetDisplay = function(planetData) { 138 planetData.forEach((d) => { 139 var content = [d.name, d.az, d.alt] 140 var planetRow = document.getElementById(d.name) 141 planetRow.childNodes.forEach((node, idx) => { 142 var contentFloat = parseFloat(content[idx]) 143 if (isNaN(contentFloat)) { 144 node.textContent = content[idx] 145 } else { 146 node.textContent = contentFloat.toFixed(2) 147 } 148 }) 149 }) 150 } 151 152 this.initGeoLocationDisplay = function() { 153 this.geoLocationIds.forEach((id) => { 154 var node = document.getElementById(id) 155 node.childNodes[1].onkeyup = this.onGeoLocationKeyUp() 156 }) 157 var appNode = document.getElementById("app") 158 var resetLocationButton = document.createElement("button") 159 resetLocationButton.setAttribute("id", "reset-location") 160 resetLocationButton.onclick = this.onResetLocationClick() 161 resetLocationButton.textContent = "Reset Geo Location" 162 appNode.appendChild(resetLocationButton) 163 } 164 165 this.updateGeoLocationDisplay = function() { 166 Object.keys(this.geoLocation).forEach((id) => { 167 var node = document.getElementById(id) 168 node.childNodes[1].value = parseFloat( 169 this.geoLocation[id] 170 ).toFixed(2) 171 }) 172 } 173 174 this.getDisplayedGeoLocation = function() { 175 var displayedGeoLocation = this.geoLocationIds.reduce((val, id) => { 176 var node = document.getElementById(id) 177 var nodeVal = parseFloat(node.childNodes[1].value) 178 val[id] = nodeVal 179 if (isNaN(nodeVal)) { 180 val.valid = false 181 } 182 return val 183 }, {valid: true}) 184 return displayedGeoLocation 185 } 186 187 this.onGeoLocationKeyUp = function() { 188 return (evt) => { 189 // console.log(evt.key, evt.code) 190 var currentTime = new Date() 191 if (this.keyUpTimer !== null){ 192 clearTimeout(this.keyUpTimer) 193 } 194 this.keyUpTimer = setTimeout(() => { 195 var displayedGeoLocation = this.getDisplayedGeoLocation() 196 if (displayedGeoLocation.valid) { 197 delete displayedGeoLocation.valid 198 this.geoLocation = displayedGeoLocation 199 console.log("Using user supplied geo location") 200 } 201 }, this.keyUpInterval) 202 } 203 } 204 205 this.onResetLocationClick = function() { 206 return (evt) => { 207 console.log("Geo location reset clicked") 208 this.getGeoLocation().then((coords) => { 209 this.geoLocation = this.processCoordinates(coords) 210 this.updateGeoLocationDisplay() 211 }) 212 } 213 } 214 215 this.initUpdateTimer = function () { 216 if (this.updateTimer !== null) { 217 clearInterval(this.updateTimer) 218 } 219 this.updateTimer = setInterval( 220 this.update.bind(this), 221 this.updateInterval 222 ) 223 return this.updateTimer 224 } 225 226 this.testPerformance = function(n) { 227 var t0 = performance.now() 228 var promises = [] 229 for (var i=0; i<n; i++) { 230 promises.push(this.getPlanetEphemeris("mars")) 231 } 232 Promise.all(promises).then(() => { 233 var delta = (performance.now() - t0)/1000 234 console.log(`Took ${delta.toFixed(4)} seconds to do ${n} requests`) 235 }) 236 } 237} 238 239var app 240document.addEventListener("DOMContentLoaded", (evt) => { 241 app = new App() 242 app.init() 243})

该应用程序将定时(间隔2秒)更新显示行星的方位和高度信息。Web Geolocation API 会默认读取用户当前地理位置,也可以自己手动输入地理坐标置。如果用户停止输入半秒以上时间,就会开始自动更新行星位置数据。

虽然这不是JavaScript教程,但是这里可以简单讲解下JS脚本的部分内容:

  • createPlanetDisplay 动态创建HTML元素并绑定到Document Object Model(DOM);

  • updatePlanetDisplay 用来从服务器接收数据,使用 createPlanetDisplay 显示;

  • getGeoLocation 使用 Web Geolocation API 获取用户当前的地理坐标。但要求在“安全的上下文中”使用(即必须使用HTTPS而不是HTTP)

  • getPlanetEphemerisgetPlanetEphemerides 都是向服务器发出GET请求,分别获取指定行星和所有行星的位置信息。

结语

在本文中,简单介绍了Python中的异步web开发是什么样子的——它的优点和用途。之后,构建了一个简单的基于 aiohttp 的响应式应用程序,在用户给定地理坐标的情况下,动态显示当前太阳系行星的相关天空位置。

点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

MySQL部分从库上面因为大量的临时表tmp_table造成慢查询

背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_

皕杰报表之UUID

​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为

手写Java HashMap源码

HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22

2020年前端实用代码段,为你的工作保驾护航

有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )

Python异步Web编程 - HelloWorld